From be468cea17cb3a9a2c886475e6f6553d22d4deb9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 03:01:39 +0700 Subject: [PATCH 1/2] feat(ios): open Cloudflare D1 connections iPhone and iPad open Cloudflare D1 with the macOS plugin compiled into the app. The form takes an Account ID and an API Token and lists the account's databases. PluginBackedDriver no longer owns row writes, so D1 writes through SQLBuilder. iOS sorts engines by connection mode: server, file or API. The D1 plugin, on both apps: - resolves a database by name or UUID with one request - pages the database list - reads BLOBs as bytes - refuses binary parameters - keeps the real connect error - bounds unlimited reads before D1's 50 MB result limit Filters and search switch from LIKE to instr past D1's 50-byte LIKE pattern limit, through a new dialect field. Also fixes on iOS: Truncate on SQLite sent TRUNCATE; a connection switched to a file type kept dialing its SSH tunnel. --- .../TableProCoreTypes/DatabaseType.swift | 9 + .../TableProDatabase/ConnectionManager.swift | 2 +- .../Protocols/DriverFactory.swift | 7 + .../TableProQuery/FilterColumnKind.swift | 3 +- .../TableProQuery/FilterSQLGenerator.swift | 19 + .../SQLStatementRowLimit.swift | 218 +++++++++ .../SQLSubstringMatch.swift | 37 ++ .../DatabaseTypeSQLiteFamilyTests.swift | 17 + .../ConnectionManagerTestSupport.swift | 3 + .../ConnectionManagerTests.swift | 23 + .../FilterSQLGeneratorLikeLimitTests.swift | 76 +++ .../FilterSQLGeneratorTests.swift | 10 + .../SQLStatementRowLimitTests.swift | 237 ++++++++++ .../SQLSubstringMatchTests.swift | 49 ++ .../CloudflareD1DefaultValue.swift | 6 +- .../CloudflareD1Plugin.swift | 11 +- .../CloudflareD1PluginDriver+Rename.swift | 2 +- .../CloudflareD1PluginDriver+Triggers.swift | 2 +- .../CloudflareD1PluginDriver.swift | 249 +++++++--- .../D1HttpClient.swift | 228 +++++++-- .../SQLDialectDescriptor.swift | 56 ++- .../Core/Database/FilterSQLGenerator.swift | 25 + ...uginMetadataRegistry+CuratedDefaults.swift | 3 +- ...ginMetadataRegistry+RegistryDefaults.swift | 31 +- ...ginMetadataRegistry+SnapshotAdoption.swift | 3 +- .../Services/Query/LeadingRowsStatement.swift | 9 +- .../Core/Utilities/SQL/SQLLimitDetector.swift | 148 +----- .../TablePro/Resources/Localizable.xcstrings | 8 + .../CompletionIdentifierQuoting.swift | 5 +- .../Completion/CompletionReservedWords.swift | 2 +- .../Coordinators/ConnectionCoordinator.swift | 11 +- .../Drivers/CloudflareD1BackedDriver.swift | 34 ++ .../Drivers/MongoDBBackedDriver.swift | 2 +- .../Drivers/PluginBackedDriver.swift | 167 +------ .../Drivers/PluginRowWritingDriver.swift | 183 ++++++++ .../TableProMobile/Helpers/AppError.swift | 26 +- .../Helpers/ConnectionDetailFormatter.swift | 9 +- .../Helpers/ConnectionFormEdits.swift | 17 + .../Helpers/ConnectionInfoContent.swift | 28 +- .../Helpers/DatabaseType+Mobile.swift | 23 +- .../TableProMobile/Helpers/SQLBuilder.swift | 28 +- .../Intents/ConnectionEntity.swift | 10 +- .../Intents/IntentDatabaseSession.swift | 3 +- .../TableProMobile/Localizable.xcstrings | 102 ++++ .../Platform/IOSDriverFactory.swift | 26 +- .../Services/IOSImportLibraryStore.swift | 9 +- .../ViewModels/CloudflareD1DatabaseList.swift | 141 ++++++ ...ConnectionFormViewModel+Certificates.swift | 2 +- .../ViewModels/ConnectionFormViewModel.swift | 64 ++- .../Views/CloudflareD1DatabasePicker.swift | 150 ++++++ .../Views/Components/DatabaseIconView.swift | 1 + .../Views/ConnectionFormView.swift | 50 +- .../Views/ConnectionInfoView.swift | 22 + .../Views/MobileConnectionImportSheet.swift | 3 +- .../TableProMobile/Views/TableListView.swift | 6 +- .../CloudflareD1DatabaseListTests.swift | 254 ++++++++++ .../CompletionIdentifierQuotingTests.swift | 8 + .../ConnectionCoordinatorCatalogTests.swift | 31 +- ...ectionFormViewModelCloudflareD1Tests.swift | 233 ++++++++++ .../ConnectionDetailFormatterTests.swift | 9 + .../ConnectionFormEditsTests.swift | 47 ++ .../ConnectionInfoContentTests.swift | 28 ++ .../Drivers/ClickHouseOnIOSTests.swift | 6 +- .../Drivers/CloudflareD1OnIOSTests.swift | 435 ++++++++++++++++++ .../Drivers/MongoDBOnIOSTests.swift | 7 +- .../Drivers/PluginBackedDriverTests.swift | 37 +- .../Helpers/RowWriteRoutingTests.swift | 44 +- .../SQLBuilderDefaultValuesTests.swift | 7 + .../Helpers/SQLBuilderSQLiteFamilyTests.swift | 60 +++ .../Intents/ConnectionEntityTests.swift | 25 + .../MacOnlyImportRowTests.swift | 12 + .../Mocks/MockDatabaseDriver.swift | 9 +- .../IOSDriverFactoryCatalogTests.swift | 5 +- .../IOSDriverFactoryRefusalTests.swift | 17 +- .../SQLDialectParityTests.swift | 3 +- .../Helpers/DatabaseTypeStyle.swift | 1 + apple/TableProMobile/project.yml | 4 + .../Database/FilterSQLGeneratorTests.swift | 84 ++++ .../Utilities/SQL/SQLLimitDetectorTests.swift | 159 +------ .../probes/check-cloudflare-d1-wire-shapes.sh | 48 ++ 80 files changed, 3513 insertions(+), 675 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementRowLimit.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLSubstringMatch.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProCoreTypesTests/DatabaseTypeSQLiteFamilyTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorLikeLimitTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementRowLimitTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLSubstringMatchTests.swift create mode 100644 apple/TableProMobile/TableProMobile/Drivers/CloudflareD1BackedDriver.swift create mode 100644 apple/TableProMobile/TableProMobile/Drivers/PluginRowWritingDriver.swift create mode 100644 apple/TableProMobile/TableProMobile/ViewModels/CloudflareD1DatabaseList.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/CloudflareD1DatabasePicker.swift create mode 100644 apple/TableProMobile/TableProMobileTests/CloudflareD1DatabaseListTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelCloudflareD1Tests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Drivers/CloudflareD1OnIOSTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSQLiteFamilyTests.swift create mode 100755 apple/scripts/probes/check-cloudflare-d1-wire-shapes.sh 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/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" From a77343c3db18b8ef993300af0279e4295061eb7a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 03:02:25 +0700 Subject: [PATCH 2/2] docs(changelog): add the fragments for #3998 --- apple/changelog.d/3998.added.md | 1 + apple/changelog.d/3998.fixed.1.md | 1 + apple/changelog.d/3998.fixed.2.md | 1 + apple/changelog.d/3998.fixed.3.md | 1 + apple/changelog.d/3998.fixed.4.md | 1 + apple/changelog.d/3998.fixed.5.md | 1 + apple/changelog.d/3998.fixed.6.md | 1 + apple/changelog.d/3998.fixed.7.md | 1 + apple/changelog.d/3998.fixed.8.md | 1 + apple/changelog.d/3998.fixed.md | 1 + 10 files changed, 10 insertions(+) create mode 100644 apple/changelog.d/3998.added.md create mode 100644 apple/changelog.d/3998.fixed.1.md create mode 100644 apple/changelog.d/3998.fixed.2.md create mode 100644 apple/changelog.d/3998.fixed.3.md create mode 100644 apple/changelog.d/3998.fixed.4.md create mode 100644 apple/changelog.d/3998.fixed.5.md create mode 100644 apple/changelog.d/3998.fixed.6.md create mode 100644 apple/changelog.d/3998.fixed.7.md create mode 100644 apple/changelog.d/3998.fixed.8.md create mode 100644 apple/changelog.d/3998.fixed.md 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.