From 29e4ea8c0af87e1cc0eb6fb649183dcf4ae193a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 10:57:48 +0700 Subject: [PATCH] fix(ios): insert-row defaults and the missing table list search field --- CHANGELOG.md | 9 + .../Sources/TableProModels/QueryResult.swift | 12 +- .../TableProMobile/Drivers/MSSQLDriver.swift | 3 +- .../TableProMobile/Drivers/MySQLDriver.swift | 5 +- .../Drivers/PostgreSQLDriver.swift | 73 +++-- .../TableProMobile/Drivers/SQLiteDriver.swift | 23 +- .../Helpers/ColumnMetadataRules.swift | 45 +++ .../{Intents => Helpers}/PayloadRow.swift | 0 .../{Intents => Helpers}/RowInserter.swift | 14 +- .../TableProMobile/Helpers/SQLBuilder.swift | 23 +- .../ViewModels/RowDetailViewModel.swift | 6 + .../TableProMobile/Views/ConnectedView.swift | 94 ++++-- .../Views/ConnectionListView.swift | 56 ++-- .../Views/DataBrowserView.swift | 15 +- .../TableProMobile/Views/InsertRowView.swift | 294 ++++++++++-------- .../TableProMobile/Views/RowDetailView.swift | 25 +- .../TableProMobile/Views/TableListView.swift | 9 +- .../Helpers/ColumnMetadataRulesTests.swift | 80 +++++ .../RowInsertPlannerTests.swift | 101 ++++++ .../RowInserterTests.swift | 0 .../SQLBuilderDefaultValuesTests.swift | 40 +++ .../RowDetailViewModelTests.swift | 11 + docs/ios/index.mdx | 7 +- 23 files changed, 713 insertions(+), 232 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/ColumnMetadataRules.swift rename TableProMobile/TableProMobile/{Intents => Helpers}/PayloadRow.swift (100%) rename TableProMobile/TableProMobile/{Intents => Helpers}/RowInserter.swift (79%) create mode 100644 TableProMobile/TableProMobileTests/Helpers/ColumnMetadataRulesTests.swift rename TableProMobile/TableProMobileTests/{Intents => Helpers}/RowInsertPlannerTests.swift (58%) rename TableProMobile/TableProMobileTests/{Intents => Helpers}/RowInserterTests.swift (100%) create mode 100644 TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a066b166dd..8ce5961f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Editor tabs drawn as a segmented tab picker rather than in Liquid Glass, on every macOS version. (#2439) +- A connection opens full screen on iPhone and iPad, with its four sections in a sidebar on iPad. (#2544) ### Fixed @@ -84,6 +85,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Snowflake reporting no rows changed for an `UPDATE` or `MERGE`, and a `SELECT`'s own result for a column named `number of rows`. - Two saved Snowflake connections to one account sharing a session, so switching database in one window moved the other. - Stop on a Snowflake query cancelling every other query on the same connection, including a sidebar refresh or a save. +- Insert Row on iPhone and iPad writing every column, so a `NOT NULL` column with a default could not be inserted. (#2543) +- NULL offered on a `NOT NULL` column in Insert Row and the row editor on iPhone and iPad. +- Insert Row on iPhone and iPad writing a generated column, which every engine refuses. +- Both halves of a composite integer primary key left out of the insert on iPhone and iPad. +- Typed values discarded when Insert Row opened before the column list had loaded on iPhone and iPad. +- Insert Row offered on Redis connections on iPhone and iPad, where it can only fail. +- Missing search field in a connection's Tables tab on iPhone and iPad. (#2544) +- A search from one connection or table still filtering another's list on iPhone and iPad. ### Security diff --git a/Packages/TableProCore/Sources/TableProModels/QueryResult.swift b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift index 9c2a72181c..3f3e9dd0d5 100644 --- a/Packages/TableProCore/Sources/TableProModels/QueryResult.swift +++ b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift @@ -38,6 +38,8 @@ public struct ColumnInfo: Sendable, Identifiable { public let comment: String? public let characterMaxLength: Int? public let ordinalPosition: Int + public let isAutoIncrement: Bool + public let isGenerated: Bool public init( name: String, @@ -47,7 +49,9 @@ public struct ColumnInfo: Sendable, Identifiable { defaultValue: String? = nil, comment: String? = nil, characterMaxLength: Int? = nil, - ordinalPosition: Int = 0 + ordinalPosition: Int = 0, + isAutoIncrement: Bool = false, + isGenerated: Bool = false ) { self.name = name self.typeName = typeName @@ -57,6 +61,8 @@ public struct ColumnInfo: Sendable, Identifiable { self.comment = comment self.characterMaxLength = characterMaxLength self.ordinalPosition = ordinalPosition + self.isAutoIncrement = isAutoIncrement + self.isGenerated = isGenerated } } @@ -227,7 +233,9 @@ public extension ColumnInfo { isNullable: plugin.isNullable, defaultValue: plugin.defaultValue, comment: plugin.comment, - ordinalPosition: ordinalPosition + ordinalPosition: ordinalPosition, + isAutoIncrement: plugin.isIdentity, + isGenerated: plugin.isGenerated ) } } diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 7bbb4c59f4..f65a34f47a 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -250,7 +250,8 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { isNullable: parsed.isNullable, defaultValue: parsed.defaultValue, characterMaxLength: parsed.characterMaxLength, - ordinalPosition: idx + ordinalPosition: idx, + isAutoIncrement: parsed.isIdentity ) } } diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 8a0cd4c400..d22c7260cb 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -142,6 +142,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { guard row.count >= 9, let name = row[0], let dataType = row[1] else { return nil } let isPK = row[4]?.uppercased().contains("PRI") == true let isNullable = row[3]?.uppercased() == "YES" + let extra = row[6] return ColumnInfo( name: name, typeName: dataType, @@ -150,7 +151,9 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { defaultValue: row[5], comment: row[8], characterMaxLength: nil, - ordinalPosition: index + ordinalPosition: index, + isAutoIncrement: ColumnMetadataRules.mySQLIsAutoIncrement(extra: extra), + isGenerated: ColumnMetadataRules.mySQLIsGenerated(extra: extra) ) } } diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 6f6d2cd8e8..11cc179224 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -19,6 +19,11 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { nonisolated(unsafe) private(set) var currentSchema: String? = "public" nonisolated(unsafe) private(set) var serverVersion: String? + /// Nil until the first column fetch answers it. Redshift shares this driver and its + /// `information_schema` predates `is_identity`, so the answer is remembered rather than + /// re-probed for every table. + nonisolated(unsafe) private var reportsIdentityColumns: Bool? + init(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) { self.host = host self.port = port @@ -154,14 +159,55 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { let safeTbl = table.replacingOccurrences(of: "'", with: "''") let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") - let raw = try await actor.execute(""" + let result: RawPGResult + if reportsIdentityColumns == false { + result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false)) + } else { + do { + result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: true)) + reportsIdentityColumns = true + } catch is CancellationError { + throw CancellationError() + } catch { + reportsIdentityColumns = false + result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false)) + } + } + + return result.rows.enumerated().compactMap { index, row in + guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil } + let maxLen = row[4].flatMap { Int($0) } + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: row[5] == "YES", + isNullable: row[2]?.uppercased() == "YES", + defaultValue: row[3], + comment: nil, + characterMaxLength: maxLen, + ordinalPosition: index, + isAutoIncrement: ColumnMetadataRules.postgresIsAutoIncrement( + isIdentity: row.count > 6 ? row[6] : nil, columnDefault: row[3] + ), + isGenerated: ColumnMetadataRules.postgresIsGenerated( + isGenerated: row.count > 7 ? row[7] : nil + ) + ) + } + } + + /// The plain form drops `is_identity` (PostgreSQL 10) and `is_generated` (PostgreSQL 12) for a + /// server that has neither. A serial default still reports auto-increment through `nextval`. + private func columnsQuery(schema: String, table: String, identity: Bool) -> String { + let identityColumns = identity ? ",\n c.is_identity,\n c.is_generated" : "" + return """ SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk\(identityColumns) FROM information_schema.columns c LEFT JOIN ( SELECT kcu.column_name @@ -170,27 +216,12 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(safeSchema)' - AND tc.table_name = '\(safeTbl)' + AND tc.table_schema = '\(schema)' + AND tc.table_name = '\(table)' ) pk ON c.column_name = pk.column_name - WHERE c.table_schema = '\(safeSchema)' AND c.table_name = '\(safeTbl)' + WHERE c.table_schema = '\(schema)' AND c.table_name = '\(table)' ORDER BY c.ordinal_position - """) - - return raw.rows.enumerated().compactMap { index, row in - guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil } - let maxLen = row[4].flatMap { Int($0) } - return ColumnInfo( - name: name, - typeName: dataType, - isPrimaryKey: row[5] == "YES", - isNullable: row[2]?.uppercased() == "YES", - defaultValue: row[3], - comment: nil, - characterMaxLength: maxLen, - ordinalPosition: index - ) - } + """ } func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index 837867a6be..cb3ea2b05b 100644 --- a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -135,22 +135,41 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { let safe = table.replacingOccurrences(of: "'", with: "''") let raw = try await actor.execute("PRAGMA table_info('\(safe)')") + let createStatement = try await fetchCreateStatement(table: safe) + + let primaryKeyCount = raw.rows.filter { row in + row.count >= 6 && ColumnMetadataRules.sqliteIsPrimaryKey(pk: row[5]) + }.count return raw.rows.enumerated().compactMap { index, row in guard row.count >= 6, let name = row[1], let dataType = row[2] else { return nil } + let isPrimaryKey = ColumnMetadataRules.sqliteIsPrimaryKey(pk: row[5]) return ColumnInfo( name: name, typeName: dataType, - isPrimaryKey: row[5] == "1", + isPrimaryKey: isPrimaryKey, isNullable: row[3] == "0", defaultValue: row[4], comment: nil, characterMaxLength: nil, - ordinalPosition: index + ordinalPosition: index, + isAutoIncrement: ColumnMetadataRules.sqliteIsRowIdAlias( + typeName: dataType, + isPrimaryKey: isPrimaryKey, + primaryKeyCount: primaryKeyCount, + createStatement: createStatement + ) ) } } + private func fetchCreateStatement(table safeTable: String) async throws -> String? { + let raw = try await actor.execute( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = '\(safeTable)'" + ) + return raw.rows.first?.first ?? nil + } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { let safe = table.replacingOccurrences(of: "'", with: "''") let raw = try await actor.execute(""" diff --git a/TableProMobile/TableProMobile/Helpers/ColumnMetadataRules.swift b/TableProMobile/TableProMobile/Helpers/ColumnMetadataRules.swift new file mode 100644 index 0000000000..76d137db75 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ColumnMetadataRules.swift @@ -0,0 +1,45 @@ +import Foundation + +nonisolated enum ColumnMetadataRules { + static func mySQLIsAutoIncrement(extra: String?) -> Bool { + normalized(extra).contains("AUTO_INCREMENT") + } + + static func mySQLIsGenerated(extra: String?) -> Bool { + let value = normalized(extra) + return value.contains("VIRTUAL GENERATED") || value.contains("STORED GENERATED") + } + + static func postgresIsAutoIncrement(isIdentity: String?, columnDefault: String?) -> Bool { + if normalized(isIdentity) == "YES" { return true } + return normalized(columnDefault).hasPrefix("NEXTVAL(") + } + + static func postgresIsGenerated(isGenerated: String?) -> Bool { + normalized(isGenerated) == "ALWAYS" + } + + /// `PRAGMA table_info` reports `pk` as the column's 1-based rank inside the primary key, not as + /// a flag, so a composite key's second column comes back as 2. + static func sqliteIsPrimaryKey(pk: String?) -> Bool { + guard let pk, !pk.isEmpty else { return false } + return pk != "0" + } + + /// Only a lone `INTEGER PRIMARY KEY` on a rowid table aliases the rowid and is filled in when + /// omitted. The same declaration on a `WITHOUT ROWID` table is an ordinary NOT NULL column. + static func sqliteIsRowIdAlias( + typeName: String?, + isPrimaryKey: Bool, + primaryKeyCount: Int, + createStatement: String? + ) -> Bool { + guard isPrimaryKey, primaryKeyCount == 1 else { return false } + guard normalized(typeName) == "INTEGER" else { return false } + return !normalized(createStatement).contains("WITHOUT ROWID") + } + + private static func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } +} diff --git a/TableProMobile/TableProMobile/Intents/PayloadRow.swift b/TableProMobile/TableProMobile/Helpers/PayloadRow.swift similarity index 100% rename from TableProMobile/TableProMobile/Intents/PayloadRow.swift rename to TableProMobile/TableProMobile/Helpers/PayloadRow.swift diff --git a/TableProMobile/TableProMobile/Intents/RowInserter.swift b/TableProMobile/TableProMobile/Helpers/RowInserter.swift similarity index 79% rename from TableProMobile/TableProMobile/Intents/RowInserter.swift rename to TableProMobile/TableProMobile/Helpers/RowInserter.swift index 982db9a63e..2e36c7c0cd 100644 --- a/TableProMobile/TableProMobile/Intents/RowInserter.swift +++ b/TableProMobile/TableProMobile/Helpers/RowInserter.swift @@ -3,13 +3,20 @@ import TableProDatabase import TableProModels nonisolated enum RowInsertPlanner { + /// A column absent from a row is left out of the statement so the database applies its default. + /// + /// `allowAllDefaults` and `dropsEmptyPrimaryKey` both default to the behaviour the Shortcuts + /// path has always had, where a payload cannot say "leave this out" any other way. A caller that + /// distinguishes omission from an empty value, as the insert form does, turns them off. static func statements( table: String, schema: String?, type: DatabaseType, driver: any DatabaseDriver, columns: [ColumnInfo], - rows: [PayloadRow] + rows: [PayloadRow], + allowAllDefaults: Bool = false, + dropsEmptyPrimaryKey: Bool = true ) throws -> [String] { guard !columns.isEmpty else { throw IntentDataError.noColumns(table) } let columnNames = Set(columns.map(\.name)) @@ -22,12 +29,13 @@ nonisolated enum RowInsertPlanner { var insertColumns: [String] = [] var insertValues: [String?] = [] for column in columns { + guard !column.isGenerated else { continue } guard let value = row.value(for: column.name) else { continue } - if primaryKeys.contains(column.name), value.isEmptyOrNull { continue } + if dropsEmptyPrimaryKey, primaryKeys.contains(column.name), value.isEmptyOrNull { continue } insertColumns.append(column.name) insertValues.append(value.sqlValue) } - guard !insertColumns.isEmpty else { return nil } + guard !insertColumns.isEmpty || allowAllDefaults else { return nil } return SQLBuilder.buildInsert( table: table, schema: schema, diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 1a1526f80f..92fb41ec70 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -82,8 +82,11 @@ nonisolated enum SQLBuilder { driver: any DatabaseDriver, columns: [String], values: [String?] - ) -> String { + ) -> String? { let qualifiedTable = qualifiedIdentifier(table: table, schema: schema, for: type) + guard !columns.isEmpty else { + return buildAllDefaultsInsert(qualifiedTable: qualifiedTable, for: type) + } let cols = columns.map { quoteIdentifier($0, for: type) }.joined(separator: ", ") let vals = values.map { val in if let val { return "'\(driver.escapeStringLiteral(val))'" } @@ -92,6 +95,24 @@ nonisolated enum SQLBuilder { return "INSERT INTO \(qualifiedTable) (\(cols)) VALUES (\(vals))" } + /// Every column left on its database default. MySQL and MariaDB take an empty column list; + /// the PostgreSQL family, SQLite and SQL Server take `DEFAULT VALUES`. Oracle accepts + /// neither, so it gets no statement. + static func buildAllDefaultsInsert(qualifiedTable: String, for type: DatabaseType) -> String? { + switch type { + case .mysql, .mariadb: + return "INSERT INTO \(qualifiedTable) () VALUES ()" + case .postgresql, .redshift, .sqlite, .mssql, .duckdb: + return "INSERT INTO \(qualifiedTable) DEFAULT VALUES" + default: + return nil + } + } + + static func supportsAllDefaultsInsert(_ type: DatabaseType) -> Bool { + buildAllDefaultsInsert(qualifiedTable: "t", for: type) != nil + } + static func qualifiedIdentifier(table: String, schema: String?, for type: DatabaseType) -> String { let quotedTable = quoteIdentifier(table, for: type) guard let schema, !schema.isEmpty else { return quotedTable } diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 3c9f2a4b8b..9d41ffd4dd 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -110,6 +110,12 @@ final class RowDetailViewModel { return columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey } + func isNullable(at index: Int) -> Bool { + guard index >= 0, index < columns.count else { return true } + let column = columns[index] + return columnDetail(for: column.name)?.isNullable ?? column.isNullable + } + // MARK: - Edit Lifecycle func startEditing() { diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 884516866b..46c318c5d1 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -15,25 +15,29 @@ struct ConnectedView: View { @State private var hapticError = false @State private var showDeletedAlert = false + private var displayTitle: String { + connection.name.isEmpty ? connection.host : connection.name + } + var body: some View { Group { if let coordinator { switch coordinator.phase { case .connecting: - connectingView + statusScreen { connectingView } case .error(let error): - ErrorView(error: error) { - await coordinator.connect() + statusScreen { + ErrorView(error: error) { + await coordinator.connect() + } } case .connected: connectedContent(coordinator) } } else { - connectingView + statusScreen { connectingView } } } - .navigationTitle(connection.name.isEmpty ? connection.host : connection.name) - .navigationBarTitleDisplayMode(.inline) .onChange(of: appState.connections) { _, newConnections in if !newConnections.contains(where: { $0.id == connection.id }) { showDeletedAlert = true @@ -74,6 +78,29 @@ struct ConnectedView: View { .sensoryFeedback(.error, trigger: hapticError) } + // MARK: - Chrome + + private func statusScreen(@ViewBuilder _ content: () -> some View) -> some View { + NavigationStack { + content() + .navigationTitle(displayTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { closeToolbar } + } + } + + @ToolbarContentBuilder + private var closeToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Label("Connections", systemImage: "chevron.backward") + } + .accessibilityLabel(Text("Connections")) + } + } + // MARK: - Connecting private var connectingView: some View { @@ -94,32 +121,35 @@ struct ConnectedView: View { private func connectedContent(_ coordinator: ConnectionCoordinator) -> some View { @Bindable var coordinator = coordinator - return NavigationStack(path: $coordinator.tablesPath) { - TabView(selection: $coordinator.selectedTab) { - Tab("Tables", systemImage: "tablecells", value: .tables) { - TableListView() - .environment(coordinator) - } - Tab("Query", systemImage: "terminal", value: .query) { - QueryEditorView() - .environment(coordinator) + return TabView(selection: $coordinator.selectedTab) { + Tab("Tables", systemImage: "tablecells", value: .tables) { + NavigationStack(path: $coordinator.tablesPath) { + tabChrome(coordinator) { + TableListView(connectionId: connection.id) + } + .navigationDestination(for: TableInfo.self) { table in + DataBrowserView(table: table) + .environment(coordinator) + } } - Tab("History", systemImage: "clock", value: .history) { - QueryHistoryView() - .environment(coordinator) + } + Tab("Query", systemImage: "terminal", value: .query) { + NavigationStack { + tabChrome(coordinator) { QueryEditorView() } } - Tab("Info", systemImage: "info.circle", value: .info) { - ConnectionInfoView() - .environment(coordinator) + } + Tab("History", systemImage: "clock", value: .history) { + NavigationStack { + tabChrome(coordinator) { QueryHistoryView() } } } - .navigationBarTitleDisplayMode(.inline) - .toolbar { connectionToolbar(coordinator) } - .navigationDestination(for: TableInfo.self) { table in - DataBrowserView(table: table) - .environment(coordinator) + Tab("Info", systemImage: "info.circle", value: .info) { + NavigationStack { + tabChrome(coordinator) { ConnectionInfoView() } + } } } + .tabViewStyle(.sidebarAdaptable) .background { Button("") { coordinator.selectedTab = .tables } .keyboardShortcut("1", modifiers: .command) @@ -180,6 +210,18 @@ struct ConnectedView: View { } } + private func tabChrome( + _ coordinator: ConnectionCoordinator, + @ViewBuilder _ content: () -> some View + ) -> some View { + content() + .environment(coordinator) + .navigationTitle(displayTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { closeToolbar } + .toolbar { connectionToolbar(coordinator) } + } + // MARK: - Connection Toolbar @ToolbarContentBuilder diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 69347cc74b..15ab659976 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -6,11 +6,9 @@ import UniformTypeIdentifiers struct ConnectionListView: View { @Environment(AppState.self) private var appState - @Environment(\.horizontalSizeClass) private var sizeClass @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @SceneStorage("lastConnectionId") private var selectedConnectionIdString: String? - @State private var columnVisibility: NavigationSplitViewVisibility = .automatic @State private var showingGroupManagement = false @State private var showingTagManagement = false @AppStorage("lastFilterTagId") private var filterTagIdString: String? @@ -32,17 +30,17 @@ struct ConnectionListView: View { ) } - private var selectedConnectionId: Binding { - Binding( - get: { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } }, - set: { selectedConnectionIdString = $0?.uuidString } - ) - } - private var selectedConnectionUUID: UUID? { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } } + private var openConnection: Binding { + Binding( + get: { selectedConnection }, + set: { selectedConnectionIdString = $0?.id.uuidString } + ) + } + private var filterTagId: UUID? { filterTagIdString.flatMap { UUID(uuidString: $0) } } @@ -68,7 +66,7 @@ struct ConnectionListView: View { } var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { + NavigationStack { sidebar .navigationTitle("Connections") .toolbar { @@ -130,19 +128,12 @@ struct ConnectionListView: View { .onAppear { navigateToPendingConnection(appState.pendingConnectionId) } - } detail: { - if let connection = selectedConnection { - ConnectedView(connection: connection, cachedCoordinator: coordinatorCache[connection.id]) { coordinator in - coordinatorCache[connection.id] = coordinator - } - .id(connection.id) - } else { - ContentUnavailableView( - "Select a Connection", - systemImage: "server.rack", - description: Text("Choose a connection from the sidebar.") - ) + } + .fullScreenCover(item: openConnection) { connection in + ConnectedView(connection: connection, cachedCoordinator: coordinatorCache[connection.id]) { coordinator in + coordinatorCache[connection.id] = coordinator } + .id(connection.id) } .sheet(isPresented: $showingAddConnection) { ConnectionFormView { connection in @@ -244,7 +235,7 @@ struct ConnectionListView: View { @ViewBuilder private var connectionList: some View { - let list = List(selection: selectedConnectionId) { + let list = List { if groupByGroup { groupedContent } else { @@ -261,11 +252,7 @@ struct ConnectionListView: View { } } } - if sizeClass == .regular { - list.listStyle(.sidebar) - } else { - list.listStyle(.insetGrouped) - } + list.listStyle(.insetGrouped) } @ViewBuilder @@ -448,9 +435,18 @@ struct ConnectionListView: View { } private func connectionRow(_ connection: DatabaseConnection) -> some View { - NavigationLink(value: connection.id) { - ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) + Button { + selectedConnectionIdString = connection.id.uuidString + } label: { + HStack(spacing: 8) { + ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) .hoverEffect() .swipeActions(edge: .leading) { Button { diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index c0c1f685b3..893e63d4dc 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -11,7 +11,9 @@ struct DataBrowserView: View { private var session: ConnectionSession? { coordinator.session } @State private var viewModel = DataBrowserViewModel() - @SceneStorage("dataBrowser.searchText") private var searchText = "" + /// Not persisted. This search runs on the server and is applied on submit, so a value restored + /// into the field would name a filter the rows on screen were never fetched under. + @State private var searchText = "" @FocusState private var searchFocused: Bool @State private var showInsertSheet = false @State private var showFilterSheet = false @@ -28,6 +30,13 @@ struct DataBrowserView: View { private var isView: Bool { table.type == .view || table.type == .materializedView } private var isRedis: Bool { connection.type == .redis } + + /// Both entry points ask this. Redis takes no `INSERT`, and the form cannot be filled in before + /// the column list has arrived. + private var canInsertRow: Bool { + !isView && !isRedis && !connection.safeModeLevel.blocksWrites && !viewModel.columnDetails.isEmpty + } + private var columns: [ColumnInfo] { viewModel.columns } private var rows: [[String?]] { viewModel.legacyRows } @@ -211,7 +220,7 @@ struct DataBrowserView: View { } description: { Text("This table is empty.") } actions: { - if !isView && !connection.safeModeLevel.blocksWrites { + if canInsertRow { Button("Insert Row") { showInsertSheet = true } .buttonStyle(.borderedProminent) } @@ -383,7 +392,7 @@ struct DataBrowserView: View { Image(systemName: "ellipsis.circle") } } - if !isView && !connection.safeModeLevel.blocksWrites { + if canInsertRow { ToolbarItem(placement: .primaryAction) { Button { showInsertSheet = true } label: { Image(systemName: "plus") diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 048dc680de..2bdc815288 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -1,4 +1,3 @@ -import os import SwiftUI import TableProDatabase import TableProModels @@ -12,28 +11,11 @@ struct InsertRowView: View { var onInserted: (() -> Void)? @Environment(\.dismiss) private var dismiss - @State private var values: [String] - @State private var isNullFlags: [Bool] - init( - table: TableInfo, - columnDetails: [ColumnInfo], - session: ConnectionSession?, - databaseType: DatabaseType, - safeModeLevel: SafeModeLevel = .off, - onInserted: (() -> Void)? = nil - ) { - self.table = table - self.columnDetails = columnDetails - self.session = session - self.databaseType = databaseType - self.safeModeLevel = safeModeLevel - self.onInserted = onInserted - _values = State(initialValue: Array(repeating: "", count: columnDetails.count)) - _isNullFlags = State(initialValue: columnDetails.map { col in - col.isPrimaryKey && col.typeName.uppercased().contains("INT") - }) - } + /// A column absent from this dictionary is left out of the `INSERT` so the database applies + /// its own default. Keying by name rather than by position is what keeps the state from + /// drifting when `columnDetails` arrives or changes while the sheet is open. + @State private var fields: [String: PayloadValue] = [:] @State private var isSaving = false @State private var operationError: AppError? @State private var showOperationError = false @@ -42,76 +24,31 @@ struct InsertRowView: View { @State private var hapticSuccess = false @State private var hapticError = false + private var columnNames: [String] { columnDetails.map(\.name) } + + private var canSave: Bool { + guard let driver = session?.driver else { return false } + return buildInsertSQL(driver: driver) != nil + } + var body: some View { NavigationStack { Form { - ForEach(Array(columnDetails.enumerated()), id: \.offset) { index, column in + ForEach(columnDetails, id: \.name) { column in Section { - HStack { - if isNullFlags[safe: index] == true { - Text("NULL") - .font(.body) - .foregroundStyle(.secondary) - .italic() - } else { - TextField(text: binding(for: index), prompt: placeholder(for: column)) { - Text(verbatim: column.name) - } - .font(.body) - .keyboardType(keyboardType(for: column)) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } - - Spacer() - - Button { - guard index < isNullFlags.count else { return } - isNullFlags[index].toggle() - if isNullFlags[index], index < values.count { - values[index] = "" - } - } label: { - Text("NULL") - .font(.caption2) - .foregroundStyle(isNullFlags[safe: index] == true ? .white : .secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(isNullFlags[safe: index] == true ? Color.accentColor : Color(.systemFill)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } + columnRow(column) } header: { - HStack(spacing: 6) { - if column.isPrimaryKey { - Image(systemName: "key.fill") - .font(.caption2) - .foregroundStyle(.orange) - } - Text(column.name) - - if column.isPrimaryKey { - Group { - if isAutoIncrement(column) { - Text("auto-increment") - } else { - Text("primary key") - } - } - .font(.caption2) - .foregroundStyle(.secondary) - } - - Spacer() - - MetadataBadge(column.typeName) - } + header(for: column) } footer: { - if let defaultValue = column.defaultValue { - Text("Default: \(defaultValue)") - .font(.caption2) - } + footer(for: column) + } + } + + if !canSave { + Section { + Text("Fill in at least one column. This database cannot insert a row made only of defaults.") + .font(.footnote) + .foregroundStyle(.secondary) } } } @@ -128,9 +65,13 @@ struct InsertRowView: View { ConfirmButton(title: "Save", isInProgress: isSaving) { Task { await insertRow() } } - .disabled(isSaving) + .disabled(isSaving || !canSave) } } + .onChange(of: columnNames) { _, newNames in + let known = Set(newNames) + fields = fields.filter { known.contains($0.key) } + } .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) .alert(operationError?.title ?? "Error", isPresented: $showOperationError) { @@ -153,24 +94,142 @@ struct InsertRowView: View { } } - private func binding(for index: Int) -> Binding { + // MARK: - Rows + + @ViewBuilder + private func columnRow(_ column: ColumnInfo) -> some View { + HStack { + if column.isGenerated { + Text("Computed by the database") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } else { + if fields[column.name] == .null { + Text("NULL") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } else { + TextField(text: literalBinding(for: column), prompt: placeholder(for: column)) { + Text(verbatim: column.name) + } + .font(.body) + .keyboardType(keyboardType(for: column)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + Spacer() + + stateMenu(for: column) + } + } + } + + private func stateMenu(for column: ColumnInfo) -> some View { + Menu { + Button { + fields[column.name] = nil + } label: { + stateLabel(String(localized: "Use Default"), isActive: fields[column.name] == nil) + } + if column.isNullable { + Button { + fields[column.name] = .null + } label: { + stateLabel(String(localized: "NULL"), isActive: fields[column.name] == .null) + } + } + Button { + fields[column.name] = .text("") + } label: { + stateLabel(String(localized: "Empty String"), isActive: fields[column.name] == .text("")) + } + } label: { + Text(stateBadge(for: column)) + .font(.caption2) + .foregroundStyle(fields[column.name] == .null ? .white : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(fields[column.name] == .null ? Color.accentColor : Color(.systemFill)) + .clipShape(Capsule()) + } + .accessibilityLabel(Text(String(format: String(localized: "Value for %@"), column.name))) + } + + @ViewBuilder + private func stateLabel(_ title: String, isActive: Bool) -> some View { + if isActive { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + + private func stateBadge(for column: ColumnInfo) -> String { + switch fields[column.name] { + case .none: return String(localized: "DEFAULT") + case .some(.null): return String(localized: "NULL") + case .some(.text): return String(localized: "VALUE") + } + } + + @ViewBuilder + private func header(for column: ColumnInfo) -> some View { + HStack(spacing: 6) { + if column.isPrimaryKey { + Image(systemName: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + Text(column.name) + + Group { + if column.isAutoIncrement { + Text("auto-increment") + } else if column.isPrimaryKey { + Text("primary key") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + + Spacer() + + MetadataBadge(column.typeName) + } + } + + @ViewBuilder + private func footer(for column: ColumnInfo) -> some View { + if column.isGenerated { + Text("This column is generated, so it is never written.") + .font(.caption2) + } else if let defaultValue = column.defaultValue { + Text("Default: \(defaultValue)") + .font(.caption2) + } + } + + // MARK: - Field State + + private func literalBinding(for column: ColumnInfo) -> Binding { Binding( - get: { values[safe: index] ?? "" }, + get: { + if case .text(let value) = fields[column.name] { return value } + return "" + }, set: { newValue in - guard index < values.count else { return } - values[index] = newValue + fields[column.name] = newValue.isEmpty ? nil : .text(newValue) } ) } private func placeholder(for column: ColumnInfo) -> Text { - if column.isPrimaryKey { return Text("Auto") } + if column.isAutoIncrement { return Text("Auto") } if let defaultValue = column.defaultValue { return Text("Default: \(defaultValue)") } - return Text(verbatim: column.typeName) - } - - private func isAutoIncrement(_ column: ColumnInfo) -> Bool { - column.isPrimaryKey && column.typeName.uppercased().contains("INT") + return Text("Default") } private func keyboardType(for column: ColumnInfo) -> UIKeyboardType { @@ -183,10 +242,12 @@ struct InsertRowView: View { return .default } + // MARK: - Insert + private func insertRow() async { guard let session else { return } - let sql = buildInsertSQL(driver: session.driver) + guard let sql = buildInsertSQL(driver: session.driver) else { return } switch safeModeLevel.writePermission { case .blocked: @@ -205,34 +266,17 @@ struct InsertRowView: View { await executeInsert(sql: sql, session: session) } - private func buildInsertSQL(driver: any DatabaseDriver) -> String { - var insertColumns: [String] = [] - var insertValues: [String?] = [] - - for (index, column) in columnDetails.enumerated() { - let isNull = isNullFlags[safe: index] == true - let text = values[safe: index] ?? "" - - if column.isPrimaryKey && (isNull || text.isEmpty) { - continue - } - - insertColumns.append(column.name) - if isNull { - insertValues.append(nil) - } else { - insertValues.append(text) - } - } - - return SQLBuilder.buildInsert( + private func buildInsertSQL(driver: any DatabaseDriver) -> String? { + try? RowInsertPlanner.statements( table: table.name, schema: nil, type: databaseType, driver: driver, - columns: insertColumns, - values: insertValues - ) + columns: columnDetails, + rows: [PayloadRow(values: fields)], + allowAllDefaults: true, + dropsEmptyPrimaryKey: false + ).first } private func executeInsert(sql: String, session: ConnectionSession) async { @@ -252,9 +296,3 @@ struct InsertRowView: View { } } } - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index e6f9465ecb..7d93fd4546 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -305,6 +305,7 @@ struct RowDetailView: View { ) let isNull = index < viewModel.editedValues.count ? viewModel.editedValues[index] == nil : true + let offersNull = viewModel.isNullable(at: index) || isNull return HStack { if isNull { @@ -317,18 +318,20 @@ struct RowDetailView: View { .font(.body) } - Button { - viewModel.toggleNull(at: index) - } label: { - Text("NULL") - .font(.caption2) - .foregroundStyle(isNull ? .white : .secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(isNull ? Color.accentColor : Color(.systemFill)) - .clipShape(Capsule()) + if offersNull { + Button { + viewModel.toggleNull(at: index) + } label: { + Text("NULL") + .font(.caption2) + .foregroundStyle(isNull ? .white : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isNull ? Color.accentColor : Color(.systemFill)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 86313c406d..5ab8cf9354 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -9,7 +9,14 @@ struct TableListView: View { private var tables: [TableInfo] { coordinator.tables } private var session: ConnectionSession? { coordinator.session } - @SceneStorage("tableList.searchText") private var searchText = "" + /// Scoped to the connection: one shared key leaves a filter from another connection applied + /// to a list that never shows it. + @SceneStorage private var searchText: String + + init(connectionId: UUID) { + _searchText = SceneStorage(wrappedValue: "", "tableList.searchText.\(connectionId.uuidString)") + } + @FocusState private var searchFocused: Bool @State private var tableToTruncate: TableInfo? @State private var tableToDrop: TableInfo? diff --git a/TableProMobile/TableProMobileTests/Helpers/ColumnMetadataRulesTests.swift b/TableProMobile/TableProMobileTests/Helpers/ColumnMetadataRulesTests.swift new file mode 100644 index 0000000000..251ca396b6 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Helpers/ColumnMetadataRulesTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import TableProMobile + +@Suite("ColumnMetadataRules") +struct ColumnMetadataRulesTests { + @Test("MySQL reports an auto-increment column from Extra") + func mySQLAutoIncrement() { + #expect(ColumnMetadataRules.mySQLIsAutoIncrement(extra: "auto_increment")) + #expect(ColumnMetadataRules.mySQLIsAutoIncrement(extra: "AUTO_INCREMENT")) + #expect(!ColumnMetadataRules.mySQLIsAutoIncrement(extra: "")) + #expect(!ColumnMetadataRules.mySQLIsAutoIncrement(extra: nil)) + } + + @Test("MySQL DEFAULT_GENERATED is a default, not a generated column") + func mySQLDefaultGeneratedIsNotGenerated() { + #expect(!ColumnMetadataRules.mySQLIsGenerated(extra: "DEFAULT_GENERATED")) + #expect(!ColumnMetadataRules.mySQLIsGenerated(extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP")) + } + + @Test("MySQL reports virtual and stored generated columns") + func mySQLGenerated() { + #expect(ColumnMetadataRules.mySQLIsGenerated(extra: "STORED GENERATED")) + #expect(ColumnMetadataRules.mySQLIsGenerated(extra: "VIRTUAL GENERATED")) + #expect(!ColumnMetadataRules.mySQLIsGenerated(extra: "auto_increment")) + } + + @Test("PostgreSQL reports identity columns and serial defaults") + func postgresAutoIncrement() { + #expect(ColumnMetadataRules.postgresIsAutoIncrement(isIdentity: "YES", columnDefault: nil)) + #expect(ColumnMetadataRules.postgresIsAutoIncrement( + isIdentity: "NO", columnDefault: "nextval('t_id_seq'::regclass)" + )) + #expect(!ColumnMetadataRules.postgresIsAutoIncrement(isIdentity: "NO", columnDefault: "now()")) + #expect(!ColumnMetadataRules.postgresIsAutoIncrement(isIdentity: nil, columnDefault: nil)) + } + + @Test("PostgreSQL reports a generated column only when is_generated is ALWAYS") + func postgresGenerated() { + #expect(ColumnMetadataRules.postgresIsGenerated(isGenerated: "ALWAYS")) + #expect(!ColumnMetadataRules.postgresIsGenerated(isGenerated: "NEVER")) + #expect(!ColumnMetadataRules.postgresIsGenerated(isGenerated: nil)) + } + + @Test("SQLite reports pk as a rank, so every member of a composite key is a primary key") + func sqlitePrimaryKeyRank() { + #expect(ColumnMetadataRules.sqliteIsPrimaryKey(pk: "1")) + #expect(ColumnMetadataRules.sqliteIsPrimaryKey(pk: "2")) + #expect(!ColumnMetadataRules.sqliteIsPrimaryKey(pk: "0")) + #expect(!ColumnMetadataRules.sqliteIsPrimaryKey(pk: nil)) + } + + @Test("SQLite treats a lone INTEGER primary key as the rowid alias") + func sqliteRowIdAlias() { + #expect(ColumnMetadataRules.sqliteIsRowIdAlias( + typeName: "INTEGER", isPrimaryKey: true, primaryKeyCount: 1, + createStatement: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" + )) + #expect(!ColumnMetadataRules.sqliteIsRowIdAlias( + typeName: "TEXT", isPrimaryKey: true, primaryKeyCount: 1, + createStatement: "CREATE TABLE t (id TEXT PRIMARY KEY)" + )) + } + + @Test("SQLite never treats a composite primary key as the rowid alias") + func sqliteCompositeKeyIsNotRowId() { + #expect(!ColumnMetadataRules.sqliteIsRowIdAlias( + typeName: "INTEGER", isPrimaryKey: true, primaryKeyCount: 2, + createStatement: "CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a, b))" + )) + } + + @Test("a WITHOUT ROWID primary key is an ordinary column the database will not fill in") + func sqliteWithoutRowIdIsNotAnAlias() { + #expect(!ColumnMetadataRules.sqliteIsRowIdAlias( + typeName: "INTEGER", isPrimaryKey: true, primaryKeyCount: 1, + createStatement: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT) WITHOUT ROWID" + )) + } +} diff --git a/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift b/TableProMobile/TableProMobileTests/Helpers/RowInsertPlannerTests.swift similarity index 58% rename from TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift rename to TableProMobile/TableProMobileTests/Helpers/RowInsertPlannerTests.swift index dbc6fd5b41..3419b9a8a3 100644 --- a/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift +++ b/TableProMobile/TableProMobileTests/Helpers/RowInsertPlannerTests.swift @@ -143,4 +143,105 @@ struct RowInsertPlannerTests { ) #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('C:\\Users\\dat')"#]) } + + // MARK: - Omitted Columns + + private let defaultsColumns = [ + ColumnInfo( + name: "id", typeName: "integer", isPrimaryKey: true, isNullable: false, + ordinalPosition: 0, isAutoIncrement: true + ), + ColumnInfo( + name: "created_at", typeName: "datetime", isNullable: false, + defaultValue: "CURRENT_TIMESTAMP", ordinalPosition: 1 + ), + ColumnInfo(name: "name", typeName: "text", isNullable: false, ordinalPosition: 2), + ColumnInfo(name: "doubled", typeName: "integer", ordinalPosition: 3, isGenerated: true) + ] + + @Test("leaves an untouched column out so the database applies its default") + func omitsUntouchedColumn() throws { + let row = PayloadRow(values: ["name": .text("Ada")]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('Ada')"#]) + } + + @Test("never writes a generated column, even when a value is supplied") + func dropsGeneratedColumn() throws { + let row = PayloadRow(values: ["name": .text("Ada"), "doubled": .text("10")]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('Ada')"#]) + } + + @Test("keeps an explicit value written into an auto-increment column") + func keepsExplicitAutoIncrementValue() throws { + let row = PayloadRow(values: ["id": .text("42"), "name": .text("Ada")]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`id`, `name`) VALUES ('42', 'Ada')"#]) + } + + @Test("writes NULL rather than omitting a column the user set to NULL") + func nullIsNotOmission() throws { + let row = PayloadRow(values: ["name": .null]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`name`) VALUES (NULL)"#]) + } + + @Test("an all-default row uses the empty column list on MySQL") + func allDefaultsOnMySQL() throws { + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [PayloadRow(values: [:])], allowAllDefaults: true + ) + #expect(statements == [#"INSERT INTO `people` () VALUES ()"#]) + } + + @Test("an all-default row uses DEFAULT VALUES on PostgreSQL") + func allDefaultsOnPostgres() throws { + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), + columns: defaultsColumns, rows: [PayloadRow(values: [:])], allowAllDefaults: true + ) + #expect(statements == [#"INSERT INTO "people" DEFAULT VALUES"#]) + } + + @Test("an all-default row produces nothing on Oracle, which has no such statement") + func allDefaultsOnOracle() throws { + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .oracle, driver: ansiDriver(), + columns: defaultsColumns, rows: [PayloadRow(values: [:])], allowAllDefaults: true + ) + #expect(statements.isEmpty) + } + + @Test("a caller that distinguishes omission writes an explicitly empty primary key") + func keepsExplicitEmptyPrimaryKey() throws { + let row = PayloadRow(values: ["id": .text(""), "name": .text("Ada")]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), + columns: columns, rows: [row], dropsEmptyPrimaryKey: false + ) + #expect(statements == [#"INSERT INTO "people" ("id", "name") VALUES ('', 'Ada')"#]) + } + + @Test("the Shortcuts path still produces nothing for a row that sets nothing") + func allDefaultsStayOptOut() throws { + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), + columns: defaultsColumns, rows: [PayloadRow(values: [:])] + ) + #expect(statements.isEmpty) + } } diff --git a/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift b/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift similarity index 100% rename from TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift rename to TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift diff --git a/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift new file mode 100644 index 0000000000..0b9b18a386 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift @@ -0,0 +1,40 @@ +import Foundation +import TableProModels +import Testing +@testable import TableProMobile + +@Suite("SQLBuilder all-defaults insert") +struct SQLBuilderDefaultValuesTests { + @Test("MySQL and MariaDB take an empty column list") + func mySQLFamily() { + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .mysql) + == "INSERT INTO `t` () VALUES ()") + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .mariadb) + == "INSERT INTO `t` () VALUES ()") + } + + @Test("the PostgreSQL family, SQLite, SQL Server and DuckDB take DEFAULT VALUES") + func defaultValuesDialects() { + for type in [DatabaseType.postgresql, .redshift, .sqlite, .mssql, .duckdb] { + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "\"t\"", for: type) + == "INSERT INTO \"t\" DEFAULT VALUES") + } + } + + @Test("Oracle has no all-defaults statement") + func oracleHasNoForm() { + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "\"t\"", for: .oracle) == nil) + #expect(!SQLBuilder.supportsAllDefaultsInsert(.oracle)) + } + + @Test("an empty column list routes through the all-defaults form") + func emptyColumnListRoutes() { + let driver = MockDatabaseDriver() + #expect(SQLBuilder.buildInsert( + table: "people", schema: nil, type: .postgresql, driver: driver, columns: [], values: [] + ) == "INSERT INTO \"people\" DEFAULT VALUES") + #expect(SQLBuilder.buildInsert( + table: "people", schema: nil, type: .oracle, driver: driver, columns: [], values: [] + ) == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index cf601ddf90..15924b4bc7 100644 --- a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -215,4 +215,15 @@ struct RowDetailViewModelTests { #expect(vm.loadingCell == nil) #expect(vm.hasOverride(forRow: 0, cellIndex: 1) == true) } + + @Test("isNullable follows the column metadata so NOT NULL columns are not offered NULL") + func isNullableFollowsMetadata() { + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), columnDetails: makeColumns() + ) + #expect(vm.isNullable(at: 0) == false) + #expect(vm.isNullable(at: 1) == true) + #expect(vm.isNullable(at: 99) == true) + } } diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index e4cae737ce..b0ace05e7c 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -50,16 +50,18 @@ Picking a SQLite file copies it into the app, and edits go to that copy, so the ## What works -An open connection has four tabs: **Tables**, **Query**, **History**, **Info**. `Cmd+1` through `Cmd+4` switch between them on a keyboard, and a toolbar menu switches database and schema when the engine has more than one. +An open connection fills the screen and carries four sections: **Tables**, **Query**, **History**, **Info**. They sit in a tab bar on iPhone and in a sidebar on iPad. `Cmd+1` through `Cmd+4` switch between them on a keyboard, a toolbar menu switches database and schema when the engine has more than one, and **Connections** returns to the list. ### Browsing -Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. +Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. ### Editing Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. Inserting and deleting rows, and truncating or dropping a table, are here too. Editing needs a primary key. +A new row starts with every column on **DEFAULT**, which leaves that column out of the `INSERT` so the database fills it in. The badge beside a field switches it between **DEFAULT**, **NULL** and a typed value, and **NULL** is offered on nullable columns only. Generated columns are never written, and an auto-increment key stays on **DEFAULT** until you type one. + ### Querying The editor highlights SQL, runs a statement, and stops one mid-flight. Results copy or export as JSON, CSV, or SQL `INSERT`. A running query appears in a Live Activity on the lock screen and Dynamic Island; **Settings > Privacy** hides the SQL text there. @@ -67,6 +69,7 @@ The editor highlights SQL, runs a statement, and stops one mid-flight. Results c ## What is missing - **Schema changes.** Truncating and dropping a table is the whole list; creating or altering tables, columns, indexes, triggers, and views is Mac only. +- **Inserting a row on Redis.** Editing and deleting a key still work. - **AI.** No chat, no inline suggestions, no MCP server. ## Security