diff --git a/CHANGELOG.md b/CHANGELOG.md index e273d21ed..461eb0c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Query results appear as soon as the database returns rows. Column defaults, foreign keys, and row counts now load in the background instead of holding up the grid, which removes a multi-second wait on remote databases whose system tables are slow to query. (#1574) +- MySQL and MariaDB queries are ready to edit right away. Primary key and nullability come back with the rows, so an editable query no longer waits on a separate metadata query. (#1574) - Pagination and other status bar buttons no longer get blocked by the window resize zone in the bottom-right corner. (#1569) - VoiceOver now reads clear labels for the Columns button, Filters toggle, and loading indicators in the results status bar. (#1569) - The custom rows-per-page popover now points at the page-size menu instead of the center of the pagination controls. (#1569) diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index e57138a6b..b7ca34cb2 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -12,13 +12,26 @@ import OSLog import TableProPluginKit // MySQL/MariaDB field flag and charset constants +internal let mysqlNotNullFlag: UInt = 0x0001 +internal let mysqlPriKeyFlag: UInt = 0x0002 internal let mysqlBinaryFlag: UInt = 0x0080 internal let mysqlEnumFlag: UInt = 0x0100 +internal let mysqlAutoIncrementFlag: UInt = 0x0200 internal let mysqlSetFlag: UInt = 0x0800 internal let mysqlBinaryCharset: UInt32 = 63 private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection") +internal func makeColumnMeta(name: String, typeName: String, flags: UInt) -> PluginColumnInfo { + PluginColumnInfo( + name: name, + dataType: typeName, + isNullable: (flags & mysqlNotNullFlag) == 0, + isPrimaryKey: (flags & mysqlPriKeyFlag) != 0, + identityKind: (flags & mysqlAutoIncrementFlag) != 0 ? .byDefault : nil + ) +} + // MARK: - Error Types struct MariaDBPluginError: Error { @@ -44,6 +57,7 @@ struct MariaDBPluginQueryResult { let affectedRows: UInt64 let insertId: UInt64 let isTruncated: Bool + let columnMeta: [PluginColumnInfo] } // MARK: - SSL Configuration @@ -461,7 +475,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { let insertId = mysql_insert_id(mysql) return MariaDBPluginQueryResult( columns: [], columnTypes: [], columnTypeNames: [], - rows: [], affectedRows: affected, insertId: insertId, isTruncated: false + rows: [], affectedRows: affected, insertId: insertId, isTruncated: false, + columnMeta: [] ) } else { throw self.getError() @@ -473,31 +488,32 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] var columnIsBinary: [Bool] = [] + var columnMeta: [PluginColumnInfo] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) columnIsBinary.reserveCapacity(numFields) + columnMeta.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0..?, - tableName: String - ) async -> SchemaResult? { - await QueryExecutor.awaitSchemaResult( - connectionId: parent.connectionId, - parallelTask: parallelTask, - tableName: tableName - ) - } - func isMetadataCached(tabId: UUID, tableName: String) -> Bool { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return false @@ -301,32 +290,33 @@ extension QueryExecutionCoordinator { tabId: UUID, capturedGeneration: Int, connectionType: DatabaseType, - schemaResult: SchemaResult? + schemaTask: Task? ) { - resolveRowCount( - tableName: tableName, - tabId: tabId, - capturedGeneration: capturedGeneration, - connectionType: connectionType - ) - let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql - guard !isNonSQL else { return } Task(priority: .utility) { [weak self, parent] in guard let self else { return } guard !parent.isTearingDown else { return } - let columnInfo: [ColumnInfo] - if let schema = schemaResult { - columnInfo = schema.columnInfo - } else { - columnInfo = (try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in - try await driver.fetchColumns(table: tableName) - }) ?? [] + let schema = try? await schemaTask?.value + + await MainActor.run { [weak self] in + guard let self else { return } + guard capturedGeneration == parent.queryGeneration else { return } + if let schema { + applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) + } + resolveRowCount( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: connectionType + ) } + guard !isNonSQL, let schema else { return } + let columnEnumValues = await parent.fetchEnumValues( - columnInfo: columnInfo, + columnInfo: schema.columnInfo, tableName: tableName, connectionType: connectionType ) @@ -361,6 +351,40 @@ extension QueryExecutionCoordinator { } } + private func applyPhase2Metadata(parsed: ParsedSchemaMetadata, tabId: UUID) { + guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return } + + parent.mutateActiveTableRows(for: tabId) { rows in + rows.updateDisplayMetadata( + columnDefaults: parsed.columnDefaults, + columnForeignKeys: parsed.columnForeignKeys, + columnNullable: parsed.columnNullable + ) + } + + parent.tabManager.mutate(tabId: tabId) { tab in + if !parsed.primaryKeyColumns.isEmpty { + tab.tableContext.primaryKeyColumns = parsed.primaryKeyColumns + } + if let approxCount = parsed.approximateRowCount, approxCount > 0, + !tab.filterState.hasAppliedFilters { + tab.pagination.totalRowCount = approxCount + tab.pagination.isApproximateRowCount = true + } + tab.metadataVersion += 1 + } + + if parent.tabManager.selectedTabId == tabId, !parsed.primaryKeyColumns.isEmpty { + parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns) + } + + if let activeIdx = parent.tabManager.selectedTabIndex, + activeIdx < parent.tabManager.tabs.count, + parent.tabManager.tabs[activeIdx].id == tabId { + parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() + } + } + func launchPhase2Count( tableName: String, tabId: UUID, diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 42d57679f..c24246c13 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -94,31 +94,39 @@ extension QueryExecutionCoordinator { } else { needsMetadataFetch = false } + let connId = parent.connectionId parent.currentQueryTask = Task { [weak self, parent] in guard let self else { return } + let schemaTask: Task? + if needsMetadataFetch, let tableName { + schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } + } else { + schemaTask = nil + } + do { - let executionResult = try await parent.queryExecutor.executeQuery( + let fetchResult = try await parent.queryExecutor.executeQuery( sql: sql, parameters: parameters, - rowCap: rowCap, - tableName: tableName, - fetchSchemaForTable: needsMetadataFetch + rowCap: rowCap ) guard !Task.isCancelled else { - await parent.resetExecutionState( - tabId: tabId, - executionTime: executionResult.fetchResult.executionTime - ) + schemaTask?.cancel() + await parent.resetExecutionState(tabId: tabId, executionTime: fetchResult.executionTime) return } + let inlineMeta = needsMetadataFetch + ? QueryExecutor.inlineMetadata(from: fetchResult.resultColumnMeta, columns: fetchResult.columns) + : nil + await applyParameterizedResult( tabId: tabId, - fetchResult: executionResult.fetchResult, - schemaResult: executionResult.schemaResult, + fetchResult: fetchResult, + inlineMetadata: inlineMeta, tableName: tableName, isEditable: isEditable, sql: sql, @@ -135,7 +143,7 @@ extension QueryExecutionCoordinator { tabId: tabId, capturedGeneration: capturedGeneration, connectionType: conn.type, - schemaResult: executionResult.schemaResult + schemaTask: schemaTask ) } else { launchPhase2Count( @@ -154,6 +162,7 @@ extension QueryExecutionCoordinator { } } } catch { + schemaTask?.cancel() await MainActor.run { [weak self] in guard let self else { return } parent.tabManager.mutate(tabId: tabId) { tab in @@ -341,7 +350,7 @@ extension QueryExecutionCoordinator { func applyParameterizedResult( tabId: UUID, fetchResult: QueryFetchResult, - schemaResult: SchemaResult?, + inlineMetadata: ParsedSchemaMetadata?, tableName: String?, isEditable: Bool, sql: String, @@ -350,8 +359,6 @@ extension QueryExecutionCoordinator { originalParameters: [QueryParameter], nativeParameters: [Any?] ) async { - let metadata = schemaResult.map { QueryExecutor.parseSchemaMetadata($0) } - await MainActor.run { [weak self] in guard let self else { return } parent.currentQueryTask = nil @@ -376,8 +383,8 @@ extension QueryExecutionCoordinator { statusMessage: fetchResult.statusMessage, tableName: tableName, isEditable: isEditable, - metadata: metadata, - hasSchema: schemaResult != nil, + metadata: inlineMetadata, + hasSchema: false, sql: sql, connection: connection, isTruncated: fetchResult.isTruncated, diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e95e5275b..00775001b 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -623,6 +623,9 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { ) result.isTruncated = pluginResult.isTruncated result.statusMessage = pluginResult.statusMessage + result.columnMeta = pluginResult.columnMeta?.map { + ResultColumnMeta(isPrimaryKey: $0.isPrimaryKey, isNullable: $0.isNullable, isAutoIncrement: $0.isIdentity) + } return result } diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 27c1086d2..ec77ab046 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -12,6 +12,7 @@ struct QueryFetchResult { let rowsAffected: Int let statusMessage: String? let isTruncated: Bool + let resultColumnMeta: [ResultColumnMeta]? } typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo], approximateRowCount: Int?) @@ -25,12 +26,6 @@ struct ParsedSchemaMetadata { let columnEnumValues: [String: [String]] } -struct QueryExecutionResult { - let fetchResult: QueryFetchResult - let schemaResult: SchemaResult? - let parsedMetadata: ParsedSchemaMetadata? -} - @MainActor final class QueryExecutor { let connection: DatabaseConnection @@ -54,57 +49,22 @@ final class QueryExecutor { func executeQuery( sql: String, parameters: [Any?]? = nil, - rowCap: Int?, - tableName: String?, - fetchSchemaForTable: Bool - ) async throws -> QueryExecutionResult { - let connId = connectionId - - var parallelSchemaTask: Task? - if fetchSchemaForTable, let tableName, !tableName.isEmpty { - parallelSchemaTask = Task { - try await Self.fetchTableSchema(connectionId: connId, tableName: tableName) - } - } - + rowCap: Int? + ) async throws -> QueryFetchResult { let driver = try resolveDriver() - let fetchResult: QueryFetchResult - do { - if let parameters { - fetchResult = try await Self.fetchQueryDataParameterized( - driver: driver, - sql: sql, - parameters: parameters, - rowCap: rowCap - ) - } else { - fetchResult = try await Self.fetchQueryData( - driver: driver, - sql: sql, - rowCap: rowCap - ) - } - } catch { - parallelSchemaTask?.cancel() - throw error - } - - var schemaResult: SchemaResult? - if fetchSchemaForTable, let tableName, !tableName.isEmpty { - schemaResult = await Self.awaitSchemaResult( - connectionId: connId, - parallelTask: parallelSchemaTask, - tableName: tableName + if let parameters { + return try await Self.fetchQueryDataParameterized( + driver: driver, + sql: sql, + parameters: parameters, + rowCap: rowCap ) } - - let parsedMetadata = schemaResult.map { Self.parseSchemaMetadata($0) } - - return QueryExecutionResult( - fetchResult: fetchResult, - schemaResult: schemaResult, - parsedMetadata: parsedMetadata + return try await Self.fetchQueryData( + driver: driver, + sql: sql, + rowCap: rowCap ) } @@ -127,7 +87,8 @@ final class QueryExecutor { executionTime: result.executionTime, rowsAffected: result.rowsAffected, statusMessage: result.statusMessage, - isTruncated: result.isTruncated + isTruncated: result.isTruncated, + resultColumnMeta: result.columnMeta ) } @@ -149,27 +110,12 @@ final class QueryExecutor { executionTime: result.executionTime, rowsAffected: result.rowsAffected, statusMessage: result.statusMessage, - isTruncated: result.isTruncated + isTruncated: result.isTruncated, + resultColumnMeta: result.columnMeta ) } - // MARK: - Schema await + parse - - static func awaitSchemaResult( - connectionId: UUID, - parallelTask: Task?, - tableName: String - ) async -> SchemaResult? { - if let parallelTask { - return try? await parallelTask.value - } - do { - return try await fetchTableSchema(connectionId: connectionId, tableName: tableName) - } catch { - queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } + // MARK: - Schema fetch + parse static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult { try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in @@ -207,6 +153,26 @@ final class QueryExecutor { ) } + static func inlineMetadata(from meta: [ResultColumnMeta]?, columns: [String]) -> ParsedSchemaMetadata? { + guard let meta, !meta.isEmpty, meta.count == columns.count else { return nil } + var nullable: [String: Bool] = [:] + var primaryKeys: [String] = [] + for (index, column) in columns.enumerated() { + nullable[column] = meta[index].isNullable + if meta[index].isPrimaryKey { + primaryKeys.append(column) + } + } + return ParsedSchemaMetadata( + columnDefaults: [:], + columnForeignKeys: [:], + columnNullable: nullable, + primaryKeyColumns: primaryKeys, + approximateRowCount: nil, + columnEnumValues: [:] + ) + } + // MARK: - Row cap policy static func resolveRowCap(sql: String, tabType: TabType, databaseType: DatabaseType) -> Int? { diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 3e61da4db..175ce185b 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -22,6 +22,8 @@ struct QueryResult { /// Optional status message from the plugin (e.g. server notices, warnings) var statusMessage: String? + var columnMeta: [ResultColumnMeta]? + var isEmpty: Bool { rows.isEmpty } @@ -44,6 +46,12 @@ struct QueryResult { ) } +struct ResultColumnMeta: Sendable { + let isPrimaryKey: Bool + let isNullable: Bool + let isAutoIncrement: Bool +} + /// Database error types enum DatabaseError: Error, LocalizedError { case connectionFailed(String) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 012da8eaf..7a053f6e5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -15,16 +15,6 @@ extension MainContentCoordinator { queryExecutionCoordinator.parseSchemaMetadata(schema) } - func awaitSchemaResult( - parallelTask: Task?, - tableName: String - ) async -> SchemaResult? { - await queryExecutionCoordinator.awaitSchemaResult( - parallelTask: parallelTask, - tableName: tableName - ) - } - func isMetadataCached(tabId: UUID, tableName: String) -> Bool { queryExecutionCoordinator.isMetadataCached(tabId: tabId, tableName: tableName) } @@ -70,14 +60,14 @@ extension MainContentCoordinator { tabId: UUID, capturedGeneration: Int, connectionType: DatabaseType, - schemaResult: SchemaResult? + schemaTask: Task? ) { queryExecutionCoordinator.launchPhase2Work( tableName: tableName, tabId: tabId, capturedGeneration: capturedGeneration, connectionType: connectionType, - schemaResult: schemaResult + schemaTask: schemaTask ) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 867f0ddbd..6b887edd1 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1065,27 +1065,35 @@ final class MainContentCoordinator { } else { needsMetadataFetch = false } + let connId = connectionId currentQueryTask = Task { [weak self] in guard let self else { return } + let schemaTask: Task? + if needsMetadataFetch, let tableName { + schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } + } else { + schemaTask = nil + } + do { - let executionResult = try await queryExecutor.executeQuery( + let fetchResult = try await queryExecutor.executeQuery( sql: sql, parameters: nil, - rowCap: rowCap, - tableName: tableName, - fetchSchemaForTable: needsMetadataFetch + rowCap: rowCap ) guard !Task.isCancelled else { - await resetExecutionState( - tabId: tabId, - executionTime: executionResult.fetchResult.executionTime - ) + schemaTask?.cancel() + await resetExecutionState(tabId: tabId, executionTime: fetchResult.executionTime) return } + let inlineMeta = needsMetadataFetch + ? QueryExecutor.inlineMetadata(from: fetchResult.resultColumnMeta, columns: fetchResult.columns) + : nil + await MainActor.run { [weak self] in guard let self else { return } currentQueryTask = nil @@ -1093,7 +1101,7 @@ final class MainContentCoordinator { self.clearClickHouseProgress() } toolbarState.setExecuting(false) - toolbarState.lastQueryDuration = executionResult.fetchResult.executionTime + toolbarState.lastQueryDuration = fetchResult.executionTime if capturedGeneration != queryGeneration || Task.isCancelled { tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } @@ -1102,19 +1110,19 @@ final class MainContentCoordinator { applyPhase1Result( tabId: tabId, - columns: executionResult.fetchResult.columns, - columnTypes: executionResult.fetchResult.columnTypes, - rows: executionResult.fetchResult.rows, - executionTime: executionResult.fetchResult.executionTime, - rowsAffected: executionResult.fetchResult.rowsAffected, - statusMessage: executionResult.fetchResult.statusMessage, + columns: fetchResult.columns, + columnTypes: fetchResult.columnTypes, + rows: fetchResult.rows, + executionTime: fetchResult.executionTime, + rowsAffected: fetchResult.rowsAffected, + statusMessage: fetchResult.statusMessage, tableName: tableName, isEditable: isEditable, - metadata: executionResult.parsedMetadata, - hasSchema: executionResult.schemaResult != nil, + metadata: inlineMeta, + hasSchema: false, sql: sql, connection: conn, - isTruncated: executionResult.fetchResult.isTruncated + isTruncated: fetchResult.isTruncated ) } @@ -1125,7 +1133,7 @@ final class MainContentCoordinator { tabId: tabId, capturedGeneration: capturedGeneration, connectionType: conn.type, - schemaResult: executionResult.schemaResult + schemaTask: schemaTask ) } else { launchPhase2Count( @@ -1144,6 +1152,7 @@ final class MainContentCoordinator { } } } catch { + schemaTask?.cancel() await MainActor.run { [weak self] in guard let self else { return } tabManager.mutate(tabId: tabId) { tab in diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index da964e44e..5f074f157 100644 --- a/TableProTests/Core/Services/Query/QueryExecutorTests.swift +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -220,11 +220,50 @@ struct QueryExecutorTests { #expect(parsed.approximateRowCount == nil) } - // TODO: integration test for `QueryExecutor.executeQuery` orchestration - // (parallel schema fetch, cancel-on-fetch-error, parameterised path). - // Requires either a `DatabaseDriver` mock registered with - // `DatabaseManager.shared` or a DI refactor of `QueryExecutor` to accept - // an injected driver. Static helpers above already cover SQL parsing, - // metadata parsing, parameter reconciliation, DDL detection, and row-cap - // policy. + // MARK: - Inline result-set metadata + + @Test("inlineMetadata extracts primary keys and nullability from result flags") + func inlineMetadataExtractsFlags() throws { + let meta = [ + ResultColumnMeta(isPrimaryKey: true, isNullable: false, isAutoIncrement: true), + ResultColumnMeta(isPrimaryKey: false, isNullable: true, isAutoIncrement: false) + ] + let parsed = try #require(QueryExecutor.inlineMetadata(from: meta, columns: ["id", "name"])) + #expect(parsed.primaryKeyColumns == ["id"]) + #expect(parsed.columnNullable["id"] == false) + #expect(parsed.columnNullable["name"] == true) + #expect(parsed.columnDefaults.isEmpty) + #expect(parsed.columnForeignKeys.isEmpty) + #expect(parsed.approximateRowCount == nil) + } + + @Test("inlineMetadata reports a composite primary key in column order") + func inlineMetadataCompositePrimaryKey() throws { + let meta = [ + ResultColumnMeta(isPrimaryKey: true, isNullable: false, isAutoIncrement: false), + ResultColumnMeta(isPrimaryKey: true, isNullable: false, isAutoIncrement: false), + ResultColumnMeta(isPrimaryKey: false, isNullable: true, isAutoIncrement: false) + ] + let parsed = try #require(QueryExecutor.inlineMetadata(from: meta, columns: ["order_id", "product_id", "qty"])) + #expect(parsed.primaryKeyColumns == ["order_id", "product_id"]) + } + + @Test("inlineMetadata returns nil when result metadata is absent or empty") + func inlineMetadataNilWhenAbsent() { + #expect(QueryExecutor.inlineMetadata(from: nil, columns: ["id"]) == nil) + #expect(QueryExecutor.inlineMetadata(from: [], columns: ["id"]) == nil) + } + + @Test("inlineMetadata returns nil when metadata count does not match columns") + func inlineMetadataNilOnCountMismatch() { + let meta = [ResultColumnMeta(isPrimaryKey: true, isNullable: false, isAutoIncrement: false)] + #expect(QueryExecutor.inlineMetadata(from: meta, columns: ["id", "name"]) == nil) + } + + // TODO: integration test for the execute -> Phase 1 render -> Phase 2 metadata + // flow in QueryExecutionCoordinator (rows render without awaiting schema; the + // schema task applies metadata and bumps metadataVersion afterwards). Requires + // a `DatabaseDriver` mock registered with `DatabaseManager.shared` or a DI + // refactor. Static helpers above cover SQL parsing, metadata parsing, inline + // result-set metadata, parameter reconciliation, DDL detection, and row-cap policy. } diff --git a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift index f81df717d..883dbc64f 100644 --- a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift +++ b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift @@ -5,12 +5,29 @@ #if canImport(MySQLDriverPlugin) import Foundation +import TableProPluginKit import Testing @testable import MySQLDriverPlugin @Suite("MariaDBFieldClassifier") struct MariaDBFieldClassifierTests { + @Test("makeColumnMeta reads PRIMARY KEY, NOT NULL, and AUTO_INCREMENT flags") + func makeColumnMetaReadsKeyFlags() { + let pk = makeColumnMeta( + name: "id", typeName: "int", + flags: mysqlPriKeyFlag | mysqlNotNullFlag | mysqlAutoIncrementFlag + ) + #expect(pk.isPrimaryKey) + #expect(!pk.isNullable) + #expect(pk.isIdentity) + + let plain = makeColumnMeta(name: "name", typeName: "varchar", flags: 0) + #expect(!plain.isPrimaryKey) + #expect(plain.isNullable) + #expect(!plain.isIdentity) + } + @Test("BIT no longer routes to binary (it was rendering as raw control characters in the data grid)") func bitIsNotBinary() { #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 63))