diff --git a/CHANGELOG.md b/CHANGELOG.md index b746cfd7f..26990b1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The iOS Shortcuts documentation says which app build each action needs and where to find the full action list, so a Mac release note no longer reads as though the iPhone app already has them. - `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar. - Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab. - The PHP serialized viewer's tree filter now behaves like the JSON one. Both ignore accents, so `cafe` finds `café`. (#2204) ### Fixed +- iOS: a value containing a backslash, a newline or a carriage return is stored as you typed it on PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and Oracle. Every insert, edit and delete used MySQL's escaping rules, so `C:\Users\dat` was saved with doubled backslashes, a line break was saved as the two characters `\n`, and editing a row that already held a backslash matched nothing and silently changed no rows. MySQL and MariaDB are unaffected. +- iOS: PostgreSQL sessions now set `standard_conforming_strings` on, so a backslash in a value is always data and never an escape character. Servers that do not have the setting, Redshift among them, already behave that way and are left alone. +- iOS: Add Row to Table and Add Rows to Table write to the database or schema you picked in Shortcuts. The rows went to the connection's default schema instead, while the column names were checked against the schema you chose, so a row could land in the wrong table or fail against a table the picker had just offered. +- iOS: the three Shortcuts actions are found by searching "TablePro" in the Shortcuts action list, and are grouped under Database. Only Open Connection matched before, because the two insert actions carried no keywords. - Filtering a JSON or PHP tree now reveals nested key and value matches instead of leaving them behind collapsed parent rows. (#2204) - A tree filter that matches a key now lets you open that key and read what is inside it. (#2204) - Expanding or collapsing rows while a tree filter is active no longer springs back on the next keystroke. (#2204) diff --git a/Configs/Version-iOS.xcconfig b/Configs/Version-iOS.xcconfig index ca58aadd5..6f5cd3b36 100644 --- a/Configs/Version-iOS.xcconfig +++ b/Configs/Version-iOS.xcconfig @@ -2,4 +2,4 @@ // The app and its widget extension must ship the same values, so both read this file. MARKETING_VERSION = 1.0 -CURRENT_PROJECT_VERSION = 16 +CURRENT_PROJECT_VERSION = 17 diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift index b03bb6b32..244996062 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -29,11 +29,17 @@ public protocol DatabaseDriver: AnyObject, Sendable { func rollbackTransaction() async throws var serverVersion: String? { get } + + func escapeStringLiteral(_ value: String) -> String } public extension DatabaseDriver { var holdsSuspensionBlockingResource: Bool { false } + func escapeStringLiteral(_ value: String) -> String { + SQLEscaping.ansiStringLiteral(value) + } + func executeStreaming(query: String, options: StreamOptions = .default) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { diff --git a/Packages/TableProCore/Sources/TableProDatabase/SQLEscaping.swift b/Packages/TableProCore/Sources/TableProDatabase/SQLEscaping.swift new file mode 100644 index 000000000..4a31b07dd --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/SQLEscaping.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum SQLEscaping { + public static func ansiStringLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\0", with: "") + .replacingOccurrences(of: "'", with: "''") + } + + public static func backslashStringLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\0", with: "\\0") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\u{1a}", with: "\\Z") + .replacingOccurrences(of: "'", with: "''") + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 4af8c2c45..5123b6864 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -16,6 +16,10 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { var currentSchema: String? { nil } var supportsTransactions: Bool { true } + func escapeStringLiteral(_ value: String) -> String { + SQLEscaping.backslashStringLiteral(value) + } + // Set once during connect() before the driver is shared — safe for concurrent reads nonisolated(unsafe) private(set) var serverVersion: String? diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 73db22481..9c126965f 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -33,6 +33,7 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl) + _ = try? await actor.execute("SET standard_conforming_strings = on") serverVersion = await actor.serverVersion() } diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 6cd11e903..62df73f03 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -1,4 +1,5 @@ import Foundation +import TableProDatabase import TableProModels import TableProPluginKit import TableProQuery @@ -31,16 +32,6 @@ enum SQLBuilder { } } - static func escapeString(_ value: String) -> String { - value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\0", with: "\\0") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\u{1a}", with: "\\Z") - .replacingOccurrences(of: "'", with: "''") - } - static func buildCount(table: String, type: DatabaseType) -> String { let quoted = quoteIdentifier(table, for: type) return "SELECT COUNT(*) FROM \(quoted)" @@ -55,46 +46,56 @@ enum SQLBuilder { static func buildDelete( table: String, type: DatabaseType, + driver: any DatabaseDriver, primaryKeys: [(column: String, value: String)] ) -> String { let quotedTable = quoteIdentifier(table, for: type) - let where_ = primaryKeys.map { - "\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'" + let predicate = primaryKeys.map { + "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" }.joined(separator: " AND ") - return "DELETE FROM \(quotedTable) WHERE \(where_)" + return "DELETE FROM \(quotedTable) WHERE \(predicate)" } static func buildUpdate( table: String, type: DatabaseType, + driver: any DatabaseDriver, changes: [(column: String, value: String?)], primaryKeys: [(column: String, value: String)] ) -> String { let quotedTable = quoteIdentifier(table, for: type) - let set_ = changes.map { col, val in + let assignments = changes.map { col, val in let qcol = quoteIdentifier(col, for: type) - if let val { return "\(qcol) = '\(escapeString(val))'" } + if let val { return "\(qcol) = '\(driver.escapeStringLiteral(val))'" } return "\(qcol) = NULL" }.joined(separator: ", ") - let where_ = primaryKeys.map { - "\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'" + let predicate = primaryKeys.map { + "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" }.joined(separator: " AND ") - return "UPDATE \(quotedTable) SET \(set_) WHERE \(where_)" + return "UPDATE \(quotedTable) SET \(assignments) WHERE \(predicate)" } static func buildInsert( table: String, + schema: String?, type: DatabaseType, + driver: any DatabaseDriver, columns: [String], values: [String?] ) -> String { - let quotedTable = quoteIdentifier(table, for: type) + let qualifiedTable = qualifiedIdentifier(table: table, schema: schema, for: type) let cols = columns.map { quoteIdentifier($0, for: type) }.joined(separator: ", ") let vals = values.map { val in - if let val { return "'\(escapeString(val))'" } + if let val { return "'\(driver.escapeStringLiteral(val))'" } return "NULL" }.joined(separator: ", ") - return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))" + return "INSERT INTO \(qualifiedTable) (\(cols)) VALUES (\(vals))" + } + + static func qualifiedIdentifier(table: String, schema: String?, for type: DatabaseType) -> String { + let quotedTable = quoteIdentifier(table, for: type) + guard let schema, !schema.isEmpty else { return quotedTable } + return "\(quoteIdentifier(schema, for: type)).\(quotedTable)" } static func buildSelect( diff --git a/TableProMobile/TableProMobile/Intents/AddRowIntents.swift b/TableProMobile/TableProMobile/Intents/AddRowIntents.swift index d29007e0e..534e99c55 100644 --- a/TableProMobile/TableProMobile/Intents/AddRowIntents.swift +++ b/TableProMobile/TableProMobile/Intents/AddRowIntents.swift @@ -35,7 +35,9 @@ extension RowInsertingIntent { struct AddRowToTableIntent: RowInsertingIntent { static var title: LocalizedStringResource = "Add Row to Table" static var description = IntentDescription( - "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row." + "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row.", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "SQL", "insert", "row", "table"] ) static var openAppWhenRun = false static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication @@ -70,7 +72,9 @@ struct AddRowToTableIntent: RowInsertingIntent { struct AddRowsToTableIntent: RowInsertingIntent { static var title: LocalizedStringResource = "Add Rows to Table" static var description = IntentDescription( - "Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file." + "Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file.", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "SQL", "insert", "rows", "table", "import"] ) static var openAppWhenRun = false static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication diff --git a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift index 6be5f7b3b..da3267697 100644 --- a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift +++ b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -87,10 +87,16 @@ struct IntentDatabaseSession { table: table, type: connection.type, schema: schema, + qualifier: pickedSchema(namespace: namespace), rows: rows ) } + private func pickedSchema(namespace: String?) -> String? { + guard let namespace, !namespace.isEmpty, session.driver.supportsSchemas else { return nil } + return namespace + } + private func resolveSchema(namespace: String?) async throws -> String? { let driver = session.driver guard let namespace, !namespace.isEmpty else { diff --git a/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift b/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift index d07172e5d..7766a0e9c 100644 --- a/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift +++ b/TableProMobile/TableProMobile/Intents/OpenConnectionIntent.swift @@ -4,7 +4,11 @@ import UIKit struct OpenConnectionIntent: AppIntent { static var title: LocalizedStringResource = "Open Connection" - static var description = IntentDescription("Opens a database connection in TablePro") + static var description = IntentDescription( + "Opens a database connection in TablePro", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "connection", "SQL", "open"] + ) static var openAppWhenRun = true @Parameter(title: "Connection") diff --git a/TableProMobile/TableProMobile/Intents/RowInserter.swift b/TableProMobile/TableProMobile/Intents/RowInserter.swift index 2c6cb070c..45d658866 100644 --- a/TableProMobile/TableProMobile/Intents/RowInserter.swift +++ b/TableProMobile/TableProMobile/Intents/RowInserter.swift @@ -5,7 +5,9 @@ import TableProModels enum RowInsertPlanner { static func statements( table: String, + schema: String?, type: DatabaseType, + driver: any DatabaseDriver, columns: [ColumnInfo], rows: [PayloadRow] ) throws -> [String] { @@ -28,7 +30,9 @@ enum RowInsertPlanner { guard !insertColumns.isEmpty else { return nil } return SQLBuilder.buildInsert( table: table, + schema: schema, type: type, + driver: driver, columns: insertColumns, values: insertValues ) @@ -42,10 +46,18 @@ enum RowInserter { table: String, type: DatabaseType, schema: String?, + qualifier: String?, rows: [PayloadRow] ) async throws -> Int { let columns = try await driver.fetchColumns(table: table, schema: schema) - let statements = try RowInsertPlanner.statements(table: table, type: type, columns: columns, rows: rows) + let statements = try RowInsertPlanner.statements( + table: table, + schema: qualifier, + type: type, + driver: driver, + columns: columns, + rows: rows + ) guard !statements.isEmpty else { throw IntentDataError.noInsertableValues(table) } if driver.supportsTransactions, statements.count > 1 { diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 40326d1dc..5eb97ebda 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -321,7 +321,12 @@ final class DataBrowserViewModel { guard let session, let table, !pkValues.isEmpty else { return false } do { _ = try await session.driver.execute( - query: SQLBuilder.buildDelete(table: table.name, type: databaseType, primaryKeys: pkValues) + query: SQLBuilder.buildDelete( + table: table.name, + type: databaseType, + driver: session.driver, + primaryKeys: pkValues + ) ) await load() return true @@ -348,7 +353,7 @@ final class DataBrowserViewModel { func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? { let predicates = ref.primaryKey.map { component in - "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" + "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(driver.escapeStringLiteral(component.value))'" } let predicate = predicates.joined(separator: " AND ") let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType) diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index a4f3b2f10..3c9f2a4b8 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -184,6 +184,7 @@ final class RowDetailViewModel { let sql = SQLBuilder.buildUpdate( table: table.name, type: databaseType, + driver: session.driver, changes: changes, primaryKeys: pkValues ) diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 0b3465260..12504f85e 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -178,7 +178,7 @@ struct InsertRowView: View { private func insertRow() async { guard let session else { return } - let sql = buildInsertSQL() + let sql = buildInsertSQL(driver: session.driver) switch safeModeLevel.writePermission { case .blocked: @@ -197,7 +197,7 @@ struct InsertRowView: View { await executeInsert(sql: sql, session: session) } - private func buildInsertSQL() -> String { + private func buildInsertSQL(driver: any DatabaseDriver) -> String { var insertColumns: [String] = [] var insertValues: [String?] = [] @@ -219,7 +219,9 @@ struct InsertRowView: View { return SQLBuilder.buildInsert( table: table.name, + schema: nil, type: databaseType, + driver: driver, columns: insertColumns, values: insertValues ) diff --git a/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift b/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift index 974cc3a6a..dbc6fd5b4 100644 --- a/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift +++ b/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift @@ -11,11 +11,21 @@ struct RowInsertPlannerTests { ColumnInfo(name: "note", typeName: "text", ordinalPosition: 2) ] + private func ansiDriver() -> MockDatabaseDriver { + MockDatabaseDriver() + } + + private func backslashDriver() -> MockDatabaseDriver { + let driver = MockDatabaseDriver() + driver.usesBackslashEscaping = true + return driver + } + @Test("builds an insert for known columns in table order") func buildsInsert() throws { let row = PayloadRow(values: ["name": .text("Ada"), "note": .text("hi")]) let statements = try RowInsertPlanner.statements( - table: "people", type: .postgresql, columns: columns, rows: [row] + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] ) #expect(statements == [#"INSERT INTO "people" ("name", "note") VALUES ('Ada', 'hi')"#]) } @@ -24,7 +34,7 @@ struct RowInsertPlannerTests { func skipsEmptyPrimaryKey() throws { let row = PayloadRow(values: ["id": .text(""), "name": .text("Ada")]) let statements = try RowInsertPlanner.statements( - table: "people", type: .postgresql, columns: columns, rows: [row] + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] ) #expect(statements == [#"INSERT INTO "people" ("name") VALUES ('Ada')"#]) } @@ -33,7 +43,7 @@ struct RowInsertPlannerTests { func includesProvidedPrimaryKey() throws { let row = PayloadRow(values: ["id": .text("5"), "name": .text("Ada")]) let statements = try RowInsertPlanner.statements( - table: "people", type: .postgresql, columns: columns, rows: [row] + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] ) #expect(statements == [#"INSERT INTO "people" ("id", "name") VALUES ('5', 'Ada')"#]) } @@ -42,7 +52,7 @@ struct RowInsertPlannerTests { func nullValue() throws { let row = PayloadRow(values: ["name": .text("Ada"), "note": .null]) let statements = try RowInsertPlanner.statements( - table: "people", type: .postgresql, columns: columns, rows: [row] + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] ) #expect(statements == [#"INSERT INTO "people" ("name", "note") VALUES ('Ada', NULL)"#]) } @@ -50,16 +60,22 @@ struct RowInsertPlannerTests { @Test("rejects a column that the table does not have") func unknownColumnThrows() throws { let row = PayloadRow(values: ["name": .text("Ada"), "missing": .text("x")]) + let driver = ansiDriver() #expect(throws: IntentDataError.self) { - _ = try RowInsertPlanner.statements(table: "people", type: .postgresql, columns: columns, rows: [row]) + _ = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .postgresql, driver: driver, columns: columns, rows: [row] + ) } } @Test("throws when the table has no columns") func noColumnsThrows() throws { let row = PayloadRow(values: ["name": .text("Ada")]) + let driver = ansiDriver() #expect(throws: IntentDataError.self) { - _ = try RowInsertPlanner.statements(table: "people", type: .postgresql, columns: [], rows: [row]) + _ = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .postgresql, driver: driver, columns: [], rows: [row] + ) } } @@ -67,7 +83,7 @@ struct RowInsertPlannerTests { func emptyRowProducesNoStatement() throws { let row = PayloadRow(values: ["id": .text("")]) let statements = try RowInsertPlanner.statements( - table: "people", type: .postgresql, columns: columns, rows: [row] + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] ) #expect(statements.isEmpty) } @@ -76,8 +92,55 @@ struct RowInsertPlannerTests { func escapesQuotes() throws { let row = PayloadRow(values: ["name": .text("O'Hara")]) let statements = try RowInsertPlanner.statements( - table: "people", type: .mysql, columns: columns, rows: [row] + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), columns: columns, rows: [row] ) #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('O''Hara')"#]) } + + @Test("qualifies the table with the chosen schema so the row cannot land in the default one") + func qualifiesWithSchema() throws { + let row = PayloadRow(values: ["name": .text("Ada")]) + let statements = try RowInsertPlanner.statements( + table: "events", schema: "reporting", type: .postgresql, + driver: ansiDriver(), columns: columns, rows: [row] + ) + #expect(statements == [#"INSERT INTO "reporting"."events" ("name") VALUES ('Ada')"#]) + } + + @Test("qualifies with SQL Server bracket quoting") + func qualifiesWithSchemaOnSQLServer() throws { + let row = PayloadRow(values: ["name": .text("Ada")]) + let statements = try RowInsertPlanner.statements( + table: "events", schema: "sales", type: .mssql, + driver: ansiDriver(), columns: columns, rows: [row] + ) + #expect(statements == ["INSERT INTO [sales].[events] ([name]) VALUES ('Ada')"]) + } + + @Test("leaves a backslash alone on a database that does not treat it as an escape") + func keepsBackslashOnAnsiDialect() throws { + let row = PayloadRow(values: ["name": .text(#"C:\Users\dat"#)]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .postgresql, driver: ansiDriver(), columns: columns, rows: [row] + ) + #expect(statements == [#"INSERT INTO "people" ("name") VALUES ('C:\Users\dat')"#]) + } + + @Test("keeps a newline as a newline on a database that does not treat it as an escape") + func keepsNewlineOnAnsiDialect() throws { + let row = PayloadRow(values: ["name": .text("line1\nline2")]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .sqlite, driver: ansiDriver(), columns: columns, rows: [row] + ) + #expect(statements == ["INSERT INTO \"people\" (\"name\") VALUES ('line1\nline2')"]) + } + + @Test("still escapes backslashes on MySQL, where they are an escape character") + func escapesBackslashOnMySQL() throws { + let row = PayloadRow(values: ["name": .text(#"C:\Users\dat"#)]) + let statements = try RowInsertPlanner.statements( + table: "people", schema: nil, type: .mysql, driver: backslashDriver(), columns: columns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('C:\\Users\\dat')"#]) + } } diff --git a/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift b/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift index dd2a80915..9e4cbf10d 100644 --- a/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift +++ b/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift @@ -30,7 +30,7 @@ struct RowInserterTests { PayloadRow(values: ["name": .text("Grace")]) ] let affected = try await RowInserter.insert( - driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows ) #expect(affected == 2) #expect(driver.didBeginTransaction) @@ -47,7 +47,7 @@ struct RowInserterTests { ] await #expect(throws: (any Error).self) { _ = try await RowInserter.insert( - driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows ) } #expect(driver.didBeginTransaction) @@ -60,19 +60,42 @@ struct RowInserterTests { let driver = makeDriver(results: [ok()]) let rows = [PayloadRow(values: ["name": .text("Ada")])] let affected = try await RowInserter.insert( - driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows ) #expect(affected == 1) #expect(!driver.didBeginTransaction) } + @Test("sends the chosen schema in the statement instead of relying on the session default") + func qualifiesStatementWithSchema() async throws { + let driver = makeDriver(results: [ok()]) + driver.supportsSchemas = true + let rows = [PayloadRow(values: ["name": .text("Ada")])] + _ = try await RowInserter.insert( + driver: driver, table: "events", type: .postgresql, schema: "reporting", qualifier: "reporting", rows: rows + ) + #expect(driver.executedQueries == [#"INSERT INTO "reporting"."events" ("name") VALUES ('Ada')"#]) + } + + @Test("leaves the table unqualified when no schema was picked, so the search path still decides") + func leavesTableUnqualifiedWithoutAPickedSchema() async throws { + let driver = makeDriver(results: [ok()]) + driver.supportsSchemas = true + driver.currentSchema = "public" + let rows = [PayloadRow(values: ["name": .text("Ada")])] + _ = try await RowInserter.insert( + driver: driver, table: "events", type: .postgresql, schema: "public", qualifier: nil, rows: rows + ) + #expect(driver.executedQueries == [#"INSERT INTO "events" ("name") VALUES ('Ada')"#]) + } + @Test("throws when no row produces a value to insert") func noInsertableValuesThrows() async throws { let driver = makeDriver(results: []) let rows = [PayloadRow(values: ["id": .text("")])] await #expect(throws: IntentDataError.self) { _ = try await RowInserter.insert( - driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows ) } #expect(driver.executedQueries.isEmpty) diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index ec2d32e06..820c71f00 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -24,6 +24,13 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool = true var serverVersion: String? = "Mock 1.0" var holdsSuspensionBlockingResource: Bool = false + var usesBackslashEscaping: Bool = false + + func escapeStringLiteral(_ value: String) -> String { + usesBackslashEscaping + ? SQLEscaping.backslashStringLiteral(value) + : SQLEscaping.ansiStringLiteral(value) + } var beforeDisconnect: (@Sendable () async -> Void)? diff --git a/docs/external-api/ios-shortcuts.mdx b/docs/external-api/ios-shortcuts.mdx index 2ad6aced3..fb8367870 100644 --- a/docs/external-api/ios-shortcuts.mdx +++ b/docs/external-api/ios-shortcuts.mdx @@ -7,6 +7,20 @@ TablePro for iOS exposes App Intents, so you can add data to a database table fr This is iOS only. On macOS, use the [URL scheme](/external-api/url-scheme) and [MCP](/external-api/mcp-tools). +## Availability + +The iOS app updates separately from the Mac app, so a Mac release note about these actions does not mean your iPhone or iPad already has them. + +| Action | Needs | +| --- | --- | +| **Open Connection** | Any recent build. | +| **Add Row to Table** | iOS app build 17 or later. | +| **Add Rows to Table** | iOS app build 17 or later. | + +Check your build under **Settings > About** in the iOS app. If you are on an older build, update through TestFlight; the two insert actions will not appear in Shortcuts until you do. + +To see every action the app offers, open Shortcuts, tap the action list, and go to **Apps > TablePro**. Searching the app name works too, but the app list is the complete view. + ## Actions | Action | Use it for | @@ -17,7 +31,7 @@ This is iOS only. On macOS, use the [URL scheme](/external-api/url-scheme) and [ Open Connection launches the app on the chosen connection. The two insert actions run in the background and share the same pickers: -- **Connection**: the picker lists every saved connection. Inserts work on MySQL, MariaDB, PostgreSQL, Redshift, SQL Server, SQLite, and DuckDB. Pick any other type, Redis for example, and the action fails with an unsupported database type error. +- **Connection**: the picker lists every saved connection. Inserts work on MySQL, MariaDB, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB, and Oracle. Pick any other type, Redis for example, and the action fails with an unsupported database type error. - **Database or Schema**: optional. Leave it empty to use the connection's configured database. On schema databases like PostgreSQL it lists schemas; on others it lists databases. - **Table**: the table to insert into. The list is read from the chosen connection.