diff --git a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index d3cf5e8255..6cfbe8a855 100644 --- a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -99,6 +99,15 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { } } + // These engines run SQLite itself, so they share its grammar and its storage classes. DuckDB, which PluginKit's + // SqlDialect files under SQLite, types and compares values its own way. + public var isSQLiteFamily: Bool { + switch self { + case .sqlite, .cloudflareD1, .libsql, .turso: return true + default: return false + } + } + public var pluginTypeId: String { switch self { case .mariadb, .tidb, .databend, .oceanbase: return DatabaseType.mysql.rawValue diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 585a77d456..fd4cf096d7 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -68,7 +68,7 @@ public final class ConnectionManager: @unchecked Sendable { var effectivePort = connection.port var forwardsToUnixSocket = false var tunnelId: UUID? - if connection.sshEnabled, let ssh = connection.sshConfigurationForDialing { + if driverFactory.dialsSSHTunnel(for: connection), let ssh = connection.sshConfigurationForDialing { guard let provider = sshProvider else { throw ConnectionError.sshNotSupported } diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift index e24a23114b..d31bd189e2 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift @@ -21,6 +21,9 @@ public protocol DriverFactory: Sendable { func opensCatalogConnections(for connection: DatabaseConnection) -> Bool // Runs once on a catalog connection after it connects, never on the user's session. func prepareCatalogDriver(_ driver: any DatabaseDriver, for connection: DatabaseConnection) async throws + // Whether the connection's SSH tunnel is dialed. A record can carry a tunnel its engine has no use for, such as + // one kept from the type it had before, and dialing it would fail the connect before the driver is made. + func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool } public extension DriverFactory { @@ -33,4 +36,8 @@ public extension DriverFactory { } func prepareCatalogDriver(_ driver: any DatabaseDriver, for connection: DatabaseConnection) async throws {} + + func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool { + connection.sshEnabled + } } diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift index 03b827341b..a9dd1ff664 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift @@ -46,8 +46,9 @@ nonisolated public enum FilterColumnKind { } // SQLite keeps 5 and '5' apart in a column with no affinity, and the text the grid read cannot say which one it was. + // Every engine that runs SQLite stores values the same way. public static func comparesEitherStorageClass(_ column: ColumnInfo?, databaseType: DatabaseType?) -> Bool { - databaseType == .sqlite && kind(of: column, databaseType: databaseType) == nil + databaseType?.isSQLiteFamily == true && kind(of: column, databaseType: databaseType) == nil } // Character.isNumber takes digits of every script, and no engine parses those as a number. diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index f50c3a181e..9a184bf858 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -96,16 +96,35 @@ public struct FilterSQLGenerator: Sendable { return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" case .contains: let pattern = likePatternBody(filter.value) + if exceedsLikePatternLimit(pattern, wildcards: 2) { + return SQLSubstringMatch.contains(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction) + } return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding) case .startsWith: let pattern = likePatternBody(filter.value) + if exceedsLikePatternLimit(pattern, wildcards: 1) { + return SQLSubstringMatch.startsWith(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction) + } return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding) case .endsWith: let pattern = likePatternBody(filter.value) + if exceedsLikePatternLimit(pattern, wildcards: 1) { + return SQLSubstringMatch.endsWith(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction) + } return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding) } } + // An engine that refuses a LIKE pattern this long (D1 past 50 bytes) gets the plain substring test instead, which + // reads no wildcard, so its literal carries no LIKE escape. + public func exceedsLikePatternLimit(_ patternBody: String, wildcards: Int) -> Bool { + SQLSubstringMatch.exceeds(patternBody, wildcards: wildcards, limit: dialect.likePatternByteLimit) + } + + public func stringLiteral(_ value: String) -> String { + "'\(literalBody(value))'" + } + // A number, a boolean or a bit has no case, and compared folded as text `5.50` would no longer equal `5.5`. // A pattern still folds: `contains TRUE` finds PostgreSQL's `true`. private func caseFolding(for filter: TableFilter, column: ColumnInfo?) -> PluginSQLCaseFolding { diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementRowLimit.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementRowLimit.swift new file mode 100644 index 0000000000..b9b52067be --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementRowLimit.swift @@ -0,0 +1,218 @@ +import Foundation + +/// The row-limiting clauses of one statement, read only where the engine reads code: outside strings, comments and +/// dollar quotes, and at paren depth zero, so a CTE's or a subquery's LIMIT never counts for the whole statement. +public enum SQLStatementRowLimit { + /// Whether the statement already bounds its own rows with `LIMIT` or `FETCH`. `countsTop` also takes `TOP`, for + /// an engine that limits with `SELECT TOP n`; elsewhere `top` is an ordinary column name. + public static func hasExplicitRowLimit(_ sql: String, countsTop: Bool, grammar: SQLLexicalGrammar) -> Bool { + var found = false + forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in + guard isLimitingKeyword(in: buffer, start: start, end: end, countsTop: countsTop, grammar: grammar) else { + return false + } + found = true + return true + } + return found + } + + /// The UTF-16 offset of the first top-level row-limiting clause, so a caller splicing a clause + /// onto the end of a statement can put it before that clause rather than after it. + /// + /// `TOP` is deliberately absent: it sits between SELECT and the column list, so it is not a + /// trailing clause and splitting there would cut the statement in half. + public static func firstRowLimitClauseOffset(_ sql: String, grammar: SQLLexicalGrammar) -> Int? { + var offset: Int? + forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in + guard startsRowLimitClause(in: buffer, start: start, end: end, grammar: grammar) else { return false } + offset = start + return true + } + return offset + } + + /// Whether a compound term after the first is a bare `VALUES` list, as in `SELECT 1 UNION ALL VALUES (2)`. SQLite + /// rejects a LIMIT after such a term, so a caller appending one leaves the statement as it is. + public static func hasCompoundValuesTerm(_ sql: String, grammar: SQLLexicalGrammar) -> Bool { + var found = false + var isFirstWord = true + forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in + defer { isFirstWord = false } + guard !isFirstWord, matchesKeyword("VALUES", in: buffer, start: start, end: end) else { return false } + found = true + return true + } + return found + } + + /// The statement with `LIMIT limit` after it, for a caller that has checked it states no limit of its own. + public static func appendingLimit(_ limit: Int, to sql: String) -> String { + "\(clauseHost(sql))\nLIMIT \(limit)" + } + + /// The statement with the ANSI `FETCH FIRST limit ROWS ONLY` after it. + public static func appendingFetchFirst(_ limit: Int, to sql: String) -> String { + "\(clauseHost(sql))\nFETCH FIRST \(limit) ROWS ONLY" + } + + /// The statement's first word, uppercased, past the blanks and comments `grammar` reads and a leading `(`, so a + /// caller can tell a `SELECT` or `WITH` from a `VALUES` that refuses an appended LIMIT. Nil when no word starts it. + public static func leadingKeyword(_ sql: String, grammar: SQLLexicalGrammar) -> String? { + let keyword = SQLLeadingComments.leadingCodeKeyword(SQLLeadingComments.stripping(sql, grammar: grammar)) + return keyword.isEmpty ? nil : keyword + } + + // The clause goes on a line of its own, so a trailing `--` comment cannot swallow it. A trailing `;` goes first, + // or the clause would start a statement of its own. + private static func clauseHost(_ sql: String) -> String { + var statement = StatementBlank.trimming(sql) + while statement.hasSuffix(";") { + statement = StatementBlank.trimming(String(statement.dropLast())) + } + return statement + } + + /// `OFFSET` is non-reserved in MySQL and MariaDB, so `SELECT offset, name FROM events` reaches + /// here as a column name. What separates the clause from the identifier is what follows it: a + /// row count, a placeholder, or `FIRST`/`NEXT` for the ANSI `FETCH` form. + private static func startsRowLimitClause( + in buffer: NSString, + start: Int, + end: Int, + grammar: SQLLexicalGrammar + ) -> Bool { + if matchesKeyword("FETCH", in: buffer, start: start, end: end) { + let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end, grammar: grammar) + return matchesKeyword("FIRST", in: buffer, start: wordStart, end: wordEnd) + || matchesKeyword("NEXT", in: buffer, start: wordStart, end: wordEnd) + } + guard matchesKeyword("LIMIT", in: buffer, start: start, end: end) + || matchesKeyword("OFFSET", in: buffer, start: start, end: end) + else { + return false + } + let index = nextCodeIndex(in: buffer, from: end, grammar: grammar) + guard index < buffer.length else { return false } + let next = buffer.character(at: index) + if next >= zero, next <= nine { return true } + if next == question || next == colon || next == dollar || next == at { return true } + let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end, grammar: grammar) + return matchesKeyword("ALL", in: buffer, start: wordStart, end: wordEnd) + } + + private static func nextWordRange(in buffer: NSString, from index: Int, grammar: SQLLexicalGrammar) -> (Int, Int) { + let start = nextCodeIndex(in: buffer, from: index, grammar: grammar) + var end = start + while end < buffer.length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 } + return (start, end) + } + + // Past blanks and the comments `grammar` reads, so `FETCH /* page */ FIRST 10 ROWS ONLY` is still a clause. + private static func nextCodeIndex(in buffer: NSString, from index: Int, grammar: SQLLexicalGrammar) -> Int { + var index = index + while index < buffer.length { + if isSpace(buffer.character(at: index)) { + index += 1 + } else if let span = SQLNonCodeSpan.span(at: index, in: buffer, grammar: grammar), span.kind.isComment { + index = max(span.end, index + 1) + } else { + break + } + } + return index + } + + private static func isSpace(_ ch: UInt16) -> Bool { + ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D + } + + /// Walks `sql` and reports every identifier that sits outside a string, comment or dollar quote + /// and at paren depth zero. The visitor returns true to stop the walk. + private static func forEachTopLevelIdentifier( + in sql: String, + grammar: SQLLexicalGrammar, + visit: (NSString, Int, Int) -> Bool + ) { + let buffer = sql as NSString + let length = buffer.length + guard length > 0 else { return } + + var parenDepth = 0 + var i = 0 + + while i < length { + let ch = buffer.character(at: i) + + if let span = SQLNonCodeSpan.span(at: i, in: buffer, grammar: grammar) { + i = max(span.end, i + 1) + continue + } + + if ch == openParen { + parenDepth += 1 + i += 1 + continue + } + + if ch == closeParen { + parenDepth -= 1 + i += 1 + continue + } + + if SqlDollarQuote.isIdentifierStart(ch), + i == 0 || !SqlDollarQuote.isIdentifierContinuation(buffer.character(at: i - 1)) { + var end = i + 1 + while end < length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 } + if parenDepth == 0, visit(buffer, i, end) { + return + } + i = end + continue + } + + i += 1 + } + } + + private static func isLimitingKeyword( + in buffer: NSString, + start: Int, + end: Int, + countsTop: Bool, + grammar: SQLLexicalGrammar + ) -> Bool { + if matchesKeyword("LIMIT", in: buffer, start: start, end: end) { + return true + } + // SQLite does not reserve FETCH, so `SELECT * FROM fetch` names a table; only FETCH FIRST or NEXT limits rows. + if matchesKeyword("FETCH", in: buffer, start: start, end: end) { + return startsRowLimitClause(in: buffer, start: start, end: end, grammar: grammar) + } + return countsTop && matchesKeyword("TOP", in: buffer, start: start, end: end) + } + + private static func matchesKeyword(_ keyword: String, in buffer: NSString, start: Int, end: Int) -> Bool { + let keywordBuffer = keyword as NSString + guard end - start == keywordBuffer.length else { return false } + for offset in 0.. UInt16 { + (ch >= 0x61 && ch <= 0x7A) ? ch - 0x20 : ch + } + + private static let zero = UInt16(UnicodeScalar("0").value) + private static let nine = UInt16(UnicodeScalar("9").value) + private static let question = UInt16(UnicodeScalar("?").value) + private static let colon = UInt16(UnicodeScalar(":").value) + private static let at = UInt16(UnicodeScalar("@").value) + private static let dollar = UInt16(UnicodeScalar("$").value) + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) +} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLSubstringMatch.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLSubstringMatch.swift new file mode 100644 index 0000000000..60f3b662a6 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLSubstringMatch.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Substring tests written without LIKE, for an engine that refuses a LIKE pattern past a byte limit. +/// +/// `operand` is the SQL expression searched, `literal` an already quoted string literal of the plain value, with no +/// LIKE escaping, since nothing here reads `%` or `_` as a wildcard. `fold` names the case-fold function applied to +/// both sides, such as `LOWER`. Measured on SQLite 3.54: every form gives NULL for a NULL operand, as LIKE does, so a +/// "does not contain" still leaves NULL rows out. +public enum SQLSubstringMatch { + public static func contains(_ operand: String, literal: String, fold: String) -> String { + "instr(\(fold)(\(operand)), \(fold)(\(literal))) > 0" + } + + public static func notContains(_ operand: String, literal: String, fold: String) -> String { + "instr(\(fold)(\(operand)), \(fold)(\(literal))) = 0" + } + + public static func startsWith(_ operand: String, literal: String, fold: String) -> String { + "instr(\(fold)(\(operand)), \(fold)(\(literal))) = 1" + } + + /// Compares the tail, because `instr` reports only the first match: measured on SQLite 3.54, 'abab' ends with + /// 'ab', yet its first 'ab' is at 1, not 3. The value must not be empty: `substr(x, 0)` is the whole of `x`, so an + /// empty one matches no row where `LIKE '%'` matches every row. An empty value never exceeds a limit anyway. + public static func endsWith(_ operand: String, literal: String, fold: String) -> String { + "substr(\(fold)(\(operand)), -length(\(literal))) = \(fold)(\(literal))" + } + + /// Whether the LIKE pattern as sent is longer than `limit` bytes of UTF-8: `likePatternBody` with its wildcard + /// escapes, plus the `wildcards` `%` the caller adds. Measured on SQLite 3.54 with the limit at 50: a 50-byte + /// pattern runs and a 51-byte one is "LIKE or GLOB pattern too complex", and a doubled quote counts once. A SQL + /// literal body therefore over-counts each quote, which only switches to the LIKE-free form early. + public static func exceeds(_ likePatternBody: String, wildcards: Int, limit: Int?) -> Bool { + guard let limit else { return false } + return likePatternBody.utf8.count + wildcards > limit + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProCoreTypesTests/DatabaseTypeSQLiteFamilyTests.swift b/apple/Packages/TableProCore/Tests/TableProCoreTypesTests/DatabaseTypeSQLiteFamilyTests.swift new file mode 100644 index 0000000000..0f2891c2e3 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProCoreTypesTests/DatabaseTypeSQLiteFamilyTests.swift @@ -0,0 +1,17 @@ +import TableProCoreTypes +import Testing + +@Test("The engines that run SQLite itself are its family", arguments: [ + DatabaseType.sqlite, .cloudflareD1, .libsql, .turso +]) +func runsSQLite(type: DatabaseType) { + #expect(type.isSQLiteFamily) +} + +// PluginKit's SqlDialect files DuckDB under SQLite, yet DuckDB types and compares values its own way. +@Test("An engine that does not run SQLite is not its family", arguments: [ + DatabaseType.duckdb, .postgresql, .mysql, .clickhouse +]) +func doesNotRunSQLite(type: DatabaseType) { + #expect(!type.isSQLiteFamily) +} diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift index aa8415c899..9d2cb27f8a 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift @@ -55,6 +55,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { final class MockDriverFactory: DriverFactory, @unchecked Sendable { var drivers: [String: any DatabaseDriver] = [:] + var dialsTunnels = true private(set) var receivedConnections: [DatabaseConnection] = [] private(set) var receivedPreTunnelHosts: [String?] = [] @@ -72,6 +73,8 @@ final class MockDriverFactory: DriverFactory, @unchecked Sendable { } func supportedTypes() -> [DatabaseType] { drivers.keys.map(DatabaseType.init(rawValue:)) } + + func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool { dialsTunnels && connection.sshEnabled } } final class MockSecureStore: SecureStore, Sendable { diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index 2dc1e5296d..54593e37d6 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -131,6 +131,29 @@ struct ConnectionManagerTests { #expect(manager.session(for: connection.id) == nil) } + @Test("A tunnel the factory does not dial stays closed, and the driver gets the saved host") + func tunnelTheFactoryDeclinesStaysClosed() async throws { + let factory = MockDriverFactory() + factory.dialsTunnels = false + factory.drivers["mock"] = MockDatabaseDriver() + let ssh = MockSSHProvider() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore(), sshProvider: ssh) + let connection = DatabaseConnection( + name: "File", + type: DatabaseType(rawValue: "mock"), + host: "", + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "jump.example.com") + ) + + _ = try await manager.connect(connection) + await manager.disconnect(connection.id) + + #expect(ssh.tunnelledConnectionIds.isEmpty) + #expect(factory.receivedConnections.first?.host == "") + #expect(factory.receivedPreTunnelHosts.first == .some(nil)) + } + @Test("Connect dials the Mac's route, forwards to its socket and names its SSH profile") func connectDialsTheMacRoute() async throws { let factory = MockDriverFactory() diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorLikeLimitTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorLikeLimitTests.swift new file mode 100644 index 0000000000..00556e900a --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorLikeLimitTests.swift @@ -0,0 +1,76 @@ +import Foundation +@testable import TableProModels +@testable import TableProPluginKit +@testable import TableProQuery +import Testing + +// D1 refuses a LIKE or GLOB pattern over 50 bytes; measured on sqlite3 with the same limit, a 51-byte pattern fails +// with "LIKE or GLOB pattern too complex". +struct FilterSQLGeneratorLikeLimitTests { + private func dialect(limit: Int?) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .explicit, + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + likePatternByteLimit: limit + ) + } + + private func clause(_ value: String, op: FilterOperator, limit: Int? = 50) -> String { + FilterSQLGenerator(dialect: dialect(limit: limit), databaseType: .cloudflareD1).generateWhereClause( + from: [TableFilter(columnName: "note", filterOperator: op, value: value)], + logicMode: .and, + columns: [ColumnInfo(name: "note", typeName: "TEXT")] + ) + } + + @Test("A contains pattern at the limit stays a LIKE, byte for byte") + func patternAtTheLimitStaysLike() { + let body = String(repeating: "a", count: 48) + + #expect(clause(body, op: .contains) == "WHERE \"note\" LIKE '%\(body)%' ESCAPE '!'") + } + + @Test("A contains pattern past the limit is matched as a plain substring, case folded like D1's LIKE") + func patternPastTheLimitUsesInstr() { + let body = String(repeating: "a", count: 49) + + #expect(clause(body, op: .contains) == "WHERE instr(LOWER(\"note\"), LOWER('\(body)')) > 0") + } + + @Test("Starts with and ends with past the limit match the head and the tail") + func prefixAndSuffixPastTheLimit() { + let body = String(repeating: "b", count: 50) + + #expect(clause(body, op: .startsWith) == "WHERE instr(LOWER(\"note\"), LOWER('\(body)')) = 1") + #expect(clause(body, op: .endsWith) == "WHERE substr(LOWER(\"note\"), -length('\(body)')) = LOWER('\(body)')") + } + + // A `!` escape in the literal would be searched for as text: instr('a%b', '!%') is 0. + @Test("The substring form takes the value as written, with no LIKE escape, and still doubles a quote") + func substringLiteralHasNoLikeEscape() { + let padding = String(repeating: "x", count: 44) + + #expect(clause(padding + "50%_it's", op: .contains) + == "WHERE instr(LOWER(\"note\"), LOWER('\(padding)50%_it''s')) > 0") + } + + @Test("The limit counts bytes of UTF-8, not characters", arguments: [(24, false), (25, true)]) + func limitCountsBytes(count: Int, exceeds: Bool) { + let result = clause(String(repeating: "é", count: count), op: .contains) + + #expect(result.contains("instr(") == exceeds) + } + + @Test("An engine with no limit keeps a LIKE however long the value") + func noLimitKeepsLike() { + let body = String(repeating: "a", count: 200) + + #expect(clause(body, op: .contains, limit: nil) == "WHERE \"note\" LIKE '%\(body)%' ESCAPE '!'") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 1df53ee3da..5c8fc30e43 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -468,6 +468,16 @@ struct FilterSQLGeneratorTests { #expect(engineClause(.sqlite, column, "5", op: .greaterThan) == "WHERE \"v\" > 5") } + // The Mac's FilterSQLGenerator already reads D1 as SQLite through SQLTypeFamily, so the iOS filter must too. + @Test("Every engine that runs SQLite matches an untyped column either way it was stored", + arguments: [DatabaseType.cloudflareD1, .libsql, .turso]) + func sqliteFamilyMatchesEitherStorageClass(type: DatabaseType) { + let column = ColumnInfo(name: "v", typeName: "") + #expect(engineClause(type, column, "5") == "WHERE (\"v\" IN (5, '5') AND CAST(\"v\" AS TEXT) = '5')") + #expect(FilterColumnKind.keyLiteral("5", column: nil, databaseType: type) == .numberOrText("5")) + #expect(FilterColumnKind.keyLiteral("5", column: nil, databaseType: .duckdb) == .text("5")) + } + @Test("A typed SQLite column keeps its literal") func sqliteTypedColumnUnchanged() { #expect(engineClause(.sqlite, ColumnInfo(name: "id", typeName: "INTEGER"), "5") == "WHERE \"id\" = 5") diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementRowLimitTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementRowLimitTests.swift new file mode 100644 index 0000000000..dc18a61e3f --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementRowLimitTests.swift @@ -0,0 +1,237 @@ +import Foundation +import TableProSQLGrammar +import Testing + +struct SQLStatementRowLimitTests { + private static func grammar(_ typeId: String) -> SQLLexicalGrammar { + SQLLexicalReadings.resolve(databaseTypeId: typeId, declared: nil, session: nil).execution + } + + private static let standard = SQLLexicalGrammar.ansi + private static let postgreSQL = grammar("PostgreSQL") + private static let mySQL = grammar("MySQL") + private static let sqlite = grammar("SQLite") + + private func hasLimit(_ sql: String, countsTop: Bool = false, grammar: SQLLexicalGrammar = standard) -> Bool { + SQLStatementRowLimit.hasExplicitRowLimit(sql, countsTop: countsTop, grammar: grammar) + } + + private func clauseOffset(_ sql: String, grammar: SQLLexicalGrammar = postgreSQL) -> Int? { + SQLStatementRowLimit.firstRowLimitClauseOffset(sql, grammar: grammar) + } + + // MARK: - Explicit row limit + + @Test("A bare SELECT has no explicit row limit") + func bareSelect() { + #expect(!hasLimit("SELECT * FROM users")) + } + + @Test("A SELECT ending in ORDER BY has no explicit row limit") + func orderByIsNotALimit() { + #expect(!hasLimit("SELECT * FROM alert_events ORDER BY alert_time")) + #expect(!hasLimit("SELECT *\nFROM alert_events ORDER BY alert_time")) + #expect(!hasLimit("SELECT * FROM t WHERE created_at >= '2026-01-01' ORDER BY id DESC")) + #expect(!hasLimit("SELECT * FROM t ORDER BY a, b DESC")) + } + + @Test("Detects a top-level LIMIT") + func topLevelLimit() { + #expect(hasLimit("SELECT * FROM users LIMIT 10")) + #expect(hasLimit("select * from users limit 10 offset 5")) + #expect(hasLimit("SELECT * FROM t ORDER BY id DESC LIMIT 10")) + } + + @Test("Detects FETCH FIRST") + func fetchFirst() { + #expect(hasLimit("SELECT * FROM t FETCH FIRST 5 ROWS ONLY")) + } + + @Test("A table or column named fetch is no FETCH clause") + func fetchAsIdentifier() { + #expect(!hasLimit("SELECT * FROM fetch", grammar: Self.sqlite)) + #expect(!hasLimit("SELECT fetch FROM jobs ORDER BY fetch", grammar: Self.sqlite)) + #expect(hasLimit("SELECT * FROM fetch FETCH NEXT 5 ROWS ONLY")) + } + + @Test("A comment between FETCH and FIRST still makes a FETCH clause") + func fetchWithComment() { + #expect(hasLimit("SELECT * FROM events FETCH /* page */ FIRST 5000 ROWS ONLY", grammar: Self.postgreSQL)) + #expect(hasLimit("SELECT * FROM events FETCH -- page\nNEXT 10 ROWS ONLY", grammar: Self.postgreSQL)) + #expect(clauseOffset("SELECT * FROM events LIMIT /* n */ 10") == 21) + } + + @Test("A VALUES list after a compound operator is a compound VALUES term", arguments: [ + ("SELECT 1 UNION ALL VALUES (2)", true), + ("SELECT 1 union values (2), (3)", true), + ("SELECT * FROM t WHERE id IN (VALUES (1), (2))", false), + ("VALUES (1), (2)", false), + ("SELECT 'VALUES' FROM t -- VALUES", false) + ]) + func compoundValuesTerm(sql: String, expected: Bool) { + #expect(SQLStatementRowLimit.hasCompoundValuesTerm(sql, grammar: Self.sqlite) == expected) + } + + // A caller appending a LIMIT takes the scanner's statement, which ends before its terminator and any comment + // after it: `SELECT 1; -- note` with a LIMIT after the comment would be a second, invalid statement. + @Test("The scanned statement leaves out a comment after its terminator") + func scannedStatementTakesTheLimit() { + let statements = SQLStatementScanner.allStatements(in: "SELECT * FROM events; -- note", grammar: Self.sqlite) + #expect(statements == ["SELECT * FROM events"]) + #expect(SQLStatementRowLimit.appendingLimit(3, to: statements[0]) == "SELECT * FROM events\nLIMIT 3") + } + + @Test("Counts TOP only when asked to") + func topOnlyWhenCounted() { + #expect(hasLimit("SELECT TOP 5 * FROM t", countsTop: true)) + #expect(!hasLimit("SELECT TOP 5 * FROM t")) + #expect(!hasLimit("SELECT top FROM quotas")) + } + + @Test("A top-level OFFSET without LIMIT is not an explicit row limit") + func offsetWithoutLimit() { + #expect(!hasLimit("SELECT * FROM users OFFSET 5")) + } + + @Test("An inner LIMIT in a CTE or subquery does not bound the outer statement") + func innerLimitDoesNotCount() { + #expect(!hasLimit("WITH cte AS (SELECT * FROM t LIMIT 5) SELECT * FROM cte")) + #expect(!hasLimit("SELECT * FROM (SELECT * FROM t LIMIT 5) sub")) + #expect(!hasLimit("(SELECT a FROM t1 LIMIT 5) UNION (SELECT b FROM t2 LIMIT 5)")) + } + + @Test("Detects a LIMIT that applies to a whole UNION") + func unionLimit() { + #expect(hasLimit("SELECT a FROM t1 UNION SELECT b FROM t2 LIMIT 5")) + #expect(!hasLimit("SELECT a FROM t1 UNION ALL SELECT b FROM t2")) + } + + @Test("Ignores LIMIT-like text inside comments") + func ignoresComments() { + #expect(!hasLimit("SELECT * FROM t -- fetch it all")) + #expect(!hasLimit("SELECT * FROM t /* LIMIT 9 */")) + #expect(!hasLimit("SELECT * FROM t # see LIMIT docs", grammar: Self.mySQL)) + } + + @Test("Ignores LIMIT-like text inside string literals") + func ignoresStringLiterals() { + #expect(!hasLimit("SELECT * FROM t WHERE note = 'no LIMIT here'")) + } + + @Test("Ignores LIMIT inside dollar-quoted bodies for PostgreSQL") + func ignoresDollarQuotedBodies() { + #expect(!hasLimit("SELECT $tag$LIMIT 5$tag$ FROM t", grammar: Self.postgreSQL)) + } + + @Test("Does not mistake identifiers containing limit for a LIMIT clause") + func identifiersContainingLimit() { + #expect(!hasLimit("SELECT limit_used FROM quotas")) + #expect(!hasLimit("SELECT `limit` FROM quotas", grammar: Self.mySQL)) + } + + @Test("Detects a LIMIT before a trailing semicolon") + func trailingSemicolon() { + #expect(hasLimit("SELECT * FROM t LIMIT 5;")) + #expect(!hasLimit("SELECT * FROM t;")) + } + + // MARK: - First row-limiting clause + + @Test("firstRowLimitClauseOffset points at the top-level LIMIT, OFFSET or FETCH") + func clauseOffsetFindsTopLevelClause() throws { + let sql = "SELECT * FROM orders LIMIT 100" + let offset = try #require(clauseOffset(sql)) + #expect(offset == 21) + #expect((sql as NSString).substring(from: offset) == "LIMIT 100") + } + + @Test("firstRowLimitClauseOffset finds a bare OFFSET with no LIMIT") + func clauseOffsetFindsBareOffset() { + #expect(clauseOffset("SELECT * FROM orders OFFSET 20") == 21) + } + + @Test("firstRowLimitClauseOffset finds an ANSI FETCH clause") + func clauseOffsetFindsFetch() { + #expect(clauseOffset("SELECT * FROM orders OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY") == 21) + } + + @Test("firstRowLimitClauseOffset ignores a LIMIT inside a subquery or a string") + func clauseOffsetIgnoresNested() { + #expect(clauseOffset("SELECT * FROM (SELECT * FROM t LIMIT 5) s") == nil) + #expect(clauseOffset("SELECT * FROM t WHERE note = 'no LIMIT here'") == nil) + } + + @Test("firstRowLimitClauseOffset ignores TOP, which is not a trailing clause") + func clauseOffsetIgnoresTop() { + #expect(clauseOffset("SELECT TOP 10 * FROM t", grammar: Self.standard) == nil) + } + + @Test("firstRowLimitClauseOffset ignores OFFSET and FETCH used as column names") + func clauseOffsetIgnoresIdentifiers() { + #expect(clauseOffset("SELECT offset, name FROM events", grammar: Self.mySQL) == nil) + #expect(clauseOffset("SELECT fetch FROM t", grammar: Self.mySQL) == nil) + #expect(clauseOffset("SELECT offset FROM t ORDER BY offset", grammar: Self.mySQL) == nil) + } + + @Test("firstRowLimitClauseOffset accepts a placeholder row count") + func clauseOffsetAcceptsPlaceholders() { + #expect(clauseOffset("SELECT * FROM t LIMIT ?", grammar: Self.mySQL) != nil) + #expect(clauseOffset("SELECT * FROM t LIMIT $1") != nil) + #expect(clauseOffset("SELECT * FROM t LIMIT ALL") != nil) + } + + // MARK: - Appending a limit + + @Test("The appended clause goes on a line of its own, after the trailing semicolons and blanks", arguments: [ + ("SELECT * FROM t", "SELECT * FROM t\nLIMIT 11"), + ("SELECT * FROM t;", "SELECT * FROM t\nLIMIT 11"), + (" SELECT * FROM t ; ;\n\t", "SELECT * FROM t\nLIMIT 11"), + ("SELECT * FROM t -- newest first", "SELECT * FROM t -- newest first\nLIMIT 11"), + ]) + func appendingLimit(sql: String, expected: String) { + #expect(SQLStatementRowLimit.appendingLimit(11, to: sql) == expected) + } + + @Test("A LIMIT appended after a trailing line comment is still read as code") + func appendedLimitSurvivesLineComment() { + let bounded = SQLStatementRowLimit.appendingLimit(11, to: "SELECT * FROM t -- newest first") + #expect(SQLStatementRowLimit.hasExplicitRowLimit(bounded, countsTop: false, grammar: Self.sqlite)) + } + + @Test("FETCH FIRST is appended the same way", arguments: [ + ("SELECT * FROM t;\n", "SELECT * FROM t\nFETCH FIRST 11 ROWS ONLY"), + ("SELECT * FROM t -- newest first", "SELECT * FROM t -- newest first\nFETCH FIRST 11 ROWS ONLY"), + ]) + func appendingFetchFirst(sql: String, expected: String) { + #expect(SQLStatementRowLimit.appendingFetchFirst(11, to: sql) == expected) + } + + // MARK: - Leading keyword + + @Test("The leading keyword is the first word, uppercased, past blanks and comments", arguments: [ + ("SELECT 1", "SELECT"), + ("select * from t", "SELECT"), + (" \n\tWith c AS (SELECT 1) SELECT * FROM c", "WITH"), + ("-- latest orders\nSELECT * FROM orders", "SELECT"), + ("/* one */ /* two */\n-- three\nwith c AS (SELECT 1) SELECT * FROM c", "WITH"), + ("VALUES (1), (2)", "VALUES"), + ("(SELECT 1) UNION (SELECT 2)", "SELECT"), + ("pragma table_info(t)", "PRAGMA"), + ]) + func leadingKeyword(sql: String, expected: String) { + #expect(SQLStatementRowLimit.leadingKeyword(sql, grammar: Self.sqlite) == expected) + } + + @Test("A statement that is only blanks and comments has no leading keyword", arguments: [ + "", " \n", "-- nothing here", "/* only a comment */", + ]) + func noLeadingKeyword(sql: String) { + #expect(SQLStatementRowLimit.leadingKeyword(sql, grammar: Self.sqlite) == nil) + } + + @Test("A comment marker the grammar does not read as one is not skipped") + func leadingKeywordFollowsTheGrammar() { + #expect(SQLStatementRowLimit.leadingKeyword("# note\nSELECT 1", grammar: Self.mySQL) == "SELECT") + #expect(SQLStatementRowLimit.leadingKeyword("# note\nSELECT 1", grammar: Self.sqlite) == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLSubstringMatchTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLSubstringMatchTests.swift new file mode 100644 index 0000000000..6b00d2b396 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLSubstringMatchTests.swift @@ -0,0 +1,49 @@ +import Foundation +import TableProSQLGrammar +import Testing + +struct SQLSubstringMatchTests { + @Test("Each test folds both sides with the function it is given") + func forms() { + #expect(SQLSubstringMatch.contains("\"name\"", literal: "'ab'", fold: "LOWER") == "instr(LOWER(\"name\"), LOWER('ab')) > 0") + #expect(SQLSubstringMatch.notContains("\"name\"", literal: "'ab'", fold: "LOWER") == "instr(LOWER(\"name\"), LOWER('ab')) = 0") + #expect(SQLSubstringMatch.startsWith("\"name\"", literal: "'ab'", fold: "LOWER") == "instr(LOWER(\"name\"), LOWER('ab')) = 1") + #expect(SQLSubstringMatch.contains("c", literal: "'x'", fold: "UPPER") == "instr(UPPER(c), UPPER('x')) > 0") + } + + @Test("Ends with compares the tail, never the position of the first match") + func endsWithComparesTheTail() { + let condition = SQLSubstringMatch.endsWith("\"name\"", literal: "'ab'", fold: "LOWER") + #expect(condition == "substr(LOWER(\"name\"), -length('ab')) = LOWER('ab')") + #expect(!condition.contains("instr")) + } + + @Test("The literal goes in as given, so a percent or underscore stays a plain character") + func literalIsNotAPattern() { + #expect( + SQLSubstringMatch.contains("c", literal: "'50%_''s'", fold: "LOWER") + == "instr(LOWER(c), LOWER('50%_''s')) > 0" + ) + } + + @Test("A pattern exceeds the limit only past it, counting the wildcards", arguments: [ + (String(repeating: "a", count: 48), 2, 50, false), + (String(repeating: "a", count: 49), 2, 50, true), + (String(repeating: "a", count: 49), 1, 50, false), + (String(repeating: "a", count: 50), 1, 50, true), + ]) + func exceedsCountsWildcards(body: String, wildcards: Int, limit: Int, expected: Bool) { + #expect(SQLSubstringMatch.exceeds(body, wildcards: wildcards, limit: limit) == expected) + } + + @Test("The limit is in bytes of UTF-8, not characters") + func exceedsCountsBytes() { + #expect(!SQLSubstringMatch.exceeds(String(repeating: "é", count: 24), wildcards: 2, limit: 50)) + #expect(SQLSubstringMatch.exceeds(String(repeating: "é", count: 25), wildcards: 2, limit: 50)) + } + + @Test("No limit never exceeds") + func noLimit() { + #expect(!SQLSubstringMatch.exceeds(String(repeating: "a", count: 10_000), wildcards: 2, limit: nil)) + } +} diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift index 32dc5df3da..75ac613d63 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift @@ -7,7 +7,7 @@ import Foundation import TableProSQLQuoting // The pragma drops a default's parentheses and a bare word's quotes (3.54.0); only an expression has a call or an operator. -internal func cloudflareD1DefaultValueFromCatalog(_ value: String?) -> String? { +nonisolated internal func cloudflareD1DefaultValueFromCatalog(_ value: String?) -> String? { guard let value, !value.isEmpty else { return nil } let upper = value.uppercased() if upper == "NULL" || upper == "TRUE" || upper == "FALSE" @@ -20,8 +20,8 @@ internal func cloudflareD1DefaultValueFromCatalog(_ value: String?) -> String? { return cloudflareD1LooksLikeExpression(value) ? "(\(value))" : SQLQuoting.stringLiteral(value) } -private let cloudflareD1ExpressionScalars: Set = ["(", ")", "|", "+", "-", "*", "/", "%", "<", ">", "=", "!", "~", "&", " ", ","] +nonisolated private let cloudflareD1ExpressionScalars: Set = ["(", ")", "|", "+", "-", "*", "/", "%", "<", ">", "=", "!", "~", "&", " ", ","] -internal func cloudflareD1LooksLikeExpression(_ value: String) -> Bool { +nonisolated internal func cloudflareD1LooksLikeExpression(_ value: String) -> Bool { value.unicodeScalars.contains { cloudflareD1ExpressionScalars.contains($0) } } diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index cff710c29a..bfaad869b5 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -7,7 +7,7 @@ import Foundation import os import TableProPluginKit -final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { +nonisolated final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "Cloudflare D1 Driver" static let pluginVersion = "1.0.0" static let pluginDescription = "Cloudflare D1 serverless SQLite-compatible database support via REST API" @@ -15,6 +15,9 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Cloudflare D1" + // The Mac app repeats this id in its curated metadata and its d1:// import: it cannot import a registry plugin. + static let accountIdFieldId = "cfAccountId" + static let supportsRenameTable = true static let supportsRenameView = false static let databaseDisplayName = "Cloudflare D1" @@ -100,12 +103,14 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, textCastTypeName: nil, functionNamesAreCaseInsensitive: true, - typeNamesAreCaseInsensitive: true + typeNamesAreCaseInsensitive: true, + // D1 refuses a LIKE or GLOB pattern over 50 bytes, so a longer search has to match without LIKE. + likePatternByteLimit: 50 ) static let additionalConnectionFields: [ConnectionField] = [ ConnectionField( - id: "cfAccountId", + id: accountIdFieldId, label: String(localized: "Account ID"), placeholder: "Cloudflare Account ID", required: true, diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift index a5fd32d4ba..7b25b22237 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -extension CloudflareD1PluginDriver { +nonisolated extension CloudflareD1PluginDriver { /// SQLite's rules, and SQLite's one rename: `ALTER TABLE` refuses a view. A D1 database is an /// API object whose edit endpoint accepts only read replication, so its name cannot change. func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift index 437eff6e3e..3df63a74eb 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -extension CloudflareD1PluginDriver { +nonisolated extension CloudflareD1PluginDriver { var providesBulkTriggerFetch: Bool { true } func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 495f97eb9d..3ef3e11102 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -10,7 +10,7 @@ import TableProSQLGrammar // MARK: - Error -private struct CloudflareD1Error: Error, PluginDriverError { +nonisolated private struct CloudflareD1Error: Error, PluginDriverError { let message: String var pluginErrorMessage: String { message } @@ -20,20 +20,35 @@ private struct CloudflareD1Error: Error, PluginDriverError { // MARK: - Plugin Driver -final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable { +nonisolated final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig + private let sessionConfiguration: URLSessionConfiguration private var httpClient: D1HttpClient? private var _serverVersion: String? + private var _resolvedDatabaseName: String? private var databaseNameToUuid: [String: String] = [:] private let lock = NSLock() private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareD1PluginDriver") + private static let lexicalGrammar: SQLLexicalGrammar = + SQLLexicalProfile.curated(forDatabaseTypeId: CloudflareD1Plugin.databaseTypeId)?.grammar ?? .ansi + var serverVersion: String? { lock.lock() defer { lock.unlock() } return _serverVersion } + + /// The name of the database the client points at, set at connect and by every switch, whether the connection + /// named it or gave its UUID. + /// + /// PluginKit's `currentDatabase` stays nil on purpose. The Mac adopts that value at connect as the browse + /// database, so a connection stored by UUID would re-key its per-database state (favorites, filters, tabs). + var resolvedDatabaseName: String? { + lock.withLock { _resolvedDatabaseName } + } + var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } var currentSchema: String? { nil } @@ -50,14 +65,17 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable ] } - init(config: DriverConnectionConfig) { + /// Every client the driver creates copies `sessionConfiguration`, so a test can route the driver's requests + /// through its own `URLProtocol`. + init(config: DriverConnectionConfig, sessionConfiguration: URLSessionConfiguration = .default) { self.config = config + self.sessionConfiguration = sessionConfiguration } // MARK: - Connection func connect() async throws { - guard let accountId = config.additionalFields["cfAccountId"], !accountId.isEmpty else { + guard let accountId = config.additionalFields[CloudflareD1Plugin.accountIdFieldId], !accountId.isEmpty else { throw CloudflareD1Error(message: String(localized: "Account ID is required")) } @@ -76,54 +94,64 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable ) let deadline = PluginConnectDeadline(milliseconds: connectTimeout) - let databaseId: String - if isUuid(databaseName) { - databaseId = databaseName - } else { - let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: "") - client.createSession(connectDeadline: deadline) - defer { client.invalidateSession() } - let databases = try await client.listDatabases() + let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: "") + client.createSession(configuration: sessionConfiguration, connectDeadline: deadline) - guard let match = databases.first(where: { $0.name == databaseName }) else { + // The real failure goes to the user: an expired token, a rate limit or no network each needs its own fix. + let details: D1DatabaseInfo + do { + guard let found = try await Self.lookUpDatabase(databaseName, client: client) else { throw CloudflareD1Error( message: String(format: String(localized: "Database '%@' not found in account"), databaseName) ) } - databaseId = match.uuid - - lock.withLock { - for db in databases { - databaseNameToUuid[db.name] = db.uuid - } - } - } - - let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: databaseId) - client.createSession(connectDeadline: deadline) - - do { - let details = try await client.getDatabaseDetails() - lock.withLock { _serverVersion = details.version ?? "D1" } - client.finishConnecting() + details = found } catch { client.invalidateSession() - Self.logger.error("Connection test failed: \(error.localizedDescription)") - throw CloudflareD1Error(message: String(localized: "Failed to connect to Cloudflare D1")) + Self.logger.error("Cloudflare D1 connect failed: \(Self.logSummary(of: error), privacy: .public)") + throw error } - lock.withLock { httpClient = client } + client.databaseId = details.uuid + client.finishConnecting() - Self.logger.debug("Connected to Cloudflare D1 database: \(databaseName)") + lock.withLock { + _serverVersion = details.version ?? "D1" + _resolvedDatabaseName = details.name + databaseNameToUuid[details.name] = details.uuid + httpClient = client + } + + Self.logger.debug("Connected to Cloudflare D1 database: \(details.name)") } func disconnect() { lock.lock() httpClient?.invalidateSession() httpClient = nil + _resolvedDatabaseName = nil lock.unlock() } + /// The databases an account holds, for a form that offers them before any connection exists. It reads through + /// the same paged client a connected driver uses. + static func listDatabases( + accountId: String, + apiToken: String, + sessionConfiguration: URLSessionConfiguration = .default + ) async throws -> [D1DatabaseInfo] { + guard !accountId.isEmpty else { + throw CloudflareD1Error(message: String(localized: "Account ID is required")) + } + guard !apiToken.isEmpty else { + throw CloudflareD1Error(message: String(localized: "API Token is required")) + } + let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: "") + client.createSession(configuration: sessionConfiguration) + defer { client.invalidateSession() } + return try await client.listDatabases() + } + func ping() async throws { _ = try await execute(query: "SELECT 1") } @@ -153,11 +181,16 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let anyParams: [Any?] = parameters.map { param -> Any? in + let anyParams: [Any?] = try parameters.map { param -> Any? in switch param { case .null: return nil case .text(let s): return s - case .bytes(let d): return d.base64EncodedString() + // The API takes JSON parameters and documents no binary form, so bytes sent as text would be stored as + // that text, not as the value. + case .bytes: + throw CloudflareD1Error( + message: String(localized: "Cloudflare D1 cannot bind a binary value through its API.") + ) } } let payload = try await client.executeRaw(sql: trimmed, params: anyParams) @@ -165,6 +198,52 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable return mapRawResult(payload, executionTime: executionTime) } + /// D1 fails a result over 50 MB from 2026-11-04 and answers each request with the whole result, so a read that + /// names no limit is sent with one: one row past the cap, which tells a trimmed result from a complete one. + func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? { + guard rowCap > 0, let statement = Self.boundableStatement(query) else { return nil } + let sent = SQLStatementRowLimit.hasExplicitRowLimit(statement, countsTop: false, grammar: Self.lexicalGrammar) + ? query + : SQLStatementRowLimit.appendingLimit(rowCap + 1, to: statement) + let result = try await execute(query: sent) + guard result.rows.count > rowCap else { return result } + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: Array(result.rows.prefix(rowCap)), + rowsAffected: result.rowsAffected, + timing: result.timing, + isTruncated: true, + statusMessage: result.statusMessage, + columnMeta: result.columnMeta + ) + } + + /// The one statement of `query`, past its terminator and any comment after it, when a trailing `LIMIT` is valid + /// on it: a SELECT, or a WITH list whose statement is a SELECT, with no compound term that is a bare VALUES list, + /// which SQLite takes no LIMIT after. Nil for several statements: D1 runs them all and answers with the first + /// one's rows, so a LIMIT appended to the last would bound the wrong one. + private static func boundableStatement(_ query: String) -> String? { + let grammar = lexicalGrammar + let statements = SQLStatementScanner.allStatements(in: query, grammar: grammar) + guard statements.count == 1, let statement = statements.first, + let keyword = SQLStatementRowLimit.leadingKeyword(statement, grammar: grammar) + else { return nil } + switch keyword { + case "SELECT": + return SQLStatementRowLimit.hasCompoundValuesTerm(statement, grammar: grammar) ? nil : statement + case "WITH": + guard let reading = SQLCommonTableExpressions.read(statement, grammar: grammar) else { return nil } + let main = (statement as NSString).substring(from: reading.mainStatement) + guard SQLStatementRowLimit.leadingKeyword(main, grammar: grammar) == "SELECT", + !SQLStatementRowLimit.hasCompoundValuesTerm(main, grammar: grammar) + else { return nil } + return statement + default: + return nil + } + } + func executeBatch(queries: [String]) async throws -> [PluginQueryResult] { guard let client = getClient() else { throw CloudflareD1Error.notConnected @@ -229,7 +308,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let rawRows = payload.results.rows ?? [] if !rawRows.isEmpty { let rows = rawRows.map { rawRow in - rawRow.map(\.stringValue).map(PluginCellValue.fromOptional) + rawRow.map(\.cellValue) } continuation.yield(.rows(rows)) } @@ -465,47 +544,78 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable throw CloudflareD1Error.notConnected } - let uuid = lock.withLock { databaseNameToUuid[name] } - - guard let databaseId = uuid ?? (isUuid(name) ? name : nil) else { - throw CloudflareD1Error(message: String(format: String(localized: "Database '%@' not found"), name)) + let cached = lock.withLock { databaseNameToUuid[name] } + let databaseId: String + if let cached { + databaseId = cached + } else if Self.isUuid(name) { + databaseId = name + } else { + guard let found = try await Self.lookUpDatabase(name, client: client) else { + throw CloudflareD1Error(message: String(format: String(localized: "Database '%@' not found"), name)) + } + databaseId = found.uuid } try await client.deleteDatabase(databaseId: databaseId) - lock.withLock { _ = databaseNameToUuid.removeValue(forKey: name) } + lock.withLock { databaseNameToUuid = databaseNameToUuid.filter { $0.value != databaseId } } } func switchDatabase(to database: String) async throws { - var uuid = lock.withLock { databaseNameToUuid[database] } - - if uuid == nil && isUuid(database) { - uuid = database + let cached = lock.withLock { () -> (name: String, uuid: String)? in + if let uuid = databaseNameToUuid[database] { return (name: database, uuid: uuid) } + return databaseNameToUuid.first { $0.value == database }.map { (name: $0.key, uuid: $0.value) } } - if uuid == nil { + let target: (name: String, uuid: String) + if let cached { + target = cached + } else { guard let client = getClient() else { throw CloudflareD1Error.notConnected } - - let databases = try await client.listDatabases() - - uuid = lock.withLock { - databaseNameToUuid.removeAll() - for db in databases { - databaseNameToUuid[db.name] = db.uuid - } - return databaseNameToUuid[database] + guard let found = try await Self.lookUpDatabase(database, client: client) else { + throw CloudflareD1Error( + message: String(format: String(localized: "Database '%@' not found"), database) + ) } + target = (found.name, found.uuid) } - guard let resolvedUuid = uuid else { + lock.withLock { + guard let httpClient else { return } + databaseNameToUuid[target.name] = target.uuid + httpClient.databaseId = target.uuid + _resolvedDatabaseName = target.name + } + } + + /// One GET answers a name or a UUID with both. Nil when the account holds no such database; every other failure + /// is thrown as it came, so an expired token never reads as a missing database. + private static func lookUpDatabase(_ nameOrId: String, client: D1HttpClient) async throws -> D1DatabaseInfo? { + // The text goes into the URL path, so only the two shapes D1 accepts get that far. + guard isUuid(nameOrId) || isDatabaseName(nameOrId) else { throw CloudflareD1Error( - message: String(format: String(localized: "Database '%@' not found"), database) + message: String(format: String(localized: "'%@' is not a Cloudflare D1 database name or UUID"), nameOrId) ) } + do { + return try await client.getDatabaseDetails(nameOrId: nameOrId) + } catch let error as D1HttpError where error.statusCode == 404 { + return nil + } + } - lock.withLock { httpClient?.databaseId = resolvedUuid } + // The status and the error's type only: an error's text may carry a response body. + private static func logSummary(of error: Error) -> String { + if let error = error as? D1HttpError { + return "D1HttpError status \(error.statusCode.map { String($0) } ?? "none")" + } + if let error = error as? URLError { + return "URLError \(error.code.rawValue)" + } + return String(describing: type(of: error)) } func castColumnToText(_ column: String) -> String { @@ -781,9 +891,23 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable return httpClient } - private func isUuid(_ string: String) -> Bool { - let uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" - return string.range(of: uuidPattern, options: .regularExpression) != nil + // Checked scalar by scalar: a regex `$` also matches before a trailing newline, which would reach the URL path. + private static func isUuid(_ string: String) -> Bool { + let scalars = Array(string.unicodeScalars) + guard scalars.count == 36 else { return false } + return scalars.enumerated().allSatisfy { index, scalar in + [8, 13, 18, 23].contains(index) ? scalar == "-" : scalar.properties.isASCIIHexDigit + } + } + + // D1's name rule, `^[a-zA-Z0-9][a-zA-Z0-9_-]*$`, from Cloudflare's API schema. + private static func isDatabaseName(_ string: String) -> Bool { + guard let first = string.unicodeScalars.first, isASCIIAlphanumeric(first) else { return false } + return string.unicodeScalars.allSatisfy { isASCIIAlphanumeric($0) || $0 == "_" || $0 == "-" } + } + + private static func isASCIIAlphanumeric(_ scalar: Unicode.Scalar) -> Bool { + ("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar) || ("0"..."9").contains(scalar) } private func mapRawResult(_ payload: D1RawResultPayload, executionTime: TimeInterval) -> PluginQueryResult { @@ -798,8 +922,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable truncated = true break } - let row = rawRow.map(\.stringValue).map(PluginCellValue.fromOptional) - rows.append(row) + rows.append(rawRow.map(\.cellValue)) } return PluginQueryResult( diff --git a/apple/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/apple/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index 1a5edb6df6..aa39c14070 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -9,7 +9,7 @@ import TableProPluginKit // MARK: - API Response Types -struct D1ApiResponse: Decodable { +nonisolated struct D1ApiResponse: Decodable { let result: T? let success: Bool let errors: [D1ApiErrorDetail]? @@ -19,7 +19,7 @@ struct D1ApiResponse: Decodable { } } -struct D1ApiErrorDetail: Decodable { +nonisolated struct D1ApiErrorDetail: Decodable { let code: Int? let message: String @@ -28,7 +28,7 @@ struct D1ApiErrorDetail: Decodable { } } -struct D1RawResultPayload: Decodable { +nonisolated struct D1RawResultPayload: Decodable { let results: D1RawResults let meta: D1QueryMeta? let success: Bool @@ -38,7 +38,7 @@ struct D1RawResultPayload: Decodable { } } -struct D1RawResults: Decodable { +nonisolated struct D1RawResults: Decodable { let columns: [String]? let rows: [[D1Value]]? @@ -48,7 +48,7 @@ struct D1RawResults: Decodable { } -struct D1QueryMeta: Decodable { +nonisolated struct D1QueryMeta: Decodable { let duration: Double? let changes: Int? let rowsRead: Int? @@ -61,7 +61,7 @@ struct D1QueryMeta: Decodable { } } -struct D1DatabaseInfo: Decodable { +nonisolated struct D1DatabaseInfo: Decodable, Sendable, Hashable { let uuid: String let name: String let createdAt: String? @@ -73,29 +73,44 @@ struct D1DatabaseInfo: Decodable { } } -struct D1ListResponse: Decodable { +nonisolated struct D1ListResponse: Decodable { let result: [D1DatabaseInfo] let success: Bool + let resultInfo: D1ResultInfo? private enum CodingKeys: String, CodingKey { case result, success + case resultInfo = "result_info" } } -// No .bool case: D1/SQLite stores booleans as integers (0/1), -// and Foundation's JSONDecoder decodes JSON true/false as Int when Int is tried first. -enum D1Value: Decodable { +nonisolated struct D1ResultInfo: Decodable { + let perPage: Int? + let totalCount: Int? + + private enum CodingKeys: String, CodingKey { + case perPage = "per_page" + case totalCount = "total_count" + } +} + +// SQLite has no boolean storage class, so D1 sends a boolean as 0 or 1; a JSON true or false, which never decodes as +// Int, still reads as 1 or 0. D1's Worker API documents a BLOB as an array of its byte values. Any other JSON keeps its +// text, so a value in a shape nobody expected is still shown instead of reading as NULL. +nonisolated enum D1Value: Decodable { case string(String) case int(Int) case double(Double) + case bytes(Data) case null - var stringValue: String? { + var cellValue: PluginCellValue { switch self { - case .string(let val): return val - case .int(let val): return String(val) - case .double(let val): return String(val) - case .null: return nil + case .string(let val): return .text(val) + case .int(let val): return .text(String(val)) + case .double(let val): return .text(String(val)) + case .bytes(let val): return .bytes(val) + case .null: return .null } } @@ -107,6 +122,7 @@ enum D1Value: Decodable { return } + // Int before Double, so an integer past 2^53 keeps every digit. if let intVal = try? container.decode(Int.self) { self = .int(intVal) return @@ -122,7 +138,72 @@ enum D1Value: Decodable { return } - self = .null + if let boolVal = try? container.decode(Bool.self) { + self = .int(boolVal ? 1 : 0) + return + } + + if let byteValues = try? container.decode([UInt8].self) { + self = .bytes(Data(byteValues)) + return + } + + let json = try container.decode(D1JSONText.self) + let text = try D1JSONText.compact(json) + self = .string(text) + } +} + +/// Any JSON value, read only to be written back as text. +nonisolated private enum D1JSONText: Codable { + case null + case int(Int) + case double(Double) + case string(String) + case bool(Bool) + case array([D1JSONText]) + case object([String: D1JSONText]) + + // Decodable never sees the source text, so an object's keys come back sorted rather than in the order D1 sent. + static func compact(_ value: D1JSONText) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(value) + // JSONEncoder writes UTF-8 only, so the fallback is never taken. + return String(bytes: data, encoding: .utf8) ?? "" + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([D1JSONText].self) { + self = .array(value) + } else { + let value = try container.decode([String: D1JSONText].self) + self = .object(value) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } } } @@ -132,9 +213,21 @@ enum D1Value: Decodable { /// cancels its URL task, and keeps every request in flight so `cancelAll` stops each one. The app /// runs sidebar and autocomplete reads on this client while a query runs, so a single stored task /// handle cancelled whichever request started last instead of the query the user stopped. -final class D1HttpClient: @unchecked Sendable { +nonisolated final class D1HttpClient: @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "D1HttpClient") + /// Cloudflare's documented maximum. One page then holds nearly every account, and each page is a request out of + /// the account's shared quota of 1,200 per five minutes. + static let databasesPerPage = 10_000 + + /// A server that keeps answering full pages cannot hold the caller in a loop past this many requests. + static let databaseListPageLimit = 10 + + // Path segments carry only unreserved ASCII, so a `/` or `..` in an identifier cannot change the route. + private static let pathSegmentAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_~" + ) + private let accountId: String private let apiToken: String private let lock = NSLock() @@ -171,6 +264,8 @@ final class D1HttpClient: @unchecked Sendable { configuration: URLSessionConfiguration = .default, connectDeadline: PluginConnectDeadline? = nil ) { + // A copy, so the timeouts set here never reach a configuration the caller hands to its next session. + let configuration = (configuration.copy() as? URLSessionConfiguration) ?? configuration configuration.timeoutIntervalForRequest = max( HttpQueryTimeout.sessionBootstrapRequestTimeout, connectDeadline?.remainingSeconds() ?? 0 @@ -216,7 +311,7 @@ final class D1HttpClient: @unchecked Sendable { func executeRaw(sql: String, params: [Any?]? = nil) async throws -> D1RawResultPayload { let dbId = databaseId - let url = try baseURL(databaseId: dbId).appendingPathComponent("raw") + let url = try baseURL(databaseId: dbId, endpoint: "raw") let body = try buildQueryBody(sql: sql, params: params) let data = try await performRequest(url: url, method: "POST", body: body) @@ -241,16 +336,17 @@ final class D1HttpClient: @unchecked Sendable { } let body = try JSONSerialization.data(withJSONObject: ["batch": batch]) - let url = try baseURL(databaseId: dbId).appendingPathComponent("raw") + let url = try baseURL(databaseId: dbId, endpoint: "raw") let data = try await performRequest(url: url, method: "POST", body: body) let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: data) try checkApiSuccess(envelope) return envelope.result ?? [] } - func getDatabaseDetails() async throws -> D1DatabaseInfo { - let dbId = databaseId - let url = try baseURL(databaseId: dbId) + /// The path takes a database's UUID or its name, so one request resolves either form and returns both. A missing + /// database is a ``D1HttpError`` with status 404. + func getDatabaseDetails(nameOrId: String) async throws -> D1DatabaseInfo { + let url = try baseURL(databaseId: nameOrId) let data = try await performRequest(url: url, method: "GET", body: nil) let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: data) @@ -263,16 +359,34 @@ final class D1HttpClient: @unchecked Sendable { return result } + /// Every database in the account, page by page, up to ``databaseListPageLimit`` pages. func listDatabases() async throws -> [D1DatabaseInfo] { - let url = try baseURL(databaseId: nil) - let data = try await performRequest(url: url, method: "GET", body: nil) + var databases: [D1DatabaseInfo] = [] + // A database created or dropped between two page reads shifts the pages, so one can repeat across them. + var seen: Set = [] + for page in 1...Self.databaseListPageLimit { + let url = try baseURL(databaseId: nil, queryItems: [ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "per_page", value: String(Self.databasesPerPage)), + ]) + let data = try await performRequest(url: url, method: "GET", body: nil) + + let response = try JSONDecoder().decode(D1ListResponse.self, from: data) + guard response.success else { + throw D1HttpError(message: String(localized: "Failed to list databases")) + } - let response = try JSONDecoder().decode(D1ListResponse.self, from: data) - guard response.success else { - throw D1HttpError(message: String(localized: "Failed to list databases")) + databases += response.result.filter { seen.insert($0.uuid).inserted } + if response.result.isEmpty { return databases } + if let total = response.resultInfo?.totalCount { + if databases.count >= total { return databases } + } else if response.result.count < (response.resultInfo?.perPage ?? Self.databasesPerPage) { + // The server may clamp per_page, so a short page is measured against the size it says it used. + return databases + } } - - return response.result + Self.logger.warning("D1 database list stopped after \(Self.databaseListPageLimit) pages") + return databases } func createDatabase(name: String) async throws -> D1DatabaseInfo { @@ -300,19 +414,31 @@ final class D1HttpClient: @unchecked Sendable { // MARK: - Private Helpers - private func baseURL(databaseId: String?) throws -> URL { - guard let encodedAccount = accountId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + private func baseURL( + databaseId: String?, + endpoint: String? = nil, + queryItems: [URLQueryItem] = [] + ) throws -> URL { + guard let encodedAccount = accountId.addingPercentEncoding(withAllowedCharacters: Self.pathSegmentAllowed) else { throw D1HttpError(message: String(localized: "Invalid Account ID")) } var components = URLComponents() components.scheme = "https" components.host = "api.cloudflare.com" var path = "/client/v4/accounts/\(encodedAccount)/d1/database" - if let dbId = databaseId, - let encodedDb = dbId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + if let databaseId { + guard let encodedDb = databaseId.addingPercentEncoding(withAllowedCharacters: Self.pathSegmentAllowed) else { + throw D1HttpError(message: String(localized: "Invalid Account ID or database identifier")) + } path += "/\(encodedDb)" } - components.path = path + if let endpoint { + path += "/\(endpoint)" + } + components.percentEncodedPath = path + if !queryItems.isEmpty { + components.queryItems = queryItems + } guard let url = components.url else { throw D1HttpError(message: String(localized: "Invalid Account ID or database identifier")) } @@ -361,36 +487,39 @@ final class D1HttpClient: @unchecked Sendable { return data } + // Logs the status and Cloudflare's error code, never the body: an error body may echo what the request carried. private func handleHttpError(statusCode: Int, data: Data, response: HTTPURLResponse) throws { - let bodyText = String(data: data, encoding: .utf8) ?? "Unknown error" - switch statusCode { case 401, 403: - Self.logger.error("D1 auth error (\(statusCode)): \(bodyText)") + Self.logger.error("D1 auth error (\(statusCode))") throw D1HttpError( - message: String(localized: "Authentication failed. Check your API token and Account ID.") + message: String(localized: "Authentication failed. Check your API token and Account ID."), + statusCode: statusCode ) case 429: let retryAfter = response.value(forHTTPHeaderField: "Retry-After") Self.logger.warning("D1 rate limited. Retry-After: \(retryAfter ?? "not specified")") if let seconds = retryAfter { throw D1HttpError( - message: String(format: String(localized: "Rate limited by Cloudflare. Retry after %@ seconds."), seconds) + message: String(format: String(localized: "Rate limited by Cloudflare. Retry after %@ seconds."), seconds), + statusCode: statusCode ) } else { throw D1HttpError( - message: String(localized: "Rate limited by Cloudflare. Please try again later.") + message: String(localized: "Rate limited by Cloudflare. Please try again later."), + statusCode: statusCode ) } default: if let errorResponse = try? JSONDecoder().decode( D1ApiResponse.self, from: data ), let errors = errorResponse.errors, let first = errors.first { - Self.logger.error("D1 API error (\(statusCode)): \(first.message)") - throw D1HttpError(message: first.message) + Self.logger.error("D1 API error (\(statusCode)): code \(first.code ?? 0)") + throw D1HttpError(message: first.message, statusCode: statusCode) } - Self.logger.error("D1 HTTP error (\(statusCode)): \(bodyText)") - throw D1HttpError(message: bodyText.trimmingCharacters(in: .whitespacesAndNewlines)) + Self.logger.error("D1 HTTP error (\(statusCode))") + let bodyText = String(data: data, encoding: .utf8) ?? "Unknown error" + throw D1HttpError(message: bodyText.trimmingCharacters(in: .whitespacesAndNewlines), statusCode: statusCode) } } @@ -404,7 +533,7 @@ final class D1HttpClient: @unchecked Sendable { } } -private final class D1TaskTracker: PluginRedirectRefusingDelegate, @unchecked Sendable { +nonisolated private final class D1TaskTracker: PluginRedirectRefusingDelegate, @unchecked Sendable { private weak var client: D1HttpClient? private let lock = NSLock() private var task: URLSessionTask? @@ -426,8 +555,15 @@ private final class D1TaskTracker: PluginRedirectRefusingDelegate, @unchecked Se // MARK: - Error -struct D1HttpError: Error, LocalizedError { +nonisolated struct D1HttpError: Error, LocalizedError { let message: String + /// Set when the API answered, so a caller can tell a missing database (404) from every other failure. + let statusCode: Int? + + init(message: String, statusCode: Int? = nil) { + self.message = message + self.statusCode = statusCode + } var errorDescription: String? { message } } diff --git a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index 46e48f7dc5..53df03ea78 100644 --- a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -111,6 +111,11 @@ public struct SQLDialectDescriptor: Sendable { // plugin does not say: ClickHouse's `String` and `UInt8` are matched by exact case. public let typeNamesAreCaseInsensitive: Bool + /// The longest LIKE or GLOB pattern the engine accepts, in bytes of UTF-8; it rejects a longer one. Cloudflare D1 + /// caps it at 50. `nil` means no cap. Past it the app matches a contains, starts-with or ends-with filter with + /// `instr` and `substr`, both sides folded by ``caseFoldFunction``, which assumes the engine's LIKE ignores case. + public let likePatternByteLimit: Int? + public enum CaseSensitivityStyle: String, Sendable { case ilikeOperator // PostgreSQL, CockroachDB, PGlite, DuckDB, Snowflake case caseFoldFunction // Oracle, BigQuery, ClickHouse, Redshift @@ -379,6 +384,7 @@ public struct SQLDialectDescriptor: Sendable { ) } + @_disfavoredOverload public init( identifierQuote: String, keywords: Set, @@ -400,6 +406,53 @@ public struct SQLDialectDescriptor: Sendable { lexicalFeatures: SQLLexicalFeatures? = nil, typeNamesAreCaseInsensitive: Bool = false ) { + self.init( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + offsetFetchOrderBy: offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive, + likePatternByteLimit: nil + ) + } + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set, + tableOptions: [String] = [], + regexSyntax: RegexSyntax = .unsupported, + booleanLiteralStyle: BooleanLiteralStyle = .numeric, + likeEscapeStyle: LikeEscapeStyle = .explicit, + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false, + autoLimitStyle: AutoLimitStyle = .limit, + caseSensitivityStyle: CaseSensitivityStyle = .unsupported, + caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction, + operators: [SQLOperatorDescriptor] = [], + textCastTypeName: String?, + functionNamesAreCaseInsensitive: Bool, + lexicalFeatures: SQLLexicalFeatures? = nil, + typeNamesAreCaseInsensitive: Bool = false, + likePatternByteLimit: Int? = nil + ) { + self.likePatternByteLimit = likePatternByteLimit self.lexicalFeatures = lexicalFeatures self.typeNamesAreCaseInsensitive = typeNamesAreCaseInsensitive self.identifierQuote = identifierQuote @@ -446,7 +499,8 @@ public struct SQLDialectDescriptor: Sendable { textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, lexicalFeatures: lexicalFeatures, - typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive, + likePatternByteLimit: likePatternByteLimit ) } } diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 1f4d702dcc..09a505e471 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -168,6 +168,10 @@ struct FilterSQLGenerator { case .regex: let operand = patternOperand(quotedColumn, columnType: columnType) guard dialect.regexSyntax != .unsupported else { + if SQLSubstringMatch.exceeds(filter.value, wildcards: 2, limit: dialect.likePatternByteLimit) { + let literal = quotedLiteral(escapeStringValue(filter.value)) + return SQLSubstringMatch.contains(operand, literal: literal, fold: dialect.caseFoldFunction) + } let pattern = quotedLiteral("%\(escapeStringValue(filter.value))%") return "\(folding.foldingLikeOperand(operand)) \(folding.likeKeyword) " + folding.foldingLikeOperand(pattern) @@ -362,9 +366,30 @@ struct FilterSQLGenerator { default: pattern = "%\(escaped)%" } + let wildcards = pattern.utf8.count - escaped.utf8.count + if SQLSubstringMatch.exceeds(escaped, wildcards: wildcards, limit: dialect.likePatternByteLimit) { + return generateSubstringCondition(column: column, filter: filter) + } return generateLikeCondition(column: column, pattern: pattern, negated: negated, folding: folding) } + /// The engine rejects a LIKE pattern this long, so the value is matched as a plain substring, which needs no + /// wildcard escape. + private func generateSubstringCondition(column: String, filter: TableFilter) -> String { + let literal = quotedLiteral(escapeStringValue(filter.value)) + let fold = dialect.caseFoldFunction + switch filter.filterOperator { + case .startsWith: + return SQLSubstringMatch.startsWith(column, literal: literal, fold: fold) + case .endsWith: + return SQLSubstringMatch.endsWith(column, literal: literal, fold: fold) + case .notContains: + return SQLSubstringMatch.notContains(column, literal: literal, fold: fold) + default: + return SQLSubstringMatch.contains(column, literal: literal, fold: fold) + } + } + private func generateLikeCondition( column: String, pattern: String, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 4516709e87..298a4c1a62 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -762,7 +762,8 @@ private extension SQLDialectDescriptor { textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: vocabulary.functionNamesAreCaseInsensitive, lexicalFeatures: lexicalFeatures, - typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive, + likePatternByteLimit: likePatternByteLimit ) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index fd3eb6dd2d..6eca6b5062 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -37,6 +37,8 @@ extension PluginMetadataRegistry { cassandraDialect, cassandraColumnTypes, cassandraCapabilities, mongoCompletions, mongoColumnTypes, etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes ) = registryDefaultIngredients() + // libSQL and Turso share this dialect, but only D1 refuses a LIKE pattern past 50 bytes, as its plugin says. + let cloudflareD1Dialect = d1Dialect.withLikePatternByteLimit(50) return [ ("MongoDB", PluginMetadataSnapshot( @@ -935,7 +937,7 @@ extension PluginMetadataRegistry { structureColumnFields: [.name, .type, .nullable, .defaultValue] ), editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: d1Dialect, + sqlDialect: cloudflareD1Dialect, statementCompletions: [], columnTypesByCategory: d1ColumnTypes ), @@ -1056,6 +1058,33 @@ extension PluginMetadataRegistry { // swiftlint:enable function_body_length } +private extension SQLDialectDescriptor { + func withLikePatternByteLimit(_ limit: Int) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + offsetFetchOrderBy: offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive, + likePatternByteLimit: limit + ) + } +} + // Kept identical to CassandraConsistency.connectionFields in the plugin. ScyllaDB's form shows this list even once the // plugin is loaded (registerVariant). private func cassandraConsistencyFields() -> [ConnectionField] { diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index 4b3bc5e265..d62f8f3b34 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -138,7 +138,8 @@ private extension SQLDialectDescriptor { textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, lexicalFeatures: lexicalFeatures, - typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive, + likePatternByteLimit: likePatternByteLimit ) } } diff --git a/apple/TablePro/Core/Services/Query/LeadingRowsStatement.swift b/apple/TablePro/Core/Services/Query/LeadingRowsStatement.swift index 8aed09b4d2..baa8685265 100644 --- a/apple/TablePro/Core/Services/Query/LeadingRowsStatement.swift +++ b/apple/TablePro/Core/Services/Query/LeadingRowsStatement.swift @@ -34,17 +34,12 @@ struct LeadingRowsStatement: Equatable { return LeadingRowsStatement(sql: limited, rowCap: cap) } - /// The clause goes on a line of its own, so a trailing `--` comment cannot swallow it. private static func appending(limit: Int, to sql: String, style: AutoLimitStyle) -> String? { - var statement = StatementBlank.trimming(sql) - while statement.hasSuffix(";") { - statement = StatementBlank.trimming(String(statement.dropLast())) - } switch style { case .limit: - return "\(statement)\nLIMIT \(limit)" + return SQLStatementRowLimit.appendingLimit(limit, to: sql) case .fetchFirst: - return "\(statement)\nFETCH FIRST \(limit) ROWS ONLY" + return SQLStatementRowLimit.appendingFetchFirst(limit, to: sql) case .top, .none: return nil @unknown default: diff --git a/apple/TablePro/Core/Utilities/SQL/SQLLimitDetector.swift b/apple/TablePro/Core/Utilities/SQL/SQLLimitDetector.swift index 7ca7e41812..d84f4de339 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLLimitDetector.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLLimitDetector.swift @@ -7,158 +7,18 @@ import Foundation import TableProPluginKit import TableProSQLGrammar +/// The app's reading of a statement's row limit, in the dialect's own terms. The walk is `SQLStatementRowLimit`'s, which +/// the plugins and the iOS app share. enum SQLLimitDetector { static func hasExplicitRowLimit( _ sql: String, autoLimitStyle: AutoLimitStyle, grammar: SQLLexicalGrammar ) -> Bool { - var found = false - forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in - guard isLimitingKeyword(in: buffer, start: start, end: end, autoLimitStyle: autoLimitStyle) else { - return false - } - found = true - return true - } - return found + SQLStatementRowLimit.hasExplicitRowLimit(sql, countsTop: autoLimitStyle == .top, grammar: grammar) } - /// The UTF-16 offset of the first top-level row-limiting clause, so a caller splicing a clause - /// onto the end of a statement can put it before that clause rather than after it. - /// - /// `TOP` is deliberately absent: it sits between SELECT and the column list, so it is not a - /// trailing clause and splitting there would cut the statement in half. static func firstRowLimitClauseOffset(_ sql: String, grammar: SQLLexicalGrammar) -> Int? { - var offset: Int? - forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in - guard startsRowLimitClause(in: buffer, start: start, end: end) else { return false } - offset = start - return true - } - return offset + SQLStatementRowLimit.firstRowLimitClauseOffset(sql, grammar: grammar) } - - /// `OFFSET` is non-reserved in MySQL and MariaDB, so `SELECT offset, name FROM events` reaches - /// here as a column name. What separates the clause from the identifier is what follows it: a - /// row count, a placeholder, or `FIRST`/`NEXT` for the ANSI `FETCH` form. - private static func startsRowLimitClause(in buffer: NSString, start: Int, end: Int) -> Bool { - if matchesKeyword("FETCH", in: buffer, start: start, end: end) { - let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end) - return matchesKeyword("FIRST", in: buffer, start: wordStart, end: wordEnd) - || matchesKeyword("NEXT", in: buffer, start: wordStart, end: wordEnd) - } - guard matchesKeyword("LIMIT", in: buffer, start: start, end: end) - || matchesKeyword("OFFSET", in: buffer, start: start, end: end) - else { - return false - } - var index = end - while index < buffer.length, isSpace(buffer.character(at: index)) { index += 1 } - guard index < buffer.length else { return false } - let next = buffer.character(at: index) - if next >= zero, next <= nine { return true } - if next == question || next == colon || next == dollar || next == at { return true } - let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end) - return matchesKeyword("ALL", in: buffer, start: wordStart, end: wordEnd) - } - - private static func nextWordRange(in buffer: NSString, from index: Int) -> (Int, Int) { - var start = index - while start < buffer.length, isSpace(buffer.character(at: start)) { start += 1 } - var end = start - while end < buffer.length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 } - return (start, end) - } - - private static func isSpace(_ ch: UInt16) -> Bool { - ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D - } - - private static let zero = UInt16(UnicodeScalar("0").value) - private static let nine = UInt16(UnicodeScalar("9").value) - private static let question = UInt16(UnicodeScalar("?").value) - private static let colon = UInt16(UnicodeScalar(":").value) - private static let at = UInt16(UnicodeScalar("@").value) - - /// Walks `sql` and reports every identifier that sits outside a string, comment or dollar quote - /// and at paren depth zero. The visitor returns true to stop the walk. - private static func forEachTopLevelIdentifier( - in sql: String, - grammar: SQLLexicalGrammar, - visit: (NSString, Int, Int) -> Bool - ) { - let buffer = sql as NSString - let length = buffer.length - guard length > 0 else { return } - - var parenDepth = 0 - var i = 0 - - while i < length { - let ch = buffer.character(at: i) - - if let span = SQLNonCodeSpan.span(at: i, in: buffer, grammar: grammar) { - i = max(span.end, i + 1) - continue - } - - if ch == openParen { - parenDepth += 1 - i += 1 - continue - } - - if ch == closeParen { - parenDepth -= 1 - i += 1 - continue - } - - if SqlDollarQuote.isIdentifierStart(ch), - i == 0 || !SqlDollarQuote.isIdentifierContinuation(buffer.character(at: i - 1)) { - var end = i + 1 - while end < length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 } - if parenDepth == 0, visit(buffer, i, end) { - return - } - i = end - continue - } - - i += 1 - } - } - - private static let limitingKeywords: [String] = ["LIMIT", "FETCH"] - - private static func isLimitingKeyword( - in buffer: NSString, - start: Int, - end: Int, - autoLimitStyle: AutoLimitStyle - ) -> Bool { - if limitingKeywords.contains(where: { matchesKeyword($0, in: buffer, start: start, end: end) }) { - return true - } - return autoLimitStyle == .top && matchesKeyword("TOP", in: buffer, start: start, end: end) - } - - private static func matchesKeyword(_ keyword: String, in buffer: NSString, start: Int, end: Int) -> Bool { - let keywordBuffer = keyword as NSString - guard end - start == keywordBuffer.length else { return false } - for offset in 0.. UInt16 { - (ch >= 0x61 && ch <= 0x7A) ? ch - 0x20 : ch - } - - private static let dollar = UInt16(UnicodeScalar("$").value) - private static let openParen = UInt16(UnicodeScalar("(").value) - private static let closeParen = UInt16(UnicodeScalar(")").value) } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 18376062a4..807f316f27 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -646,6 +646,10 @@ } } }, + "'%@' is not a Cloudflare D1 database name or UUID" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "'%@' is not a process id on this engine. Take one from get_server_dashboard." : { "localizations" : { "fr" : { @@ -42194,6 +42198,10 @@ } } }, + "Cloudflare D1 cannot bind a binary value through its API." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Cloudflare hostname is required" : { "localizations" : { "fr" : { diff --git a/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift b/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift index 324c1f44b3..5691dffa59 100644 --- a/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift +++ b/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift @@ -58,6 +58,9 @@ nonisolated enum CompletionIdentifierQuoting { private static func isBare(_ scalar: Unicode.Scalar, leading: Bool, for type: DatabaseType) -> Bool { let letter = isASCIILetter(scalar) let digit = isDigit(scalar) + if type.isSQLiteFamily { + return letter || scalar == "_" || !scalar.isASCII || (!leading && (digit || scalar == "$")) + } switch type { case .postgresql, .redshift: let unicodeLetter = letter || (!scalar.isASCII && CharacterSet.letters.contains(scalar)) @@ -65,8 +68,6 @@ nonisolated enum CompletionIdentifierQuoting { case .mssql: let unicodeLetter = letter || (!scalar.isASCII && CharacterSet.letters.contains(scalar)) return unicodeLetter || scalar == "_" || (!leading && (digit || scalar == "@" || scalar == "#" || scalar == "$")) - case .sqlite: - return letter || scalar == "_" || !scalar.isASCII || (!leading && (digit || scalar == "$")) case .oracle: return letter || (!leading && (digit || scalar == "_" || scalar == "$" || scalar == "#")) default: diff --git a/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift b/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift index 733b26d2df..67426d28ff 100644 --- a/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift +++ b/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift @@ -10,12 +10,12 @@ nonisolated enum CompletionReservedWords { private static func words(for type: DatabaseType) -> Set { if SQLBuilder.speaksMySQLDialect(type) { return mySQL } + if type.isSQLiteFamily { return sqlite } switch type { case .postgresql, .redshift: return postgreSQL case .duckdb: return duckDB case .mssql: return sqlServer case .oracle: return oracle - case .sqlite: return sqlite default: return all } } diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 146d4dd295..7aae1c9352 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -88,7 +88,7 @@ final class ConnectionCoordinator { connection.type == .tidb || connection.type == .oceanbase || connection.type == .postgresql || connection.type == .redshift || connection.type == .mssql || connection.type == .clickhouse || - connection.type == .mongodb + connection.type == .mongodb || connection.type == .cloudflareD1 } var supportsSchemas: Bool { @@ -1011,10 +1011,13 @@ final class ConnectionCoordinator { activeDatabase = sessionDB await switchDatabase(to: target) } - } else if let stored = appState.connectionManager.session(for: connection.id) { - activeDatabase = stored.activeDatabase } else { - activeDatabase = connection.database + activeDatabase = appState.connectionManager.session(for: connection.id)?.activeDatabase ?? connection.database + // A database the connection names by its id is listed by its name, which the driver reports. + if !databases.contains(activeDatabase), let reported = session.driver.currentDatabase, + databases.contains(reported) { + await switchDatabase(to: reported) + } } } catch { Self.logger.warning("Failed to load databases: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)") diff --git a/apple/TableProMobile/TableProMobile/Drivers/CloudflareD1BackedDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/CloudflareD1BackedDriver.swift new file mode 100644 index 0000000000..f7143d44b0 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Drivers/CloudflareD1BackedDriver.swift @@ -0,0 +1,34 @@ +import Foundation +import TableProModels +import TableProPluginKit +import TableProQuery + +// D1 is reached over HTTPS to Cloudflare's API, so there is no host on the local network to ask permission for. Its +// plugin writes no rows of its own, so edits take SQLBuilder's SQLite statements. +nonisolated final class CloudflareD1BackedDriver: PluginBackedDriver, @unchecked Sendable { + private let d1: CloudflareD1PluginDriver + + // No `cancelsRunningRequest`: the plugin's cancel stops every request in flight, catalog reads included. Stop + // cancels the run's own URL task instead. + init(d1: CloudflareD1PluginDriver, queryTimeoutSeconds: Int? = nil) { + self.d1 = d1 + super.init( + plugin: d1, + dialedHosts: [], + queryTimeoutSeconds: queryTimeoutSeconds, + readsOnly: { !SQLWriteClassifier.isWriteQuery($0, databaseType: .cloudflareD1) } + ) + } + + // A connection may name its database by UUID, and the picker lists names, so the session reports the name the + // plugin resolved, which follows every switch. + override var currentDatabase: String? { + d1.resolvedDatabaseName + } + + // No D1 statement moves the client to another database, so it is always where the picker put it. The switch back + // the base class makes after a MongoDB `use` would, during a picker switch, send the client back to the old one. + override func inSessionDatabase(_ request: () async throws -> T) async throws -> T { + try await request() + } +} diff --git a/apple/TableProMobile/TableProMobile/Drivers/MongoDBBackedDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MongoDBBackedDriver.swift index 15ea81577c..c9a2697eb0 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MongoDBBackedDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MongoDBBackedDriver.swift @@ -6,7 +6,7 @@ import TableProPluginKit import TableProQuery // The plugin builds every browse, count and lookup on the session driver, whose field kinds type the filter values. -nonisolated final class MongoDBBackedDriver: PluginBackedDriver, DriverQueryBuilding, @unchecked Sendable { +nonisolated final class MongoDBBackedDriver: PluginRowWritingDriver, DriverQueryBuilding, @unchecked Sendable { private let mongo: MongoDBPluginDriver init(mongo: MongoDBPluginDriver, dialedHosts: [String], queryTimeoutSeconds: Int? = nil) { diff --git a/apple/TableProMobile/TableProMobile/Drivers/PluginBackedDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/PluginBackedDriver.swift index 2465c99520..a05efdfbfd 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/PluginBackedDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/PluginBackedDriver.swift @@ -5,19 +5,19 @@ import TableProLogRedaction import TableProModels import TableProPluginKit -// Runs a driver the Mac ships as a plugin, compiled into this app, the way the Mac's PluginDriverAdapter does. -nonisolated class PluginBackedDriver: DriverRowWriting, @unchecked Sendable { +// Runs a driver the Mac ships as a plugin, compiled into this app, the way the Mac's PluginDriverAdapter does. Its +// rows are written with SQLBuilder's statements, as the Mac writes them for a plugin that generates none of its own. +nonisolated class PluginBackedDriver: DatabaseDriver, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "PluginBackedDriver") - private let plugin: any PluginDatabaseDriver - private let dialedHosts: [String] + let plugin: any PluginDatabaseDriver + let dialedHosts: [String] private let queryTimeoutSeconds: Int? private let readsOnly: @Sendable (String) -> Bool private let provesReads: Bool private let cancelsRunningRequest: (@Sendable () -> Void)? private let requestGate = SerialRequestGate() private let sessionDatabase = OSAllocatedUnfairLock(initialState: nil) - let rowIdentity: RowIdentity // `provesReads`: `readsOnly` is a proof rather than a guess, so a statement it passes runs marked read-only and the // plugin refuses any write it makes, as the Mac's PluginStatementContext.statement does for MongoDB. @@ -27,7 +27,6 @@ nonisolated class PluginBackedDriver: DriverRowWriting, @unchecked Sendable { plugin: any PluginDatabaseDriver, dialedHosts: [String], queryTimeoutSeconds: Int? = nil, - rowIdentity: RowIdentity, readsOnly: @escaping @Sendable (String) -> Bool, provesReads: Bool = false, cancelsRunningRequest: (@Sendable () -> Void)? = nil @@ -35,7 +34,6 @@ nonisolated class PluginBackedDriver: DriverRowWriting, @unchecked Sendable { self.plugin = plugin self.dialedHosts = dialedHosts self.queryTimeoutSeconds = queryTimeoutSeconds - self.rowIdentity = rowIdentity self.readsOnly = readsOnly self.provesReads = provesReads self.cancelsRunningRequest = cancelsRunningRequest @@ -344,159 +342,4 @@ nonisolated class PluginBackedDriver: DriverRowWriting, @unchecked Sendable { @unknown default: return .unknown } } - - // MARK: - Row writes - - func updateWrites( - table: String, - schema: String?, - columns: [String], - originalRow: [String?], - changes: [(column: String, value: String?)] - ) throws -> [PluginRowWrite] { - let original = originalRow.map(PluginCellValue.init(optionalText:)) - let cellChanges = changes.compactMap { change -> (Int, String, PluginCellValue, PluginCellValue)? in - guard let index = columns.firstIndex(of: change.column) else { return nil } - let old = index < original.count ? original[index] : .null - return (index, change.column, old, PluginCellValue(optionalText: change.value)) - } - let rowChange = PluginRowChange(rowIndex: 0, type: .update, cellChanges: cellChanges, originalRow: original) - return try generate(table: table, schema: schema, columns: columns, changes: [rowChange]) - } - - func deleteWrites(table: String, schema: String?, columns: [String], originalRow: [String?]) throws -> [PluginRowWrite] { - let original = originalRow.map(PluginCellValue.init(optionalText:)) - let rowChange = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: original) - return try generate(table: table, schema: schema, columns: columns, changes: [rowChange], deleted: [0]) - } - - func insertWrites( - table: String, - schema: String?, - columns: [String], - values: [(column: String, value: String?)] - ) throws -> [PluginRowWrite] { - // The plugin reads this text as "leave the column out", so storing it as a value cannot be expressed. - if values.contains(where: { $0.value == Self.defaultSentinel }) { - throw DatabaseError(message: String(format: String( - localized: "The text %@ cannot be inserted: this database reads it as the column's default." - ), Self.defaultSentinel)) - } - let given = Dictionary(values.map { ($0.column, $0.value) }, uniquingKeysWith: { _, last in last }) - let row = columns.map { column -> PluginCellValue in - guard let value = given[column] else { return .text(Self.defaultSentinel) } - return PluginCellValue(optionalText: value) - } - let rowChange = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - return try generate( - table: table, schema: schema, columns: columns, changes: [rowChange], inserted: [0: row] - ) - } - - // A write with a lookup runs only on the rows the lookup finds, the way the Mac's DataWriteExecutor runs it. - func run(_ writes: [PluginRowWrite]) async throws -> Int { - let plugin = plugin - return try await inSessionDatabase { - var affected = 0 - for write in writes { - var parameters = write.parameters - if let lookup = write.lookup { - let found = try await Self.rows(lookup.query, lookup.parameters, on: plugin) - guard found.count == lookup.rowCount else { - throw found.count < lookup.rowCount ? RowChangeRefusal.rowNotFound : RowChangeRefusal.identicalRows - } - if lookup.pinsRows { - parameters += found.flatMap { $0 } - } - } - let result = parameters.isEmpty - ? try await plugin.execute(query: write.statement) - : try await plugin.executeParameterized(query: write.statement, parameters: parameters) - affected += max(result.rowsAffected, 0) - if let lookup = write.lookup, let check = lookup.check, - try await !Self.rows(check, lookup.checkParameters, on: plugin).isEmpty { - throw RowChangeRefusal.notApplied - } - } - return affected - } - } - - private static func rows( - _ query: String, - _ parameters: [PluginCellValue], - on plugin: any PluginDatabaseDriver - ) async throws -> [[PluginCellValue]] { - parameters.isEmpty - ? try await plugin.execute(query: query).rows - : try await plugin.executeParameterized(query: query, parameters: parameters).rows - } - - // Unqualified and with the same predicates as the plugin's own row writes, so it finds the rows they would touch: - // at most two. - func matchingRowCount(table: String, schema: String?, columns: [String], row: [String?]) async throws -> Int { - guard !columns.isEmpty, columns.count == row.count else { throw Self.cannotWrite } - var parameters: [PluginCellValue] = [] - let conditions = zip(columns, row).map { column, value -> String in - let quoted = plugin.quoteIdentifier(column) - guard let value else { return "\(quoted) IS NULL" } - parameters.append(.text(value)) - return "\(quoted) = ?" - } - let query = "SELECT 1 FROM \(plugin.quoteIdentifier(table)) WHERE \(conditions.joined(separator: " AND ")) LIMIT 2" - let plugin = plugin - return try await inSessionDatabase { - try await plugin.executeParameterized(query: query, parameters: parameters).rows.count - } - } - - private static var cannotWrite: DatabaseError { - DatabaseError(message: String(localized: "This database cannot write this change from iPhone or iPad.")) - } - - // The Mac's grid marks a cell left at the column default with this text, and the drivers leave such a column out. - static let defaultSentinel = "__DEFAULT__" - - private func generate( - table: String, - schema: String?, - columns: [String], - changes: [PluginRowChange], - inserted: [Int: [PluginCellValue]] = [:], - deleted: Set = [] - ) throws -> [PluginRowWrite] { - guard let writes = try pluginRowWrites( - table: table, schema: schema, columns: columns, changes: changes, inserted: inserted, deleted: deleted - ) else { - throw Self.cannotWrite - } - return writes - } - - func pluginRowWrites( - table: String, - schema: String?, - columns: [String], - changes: [PluginRowChange], - inserted: [Int: [PluginCellValue]], - deleted: Set - ) throws -> [PluginRowWrite]? { - try plugin.generateRowWrites( - table: table, - schema: schema, - columns: columns, - primaryKeyColumns: [], - changes: changes, - insertedRowData: inserted, - deletedRowIndices: deleted, - insertedRowIndices: Set(inserted.keys), - context: PluginRowWriteContext() - ) - } -} - -nonisolated private extension PluginCellValue { - init(optionalText value: String?) { - self = value.map { .text($0) } ?? .null - } } diff --git a/apple/TableProMobile/TableProMobile/Drivers/PluginRowWritingDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/PluginRowWritingDriver.swift new file mode 100644 index 0000000000..374de200bd --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Drivers/PluginRowWritingDriver.swift @@ -0,0 +1,183 @@ +import Foundation +import TableProDatabase +import TableProModels +import TableProPluginKit + +// For a plugin that generates its own row writes. A conformance cannot be switched off per instance, so a plugin that +// generates none stays a plain PluginBackedDriver and its writes reach SQLBuilder. +nonisolated class PluginRowWritingDriver: PluginBackedDriver, DriverRowWriting, @unchecked Sendable { + let rowIdentity: RowIdentity + + init( + plugin: any PluginDatabaseDriver, + dialedHosts: [String], + queryTimeoutSeconds: Int? = nil, + rowIdentity: RowIdentity, + readsOnly: @escaping @Sendable (String) -> Bool, + provesReads: Bool = false, + cancelsRunningRequest: (@Sendable () -> Void)? = nil + ) { + self.rowIdentity = rowIdentity + super.init( + plugin: plugin, + dialedHosts: dialedHosts, + queryTimeoutSeconds: queryTimeoutSeconds, + readsOnly: readsOnly, + provesReads: provesReads, + cancelsRunningRequest: cancelsRunningRequest + ) + } + + func updateWrites( + table: String, + schema: String?, + columns: [String], + originalRow: [String?], + changes: [(column: String, value: String?)] + ) throws -> [PluginRowWrite] { + let original = originalRow.map(PluginCellValue.init(optionalText:)) + let cellChanges = changes.compactMap { change -> (Int, String, PluginCellValue, PluginCellValue)? in + guard let index = columns.firstIndex(of: change.column) else { return nil } + let old = index < original.count ? original[index] : .null + return (index, change.column, old, PluginCellValue(optionalText: change.value)) + } + let rowChange = PluginRowChange(rowIndex: 0, type: .update, cellChanges: cellChanges, originalRow: original) + return try generate(table: table, schema: schema, columns: columns, changes: [rowChange]) + } + + func deleteWrites(table: String, schema: String?, columns: [String], originalRow: [String?]) throws -> [PluginRowWrite] { + let original = originalRow.map(PluginCellValue.init(optionalText:)) + let rowChange = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: original) + return try generate(table: table, schema: schema, columns: columns, changes: [rowChange], deleted: [0]) + } + + func insertWrites( + table: String, + schema: String?, + columns: [String], + values: [(column: String, value: String?)] + ) throws -> [PluginRowWrite] { + // The plugin reads this text as "leave the column out", so storing it as a value cannot be expressed. + if values.contains(where: { $0.value == Self.defaultSentinel }) { + throw DatabaseError(message: String(format: String( + localized: "The text %@ cannot be inserted: this database reads it as the column's default." + ), Self.defaultSentinel)) + } + let given = Dictionary(values.map { ($0.column, $0.value) }, uniquingKeysWith: { _, last in last }) + let row = columns.map { column -> PluginCellValue in + guard let value = given[column] else { return .text(Self.defaultSentinel) } + return PluginCellValue(optionalText: value) + } + let rowChange = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + return try generate( + table: table, schema: schema, columns: columns, changes: [rowChange], inserted: [0: row] + ) + } + + // A write with a lookup runs only on the rows the lookup finds, the way the Mac's DataWriteExecutor runs it. + func run(_ writes: [PluginRowWrite]) async throws -> Int { + let plugin = plugin + return try await inSessionDatabase { + var affected = 0 + for write in writes { + var parameters = write.parameters + if let lookup = write.lookup { + let found = try await Self.rows(lookup.query, lookup.parameters, on: plugin) + guard found.count == lookup.rowCount else { + throw found.count < lookup.rowCount ? RowChangeRefusal.rowNotFound : RowChangeRefusal.identicalRows + } + if lookup.pinsRows { + parameters += found.flatMap { $0 } + } + } + let result = parameters.isEmpty + ? try await plugin.execute(query: write.statement) + : try await plugin.executeParameterized(query: write.statement, parameters: parameters) + affected += max(result.rowsAffected, 0) + if let lookup = write.lookup, let check = lookup.check, + try await !Self.rows(check, lookup.checkParameters, on: plugin).isEmpty { + throw RowChangeRefusal.notApplied + } + } + return affected + } + } + + private static func rows( + _ query: String, + _ parameters: [PluginCellValue], + on plugin: any PluginDatabaseDriver + ) async throws -> [[PluginCellValue]] { + parameters.isEmpty + ? try await plugin.execute(query: query).rows + : try await plugin.executeParameterized(query: query, parameters: parameters).rows + } + + // Unqualified and with the same predicates as the plugin's own row writes, so it finds the rows they would touch: + // at most two. + func matchingRowCount(table: String, schema: String?, columns: [String], row: [String?]) async throws -> Int { + guard !columns.isEmpty, columns.count == row.count else { throw Self.cannotWrite } + var parameters: [PluginCellValue] = [] + let conditions = zip(columns, row).map { column, value -> String in + let quoted = plugin.quoteIdentifier(column) + guard let value else { return "\(quoted) IS NULL" } + parameters.append(.text(value)) + return "\(quoted) = ?" + } + let query = "SELECT 1 FROM \(plugin.quoteIdentifier(table)) WHERE \(conditions.joined(separator: " AND ")) LIMIT 2" + let plugin = plugin + return try await inSessionDatabase { + try await plugin.executeParameterized(query: query, parameters: parameters).rows.count + } + } + + private static var cannotWrite: DatabaseError { + DatabaseError(message: String(localized: "This database cannot write this change from iPhone or iPad.")) + } + + // The Mac's grid marks a cell left at the column default with this text, and the drivers leave such a column out. + static let defaultSentinel = "__DEFAULT__" + + private func generate( + table: String, + schema: String?, + columns: [String], + changes: [PluginRowChange], + inserted: [Int: [PluginCellValue]] = [:], + deleted: Set = [] + ) throws -> [PluginRowWrite] { + guard let writes = try pluginRowWrites( + table: table, schema: schema, columns: columns, changes: changes, inserted: inserted, deleted: deleted + ) else { + throw Self.cannotWrite + } + return writes + } + + func pluginRowWrites( + table: String, + schema: String?, + columns: [String], + changes: [PluginRowChange], + inserted: [Int: [PluginCellValue]], + deleted: Set + ) throws -> [PluginRowWrite]? { + try plugin.generateRowWrites( + table: table, + schema: schema, + columns: columns, + primaryKeyColumns: [], + changes: changes, + insertedRowData: inserted, + deletedRowIndices: deleted, + insertedRowIndices: Set(inserted.keys), + context: PluginRowWriteContext() + ) + } +} + +nonisolated private extension PluginCellValue { + init(optionalText value: String?) { + self = value.map { .text($0) } ?? .null + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index ac2bf115be..0cb400910f 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -168,6 +168,10 @@ nonisolated enum ErrorClassifier { return mismatch } + if context.databaseType?.connectionMode == .apiOnly, isAPICredentialFailure(error) { + return auth(error, context: context) + } + let host = context.host ?? "" let mayUseLocalNetwork = context.sshEnabled || LocalNetworkPermission.mayBeLocalNetworkHost(host) let timedOut = message.contains("timeout") || message.contains("timed out") || message.contains("operation timed out") || message.contains("system error: 60") @@ -382,11 +386,27 @@ nonisolated enum ErrorClassifier { ) } + // The plugin's text is localized, so the English substring rules in `classify` miss it in another language: its + // two missing-field errors are matched whole, and a refused request by its status. + private static func isAPICredentialFailure(_ error: Error) -> Bool { + if let httpError = error as? D1HttpError, let status = httpError.statusCode { + return status == 401 || status == 403 + } + let message = error.localizedDescription + return message == String(localized: "API Token is required") || message == String(localized: "Account ID is required") + } + private static func auth(_ error: Error, context: ErrorContext) -> AppError { let dbName = context.databaseType?.rawValue ?? String(localized: "Database") - let recovery = context.signsInWithIAM - ? String(localized: "This connection signs in with IAM, not a password. Check that the database user is set up for IAM sign-in.") - : String(format: String(localized: "Check your %@ username and password."), dbName) + let recovery: String + if context.signsInWithIAM { + recovery = String(localized: "This connection signs in with IAM, not a password. Check that the database user is set up for IAM sign-in.") + } else if context.databaseType?.connectionMode == .apiOnly { + // An engine reached through an API signs in with a token for an account, never a username. + recovery = String(localized: "Check the API token and account of this connection.") + } else { + recovery = String(format: String(localized: "Check your %@ username and password."), dbName) + } return AppError( category: .auth, title: String(localized: "Authentication Failed"), diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift index 0ea4fcc1fd..6a83220607 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift @@ -1,10 +1,15 @@ import Foundation import TableProModels +import TableProPluginKit nonisolated enum ConnectionDetailFormatter { static func detail(for connection: DatabaseConnection) -> String { - guard connection.type.isLocalFile else { return networkDetail(for: connection) } - return fileDetail(connection.database) + switch connection.type.connectionMode { + case .network: networkDetail(for: connection) + case .fileBased: fileDetail(connection.database) + // A Mac `d1://` import keeps the account id in Host, which names no server. + case .apiOnly: connection.database + } } private static func fileDetail(_ path: String) -> String { diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 966bbf4879..27007616a3 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -54,6 +54,11 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { var replicationGroupId: String } + // The one field the Mac's Cloudflare D1 plugin declares; the API token is the connection's password. + nonisolated struct CloudflareD1Options: Equatable, Sendable { + var accountId: String + } + var name: String var type: DatabaseType var host: String @@ -69,6 +74,7 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { var oracle: OracleOptions? var mongo: MongoOptions? var iam: IAMOptions? + var cloudflareD1: CloudflareD1Options? static func tagIds(selecting tagId: UUID?, over existing: [UUID]) -> [UUID] { let others = existing.dropFirst().filter { $0 != tagId } @@ -100,6 +106,7 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { if changed(\.oracle) { applyOracleOptions(to: &connection, changedSince: opening?.oracle) } if changed(\.mongo) { applyMongoOptions(to: &connection, changedSince: opening?.mongo) } if changed(\.iam) { applyIAMOptions(to: &connection, changedSince: opening?.iam) } + if changed(\.cloudflareD1) { applyCloudflareD1Options(to: &connection, changedSince: opening?.cloudflareD1) } return connection } @@ -263,4 +270,14 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { if changed(\.rdsEndpoint) { write(iam.rdsEndpoint, to: Field.rdsEndpoint) } if changed(\.replicationGroupId) { write(iam.replicationGroupId, to: Field.replicationGroupId) } } + + private func applyCloudflareD1Options( + to connection: inout DatabaseConnection, + changedSince opening: CloudflareD1Options? + ) { + guard let cloudflareD1 else { return } + if Self.differs(\CloudflareD1Options.accountId, from: opening, to: cloudflareD1) { + connection.additionalFields[CloudflareD1Plugin.accountIdFieldId] = cloudflareD1.accountId + } + } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift index b15e73e152..222eba6701 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionInfoContent.swift @@ -1,25 +1,39 @@ import Foundation import TableProModels +import TableProPluginKit nonisolated struct ConnectionFileDetail: Equatable, Sendable { let name: String let path: String? } +nonisolated struct ConnectionAccountDetail: Equatable, Sendable { + let accountId: String? +} + nonisolated enum ConnectionInfoSection: Equatable, Sendable { case server case file(ConnectionFileDetail) + // An engine reached through a web API: no host, port, SSL or SSH to show. + case account(ConnectionAccountDetail) } nonisolated enum ConnectionInfoContent { static func section(for connection: DatabaseConnection, fileURL: URL?) -> ConnectionInfoSection { - guard connection.type.isLocalFile else { return .server } - guard connection.database != LocalDatabaseLocation.inMemoryPath else { - return .file(ConnectionFileDetail(name: String(localized: "In Memory"), path: nil)) + switch connection.type.connectionMode { + case .network: + return .server + case .apiOnly: + let accountId = connection.additionalFields[CloudflareD1Plugin.accountIdFieldId] + return .account(ConnectionAccountDetail(accountId: accountId?.isEmpty == false ? accountId : nil)) + case .fileBased: + guard connection.database != LocalDatabaseLocation.inMemoryPath else { + return .file(ConnectionFileDetail(name: String(localized: "In Memory"), path: nil)) + } + return .file(ConnectionFileDetail( + name: fileURL?.lastPathComponent ?? connection.database, + path: fileURL?.path ?? connection.database + )) } - return .file(ConnectionFileDetail( - name: fileURL?.lastPathComponent ?? connection.database, - path: fileURL?.path ?? connection.database - )) } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index a92374d29d..1a0aea437b 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -1,8 +1,10 @@ import Foundation import TableProModels +import TableProPluginKit nonisolated extension DatabaseType { var defaultPort: String { + guard dialsServer else { return "" } switch self { case .mysql, .mariadb: return "3306" case .tidb: return "4000" @@ -15,18 +17,28 @@ nonisolated extension DatabaseType { case .oracle: return "1521" case .clickhouse: return String(ClickHousePlugin.defaultPort) case .mongodb: return "27017" - case .sqlite, .duckdb: return "" default: return "3306" } } - var isLocalFile: Bool { + // The Mac's three shapes, which its plugins declare: a server dialed by host, a file, or an API with neither. + var connectionMode: ConnectionMode { switch self { - case .sqlite, .duckdb: true - default: false + case .sqlite, .duckdb: .fileBased + case .cloudflareD1: .apiOnly + default: .network } } + var isLocalFile: Bool { + connectionMode == .fileBased + } + + // A server dialed by host and port, the only shape a tunnel, a TLS mode or a port applies to. + var dialsServer: Bool { + connectionMode == .network + } + var speaksSQL: Bool { switch self { case .redis, .mongodb: false @@ -64,6 +76,7 @@ nonisolated extension DatabaseType { .mssql, .oracle, .clickhouse, - .mongodb + .mongodb, + .cloudflareD1 ] } diff --git a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 4e9ab2a140..3f0b02897f 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -188,12 +188,14 @@ nonisolated enum SQLBuilder { if speaksMySQLDialect(type) { return "INSERT INTO \(qualifiedTable) () VALUES ()" } - switch type { - case .postgresql, .redshift, .sqlite, .mssql, .duckdb: - return "INSERT INTO \(qualifiedTable) DEFAULT VALUES" - default: - return nil - } + let takesDefaultValues = type.isSQLiteFamily || [.postgresql, .redshift, .mssql, .duckdb].contains(type) + return takesDefaultValues ? "INSERT INTO \(qualifiedTable) DEFAULT VALUES" : nil + } + + // SQLite has no TRUNCATE statement; a DELETE with no WHERE is its truncate. + static func truncateStatement(table: String, schema: String?, type: DatabaseType) -> String { + let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) + return type.isSQLiteFamily ? "DELETE FROM \(quoted)" : "TRUNCATE TABLE \(quoted)" } static func supportsAllDefaultsInsert(_ type: DatabaseType) -> Bool { @@ -354,12 +356,18 @@ nonisolated enum SQLBuilder { let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !columns.isEmpty else { return "" } - let generator = filterGenerator(dialect: dialectDescriptor(for: type), type: type, session: session) + let dialect = dialectDescriptor(for: type) + let generator = filterGenerator(dialect: dialect, type: type, session: session) let pattern = generator.likePatternBody(trimmed) + // D1 refuses a LIKE pattern this long, so the text is found as a plain substring, folded as its LIKE folds. + let substring = generator.exceedsLikePatternLimit(pattern, wildcards: 2) ? generator.stringLiteral(trimmed) : nil let conditions = columns.map { col -> String in let quotedCol = quoteIdentifier(col.name, for: type) let castExpr = searchCastExpression(quotedCol, for: type) + if let substring { + return SQLSubstringMatch.contains(castExpr, literal: substring, fold: dialect.caseFoldFunction) + } let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE" return "\(castExpr) \(likeOp) '%\(pattern)%'\(generator.likeEscapeClause)" } @@ -414,10 +422,14 @@ nonisolated enum SQLBuilder { caseSensitivityStyle: .collationDefined ) } - // The ClickHouse plugin is compiled into this app, so its own dialect is read rather than mirrored. + // The ClickHouse and Cloudflare D1 plugins are compiled into this app, so their own dialects are read rather + // than mirrored. if type == .clickhouse, let dialect = ClickHousePlugin.sqlDialect { return dialect } + if type == .cloudflareD1, let dialect = CloudflareD1Plugin.sqlDialect { + return dialect + } switch type { case .postgresql: return SQLDialectDescriptor( diff --git a/apple/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift b/apple/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift index c150e996da..a194b7d11e 100644 --- a/apple/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift +++ b/apple/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift @@ -10,19 +10,22 @@ struct ConnectionEntity: AppEntity { var name: String var host: String var databaseType: String + // The connection list's line, so an engine without a host names its file or database instead. + var detail: String var displayRepresentation: DisplayRepresentation { DisplayRepresentation( title: "\(name)", - subtitle: "\(databaseType), \(host)" + subtitle: "\(databaseType), \(detail)" ) } - init(id: UUID, name: String, host: String, databaseType: String) { + init(id: UUID, name: String, host: String, databaseType: String, detail: String? = nil) { self.id = id self.name = name self.host = host self.databaseType = databaseType + self.detail = detail ?? host } init(connection: DatabaseConnection) { @@ -30,7 +33,8 @@ struct ConnectionEntity: AppEntity { id: connection.id, name: connection.name.isEmpty ? connection.host : connection.name, host: connection.host, - databaseType: connection.type.rawValue + databaseType: connection.type.rawValue, + detail: ConnectionDetailFormatter.detail(for: connection) ) } } diff --git a/apple/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/apple/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift index f03d650f67..e6b1fab00f 100644 --- a/apple/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift +++ b/apple/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -9,7 +9,8 @@ struct IntentDatabaseSession { static func supportsTabularInsert(_ type: DatabaseType) -> Bool { switch type { - case .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle, .clickhouse: + case .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle, .clickhouse, + .cloudflareD1: return true default: return false diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 7a55224b7c..a37f065844 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -28,6 +28,9 @@ } } } + }, + "'%@' is not a Cloudflare D1 database name or UUID" : { + }, "%1$@ has type %2$@, which MongoDB does not have. Choose a type from the list." : { @@ -49,9 +52,99 @@ }, "%@ holds values that cannot be compared, so the row cannot be found again." : { + }, + "Account ID" : { + + }, + "Account ID is required" : { + + }, + "API request failed" : { + + }, + "API Token" : { + + }, + "API Token is required" : { + + }, + "Authentication failed. Check your API token and Account ID." : { + + }, + "Can't Load Databases" : { + + }, + "Check the API token and account of this connection." : { + + }, + "Cloudflare D1 cannot bind a binary value through its API." : { + + }, + "Copy the Account ID from the Cloudflare dashboard. The API token needs the D1 Edit permission." : { + + }, + "Database '%@' not found" : { + + }, + "Database '%@' not found in account" : { + + }, + "Database name or UUID is required" : { + + }, + "Empty response from Cloudflare D1" : { + + }, + "Enter the Account ID and API Token to choose a database." : { + + }, + "Enter the Account ID and API Token to list the databases in this account." : { + + }, + "Failed to create database" : { + + }, + "Failed to fetch database details" : { + + }, + "Failed to list databases" : { + + }, + "Index %@ has a key part that is not one SQL expression." : { + + }, + "Invalid Account ID" : { + + }, + "Invalid Account ID or database identifier" : { + + }, + "Name or UUID" : { + + }, + "No Account" : { + + }, + "No D1 databases are available to this API token." : { + + }, + "No Databases" : { + + }, + "Rate limited by Cloudflare. Please try again later." : { + + }, + "Rate limited by Cloudflare. Retry after %@ seconds." : { + }, "The %@ setting holds a NUL character, which a PostgreSQL connection cannot carry." : { + }, + "The condition of index %@ cannot be written as SQL." : { + + }, + "The default of column %@ cannot be written as SQL." : { + }, "The SSH host %@ contains characters TablePro does not allow in a host name." : { "extractionState" : "manual", @@ -84,6 +177,9 @@ }, "Refused %@: this statement was not approved to run server-side code, $out or $merge." : { + }, + "The type of column %@ cannot be written as SQL." : { + }, "This request is read only, and the statement is not a read." : { @@ -27938,6 +28034,9 @@ }, "Transaction open on %@" : { + }, + "Transactions are not supported by Cloudflare D1" : { + }, "Transfer" : { "extractionState" : "manual", @@ -28638,6 +28737,9 @@ "Uptrend" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Use “%@”" : { + }, "Use Default" : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 5668bea1d4..d5b2612eec 100644 --- a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -14,19 +14,23 @@ nonisolated final class IOSDriverFactory: DriverFactory { private let localFiles: LocalDatabaseFileLocator private let mint: IAMPasswordMint private let secureStore: any SecureStore + // The session configuration of an engine reached over HTTPS, which a test points at its own URLProtocol. + private let apiSessionConfiguration: @Sendable () -> URLSessionConfiguration init( bookmarkStore: FileBookmarkStore = FileBookmarkStore(), materializer: CertificateMaterializer = CertificateMaterializer(), localFiles: LocalDatabaseFileLocator = .live, mint: IAMPasswordMint = IAMPasswordMint(), - secureStore: any SecureStore = KeychainSecureStore() + secureStore: any SecureStore = KeychainSecureStore(), + apiSessionConfiguration: @escaping @Sendable () -> URLSessionConfiguration = { .default } ) { self.bookmarkStore = bookmarkStore self.materializer = materializer self.localFiles = localFiles self.mint = mint self.secureStore = secureStore + self.apiSessionConfiguration = apiSessionConfiguration } private func sqliteSource(for connection: DatabaseConnection) throws -> LocalDatabaseFileSource { @@ -169,7 +173,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { case .oracle: return OracleDriver(connection: connection, password: password, serverName: preTunnelHost) case .clickhouse: - return PluginBackedDriver( + return PluginRowWritingDriver( plugin: ClickHousePluginDriver(config: pluginConfig( for: connection, password: password, @@ -202,6 +206,14 @@ nonisolated final class IOSDriverFactory: DriverFactory { dialedHosts: preTunnelHost == nil ? MongoDBDialPlan.seedHosts(of: connection) : [connection.host], queryTimeoutSeconds: connection.queryTimeoutSeconds ) + case .cloudflareD1: + return CloudflareD1BackedDriver( + d1: CloudflareD1PluginDriver( + config: pluginConfig(for: connection, password: password, ssl: .disabled, preTunnelHost: nil), + sessionConfiguration: apiSessionConfiguration() + ), + queryTimeoutSeconds: connection.queryTimeoutSeconds + ) default: throw ConnectionError.driverNotFound(connection.type.rawValue) } @@ -209,7 +221,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { static let supportedTypeList: [DatabaseType] = [ .sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle, - .clickhouse, .mongodb + .clickhouse, .mongodb, .cloudflareD1 ] // A plugin reads the server behind a tunnel from its fields; one stored or imported there never stands in for it. @@ -249,7 +261,8 @@ nonisolated final class IOSDriverFactory: DriverFactory { guard connection.macSocketPath == nil else { return .localSocketNotSupported } guard !KerberosAuthFields.isSelected(in: connection.additionalFields) else { return .kerberosNotSupported } let sslMode = storedSSLMode(of: connection) - guard connection.type.isLocalFile || supportedSSLModes(for: connection.type).contains(sslMode) else { + // A file engine dials nothing, and an API engine always reaches its API over HTTPS: neither reads the SSL mode. + guard !connection.type.dialsServer || supportedSSLModes(for: connection.type).contains(sslMode) else { return .sslModeNotSupported(mode: displayName(of: sslMode), typeName: connection.type.mobileDisplayName) } return nil @@ -273,6 +286,11 @@ nonisolated final class IOSDriverFactory: DriverFactory { Self.refusal(for: connection) } + // An older form kept the tunnel of a server type switched to a file, and a D1 record can carry one from the Mac. + func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool { + connection.sshEnabled && connection.type.dialsServer + } + // A second SQLite reader can make the user's COMMIT fail with SQLITE_BUSY, and DuckDB opens a file once per process. func opensCatalogConnections(for connection: DatabaseConnection) -> Bool { switch connection.type { diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index c1c8ef971a..684dcbd354 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -189,11 +189,16 @@ nonisolated extension DatabaseConnection { init(importedSettings settings: ExportableConnection, id: UUID = UUID(), groupId: UUID? = nil, tagIds: [UUID] = []) { let safeModeLevel = SafeModeLevel(wireValue: settings.safeModeLevel, isReadOnly: false) let sslMode = settings.sslConfig.map { SSLConfiguration.SSLMode(importing: $0.portableMode) } ?? .disable + let type = DatabaseType(rawValue: settings.type) + // Only a server dials a host: an empty one there means this machine, while a file or API connection has none. + let host = settings.host.trimmingCharacters(in: .whitespaces).isEmpty && type.dialsServer + ? "localhost" + : settings.host self.init( id: id, name: settings.name, - type: DatabaseType(rawValue: settings.type), - host: settings.host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : settings.host, + type: type, + host: host, port: settings.port, username: settings.username, database: settings.database, diff --git a/apple/TableProMobile/TableProMobile/ViewModels/CloudflareD1DatabaseList.swift b/apple/TableProMobile/TableProMobile/ViewModels/CloudflareD1DatabaseList.swift new file mode 100644 index 0000000000..5453b0219f --- /dev/null +++ b/apple/TableProMobile/TableProMobile/ViewModels/CloudflareD1DatabaseList.swift @@ -0,0 +1,141 @@ +import Foundation +import os +import TableProLogRedaction + +// Another Account ID or token is another account's list, so a change of either loads again. +nonisolated struct CloudflareD1Credentials: Hashable, Sendable { + let accountId: String + let apiToken: String + + var isComplete: Bool { + !accountId.isEmpty && !apiToken.isEmpty + } +} + +@MainActor +@Observable +final class CloudflareD1DatabaseList { + typealias Loader = @Sendable (_ accountId: String, _ apiToken: String) async throws -> [D1DatabaseInfo] + + enum State { + case idle + case loading + case loaded([D1DatabaseInfo]) + case failed(String) + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareD1DatabaseList") + + private(set) var state: State = .idle + // A refresh that fails keeps the list it refreshed, so its error is reported beside that list. + private(set) var refreshError: String? + @ObservationIgnored private var loadedCredentials: CloudflareD1Credentials? + @ObservationIgnored private var generation = 0 + @ObservationIgnored private let loader: Loader + + init(loader: @escaping Loader = { accountId, apiToken in + try await CloudflareD1PluginDriver.listDatabases(accountId: accountId, apiToken: apiToken) + }) { + self.loader = loader + } + + var databases: [D1DatabaseInfo] { + guard case .loaded(let databases) = state else { return [] } + return databases + } + + // Each list is a request against the API quota the Mac shares, so a list these credentials + // already fetched is kept. A fetch the picker cancelled by leaving counts as not done. + func load(_ credentials: CloudflareD1Credentials) async { + if loadedCredentials == credentials, holdsList { return } + await fetch(credentials) + } + + func refresh(_ credentials: CloudflareD1Credentials) async { + await fetch(credentials) + } + + func databases(matching query: String) -> [D1DatabaseInfo] { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return databases } + return databases.filter { $0.name.localizedStandardContains(term) || $0.uuid.localizedStandardContains(term) } + } + + func listedDatabase(_ reference: String) -> D1DatabaseInfo? { + let term = reference.trimmingCharacters(in: .whitespacesAndNewlines) + return databases.first { Self.reference(term, names: $0) } + } + + // A typed value is still offered when the list failed or cannot see the database yet. + func typedDatabase(in query: String) -> String? { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard Self.isDatabaseReference(term), listedDatabase(term) == nil else { return nil } + return term + } + + // A Mac connection may store the database's UUID where iOS stores its name. + nonisolated static func reference(_ value: String, names database: D1DatabaseInfo) -> Bool { + database.name == value || database.uuid.caseInsensitiveCompare(value) == .orderedSame + } + + // The plugin opens a database by its UUID or by a name in Cloudflare's pattern, ^[a-zA-Z0-9][a-zA-Z0-9_-]*$. + nonisolated static func isDatabaseReference(_ text: String) -> Bool { + if UUID(uuidString: text) != nil { return true } + guard let first = text.unicodeScalars.first, isASCIIAlphanumeric(first) else { return false } + return text.unicodeScalars.allSatisfy { isASCIIAlphanumeric($0) || $0 == "_" || $0 == "-" } + } + + nonisolated private static func isASCIIAlphanumeric(_ scalar: Unicode.Scalar) -> Bool { + scalar.isASCII && CharacterSet.alphanumerics.contains(scalar) + } + + private func fetch(_ credentials: CloudflareD1Credentials) async { + generation += 1 + let attempt = generation + guard credentials.isComplete else { + loadedCredentials = nil + state = .idle + refreshError = nil + return + } + let refreshing = loadedCredentials == credentials && holdsList + if !refreshing { + loadedCredentials = nil + state = .loading + refreshError = nil + } + do { + let fetched = try await loader(credentials.accountId, credentials.apiToken) + guard attempt == generation else { return } + loadedCredentials = credentials + state = .loaded(fetched.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) + refreshError = nil + } catch { + guard attempt == generation else { return } + // The picker left or the credentials changed: not a failure, and the next visit loads again. + if Task.isCancelled || Self.isCancellation(error) { + if !refreshing { state = .idle } + return + } + Self.logger.error( + "Listing Cloudflare D1 databases failed: \(LogRedaction.publicDescription(of: error), privacy: .public) \(error.localizedDescription, privacy: .private)" + ) + if refreshing { + refreshError = error.localizedDescription + } else { + state = .failed(error.localizedDescription) + } + } + } + + private var holdsList: Bool { + guard case .loaded = state else { return false } + return true + } + + nonisolated private static func isCancellation(_ error: any Error) -> Bool { + if error is CancellationError { return true } + if let urlError = error as? URLError, urlError.code == .cancelled { return true } + return false + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift index 0f8c684e86..2b4a3991b0 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift @@ -7,7 +7,7 @@ extension ConnectionFormViewModel { private static let certificateLogger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel") var usesCertificateSection: Bool { - !isFileBased && type != .oracle && type != .mssql + hasNetworkTransport && type != .oracle && type != .mssql } var showsCertificateRows: Bool { diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 4322db381b..308cc82ceb 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -5,6 +5,7 @@ import TableProDatabase import TableProLogRedaction import TableProModels import TableProOracleCore +import TableProPluginKit import TableProSSHTransport @MainActor @@ -73,6 +74,7 @@ final class ConnectionFormViewModel { var mongoAuthMechanism = "" var mongoReplicaSet = "" var mongoUsesSRV = false + var cloudflareAccountId = "" var iam = ConnectionIAMFormState() // Organization @@ -181,8 +183,10 @@ final class ConnectionFormViewModel { mongoAuthMechanism = conn.additionalFields[MongoDBDialPlan.authMechanismField] ?? "" mongoReplicaSet = conn.additionalFields[MongoDBDialPlan.replicaSetField] ?? "" mongoUsesSRV = conn.additionalFields[MongoDBDialPlan.srvField] == "true" + cloudflareAccountId = conn.additionalFields[CloudflareD1Plugin.accountIdFieldId] ?? "" iam = ConnectionIAMFormState(connection: conn) - sshEnabled = conn.sshEnabled + // The SSH section is hidden for an engine dialed some other way, where a synced tunnel could not be turned off. + sshEnabled = conn.sshEnabled && hasNetworkTransport groupId = conn.groupId tagId = conn.tagId iconName = conn.iconName @@ -234,9 +238,28 @@ final class ConnectionFormViewModel { if type == .mongodb { return mongoHostsIssue == nil && !hasJumpHostWithoutHost } + // No token check: an edit keeps the stored token when the field is left empty. + if type == .cloudflareD1 { + return !trimmedCloudflareAccountId.isEmpty && !database.isEmpty + } return !host.isEmpty && !hasJumpHostWithoutHost } + // The plugin declares the field, so iOS shows the label the Mac shows. + static let cloudflareAccountIdLabel: String = { + let field = CloudflareD1Plugin.additionalConnectionFields.first { $0.id == CloudflareD1Plugin.accountIdFieldId } + return field?.label ?? String(localized: "Account ID") + }() + + var cloudflareD1Credentials: CloudflareD1Credentials { + CloudflareD1Credentials(accountId: trimmedCloudflareAccountId, apiToken: password) + } + + // A pasted Account ID often carries a trailing newline. + private var trimmedCloudflareAccountId: String { + cloudflareAccountId.trimmingCharacters(in: .whitespacesAndNewlines) + } + // The Mac's MongoDB form keeps the seed list in place of Host and Port, and stores its first entry there too. static func mongoHosts(of connection: DatabaseConnection) -> String { if let listed = connection.additionalFields[MongoDBDialPlan.hostsField], !listed.isEmpty { return listed } @@ -263,6 +286,11 @@ final class ConnectionFormViewModel { type.isLocalFile } + // Host, port, SSL and SSH exist only for an engine the app dials over the network. + var hasNetworkTransport: Bool { + type.dialsServer + } + var isEditing: Bool { existingConnection != nil } var pastedPrivateKey: String? { @@ -286,14 +314,17 @@ final class ConnectionFormViewModel { } var edits: ConnectionFormEdits { + // An API-only engine has no server: the hidden Host, Port and Username fields keep what the + // last type left in them, and a record that stored them would show and match on them. + let isAPIOnly = type.connectionMode == .apiOnly let primary = type == .mongodb ? mongoEndpoints.first : nil - let dialedHost = primary?.host ?? self.host + let dialedHost = isAPIOnly ? "" : (primary?.host ?? self.host) return ConnectionFormEdits( - name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? dialedHost) : name, + name: name.isEmpty ? defaultName(dialedHost: dialedHost, isAPIOnly: isAPIOnly) : name, type: type, host: dialedHost, - port: primary?.port ?? Int(port) ?? Int(type.defaultPort) ?? 0, - username: username, + port: isAPIOnly ? 0 : (primary?.port ?? Int(port) ?? Int(type.defaultPort) ?? 0), + username: isAPIOnly ? "" : username, database: database, groupId: groupId, tagId: tagId, @@ -303,10 +334,16 @@ final class ConnectionFormViewModel { sshTunnel: sshTunnel, oracle: oracleOptions, mongo: mongoOptions, - iam: iamOptions + iam: iamOptions, + cloudflareD1: cloudflareD1Options ) } + private func defaultName(dialedHost: String, isAPIOnly: Bool) -> String { + if let fileName = selectedFileURL?.lastPathComponent { return fileName } + return isAPIOnly ? database : dialedHost + } + var offersOracleVerifyCa: Bool { existingConnection?.sslConfiguration?.mode == .verifyCa } @@ -357,8 +394,8 @@ final class ConnectionFormViewModel { } private var effectiveSSLMode: SSLModeChoice? { - switch type { - case .sqlite, .duckdb: nil + guard hasNetworkTransport else { return nil } + return switch type { case .mssql: mssqlSSLMode case .oracle: oracleSSLMode default: sslMode @@ -403,6 +440,11 @@ final class ConnectionFormViewModel { ) } + private var cloudflareD1Options: ConnectionFormEdits.CloudflareD1Options? { + guard type == .cloudflareD1 else { return nil } + return ConnectionFormEdits.CloudflareD1Options(accountId: trimmedCloudflareAccountId) + } + private var oracleOptions: ConnectionFormEdits.OracleOptions? { guard type == .oracle else { return nil } return ConnectionFormEdits.OracleOptions( @@ -508,6 +550,10 @@ final class ConnectionFormViewModel { if type == .mongodb, mongoHosts.isEmpty, !host.isEmpty { mongoHosts = HostListEndpoint(host: host, port: Self.mongoDefaultPort).entry } + // The SSH section hides for this type, so a tunnel left on could not be turned off and every connect would dial it. + if !hasNetworkTransport { + sshEnabled = false + } selectedFileURL = nil database = "" pendingFile = nil @@ -719,7 +765,7 @@ final class ConnectionFormViewModel { let context = ErrorContext( operation: "testConnection", databaseType: type, - host: host, + host: testConn.host, sshEnabled: sshEnabled, signsInWithIAM: testConn.iamSignIn != nil ) diff --git a/apple/TableProMobile/TableProMobile/Views/CloudflareD1DatabasePicker.swift b/apple/TableProMobile/TableProMobile/Views/CloudflareD1DatabasePicker.swift new file mode 100644 index 0000000000..3fb2c37ec7 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/CloudflareD1DatabasePicker.swift @@ -0,0 +1,150 @@ +import SwiftUI + +// iOS has no combo box, so the search field both filters the account's databases and takes a typed name or UUID. +struct CloudflareD1DatabasePicker: View { + @Environment(\.dismiss) private var dismiss + @Binding var selection: String + let credentials: CloudflareD1Credentials + let list: CloudflareD1DatabaseList + + @State private var query = "" + @State private var attempt = 0 + + nonisolated private struct LoadRequest: Hashable { + let credentials: CloudflareD1Credentials + let attempt: Int + } + + var body: some View { + let shown = list.databases(matching: query) + let typed = list.typedDatabase(in: query) + List { + if let typed { + Section { + Button(String(format: String(localized: "Use “%@”"), typed)) { pick(typed) } + } footer: { + if case .failed(let message) = list.state { + Text(message) + } + } + } + if !shown.isEmpty { + Section { + ForEach(shown, id: \.uuid) { database in + row(for: database) + } + } footer: { + if let refreshError = list.refreshError { + Text(refreshError) + } + } + } + } + .overlay { + placeholder(hasRows: typed != nil || !shown.isEmpty) + } + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Name or UUID")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit(of: .search, submit) + // Keyed on the credentials, never on each keystroke in the form: every list is a request against the API quota. + .task(id: LoadRequest(credentials: credentials, attempt: attempt)) { + await list.load(credentials) + } + .refreshable { + await list.refresh(credentials) + } + .navigationTitle(Text("Database")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func placeholder(hasRows: Bool) -> some View { + if !credentials.isComplete { + if !hasRows { + if isQueryEmpty { + ContentUnavailableView( + "No Account", + systemImage: "key", + description: Text("Enter the Account ID and API Token to list the databases in this account.") + ) + } else { + ContentUnavailableView.search(text: query) + } + } + } else { + switch list.state { + case .idle, .loading: + ProgressView() + case .failed(let message): + if !hasRows { + ContentUnavailableView { + Label("Can't Load Databases", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try Again") { attempt += 1 } + } + } + case .loaded(let databases): + if !hasRows { + if databases.isEmpty, isQueryEmpty { + ContentUnavailableView( + "No Databases", + systemImage: "cylinder.split.1x2", + description: Text("No D1 databases are available to this API token.") + ) + } else { + ContentUnavailableView.search(text: query) + } + } + } + } + } + + private func row(for database: D1DatabaseInfo) -> some View { + let isSelected = CloudflareD1DatabaseList.reference(selection, names: database) + return Button { + pick(database.name) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: database.name) + .foregroundStyle(Color.primary) + Text(verbatim: database.uuid) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var isQueryEmpty: Bool { + query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // Return picks the database the text names, listed or not. + private func submit() { + if let listed = list.listedDatabase(query) { + pick(listed.name) + } else if let typed = list.typedDatabase(in: query) { + pick(typed) + } + } + + // The name, not the UUID, is stored: the session's database switcher lists names. + private func pick(_ database: String) { + selection = database + dismiss() + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift b/apple/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift index 859fa087e5..38f4462cdf 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift @@ -39,6 +39,7 @@ struct DatabaseIconView: View { case .redis: return .red case .mongodb: return .green case .clickhouse: return .yellow + case .cloudflareD1: return .orange case .mssql: return .indigo case .oracle: return .red default: return .gray diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 9afcfe7240..561b56f381 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -22,6 +22,8 @@ struct ConnectionFormView: View { @State private var pastingServiceAccountKey = false @State private var pushedJumpHostId: UUID? @State private var reconnectingConnectionName: String? + // Held by the form, so going back to the picker shows the list it already fetched. + @State private var cloudflareD1Databases = CloudflareD1DatabaseList() var onSaved: (UUID) -> Void @@ -98,6 +100,8 @@ struct ConnectionFormView: View { sqliteSection(viewModel: viewModel) } else if viewModel.type == .duckdb { duckDBSection(viewModel: viewModel) + } else if viewModel.type == .cloudflareD1 { + cloudflareD1Sections(viewModel: viewModel) } else { serverSection(viewModel: viewModel) } @@ -110,7 +114,7 @@ struct ConnectionFormView: View { mongoSection(viewModel: viewModel) } - if !viewModel.isFileBased { + if viewModel.hasNetworkTransport { Section { if viewModel.type == .oracle { Picker(String(localized: "SSL Mode"), selection: $viewModel.oracleSSLMode) { @@ -521,6 +525,50 @@ struct ConnectionFormView: View { } } + // MARK: - Cloudflare D1 Sections + + @ViewBuilder + private func cloudflareD1Sections(viewModel: ConnectionFormViewModel) -> some View { + @Bindable var viewModel = viewModel + Section { + TextField(ConnectionFormViewModel.cloudflareAccountIdLabel, text: $viewModel.cloudflareAccountId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + // The token is the connection's password, so the Keychain and iCloud keep it under the Mac's item. + SecureField("API Token", text: $viewModel.password) + } header: { + Text("Authentication") + } footer: { + Text("Copy the Account ID from the Cloudflare dashboard. The API token needs the D1 Edit permission.") + } + Section { + NavigationLink { + CloudflareD1DatabasePicker( + selection: $viewModel.database, + credentials: viewModel.cloudflareD1Credentials, + list: cloudflareD1Databases + ) + } label: { + LabeledContent { + if viewModel.database.isEmpty { + Text("Required") + } else { + Text(verbatim: viewModel.database) + } + } label: { + Text("Database") + } + } + } header: { + Text("Database") + } footer: { + if !viewModel.cloudflareD1Credentials.isComplete { + Text("Enter the Account ID and API Token to choose a database.") + } + } + } + // MARK: - SSH Section @ViewBuilder diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift index 5f257e4a01..3f709863c4 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -44,6 +44,8 @@ struct ConnectionInfoView: View { } case .file(let file): fileSection(file) + case .account(let account): + accountSection(account) } statsSection @@ -89,6 +91,26 @@ struct ConnectionInfoView: View { } } + @ViewBuilder + private func accountSection(_ account: ConnectionAccountDetail) -> some View { + Section("Account") { + if let accountId = account.accountId { + LabeledContent("Account ID") { + Text(verbatim: accountId) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + if !activeDatabaseLabel.isEmpty { + LabeledContent("Database") { + Text(verbatim: activeDatabaseLabel) + .textSelection(.enabled) + } + } + } + } + private var activeDatabaseLabel: String { if !coordinator.activeDatabase.isEmpty { return coordinator.activeDatabase } return connection.database diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 0b809d0976..6f58d13597 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -199,7 +199,8 @@ struct MobileConnectionImportSheet: View { if let status = MacOnlyConnection.importStatus(for: row.settings) { return status } - return row.warnings.first ?? "\(row.settings.host):\(row.settings.port)" + // The line the connection list will show, so a file or API-only engine is not shown as a host and port. + return row.warnings.first ?? ConnectionDetailFormatter.detail(for: DatabaseConnection(importedSettings: row.settings)) } private static func needsAttention(_ row: ConnectionRow) -> Bool { diff --git a/apple/TableProMobile/TableProMobile/Views/TableListView.swift b/apple/TableProMobile/TableProMobile/Views/TableListView.swift index edd846f160..21c06dbf9e 100644 --- a/apple/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/TableListView.swift @@ -161,10 +161,10 @@ struct TableListView: View { Task { guard let driver = driver(for: action) else { return } do { - let quoted = SQLBuilder.qualifiedIdentifier( - table: action.table.name, schema: action.placement.schema, for: connection.type + let statement = SQLBuilder.truncateStatement( + table: action.table.name, schema: action.placement.schema, type: connection.type ) - _ = try await coordinator.track { try await driver.executeWrite(["TRUNCATE TABLE \(quoted)"]) } + _ = try await coordinator.track { try await driver.executeWrite([statement]) } await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription diff --git a/apple/TableProMobile/TableProMobileTests/CloudflareD1DatabaseListTests.swift b/apple/TableProMobile/TableProMobileTests/CloudflareD1DatabaseListTests.swift new file mode 100644 index 0000000000..302d974d17 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/CloudflareD1DatabaseListTests.swift @@ -0,0 +1,254 @@ +import Foundation +@testable import TableProMobile +import Testing + +@MainActor +struct CloudflareD1DatabaseListTests { + private struct ListFailure: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private enum Outcome: Sendable { + case databases([D1DatabaseInfo]) + case failure(String) + case cancelled + } + + // Hands out one scripted outcome per call and records the credentials each call carried. + private actor ScriptedLoader { + private var outcomes: [Outcome] + private(set) var calls: [CloudflareD1Credentials] = [] + + init(_ outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func next(accountId: String, apiToken: String) throws -> [D1DatabaseInfo] { + calls.append(CloudflareD1Credentials(accountId: accountId, apiToken: apiToken)) + let outcome = outcomes.isEmpty ? Outcome.failure("no outcome left") : outcomes.removeFirst() + switch outcome { + case .databases(let databases): return databases + case .failure(let message): throw ListFailure(message: message) + case .cancelled: throw CancellationError() + } + } + } + + private let account = CloudflareD1Credentials(accountId: "b5ad7a2e0123", apiToken: "cf-token") + + private func databases(_ entries: [(name: String, uuid: String)]) throws -> [D1DatabaseInfo] { + let objects = entries.map { ["name": $0.name, "uuid": $0.uuid] } + return try JSONDecoder().decode([D1DatabaseInfo].self, from: JSONSerialization.data(withJSONObject: objects)) + } + + private func sample() throws -> [D1DatabaseInfo] { + try databases([ + ("shop", "0f3c8a9e-4b1d-4c2e-9a7f-1d2e3f4a5b6c"), + ("analytics", "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d") + ]) + } + + private func makeList(_ loader: ScriptedLoader) -> CloudflareD1DatabaseList { + CloudflareD1DatabaseList { accountId, apiToken in + try await loader.next(accountId: accountId, apiToken: apiToken) + } + } + + private func names(_ list: CloudflareD1DatabaseList) -> [String] { + list.databases.map { $0.name } + } + + private func isIdle(_ list: CloudflareD1DatabaseList) -> Bool { + if case .idle = list.state { return true } + return false + } + + private func failure(_ list: CloudflareD1DatabaseList) -> String? { + if case .failed(let message) = list.state { return message } + return nil + } + + @Test("A load fetches the account's databases with its credentials and sorts them by name") + func loadFetchesAndSorts() async throws { + let loader = ScriptedLoader([.databases(try sample())]) + let list = makeList(loader) + + await list.load(account) + + #expect(names(list) == ["analytics", "shop"]) + #expect(await loader.calls == [account]) + } + + @Test("A second load with the same credentials does not fetch again, and new credentials do") + func sameCredentialsDoNotRefetch() async throws { + let other = CloudflareD1Credentials(accountId: "c0ffee000000", apiToken: "other-token") + let loader = ScriptedLoader([.databases(try sample()), .databases(try databases([("logs", "1")]))]) + let list = makeList(loader) + + await list.load(account) + await list.load(account) + #expect(await loader.calls.count == 1) + + await list.load(other) + #expect(await loader.calls == [account, other]) + #expect(names(list) == ["logs"]) + } + + @Test("A refresh fetches again and keeps the list it had when the fetch fails") + func failedRefreshKeepsTheList() async throws { + let loader = ScriptedLoader([ + .databases(try sample()), + .failure("Rate limited by Cloudflare."), + .databases(try databases([("shop", "0f3c8a9e-4b1d-4c2e-9a7f-1d2e3f4a5b6c")])) + ]) + let list = makeList(loader) + await list.load(account) + + await list.refresh(account) + + #expect(names(list) == ["analytics", "shop"]) + #expect(list.refreshError == "Rate limited by Cloudflare.") + #expect(failure(list) == nil) + + await list.refresh(account) + + #expect(names(list) == ["shop"]) + #expect(list.refreshError == nil) + #expect(await loader.calls.count == 3) + } + + @Test("A first load that fails says why, and the next load tries again") + func failedLoadIsRetried() async throws { + let loader = ScriptedLoader([.failure("Authentication failed."), .databases(try sample())]) + let list = makeList(loader) + + await list.load(account) + #expect(failure(list) == "Authentication failed.") + + await list.load(account) + #expect(names(list) == ["analytics", "shop"]) + #expect(await loader.calls.count == 2) + } + + @Test("A cancelled load is not a failure, and the next visit loads again") + func cancelledLoadIsNotAFailure() async throws { + let loader = ScriptedLoader([.cancelled, .databases(try sample())]) + let list = makeList(loader) + + await list.load(account) + #expect(isIdle(list)) + + await list.load(account) + #expect(names(list) == ["analytics", "shop"]) + } + + @Test("Leaving the picker mid-load cancels it without a failure") + func cancelledTaskIsNotAFailure() async throws { + let gate = TestGate() + let fetched = try sample() + let list = CloudflareD1DatabaseList { _, _ in + await gate.enter() + try Task.checkCancellation() + return fetched + } + + let visit = Task { await list.load(account) } + await gate.waitUntilEntered() + visit.cancel() + await gate.open() + await visit.value + + #expect(isIdle(list)) + #expect(list.databases.isEmpty) + } + + @Test("A cancelled refresh keeps the list and reports nothing") + func cancelledRefreshKeepsTheList() async throws { + let loader = ScriptedLoader([.databases(try sample()), .cancelled]) + let list = makeList(loader) + await list.load(account) + + await list.refresh(account) + + #expect(names(list) == ["analytics", "shop"]) + #expect(list.refreshError == nil) + } + + @Test("A list for credentials the user has since replaced is dropped when it arrives") + func staleListIsDropped() async throws { + let gate = TestGate() + let stale = try databases([("old", "1")]) + let current = try databases([("new", "2")]) + let list = CloudflareD1DatabaseList { accountId, _ in + if accountId == "old-account" { + await gate.enter() + return stale + } + return current + } + + let first = Task { await list.load(CloudflareD1Credentials(accountId: "old-account", apiToken: "t")) } + await gate.waitUntilEntered() + await list.load(account) + await gate.open() + await first.value + + #expect(names(list) == ["new"]) + } + + @Test("Without an Account ID or token nothing is fetched") + func incompleteCredentialsFetchNothing() async { + let loader = ScriptedLoader([]) + let list = makeList(loader) + + await list.load(CloudflareD1Credentials(accountId: "", apiToken: "cf-token")) + await list.load(CloudflareD1Credentials(accountId: "b5ad7a2e0123", apiToken: "")) + + #expect(isIdle(list)) + #expect(await loader.calls.isEmpty) + } + + @Test("Search matches a name or a UUID, ignoring case") + func searchMatchesNameOrUUID() async throws { + let list = makeList(ScriptedLoader([.databases(try sample())])) + await list.load(account) + + #expect(list.databases(matching: "SHO").map { $0.name } == ["shop"]) + #expect(list.databases(matching: "7A6B5C4D").map { $0.name } == ["analytics"]) + #expect(list.databases(matching: " ").count == 2) + } + + @Test("A typed name or UUID is offered only when valid and not already listed") + func typedDatabaseOffer() async throws { + let list = makeList(ScriptedLoader([.databases(try sample())])) + await list.load(account) + + #expect(list.typedDatabase(in: " staging-db_2 ") == "staging-db_2") + #expect(list.typedDatabase(in: "11111111-2222-4333-8444-555555555555") == "11111111-2222-4333-8444-555555555555") + #expect(list.typedDatabase(in: "shop") == nil) + #expect(list.typedDatabase(in: "0F3C8A9E-4B1D-4C2E-9A7F-1D2E3F4A5B6C") == nil) + #expect(list.typedDatabase(in: "my db") == nil) + #expect(list.typedDatabase(in: "-shop") == nil) + #expect(list.typedDatabase(in: "shop/../x") == nil) + #expect(list.typedDatabase(in: "") == nil) + } + + @Test("A typed value is still offered when the list could not load") + func typedDatabaseAfterFailure() async { + let list = makeList(ScriptedLoader([.failure("offline")])) + await list.load(account) + + #expect(list.typedDatabase(in: "shop") == "shop") + #expect(list.listedDatabase("shop") == nil) + } + + @Test("A stored UUID finds its listed database, so the picker marks it") + func storedUUIDFindsItsDatabase() async throws { + let list = makeList(ScriptedLoader([.databases(try sample())])) + await list.load(account) + + #expect(list.listedDatabase("0f3c8a9e-4b1d-4c2e-9a7f-1d2e3f4a5b6c")?.name == "shop") + #expect(list.listedDatabase("analytics")?.name == "analytics") + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift b/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift index fb48d5daac..490a32e0bd 100644 --- a/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift @@ -65,6 +65,14 @@ struct CompletionIdentifierQuotingTests { #expect(CompletionIdentifierQuoting.quoted(name, for: type) == expected) } + @Test("D1 quotes names by SQLite's rules, since it runs SQLite") + func cloudflareD1FollowsSQLite() { + #expect(CompletionIdentifierQuoting.quoted("café", for: .cloudflareD1) == "café") + #expect(CompletionIdentifierQuoting.quoted("price$", for: .cloudflareD1) == "price$") + #expect(CompletionIdentifierQuoting.quoted("pragma", for: .cloudflareD1) == "\"pragma\"") + #expect(CompletionIdentifierQuoting.quoted("user", for: .cloudflareD1) == "user") + } + @Test("a word only another engine reserves stays bare") func otherEngineReservedWord() { #expect(CompletionIdentifierQuoting.quoted("user", for: .mysql) == "user") diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorCatalogTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorCatalogTests.swift index 80b6d72265..b8843230d4 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorCatalogTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorCatalogTests.swift @@ -293,9 +293,38 @@ struct ConnectionCoordinatorCatalogTests { appState.setEditorHold(false, by: otherWindow, on: connection.id) } + // The Mac lets a D1 connection store its database's UUID, and the account lists its databases by name. + @Test("A database the connection names by its id moves the picker to the listed name the driver reports") + func reportedNameReplacesTheStoredId() async { + let d1 = DatabaseConnection(name: "Edge", type: .cloudflareD1, database: "0f6c1f8e-5a8b-4c4e-9a65-3d1f2c7e9b10") + let session = driver(tables: ["events"]) + session.scriptedDatabases = ["analytics", "app"] + session.scriptedCurrentDatabase = "app" + factory.queue(session) + let coordinator = ConnectionCoordinator(connection: d1, appState: appState) + + await coordinator.connect() + + #expect(coordinator.activeDatabase == "app") + #expect(session.switchedDatabases == ["app"]) + #expect(appState.connectionManager.session(for: d1.id)?.activeDatabase == "app") + } + + @Test("A stored database the list holds stays the picker's, whatever the driver reports") + func listedDatabaseStays() async { + let session = driver(tables: ["orders"]) + session.scriptedDatabases = ["app_prod", "archive"] + session.scriptedCurrentDatabase = "archive" + + let coordinator = await connected(session) + + #expect(coordinator.activeDatabase == "app_prod") + #expect(session.switchedDatabases.isEmpty) + } + private func usedDatabaseDriver(_ plugin: StubPluginDriver) -> UsedDatabaseDriver { UsedDatabaseDriver( - plugin: plugin, dialedHosts: [], rowIdentity: .keyColumns, readsOnly: { _ in true }, + plugin: plugin, dialedHosts: [], readsOnly: { _ in true }, cancelsRunningRequest: { try? plugin.cancelQuery() } ) } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelCloudflareD1Tests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelCloudflareD1Tests.swift new file mode 100644 index 0000000000..e457c32f98 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelCloudflareD1Tests.swift @@ -0,0 +1,233 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +struct ConnectionFormViewModelCloudflareD1Tests { + private let fixture: AppStateFixture + + init() throws { + fixture = try AppStateFixture() + } + + // A Mac `d1://` import, which writes the account id to Host as well as to the plugin's field. + private func macConnection() -> DatabaseConnection { + DatabaseConnection( + name: "Edge", + type: .cloudflareD1, + host: "b5ad7a2e0123", + port: 0, + database: "shop", + additionalFields: [ + "cfAccountId": "b5ad7a2e0123", + "connectTimeout": "15000" + ] + ) + } + + private func newD1Form() -> ConnectionFormViewModel { + let viewModel = fixture.makeFormViewModel() + viewModel.type = .cloudflareD1 + return viewModel + } + + @Test("Save needs an Account ID and a database, and no token, since an edit keeps the stored one") + func canSaveNeedsAccountAndDatabase() { + let viewModel = newD1Form() + #expect(!viewModel.canSave) + + viewModel.cloudflareAccountId = " \n" + viewModel.database = "shop" + #expect(!viewModel.canSave) + + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.database = "" + #expect(!viewModel.canSave) + + viewModel.database = "shop" + #expect(viewModel.password.isEmpty) + #expect(viewModel.canSave) + } + + @Test("The Account ID is saved trimmed under the Mac plugin's key and opens again in the form") + func accountIdRoundTrips() { + let viewModel = newD1Form() + viewModel.cloudflareAccountId = " b5ad7a2e0123\n" + viewModel.database = "shop" + + let built = viewModel.buildConnection() + let reopened = fixture.makeFormViewModel(editing: built) + + #expect(built.additionalFields["cfAccountId"] == "b5ad7a2e0123") + #expect(reopened.cloudflareAccountId == "b5ad7a2e0123") + #expect(reopened.cloudflareD1Credentials.accountId == "b5ad7a2e0123") + } + + @Test("A rename keeps the Account ID, the Mac's other fields and the Host the Mac wrote") + func renameKeepsTheMacFields() { + let stored = macConnection() + let viewModel = fixture.makeFormViewModel(editing: stored) + + viewModel.name = "Edge Store" + let saved = viewModel.applyingEdits(to: stored) + + #expect(viewModel.cloudflareAccountId == "b5ad7a2e0123") + #expect(saved.name == "Edge Store") + #expect(saved.additionalFields == stored.additionalFields) + #expect(saved.host == stored.host) + #expect(saved.database == "shop") + } + + @Test("An untouched D1 form has no changes and hands the record back as it is") + func untouchedFormIsClean() { + let stored = macConnection() + let viewModel = fixture.makeFormViewModel(editing: stored) + + #expect(!viewModel.hasChanges) + #expect(viewModel.applyingEdits(to: stored) == stored) + } + + @Test("A changed Account ID is a change, is saved, and redials an open session") + func changedAccountIdRedials() { + let stored = macConnection() + let viewModel = fixture.makeFormViewModel(editing: stored) + + viewModel.cloudflareAccountId = "c0ffee000000" + let saved = viewModel.applyingEdits(to: stored) + + #expect(viewModel.hasChanges) + #expect(saved.additionalFields["cfAccountId"] == "c0ffee000000") + #expect(saved.additionalFields["connectTimeout"] == "15000") + #expect(viewModel.redialsAfterSave(over: stored)) + + viewModel.cloudflareAccountId = "b5ad7a2e0123" + #expect(!viewModel.hasChanges) + } + + @Test("The API token is the connection's password, stored where the Mac keeps it") + func tokenIsThePassword() async throws { + let store = MockSecureStore() + let appState = fixture.makeState(syncEnabled: false, secureStore: store) + let viewModel = newD1Form() + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.password = "cf-token" + viewModel.database = "shop" + let testId = UUID() + + #expect(viewModel.cloudflareD1Credentials == CloudflareD1Credentials(accountId: "b5ad7a2e0123", apiToken: "cf-token")) + #expect(viewModel.testSecrets(for: testId)["com.TablePro.password.\(testId.uuidString)"] == "cf-token") + + let savedId = try #require(await viewModel.save(appState: appState, secureStore: store)) + + #expect(try store.retrieve(forKey: "com.TablePro.password.\(savedId.uuidString)") == "cf-token") + } + + @Test("A D1 connection with no name is named after its database") + func nameDefaultsToTheDatabase() { + let viewModel = newD1Form() + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.database = "shop" + + #expect(viewModel.edits.name == "shop") + #expect(viewModel.buildConnection().name == "shop") + } + + @Test("D1 writes no SSL settings, whatever the form held for the previous type") + func noSSLWritten() { + let viewModel = fixture.makeFormViewModel() + viewModel.sslMode = .require + viewModel.type = .cloudflareD1 + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.database = "shop" + + let built = viewModel.buildConnection() + + #expect(viewModel.edits.sslMode == nil) + #expect(!viewModel.usesCertificateSection) + #expect(!built.sslEnabled) + #expect(built.sslConfiguration == nil) + } + + @Test("D1 stores no host, port or username, even ones typed for the previous type") + func noServerFieldsWritten() { + let viewModel = fixture.makeFormViewModel() + viewModel.host = "db.example.com" + viewModel.port = "3307" + viewModel.username = "root" + viewModel.type = .cloudflareD1 + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.database = "shop" + + let built = viewModel.buildConnection() + + #expect(built.host == "") + #expect(built.port == 0) + #expect(built.username == "") + } + + @Test("A MySQL record switched to D1 drops its host, port and username") + func switchedRecordDropsServerFields() { + let stored = DatabaseConnection( + name: "Shop", type: .mysql, host: "10.0.0.5", port: 3_306, username: "root", database: "shop" + ) + let viewModel = fixture.makeFormViewModel(editing: stored) + + viewModel.type = .cloudflareD1 + viewModel.cloudflareAccountId = "b5ad7a2e0123" + viewModel.database = "shop" + let saved = viewModel.applyingEdits(to: stored) + + #expect(saved.type == .cloudflareD1) + #expect(saved.host == "") + #expect(saved.port == 0) + #expect(saved.username == "") + #expect(saved.additionalFields["cfAccountId"] == "b5ad7a2e0123") + } + + @Test( + "Switching to a type with no network transport turns SSH off, since its SSH section is hidden", + arguments: [DatabaseType.cloudflareD1, .sqlite, .duckdb] + ) + func typeChangeTurnsSSHOff(type: DatabaseType) { + let viewModel = fixture.makeFormViewModel() + viewModel.host = "10.0.0.5" + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshUsername = "deploy" + + viewModel.type = type + viewModel.database = "/tmp/shop.db" + viewModel.cloudflareAccountId = "b5ad7a2e0123" + let built = viewModel.buildConnection() + + #expect(!viewModel.sshEnabled) + #expect(viewModel.edits.sshTunnel == nil) + #expect(!built.sshEnabled) + } + + @Test("Switching between network types keeps the tunnel on") + func networkTypeChangeKeepsSSH() { + let viewModel = fixture.makeFormViewModel() + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + + viewModel.type = .postgresql + + #expect(viewModel.sshEnabled) + #expect(viewModel.hasNetworkTransport) + } + + @Test("A synced D1 record with a tunnel opens with it off, so Test Connection dials no SSH") + func syncedTunnelOpensOff() { + var stored = macConnection() + stored.sshEnabled = true + stored.sshConfiguration = SSHConfiguration(host: "bastion.example.com", username: "deploy") + let viewModel = fixture.makeFormViewModel(editing: stored) + + #expect(!viewModel.sshEnabled) + #expect(!viewModel.hasNetworkTransport) + #expect(!viewModel.buildConnection().sshEnabled) + #expect(!viewModel.hasChanges) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift index 7c1c9e1ab3..df643652d9 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift @@ -107,4 +107,13 @@ struct ConnectionDetailFormatterTests { #expect(ConnectionDetailFormatter.detail(for: blank) == "db.acme.io:3307") #expect(ConnectionDetailFormatter.detail(for: redshift) == "db.acme.io") } + + @Test("A Cloudflare D1 connection shows its database, never the account id a Mac import keeps in Host") + func cloudflareD1ShowsTheDatabase() { + let macImport = DatabaseConnection(type: .cloudflareD1, host: "b5ad7a2e0123", port: 0, database: "shop") + let formMade = DatabaseConnection(type: .cloudflareD1, host: "", port: 0, database: "shop") + + #expect(ConnectionDetailFormatter.detail(for: macImport) == "shop") + #expect(ConnectionDetailFormatter.detail(for: formMade) == "shop") + } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift index 51389abd51..9075438f26 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -654,4 +654,51 @@ struct ConnectionFormEditsTests { #expect(saved.safeModeLevel == .readOnly) #expect(saved.isReadOnly) } + + private func cloudflareD1Connection() -> DatabaseConnection { + DatabaseConnection( + name: "Edge", + type: .cloudflareD1, + host: "", + port: 0, + database: "shop", + additionalFields: ["cfAccountId": "b5ad7a2e0123", "connectTimeout": "15000"] + ) + } + + @Test("A D1 edit that leaves the Account ID alone keeps one that synced in while the form was open") + func cloudflareD1EditKeepsSyncedAccountId() { + let snapshot = cloudflareD1Connection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.additionalFields["cfAccountId"] = "c0ffee000000" + + viewModel.name = "Edge Store" + let saved = viewModel.applyingEdits(to: current) + + #expect(saved.name == "Edge Store") + #expect(saved.additionalFields == current.additionalFields) + } + + @Test("A changed D1 Account ID is written trimmed, and the Mac's other fields stay") + func cloudflareD1AccountIdEdit() { + let snapshot = cloudflareD1Connection() + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.cloudflareAccountId = "c0ffee000000 \n" + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(viewModel.edits.cloudflareD1 == ConnectionFormEdits.CloudflareD1Options(accountId: "c0ffee000000")) + #expect(saved.additionalFields["cfAccountId"] == "c0ffee000000") + #expect(saved.additionalFields["connectTimeout"] == "15000") + } + + @Test("Another engine's form carries no D1 options and writes no Account ID") + func otherEnginesWriteNoAccountId() { + let viewModel = ConnectionFormViewModel(editing: storedConnection()) + viewModel.cloudflareAccountId = "b5ad7a2e0123" + + #expect(viewModel.edits.cloudflareD1 == nil) + #expect(viewModel.buildConnection().additionalFields["cfAccountId"] == nil) + } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift index 45b5fae2a8..0b9562cd97 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionInfoContentTests.swift @@ -47,6 +47,34 @@ struct ConnectionInfoContentTests { #expect(ConnectionInfoContent.section(for: connection, fileURL: nil) == .server) } + @Test("A Cloudflare D1 connection gets the Account section, with no host, port or SSL") + func cloudflareD1Account() { + let connection = DatabaseConnection( + type: .cloudflareD1, + host: "b5ad7a2e0123", + port: 0, + database: "shop", + additionalFields: ["cfAccountId": "b5ad7a2e0123"] + ) + + let section = ConnectionInfoContent.section(for: connection, fileURL: nil) + + #expect(section == .account(ConnectionAccountDetail(accountId: "b5ad7a2e0123"))) + } + + @Test("A D1 connection with no Account ID stored shows none") + func cloudflareD1WithoutAccountId() { + let connection = DatabaseConnection( + type: .cloudflareD1, + host: "", + port: 0, + database: "shop", + additionalFields: ["cfAccountId": ""] + ) + + #expect(ConnectionInfoContent.section(for: connection, fileURL: nil) == .account(ConnectionAccountDetail(accountId: nil))) + } + @Test("Only SQLite and DuckDB are local file engines", arguments: DatabaseType.mobileSupportedTypes) func localFileEngines(type: DatabaseType) { #expect(type.isLocalFile == (type == .sqlite || type == .duckdb)) diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/ClickHouseOnIOSTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/ClickHouseOnIOSTests.swift index b28e53902b..b8266c01a4 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/ClickHouseOnIOSTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/ClickHouseOnIOSTests.swift @@ -6,9 +6,9 @@ import TableProPluginKit import Testing struct ClickHouseOnIOSTests { - private func driver() -> PluginBackedDriver { + private func driver() -> PluginRowWritingDriver { let config = DriverConnectionConfig(host: "ch.example.com", port: 8_123, username: "u", password: "p", database: "default") - return PluginBackedDriver( + return PluginRowWritingDriver( plugin: ClickHousePluginDriver(config: config), dialedHosts: [], rowIdentity: .everyColumn, @@ -72,7 +72,7 @@ struct ClickHouseOnIOSTests { let opened = try IOSDriverFactory(materializer: CertificateMaterializer(store: InMemoryCertificateStore())) .createDriver(for: connection, password: "p") - let writer = try #require(opened as? PluginBackedDriver) + let writer = try #require(opened as? PluginRowWritingDriver) #expect(writer.rowIdentity == .everyColumn) #expect(writer.supportsTransactions == false) } diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/CloudflareD1OnIOSTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/CloudflareD1OnIOSTests.swift new file mode 100644 index 0000000000..cb5453a1cb --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Drivers/CloudflareD1OnIOSTests.swift @@ -0,0 +1,435 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +struct CloudflareD1OnIOSTests { + private static let base = "/client/v4/accounts/acc123/d1/database" + private static let appUUID = "8a1f3f6e-0c2d-4b7a-9e51-2f4c6d8b0a13" + private static let archiveUUID = "5c9e2b71-3d4a-4f86-a0b2-7e1d9c3f5a64" + + // An account holding `app` and `archive`; every query answers `raw`. + private func account(raw: String = D1Wire.rows([], "[]")) -> D1Stub.Script { + D1Stub.Script { exchange in + switch (exchange.method, exchange.path) { + case ("GET", "\(Self.base)/app"), ("GET", "\(Self.base)/\(Self.appUUID)"): + return D1Stub.Reply(body: D1Wire.database("app", uuid: Self.appUUID)) + case ("GET", "\(Self.base)/archive"), ("GET", "\(Self.base)/\(Self.archiveUUID)"): + return D1Stub.Reply(body: D1Wire.database("archive", uuid: Self.archiveUUID)) + case ("POST", let path) where path.hasSuffix("/raw"): + return D1Stub.Reply(body: raw) + default: + return D1Stub.Reply(status: 404, body: D1Wire.notFound) + } + } + } + + private func plugin( + database: String = "app", + token: String = "token", + accountId: String = "acc123", + script: D1Stub.Script + ) -> CloudflareD1PluginDriver { + CloudflareD1PluginDriver( + config: DriverConnectionConfig( + host: "", port: 0, username: "", password: token, database: database, + additionalFields: [CloudflareD1Plugin.accountIdFieldId: accountId] + ), + sessionConfiguration: script.configuration + ) + } + + private func connection(database: String = "app") -> DatabaseConnection { + DatabaseConnection( + name: "Edge", type: .cloudflareD1, host: "", port: 0, database: database, + additionalFields: [CloudflareD1Plugin.accountIdFieldId: "acc123"] + ) + } + + private var connectContext: ErrorContext { + ErrorContext(operation: "connect", databaseType: .cloudflareD1, host: "") + } + + // MARK: - Factory + + // D1's plugin writes no rows of its own, so a row writer would fail every edit; an empty host would be probed for + // Local Network permission. + @Test("The factory opens D1 as a plain plugin driver, with no host to ask Local Network permission for") + func factoryOpensD1() throws { + let opened = try IOSDriverFactory().createDriver(for: connection(), password: "token") + + let driver = try #require(opened as? CloudflareD1BackedDriver) + #expect(!(opened is any DriverRowWriting)) + #expect(driver.dialedHosts.isEmpty) + #expect(driver.supportsTransactions == false) + #expect(IOSDriverFactory.refusal(for: connection()) == nil) + #expect(!IOSDriverFactory().opensCatalogConnections(for: connection())) + } + + @Test("The factory hands the plugin the account, the token and the database the connection stores") + func factoryCarriesTheConnection() async throws { + let script = account() + let factory = IOSDriverFactory(apiSessionConfiguration: { script.configuration }) + + let driver = try factory.createDriver(for: connection(), password: "secret-token") + try await driver.connect() + + let lookup = try #require(script.exchanges.first) + #expect(lookup.method == "GET") + #expect(lookup.path == "\(Self.base)/app") + #expect(lookup.authorization == "Bearer secret-token") + #expect(driver.currentDatabase == "app") + } + + @Test("D1 is offered, opens here, takes rows from Shortcuts, and has no port") + @MainActor + func typeIsListed() { + #expect(DatabaseType.mobileSupportedTypes.contains(.cloudflareD1)) + #expect(IOSDriverFactory.supportedTypeList.contains(.cloudflareD1)) + #expect(IntentDatabaseSession.supportsTabularInsert(.cloudflareD1)) + #expect(DatabaseType.cloudflareD1.mobileDisplayName == "Cloudflare D1") + #expect(DatabaseType.cloudflareD1.defaultPort == "") + } + + @Test("Each engine has the Mac's connection shape", arguments: [ + (DatabaseType.cloudflareD1, ConnectionMode.apiOnly), + (.sqlite, .fileBased), + (.duckdb, .fileBased), + (.mysql, .network), + (.clickhouse, .network) + ]) + func connectionShape(type: DatabaseType, mode: ConnectionMode) { + #expect(type.connectionMode == mode) + #expect(type.isLocalFile == (mode == .fileBased)) + } + + // MARK: - Values + + // Before, every JSON shape but a number, a string or null read as NULL (measured), so a BLOB looked empty. + @Test("Each value D1 sends keeps its value: bytes as bytes, a boolean as 1, any other JSON as its text") + func valuesKeepTheirShape() async throws { + let script = account(raw: D1Wire.rows( + ["blob", "empty", "flag", "object", "big", "real", "text", "nothing"], + #"[[[0,1,2,255],[],true,{"a":1},9007199254740993,2.5,"x",null]]"# + )) + let d1 = plugin(script: script) + try await d1.connect() + + let result = try await d1.execute(query: "SELECT * FROM shapes") + + let expected: [[PluginCellValue]] = [[ + .bytes(Data([0, 1, 2, 255])), .bytes(Data()), "1", #"{"a":1}"#, "9007199254740993", "2.5", "x", .null + ]] + #expect(result.rows == expected) + } + + @Test("A BLOB reaches the grid as a binary cell") + func blobIsABinaryCell() async throws { + let script = account(raw: D1Wire.rows(["id", "blob"], "[[1,[0,1,255]]]")) + let driver = CloudflareD1BackedDriver(d1: plugin(script: script)) + try await driver.connect() + + var cells: [Cell] = [] + for try await element in driver.executeStreaming(query: "SELECT * FROM files", options: StreamOptions(maxRows: 10)) { + if case .row(let row) = element { cells += row.cells } + } + + guard cells.count == 2, case .binary(let count, _) = cells[1] else { + Issue.record("the BLOB should arrive as a binary cell") + return + } + #expect(count == 3) + } + + // MARK: - Databases + + @Test("A connection that stores its database's UUID reports the database by name, after one lookup") + func uuidConnectionReportsTheName() async throws { + let script = account() + let driver = CloudflareD1BackedDriver(d1: plugin(database: Self.appUUID, script: script)) + + try await driver.connect() + + #expect(driver.currentDatabase == "app") + #expect(script.exchanges.map { $0.path } == ["\(Self.base)/\(Self.appUUID)"]) + } + + // Behind PluginBackedDriver a stale name would make each catalog read switch back, so writes went to the old one. + @Test("After a switch the driver reports the new database, and every later request goes to it") + func switchMovesEveryRequest() async throws { + let script = account() + let driver = CloudflareD1BackedDriver(d1: plugin(script: script)) + try await driver.connect() + + try await driver.switchDatabase(to: "archive") + _ = try await driver.fetchTables(schema: nil) + _ = try await driver.execute(query: "SELECT 1") + + #expect(driver.currentDatabase == "archive") + let queries = script.exchanges.filter { $0.method == "POST" }.map { $0.path } + #expect(queries.count >= 2) + #expect(queries.allSatisfy { $0 == "\(Self.base)/\(Self.archiveUUID)/raw" }) + } + + @Test("The account's databases are read page by page, until the count D1 reports") + func listFollowsTheTotalCount() async throws { + let script = D1Stub.Script { exchange in + switch exchange.query["page"] ?? "" { + case "1": return D1Stub.Reply(body: D1Wire.list(["a", "b"], total: 3)) + case "2": return D1Stub.Reply(body: D1Wire.list(["c"], total: 3)) + default: return D1Stub.Reply(body: D1Wire.list([], total: 3)) + } + } + + let databases = try await CloudflareD1PluginDriver.listDatabases( + accountId: "acc123", apiToken: "token", sessionConfiguration: script.configuration + ) + + #expect(databases.map { $0.name } == ["a", "b", "c"]) + #expect(script.exchanges.map { $0.query["page"] } == ["1", "2"]) + #expect(script.exchanges.allSatisfy { $0.query["per_page"] == "10000" }) + } + + @Test("A database the account does not hold is named in the error") + func missingDatabaseIsNamed() async { + let d1 = plugin(database: "missing", script: account()) + + let thrown = await #expect(throws: (any Error).self) { try await d1.connect() } + + #expect(thrown?.localizedDescription + == String(format: String(localized: "Database '%@' not found in account"), "missing")) + } + + // MARK: - Errors + + @Test("A token D1 refuses keeps its own error and asks for the token and account, never a username") + func refusedTokenIsAuth() async throws { + let script = D1Stub.Script { _ in D1Stub.Reply(status: 401, body: D1Wire.authFailure) } + let d1 = plugin(script: script) + + let thrown = await #expect(throws: (any Error).self) { try await d1.connect() } + let error = try #require(thrown) + let classified = ErrorClassifier.classify(error, context: connectContext) + + #expect(error.localizedDescription == String(localized: "Authentication failed. Check your API token and Account ID.")) + #expect(classified.category == .auth) + #expect(classified.recovery == String(localized: "Check the API token and account of this connection.")) + } + + // A record synced without password sync arrives with no token. + @Test("A connection missing its token or its account asks for them", arguments: [("", "acc123"), ("token", "")]) + func missingCredentialIsAuth(token: String, accountId: String) async throws { + let script = account() + let d1 = plugin(token: token, accountId: accountId, script: script) + + let thrown = await #expect(throws: (any Error).self) { try await d1.connect() } + let classified = ErrorClassifier.classify(try #require(thrown), context: connectContext) + + #expect(classified.category == .auth) + #expect(classified.recovery == String(localized: "Check the API token and account of this connection.")) + #expect(script.exchanges.isEmpty) + } + + // MARK: - Bounded reads + + // D1 fails a result over 50 MB from 2026-11-04, so the editor's row cap is sent with the read. + @Test("A read with no limit of its own is sent with one row past the cap, then trimmed and marked truncated") + func boundedReadAppendsALimit() async throws { + let script = account(raw: D1Wire.rows(["id"], "[[1],[2],[3]]")) + let d1 = plugin(script: script) + try await d1.connect() + + let bounded = try await d1.executeBoundedQuery(query: "SELECT * FROM events;", rowCap: 2) + + let result = try #require(bounded) + #expect(script.sentSQL.last == "SELECT * FROM events\nLIMIT 3") + #expect(result.rows == [["1"], ["2"]]) + #expect(result.isTruncated) + } + + @Test("A table named fetch is no FETCH clause, so the read is still bounded") + func tableNamedFetchIsBounded() async throws { + let script = account(raw: D1Wire.rows(["id"], "[[1]]")) + let d1 = plugin(script: script) + try await d1.connect() + + _ = try await d1.executeBoundedQuery(query: "SELECT * FROM fetch", rowCap: 2) + + #expect(script.sentSQL.last == "SELECT * FROM fetch\nLIMIT 3") + } + + @Test("A comment after the terminator stays out of the bounded statement") + func commentAfterTerminatorIsDropped() async throws { + let script = account(raw: D1Wire.rows(["id"], "[[1]]")) + let d1 = plugin(script: script) + try await d1.connect() + + _ = try await d1.executeBoundedQuery(query: "SELECT * FROM events; -- note", rowCap: 2) + + #expect(script.sentSQL.last == "SELECT * FROM events\nLIMIT 3") + } + + @Test("A read that states its own limit is sent as written") + func explicitLimitIsKept() async throws { + let script = account(raw: D1Wire.rows(["id"], "[[1]]")) + let d1 = plugin(script: script) + try await d1.connect() + + _ = try await d1.executeBoundedQuery(query: "SELECT * FROM events LIMIT 5", rowCap: 2) + + #expect(script.sentSQL.last == "SELECT * FROM events LIMIT 5") + } + + // Measured on sqlite3: `VALUES (1),(2)` with a LIMIT appended is a syntax error. + @Test("A statement a LIMIT cannot follow is left to the plain path", arguments: [ + "VALUES (1), (2)", "PRAGMA table_info(events)", "INSERT INTO events VALUES (1)", + "SELECT 1 UNION ALL VALUES (2)", "WITH t AS (SELECT 1) SELECT * FROM t UNION VALUES (2)" + ]) + func unboundableStatementIsNotBounded(query: String) async throws { + let script = account() + let d1 = plugin(script: script) + try await d1.connect() + + let bounded = try await d1.executeBoundedQuery(query: query, rowCap: 2) + + #expect(bounded == nil) + #expect(script.sentSQL.isEmpty) + } +} + +// MARK: - Wire + +private enum D1Wire { + static func database(_ name: String, uuid: String) -> String { + #"{"result":{"uuid":"\#(uuid)","name":"\#(name)","version":"production","created_at":"2026-01-01T00:00:00Z"},"success":true,"errors":[],"messages":[]}"# + } + + static func rows(_ columns: [String], _ rows: String) -> String { + let names = columns.map { "\"\($0)\"" }.joined(separator: ",") + return #"{"result":[{"results":{"columns":[\#(names)],"rows":\#(rows)},"meta":{"duration":0.1,"changes":0},"success":true}],"success":true,"errors":[],"messages":[]}"# + } + + static func list(_ names: [String], total: Int) -> String { + let databases = names.map { #"{"uuid":"\#(UUID().uuidString.lowercased())","name":"\#($0)"}"# }.joined(separator: ",") + return #"{"result":[\#(databases)],"success":true,"errors":[],"messages":[],"result_info":{"page":1,"per_page":10000,"count":\#(names.count),"total_count":\#(total)}}"# + } + + static let notFound = #"{"result":null,"success":false,"errors":[{"code":7404,"message":"The database could not be found"}],"messages":[]}"# + + static let authFailure = #"{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}],"messages":[]}"# +} + +// Answers each request from the script its session was made for, so cases running at once never share replies. +private final class D1Stub: URLProtocol, @unchecked Sendable { + struct Reply: Sendable { + var status = 200 + var body: String + } + + struct Exchange: Sendable { + let method: String + let path: String + let query: [String: String] + let authorization: String? + let sql: String? + } + + final class Script: @unchecked Sendable { + typealias Respond = @Sendable (Exchange) -> Reply + + fileprivate let id = UUID().uuidString + private let respond: Respond + private let lock = NSLock() + private var recorded: [Exchange] = [] + + init(_ respond: @escaping Respond) { + self.respond = respond + D1Stub.register(self) + } + + var exchanges: [Exchange] { + lock.withLock { recorded } + } + + var sentSQL: [String] { + exchanges.compactMap { $0.sql } + } + + var configuration: URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [D1Stub.self] + configuration.httpAdditionalHeaders = [D1Stub.routeHeader: id] + return configuration + } + + fileprivate func reply(to exchange: Exchange) -> Reply { + lock.withLock { recorded.append(exchange) } + return respond(exchange) + } + } + + private static let routeHeader = "X-D1-Stub-Route" + private static let lock = NSLock() + nonisolated(unsafe) private static var scripts: [String: Script] = [:] + + fileprivate static func register(_ script: Script) { + lock.withLock { scripts[script.id] = script } + } + + private static func script(for request: URLRequest) -> Script? { + guard let id = request.value(forHTTPHeaderField: routeHeader) else { return nil } + return lock.withLock { scripts[id] } + } + + // URLSession hands a custom protocol the body as a stream, not as httpBody. + private static func body(of request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } + + private static func exchange(for request: URLRequest, url: URL) -> Exchange { + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let json = try? JSONSerialization.jsonObject(with: body(of: request)) as? [String: Any] + return Exchange( + method: request.httpMethod ?? "GET", + path: url.path, + query: Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { _, last in last }), + authorization: request.value(forHTTPHeaderField: "Authorization"), + sql: json?["sql"] as? String + ) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let script = Self.script(for: request), let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) + return + } + let reply = script.reply(to: Self.exchange(for: request, url: url)) + guard let response = HTTPURLResponse( + url: url, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"] + ) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(reply.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MongoDBOnIOSTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MongoDBOnIOSTests.swift index c26ca1a159..0adc8a1d7e 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MongoDBOnIOSTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MongoDBOnIOSTests.swift @@ -180,7 +180,6 @@ struct MongoDBOnIOSTests { let driver = PluginBackedDriver( plugin: plugin, dialedHosts: [], - rowIdentity: .keyColumns, readsOnly: { !SQLWriteClassifier.isWriteQuery($0, databaseType: .mongodb) }, provesReads: true ) @@ -196,7 +195,7 @@ struct MongoDBOnIOSTests { func guessedReadsStayWritable() async throws { let plugin = StubPluginDriver() plugin.answersBoundedReads = false - let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], rowIdentity: .everyColumn, readsOnly: { _ in true }) + let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], readsOnly: { _ in true }) _ = try await driver.execute(query: "SELECT 1") @@ -214,7 +213,7 @@ struct MongoDBOnIOSTests { for await _ in released { break } } let driver = PluginBackedDriver( - plugin: plugin, dialedHosts: [], rowIdentity: .keyColumns, readsOnly: { _ in true }, + plugin: plugin, dialedHosts: [], readsOnly: { _ in true }, cancelsRunningRequest: { try? plugin.cancelQuery() } ) @@ -324,7 +323,7 @@ struct MongoDBOnIOSTests { } } -private final class ShellDatabaseDriver: PluginBackedDriver, @unchecked Sendable { +private final class ShellDatabaseDriver: PluginRowWritingDriver, @unchecked Sendable { private let stub: StubPluginDriver init(plugin: StubPluginDriver) { diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/PluginBackedDriverTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/PluginBackedDriverTests.swift index 2451353b04..464193d330 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/PluginBackedDriverTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/PluginBackedDriverTests.swift @@ -6,11 +6,15 @@ import TableProPluginKit import Testing struct PluginBackedDriverTests { - private func makeDriver(_ plugin: StubPluginDriver, identity: RowIdentity = .everyColumn) -> PluginBackedDriver { - PluginBackedDriver( + private func makeDriver(_ plugin: StubPluginDriver) -> PluginBackedDriver { + PluginBackedDriver(plugin: plugin, dialedHosts: [], readsOnly: { $0.uppercased().hasPrefix("SELECT") }) + } + + private func makeWriter(_ plugin: StubPluginDriver) -> PluginRowWritingDriver { + PluginRowWritingDriver( plugin: plugin, dialedHosts: [], - rowIdentity: identity, + rowIdentity: .everyColumn, readsOnly: { $0.uppercased().hasPrefix("SELECT") } ) } @@ -79,7 +83,7 @@ struct PluginBackedDriverTests { let plugin = StubPluginDriver() let long = String(repeating: "x", count: 5_000) plugin.scriptedResult = result(rows: [["7", .text(long)]]) - let driver = makeDriver(plugin, identity: .keyColumns) + let driver = makeDriver(plugin) let options = StreamOptions(maxRows: 10, lazyContext: LazyContext(table: "t", primaryKeyColumns: ["id"])) let elements = try await drain(driver.executeStreaming(query: "SELECT * FROM t", options: options)) @@ -116,6 +120,17 @@ struct PluginBackedDriverTests { #expect(RowLocator.locate(cells: row.cells, columns: columns, columnDetails: columns, identity: .everyColumn) == nil) } + // The Mac writes such a plugin's rows with its own SQL; on iOS that is SQLBuilder, reached only by a non-writer. + @Test("A plugin that generates no row writes of its own leaves its driver out of the row writers") + func plainPluginIsNotARowWriter() { + let plain: any DatabaseDriver = makeDriver(StubPluginDriver()) + let writer: any DatabaseDriver = makeWriter(StubPluginDriver()) + + #expect(!(plain is any DriverRowWriting)) + #expect(writer is any DriverRowWriting) + #expect(RowLocator.identity(of: plain) == .keyColumns) + } + @Test("An engine without transactions reports nothing pending") func noTransactionsMeansIdle() async { let plugin = StubPluginDriver() @@ -135,7 +150,7 @@ struct PluginBackedDriverTests { @Test("A row edit hands the plugin the row as it was read and the changed cell, then binds its parameters") func updateHandsThePluginTheWholeRow() async throws { let plugin = StubPluginDriver() - let driver = makeDriver(plugin) + let driver = makeWriter(plugin) let writes = try driver.updateWrites( table: "events", @@ -158,26 +173,26 @@ struct PluginBackedDriverTests { @Test("A new row marks every column it leaves out as the server default") func insertLeavesOmittedColumnsToTheServer() throws { let plugin = StubPluginDriver() - let driver = makeDriver(plugin) + let driver = makeWriter(plugin) _ = try driver.insertWrites( table: "events", schema: nil, columns: ["id", "name", "note"], values: [(column: "name", value: "x")] ) #expect(plugin.generatedInserts[0] == [ - .text(PluginBackedDriver.defaultSentinel), "x", .text(PluginBackedDriver.defaultSentinel) + .text(PluginRowWritingDriver.defaultSentinel), "x", .text(PluginRowWritingDriver.defaultSentinel) ]) } @Test("A value spelled as the default marker is refused, never stored as the default") func insertRefusesTheDefaultMarker() { let plugin = StubPluginDriver() - let driver = makeDriver(plugin) + let driver = makeWriter(plugin) #expect(throws: DatabaseError.self) { try driver.insertWrites( table: "events", schema: nil, columns: ["id", "note"], - values: [(column: "id", value: "1"), (column: "note", value: PluginBackedDriver.defaultSentinel)] + values: [(column: "id", value: "1"), (column: "note", value: PluginRowWritingDriver.defaultSentinel)] ) } #expect(plugin.generatedInserts.isEmpty) @@ -188,9 +203,7 @@ struct PluginBackedDriverTests { ]) func savedQueryTimeoutIsApplied(saved: Int?, applied: [Int]) async throws { let plugin = StubPluginDriver() - let driver = PluginBackedDriver( - plugin: plugin, dialedHosts: [], queryTimeoutSeconds: saved, rowIdentity: .everyColumn, readsOnly: { _ in true } - ) + let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], queryTimeoutSeconds: saved, readsOnly: { _ in true }) try await driver.connect() diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/RowWriteRoutingTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/RowWriteRoutingTests.swift index 336f0e6d72..fc57542a69 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/RowWriteRoutingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/RowWriteRoutingTests.swift @@ -28,7 +28,7 @@ struct RowWriteRoutingTests { } private func session(_ plugin: StubPluginDriver, identity: RowIdentity = .everyColumn) -> ConnectionSession { - let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], rowIdentity: identity, readsOnly: { _ in true }) + let driver = PluginRowWritingDriver(plugin: plugin, dialedHosts: [], rowIdentity: identity, readsOnly: { _ in true }) return ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "default") } @@ -145,7 +145,7 @@ struct RowWriteRoutingTests { func changeIsPinnedToTheRowItFound() async throws { let plugin = StubPluginDriver() plugin.rowCounts = [1] - let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], rowIdentity: .everyColumn, readsOnly: { _ in true }) + let driver = PluginRowWritingDriver(plugin: plugin, dialedHosts: [], rowIdentity: .everyColumn, readsOnly: { _ in true }) let writes = try driver.updateWrites( table: "events", schema: nil, columns: ["id", "name"], originalRow: ["1", "signup"], changes: [(column: "name", value: "purchase")] @@ -354,7 +354,7 @@ struct RowWriteRoutingTests { @Test("An insert on an engine that writes its own rows goes to its writer, leaving out what the row leaves out") func insertGoesToTheWriter() throws { let plugin = StubPluginDriver() - let driver = PluginBackedDriver(plugin: plugin, dialedHosts: [], rowIdentity: .everyColumn, readsOnly: { _ in true }) + let driver = PluginRowWritingDriver(plugin: plugin, dialedHosts: [], rowIdentity: .everyColumn, readsOnly: { _ in true }) let batch = try #require(try RowInsertPlanner.batch( table: "events", @@ -370,6 +370,42 @@ struct RowWriteRoutingTests { return } #expect(writes.map(\.statement) == ["WRITE insert events"]) - #expect(plugin.generatedInserts[0] == [.text(PluginBackedDriver.defaultSentinel), "x"]) + #expect(plugin.generatedInserts[0] == [.text(PluginRowWritingDriver.defaultSentinel), "x"]) + } + + // D1's plugin generates no row writes; behind a row writer every edit failed with "cannot write this change". + @Test("A D1 edit, delete and insert become SQLite statements keyed on the primary key") + func cloudflareD1WritesAreSQL() throws { + let driver = CloudflareD1BackedDriver(d1: CloudflareD1PluginDriver(config: DriverConnectionConfig( + host: "", port: 0, username: "", password: "token", database: "app" + ))) + let details = [ + ColumnInfo(name: "id", typeName: "INTEGER", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "TEXT", ordinalPosition: 1) + ] + let locator = RowLocator(row: ["1", "signup"], keyValues: [(column: "id", value: "1")]) + + let update = try RowWriteBatch.update( + table: "events", schema: nil, type: .cloudflareD1, driver: driver, columns: ["id", "name"], + columnDetails: details, locator: locator, changes: [(column: "name", value: "it's")] + ) + let delete = try RowWriteBatch.delete( + table: "events", schema: nil, type: .cloudflareD1, driver: driver, columns: ["id", "name"], + columnDetails: details, locator: locator + ) + let insert = try RowInsertPlanner.batch( + table: "events", schema: nil, type: .cloudflareD1, driver: driver, columns: details, + rows: [PayloadRow(values: ["name": .text("x")])] + ) + + for batch in [update, delete, insert] { + guard case .sql = batch else { + Issue.record("a D1 write should be SQL, not the driver's own writes") + return + } + } + #expect(update?.statements == [#"UPDATE "events" SET "name" = 'it''s' WHERE "id" = 1"#]) + #expect(delete?.statements == [#"DELETE FROM "events" WHERE "id" = 1"#]) + #expect(insert?.statements == [#"INSERT INTO "events" ("name") VALUES ('x')"#]) } } diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift index 7d72893bff..a71ba93091 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift @@ -37,6 +37,13 @@ struct SQLBuilderDefaultValuesTests { } } + // Without it an Insert Row that leaves every column to its default built no statement on D1. + @Test("Every engine that runs SQLite takes DEFAULT VALUES", arguments: [DatabaseType.cloudflareD1, .libsql, .turso]) + func sqliteFamilyTakesDefaultValues(type: DatabaseType) { + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "\"t\"", for: type) == "INSERT INTO \"t\" DEFAULT VALUES") + #expect(SQLBuilder.supportsAllDefaultsInsert(type)) + } + @Test("Oracle has no all-defaults statement") func oracleHasNoForm() { #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "\"t\"", for: .oracle) == nil) diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSQLiteFamilyTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSQLiteFamilyTests.swift new file mode 100644 index 0000000000..152f48f181 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSQLiteFamilyTests.swift @@ -0,0 +1,60 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +struct SQLBuilderSQLiteFamilyTests { + // Measured on sqlite3 3.54: `TRUNCATE TABLE "t";` is `Parse error ... near "TRUNCATE": syntax error`. + @Test("Truncate on an engine that runs SQLite deletes every row, since SQLite has no TRUNCATE", + arguments: [DatabaseType.sqlite, .cloudflareD1]) + func sqliteFamilyTruncatesWithDelete(type: DatabaseType) { + #expect(SQLBuilder.truncateStatement(table: "events", schema: nil, type: type) == #"DELETE FROM "events""#) + } + + @Test("Truncate elsewhere keeps TRUNCATE TABLE, quoted and qualified for the engine") + func otherEnginesTruncate() { + #expect(SQLBuilder.truncateStatement(table: "events", schema: nil, type: .mysql) == "TRUNCATE TABLE `events`") + #expect(SQLBuilder.truncateStatement(table: "events", schema: "sales", type: .postgresql) + == #"TRUNCATE TABLE "sales"."events""#) + } + + @Test("A D1 search within its LIKE limit stays a LIKE") + func shortD1SearchIsLike() { + #expect(search("signup") == #"SELECT * FROM "events" WHERE (CAST("note" AS TEXT) LIKE '%signup%' ESCAPE '!') LIMIT 10 OFFSET 0"#) + } + + // D1 refuses a LIKE pattern over 50 bytes, so a pasted email or URL would fail the whole page read. + @Test("A D1 search past its LIKE limit is matched as a plain substring of every column") + func longD1SearchUsesInstr() { + let text = String(repeating: "a", count: 40) + "@example.com" + + #expect(search(text, columns: ["note", "email"]) + == #"SELECT * FROM "events" WHERE (instr(LOWER(CAST("note" AS TEXT)), LOWER('\#(text)')) > 0 OR "# + + #"instr(LOWER(CAST("email" AS TEXT)), LOWER('\#(text)')) > 0) LIMIT 10 OFFSET 0"#) + } + + @Test("A SQLite search keeps its LIKE however long, since SQLite on the device has no such limit") + func longSQLiteSearchIsLike() { + let text = String(repeating: "a", count: 80) + + #expect(search(text, type: .sqlite).contains(#"LIKE '%\#(text)%' ESCAPE '!'"#)) + } + + @Test("A D1 filter on a column with no type matches a number either way it was stored") + func d1UntypedColumnMatchesEitherStorageClass() { + let filters = [TableFilter(columnName: "v", filterOperator: .equal, value: "5")] + + #expect(SQLBuilder.buildFilteredCount( + table: "events", schema: nil, type: .cloudflareD1, filters: filters, logicMode: .and, + columns: [ColumnInfo(name: "v", typeName: "")] + ) == #"SELECT COUNT(*) FROM "events" WHERE ("v" IN (5, '5') AND CAST("v" AS TEXT) = '5')"#) + } + + private func search(_ text: String, columns: [String] = ["note"], type: DatabaseType = .cloudflareD1) -> String { + SQLBuilder.buildSearchSelect( + table: "events", schema: nil, type: type, + searchText: text, searchColumns: columns.map { ColumnInfo(name: $0, typeName: "TEXT") }, + limit: 10, offset: 0 + ) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift b/apple/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift index d0a1ac6895..63fbe1feab 100644 --- a/apple/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift @@ -1,6 +1,7 @@ import AppIntents import Foundation @testable import TableProMobile +import TableProModels import Testing @Suite("ConnectionEntity") @@ -13,4 +14,28 @@ struct ConnectionEntityTests { #expect(String(localized: subtitle) == "PostgreSQL, db.example.com") } + + @Test("A Cloudflare D1 connection's subtitle names its database, not the account id a Mac import keeps in Host") + func cloudflareD1SubtitleNamesTheDatabase() throws { + let connection = DatabaseConnection( + name: "Edge", + type: .cloudflareD1, + host: "b5ad7a2e0123", + port: 0, + database: "shop" + ) + + let subtitle = try #require(ConnectionEntity(connection: connection).displayRepresentation.subtitle) + + #expect(String(localized: subtitle) == "Cloudflare D1, shop") + } + + @Test("A file connection's subtitle names its file, as the connection list does") + func fileSubtitleNamesTheFile() throws { + let connection = DatabaseConnection(name: "Notes", type: .sqlite, host: "", port: 0, database: "/tmp/notes.db") + + let subtitle = try #require(ConnectionEntity(connection: connection).displayRepresentation.subtitle) + + #expect(String(localized: subtitle) == "SQLite, notes.db") + } } diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index 8e50943f94..fdf07c0034 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -71,6 +71,18 @@ struct MacOnlyImportRowTests { #expect(MacOnlyConnection.importStatus(for: row.settings) == nil) } + @Test("An imported Cloudflare D1 row opens here and keeps its empty host, where a server row dials localhost") + func apiRowKeepsNoHost() throws { + var settings = ExportableConnection(name: "Edge", host: "", port: 0, database: "app", username: "", type: "Cloudflare D1") + settings.additionalFields = ["cfAccountId": "0123456789abcdef"] + let row = try #require(try analyzed(settings).connections.first) + + #expect(MacOnlyConnection.importStatus(for: row.settings) == nil) + #expect(DatabaseConnection(importedSettings: row.settings).host.isEmpty) + let server = ExportableConnection(name: "Local", host: " ", port: 3_306, database: "app", username: "root", type: "MySQL") + #expect(DatabaseConnection(importedSettings: server).host == "localhost") + } + @Test("A MySQL row that dials a Mac socket reads the Mac only status") func socketRowIsMarked() throws { var settings = ExportableConnection(name: "Local", host: "localhost", port: 3_306, database: "app", username: "root", type: "MySQL") diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 958d309a76..6b74235534 100644 --- a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -14,6 +14,8 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var fetchColumnsError: Error? var scriptedTables: [TableInfo] = [] var scriptedDatabases: [String] = [] + // The database the driver says it is in, which may be named differently from the one the connection stores. + var scriptedCurrentDatabase: String? var scriptedSchemas: [String] = [] var scriptedTransactionState: DriverTransactionState = .unknown /// Answered in order, one per call, before falling back to `scriptedTransactionState`. @@ -35,6 +37,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { private(set) var didCommitTransaction = false private(set) var didRollbackTransaction = false private(set) var pingCount = 0 + private(set) var switchedDatabases: [String] = [] var supportsSchemas: Bool = false var currentSchema: String? @@ -107,7 +110,11 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { scriptedDatabases } func fetchSchemas() async throws -> [String] { scriptedSchemas } - func switchDatabase(to name: String) async throws {} + var currentDatabase: String? { scriptedCurrentDatabase } + + func switchDatabase(to name: String) async throws { + switchedDatabases.append(name) + } func switchSchema(to name: String) async throws { currentSchema = name } diff --git a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryCatalogTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryCatalogTests.swift index 9a35161814..5261ab2d90 100644 --- a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryCatalogTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryCatalogTests.swift @@ -5,14 +5,15 @@ import TableProModels import Testing struct IOSDriverFactoryCatalogTests { - @Test("Network engines get a catalog connection, file engines keep one connection") + @Test("Wire-protocol SQL servers get a catalog connection; file, HTTP and Redis engines keep one connection") func whichEnginesOpenOne() { let factory = IOSDriverFactory() let opens: [DatabaseType] = [.mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .mssql, .oracle] for type in opens { #expect(factory.opensCatalogConnections(for: DatabaseConnection(name: "c", type: type))) } - for type in [DatabaseType.sqlite, .duckdb, .redis] { + // D1 holds no session state a catalog read could disturb, and a second driver costs requests on every open. + for type in [DatabaseType.sqlite, .duckdb, .redis, .clickhouse, .cloudflareD1] { #expect(!factory.opensCatalogConnections(for: DatabaseConnection(name: "c", type: type))) } } diff --git a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift index 397b125068..14ae5fb26b 100644 --- a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift @@ -21,7 +21,7 @@ struct IOSDriverFactoryRefusalTests { "Every type with a driver on iPhone and iPad opens here", arguments: [ DatabaseType.sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle, - .clickhouse, .mongodb + .clickhouse, .mongodb, .cloudflareD1 ] ) func supportedTypeOpens(type: DatabaseType) { @@ -123,10 +123,23 @@ struct IOSDriverFactoryRefusalTests { #expect(IOSDriverFactory.refusal(for: connection) == nil) } - @Test("A file engine is never refused for an SSL mode left from another engine") + @Test("A file or API engine is never refused for an SSL mode left from another engine") func fileEngineIgnoresSSLMode() { #expect(IOSDriverFactory.refusal(for: tls(.sqlite, .verifyCa)) == nil) #expect(IOSDriverFactory.refusal(for: tls(.duckdb, .verifyFull)) == nil) + #expect(IOSDriverFactory.refusal(for: tls(.cloudflareD1, .verifyCa)) == nil) + } + + @Test("Only a server engine dials the SSH tunnel a record carries", arguments: [ + (DatabaseType.mysql, true), (.postgresql, true), (.sqlite, false), (.duckdb, false), (.cloudflareD1, false) + ]) + func onlyServerEnginesDialTheTunnel(type: DatabaseType, dials: Bool) { + let connection = DatabaseConnection( + type: type, sshEnabled: true, sshConfiguration: SSHConfiguration(host: "jump.example.com") + ) + let factory: any DriverFactory = IOSDriverFactory() + + #expect(factory.dialsSSHTunnel(for: connection) == dials) } @Test("The connect path gets the device's answer, socket included, not the protocol default") diff --git a/apple/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift b/apple/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift index 9fed43d3b4..b01f506cdb 100644 --- a/apple/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift @@ -38,7 +38,8 @@ struct SQLDialectParityTests { (.redshift, .caseFoldFunction), (.oracle, .caseFoldFunction), (.clickhouse, .caseFoldFunction), - (.mongodb, .driverManaged) + (.mongodb, .driverManaged), + (.cloudflareD1, .collationDefined) ] for (type, style) in expected { #expect( diff --git a/apple/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift b/apple/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift index fc564f2355..8c84d8ec9c 100644 --- a/apple/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift +++ b/apple/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift @@ -28,6 +28,7 @@ enum DatabaseTypeStyle { case "Redis": return .red case "MongoDB": return .green case "ClickHouse": return .yellow + case "Cloudflare D1": return .orange case "SQL Server": return .indigo case "Oracle": return .red default: return .gray diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 3070aafe4b..7855c9e846 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -73,6 +73,10 @@ targets: - path: ../Plugins/ClickHouseDriverPlugin excludes: - Info.plist + # The Cloudflare D1 plugin driver runs whole behind PluginBackedDriver; the form and SQLBuilder read its principal class. + - path: ../Plugins/CloudflareD1DriverPlugin + excludes: + - Info.plist - ../Plugins/Shared/PluginConnectTimeout.swift - ../Plugins/Shared/PluginRedirectRefusingDelegate.swift # The MongoDB plugin driver runs behind PluginBackedDriver too; the iOS CLibMongoc bridge stands in for the diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 9e1e302628..29f59888de 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -1140,4 +1140,88 @@ struct FilterSQLGeneratorTests { == "\"code\" LIKE '%x''\u{0301})%' ESCAPE '!'" ) } + + // MARK: - LIKE Pattern Byte Limit + + // Cloudflare D1's shape: SQLite's LIKE, which rejects a pattern longer than 50 bytes. + private static let cappedPatternDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .limit, + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, functionNamesAreCaseInsensitive: true, + likePatternByteLimit: 50 + ) + + private static let uncappedPatternDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .limit, + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, functionNamesAreCaseInsensitive: true + ) + + @Test("A pattern at the byte limit stays the LIKE it was", arguments: [ + (FilterOperator.contains, String(repeating: "a", count: 48)), + (.notContains, String(repeating: "a", count: 48)), + (.startsWith, String(repeating: "a", count: 49)), + (.endsWith, String(repeating: "a", count: 49)), + (.contains, String(repeating: "a", count: 46) + "%"), + (.contains, String(repeating: "é", count: 24)) + ]) + func patternAtTheLimitKeepsLike(filterOperator: FilterOperator, value: String) throws { + let capped = try #require(condition(Self.cappedPatternDialect, filterOperator, value)) + #expect(capped == condition(Self.uncappedPatternDialect, filterOperator, value)) + #expect(capped.contains(" LIKE ")) + } + + @Test("A pattern past the byte limit matches the value as a substring", arguments: [ + (FilterOperator.contains, "instr(LOWER(\"code\"), LOWER('V')) > 0"), + (.notContains, "instr(LOWER(\"code\"), LOWER('V')) = 0"), + (.startsWith, "instr(LOWER(\"code\"), LOWER('V')) = 1"), + (.endsWith, "substr(LOWER(\"code\"), -length('V')) = LOWER('V')") + ]) + func patternPastTheLimitMatchesASubstring(filterOperator: FilterOperator, expected: String) { + let value = String(repeating: "a", count: 50) + #expect( + condition(Self.cappedPatternDialect, filterOperator, value) + == expected.replacingOccurrences(of: "V", with: value) + ) + } + + @Test("The bytes counted are the pattern as sent, wildcard escapes and multi-byte letters included", arguments: [ + String(repeating: "a", count: 49), + String(repeating: "a", count: 47) + "%", + String(repeating: "é", count: 25) + ]) + func limitCountsThePatternAsSent(value: String) throws { + let capped = try #require(condition(Self.cappedPatternDialect, .contains, value)) + #expect(capped.hasPrefix("instr(")) + } + + @Test("A long value past the limit is a plain literal, with no LIKE escape") + func substringLiteralKeepsWildcardsLiteral() { + let value = String(repeating: "a", count: 45) + "50%_'s!" + let literal = "'" + String(repeating: "a", count: 45) + "50%_''s!'" + #expect( + condition(Self.cappedPatternDialect, .contains, value) + == "instr(LOWER(\"code\"), LOWER(\(literal))) > 0" + ) + } + + @Test("A regex an engine without one turns into LIKE matches a substring past the byte limit") + func regexFallbackPastTheLimitMatchesASubstring() { + let value = String(repeating: "a", count: 49) + #expect(condition(Self.cappedPatternDialect, .regex, value) == "instr(LOWER(\"code\"), LOWER('\(value)')) > 0") + #expect(condition(Self.cappedPatternDialect, .regex, "abc") == condition(Self.uncappedPatternDialect, .regex, "abc")) + } + + @Test("A dialect with no byte limit keeps LIKE for any length") + func uncappedDialectKeepsLike() { + let value = String(repeating: "a", count: 200) + #expect( + condition(Self.uncappedPatternDialect, .contains, value) + == "\"code\" LIKE '%\(value)%' ESCAPE '!'" + ) + } } diff --git a/apple/TableProTests/Core/Utilities/SQL/SQLLimitDetectorTests.swift b/apple/TableProTests/Core/Utilities/SQL/SQLLimitDetectorTests.swift index 5162f15b75..ea3d790112 100644 --- a/apple/TableProTests/Core/Utilities/SQL/SQLLimitDetectorTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/SQLLimitDetectorTests.swift @@ -10,157 +10,14 @@ import Testing @testable import TablePro +// The walk itself is tested in TableProSQLGrammarTests (SQLStatementRowLimitTests); this pins the dialect mapping. struct SQLLimitDetectorTests { - private func hasLimit( - _ sql: String, - style: AutoLimitStyle = .limit, - grammar: SQLLexicalGrammar = TestGrammar.standard - ) -> Bool { - SQLLimitDetector.hasExplicitRowLimit(sql, autoLimitStyle: style, grammar: grammar) - } - - @Test("A bare SELECT has no explicit row limit") - func bareSelect() { - #expect(!hasLimit("SELECT * FROM users")) - } - - @Test("A SELECT ending in ORDER BY has no explicit row limit") - func orderByIsNotALimit() { - #expect(!hasLimit("SELECT * FROM alert_events ORDER BY alert_time")) - #expect(!hasLimit("SELECT *\nFROM alert_events ORDER BY alert_time")) - #expect(!hasLimit("SELECT * FROM t WHERE created_at >= '2026-01-01' ORDER BY id DESC")) - #expect(!hasLimit("SELECT * FROM t ORDER BY a, b DESC")) - } - - @Test("Detects a top-level LIMIT") - func topLevelLimit() { - #expect(hasLimit("SELECT * FROM users LIMIT 10")) - #expect(hasLimit("select * from users limit 10 offset 5")) - #expect(hasLimit("SELECT * FROM t ORDER BY id DESC LIMIT 10")) - } - - @Test("Detects FETCH FIRST") - func fetchFirst() { - #expect(hasLimit("SELECT * FROM t FETCH FIRST 5 ROWS ONLY")) - } - - @Test("Detects TOP only under the top style") - func topOnlyForTopStyle() { - #expect(hasLimit("SELECT TOP 5 * FROM t", style: .top)) - #expect(!hasLimit("SELECT top FROM quotas")) - } - - @Test("A top-level OFFSET without LIMIT is not an explicit row limit") - func offsetWithoutLimit() { - #expect(!hasLimit("SELECT * FROM users OFFSET 5")) - } - - @Test("An inner LIMIT in a CTE or subquery does not bound the outer statement") - func innerLimitDoesNotCount() { - #expect(!hasLimit("WITH cte AS (SELECT * FROM t LIMIT 5) SELECT * FROM cte")) - #expect(!hasLimit("SELECT * FROM (SELECT * FROM t LIMIT 5) sub")) - #expect(!hasLimit("(SELECT a FROM t1 LIMIT 5) UNION (SELECT b FROM t2 LIMIT 5)")) - } - - @Test("Detects a LIMIT that applies to a whole UNION") - func unionLimit() { - #expect(hasLimit("SELECT a FROM t1 UNION SELECT b FROM t2 LIMIT 5")) - #expect(!hasLimit("SELECT a FROM t1 UNION ALL SELECT b FROM t2")) - } - - @Test("Ignores LIMIT-like text inside comments") - func ignoresComments() { - #expect(!hasLimit("SELECT * FROM t -- fetch it all")) - #expect(!hasLimit("SELECT * FROM t /* LIMIT 9 */")) - #expect(!hasLimit("SELECT * FROM t # see LIMIT docs", grammar: TestGrammar.mysql)) - } - - @Test("Ignores LIMIT-like text inside string literals") - func ignoresStringLiterals() { - #expect(!hasLimit("SELECT * FROM t WHERE note = 'no LIMIT here'")) - } - - @Test("Ignores LIMIT inside dollar-quoted bodies for PostgreSQL") - func ignoresDollarQuotedBodies() { - #expect(!hasLimit("SELECT $tag$LIMIT 5$tag$ FROM t", grammar: TestGrammar.postgres)) - } - - @Test("Does not mistake identifiers containing limit for a LIMIT clause") - func identifiersContainingLimit() { - #expect(!hasLimit("SELECT limit_used FROM quotas")) - #expect(!hasLimit("SELECT `limit` FROM quotas", grammar: TestGrammar.mysql)) - } - - @Test("Detects a LIMIT after a trailing semicolon is stripped") - func trailingSemicolon() { - #expect(hasLimit("SELECT * FROM t LIMIT 5;")) - #expect(!hasLimit("SELECT * FROM t;")) - } - - @Test("firstRowLimitClauseOffset points at the top-level LIMIT, OFFSET or FETCH") - func clauseOffsetFindsTopLevelClause() throws { - let sql = "SELECT * FROM orders LIMIT 100" - let offset = try #require(SQLLimitDetector.firstRowLimitClauseOffset(sql, grammar: TestGrammar.postgres)) - #expect(offset == 21) - let clause = (sql as NSString).substring(from: offset) - #expect(clause == "LIMIT 100") - } - - @Test("firstRowLimitClauseOffset finds a bare OFFSET with no LIMIT") - func clauseOffsetFindsBareOffset() { - let offset = SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM orders OFFSET 20", grammar: TestGrammar.postgres - ) - #expect(offset == 21) - } - - @Test("firstRowLimitClauseOffset finds an ANSI FETCH clause") - func clauseOffsetFindsFetch() { - let sql = "SELECT * FROM orders OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY" - let offset = SQLLimitDetector.firstRowLimitClauseOffset(sql, grammar: TestGrammar.postgres) - #expect(offset == 21) - } - - @Test("firstRowLimitClauseOffset ignores a LIMIT inside a subquery or a string") - func clauseOffsetIgnoresNested() { - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM (SELECT * FROM t LIMIT 5) s", grammar: TestGrammar.postgres - ) == nil) - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM t WHERE note = 'no LIMIT here'", grammar: TestGrammar.postgres - ) == nil) - } - - @Test("firstRowLimitClauseOffset ignores TOP, which is not a trailing clause") - func clauseOffsetIgnoresTop() { - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT TOP 10 * FROM t", grammar: TestGrammar.standard - ) == nil) - } - - @Test("firstRowLimitClauseOffset ignores OFFSET and FETCH used as column names") - func clauseOffsetIgnoresIdentifiers() { - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT offset, name FROM events", grammar: TestGrammar.mysql - ) == nil) - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT fetch FROM t", grammar: TestGrammar.mysql - ) == nil) - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT offset FROM t ORDER BY offset", grammar: TestGrammar.mysql - ) == nil) - } - - @Test("firstRowLimitClauseOffset accepts a placeholder row count") - func clauseOffsetAcceptsPlaceholders() { - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM t LIMIT ?", grammar: TestGrammar.mysql - ) != nil) - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM t LIMIT $1", grammar: TestGrammar.postgres - ) != nil) - #expect(SQLLimitDetector.firstRowLimitClauseOffset( - "SELECT * FROM t LIMIT ALL", grammar: TestGrammar.postgres - ) != nil) + @Test("TOP counts as a row limit only under the top style") + func topCountsOnlyUnderTopStyle() { + let sql = "SELECT TOP 5 * FROM t" + #expect(SQLLimitDetector.hasExplicitRowLimit(sql, autoLimitStyle: .top, grammar: TestGrammar.sqlServer)) + for style in [AutoLimitStyle.limit, .fetchFirst, .none] { + #expect(!SQLLimitDetector.hasExplicitRowLimit(sql, autoLimitStyle: style, grammar: TestGrammar.sqlServer)) + } } } diff --git a/apple/changelog.d/3998.added.md b/apple/changelog.d/3998.added.md new file mode 100644 index 0000000000..a8a4443fab --- /dev/null +++ b/apple/changelog.d/3998.added.md @@ -0,0 +1 @@ +iPhone and iPad open Cloudflare D1 connections, with the database picked from your account's list. diff --git a/apple/changelog.d/3998.fixed.1.md b/apple/changelog.d/3998.fixed.1.md new file mode 100644 index 0000000000..5e80416291 --- /dev/null +++ b/apple/changelog.d/3998.fixed.1.md @@ -0,0 +1 @@ +A connection switched to SQLite or DuckDB on iPhone and iPad still dialing the SSH tunnel it had before. diff --git a/apple/changelog.d/3998.fixed.2.md b/apple/changelog.d/3998.fixed.2.md new file mode 100644 index 0000000000..49f8e15541 --- /dev/null +++ b/apple/changelog.d/3998.fixed.2.md @@ -0,0 +1 @@ +Cloudflare D1 values sent as arrays, such as BLOBs, showing as NULL. diff --git a/apple/changelog.d/3998.fixed.3.md b/apple/changelog.d/3998.fixed.3.md new file mode 100644 index 0000000000..725ae18bf2 --- /dev/null +++ b/apple/changelog.d/3998.fixed.3.md @@ -0,0 +1 @@ +A Cloudflare D1 database past the first 1,000 in an account not found by name or missing from the switcher. diff --git a/apple/changelog.d/3998.fixed.4.md b/apple/changelog.d/3998.fixed.4.md new file mode 100644 index 0000000000..314aadcce9 --- /dev/null +++ b/apple/changelog.d/3998.fixed.4.md @@ -0,0 +1 @@ +Cloudflare D1 connection failures reading "Failed to connect" instead of the cause, such as an expired token. diff --git a/apple/changelog.d/3998.fixed.5.md b/apple/changelog.d/3998.fixed.5.md new file mode 100644 index 0000000000..75afa4141a --- /dev/null +++ b/apple/changelog.d/3998.fixed.5.md @@ -0,0 +1 @@ +Cloudflare D1 queries with no LIMIT downloading every row, which fails once the result passes 50 MB. diff --git a/apple/changelog.d/3998.fixed.6.md b/apple/changelog.d/3998.fixed.6.md new file mode 100644 index 0000000000..11420eaf5b --- /dev/null +++ b/apple/changelog.d/3998.fixed.6.md @@ -0,0 +1 @@ +Filtering a Cloudflare D1 table by a long text value failing with "LIKE or GLOB pattern too complex". diff --git a/apple/changelog.d/3998.fixed.7.md b/apple/changelog.d/3998.fixed.7.md new file mode 100644 index 0000000000..098be749dc --- /dev/null +++ b/apple/changelog.d/3998.fixed.7.md @@ -0,0 +1 @@ +Saving a binary value to Cloudflare D1 storing its base64 text instead of the bytes. diff --git a/apple/changelog.d/3998.fixed.8.md b/apple/changelog.d/3998.fixed.8.md new file mode 100644 index 0000000000..e2accc24fa --- /dev/null +++ b/apple/changelog.d/3998.fixed.8.md @@ -0,0 +1 @@ +A query on a table or column named fetch skipping the row limit. diff --git a/apple/changelog.d/3998.fixed.md b/apple/changelog.d/3998.fixed.md new file mode 100644 index 0000000000..f0ab82e223 --- /dev/null +++ b/apple/changelog.d/3998.fixed.md @@ -0,0 +1 @@ +Truncate Table on a SQLite connection on iPhone and iPad failing with a syntax error. diff --git a/apple/scripts/probes/check-cloudflare-d1-wire-shapes.sh b/apple/scripts/probes/check-cloudflare-d1-wire-shapes.sh new file mode 100755 index 0000000000..c32f6ce52f --- /dev/null +++ b/apple/scripts/probes/check-cloudflare-d1-wire-shapes.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Print what the Cloudflare D1 REST API answers for the requests the D1 driver sends, so the decoder in +# Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift is checked against the wire, not the docs: +# +# 1. /raw for one row holding a BLOB, an integer past 2^53, a real, a boolean expression, NULL and text +# 2. GET .../database/{name}, which the driver uses to resolve a database named rather than given by UUID +# 3. one page of the database list with per_page=10, to show result_info +# +# Usage: +# CF_ACCOUNT_ID=... CF_API_TOKEN=... scripts/probes/check-cloudflare-d1-wire-shapes.sh +# +# The token needs D1 Read. Nothing is written. The token is read from the environment and never printed. +# Exits 3 when curl or a variable is missing. + +set -uo pipefail + +DATABASE="${1:-}" +if [[ -z "$DATABASE" || -z "${CF_ACCOUNT_ID:-}" || -z "${CF_API_TOKEN:-}" ]]; then + sed -n '3,15p' "$0" >&2 + exit 3 +fi +command -v curl > /dev/null || { echo "curl is missing" >&2; exit 3; } + +BASE="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/d1/database" + +# The token reaches curl on stdin as a config line, so it never shows in the process list. +request() { + printf 'header = "Authorization: Bearer %s"\n' "$CF_API_TOKEN" \ + | curl --silent --show-error --config - --header "Content-Type: application/json" "$@" + echo +} + +echo "== GET database by name" +DETAILS="$(request "${BASE}/${DATABASE}")" +echo "$DETAILS" +UUID="$(printf '%s' "$DETAILS" | sed -n 's/.*"uuid":"\([0-9a-fA-F-]*\)".*/\1/p' | head -1)" +if [[ -z "$UUID" ]]; then + echo "No uuid in the answer above; the name lookup failed." >&2 + exit 1 +fi + +echo "== /raw value shapes" +request --request POST "${BASE}/${UUID}/raw" --data \ + '{"sql":"SELECT X'"'"'0001FF'"'"' AS blob_value, X'"'"''"'"' AS empty_blob, 9007199254740993 AS big_int, 2.5 AS real_value, (1 = 1) AS bool_value, NULL AS null_value, '"'"'text'"'"' AS text_value"}' + +echo "== list, first page of 10" +request "${BASE}?per_page=10&page=1"