From ed80b382ca956a1cf59915b2a825640e3139506a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 09:28:10 +0700 Subject: [PATCH 1/2] feat(ios): open the referenced row from a foreign key preview --- .../TableProQuery/FilterColumnKind.swift | 44 +++ .../TableProQuery/FilterSQLGenerator.swift | 46 ++-- .../FilterSQLGeneratorTests.swift | 161 ++++++++++- .../TableProMobile/Drivers/MSSQLDriver.swift | 3 +- .../Drivers/MySQLColumnListing.swift | 10 +- .../TableProMobile/Drivers/MySQLDriver.swift | 166 +++++++----- .../TableProMobile/Drivers/OracleDriver.swift | 4 +- .../TableProMobile/Drivers/SQLiteDriver.swift | 53 +++- .../TableProMobile/Helpers/SQLBuilder.swift | 77 +++--- .../TableProMobile/Localizable.xcstrings | 16 ++ .../Models/ForeignKeyReference.swift | 106 ++++++++ .../ViewModels/DataBrowserViewModel.swift | 115 ++++++-- .../ViewModels/ForeignKeyPreviewModel.swift | 150 +++++++++++ .../ViewModels/RowDetailViewModel.swift | 42 ++- .../Views/Components/FKPreviewView.swift | 116 +++----- .../Components/ForeignKeyPreviewPage.swift | 111 ++++++++ .../Views/Components/TableTitle.swift | 26 ++ .../TableProMobile/Views/ConnectedView.swift | 4 +- .../Views/DataBrowserView.swift | 134 +++++---- .../Views/ForeignKeyNavigation.swift | 67 +++++ .../TableProMobile/Views/RowDetailView.swift | 28 +- .../TableProMobile/Views/StructureView.swift | 7 +- .../Views/TableBrowserIdentity.swift | 12 + .../DataBrowserViewModelTests.swift | 254 +++++++++++++++++- .../Drivers/MySQLColumnListingTests.swift | 22 +- .../MySQLDriverLegacyServerTests.swift | 32 +++ .../Drivers/MySQLForeignKeyCatalogTests.swift | 96 +++++++ .../Drivers/SQLiteDriverFileTests.swift | 90 +++++++ .../ForeignKeyPreviewModelTests.swift | 180 +++++++++++++ .../ForeignKeyReferenceTests.swift | 194 +++++++++++++ .../SQLBuilderFilterLiteralTests.swift | 72 +++++ .../SQLBuilderSingleRowLookupTests.swift | 85 +++--- .../Mocks/MockDatabaseDriver.swift | 6 + .../RowDetailViewModelTests.swift | 29 ++ .../Views/TableBrowserIdentityTests.swift | 29 ++ 35 files changed, 2213 insertions(+), 374 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift create mode 100644 apple/TableProMobile/TableProMobile/Models/ForeignKeyReference.swift create mode 100644 apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/ForeignKeyPreviewPage.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/TableTitle.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/ForeignKeyNavigation.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/TableBrowserIdentity.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Drivers/MySQLForeignKeyCatalogTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/ForeignKeyReferenceTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Views/TableBrowserIdentityTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift new file mode 100644 index 0000000000..3705c80a7a --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift @@ -0,0 +1,44 @@ +import Foundation +import TableProModels +import TableProPluginKit + +// Unknown types fall to `.text`: `int_col = '5'` still matches everywhere, `text_col = 02134` finds the wrong row. +nonisolated public enum FilterColumnKind { + public static func kind(forTypeName typeName: String) -> PluginColumnKind { + let base = typeName.lowercased().prefix { $0 != "(" } + let words = base.split(whereSeparator: \.isWhitespace).filter { $0 != "unsigned" } + // PostgreSQL's `bit varying` is a bit string, and `= 101` has no operator there. + guard let first = words.first.map(String.init), !words.contains("varying") else { return .text } + if integerNames.contains(first) { return .integer } + if decimalNames.contains(first) { return .decimal } + return .text + } + + public static func column(named name: String, in columns: [ColumnInfo]) -> ColumnInfo? { + if let exact = columns.first(where: { $0.name == name }) { return exact } + let folded = columns.filter { $0.name.caseInsensitiveCompare(name) == .orderedSame } + return folded.count == 1 ? folded.first : nil + } + + // A SQLite column with no affinity (typeless, any BLOB, ANY in a STRICT table) does not match a + // stored 5 to `= '5'`: keep the untyped rule there. + public static func kind(of column: ColumnInfo?) -> PluginColumnKind? { + guard let column else { return nil } + let typeName = column.typeName.trimmingCharacters(in: .whitespaces).lowercased() + guard !typeName.isEmpty, !typeName.contains("blob"), typeName != "any" else { return nil } + return kind(forTypeName: typeName) + } + + private static let integerNames: Set = [ + "int", "integer", "tinyint", "smallint", "mediumint", "bigint", + "int1", "int2", "int3", "int4", "int8", + "serial", "smallserial", "bigserial", + "hugeint", "uhugeint", "utinyint", "usmallint", "uinteger", "ubigint", + "bit", "bool", "boolean" + ] + + private static let decimalNames: Set = [ + "decimal", "numeric", "number", "real", "float", "double", + "float4", "float8", "binary_float", "binary_double" + ] +} diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index 4d5fe30723..24852fc1fe 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -11,26 +11,28 @@ public struct FilterSQLGenerator: Sendable { public func generateWhereClause( from filters: [TableFilter], - logicMode: FilterLogicMode + logicMode: FilterLogicMode, + columns: [ColumnInfo] = [] ) -> String { let activeFilters = filters.filter { $0.isEnabled && $0.isValid } guard !activeFilters.isEmpty else { return "" } - let conditions = activeFilters.compactMap { generateCondition(for: $0) } + let conditions = activeFilters.compactMap { generateCondition(for: $0, columns: columns) } guard !conditions.isEmpty else { return "" } let joined = conditions.joined(separator: " \(logicMode.rawValue) ") return "WHERE \(joined)" } - private func generateCondition(for filter: TableFilter) -> String? { + private func generateCondition(for filter: TableFilter, columns: [ColumnInfo]) -> String? { if filter.columnName == TableFilter.rawSQLColumn { guard let rawSQL = filter.rawSQL, !rawSQL.isEmpty else { return nil } return rawSQL } let quotedColumn = quoteIdentifier(filter.columnName) - let escapedValue = escapeValue(filter.value) + let kind = FilterColumnKind.kind(of: FilterColumnKind.column(named: filter.columnName, in: columns)) + let escapedValue = escapeValue(filter.value, kind: kind) let folding = caseFolding(for: filter) switch filter.filterOperator { @@ -55,11 +57,11 @@ public struct FilterSQLGenerator: Sendable { case .isNotNull: return "\(quotedColumn) IS NOT NULL" case .in: - return listCondition(quotedColumn, filter.value, negated: false, folding: folding) + return listCondition(quotedColumn, filter.value, kind: kind, negated: false, folding: folding) case .notIn: - return listCondition(quotedColumn, filter.value, negated: true, folding: folding) + return listCondition(quotedColumn, filter.value, kind: kind, negated: true, folding: folding) case .between: - let escapedSecond = escapeValue(filter.secondValue) + let escapedSecond = escapeValue(filter.secondValue, kind: kind) return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" case .contains: let pattern = escapeLikePattern(filter.value) @@ -98,11 +100,11 @@ public struct FilterSQLGenerator: Sendable { } private func listCondition( - _ column: String, _ rawValue: String, negated: Bool, folding: PluginSQLCaseFolding + _ column: String, _ rawValue: String, kind: PluginColumnKind?, negated: Bool, folding: PluginSQLCaseFolding ) -> String { let keyword = negated ? "NOT IN" : "IN" let items = rawValue.split(separator: ",") - .map { escapeValue($0.trimmingCharacters(in: .whitespaces)) } + .map { escapeValue($0.trimmingCharacters(in: .whitespaces), kind: kind) } let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") } let rendered = foldable ? items.map { folding.fold($0) } : items let subject = foldable ? folding.fold(column) : column @@ -119,21 +121,31 @@ public struct FilterSQLGenerator: Sendable { } private func quoteIdentifier(_ name: String) -> String { - let q = dialect.identifierQuote - let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") - return "\(q)\(escaped)\(q)" + let open = dialect.identifierQuote + let close = open == "[" ? "]" : open + let escaped = name.replacingOccurrences(of: close, with: "\(close)\(close)") + return "\(open)\(escaped)\(close)" } - private func escapeValue(_ value: String) -> String { - if Int64(value) != nil || Double(value) != nil { + private func escapeValue(_ value: String, kind: PluginColumnKind?) -> String { + if isBareNumber(value, kind: kind) { return value } - let escaped = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") + var escaped = value.replacingOccurrences(of: "\0", with: "") + if dialect.requiresBackslashEscaping { + escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") + } + escaped = escaped.replacingOccurrences(of: "'", with: "''") return "'\(escaped)'" } + // Character.isNumber takes digits of every script, and no engine parses those as a number. + // An integer column takes a fraction bare too: PostgreSQL and SQL Server reject `int_col > '5.5'`. + private func isBareNumber(_ value: String, kind: PluginColumnKind?) -> Bool { + guard value.unicodeScalars.allSatisfy(\.isASCII) else { return false } + return kind != .text && PluginNumericLiteral.isValid(value) + } + private func escapeLikePattern(_ value: String) -> String { var result = value .replacingOccurrences(of: "'", with: "''") diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 684e99257f..189a9dad26 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -4,7 +4,6 @@ import Foundation @testable import TableProQuery import Testing -@Suite("FilterSQLGenerator Tests") struct FilterSQLGeneratorTests { private var dialect: SQLDialectDescriptor { SQLDialectDescriptor( @@ -156,4 +155,164 @@ struct FilterSQLGeneratorTests { #expect(result.contains("'active'")) #expect(result.contains("'pending'")) } + + private func makeDialect(quote: String, backslashEscaping: Bool = false) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: quote, + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: backslashEscaping ? .implicit : .explicit, + requiresBackslashEscaping: backslashEscaping + ) + } + + private func clause( + _ column: String, + _ value: String, + op filterOperator: FilterOperator = .equal, + columns: [ColumnInfo] = [], + dialect: SQLDialectDescriptor? = nil + ) -> String { + FilterSQLGenerator(dialect: dialect ?? self.dialect).generateWhereClause( + from: [TableFilter(columnName: column, filterOperator: filterOperator, value: value)], + logicMode: .and, + columns: columns + ) + } + + @Test("A bracket-quoted identifier closes with a bracket") + func bracketIdentifierCloses() { + #expect(clause("id", "5", dialect: makeDialect(quote: "[")) == "WHERE [id] = 5") + } + + @Test("A closing bracket inside a bracket-quoted identifier is doubled") + func bracketInsideIdentifierIsDoubled() { + #expect(clause("a]b", "5", dialect: makeDialect(quote: "[")) == "WHERE [a]]b] = 5") + } + + @Test("A backslash is doubled where the engine reads it as an escape") + func backslashIsDoubled() { + let mysql = makeDialect(quote: "`", backslashEscaping: true) + #expect(clause("path", "C:\\temp\\", dialect: mysql) == "WHERE `path` = 'C:\\\\temp\\\\'") + #expect(clause("code", "a\\' OR 1=1 -- ", dialect: mysql) == "WHERE `code` = 'a\\\\'' OR 1=1 -- '") + } + + @Test("A backslash stays single where the engine reads it as a character") + func backslashStaysSingle() { + #expect(clause("path", "C:\\temp") == "WHERE \"path\" = 'C:\\temp'") + } + + @Test("A number-like value against a text column is quoted") + func numberLikeTextIsQuoted() { + let columns = [ColumnInfo(name: "code", typeName: "varchar(10)")] + #expect(clause("code", "02134", columns: columns) == "WHERE \"code\" = '02134'") + } + + @Test("A filter column spelled in another case still finds its type") + func caseFoldedColumnFindsType() { + let columns = [ColumnInfo(name: "code", typeName: "TEXT")] + #expect(clause("CODE", "02134", columns: columns) == "WHERE \"CODE\" = '02134'") + } + + @Test("Two case-insensitive matches resolve no column, so the untyped rule applies") + func ambiguousCaseFoldedColumn() { + let columns = [ColumnInfo(name: "Code", typeName: "TEXT"), ColumnInfo(name: "CODE", typeName: "TEXT")] + #expect(FilterColumnKind.column(named: "code", in: columns)?.name == nil) + #expect(clause("code", "02134", columns: columns) == "WHERE \"code\" = 02134") + } + + @Test("An exact name wins over a case-insensitive one") + func exactNameWins() { + let columns = [ColumnInfo(name: "code", typeName: "TEXT"), ColumnInfo(name: "CODE", typeName: "integer")] + #expect(clause("CODE", "5", columns: columns) == "WHERE \"CODE\" = 5") + } + + @Test("Types outside the numeric allowlist are quoted", arguments: ["interval", "point", "uuid", "money"]) + func unlistedTypeIsQuoted(typeName: String) { + let columns = [ColumnInfo(name: "v", typeName: typeName)] + #expect(clause("v", "5", columns: columns) == "WHERE \"v\" = '5'") + } + + @Test("Integer and decimal types stay bare", arguments: ["bit", "tinyint(1)", "NUMBER(10)", "int(11) unsigned", "double precision"]) + func numericTypeStaysBare(typeName: String) { + let columns = [ColumnInfo(name: "v", typeName: typeName)] + #expect(clause("v", "1", columns: columns) == "WHERE \"v\" = 1") + } + + @Test("A greater-than against an INT column stays bare") + func intGreaterThanStaysBare() { + let columns = [ColumnInfo(name: "age", typeName: "INT")] + #expect(clause("age", "25", op: .greaterThan, columns: columns) == "WHERE \"age\" > 25") + } + + @Test("A fraction against an integer column stays bare, since PostgreSQL rejects bigint > '5.5'") + func fractionAgainstIntegerStaysBare() { + let columns = [ColumnInfo(name: "id", typeName: "bigint")] + #expect(clause("id", "5.5", op: .greaterThan, columns: columns) == "WHERE \"id\" > 5.5") + } + + @Test("A typeless column keeps the untyped rule") + func typelessColumnStaysUntyped() { + let columns = [ColumnInfo(name: "v", typeName: "")] + #expect(clause("v", "5", columns: columns) == "WHERE \"v\" = 5") + } + + @Test("A column without SQLite affinity keeps the untyped rule", arguments: ["BLOB", "longblob", "ANY"]) + func noAffinityColumnStaysUntyped(typeName: String) { + let columns = [ColumnInfo(name: "v", typeName: typeName)] + #expect(clause("v", "5", columns: columns) == "WHERE \"v\" = 5") + } + + @Test("Values Swift parses as a number but SQL does not are quoted", arguments: ["nan", "inf", "Infinity", "0x1F"]) + func swiftOnlyNumbersAreQuoted(value: String) { + #expect(clause("v", value) == "WHERE \"v\" = '\(value)'") + let columns = [ColumnInfo(name: "v", typeName: "double")] + #expect(clause("v", value, columns: columns) == "WHERE \"v\" = '\(value)'") + } + + @Test("Digits of another script are quoted") + func nonASCIIDigitsAreQuoted() { + #expect(clause("v", "\u{0663}") == "WHERE \"v\" = '\u{0663}'") + } + + @Test("Every IN item and both BETWEEN bounds follow the column's type") + func listAndRangeFollowType() { + let columns = [ColumnInfo(name: "code", typeName: "varchar")] + #expect(clause("code", "01, 02", op: .in, columns: columns) == "WHERE \"code\" IN ('01', '02')") + let range = FilterSQLGenerator(dialect: dialect).generateWhereClause( + from: [TableFilter(columnName: "code", filterOperator: .between, value: "01", secondValue: "09")], + logicMode: .and, + columns: columns + ) + #expect(range == "WHERE \"code\" BETWEEN '01' AND '09'") + } + + @Test("Type names map to a column kind", arguments: [ + ("INTEGER", PluginColumnKind.integer), + ("int4", .integer), + ("bigint(20) unsigned zerofill", .integer), + ("UNSIGNED INTEGER", .integer), + ("tinyint(1)", .integer), + ("bit", .integer), + ("boolean", .integer), + ("bool", .integer), + ("NUMBER(10)", .decimal), + ("numeric(10,2)", .decimal), + ("double precision", .decimal), + ("REAL", .decimal), + ("float8", .decimal), + ("DECIMAL(18,3)", .decimal), + ("varchar(255)", .text), + ("character varying", .text), + ("TEXT", .text), + ("interval", .text), + ("point", .text), + ("bit varying", .text), + ("BIT VARYING(8)", .text), + ("", .text) + ]) + func typeNameKind(typeName: String, kind: PluginColumnKind) { + #expect(FilterColumnKind.kind(forTypeName: typeName) == kind) + } } diff --git a/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 1c387ffae3..f6afc8d65c 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -276,7 +276,8 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { return result.rows.compactMap { row in MSSQLSchemaQueries.parseForeignKeyRow(row).map { ForeignKeyInfo(name: $0.constraintName, column: $0.columnName, - referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn) + referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn, + referencedSchema: $0.referencedSchema) } } } diff --git a/apple/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift index 68a1ec5b93..45e2fd0915 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift @@ -25,16 +25,14 @@ nonisolated internal enum MySQLColumnListing { return .quotedCatalog } - /// The catalog read for one table's defaults, or nil when `SHOW FULL COLUMNS` is the only source. - /// - /// It filters on `DATABASE()` because the unqualified `SHOW FULL COLUMNS` reads the session's - /// database, and the two answers are matched by column name alone. - static func catalogDefaultsQuery(table: String, source: DefaultSource) -> String? { + // Its rows are matched to SHOW FULL COLUMNS by column name alone, so both read the same database. + static func catalogDefaultsQuery(table: String, database: String?, source: DefaultSource) -> String? { switch source { case .quotedCatalog, .oceanBaseCatalog: return """ SELECT COLUMN_NAME, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))' + WHERE TABLE_SCHEMA = \(MySQLDriver.databaseLiteral(database)) + AND TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))' """ case .showFullColumns, .asReported: return nil diff --git a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index d3f99338f9..08fa33ebd1 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -200,23 +200,24 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { - let safe = table.replacingOccurrences(of: "`", with: "``") - let raw = try await actor.execute("SHOW FULL COLUMNS FROM `\(safe)`") + let raw = try await actor.execute(Self.showFullColumnsStatement(table: table, database: schema)) let source = MySQLColumnListing.defaultSource( flavor: Self.serverFlavor(for: databaseType, banner: serverVersion), banner: serverVersion ) - let catalog = await catalogDefaults(table: table, source: source) + let catalog = await catalogDefaults(table: table, database: schema, source: source) return MySQLColumnListing.columns(fromShowFullColumns: raw.rows, catalogDefaults: catalog, source: source) } - /// A catalog that refuses leaves every default on what `SHOW FULL COLUMNS` reported, as it does on - /// the Mac, rather than taking the column list down with it. + // A catalog that refuses leaves the defaults SHOW FULL COLUMNS reported, as on the Mac. private func catalogDefaults( table: String, + database: String?, source: MySQLColumnListing.DefaultSource ) async -> [String: MySQLCatalogDefault] { - guard let query = MySQLColumnListing.catalogDefaultsQuery(table: table, source: source) else { return [:] } + guard let query = MySQLColumnListing.catalogDefaultsQuery(table: table, database: database, source: source) else { + return [:] + } do { let raw = try await actor.execute(query) return MySQLColumnListing.catalogDefaults(fromRows: raw.rows, source: source) @@ -229,8 +230,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { - let safe = table.replacingOccurrences(of: "`", with: "``") - let raw = try await actor.execute("SHOW INDEX FROM `\(safe)`") + let raw = try await actor.execute(Self.showIndexStatement(table: table, database: schema)) var indexMap: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] var order: [String] = [] @@ -260,69 +260,24 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } } - /// Below 5.1.10 there is no `REFERENTIAL_CONSTRAINTS`, so the keys come from `SHOW CREATE TABLE`, as on - /// the Mac. An error here would also discard the columns and indexes loaded with the keys. + // Below 5.1.10 there is no REFERENTIAL_CONSTRAINTS. Failing there would also drop the columns and + // indexes loaded with the keys, so those servers read SHOW CREATE TABLE, as on the Mac. func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { guard Self.readsForeignKeyCatalog(for: databaseType, banner: serverVersion) else { - return try await declaredForeignKeys(table: table) - } - let safe = table.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "''") - let dbSafe = database.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "''") - let query = """ - SELECT - kcu.CONSTRAINT_NAME, - kcu.COLUMN_NAME, - kcu.REFERENCED_TABLE_NAME, - kcu.REFERENCED_COLUMN_NAME, - rc.DELETE_RULE, - rc.UPDATE_RULE - FROM information_schema.KEY_COLUMN_USAGE kcu - JOIN information_schema.REFERENTIAL_CONSTRAINTS rc - ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME - AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA - WHERE kcu.TABLE_SCHEMA = '\(dbSafe)' - AND kcu.TABLE_NAME = '\(safe)' - AND kcu.REFERENCED_TABLE_NAME IS NOT NULL - ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION - """ - let raw = try await actor.execute(query) - - return raw.rows.compactMap { row in - guard row.count >= 6, - let name = row[0], - let column = row[1], - let refTable = row[2], - let refColumn = row[3] else { return nil } - return ForeignKeyInfo( - name: name, - column: column, - referencedTable: refTable, - referencedColumn: refColumn, - onDelete: row[4] ?? "NO ACTION", - onUpdate: row[5] ?? "NO ACTION" - ) + return try await declaredForeignKeys(table: table, database: schema) } + let raw = try await actor.execute(Self.foreignKeyCatalogQuery(table: table, database: schema)) + return Self.foreignKeys(fromCatalogRows: raw.rows) } - private func declaredForeignKeys(table: String) async throws -> [ForeignKeyInfo] { - let safe = table.replacingOccurrences(of: "`", with: "``") - let raw = try await actor.execute("SHOW CREATE TABLE `\(safe)`") + private func declaredForeignKeys(table: String, database: String?) async throws -> [ForeignKeyInfo] { + let raw = try await actor.execute(Self.showCreateTableStatement(table: table, database: database)) guard let row = raw.rows.first, row.count > 1, let ddl = row[1] else { return [] } let omittedAction = MySQLServerVersion.omittedForeignKeyAction( banner: serverVersion, flavor: Self.serverFlavor(for: databaseType, banner: serverVersion) ) - return MySQLForeignKeyClause.parse(createTable: ddl, database: database, omittedAction: omittedAction) - .map { key in - ForeignKeyInfo( - name: key.name, - column: key.column, - referencedTable: key.referencedTable, - referencedColumn: key.referencedColumn, - onDelete: key.onDelete, - onUpdate: key.onUpdate - ) - } + return Self.foreignKeys(declaredIn: ddl, database: database, omittedAction: omittedAction) } func fetchDatabases() async throws -> [String] { @@ -369,6 +324,95 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } } +nonisolated extension MySQLDriver { + static func showFullColumnsStatement(table: String, database: String?) -> String { + "SHOW FULL COLUMNS FROM \(qualifiedTable(table, database: database))" + } + + static func showIndexStatement(table: String, database: String?) -> String { + "SHOW INDEX FROM \(qualifiedTable(table, database: database))" + } + + static func showCreateTableStatement(table: String, database: String?) -> String { + "SHOW CREATE TABLE \(qualifiedTable(table, database: database))" + } + + // Nil reads the session's database, which the last USE set. The one the driver connected with + // goes stale after a switch. + static func databaseLiteral(_ database: String?) -> String { + guard let database, !database.isEmpty else { return "DATABASE()" } + return "'\(SQLEscaping.backslashStringLiteral(database))'" + } + + static func foreignKeyCatalogQuery(table: String, database: String?) -> String { + """ + SELECT + kcu.CONSTRAINT_NAME, + kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME, + rc.DELETE_RULE, + rc.UPDATE_RULE, + kcu.TABLE_SCHEMA, + kcu.REFERENCED_TABLE_SCHEMA + FROM information_schema.KEY_COLUMN_USAGE kcu + JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA + WHERE kcu.TABLE_SCHEMA = \(databaseLiteral(database)) + AND kcu.TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))' + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + """ + } + + static func foreignKeys(fromCatalogRows rows: [[String?]]) -> [ForeignKeyInfo] { + rows.compactMap { row in + guard row.count >= 8, + let name = row[0], + let column = row[1], + let refTable = row[2], + let refColumn = row[3] else { return nil } + return ForeignKeyInfo( + name: name, + column: column, + referencedTable: refTable, + referencedColumn: refColumn, + referencedSchema: row[7] == row[6] ? nil : row[7], + onDelete: row[4] ?? "NO ACTION", + onUpdate: row[5] ?? "NO ACTION" + ) + } + } + + // The parser fills the source database into every unqualified key. Nil keeps a same-database key + // the same as the catalog read gives it. + static func foreignKeys(declaredIn createTable: String, database: String?, omittedAction: String) -> [ForeignKeyInfo] { + MySQLForeignKeyClause.parse(createTable: createTable, database: database ?? "", omittedAction: omittedAction) + .map { key in + ForeignKeyInfo( + name: key.name, + column: key.column, + referencedTable: key.referencedTable, + referencedColumn: key.referencedColumn, + referencedSchema: key.referencedSchema == database ? nil : key.referencedSchema, + onDelete: key.onDelete, + onUpdate: key.onUpdate + ) + } + } + + private static func qualifiedTable(_ table: String, database: String?) -> String { + let name = quotedIdentifier(table) + guard let database, !database.isEmpty else { return name } + return "\(quotedIdentifier(database)).\(name)" + } + + private static func quotedIdentifier(_ name: String) -> String { + "`\(name.replacingOccurrences(of: "`", with: "``"))`" + } +} + nonisolated enum MySQLSessionTransaction { static func state(infoResult: my_bool, serverStatus: UInt32) -> DriverTransactionState { guard infoResult == 0 else { return .unknown } diff --git a/apple/TableProMobile/TableProMobile/Drivers/OracleDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/OracleDriver.swift index 5cbb6a0d9e..ac9c2fcc39 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/OracleDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/OracleDriver.swift @@ -281,7 +281,9 @@ nonisolated final class OracleDriver: DatabaseDriver, @unchecked Sendable { name: $0.constraintName, column: $0.columnName, referencedTable: $0.referencedTable, - referencedColumn: $0.referencedColumn + referencedColumn: $0.referencedColumn, + referencedSchema: $0.referencedSchema, + onDelete: $0.deleteRule ) } } diff --git a/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index 1cf2833e9b..783123db5d 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -2,6 +2,7 @@ import Foundation import SQLite3 import TableProDatabase import TableProModels +import TableProPluginKit nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { private let source: LocalDatabaseFileSource @@ -226,24 +227,48 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { let safe = table.replacingOccurrences(of: "'", with: "''") let raw = try await actor.execute("PRAGMA foreign_key_list('\(safe)')") - - return raw.rows.compactMap { row in - guard row.count >= 5, - let refTable = row[2], - let fromCol = row[3], - let toCol = row[4] else { return nil } - - return ForeignKeyInfo( - name: "fk_\(table)_\(row[0] ?? "0")", - column: fromCol, - referencedTable: refTable, - referencedColumn: toCol, - onDelete: row.count >= 7 ? (row[6] ?? "NO ACTION") : "NO ACTION", - onUpdate: row.count >= 6 ? (row[5] ?? "NO ACTION") : "NO ACTION" + guard !raw.rows.isEmpty else { return [] } + + let parentKeys = try await primaryKeys(ofTables: raw.rows.compactMap { $0.count > 2 ? $0[2] : nil }) + // Positional names only: a declared name can equal another key's fk__, and keys are grouped by name. + return SQLiteForeignKeyGrouping.infos( + table: table, + pragmaRows: raw.rows.map { $0.map(PluginCellValue.fromOptional) }, + createTableSQL: nil, + primaryKeysByTable: parentKeys + ).map { key in + ForeignKeyInfo( + name: key.name, + column: key.column, + referencedTable: key.referencedTable, + referencedColumn: key.referencedColumn, + onDelete: key.onDelete, + onUpdate: key.onUpdate ) } } + // The pragma reports a null parent column for a shorthand `REFERENCES parent`, which means the + // parent's primary key. Keyed by lower-cased name, as SQLiteForeignKeyGrouping looks them up. + // SQLite matches names case-insensitively for ASCII only, so both sides go through its own lower(). + private func primaryKeys(ofTables tables: [String]) async throws -> [String: [String]] { + let names = Set(tables) + guard !names.isEmpty else { return [:] } + let literals = names.sorted() + .map { "lower('\($0.replacingOccurrences(of: "'", with: "''"))')" } + .joined(separator: ", ") + let raw = try await actor.execute(""" + SELECT m.name, i.name + FROM sqlite_master m, pragma_table_info(m.name) i + WHERE m.type = 'table' AND lower(m.name) IN (\(literals)) AND i.pk > 0 + ORDER BY m.name, i.pk + """) + return raw.rows.reduce(into: [:]) { keys, row in + guard row.count >= 2, let table = row[0], let column = row[1] else { return } + keys[table.lowercased(), default: []].append(column) + } + } + func fetchDatabases() async throws -> [String] { [] } func switchDatabase(to name: String) async throws { diff --git a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 4270e1f75b..a1daab8045 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -28,8 +28,7 @@ nonisolated enum SQLBuilder { case .mssql: let order = orderBy.isEmpty ? "ORDER BY (SELECT NULL)" : orderBy return "\(order) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - /// Oracle pages without an `ORDER BY`. The `ORDER BY 1` this used to add fails with ORA-22848 when the first - /// column is a LOB, so such a table never opened. + // No ORDER BY: `ORDER BY 1` fails with ORA-22848 when the first column is a LOB. case .oracle: let trailing = "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" return orderBy.isEmpty ? trailing : "\(orderBy) \(trailing)" @@ -50,25 +49,27 @@ nonisolated enum SQLBuilder { return "SELECT * FROM \(quoted) \(pagination)" } - /// The single row a foreign key points at. - /// - /// The value reaches the literal through the driver's own escaper, like every other predicate - /// here. Doubling the quote by hand is not enough on a MySQL-family server, where a backslash - /// escapes the quote that follows it, so `a\' OR 1=1 -- ` closed the literal and returned an - /// unrelated row as the referenced one. + // Every pair of a composite key is matched: one column alone can return another tenant's row. static func buildSingleRowLookup( table: String, schema: String?, - column: String, - value: String, + key: [(column: String, value: String)], type: DatabaseType, driver: any DatabaseDriver ) -> String { let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type) - let quotedColumn = quoteIdentifier(column, for: type) - let literal = driver.escapeStringLiteral(value) + let predicate = key.isEmpty ? "1 = 0" : keyPredicate(key, type: type, driver: driver) let pagination = paginationClause(orderBy: "", limit: 1, offset: 0, for: type) - return "SELECT * FROM \(quotedTable) WHERE \(quotedColumn) = '\(literal)' \(pagination)" + return "SELECT * FROM \(quotedTable) WHERE \(predicate) \(pagination)" + } + + // Through the driver's escaper: on MySQL a backslash escapes the next quote, so a hand-doubled `a\' OR 1=1 -- ` gets out. + private static func keyPredicate( + _ key: [(column: String, value: String)], type: DatabaseType, driver: any DatabaseDriver + ) -> String { + key.map { + "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" + }.joined(separator: " AND ") } static func buildDelete( @@ -79,9 +80,7 @@ nonisolated enum SQLBuilder { primaryKeys: [(column: String, value: String)] ) -> String { let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type) - let predicate = primaryKeys.map { - "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" - }.joined(separator: " AND ") + let predicate = keyPredicate(primaryKeys, type: type, driver: driver) return "DELETE FROM \(quotedTable) WHERE \(predicate)" } @@ -99,9 +98,7 @@ nonisolated enum SQLBuilder { if let val { return "\(qcol) = '\(driver.escapeStringLiteral(val))'" } return "\(qcol) = NULL" }.joined(separator: ", ") - let predicate = primaryKeys.map { - "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" - }.joined(separator: " AND ") + let predicate = keyPredicate(primaryKeys, type: type, driver: driver) return "UPDATE \(quotedTable) SET \(assignments) WHERE \(predicate)" } @@ -125,9 +122,7 @@ nonisolated enum SQLBuilder { return "INSERT INTO \(qualifiedTable) (\(cols)) VALUES (\(vals))" } - /// Every column left on its database default. The MySQL dialect takes an empty column list; - /// the PostgreSQL family, SQLite and SQL Server take `DEFAULT VALUES`. Oracle accepts - /// neither, so it gets no statement. + // Oracle accepts neither `() VALUES ()` nor `DEFAULT VALUES`, so it gets no statement. static func buildAllDefaultsInsert(qualifiedTable: String, for type: DatabaseType) -> String? { if speaksMySQLDialect(type) { return "INSERT INTO \(qualifiedTable) () VALUES ()" @@ -164,11 +159,12 @@ nonisolated enum SQLBuilder { static func buildFilteredSelect( table: String, schema: String?, type: DatabaseType, filters: [TableFilter], logicMode: FilterLogicMode, + columns: [ColumnInfo] = [], limit: Int, offset: Int ) -> String { let dialect = dialectDescriptor(for: type) let generator = FilterSQLGenerator(dialect: dialect) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) var sql = "SELECT * FROM \(quoted)" @@ -180,12 +176,13 @@ nonisolated enum SQLBuilder { static func buildFilteredSelect( table: String, schema: String?, type: DatabaseType, filters: [TableFilter], logicMode: FilterLogicMode, + columns: [ColumnInfo] = [], sortState: SortState, limit: Int, offset: Int ) -> String { let dialect = dialectDescriptor(for: type) let generator = FilterSQLGenerator(dialect: dialect) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let orderBy = buildOrderByClause(sortState, for: type) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) @@ -197,11 +194,12 @@ nonisolated enum SQLBuilder { static func buildFilteredCount( table: String, schema: String?, type: DatabaseType, - filters: [TableFilter], logicMode: FilterLogicMode + filters: [TableFilter], logicMode: FilterLogicMode, + columns: [ColumnInfo] = [] ) -> String { let dialect = dialectDescriptor(for: type) let generator = FilterSQLGenerator(dialect: dialect) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) if whereClause.isEmpty { return "SELECT COUNT(*) FROM \(quoted)" @@ -215,13 +213,14 @@ nonisolated enum SQLBuilder { table: String, schema: String?, type: DatabaseType, searchText: String, searchColumns: [ColumnInfo], filters: [TableFilter] = [], logicMode: FilterLogicMode = .and, + columns: [ColumnInfo] = [], sortState: SortState = SortState(), limit: Int, offset: Int ) -> String { let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) let whereClause = buildSearchWhereClause( searchText: searchText, searchColumns: searchColumns, - filters: filters, logicMode: logicMode, type: type + filters: filters, logicMode: logicMode, columns: columns, type: type ) let orderBy = buildOrderByClause(sortState, for: type) let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) @@ -234,12 +233,13 @@ nonisolated enum SQLBuilder { static func buildSearchCount( table: String, schema: String?, type: DatabaseType, searchText: String, searchColumns: [ColumnInfo], - filters: [TableFilter] = [], logicMode: FilterLogicMode = .and + filters: [TableFilter] = [], logicMode: FilterLogicMode = .and, + columns: [ColumnInfo] = [] ) -> String { let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) let whereClause = buildSearchWhereClause( searchText: searchText, searchColumns: searchColumns, - filters: filters, logicMode: logicMode, type: type + filters: filters, logicMode: logicMode, columns: columns, type: type ) var sql = "SELECT COUNT(*) FROM \(quoted)" if !whereClause.isEmpty { sql += " \(whereClause)" } @@ -249,7 +249,7 @@ nonisolated enum SQLBuilder { private static func buildSearchWhereClause( searchText: String, searchColumns: [ColumnInfo], filters: [TableFilter], logicMode: FilterLogicMode, - type: DatabaseType + columns: [ColumnInfo], type: DatabaseType ) -> String { var whereParts: [String] = [] @@ -258,7 +258,9 @@ nonisolated enum SQLBuilder { whereParts.append(searchClause) } - if let filterConditions = filterConditions(filters: filters, logicMode: logicMode, type: type) { + if let filterConditions = filterConditions( + filters: filters, logicMode: logicMode, columns: columns, type: type + ) { whereParts.append("(\(filterConditions))") } @@ -267,11 +269,11 @@ nonisolated enum SQLBuilder { } private static func filterConditions( - filters: [TableFilter], logicMode: FilterLogicMode, type: DatabaseType + filters: [TableFilter], logicMode: FilterLogicMode, columns: [ColumnInfo], type: DatabaseType ) -> String? { let dialect = dialectDescriptor(for: type) let generator = FilterSQLGenerator(dialect: dialect) - let clause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let clause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) guard !clause.isEmpty else { return nil } let wherePrefix = "WHERE " return clause.hasPrefix(wherePrefix) @@ -348,12 +350,9 @@ nonisolated enum SQLBuilder { dialectDescriptor(for: type).caseSensitivityStyle } - /// Mirrors what each driver's plugin declares on the Mac, because iOS links its drivers - /// directly and has no plugin bundle to ask. The default arm is the trap: an omitted type gets - /// `caseSensitivityStyle` `.unsupported`, which resolves to plain `LIKE` and to a case - /// sensitivity toggle the UI will not offer, so a filter silently returns fewer rows than the - /// same filter on the Mac. `SQLDialectParityTests` is what stops a new driver landing here - /// without an arm. + // Mirrors each driver plugin's dialect on the Mac: iOS has no plugin bundle to ask. A type that falls to the + // default arm gets `.unsupported` case sensitivity, plain `LIKE`, and fewer rows than the Mac; + // `SQLDialectParityTests` catches a driver missing an arm. private static func dialectDescriptor(for type: DatabaseType) -> SQLDialectDescriptor { if speaksMySQLDialect(type) { return SQLDialectDescriptor( diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..a62acf068d 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -11498,6 +11498,16 @@ } } }, + "No row found in %@ where %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No row found in %1$@ where %2$@." + } + } + } + }, "No Tables" : { "localizations" : { "ko" : { @@ -12307,6 +12317,9 @@ } } } + }, + "Open Table" : { + }, "Open this connection in TablePro to check the server's SSH host key first." : { "localizations" : { @@ -20223,6 +20236,9 @@ } } } + }, + "This row's primary key is not plain text, so it cannot identify the row." : { + }, "This server has no password set for the default user. Clear the Password field." : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Models/ForeignKeyReference.swift b/apple/TableProMobile/TableProMobile/Models/ForeignKeyReference.swift new file mode 100644 index 0000000000..f6b68e0cc3 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Models/ForeignKeyReference.swift @@ -0,0 +1,106 @@ +import Foundation +import TableProModels + +nonisolated struct ForeignKeyReference: Hashable, Identifiable, Sendable { + nonisolated struct Part: Hashable, Sendable { + let column: String + let value: String + } + + let table: String + let schema: String? + let parts: [Part] + + var id: Self { self } + + static func make( + forColumn column: String, + foreignKeys: [ForeignKeyInfo], + columns: [ColumnInfo], + cells: [Cell], + sourceSchema: String? + ) -> Self? { + guard let target = columnIndex(named: column, in: columns) else { return nil } + for key in keys(in: foreignKeys) where key.contains(where: { columnIndex(named: $0.column, in: columns) == target }) { + if let reference = resolve(key, columns: columns, cells: cells, sourceSchema: sourceSchema) { + return reference + } + } + return nil + } + + static func all( + foreignKeys: [ForeignKeyInfo], + columns: [ColumnInfo], + cells: [Cell], + sourceSchema: String? + ) -> [(name: String, column: String, reference: Self)] { + keys(in: foreignKeys).compactMap { key in + guard let first = key.first, + let reference = resolve(key, columns: columns, cells: cells, sourceSchema: sourceSchema) + else { return nil } + return (name: first.name, column: key.map(\.column).joined(separator: ", "), reference: reference) + } + } + + func displaySchema(current: String?) -> String? { + guard let schema, schema != current else { return nil } + return schema + } + + // A key can only point at a base table, and a table outside the active schema is not in the catalog. + func catalogTable(in tables: [TableInfo], currentSchema: String?) -> TableInfo { + let fallback = TableInfo(name: table, type: .table) + guard displaySchema(current: currentSchema) == nil else { return fallback } + if let exact = tables.first(where: { $0.name == table }) { return exact } + let folded = tables.filter { $0.name.caseInsensitiveCompare(table) == .orderedSame } + return folded.count == 1 ? folded[0] : fallback + } + + // The browser drops an invalid filter, so a blank value would open the whole table. + func filters() -> [TableFilter]? { + let filters = parts.map { TableFilter(columnName: $0.column, filterOperator: .equal, value: $0.value) } + guard !filters.isEmpty, filters.allSatisfy(\.isValid) else { return nil } + return filters + } + + private static func keys(in foreignKeys: [ForeignKeyInfo]) -> [[ForeignKeyInfo]] { + var groups: [[ForeignKeyInfo]] = [] + for foreignKey in foreignKeys { + if let index = groups.firstIndex(where: { sameKey($0[0], foreignKey) }) { + groups[index].append(foreignKey) + } else { + groups.append([foreignKey]) + } + } + return groups + } + + private static func sameKey(_ lhs: ForeignKeyInfo, _ rhs: ForeignKeyInfo) -> Bool { + lhs.name == rhs.name && lhs.referencedTable == rhs.referencedTable && lhs.referencedSchema == rhs.referencedSchema + } + + // Only a `.text` cell holds the value itself; a binary or truncated cell holds a placeholder. + private static func resolve( + _ key: [ForeignKeyInfo], + columns: [ColumnInfo], + cells: [Cell], + sourceSchema: String? + ) -> Self? { + guard let first = key.first else { return nil } + var parts: [Part] = [] + for pair in key { + guard let index = columnIndex(named: pair.column, in: columns), index < cells.count, + case .text(let value) = cells[index] + else { return nil } + parts.append(Part(column: pair.referencedColumn, value: value)) + } + return Self(table: first.referencedTable, schema: first.referencedSchema ?? sourceSchema, parts: parts) + } + + private static func columnIndex(named name: String, in columns: [ColumnInfo]) -> Int? { + if let exact = columns.firstIndex(where: { $0.name == name }) { return exact } + let folded = columns.indices.filter { columns[$0].name.caseInsensitiveCompare(name) == .orderedSame } + return folded.count == 1 ? folded[0] : nil + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 0d844e18c6..0351de3f79 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -52,6 +52,16 @@ final class DataBrowserViewModel { @ObservationIgnored private var fetchTask: Task? @ObservationIgnored private var searchTask: Task? @ObservationIgnored private var keyReadTask: Task? + @ObservationIgnored private var loadGeneration = 0 + @ObservationIgnored private var hasSeededFilters = false + @ObservationIgnored private var seededKeyColumns: [String] = [] + @ObservationIgnored private var singleRowMatch = SingleRowMatch.inactive + + private enum SingleRowMatch { + case inactive + case awaitingFirstLoad + case found + } init(windowCapacity: Int = 1_000) { self.buffer = StreamingResultBuffer(capacity: windowCapacity) @@ -100,9 +110,28 @@ final class DataBrowserViewModel { self.schema = schema } + // Only the first call counts, so filters the user changed afterwards survive the view's task running again. + func seedFilters(_ seeded: [TableFilter]) { + guard !hasSeededFilters else { return } + hasSeededFilters = true + filters = seeded + seededKeyColumns = seeded.map(\.columnName) + filterLogicMode = .and + singleRowMatch = hasActiveFilters ? .awaitingFirstLoad : .inactive + } + + func takeSingleRowMatch() -> Bool { + guard case .found = singleRowMatch else { return false } + singleRowMatch = .inactive + return true + } + // MARK: - Load func load(isInitial: Bool = false) async { + loadGeneration += 1 + let generation = loadGeneration + guard let session, let table else { loadError = AppError( category: .config, @@ -111,8 +140,7 @@ final class DataBrowserViewModel { recovery: String(localized: "Go back and reconnect to the database."), underlying: nil ) - isLoading = false - isPageLoading = false + finishLoad() return } @@ -124,18 +152,40 @@ final class DataBrowserViewModel { loadError = nil if let reader = TableBrowseMode.keyContentsReader(of: session.driver) { - await loadKeyContents(reader: reader, key: table.name) + await loadKeyContents(reader: reader, key: table.name, generation: generation) } else { - await loadRows(session: session, table: table, isInitial: isInitial) + await loadRows(session: session, table: table, isInitial: isInitial, generation: generation) } + guard isCurrent(generation) else { return } + finishLoad() + } + + private func isCurrent(_ generation: Int) -> Bool { + generation == loadGeneration + } + + private func finishLoad() { isLoading = false isPageLoading = false + guard case .awaitingFirstLoad = singleRowMatch else { return } + singleRowMatch = loadError == nil && legacyRows.count == 1 ? .found : .inactive + } + + // The preview quotes every key value, and a column without a type (a typeless SQLite key) keeps 5 and '5' + // apart, so such a seeded key column compares as text here too and the browser lists the previewed row. + private var filterColumns: [ColumnInfo] { + let untyped = seededKeyColumns.filter { + FilterColumnKind.kind(of: FilterColumnKind.column(named: $0, in: columnDetails)) == nil + } + return untyped.map { ColumnInfo(name: $0, typeName: "TEXT") } + columnDetails } - private func loadRows(session: ConnectionSession, table: TableInfo, isInitial: Bool) async { + private func loadRows(session: ConnectionSession, table: TableInfo, isInitial: Bool, generation: Int) async { do { if columnDetails.isEmpty || isInitial { - columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) + let fetched = try await session.driver.fetchColumns(table: table.name, schema: schema) + guard isCurrent(generation) else { return } + columnDetails = fetched } let pkColumns = columnDetails.filter(\.isPrimaryKey).map(\.name) @@ -143,9 +193,12 @@ final class DataBrowserViewModel { let query = buildSelectQuery(table: table) let driver = session.driver - await loadPage(options: pageOptions(lazyContext: lazyContext), startedAt: Date()) { options in + await loadPage( + options: pageOptions(lazyContext: lazyContext), startedAt: Date(), generation: generation + ) { options in driver.executeStreaming(query: query, options: options) } + guard isCurrent(generation) else { return } if case .error(let err) = phase { loadError = err @@ -155,16 +208,19 @@ final class DataBrowserViewModel { if foreignKeys.isEmpty || isInitial { do { - foreignKeys = try await session.driver.fetchForeignKeys(table: table.name, schema: nil) + let fetched = try await session.driver.fetchForeignKeys(table: table.name, schema: schema) + guard isCurrent(generation) else { return } + foreignKeys = fetched } catch { Self.logger.warning("Failed to fetch foreign keys: \(error.localizedDescription, privacy: .private)") } } - if pagination.totalRows == nil { - await fetchTotalRows(session: session, table: table) + if isCurrent(generation), pagination.totalRows == nil { + await fetchTotalRows(session: session, table: table, generation: generation) } } catch { + guard isCurrent(generation) else { return } loadError = ErrorClassifier.classify( error, context: ErrorContext(operation: "loadData", databaseType: databaseType, host: host) @@ -172,7 +228,7 @@ final class DataBrowserViewModel { } } - private func loadKeyContents(reader: any KeyContentsBrowsing, key: String) async { + private func loadKeyContents(reader: any KeyContentsBrowsing, key: String, generation: Int) async { let start = Date() let limit = pagination.pageSize let offset = pagination.currentOffset @@ -181,15 +237,16 @@ final class DataBrowserViewModel { keyReadTask = read do { let page = try await read.value - guard keyReadTask == read, !read.isCancelled else { return } + guard keyReadTask == read, !read.isCancelled, isCurrent(generation) else { return } columnDetails = page.result.columns foreignKeys = [] pagination.totalRows = page.totalCount let result = page.result - await loadPage(options: pageOptions(lazyContext: nil), startedAt: start) { options in + await loadPage(options: pageOptions(lazyContext: nil), startedAt: start, generation: generation) { options in QueryResultStreaming.stream(options: options) { result } } + guard isCurrent(generation) else { return } if case .error(let err) = phase { loadError = err @@ -197,7 +254,7 @@ final class DataBrowserViewModel { } settleTotalRowsFromShortPage() } catch { - guard keyReadTask == read, !read.isCancelled else { return } + guard keyReadTask == read, !read.isCancelled, isCurrent(generation) else { return } loadError = ErrorClassifier.classify( error, context: ErrorContext(operation: "loadKeyContents", databaseType: databaseType, host: host) @@ -225,7 +282,7 @@ final class DataBrowserViewModel { return SQLBuilder.buildSearchSelect( table: table.name, schema: schema, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), - filters: filters, logicMode: filterLogicMode, + filters: filters, logicMode: filterLogicMode, columns: filterColumns, sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) @@ -233,7 +290,7 @@ final class DataBrowserViewModel { if hasActiveFilters { return SQLBuilder.buildFilteredSelect( table: table.name, schema: schema, type: databaseType, - filters: filters, logicMode: filterLogicMode, + filters: filters, logicMode: filterLogicMode, columns: filterColumns, sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) @@ -270,24 +327,25 @@ final class DataBrowserViewModel { } } - private func fetchTotalRows(session: ConnectionSession, table: TableInfo) async { + private func fetchTotalRows(session: ConnectionSession, table: TableInfo, generation: Int) async { do { let countQuery: String if hasActiveSearch { countQuery = SQLBuilder.buildSearchCount( table: table.name, schema: schema, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), - filters: filters, logicMode: filterLogicMode + filters: filters, logicMode: filterLogicMode, columns: filterColumns ) } else if hasActiveFilters { countQuery = SQLBuilder.buildFilteredCount( table: table.name, schema: schema, type: databaseType, - filters: filters, logicMode: filterLogicMode + filters: filters, logicMode: filterLogicMode, columns: filterColumns ) } else { countQuery = SQLBuilder.buildCount(table: table.name, schema: schema, type: databaseType) } let countResult = try await session.driver.execute(query: countQuery) + guard isCurrent(generation) else { return } if let firstRow = countResult.rows.first, let firstCol = firstRow.first { pagination.totalRows = Int(firstCol ?? "0") } @@ -406,14 +464,12 @@ final class DataBrowserViewModel { } } - func primaryKeyValues(for row: [String?]) -> [(column: String, value: String)] { - columnDetails.compactMap { col in - guard col.isPrimaryKey, - let colIndex = columns.firstIndex(where: { $0.name == col.name }), - colIndex < row.count, - let value = row[colIndex] else { return nil } - return (column: col.name, value: value) - } + func primaryKeyValues(forRowAt index: Int) -> [(column: String, value: String)]? { + let rows = window.rows + guard rows.indices.contains(index) else { return nil } + return RowDetailViewModel.primaryKeyValues( + cells: rows[index].cells, columns: columns, columnDetails: columnDetails + ) } // MARK: - Lazy Cell Loading @@ -424,7 +480,7 @@ final class DataBrowserViewModel { } let predicate = predicates.joined(separator: " AND ") let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType) - let table = SQLBuilder.quoteIdentifier(ref.table, for: databaseType) + let table = SQLBuilder.qualifiedIdentifier(table: ref.table, schema: schema, for: databaseType) let query: String switch databaseType { case .mssql: @@ -476,6 +532,7 @@ final class DataBrowserViewModel { private func loadPage( options: StreamOptions, startedAt start: Date, + generation: Int, stream makeStream: @escaping @Sendable (StreamOptions) -> AsyncThrowingStream ) async { fetchTask?.cancel() @@ -489,6 +546,7 @@ final class DataBrowserViewModel { if Task.isCancelled { break } self.buffer.apply(element) } + guard self.isCurrent(generation) else { return } self.buffer.flush() self.executionTime = Date().timeIntervalSince(start) if let reason = self.buffer.truncation { @@ -497,6 +555,7 @@ final class DataBrowserViewModel { self.phase = .loaded } } catch { + guard self.isCurrent(generation) else { return } self.buffer.flush() self.phase = .error(self.classify(error: error)) } diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift new file mode 100644 index 0000000000..4703ba4684 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift @@ -0,0 +1,150 @@ +import Foundation +import os +import TableProDatabase +import TableProModels + +@MainActor +@Observable +final class ForeignKeyPreviewModel { + enum Phase { + case loading + case loaded + case notFound + case failed(AppError) + } + + struct Field: Identifiable { + let id: Int + let name: String + let value: String? + let reference: ForeignKeyReference? + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeyPreviewModel") + + let reference: ForeignKeyReference + private(set) var phase: Phase = .loading + private(set) var fields: [Field] = [] + + @ObservationIgnored private let session: ConnectionSession? + @ObservationIgnored private let databaseType: DatabaseType + @ObservationIgnored private var isRunning = false + + init(reference: ForeignKeyReference, session: ConnectionSession?, databaseType: DatabaseType) { + self.reference = reference + self.session = session + self.databaseType = databaseType + } + + var isLoaded: Bool { + if case .loaded = phase { return true } + return false + } + + var canOpen: Bool { isLoaded && reference.filters() != nil } + + var notFoundMessage: String { Self.notFoundMessage(for: reference) } + + nonisolated static func notFoundMessage( + for reference: ForeignKeyReference, + locale: Locale = .current, + bundle: Bundle = .main + ) -> String { + let formatter = ListFormatter() + formatter.locale = locale + let pairs = reference.parts.map { "\($0.column) = '\($0.value)'" } + let condition = formatter.string(from: pairs) ?? pairs.joined(separator: ", ") + return String( + format: String(localized: "No row found in %@ where %@.", bundle: bundle, locale: locale), + reference.table, + condition + ) + } + + // The page's task runs again when a pushed page pops back to it, and the row is already here. + func load() async { + guard case .loading = phase, !isRunning else { return } + guard let session else { + phase = .failed(AppError( + category: .config, + title: String(localized: "Not Connected"), + message: String(localized: "No active database session."), + recovery: nil, + underlying: nil + )) + return + } + isRunning = true + defer { isRunning = false } + + let driver = session.driver + let query = SQLBuilder.buildSingleRowLookup( + table: reference.table, + schema: reference.schema, + key: reference.parts.map { (column: $0.column, value: $0.value) }, + type: databaseType, + driver: driver + ) + let result: QueryResult + do { + result = try await driver.execute(query: query) + } catch is CancellationError { + return + } catch { + Self.logger.warning("Foreign key preview failed: \(error.localizedDescription, privacy: .private)") + phase = .failed(AppError( + category: .query, + title: String(localized: "Could Not Load the Referenced Row"), + message: error.localizedDescription, + recovery: nil, + underlying: error + )) + return + } + guard let row = result.rows.first else { + phase = .notFound + return + } + + var foreignKeys: [ForeignKeyInfo] = [] + do { + foreignKeys = try await driver.fetchForeignKeys(table: reference.table, schema: reference.schema) + } catch { + Self.logger.warning("Foreign key preview could not read keys: \(error.localizedDescription, privacy: .private)") + } + + fields = Self.fields(columns: result.columns, row: row, foreignKeys: foreignKeys, schema: reference.schema) + phase = .loaded + } + + // Typed through `Cell.from`, so a binary column's text form never becomes a key value. + private static func fields( + columns: [ColumnInfo], + row: [String?], + foreignKeys: [ForeignKeyInfo], + schema: String? + ) -> [Field] { + let options = StreamOptions(textTruncationBytes: .max) + let cells = row.indices.map { index in + Cell.from( + legacyValue: row[index], + columnTypeName: index < columns.count ? columns[index].typeName : nil, + options: options + ) + } + return zip(columns, row).enumerated().map { index, pair in + Field( + id: index, + name: pair.0.name, + value: pair.1, + reference: ForeignKeyReference.make( + forColumn: pair.0.name, + foreignKeys: foreignKeys, + columns: columns, + cells: cells, + sourceSchema: schema + ) + ) + } + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index fd5260ef88..a18e0f93d0 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -79,7 +79,27 @@ final class RowDetailViewModel { var canEdit: Bool { table != nil && session != nil && !columnDetails.isEmpty && allowsRowEditing && !safeModeLevel.blocksWrites - && columnDetails.contains(where: { $0.isPrimaryKey }) + && currentPrimaryKeyValues != nil + } + + private var currentPrimaryKeyValues: [(column: String, value: String)]? { + Self.primaryKeyValues(cells: cells(at: currentIndex), columns: columns, columnDetails: columnDetails) + } + + // A binary or cut-off key cell holds placeholder text, which as a predicate matches no row, so the row has no key. + static func primaryKeyValues( + cells: [Cell], columns: [ColumnInfo], columnDetails: [ColumnInfo] + ) -> [(column: String, value: String)]? { + let keyColumns = columnDetails.filter(\.isPrimaryKey) + guard !keyColumns.isEmpty else { return nil } + var values: [(column: String, value: String)] = [] + for key in keyColumns { + guard let index = columns.firstIndex(where: { $0.name == key.name }), + index < cells.count, + case .text(let value) = cells[index] else { return nil } + values.append((column: key.name, value: value)) + } + return values } var supportsLazyLoading: Bool { loadFullValueProvider != nil } @@ -239,14 +259,7 @@ final class RowDetailViewModel { pendingWriteConfirmation = false writeGate.cancel() - let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in - guard col.isPrimaryKey else { return nil } - let colIndex = columns.firstIndex(where: { $0.name == col.name }) - guard let colIndex, colIndex < currentRow.count, let value = currentRow[colIndex] else { return nil } - return (column: col.name, value: value) - } - - guard !pkValues.isEmpty else { + guard columnDetails.contains(where: { $0.isPrimaryKey }) else { operationError = AppError( category: .config, title: String(localized: "Cannot Save"), @@ -257,6 +270,17 @@ final class RowDetailViewModel { return false } + guard let pkValues = currentPrimaryKeyValues else { + operationError = AppError( + category: .config, + title: String(localized: "Cannot Save"), + message: String(localized: "This row's primary key is not plain text, so it cannot identify the row."), + recovery: nil, + underlying: nil + ) + return false + } + let changes = editedChanges guard !changes.isEmpty else { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/FKPreviewView.swift b/apple/TableProMobile/TableProMobile/Views/Components/FKPreviewView.swift index f34811154f..ed7655a0be 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/FKPreviewView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/FKPreviewView.swift @@ -1,99 +1,65 @@ -import os import SwiftUI import TableProDatabase import TableProModels struct FKPreviewItem: Identifiable { let id = UUID() - let fk: ForeignKeyInfo - let value: String + let reference: ForeignKeyReference } struct FKPreviewView: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "FKPreviewView") - @Environment(\.dismiss) private var dismiss - let fk: ForeignKeyInfo - let value: String + + let reference: ForeignKeyReference let session: ConnectionSession? let databaseType: DatabaseType + let currentSchema: String? + let allowsOpen: Bool + let onOpen: ((ForeignKeyReference) -> Void)? - @State private var columns: [ColumnInfo] = [] - @State private var row: [String?]? - @State private var isLoading = true - @State private var errorMessage: String? + init( + reference: ForeignKeyReference, + session: ConnectionSession?, + databaseType: DatabaseType, + currentSchema: String? = nil, + allowsOpen: Bool = true, + onOpen: ((ForeignKeyReference) -> Void)? = nil + ) { + self.reference = reference + self.session = session + self.databaseType = databaseType + self.currentSchema = currentSchema + self.allowsOpen = allowsOpen + self.onOpen = onOpen + } var body: some View { NavigationStack { - Group { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let row { - List { - ForEach(Array(zip(columns, row).enumerated()), id: \.offset) { _, pair in - LabeledContent { - Text(verbatim: pair.1 ?? "NULL") - .foregroundStyle(pair.1 == nil ? .secondary : .primary) - .textSelection(.enabled) - } label: { - Text(pair.0.name) - } - } - } - .listStyle(.insetGrouped) - } else if let errorMessage { - ContentUnavailableView( - "Could Not Load the Referenced Row", - systemImage: "exclamationmark.triangle", - description: Text(verbatim: errorMessage) - ) - } else { - ContentUnavailableView( - "No Referenced Row", - systemImage: "arrow.right.circle", - description: Text("No row found in \(fk.referencedTable) where \(fk.referencedColumn) = '\(value)'") - ) + page(for: reference) + .navigationDestination(for: ForeignKeyReference.self) { next in + page(for: next) } - } - .navigationTitle(fk.referencedTable) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - CloseButton { dismiss() } - } - } - .task { await loadReferencedRow() } } } - private func loadReferencedRow() async { - guard let session else { - isLoading = false - return - } + // A pushed page's own dismiss pops it, so every page closes the sheet through this view's. + private func page(for reference: ForeignKeyReference) -> some View { + ForeignKeyPreviewPage( + reference: reference, + session: session, + databaseType: databaseType, + currentSchema: currentSchema, + allowsOpen: allowsOpen, + onOpen: openAndClose, + close: { dismiss() } + ) + } - do { - /// SQL Server and Oracle reject `LIMIT`, so the whole statement comes from the shared - /// builder rather than being written out here. Hardcoding the clause made every preview - /// on those two engines fail, and the failure looked exactly like a key with no - /// matching row; escaping the value by hand let one break out of its literal. - let query = SQLBuilder.buildSingleRowLookup( - table: fk.referencedTable, - schema: fk.referencedSchema, - column: fk.referencedColumn, - value: value, - type: databaseType, - driver: session.driver - ) - let result = try await session.driver.execute(query: query) - columns = result.columns - row = result.rows.first - } catch { - Self.logger.warning("FK preview failed: \(error.localizedDescription, privacy: .public)") - row = nil - errorMessage = error.localizedDescription + private var openAndClose: ((ForeignKeyReference) -> Void)? { + guard let onOpen else { return nil } + return { reference in + onOpen(reference) + dismiss() } - isLoading = false } } diff --git a/apple/TableProMobile/TableProMobile/Views/Components/ForeignKeyPreviewPage.swift b/apple/TableProMobile/TableProMobile/Views/Components/ForeignKeyPreviewPage.swift new file mode 100644 index 0000000000..e1af040985 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/ForeignKeyPreviewPage.swift @@ -0,0 +1,111 @@ +import SwiftUI +import TableProDatabase +import TableProModels + +struct ForeignKeyPreviewPage: View { + let currentSchema: String? + let allowsOpen: Bool + let onOpen: ((ForeignKeyReference) -> Void)? + let close: () -> Void + + @State private var model: ForeignKeyPreviewModel + + init( + reference: ForeignKeyReference, + session: ConnectionSession?, + databaseType: DatabaseType, + currentSchema: String?, + allowsOpen: Bool, + onOpen: ((ForeignKeyReference) -> Void)?, + close: @escaping () -> Void + ) { + self.currentSchema = currentSchema + self.allowsOpen = allowsOpen + self.onOpen = onOpen + self.close = close + _model = State(initialValue: ForeignKeyPreviewModel( + reference: reference, + session: session, + databaseType: databaseType + )) + } + + private var reference: ForeignKeyReference { model.reference } + + var body: some View { + content + .tableTitle(reference.table, schema: reference.displaySchema(current: currentSchema)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .task { await model.load() } + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded: + List { + ForEach(model.fields) { field in + fieldRow(field) + } + } + .listStyle(.insetGrouped) + case .notFound: + ContentUnavailableView( + "No Referenced Row", + systemImage: "arrow.right.circle", + description: Text(verbatim: model.notFoundMessage) + ) + case .failed(let error): + ErrorView(error: error) + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + CloseButton(action: close) + } + if let onOpen { + ToolbarItem(placement: .bottomBar) { + // A toolbar draws a Label as its icon alone from iOS 26, and an arrow does not say what opens. + Button("Open Table") { + onOpen(reference) + } + .disabled(!allowsOpen || !model.canOpen) + } + } + } + + @ViewBuilder + private func fieldRow(_ field: ForeignKeyPreviewModel.Field) -> some View { + if let next = field.reference, let value = field.value { + NavigationLink(value: next) { + LabeledContent { + Text(verbatim: value) + } label: { + Text(verbatim: field.name) + Text(verbatim: next.table) + } + } + .contextMenu { + Button { + ClipboardExporter.copyToClipboard(value) + } label: { + Label("Copy Value", systemImage: "doc.on.doc") + } + } + } else { + LabeledContent { + Text(verbatim: field.value ?? "NULL") + .foregroundStyle(field.value == nil ? .secondary : .primary) + .textSelection(.enabled) + } label: { + Text(verbatim: field.name) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/TableTitle.swift b/apple/TableProMobile/TableProMobile/Views/Components/TableTitle.swift new file mode 100644 index 0000000000..0c15b95f2e --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/TableTitle.swift @@ -0,0 +1,26 @@ +import SwiftUI + +extension View { + func tableTitle(_ table: String, schema: String?) -> some View { + modifier(TableTitle(table: table, schema: schema)) + } +} + +private struct TableTitle: ViewModifier { + let table: String + let schema: String? + + func body(content: Content) -> some View { + if let schema { + if #available(iOS 26.0, *) { + content + .navigationTitle(Text(verbatim: table)) + .navigationSubtitle(Text(verbatim: schema)) + } else { + content.navigationTitle(Text(verbatim: "\(schema).\(table)")) + } + } else { + content.navigationTitle(Text(verbatim: table)) + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift index 565fa0870b..5084078b01 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -166,7 +166,6 @@ struct ConnectedView: View { if let table = coordinator.selectedTable { DataBrowserView(table: table) .environment(coordinator) - .id(table) } else { ContentUnavailableView( "No Table Selected", @@ -175,6 +174,7 @@ struct ConnectedView: View { ) } } + .id(TableBrowserIdentity(coordinator.selectedTable, coordinator.activeDatabase)) } .navigationSplitViewStyle(.balanced) } @@ -304,7 +304,7 @@ struct ConnectedView: View { Label(coordinator.activeDatabase, systemImage: "cylinder.split.1x2") .font(.subheadline) } - .disabled(coordinator.isSwitching) + .disabled(coordinator.isSwitching || presenter.isHeldByEditor) } } if coordinator.supportsSchemas && coordinator.schemas.count > 1 { diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index c1018f6f0f..8e36da9eb8 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -8,6 +8,12 @@ struct DataBrowserView: View { @Environment(ConnectionCoordinator.self) private var coordinator @Environment(\.horizontalSizeClass) private var horizontalSizeClass let table: TableInfo + private let reference: ForeignKeyReference? + + init(table: TableInfo, reference: ForeignKeyReference? = nil) { + self.table = table + self.reference = reference + } private var connection: DatabaseConnection { coordinator.connection } private var session: ConnectionSession? { coordinator.session } @@ -22,6 +28,7 @@ struct DataBrowserView: View { @State private var showShareSheet = false @State private var showDeleteConfirmation = false @State private var showStructure = false + @State private var showMatchedRow = false @State private var showGoToPage = false @State private var goToPageInput = "" @State private var deleteTarget: [(column: String, value: String)]? @@ -34,6 +41,15 @@ struct DataBrowserView: View { coordinator.supportsSchemas ? coordinator.activeSchema : nil } + private var browserSchema: String? { + if let reference { return reference.schema } + return activeSchema + } + + private var displaySchema: String? { + reference?.displaySchema(current: activeSchema) + } + /// Asked of the kind rather than compared against the two view cases, so a MariaDB sequence, /// which refuses UPDATE and DELETE with ERROR 1031, is read-only here as it is on Mac. private var allowsRowEditing: Bool { table.type.allowsRowEditing } @@ -83,7 +99,8 @@ struct DataBrowserView: View { var body: some View { @Bindable var viewModel = viewModel return searchableContent - .userActivity(SceneIntent.viewTableActivity, isActive: appState.offersHandoff(for: connection)) { activity in + // The root browser stays mounted under this one; SwiftUI leaves two advertisers of one type unspecified. + .userActivity(SceneIntent.viewTableActivity, isActive: reference == nil && appState.offersHandoff(for: connection)) { activity in activity.title = table.name activity.isEligibleForHandoff = true activity.userInfo = [ @@ -100,11 +117,19 @@ struct DataBrowserView: View { .task { viewModel.attach( session: session, table: table, databaseType: connection.type, - host: connection.host, schema: activeSchema + host: connection.host, schema: browserSchema ) + if let filters = reference?.filters() { + viewModel.seedFilters(filters) + } await viewModel.load(isInitial: true) + guard reference != nil, !Task.isCancelled else { return } + if viewModel.takeSingleRowMatch() { + showMatchedRow = true + } } .onChange(of: activeSchema) { _, newSchema in + guard reference == nil else { return } viewModel.attach( session: session, table: table, databaseType: connection.type, host: connection.host, schema: newSchema @@ -123,14 +148,13 @@ struct DataBrowserView: View { onClear: { Task { await viewModel.clearFilters() } } ) } - .sheet(item: $fkPreviewItem) { item in - FKPreviewView( - fk: item.fk, - value: item.value, - session: session, - databaseType: connection.type - ) - } + .foreignKeyPreview( + item: $fkPreviewItem, + session: session, + coordinator: coordinator, + databaseType: connection.type, + allowsOpen: true + ) .sheet(isPresented: $showShareSheet) { ActivityViewController(items: [shareText]) } @@ -178,7 +202,10 @@ struct DataBrowserView: View { .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) .navigationDestination(isPresented: $showStructure) { - StructureView(table: table, session: session, databaseType: connection.type) + StructureView(table: table, session: session, databaseType: connection.type, schema: viewModel.schema) + } + .navigationDestination(isPresented: $showMatchedRow) { + rowDetail(index: 0) } .alert("Go to Page", isPresented: $showGoToPage) { TextField("Page number", text: $goToPageInput) @@ -203,11 +230,11 @@ struct DataBrowserView: View { private var searchableContent: some View { if !browsesSQLRows { content - .navigationTitle(table.name) + .tableTitle(table.name, schema: displaySchema) .navigationBarTitleDisplayMode(.inline) } else { content - .navigationTitle(table.name) + .tableTitle(table.name, schema: displaySchema) .navigationBarTitleDisplayMode(.inline) .searchable(text: $searchText, prompt: "Search all columns") .searchFocused($searchFocused) @@ -273,25 +300,31 @@ struct DataBrowserView: View { .refreshable { await viewModel.load() } } + private func rowDetail(index: Int) -> some View { + RowDetailView( + columns: columns, + rows: viewModel.window.rows, + initialIndex: index, + table: table, + session: session, + columnDetails: viewModel.columnDetails, + databaseType: connection.type, + schema: viewModel.schema, + safeModeLevel: { [coordinator] in coordinator.connection.safeModeLevel }, + foreignKeys: viewModel.foreignKeys, + onSaved: { Task { await viewModel.load() } }, + loadFullValue: { ref in + guard let session else { return nil } + return try await viewModel.loadFullValue(driver: session.driver, ref: ref, databaseType: connection.type) + }, + coordinator: coordinator + ) + } + private func rowLink(index: Int, row: [String?]) -> some View { - NavigationLink { - RowDetailView( - columns: columns, - rows: viewModel.window.rows, - initialIndex: index, - table: table, - session: session, - columnDetails: viewModel.columnDetails, - databaseType: connection.type, - schema: viewModel.schema, - safeModeLevel: { [coordinator] in coordinator.connection.safeModeLevel }, - foreignKeys: viewModel.foreignKeys, - onSaved: { Task { await viewModel.load() } }, - loadFullValue: { ref in - guard let session else { return nil } - return try await viewModel.loadFullValue(driver: session.driver, ref: ref, databaseType: connection.type) - } - ) + let deleteKeys = canDeleteRows ? viewModel.primaryKeyValues(forRowAt: index) : nil + return NavigationLink { + rowDetail(index: index) } label: { RowCard( columns: columns, @@ -301,11 +334,11 @@ struct DataBrowserView: View { ) } .hoverEffect() - .contextMenu { rowContextMenu(row: row) } + .contextMenu { rowContextMenu(index: index, row: row) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if canDeleteRows { + if let deleteKeys { Button { - confirmDelete(row) + confirmDelete(deleteKeys) } label: { Label("Delete", systemImage: "trash") } @@ -313,19 +346,19 @@ struct DataBrowserView: View { } } .accessibilityActions { - if canDeleteRows { - Button("Delete row") { confirmDelete(row) } + if let deleteKeys { + Button("Delete row") { confirmDelete(deleteKeys) } } } } - private func confirmDelete(_ row: [String?]) { - deleteTarget = viewModel.primaryKeyValues(for: row) + private func confirmDelete(_ keys: [(column: String, value: String)]) { + deleteTarget = keys showDeleteConfirmation = true } @ViewBuilder - private func rowContextMenu(row: [String?]) -> some View { + private func rowContextMenu(index: Int, row: [String?]) -> some View { Menu("Share Row") { ForEach(ExportFormat.allCases) { format in Button(format.rawValue) { @@ -350,23 +383,18 @@ struct DataBrowserView: View { } } } - let rowFKs = viewModel.foreignKeys.filter { fk in - guard let colIndex = columns.firstIndex(where: { $0.name == fk.column }), - colIndex < row.count, - row[colIndex] != nil else { return false } - return true - } - if !rowFKs.isEmpty { + let cells = index < viewModel.window.rows.count ? viewModel.window.rows[index].cells : [] + let references = ForeignKeyReference.all( + foreignKeys: viewModel.foreignKeys, columns: columns, + cells: cells, sourceSchema: viewModel.schema + ) + if !references.isEmpty { Divider() - ForEach(rowFKs, id: \.name) { fk in + ForEach(references, id: \.name) { entry in Button { - if let colIndex = columns.firstIndex(where: { $0.name == fk.column }), - colIndex < row.count, - let value = row[colIndex] { - fkPreviewItem = FKPreviewItem(fk: fk, value: value) - } + fkPreviewItem = FKPreviewItem(reference: entry.reference) } label: { - Label("\(fk.column) -> \(fk.referencedTable)", systemImage: "arrow.right.circle") + Label("\(entry.column) -> \(entry.reference.table)", systemImage: "arrow.right.circle") } } } diff --git a/apple/TableProMobile/TableProMobile/Views/ForeignKeyNavigation.swift b/apple/TableProMobile/TableProMobile/Views/ForeignKeyNavigation.swift new file mode 100644 index 0000000000..4abb307a66 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/ForeignKeyNavigation.swift @@ -0,0 +1,67 @@ +import SwiftUI +import TableProDatabase +import TableProModels + +extension View { + func foreignKeyPreview( + item: Binding, + session: ConnectionSession?, + coordinator: ConnectionCoordinator?, + databaseType: DatabaseType, + allowsOpen: Bool + ) -> some View { + modifier(ForeignKeyPreviewModifier( + item: item, + session: session, + coordinator: coordinator, + databaseType: databaseType, + allowsOpen: allowsOpen + )) + } +} + +private struct ForeignKeyPreviewModifier: ViewModifier { + @Binding var item: FKPreviewItem? + let session: ConnectionSession? + let coordinator: ConnectionCoordinator? + let databaseType: DatabaseType + let allowsOpen: Bool + + @State private var requestedOpen: ForeignKeyReference? + @State private var openedReference: ForeignKeyReference? + + private var currentSchema: String? { + guard let coordinator, coordinator.supportsSchemas else { return nil } + return coordinator.activeSchema + } + + func body(content: Content) -> some View { + content + .sheet(item: $item, onDismiss: pushRequestedOpen) { presented in + FKPreviewView( + reference: presented.reference, + session: session, + databaseType: databaseType, + currentSchema: currentSchema, + allowsOpen: allowsOpen && coordinator != nil, + onOpen: { requestedOpen = $0 } + ) + } + .navigationDestination(item: $openedReference) { reference in + if let coordinator { + DataBrowserView( + table: reference.catalogTable(in: coordinator.tables, currentSchema: currentSchema), + reference: reference + ) + .environment(coordinator) + } + } + } + + // A swipe-down also ends here, and only a stored Open Table request pushes, after the sheet is gone. + private func pushRequestedOpen() { + guard let reference = requestedOpen else { return } + requestedOpen = nil + openedReference = reference + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift index 22464d71d6..28b0e74c67 100644 --- a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -11,6 +11,7 @@ struct RowDetailView: View { @State private var hapticError = false @State private var hapticSelection = 0 @State private var showSaveConfirmation = false + private let coordinator: ConnectionCoordinator? init( columns: [ColumnInfo], @@ -24,8 +25,10 @@ struct RowDetailView: View { safeModeLevel: @escaping () -> SafeModeLevel = { .off }, foreignKeys: [ForeignKeyInfo] = [], onSaved: (() -> Void)? = nil, - loadFullValue: ((CellRef) async throws -> String?)? = nil + loadFullValue: ((CellRef) async throws -> String?)? = nil, + coordinator: ConnectionCoordinator? = nil ) { + self.coordinator = coordinator _viewModel = State(wrappedValue: RowDetailViewModel( columns: columns, rows: rows, @@ -107,14 +110,13 @@ struct RowDetailView: View { } message: { Text(String(format: String(localized: "This will update a row in %@. Continue?"), viewModel.table?.name ?? "")) } - .sheet(item: $fkPreviewItem) { item in - FKPreviewView( - fk: item.fk, - value: item.value, - session: viewModel.session, - databaseType: viewModel.databaseType - ) - } + .foreignKeyPreview( + item: $fkPreviewItem, + session: viewModel.session, + coordinator: coordinator, + databaseType: viewModel.databaseType, + allowsOpen: !viewModel.isEditing + ) .sheet(isPresented: $showShareSheet) { ActivityViewController(items: [shareText]) } @@ -238,9 +240,13 @@ struct RowDetailView: View { !viewModel.hasOverride(forRow: rowIndex, cellIndex: index) { lazyLoadButton(cell: cells[index], rowIndex: rowIndex, cellIndex: index) } - if let fk = viewModel.foreignKeys.first(where: { $0.column == column.name }), let value { + if let fk = viewModel.foreignKeys.first(where: { $0.column == column.name }), + let reference = ForeignKeyReference.make( + forColumn: column.name, foreignKeys: viewModel.foreignKeys, + columns: viewModel.columns, cells: cells, sourceSchema: viewModel.schema + ) { Button { - fkPreviewItem = FKPreviewItem(fk: fk, value: value) + fkPreviewItem = FKPreviewItem(reference: reference) } label: { HStack(spacing: 4) { Image(systemName: "arrow.right.circle.fill") diff --git a/apple/TableProMobile/TableProMobile/Views/StructureView.swift b/apple/TableProMobile/TableProMobile/Views/StructureView.swift index 4fd504722d..8a7a823922 100644 --- a/apple/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/apple/TableProMobile/TableProMobile/Views/StructureView.swift @@ -7,6 +7,7 @@ struct StructureView: View { let table: TableInfo let session: ConnectionSession? let databaseType: DatabaseType + let schema: String? private static let logger = Logger(subsystem: "com.TablePro", category: "StructureView") @@ -240,9 +241,9 @@ struct StructureView: View { appError = nil do { - async let fetchedColumns = session.driver.fetchColumns(table: table.name, schema: nil) - async let fetchedIndexes = session.driver.fetchIndexes(table: table.name, schema: nil) - async let fetchedForeignKeys = session.driver.fetchForeignKeys(table: table.name, schema: nil) + async let fetchedColumns = session.driver.fetchColumns(table: table.name, schema: schema) + async let fetchedIndexes = session.driver.fetchIndexes(table: table.name, schema: schema) + async let fetchedForeignKeys = session.driver.fetchForeignKeys(table: table.name, schema: schema) self.columns = try await fetchedColumns self.indexes = try await fetchedIndexes diff --git a/apple/TableProMobile/TableProMobile/Views/TableBrowserIdentity.swift b/apple/TableProMobile/TableProMobile/Views/TableBrowserIdentity.swift new file mode 100644 index 0000000000..0d8da996c8 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/TableBrowserIdentity.swift @@ -0,0 +1,12 @@ +import TableProModels + +// A database switch keeps the selection when the new catalog holds an equal table, so the database is part of the identity. +nonisolated struct TableBrowserIdentity: Hashable { + let table: TableInfo? + let database: String + + init(_ table: TableInfo?, _ database: String) { + self.table = table + self.database = database + } +} diff --git a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index 2b0b842165..cc306eba75 100644 --- a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -151,10 +151,226 @@ struct DataBrowserViewModelTests { vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") await vm.load(isInitial: true) - let pks = vm.primaryKeyValues(for: ["42", "Alice"]) - #expect(pks.count == 1) - #expect(pks.first?.column == "id") - #expect(pks.first?.value == "42") + let pks = vm.primaryKeyValues(forRowAt: 0) + #expect(pks?.count == 1) + #expect(pks?.first?.column == "id") + #expect(pks?.first?.value == "42") + #expect(vm.primaryKeyValues(forRowAt: 1) == nil) + } + + @Test("A row whose key holds binary data gives no key values to delete it by") + func binaryKeyGivesNoDeleteValues() async { + let columns = [ + ColumnInfo(name: "id", typeName: "BINARY(16)", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 1) + ] + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: columns, rows: [["\u{1}\u{2}\u{3}", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "customers"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + #expect(vm.legacyRows.count == 1) + #expect(vm.primaryKeyValues(forRowAt: 0) == nil) + } + + @Test("Columns and foreign keys are read in the browser's schema, not the driver's") + func metadataReadsBrowserSchema() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach( + session: makeSession(driver: driver), table: TableInfo(name: "customers"), + databaseType: .postgresql, host: "localhost", schema: "crm" + ) + await vm.load(isInitial: true) + + #expect(driver.fetchColumnsSchemas == ["crm"]) + #expect(driver.fetchForeignKeysSchemas == ["crm"]) + #expect(driver.executedQueries.first?.hasPrefix(#"SELECT * FROM "crm"."customers""#) == true) + } + + @Test("A full value is read from the browser's schema") + func fullValueReadsBrowserSchema() async throws { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult( + columns: [ColumnInfo(name: "body", typeName: "TEXT", ordinalPosition: 0)], + rows: [["the whole body"]], + rowsAffected: 0, + executionTime: 0 + )) + ] + let vm = DataBrowserViewModel() + vm.attach( + session: makeSession(driver: driver), table: TableInfo(name: "notes"), + databaseType: .postgresql, host: "localhost", schema: "crm" + ) + let ref = CellRef(table: "notes", column: "body", primaryKey: [.init(column: "id", value: "7")]) + + let value = try await vm.loadFullValue(driver: driver, ref: ref, databaseType: .postgresql) + + #expect(value == "the whole body") + #expect(driver.executedQueries == [#"SELECT "body" FROM "crm"."notes" WHERE "id" = '7' LIMIT 1"#]) + } + + @Test("A seeded filter is already in the first SELECT, and only the first seed counts") + func seededFilterShapesFirstSelect() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["42", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + + vm.seedFilters([TableFilter(columnName: "id", value: "42")]) + await vm.load(isInitial: true) + vm.seedFilters([TableFilter(columnName: "name", value: "Bob")]) + + #expect(driver.executedQueries.first?.contains("WHERE `id` = 42") == true) + #expect(vm.activeFilterCount == 1) + let seededColumns = vm.filters.map { $0.columnName } + #expect(seededColumns == ["id"]) + } + + @Test("A seeded key spelled in another case than its TEXT column still compares as text") + func seededFilterAcrossCaseStaysQuoted() async { + let columns = [ + ColumnInfo(name: "code", typeName: "TEXT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "city", typeName: "TEXT", ordinalPosition: 1) + ] + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: columns, rows: [["02134", "Boston"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "zips"), databaseType: .sqlite, host: "") + + vm.seedFilters([TableFilter(columnName: "CODE", value: "02134")]) + await vm.load(isInitial: true) + + let select = driver.executedQueries.first ?? "" + #expect(select.contains("'02134'")) + #expect(!select.contains("= 02134")) + } + + @Test("A seeded browser whose first load finds one row hands that match over once") + func singleRowMatchIsTakenOnce() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["42", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + vm.seedFilters([TableFilter(columnName: "id", value: "42")]) + #expect(vm.takeSingleRowMatch() == false) + + await vm.load(isInitial: true) + #expect(vm.takeSingleRowMatch()) + #expect(vm.takeSingleRowMatch() == false) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["42", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.load() + #expect(vm.takeSingleRowMatch() == false) + } + + @Test("No match is handed over for several rows, or for a browser nothing was seeded into") + func singleRowMatchNeedsOneSeededRow() async { + let several = MockDatabaseDriver() + several.scriptedColumns = makeColumns() + several.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["5", "Alice"], ["5", "Bob"]], rowsAffected: 0, executionTime: 0)) + ] + let seeded = DataBrowserViewModel() + seeded.attach(session: makeSession(driver: several), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + seeded.seedFilters([TableFilter(columnName: "id", value: "5")]) + await seeded.load(isInitial: true) + #expect(seeded.takeSingleRowMatch() == false) + + let single = MockDatabaseDriver() + single.scriptedColumns = makeColumns() + single.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let unseeded = DataBrowserViewModel() + unseeded.attach(session: makeSession(driver: single), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await unseeded.load(isInitial: true) + #expect(unseeded.takeSingleRowMatch() == false) + } + + @Test("A seeded key on a column without a type is quoted as the preview quotes it", arguments: ["", "BLOB", "ANY"]) + func untypedKeyColumnIsQuoted(typeName: String) async { + let columns = [ + ColumnInfo(name: "code", typeName: typeName, ordinalPosition: 0), + ColumnInfo(name: "label", typeName: "TEXT", ordinalPosition: 1) + ] + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: columns, rows: [["5", "integer five"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "parents"), databaseType: .sqlite, host: "") + vm.seedFilters([TableFilter(columnName: "CODE", value: "5")]) + await vm.load(isInitial: true) + + let select = driver.executedQueries.first ?? "" + #expect(select.contains("\"CODE\" = '5'")) + #expect(!select.contains("= 5")) + #expect(vm.takeSingleRowMatch()) + + let userDriver = MockDatabaseDriver() + userDriver.scriptedColumns = columns + let unseeded = DataBrowserViewModel() + unseeded.attach(session: makeSession(driver: userDriver), table: TableInfo(name: "parents"), databaseType: .sqlite, host: "") + unseeded.filters = [TableFilter(columnName: "code", value: "5")] + await unseeded.load(isInitial: true) + #expect((userDriver.executedQueries.first ?? "").contains("\"code\" = 5")) + } + + @Test("A load overtaken during its row count leaves the newer load's total and loading state alone") + func overtakenCountIsDropped() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + let gate = ExecuteGate(holding: [2, 3]) + driver.beforeExecute = { await gate.pass() } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"], ["2", "Bob"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["50"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + + let overtaken = Task { await vm.changePageSize(2) } + await gate.arrival(of: 2) + + vm.filters = [TableFilter(columnName: "name", value: "Alice")] + let filtered = Task { await vm.applyFilters() } + await gate.arrival(of: 3) + + await gate.release(2) + await overtaken.value + #expect(vm.pagination.totalRows == nil) + #expect(vm.isPageLoading) + + await gate.release(3) + await filtered.value + #expect(vm.pagination.totalRows == 1) + #expect(vm.legacyRows.count == 1) + #expect(vm.isLoading == false) + #expect(vm.isPageLoading == false) } @Test("deleteRow returns true on success and runs DELETE SQL") @@ -481,3 +697,33 @@ struct DataBrowserViewModelTests { #expect(driver.executedQueries.isEmpty) } } + +private actor ExecuteGate { + private let heldCalls: Set + private var calls = 0 + private var held: [Int: CheckedContinuation] = [:] + private var arrivalWaiters: [Int: CheckedContinuation] = [:] + + init(holding heldCalls: Set) { + self.heldCalls = heldCalls + } + + func pass() async { + calls += 1 + let call = calls + guard heldCalls.contains(call) else { return } + await withCheckedContinuation { continuation in + held[call] = continuation + arrivalWaiters.removeValue(forKey: call)?.resume() + } + } + + func arrival(of call: Int) async { + guard held[call] == nil else { return } + await withCheckedContinuation { arrivalWaiters[call] = $0 } + } + + func release(_ call: Int) { + held.removeValue(forKey: call)?.resume() + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift index 58f854171a..69a49416bd 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift @@ -105,7 +105,7 @@ struct MySQLColumnListingTests { func mysqlDefaultsReadAsSQL() { let source = MySQLColumnListing.defaultSource(flavor: .mysql, banner: "8.4.11") #expect(source == .showFullColumns) - #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: source) == nil) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", database: nil, source: source) == nil) let columns = MySQLColumnListing.columns( fromShowFullColumns: Self.mysqlShowFullColumns, catalogDefaults: [:], source: source @@ -219,11 +219,25 @@ struct MySQLColumnListingTests { @Test("The catalog read names the session's database and escapes the table name") func catalogQueryReadsTheSessionDatabase() throws { - let query = try #require(MySQLColumnListing.catalogDefaultsQuery(table: #"o'b\x"#, source: .quotedCatalog)) + let query = try #require( + MySQLColumnListing.catalogDefaultsQuery(table: #"o'b\x"#, database: nil, source: .quotedCatalog) + ) #expect(query.contains("TABLE_SCHEMA = DATABASE()")) #expect(query.contains(#"TABLE_NAME = 'o''b\\x'"#)) - #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: .oceanBaseCatalog) != nil) - #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: .asReported) == nil) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", database: nil, source: .oceanBaseCatalog) != nil) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", database: nil, source: .asReported) == nil) + } + + @Test("Columns of a table in another database are read from that database") + func anotherDatabase() throws { + let query = try #require( + MySQLColumnListing.catalogDefaultsQuery(table: "regions", database: #"g'e\o"#, source: .quotedCatalog) + ) + #expect(query.contains(#"TABLE_SCHEMA = 'g''e\\o'"#)) + #expect(!query.contains("DATABASE()")) + #expect(MySQLDriver.showFullColumnsStatement(table: "regions", database: "geo") == "SHOW FULL COLUMNS FROM `geo`.`regions`") + #expect(MySQLDriver.showFullColumnsStatement(table: "a`b", database: "c`d") == "SHOW FULL COLUMNS FROM `c``d`.`a``b`") + #expect(MySQLDriver.showFullColumnsStatement(table: "regions", database: nil) == "SHOW FULL COLUMNS FROM `regions`") } /// OceanBase's catalog drops the precision `CURRENT_TIMESTAMP(3)` was declared with, reports a diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverLegacyServerTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverLegacyServerTests.swift index 206e45998c..a60e6c0275 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverLegacyServerTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverLegacyServerTests.swift @@ -35,6 +35,38 @@ struct MySQLDriverLegacyServerTests { #expect(MySQLDriver.readsForeignKeyCatalog(for: .tidb, banner: "8.0.11-TiDB-v8.5.0")) } + @Test("SHOW CREATE TABLE names the database the keys are asked for") + func showCreateTableIsQualified() { + #expect(MySQLDriver.showCreateTableStatement(table: "orders", database: "shop") == "SHOW CREATE TABLE `shop`.`orders`") + #expect(MySQLDriver.showCreateTableStatement(table: "a`b", database: "c`d") == "SHOW CREATE TABLE `c``d`.`a``b`") + #expect(MySQLDriver.showCreateTableStatement(table: "orders", database: nil) == "SHOW CREATE TABLE `orders`") + } + + private static let ordersDDL = """ + CREATE TABLE `orders` ( + `id` int(11) NOT NULL, + `customer_id` int(11) DEFAULT NULL, + `region_id` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `customer_id` (`customer_id`), + KEY `region_id` (`region_id`), + CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`), + CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`region_id`) REFERENCES `geo`.`regions` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=latin1 + """ + + @Test("A declared key into its own database has no schema, one into another database keeps it") + func declaredKeySchemas() { + for database in ["shop", nil] as [String?] { + let keys = MySQLDriver.foreignKeys(declaredIn: Self.ordersDDL, database: database, omittedAction: "RESTRICT") + + #expect(keys.map(\.column) == ["customer_id", "region_id"]) + #expect(keys.map(\.referencedTable) == ["customers", "regions"]) + #expect(keys.map(\.referencedSchema) == [nil, "geo"]) + #expect(keys.map(\.onDelete) == ["RESTRICT", "CASCADE"]) + } + } + /// Measured: 4.1.22 labels `SHOW` strings charset 63, 5.0.96 labels them utf8. @Test("Only a server before 5.0 labels SHOW text binary") func showTextFloor() { diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLForeignKeyCatalogTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLForeignKeyCatalogTests.swift new file mode 100644 index 0000000000..59e64cfa0b --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLForeignKeyCatalogTests.swift @@ -0,0 +1,96 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +struct MySQLForeignKeyCatalogTests { + // CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, DELETE_RULE, + // UPDATE_RULE, TABLE_SCHEMA, REFERENCED_TABLE_SCHEMA, in the order the catalog query selects them. + private func row( + _ name: String, + _ column: String, + _ table: String, + _ referencedColumn: String, + schema: String = "shop", + referencedSchema: String? = "shop", + onDelete: String? = "RESTRICT", + onUpdate: String? = "RESTRICT" + ) -> [String?] { + [name, column, table, referencedColumn, onDelete, onUpdate, schema, referencedSchema] + } + + @Test("The catalog read filters on the database it is asked for") + func readsTheRequestedDatabase() { + let query = MySQLDriver.foreignKeyCatalogQuery(table: "orders", database: "crm") + + #expect(query.contains("kcu.TABLE_SCHEMA = 'crm'")) + #expect(query.contains("kcu.TABLE_NAME = 'orders'")) + #expect(query.contains("kcu.REFERENCED_TABLE_SCHEMA")) + #expect(!query.contains("DATABASE()")) + } + + @Test("With no database the catalog read follows the session's current one") + func readsTheSessionDatabase() { + let query = MySQLDriver.foreignKeyCatalogQuery(table: "orders", database: nil) + + #expect(query.contains("kcu.TABLE_SCHEMA = DATABASE()")) + } + + @Test("Quotes and backslashes in the database and table names are escaped") + func escapesNames() { + let query = MySQLDriver.foreignKeyCatalogQuery(table: #"o'b\x"#, database: #"c'r\m"#) + + #expect(query.contains(#"kcu.TABLE_SCHEMA = 'c''r\\m'"#)) + #expect(query.contains(#"kcu.TABLE_NAME = 'o''b\\x'"#)) + } + + @Test("A key into its own database has no schema, one into another database keeps it") + func referencedSchemaOnlyWhenItDiffers() { + let keys = MySQLDriver.foreignKeys(fromCatalogRows: [ + row("fk_customer", "customer_id", "customers", "id"), + row("fk_region", "region_id", "regions", "id", referencedSchema: "geo"), + ]) + + #expect(keys.map(\.name) == ["fk_customer", "fk_region"]) + #expect(keys.map(\.referencedTable) == ["customers", "regions"]) + #expect(keys.map(\.referencedSchema) == [nil, "geo"]) + } + + @Test("A composite key keeps one name across its column pairs, in key order") + func compositeKeyKeepsItsName() { + let keys = MySQLDriver.foreignKeys(fromCatalogRows: [ + row("fk_user", "tid", "users", "tenant_id"), + row("fk_user", "uid", "users", "id"), + ]) + + #expect(keys.map(\.name) == ["fk_user", "fk_user"]) + #expect(keys.map(\.column) == ["tid", "uid"]) + #expect(keys.map(\.referencedColumn) == ["tenant_id", "id"]) + } + + @Test("Actions come from the catalog, and a missing one reads as NO ACTION") + func actions() { + let keys = MySQLDriver.foreignKeys(fromCatalogRows: [ + row("fk_a", "a", "p", "id", onDelete: "CASCADE", onUpdate: "SET NULL"), + row("fk_b", "b", "p", "id", onDelete: nil, onUpdate: nil), + ]) + + #expect(keys.map(\.onDelete) == ["CASCADE", "NO ACTION"]) + #expect(keys.map(\.onUpdate) == ["SET NULL", "NO ACTION"]) + } + + @Test("A row missing a name or a column is skipped") + func incompleteRowsAreSkipped() { + var nameless = row("fk_a", "a", "p", "id") + nameless[0] = nil + let short = Array(row("fk_b", "b", "p", "id").prefix(6)) + + #expect(MySQLDriver.foreignKeys(fromCatalogRows: [nameless, short]).isEmpty) + } + + @Test("Indexes of a table in another database are read from that database") + func indexStatement() { + #expect(MySQLDriver.showIndexStatement(table: "regions", database: "geo") == "SHOW INDEX FROM `geo`.`regions`") + #expect(MySQLDriver.showIndexStatement(table: "a`b", database: nil) == "SHOW INDEX FROM `a``b`") + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift index 9fe8db90bb..56fe48cd22 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift @@ -196,6 +196,96 @@ struct SQLiteDriverFileTests { #expect((count.rows.first?.first ?? nil) == "0") } + @Test("A key written as REFERENCES parent with no column points at the parent's primary key") + func shorthandKeyReadsTheParentPrimaryKey() async throws { + let driver = try await connectedDriver("shorthand.db") + _ = try await driver.execute(query: "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)") + _ = try await driver.execute( + query: "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES Customers)" + ) + + let keys = try await driver.fetchForeignKeys(table: "orders", schema: nil) + try await driver.disconnect() + + #expect(keys.map(\.column) == ["customer_id"]) + #expect(keys.map(\.referencedTable) == ["Customers"]) + #expect(keys.map(\.referencedColumn) == ["id"]) + } + + @Test("A shorthand key to a parent with a non-ASCII name still reads the parent's primary key") + func shorthandKeyToNonASCIIParent() async throws { + let driver = try await connectedDriver("non-ascii.db") + _ = try await driver.execute(query: "CREATE TABLE \"Équipe\" (id INTEGER PRIMARY KEY, name TEXT)") + _ = try await driver.execute( + query: "CREATE TABLE members (id INTEGER PRIMARY KEY, team_id INTEGER REFERENCES \"Équipe\")" + ) + + let keys = try await driver.fetchForeignKeys(table: "members", schema: nil) + try await driver.disconnect() + + #expect(keys.map(\.referencedTable) == ["Équipe"]) + #expect(keys.map(\.referencedColumn) == ["id"]) + } + + @Test("The pairs of a composite key share one name, and another key gets its own") + func compositeKeyPairsShareAName() async throws { + let driver = try await connectedDriver("composite.db") + _ = try await driver.execute(query: "CREATE TABLE regions (id INTEGER PRIMARY KEY)") + _ = try await driver.execute( + query: "CREATE TABLE users (tenant_id INTEGER, id INTEGER, name TEXT, PRIMARY KEY (tenant_id, id))" + ) + _ = try await driver.execute(query: """ + CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + tid INTEGER, + uid INTEGER, + region_id INTEGER REFERENCES regions (id), + CONSTRAINT fk_order_user FOREIGN KEY (tid, uid) REFERENCES users (tenant_id, id) + ) + """) + + let keys = try await driver.fetchForeignKeys(table: "orders", schema: nil) + try await driver.disconnect() + + let userPairs = keys.filter { $0.referencedTable == "users" } + let regionKeys = keys.filter { $0.referencedTable == "regions" } + #expect(userPairs.count == 2) + #expect(Set(userPairs.map(\.name)).count == 1) + #expect(userPairs.map(\.column) == ["tid", "uid"]) + #expect(userPairs.map(\.referencedColumn) == ["tenant_id", "id"]) + #expect(regionKeys.map(\.column) == ["region_id"]) + #expect(regionKeys.allSatisfy { $0.name != userPairs.first?.name }) + } + + @Test("A declared name equal to another key's positional name keeps the two keys apart") + func declaredNameDoesNotMergeKeys() async throws { + let driver = try await connectedDriver("collision.db") + _ = try await driver.execute(query: "CREATE TABLE p (id INTEGER PRIMARY KEY)") + _ = try await driver.execute(query: """ + CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + a INTEGER, + b INTEGER, + CONSTRAINT fk_orders_0 FOREIGN KEY (a) REFERENCES p (id), + FOREIGN KEY (b) REFERENCES p (id) + ) + """) + + let keys = try await driver.fetchForeignKeys(table: "orders", schema: nil) + try await driver.disconnect() + + #expect(keys.count == 2) + #expect(Set(keys.map(\.name)).count == 2) + let reference = ForeignKeyReference.make( + forColumn: "b", + foreignKeys: keys, + columns: ["id", "a", "b"].map { ColumnInfo(name: $0, typeName: "INTEGER") }, + cells: [.text("9"), .text("1"), .text("2")], + sourceSchema: nil + ) + #expect(reference?.parts == [ForeignKeyReference.Part(column: "id", value: "2")]) + } + // MARK: - Helpers private static let createStudent = """ diff --git a/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift b/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift new file mode 100644 index 0000000000..99541cd5a7 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift @@ -0,0 +1,180 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +struct ForeignKeyPreviewModelTests { + private static let tenantUser = ForeignKeyReference(table: "tenant_users", schema: "crm", parts: [ + ForeignKeyReference.Part(column: "tenant_id", value: "2"), + ForeignKeyReference.Part(column: "id", value: "5") + ]) + + private static let userColumns = [ + ColumnInfo(name: "tenant_id", typeName: "INT", ordinalPosition: 0), + ColumnInfo(name: "id", typeName: "INT", ordinalPosition: 1), + ColumnInfo(name: "region_id", typeName: "INT", ordinalPosition: 2), + ColumnInfo(name: "avatar_id", typeName: "VARBINARY(16)", ordinalPosition: 3), + ColumnInfo(name: "manager_id", typeName: "INT", ordinalPosition: 4) + ] + + private static let userKeys = [ + ForeignKeyInfo(name: "users_region_fk", column: "region_id", referencedTable: "regions", referencedColumn: "id"), + ForeignKeyInfo(name: "users_avatar_fk", column: "avatar_id", referencedTable: "avatars", referencedColumn: "id"), + ForeignKeyInfo(name: "users_manager_fk", column: "manager_id", referencedTable: "tenant_users", referencedColumn: "id") + ] + + private static func session(_ driver: MockDatabaseDriver) -> ConnectionSession { + ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "test") + } + + private static func driver(rows: [[String?]]) -> MockDatabaseDriver { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: userColumns, rows: rows, rowsAffected: 0, executionTime: 0)) + ] + driver.scriptedForeignKeys = userKeys + return driver + } + + private static func model(_ driver: MockDatabaseDriver?, reference: ForeignKeyReference? = nil) -> ForeignKeyPreviewModel { + ForeignKeyPreviewModel( + reference: reference ?? tenantUser, + session: driver.map { Self.session($0) }, + databaseType: .postgresql + ) + } + + private static let userRow: [String?] = ["2", "5", "9", "0A0B", nil] + + @Test("No session is a Not Connected error, not a missing row, and Open Table stays off") + func noSessionIsAnError() async { + let model = Self.model(nil) + await model.load() + + guard case .failed(let error) = model.phase else { + Issue.record("Expected a failure, got \(model.phase)") + return + } + #expect(error.title == String(localized: "Not Connected")) + #expect(error.message == String(localized: "No active database session.")) + #expect(!model.canOpen) + } + + @Test("The lookup matches every pair of the key in the reference's schema") + func lookupMatchesEveryPair() async throws { + let driver = Self.driver(rows: [Self.userRow]) + let model = Self.model(driver) + await model.load() + + let query = try #require(driver.executedQueries.first) + #expect(query == SQLBuilder.buildSingleRowLookup( + table: "tenant_users", schema: "crm", + key: [(column: "tenant_id", value: "2"), (column: "id", value: "5")], + type: .postgresql, driver: driver + )) + #expect(query.hasPrefix("SELECT * FROM \"crm\".\"tenant_users\"")) + #expect(query.contains("\"tenant_id\" = '2'")) + #expect(query.contains("\"id\" = '5'")) + } + + @Test("The referenced table's keys are read in the reference's schema, and chained links resolve against it") + func chainedLinksUseTheReferenceSchema() async throws { + let driver = Self.driver(rows: [Self.userRow]) + let model = Self.model(driver) + await model.load() + + #expect(model.isLoaded) + #expect(driver.fetchForeignKeysSchemas == ["crm"]) + let region = try #require(model.fields.first(where: { $0.name == "region_id" })?.reference) + #expect(region.table == "regions") + #expect(region.schema == "crm") + #expect(region.parts == [ForeignKeyReference.Part(column: "id", value: "9")]) + } + + @Test("A binary-typed or NULL key value gets no link, and a column outside every key gets none") + func binaryAndNullValuesGetNoLink() async { + let driver = Self.driver(rows: [Self.userRow]) + let model = Self.model(driver) + await model.load() + + let links = Dictionary(uniqueKeysWithValues: model.fields.map { ($0.name, $0.reference != nil) }) + #expect(links == ["tenant_id": false, "id": false, "region_id": true, "avatar_id": false, "manager_id": false]) + } + + @Test("A failed key read still shows the row, without links") + func keyReadFailureShowsRowWithoutLinks() async { + let driver = Self.driver(rows: [Self.userRow]) + driver.fetchForeignKeysError = MockDatabaseDriver.MockError.scripted + let model = Self.model(driver) + await model.load() + + #expect(model.isLoaded) + #expect(model.fields.count == Self.userColumns.count) + #expect(model.fields.allSatisfy { $0.reference == nil }) + #expect(model.canOpen) + } + + @Test("No row is the not-found state, and Open Table stays off") + func noRowIsNotFound() async { + let model = Self.model(Self.driver(rows: [])) + await model.load() + + guard case .notFound = model.phase else { + Issue.record("Expected not found, got \(model.phase)") + return + } + #expect(!model.canOpen) + } + + @Test("A failed lookup is an error, and Open Table stays off") + func failedLookupIsAnError() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + let model = Self.model(driver) + await model.load() + + guard case .failed(let error) = model.phase else { + Issue.record("Expected a failure, got \(model.phase)") + return + } + #expect(error.title == String(localized: "Could Not Load the Referenced Row")) + #expect(!model.canOpen) + #expect(driver.fetchForeignKeysSchemas.isEmpty) + } + + @Test("A blank key value previews but cannot open, since the browser drops a blank filter") + func blankValueCannotOpen() async { + let blank = ForeignKeyReference(table: "tenant_users", schema: nil, parts: [ + ForeignKeyReference.Part(column: "code", value: "") + ]) + let model = Self.model(Self.driver(rows: [Self.userRow]), reference: blank) + await model.load() + + #expect(model.isLoaded) + #expect(!model.canOpen) + } + + @Test("Loading again after the row arrived runs no second lookup") + func loadRunsOnce() async { + let driver = Self.driver(rows: [Self.userRow]) + let model = Self.model(driver) + await model.load() + await model.load() + + #expect(driver.executedQueries.count == 1) + #expect(driver.fetchForeignKeysSchemas.count == 1) + } + + @Test("The not-found message names every pair of the key") + func notFoundMessageNamesEveryPair() { + let english = Locale(identifier: "en_US") + #expect( + ForeignKeyPreviewModel.notFoundMessage(for: Self.tenantUser, locale: english) + == "No row found in tenant_users where tenant_id = '2' and id = '5'." + ) + let single = ForeignKeyReference(table: "users", schema: nil, parts: [ForeignKeyReference.Part(column: "id", value: "5")]) + #expect(ForeignKeyPreviewModel.notFoundMessage(for: single, locale: english) == "No row found in users where id = '5'.") + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ForeignKeyReferenceTests.swift b/apple/TableProMobile/TableProMobileTests/ForeignKeyReferenceTests.swift new file mode 100644 index 0000000000..e4b3934379 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ForeignKeyReferenceTests.swift @@ -0,0 +1,194 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +struct ForeignKeyReferenceTests { + private static func column(_ name: String, _ index: Int) -> ColumnInfo { + ColumnInfo(name: name, typeName: "TEXT", ordinalPosition: index) + } + + private static let orderColumns = [column("id", 0), column("tenant_id", 1), column("user_id", 2), column("note", 3)] + + private static let userKey = ForeignKeyInfo( + name: "orders_user_fk", column: "user_id", referencedTable: "users", referencedColumn: "id" + ) + + private static let tenantUserKey = [ + ForeignKeyInfo(name: "orders_tenant_user_fk", column: "tenant_id", referencedTable: "tenant_users", referencedColumn: "tenant_id"), + ForeignKeyInfo(name: "orders_tenant_user_fk", column: "user_id", referencedTable: "tenant_users", referencedColumn: "id") + ] + + private static func cells(_ values: Cell...) -> [Cell] { values } + + @Test("A single-column key reads the cell and keeps the source schema when the key names none") + func singleColumnKey() throws { + let reference = try #require(ForeignKeyReference.make( + forColumn: "user_id", + foreignKeys: [Self.userKey], + columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), .text("5"), .null), + sourceSchema: "public" + )) + #expect(reference.table == "users") + #expect(reference.schema == "public") + #expect(reference.parts == [ForeignKeyReference.Part(column: "id", value: "5")]) + } + + @Test("The key's own schema wins over the source schema") + func referencedSchemaWins() throws { + let key = ForeignKeyInfo( + name: "orders_user_fk", column: "user_id", referencedTable: "users", referencedColumn: "id", + referencedSchema: "crm" + ) + let reference = try #require(ForeignKeyReference.make( + forColumn: "user_id", foreignKeys: [key], columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), .text("5"), .null), sourceSchema: "public" + )) + #expect(reference.schema == "crm") + } + + @Test("A composite key carries every pair in key order, whichever of its columns was tapped") + func compositeKeyCarriesEveryPair() throws { + let cells = Self.cells(.text("1"), .text("2"), .text("5"), .null) + for tapped in ["tenant_id", "user_id"] { + let reference = try #require(ForeignKeyReference.make( + forColumn: tapped, foreignKeys: Self.tenantUserKey, columns: Self.orderColumns, + cells: cells, sourceSchema: nil + )) + #expect(reference.table == "tenant_users") + #expect(reference.parts == [ + ForeignKeyReference.Part(column: "tenant_id", value: "2"), + ForeignKeyReference.Part(column: "id", value: "5") + ]) + } + } + + @Test("A NULL, binary or truncated key cell gives no reference, so no placeholder text is used as a key") + func nonTextCellsGiveNoReference() { + let placeholders: [Cell] = [ + .null, + .binary(byteCount: 16, ref: nil), + .truncatedText(prefix: "abc", totalBytes: 9_000, ref: nil) + ] + for placeholder in placeholders { + #expect(ForeignKeyReference.make( + forColumn: "user_id", foreignKeys: [Self.userKey], columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), placeholder, .null), sourceSchema: nil + ) == nil) + } + } + + @Test("One NULL column in a composite key gives no reference") + func compositeKeyWithNullPart() { + #expect(ForeignKeyReference.make( + forColumn: "user_id", foreignKeys: Self.tenantUserKey, columns: Self.orderColumns, + cells: Self.cells(.text("1"), .null, .text("5"), .null), sourceSchema: nil + ) == nil) + } + + @Test("A key column missing from the result gives no reference") + func missingKeyColumn() { + let columns = [Self.column("id", 0), Self.column("user_id", 1)] + #expect(ForeignKeyReference.make( + forColumn: "user_id", foreignKeys: Self.tenantUserKey, columns: columns, + cells: Self.cells(.text("1"), .text("5")), sourceSchema: nil + ) == nil) + } + + @Test("A column in no key gives no reference") + func columnWithoutKey() { + #expect(ForeignKeyReference.make( + forColumn: "note", foreignKeys: [Self.userKey], columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), .text("5"), .text("x")), sourceSchema: nil + ) == nil) + } + + @Test("A key column spelled in another case reads its single match") + func keyColumnCaseFolds() throws { + let key = ForeignKeyInfo(name: "fk_orders_0", column: "USER_ID", referencedTable: "users", referencedColumn: "id") + let reference = try #require(ForeignKeyReference.make( + forColumn: "user_id", foreignKeys: [key], columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), .text("5"), .null), sourceSchema: nil + )) + #expect(reference.parts == [ForeignKeyReference.Part(column: "id", value: "5")]) + } + + @Test("Every key gives one entry, a composite one included, and a key with a NULL part is left out") + func allGivesOneEntryPerKey() { + let noteKey = ForeignKeyInfo(name: "orders_note_fk", column: "note", referencedTable: "notes", referencedColumn: "id") + let entries = ForeignKeyReference.all( + foreignKeys: Self.tenantUserKey + [Self.userKey, noteKey], + columns: Self.orderColumns, + cells: Self.cells(.text("1"), .text("2"), .text("5"), .null), + sourceSchema: nil + ) + #expect(entries.map { $0.name } == ["orders_tenant_user_fk", "orders_user_fk"]) + #expect(entries.map { $0.column } == ["tenant_id, user_id", "user_id"]) + #expect(entries.map { $0.reference.table } == ["tenant_users", "users"]) + #expect(entries.first?.reference.parts.count == 2) + } + + @Test("The schema shows only when it differs from the current one") + func displaySchema() { + func reference(_ schema: String?) -> ForeignKeyReference { + ForeignKeyReference(table: "users", schema: schema, parts: [ForeignKeyReference.Part(column: "id", value: "5")]) + } + #expect(reference("public").displaySchema(current: "public") == nil) + #expect(reference(nil).displaySchema(current: "public") == nil) + #expect(reference(nil).displaySchema(current: nil) == nil) + #expect(reference("crm").displaySchema(current: "public") == "crm") + #expect(reference("billing").displaySchema(current: nil) == "billing") + } + + @Test("The catalog's own table is used in the current schema, by exact name or its single case-insensitive match") + func catalogTableInCurrentSchema() { + let catalog = [ + TableInfo(name: "customers", type: .table, rowCount: 3), + TableInfo(name: "Regions", type: .foreignTable) + ] + let exact = ForeignKeyReference(table: "customers", schema: "public", parts: []) + #expect(exact.catalogTable(in: catalog, currentSchema: "public") == catalog[0]) + + let folded = ForeignKeyReference(table: "regions", schema: nil, parts: []) + #expect(folded.catalogTable(in: catalog, currentSchema: nil) == catalog[1]) + } + + @Test("Another schema, an unknown name or two case-insensitive matches build a plain table") + func catalogTableFallsBack() { + let catalog = [TableInfo(name: "users", type: .view), TableInfo(name: "Zips", type: .view), TableInfo(name: "ZIPS", type: .view)] + let plain = { (name: String) in TableInfo(name: name, type: .table) } + + let otherSchema = ForeignKeyReference(table: "users", schema: "crm", parts: []) + #expect(otherSchema.catalogTable(in: catalog, currentSchema: "public") == plain("users")) + + let unknown = ForeignKeyReference(table: "invoices", schema: nil, parts: []) + #expect(unknown.catalogTable(in: catalog, currentSchema: nil) == plain("invoices")) + + let ambiguous = ForeignKeyReference(table: "zips", schema: nil, parts: []) + #expect(ambiguous.catalogTable(in: catalog, currentSchema: nil) == plain("zips")) + } + + @Test("Filters are one case-sensitive equality per pair, all enabled") + func filtersPerPair() throws { + let reference = ForeignKeyReference(table: "tenant_users", schema: nil, parts: [ + ForeignKeyReference.Part(column: "tenant_id", value: "2"), + ForeignKeyReference.Part(column: "id", value: "05") + ]) + let filters = try #require(reference.filters()) + #expect(filters.map { $0.columnName } == ["tenant_id", "id"]) + #expect(filters.map { $0.value } == ["2", "05"]) + #expect(filters.allSatisfy { $0.filterOperator == .equal && $0.isEnabled && $0.isCaseSensitive && $0.isValid }) + } + + @Test("A blank or whitespace value gives no filters, since the browser would drop them and show every row") + func blankValueGivesNoFilters() { + for blank in ["", " "] { + let reference = ForeignKeyReference(table: "tenant_users", schema: nil, parts: [ + ForeignKeyReference.Part(column: "tenant_id", value: "2"), + ForeignKeyReference.Part(column: "id", value: blank) + ]) + #expect(reference.filters() == nil) + } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift new file mode 100644 index 0000000000..28f0ec8447 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift @@ -0,0 +1,72 @@ +import Foundation +import TableProModels +import TableProQuery +import Testing +@testable import TableProMobile + +struct SQLBuilderFilterLiteralTests { + private let idFilter = [TableFilter(columnName: "id", filterOperator: .equal, value: "5")] + private let codeFilter = [TableFilter(columnName: "code", filterOperator: .equal, value: "02134")] + private let codeColumns = [ColumnInfo(name: "code", typeName: "TEXT")] + private let sorted = SortState(columns: [SortColumn(name: "id", ascending: true)]) + + @Test("A SQL Server filtered select and count close the bracket around the column") + func mssqlFilteredSelectAndCount() { + #expect(SQLBuilder.buildFilteredSelect( + table: "orders", schema: nil, type: .mssql, + filters: idFilter, logicMode: .and, limit: 10, offset: 0 + ).hasPrefix("SELECT * FROM [orders] WHERE [id] = 5 ")) + #expect(SQLBuilder.buildFilteredSelect( + table: "orders", schema: nil, type: .mssql, + filters: idFilter, logicMode: .and, sortState: sorted, limit: 10, offset: 0 + ).hasPrefix("SELECT * FROM [orders] WHERE [id] = 5 ORDER BY [id] ASC")) + #expect(SQLBuilder.buildFilteredCount( + table: "orders", schema: nil, type: .mssql, filters: idFilter, logicMode: .and + ) == "SELECT COUNT(*) FROM [orders] WHERE [id] = 5") + } + + @Test("A SQL Server search with a filter closes the bracket around the column") + func mssqlSearchSelectAndCount() { + #expect(SQLBuilder.buildSearchSelect( + table: "orders", schema: nil, type: .mssql, + searchText: "", searchColumns: [], filters: idFilter, limit: 10, offset: 0 + ).hasPrefix("SELECT * FROM [orders] WHERE ([id] = 5) ")) + #expect(SQLBuilder.buildSearchCount( + table: "orders", schema: nil, type: .mssql, + searchText: "", searchColumns: [], filters: idFilter + ) == "SELECT COUNT(*) FROM [orders] WHERE ([id] = 5)") + } + + @Test("Every filtered and search builder hands the column types to the filter") + func columnsReachTheFilter() { + let quoted = "\"code\" = '02134'" + #expect(SQLBuilder.buildFilteredSelect( + table: "zips", schema: nil, type: .sqlite, + filters: codeFilter, logicMode: .and, columns: codeColumns, limit: 10, offset: 0 + ).contains(quoted)) + #expect(SQLBuilder.buildFilteredSelect( + table: "zips", schema: nil, type: .sqlite, + filters: codeFilter, logicMode: .and, columns: codeColumns, sortState: sorted, limit: 10, offset: 0 + ).contains(quoted)) + #expect(SQLBuilder.buildFilteredCount( + table: "zips", schema: nil, type: .sqlite, + filters: codeFilter, logicMode: .and, columns: codeColumns + ).contains(quoted)) + #expect(SQLBuilder.buildSearchSelect( + table: "zips", schema: nil, type: .sqlite, + searchText: "", searchColumns: [], filters: codeFilter, columns: codeColumns, limit: 10, offset: 0 + ).contains(quoted)) + #expect(SQLBuilder.buildSearchCount( + table: "zips", schema: nil, type: .sqlite, + searchText: "", searchColumns: [], filters: codeFilter, columns: codeColumns + ).contains(quoted)) + } + + @Test("A MySQL filter value ending in a backslash leaves the literal closed") + func mysqlTrailingBackslash() { + let filters = [TableFilter(columnName: "path", filterOperator: .equal, value: "C:\\temp\\")] + #expect(SQLBuilder.buildFilteredCount( + table: "files", schema: nil, type: .mysql, filters: filters, logicMode: .and + ) == "SELECT COUNT(*) FROM `files` WHERE `path` = 'C:\\\\temp\\\\'") + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift index 8df2dcff53..f01ef8e440 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift @@ -5,10 +5,6 @@ import Testing @testable import TableProMobile -/// The foreign key preview built its own literal and doubled the quote by hand, which a -/// MySQL-family server does not read the way ANSI does: a backslash there escapes the quote that -/// follows it, so a value ending in one closes the literal and the rest of it is SQL. -@Suite("SQLBuilder single row lookup") struct SQLBuilderSingleRowLookupTests { private func driver(backslashEscaping: Bool = false) -> MockDatabaseDriver { let driver = MockDatabaseDriver() @@ -16,35 +12,55 @@ struct SQLBuilderSingleRowLookupTests { return driver } + private func lookup( + _ key: [(column: String, value: String)], + schema: String? = nil, + type: DatabaseType = .postgresql, + backslashEscaping: Bool = false + ) -> String { + SQLBuilder.buildSingleRowLookup( + table: "parent", schema: schema, key: key, + type: type, driver: driver(backslashEscaping: backslashEscaping) + ) + } + @Test("A plain value reads as one statement against the named column") func plainValue() { + #expect(lookup([("code", "abc")]) == "SELECT * FROM \"parent\" WHERE \"code\" = 'abc' LIMIT 1 OFFSET 0") + } + + @Test("A composite key matches every pair, so another tenant's row cannot answer") + func compositeKeyMatchesEveryPair() { #expect( - SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: "abc", - type: .postgresql, driver: driver() - ) == "SELECT * FROM \"parent\" WHERE \"code\" = 'abc' LIMIT 1 OFFSET 0" + lookup([("tenant_id", "2"), ("id", "5")]) + == "SELECT * FROM \"parent\" WHERE \"tenant_id\" = '2' AND \"id\" = '5' LIMIT 1 OFFSET 0" ) } + @Test("Every pair of a composite key goes through the driver's escaper") + func compositeKeyEscapesEveryValue() { + let sql = lookup([("a", "x\\"), ("b", "O'Brien")], type: .mysql, backslashEscaping: true) + #expect(sql.contains("WHERE `a` = 'x\\\\' AND `b` = 'O''Brien'")) + } + + @Test("An empty key matches no row rather than any row") + func emptyKeyMatchesNothing() { + #expect(lookup([]) == "SELECT * FROM \"parent\" WHERE 1 = 0 LIMIT 1 OFFSET 0") + } + @Test("The schema is named, so the lookup cannot land on another schema's table") func schemaIsQualified() { #expect( - SQLBuilder.buildSingleRowLookup( - table: "parent", schema: "sales", column: "code", value: "abc", - type: .mssql, driver: driver() - ).hasPrefix("SELECT * FROM [sales].[parent] WHERE [code] = 'abc'") + lookup([("code", "abc")], schema: "sales", type: .mssql) + .hasPrefix("SELECT * FROM [sales].[parent] WHERE [code] = 'abc'") ) } - /// SQL Server and Oracle reject `LIMIT`, and the failure reads exactly like a key with no - /// matching row. + // SQL Server and Oracle reject `LIMIT`, and the failure reads exactly like a key with no matching row. @Test("An OFFSET/FETCH engine gets its own clause") func offsetFetchEngines() { for type in [DatabaseType.mssql, .oracle] { - let sql = SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: "abc", - type: type, driver: driver() - ) + let sql = lookup([("code", "abc")], type: type) #expect(sql.contains("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY")) #expect(!sql.contains("LIMIT")) } @@ -52,48 +68,27 @@ struct SQLBuilderSingleRowLookupTests { @Test("A quote in the value is escaped rather than closing the literal") func quoteIsEscaped() { - #expect( - SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: "O'Brien", - type: .postgresql, driver: driver() - ).contains("= 'O''Brien'") - ) + #expect(lookup([("code", "O'Brien")]).contains("= 'O''Brien'")) } - /// The payload that used to break out: MySQL reads `\'` as an escaped quote, so doubling the - /// quote alone ends the literal at the value's own backslash and runs the rest. + // MySQL reads `\'` as an escaped quote, so doubling the quote alone ends the literal at the value's backslash. @Test("A backslash before a quote cannot break out of the literal") func backslashCannotBreakOut() { - let sql = SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: "a\\' OR 1=1 -- ", - type: .mysql, driver: driver(backslashEscaping: true) - ) + let sql = lookup([("code", "a\\' OR 1=1 -- ")], type: .mysql, backslashEscaping: true) #expect(sql.contains("= 'a\\\\'' OR 1=1 -- '")) #expect(!sql.contains("'a\\'' OR")) } - /// A lone trailing backslash is an ordinary value, a Windows path or a regex, and it used to - /// leave the statement unterminated. @Test("A trailing backslash leaves the literal closed") func trailingBackslashIsEscaped() { - let sql = SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: "C:\\", - type: .mysql, driver: driver(backslashEscaping: true) - ) + let sql = lookup([("code", "C:\\")], type: .mysql, backslashEscaping: true) #expect(sql.contains("= 'C:\\\\'")) } @Test("Every escaped value goes through the driver, never a hand-written rule") func escapingFollowsTheDriver() { - let value = "a\\b" - let ansi = SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: value, - type: .postgresql, driver: driver() - ) - let backslash = SQLBuilder.buildSingleRowLookup( - table: "parent", schema: nil, column: "code", value: value, - type: .mysql, driver: driver(backslashEscaping: true) - ) + let ansi = lookup([("code", "a\\b")]) + let backslash = lookup([("code", "a\\b")], type: .mysql, backslashEscaping: true) #expect(ansi.contains("= 'a\\b'")) #expect(backslash.contains("= 'a\\\\b'")) } diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 1ae0151d33..8998a650c7 100644 --- a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -10,6 +10,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var scriptedExecuteResults: [Result] = [] var scriptedColumns: [ColumnInfo] = [] var scriptedForeignKeys: [ForeignKeyInfo] = [] + var fetchForeignKeysError: Error? var scriptedTables: [TableInfo] = [] var scriptedDatabases: [String] = [] var scriptedSchemas: [String] = [] @@ -22,6 +23,8 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { private(set) var executedQueries: [String] = [] private(set) var fetchColumnsCalls: Int = 0 private(set) var fetchForeignKeysCalls: Int = 0 + private(set) var fetchColumnsSchemas: [String?] = [] + private(set) var fetchForeignKeysSchemas: [String?] = [] private(set) var beganTransactionModes: [PluginTransactionAccessMode] = [] private(set) var didBeginTransaction = false private(set) var didCommitTransaction = false @@ -67,6 +70,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { fetchColumnsCalls += 1 + fetchColumnsSchemas.append(schema) return scriptedColumns } @@ -74,6 +78,8 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { fetchForeignKeysCalls += 1 + fetchForeignKeysSchemas.append(schema) + if let fetchForeignKeysError { throw fetchForeignKeysError } return scriptedForeignKeys } diff --git a/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index f85b925570..14144daddb 100644 --- a/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -306,6 +306,35 @@ struct RowDetailViewModelTests { #expect(vm.operationError != nil) } + @Test("A row whose key holds binary data offers no Edit, and Save builds no statement") + func binaryKeyBlocksEditAndSave() async { + let columns = [ + ColumnInfo(name: "id", typeName: "BINARY(16)", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 1) + ] + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + ] + let vm = RowDetailViewModel( + columns: columns, + rows: [Row(cells: [.binary(byteCount: 16, ref: nil), .text("Alice")])], + initialIndex: 0, + table: TableInfo(name: "customers"), + session: makeSession(driver: driver), + columnDetails: columns + ) + #expect(vm.canEdit == false) + + vm.startEditing() + vm.setEditedValue("Bea", at: 1) + #expect(vm.hasUnsavedEdits) + + #expect(await vm.saveChanges() == false) + #expect(vm.operationError?.title == String(localized: "Cannot Save")) + #expect(driver.executedQueries.isEmpty) + } + @Test("loadFullValue populates override and clears loadingCell") func lazyLoadPopulates() async { let provider: (CellRef) async throws -> String? = { _ in "the full blob value" } diff --git a/apple/TableProMobile/TableProMobileTests/Views/TableBrowserIdentityTests.swift b/apple/TableProMobile/TableProMobileTests/Views/TableBrowserIdentityTests.swift new file mode 100644 index 0000000000..a1490740de --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/TableBrowserIdentityTests.swift @@ -0,0 +1,29 @@ +@testable import TableProMobile +import TableProModels +import Testing + +struct TableBrowserIdentityTests { + private static let orders = TableInfo(name: "orders", type: .table) + + @Test("A database switch that keeps the selected table rebuilds the browser") + func databaseSwitchChangesIdentity() { + #expect(TableBrowserIdentity(Self.orders, "shop") != TableBrowserIdentity(Self.orders, "archive")) + } + + @Test("The same table in the same database keeps the browser") + func sameTableAndDatabaseKeepIdentity() { + let reloaded = TableInfo(name: "orders", type: .table) + #expect(TableBrowserIdentity(Self.orders, "shop") == TableBrowserIdentity(reloaded, "shop")) + } + + @Test("Another table in the same database rebuilds the browser") + func otherTableChangesIdentity() { + let customers = TableInfo(name: "customers", type: .table) + #expect(TableBrowserIdentity(Self.orders, "shop") != TableBrowserIdentity(customers, "shop")) + } + + @Test("Selecting a table after none rebuilds the browser") + func firstSelectionChangesIdentity() { + #expect(TableBrowserIdentity(nil, "shop") != TableBrowserIdentity(Self.orders, "shop")) + } +} From 3bd7102dd1ed0274d1265066984455b1e510126a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 09:29:36 +0700 Subject: [PATCH 2/2] docs(changelog): add fragments for #3682 --- apple/changelog.d/3682.added.md | 1 + apple/changelog.d/3682.fixed.1.md | 1 + apple/changelog.d/3682.fixed.2.md | 1 + apple/changelog.d/3682.fixed.3.md | 1 + apple/changelog.d/3682.fixed.4.md | 1 + apple/changelog.d/3682.fixed.md | 1 + 6 files changed, 6 insertions(+) create mode 100644 apple/changelog.d/3682.added.md create mode 100644 apple/changelog.d/3682.fixed.1.md create mode 100644 apple/changelog.d/3682.fixed.2.md create mode 100644 apple/changelog.d/3682.fixed.3.md create mode 100644 apple/changelog.d/3682.fixed.4.md create mode 100644 apple/changelog.d/3682.fixed.md diff --git a/apple/changelog.d/3682.added.md b/apple/changelog.d/3682.added.md new file mode 100644 index 0000000000..768100376f --- /dev/null +++ b/apple/changelog.d/3682.added.md @@ -0,0 +1 @@ +Open Table in a foreign key preview on iPhone and iPad, which opens the referenced row in its table. diff --git a/apple/changelog.d/3682.fixed.1.md b/apple/changelog.d/3682.fixed.1.md new file mode 100644 index 0000000000..6627009c4f --- /dev/null +++ b/apple/changelog.d/3682.fixed.1.md @@ -0,0 +1 @@ +Foreign key previews on iPhone and iPad missing some keys and showing the wrong row for composite or cross-schema keys. diff --git a/apple/changelog.d/3682.fixed.2.md b/apple/changelog.d/3682.fixed.2.md new file mode 100644 index 0000000000..c31e984845 --- /dev/null +++ b/apple/changelog.d/3682.fixed.2.md @@ -0,0 +1 @@ +Saving a row with a binary primary key on iPhone and iPad reporting success without changing it. diff --git a/apple/changelog.d/3682.fixed.3.md b/apple/changelog.d/3682.fixed.3.md new file mode 100644 index 0000000000..66436f1f06 --- /dev/null +++ b/apple/changelog.d/3682.fixed.3.md @@ -0,0 +1 @@ +Switching database on iPhone and iPad keeping the old table's rows, so a save could change a row in the new database. diff --git a/apple/changelog.d/3682.fixed.4.md b/apple/changelog.d/3682.fixed.4.md new file mode 100644 index 0000000000..e51b21c028 --- /dev/null +++ b/apple/changelog.d/3682.fixed.4.md @@ -0,0 +1 @@ +The row total on iPhone and iPad showing the unfiltered count when a filter applied during a slow count. diff --git a/apple/changelog.d/3682.fixed.md b/apple/changelog.d/3682.fixed.md new file mode 100644 index 0000000000..618338744e --- /dev/null +++ b/apple/changelog.d/3682.fixed.md @@ -0,0 +1 @@ +Filters on iPhone and iPad failing on SQL Server and matching the wrong rows for text that looks like a number.