diff --git a/CHANGELOG.md b/CHANGELOG.md index b8acffcd9..61fa3eba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- iOS: add data to a table from the Shortcuts app. Two new shortcuts, Add Row to Table and Add Rows to Table, pick a saved connection, database or schema, and table, then insert from JSON or CSV. They run without opening the app. (#1788) + ### Fixed - Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796) diff --git a/TableProMobile/TableProMobile/Intents/AddRowIntents.swift b/TableProMobile/TableProMobile/Intents/AddRowIntents.swift new file mode 100644 index 000000000..d29007e0e --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/AddRowIntents.swift @@ -0,0 +1,107 @@ +import AppIntents +import Foundation +import TableProModels +import UniformTypeIdentifiers + +protocol RowInsertingIntent: AppIntent { + var connection: ConnectionEntity { get } + var database: DatabaseEntity? { get } + var table: TableEntity { get } +} + +extension RowInsertingIntent { + func insert(rows: [PayloadRow]) async throws -> Int { + guard let savedConnection = IntentConnectionLoader.connection(id: connection.id) else { + throw IntentDataError.connectionNotFound + } + switch savedConnection.safeModeLevel.writePermission { + case .blocked: + throw IntentDataError.readOnly(savedConnection.name.isEmpty ? savedConnection.host : savedConnection.name) + case .requiresConfirmation: + let noun = rows.count == 1 ? "row" : "rows" + try await requestConfirmation( + actionName: .add, + dialog: "Add \(rows.count) \(noun) to \(table.name)?" + ) + case .proceed: + break + } + return try await IntentDatabaseSession.with(connection: savedConnection) { session in + try await session.insertRows(namespace: database?.id, table: table.name, rows: rows) + } + } +} + +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." + ) + static var openAppWhenRun = false + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @Parameter(title: "Database or Schema") + var database: DatabaseEntity? + + @Parameter(title: "Table") + var table: TableEntity + + @Parameter(title: "Row (JSON or CSV)") + var data: String + + static var parameterSummary: some ParameterSummary { + Summary("Add a row to \(\.$table)") { + \.$connection + \.$database + \.$data + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let rows = try await RowPayload.parseSingle(data: data, file: nil) + let count = try await insert(rows: rows) + return .result(value: count, dialog: "Added \(count) row to \(table.name).") + } +} + +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." + ) + static var openAppWhenRun = false + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @Parameter(title: "Database or Schema") + var database: DatabaseEntity? + + @Parameter(title: "Table") + var table: TableEntity + + @Parameter(title: "Rows (JSON or CSV)") + var data: String? + + @Parameter(title: "File", supportedContentTypes: [.commaSeparatedText, .json, .plainText, .data]) + var file: IntentFile? + + static var parameterSummary: some ParameterSummary { + Summary("Add rows to \(\.$table)") { + \.$connection + \.$database + \.$data + \.$file + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let rows = try await RowPayload.parse(data: data, file: file) + let count = try await insert(rows: rows) + return .result(value: count, dialog: "Added \(count) rows to \(table.name).") + } +} diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift index e14d43c84..b4b7c7526 100644 --- a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift @@ -1,5 +1,6 @@ import AppIntents import Foundation +import TableProModels struct ConnectionEntity: AppEntity { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Connection") @@ -16,4 +17,20 @@ struct ConnectionEntity: AppEntity { subtitle: "\(databaseType) ยท \(host)" ) } + + init(id: UUID, name: String, host: String, databaseType: String) { + self.id = id + self.name = name + self.host = host + self.databaseType = databaseType + } + + init(connection: DatabaseConnection) { + self.init( + id: connection.id, + name: connection.name.isEmpty ? connection.host : connection.name, + host: connection.host, + databaseType: connection.type.rawValue + ) + } } diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift index d40284b49..759f48745 100644 --- a/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift @@ -1,46 +1,15 @@ import AppIntents import Foundation +import TableProModels struct ConnectionEntityQuery: EntityQuery { func entities(for identifiers: [UUID]) async throws -> [ConnectionEntity] { - let all = loadConnections() - return all.filter { identifiers.contains($0.id) } + IntentConnectionLoader.load() + .filter { identifiers.contains($0.id) } + .map(ConnectionEntity.init(connection:)) } func suggestedEntities() async throws -> [ConnectionEntity] { - loadConnections() - } - - private func loadConnections() -> [ConnectionEntity] { - guard let dir = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first else { - return [] - } - let fileURL = dir - .appendingPathComponent("TableProMobile", isDirectory: true) - .appendingPathComponent("connections.json") - guard let data = try? Data(contentsOf: fileURL) else { return [] } - - struct StoredConnection: Decodable { - let id: UUID - let name: String - let host: String - let type: String - } - - guard let connections = try? JSONDecoder().decode([StoredConnection].self, from: data) else { - return [] - } - - return connections.map { conn in - ConnectionEntity( - id: conn.id, - name: conn.name.isEmpty ? conn.host : conn.name, - host: conn.host, - databaseType: conn.type - ) - } + IntentConnectionLoader.load().map(ConnectionEntity.init(connection:)) } } diff --git a/TableProMobile/TableProMobile/Intents/DatabaseEntity.swift b/TableProMobile/TableProMobile/Intents/DatabaseEntity.swift new file mode 100644 index 000000000..712fa98df --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/DatabaseEntity.swift @@ -0,0 +1,44 @@ +import AppIntents +import Foundation + +struct DatabaseEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Database or Schema") + static var defaultQuery = DatabaseEntityQuery() + + var id: String + var name: String + var kind: Kind + + enum Kind: String, Sendable { + case database + case schema + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct DatabaseEntityQuery: EntityQuery { + @IntentParameterDependency(\.$connection) + var addRow + + @IntentParameterDependency(\.$connection) + var addRows + + func entities(for identifiers: [String]) async throws -> [DatabaseEntity] { + identifiers.map { DatabaseEntity(id: $0, name: $0, kind: .database) } + } + + func suggestedEntities() async throws -> [DatabaseEntity] { + guard let connection = selectedConnection else { return [] } + let namespaces = try? await IntentDatabaseSession.with(connectionId: connection.id) { + try await $0.namespaces() + } + return namespaces ?? [] + } + + private var selectedConnection: ConnectionEntity? { + addRow?.connection ?? addRows?.connection + } +} diff --git a/TableProMobile/TableProMobile/Intents/IntentConnectionLoader.swift b/TableProMobile/TableProMobile/Intents/IntentConnectionLoader.swift new file mode 100644 index 000000000..2f046ef2d --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/IntentConnectionLoader.swift @@ -0,0 +1,41 @@ +import Foundation +import TableProModels + +enum IntentConnectionLoader { + static func load() -> [DatabaseConnection] { + guard let fileURL else { return [] } + guard let data = try? Data(contentsOf: fileURL) else { return [] } + return decode(data) + } + + static func connection(id: UUID) -> DatabaseConnection? { + load().first { $0.id == id } + } + + static func decode(_ data: Data) -> [DatabaseConnection] { + guard let elements = try? JSONDecoder().decode([FailableConnection].self, from: data) else { + return [] + } + return elements.compactMap(\.value) + } + + private struct FailableConnection: Decodable { + let value: DatabaseConnection? + + init(from decoder: Decoder) throws { + value = try? DatabaseConnection(from: decoder) + } + } + + private static var fileURL: URL? { + guard let directory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return nil + } + return directory + .appendingPathComponent("TableProMobile", isDirectory: true) + .appendingPathComponent("connections.json") + } +} diff --git a/TableProMobile/TableProMobile/Intents/IntentDataError.swift b/TableProMobile/TableProMobile/Intents/IntentDataError.swift new file mode 100644 index 000000000..99d53725a --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/IntentDataError.swift @@ -0,0 +1,43 @@ +import AppIntents +import Foundation + +enum IntentDataError: Error, CustomLocalizedStringResourceConvertible { + case connectionNotFound + case unsupportedDatabaseType(String) + case connectionFailed(String) + case readOnly(String) + case noColumns(String) + case noInsertableValues(String) + case unknownColumns([String], String) + case emptyPayload + case expectedSingleRow + case malformedPayload(String) + case tooManyRows(Int) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .connectionNotFound: + return "That connection no longer exists in TablePro." + case .unsupportedDatabaseType(let type): + return "\(type) connections do not support adding rows from Shortcuts." + case .connectionFailed(let message): + return "Could not connect to the database: \(message)" + case .readOnly(let name): + return "\(name) is read-only, so rows cannot be added." + case .noColumns(let table): + return "Could not read the columns of \(table)." + case .noInsertableValues(let table): + return "The data has no values to insert into \(table)." + case .unknownColumns(let columns, let table): + return "\(table) has no column named \(columns.joined(separator: ", "))." + case .emptyPayload: + return "No data was provided to add." + case .expectedSingleRow: + return "Add Row to Table expects one row. Use Add Rows to Table for multiple rows." + case .malformedPayload(let message): + return "The data could not be read: \(message)" + case .tooManyRows(let limit): + return "Too many rows. Add up to \(limit) rows at a time." + } + } +} diff --git a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift new file mode 100644 index 000000000..08ca11ca4 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -0,0 +1,105 @@ +import Foundation +import TableProDatabase +import TableProModels + +struct IntentDatabaseSession { + let connection: DatabaseConnection + let session: ConnectionSession + private let manager: ConnectionManager + + static func supportsTabularInsert(_ type: DatabaseType) -> Bool { + switch type { + case .mysql, .mariadb, .postgresql, .redshift, .mssql, .sqlite, .duckdb: + return true + default: + return false + } + } + + static func open(connection: DatabaseConnection) async throws -> IntentDatabaseSession { + guard supportsTabularInsert(connection.type) else { + throw IntentDataError.unsupportedDatabaseType(connection.type.rawValue) + } + let secureStore = KeychainSecureStore() + let sshProvider = IOSSSHProvider(secureStore: secureStore) + let manager = ConnectionManager( + driverFactory: IOSDriverFactory(), + secureStore: secureStore, + sshProvider: sshProvider + ) + if connection.sshEnabled { + await sshProvider.setPendingConnectionId(connection.id) + } + do { + let session = try await manager.connect(connection) + return IntentDatabaseSession(connection: connection, session: session, manager: manager) + } catch { + throw IntentDataError.connectionFailed(error.localizedDescription) + } + } + + static func with( + connectionId: UUID, + _ body: (IntentDatabaseSession) async throws -> T + ) async throws -> T { + guard let connection = IntentConnectionLoader.connection(id: connectionId) else { + throw IntentDataError.connectionNotFound + } + return try await with(connection: connection, body) + } + + static func with( + connection: DatabaseConnection, + _ body: (IntentDatabaseSession) async throws -> T + ) async throws -> T { + let session = try await open(connection: connection) + do { + let result = try await body(session) + await session.close() + return result + } catch { + await session.close() + throw error + } + } + + func close() async { + await manager.disconnect(connection.id) + } + + func namespaces() async throws -> [DatabaseEntity] { + let driver = session.driver + if driver.supportsSchemas { + return try await driver.fetchSchemas().map { DatabaseEntity(id: $0, name: $0, kind: .schema) } + } + return try await driver.fetchDatabases().map { DatabaseEntity(id: $0, name: $0, kind: .database) } + } + + func tables(namespace: String?) async throws -> [TableEntity] { + let schema = try await resolveSchema(namespace: namespace) + return try await session.driver.fetchTables(schema: schema).map { TableEntity(id: $0.name, name: $0.name) } + } + + func insertRows(namespace: String?, table: String, rows: [PayloadRow]) async throws -> Int { + let schema = try await resolveSchema(namespace: namespace) + return try await RowInserter.insert( + driver: session.driver, + table: table, + type: connection.type, + schema: schema, + rows: rows + ) + } + + private func resolveSchema(namespace: String?) async throws -> String? { + let driver = session.driver + guard let namespace, !namespace.isEmpty else { + return driver.supportsSchemas ? driver.currentSchema : nil + } + if driver.supportsSchemas { + return namespace + } + try await driver.switchDatabase(to: namespace) + return nil + } +} diff --git a/TableProMobile/TableProMobile/Intents/PayloadRow.swift b/TableProMobile/TableProMobile/Intents/PayloadRow.swift new file mode 100644 index 000000000..3d96b1278 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/PayloadRow.swift @@ -0,0 +1,36 @@ +import Foundation + +enum PayloadValue: Equatable { + case null + case text(String) + + var isEmptyOrNull: Bool { + switch self { + case .null: + return true + case .text(let value): + return value.isEmpty + } + } + + var sqlValue: String? { + switch self { + case .null: + return nil + case .text(let value): + return value + } + } +} + +struct PayloadRow: Equatable { + let values: [String: PayloadValue] + + var keys: [String] { + Array(values.keys) + } + + func value(for column: String) -> PayloadValue? { + values[column] + } +} diff --git a/TableProMobile/TableProMobile/Intents/RowInserter.swift b/TableProMobile/TableProMobile/Intents/RowInserter.swift new file mode 100644 index 000000000..2c6cb070c --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/RowInserter.swift @@ -0,0 +1,77 @@ +import Foundation +import TableProDatabase +import TableProModels + +enum RowInsertPlanner { + static func statements( + table: String, + type: DatabaseType, + columns: [ColumnInfo], + rows: [PayloadRow] + ) throws -> [String] { + guard !columns.isEmpty else { throw IntentDataError.noColumns(table) } + let columnNames = Set(columns.map(\.name)) + let primaryKeys = Set(columns.filter(\.isPrimaryKey).map(\.name)) + + return try rows.compactMap { row in + let unknown = row.keys.filter { !columnNames.contains($0) } + guard unknown.isEmpty else { throw IntentDataError.unknownColumns(unknown.sorted(), table) } + + var insertColumns: [String] = [] + var insertValues: [String?] = [] + for column in columns { + guard let value = row.value(for: column.name) else { continue } + if primaryKeys.contains(column.name), value.isEmptyOrNull { continue } + insertColumns.append(column.name) + insertValues.append(value.sqlValue) + } + guard !insertColumns.isEmpty else { return nil } + return SQLBuilder.buildInsert( + table: table, + type: type, + columns: insertColumns, + values: insertValues + ) + } + } +} + +enum RowInserter { + static func insert( + driver: any DatabaseDriver, + table: String, + type: DatabaseType, + schema: 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) + guard !statements.isEmpty else { throw IntentDataError.noInsertableValues(table) } + + if driver.supportsTransactions, statements.count > 1 { + return try await executeInTransaction(driver: driver, statements: statements) + } + return try await executeAll(driver: driver, statements: statements) + } + + private static func executeInTransaction(driver: any DatabaseDriver, statements: [String]) async throws -> Int { + try await driver.beginTransaction() + do { + let affected = try await executeAll(driver: driver, statements: statements) + try await driver.commitTransaction() + return affected + } catch { + try? await driver.rollbackTransaction() + throw error + } + } + + private static func executeAll(driver: any DatabaseDriver, statements: [String]) async throws -> Int { + var affected = 0 + for statement in statements { + let result = try await driver.execute(query: statement) + affected += max(result.rowsAffected, 0) + } + return affected + } +} diff --git a/TableProMobile/TableProMobile/Intents/RowPayload.swift b/TableProMobile/TableProMobile/Intents/RowPayload.swift new file mode 100644 index 000000000..af17377a3 --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/RowPayload.swift @@ -0,0 +1,162 @@ +import AppIntents +import Foundation +import UniformTypeIdentifiers + +enum RowPayload { + static let maxRows = 10_000 + + static func parse(data: String?, file: IntentFile?) async throws -> [PayloadRow] { + let raw = try await rawContent(data: data, file: file) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw IntentDataError.emptyPayload } + + let rows = trimmed.hasPrefix("{") || trimmed.hasPrefix("[") + ? try parseJSON(trimmed) + : try parseCSV(raw) + + guard !rows.isEmpty else { throw IntentDataError.emptyPayload } + guard rows.count <= maxRows else { throw IntentDataError.tooManyRows(maxRows) } + return rows + } + + static func parseSingle(data: String?, file: IntentFile?) async throws -> [PayloadRow] { + let rows = try await parse(data: data, file: file) + guard rows.count == 1 else { throw IntentDataError.expectedSingleRow } + return rows + } + + private static func rawContent(data: String?, file: IntentFile?) async throws -> String { + if let file { + let fileData = try await file.data(contentType: .data) + guard let text = String(data: fileData, encoding: .utf8) else { + throw IntentDataError.malformedPayload("the file is not UTF-8 text") + } + return text + } + if let data, !data.isEmpty { + return data + } + throw IntentDataError.emptyPayload + } + + static func parseJSON(_ text: String) throws -> [PayloadRow] { + guard let data = text.data(using: .utf8) else { + throw IntentDataError.malformedPayload("invalid text encoding") + } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + throw IntentDataError.malformedPayload(error.localizedDescription) + } + if let dictionary = object as? [String: Any] { + return [row(from: dictionary)] + } + if let array = object as? [Any] { + return try array.map { element in + guard let dictionary = element as? [String: Any] else { + throw IntentDataError.malformedPayload("expected a list of objects") + } + return row(from: dictionary) + } + } + throw IntentDataError.malformedPayload("expected a JSON object or a list of objects") + } + + static func parseCSV(_ text: String) throws -> [PayloadRow] { + let records = CSVRecordParser.parse(text) + guard let header = records.first, !header.allSatisfy(\.isEmpty) else { + throw IntentDataError.malformedPayload("the CSV has no header row") + } + return records.dropFirst().compactMap { record in + guard !(record.count == 1 && record[0].isEmpty) else { return nil } + var values: [String: PayloadValue] = [:] + for (index, column) in header.enumerated() where !column.isEmpty { + let field = index < record.count ? record[index] : "" + values[column] = .text(field) + } + return PayloadRow(values: values) + } + } + + private static func row(from dictionary: [String: Any]) -> PayloadRow { + var values: [String: PayloadValue] = [:] + for (key, value) in dictionary { + values[key] = payloadValue(from: value) + } + return PayloadRow(values: values) + } + + private static func payloadValue(from value: Any) -> PayloadValue { + switch value { + case is NSNull: + return .null + case let string as String: + return .text(string) + case let number as NSNumber: + return .text(numberString(number)) + default: + if let data = try? JSONSerialization.data(withJSONObject: value, options: []), + let string = String(data: data, encoding: .utf8) { + return .text(string) + } + return .text(String(describing: value)) + } + } + + private static func numberString(_ number: NSNumber) -> String { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + } +} + +enum CSVRecordParser { + static func parse(_ text: String) -> [[String]] { + var records: [[String]] = [] + var record: [String] = [] + var field = "" + var inQuotes = false + let characters = Array(text) + var index = 0 + + while index < characters.count { + let character = characters[index] + if inQuotes { + if character == "\"" { + if index + 1 < characters.count, characters[index + 1] == "\"" { + field.append("\"") + index += 1 + } else { + inQuotes = false + } + } else { + field.append(character) + } + } else { + switch character { + case "\"": + inQuotes = true + case ",": + record.append(field) + field = "" + case "\n": + record.append(field) + field = "" + records.append(record) + record = [] + case "\r": + break + default: + field.append(character) + } + } + index += 1 + } + + record.append(field) + records.append(record) + return records + } +} diff --git a/TableProMobile/TableProMobile/Intents/TableEntity.swift b/TableProMobile/TableProMobile/Intents/TableEntity.swift new file mode 100644 index 000000000..6a764f58a --- /dev/null +++ b/TableProMobile/TableProMobile/Intents/TableEntity.swift @@ -0,0 +1,44 @@ +import AppIntents +import Foundation + +struct TableEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Table") + static var defaultQuery = TableEntityQuery() + + var id: String + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct TableEntityQuery: EntityQuery { + @IntentParameterDependency(\.$connection, \.$database) + var addRow + + @IntentParameterDependency(\.$connection, \.$database) + var addRows + + func entities(for identifiers: [String]) async throws -> [TableEntity] { + identifiers.map { TableEntity(id: $0, name: $0) } + } + + func suggestedEntities() async throws -> [TableEntity] { + guard let context = selectedContext else { return [] } + let tables = try? await IntentDatabaseSession.with(connectionId: context.connection.id) { + try await $0.tables(namespace: context.database?.id) + } + return tables ?? [] + } + + private var selectedContext: (connection: ConnectionEntity, database: DatabaseEntity?)? { + if let addRow { + return (addRow.connection, addRow.database) + } + if let addRows { + return (addRows.connection, addRows.database) + } + return nil + } +} diff --git a/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift b/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift index 6873cef97..cc90862c0 100644 --- a/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift +++ b/TableProMobile/TableProMobile/Intents/TableProShortcuts.swift @@ -11,5 +11,21 @@ struct TableProShortcuts: AppShortcutsProvider { shortTitle: "Open Connection", systemImageName: "server.rack" ) + AppShortcut( + intent: AddRowToTableIntent(), + phrases: [ + "Add a row in \(.applicationName)" + ], + shortTitle: "Add Row to Table", + systemImageName: "plus.rectangle.on.folder" + ) + AppShortcut( + intent: AddRowsToTableIntent(), + phrases: [ + "Add rows in \(.applicationName)" + ], + shortTitle: "Add Rows to Table", + systemImageName: "rectangle.stack.badge.plus" + ) } } diff --git a/TableProMobile/TableProMobileTests/Intents/IntentConnectionLoaderTests.swift b/TableProMobile/TableProMobileTests/Intents/IntentConnectionLoaderTests.swift new file mode 100644 index 000000000..79cb1d505 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Intents/IntentConnectionLoaderTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +import TableProModels +@testable import TableProMobile + +@Suite("IntentConnectionLoader") +struct IntentConnectionLoaderTests { + @Test("decodes the full connection model that the app persists") + func decodesFullModel() throws { + let connection = DatabaseConnection( + id: UUID(), + name: "Prod", + type: .postgresql, + host: "db.example.com", + port: 5432, + username: "alice", + database: "appdb", + sshEnabled: true, + sslEnabled: true + ) + let data = try JSONEncoder().encode([connection]) + + let decoded = IntentConnectionLoader.decode(data) + + #expect(decoded.count == 1) + #expect(decoded[0].id == connection.id) + #expect(decoded[0].type == .postgresql) + #expect(decoded[0].host == "db.example.com") + #expect(decoded[0].database == "appdb") + #expect(decoded[0].sshEnabled) + } + + @Test("returns an empty list for invalid data") + func invalidDataReturnsEmpty() { + let decoded = IntentConnectionLoader.decode(Data("not json".utf8)) + #expect(decoded.isEmpty) + } + + @Test("skips a connection that does not fully decode and keeps the valid ones") + func skipsUndecodableConnection() throws { + let valid = DatabaseConnection( + id: UUID(), name: "Prod", type: .mysql, + host: "h", port: 3306, username: "u", database: "d" + ) + let validObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(valid)) + let mixed: [Any] = [["unexpected": "shape"], validObject] + let data = try JSONSerialization.data(withJSONObject: mixed) + + let decoded = IntentConnectionLoader.decode(data) + + #expect(decoded.count == 1) + #expect(decoded[0].id == valid.id) + } +} diff --git a/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift b/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift new file mode 100644 index 000000000..974cc3a6a --- /dev/null +++ b/TableProMobile/TableProMobileTests/Intents/RowInsertPlannerTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +import TableProModels +@testable import TableProMobile + +@Suite("RowInsertPlanner") +struct RowInsertPlannerTests { + private let columns = [ + ColumnInfo(name: "id", typeName: "integer", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "text", ordinalPosition: 1), + ColumnInfo(name: "note", typeName: "text", ordinalPosition: 2) + ] + + @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] + ) + #expect(statements == [#"INSERT INTO "people" ("name", "note") VALUES ('Ada', 'hi')"#]) + } + + @Test("skips an empty primary key so the database can auto-generate it") + 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] + ) + #expect(statements == [#"INSERT INTO "people" ("name") VALUES ('Ada')"#]) + } + + @Test("includes a provided primary key value") + 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] + ) + #expect(statements == [#"INSERT INTO "people" ("id", "name") VALUES ('5', 'Ada')"#]) + } + + @Test("writes NULL for null values") + 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] + ) + #expect(statements == [#"INSERT INTO "people" ("name", "note") VALUES ('Ada', NULL)"#]) + } + + @Test("rejects a column that the table does not have") + func unknownColumnThrows() throws { + let row = PayloadRow(values: ["name": .text("Ada"), "missing": .text("x")]) + #expect(throws: IntentDataError.self) { + _ = try RowInsertPlanner.statements(table: "people", type: .postgresql, columns: columns, rows: [row]) + } + } + + @Test("throws when the table has no columns") + func noColumnsThrows() throws { + let row = PayloadRow(values: ["name": .text("Ada")]) + #expect(throws: IntentDataError.self) { + _ = try RowInsertPlanner.statements(table: "people", type: .postgresql, columns: [], rows: [row]) + } + } + + @Test("produces no statement for a row that only sets an empty primary key") + func emptyRowProducesNoStatement() throws { + let row = PayloadRow(values: ["id": .text("")]) + let statements = try RowInsertPlanner.statements( + table: "people", type: .postgresql, columns: columns, rows: [row] + ) + #expect(statements.isEmpty) + } + + @Test("escapes single quotes in values") + func escapesQuotes() throws { + let row = PayloadRow(values: ["name": .text("O'Hara")]) + let statements = try RowInsertPlanner.statements( + table: "people", type: .mysql, columns: columns, rows: [row] + ) + #expect(statements == [#"INSERT INTO `people` (`name`) VALUES ('O''Hara')"#]) + } +} diff --git a/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift b/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift new file mode 100644 index 000000000..dd2a80915 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Intents/RowInserterTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +import TableProDatabase +import TableProModels +@testable import TableProMobile + +@Suite("RowInserter") +struct RowInserterTests { + private let columns = [ + ColumnInfo(name: "id", typeName: "integer", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "text", ordinalPosition: 1) + ] + + private func makeDriver(results: [Result]) -> MockDatabaseDriver { + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = results + return driver + } + + private func ok(_ rows: Int = 1) -> Result { + .success(QueryResult(columns: [], rows: [], rowsAffected: rows, executionTime: 0)) + } + + @Test("wraps a multi-row insert in a transaction and commits") + func multiRowCommits() async throws { + let driver = makeDriver(results: [ok(), ok()]) + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + let affected = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + ) + #expect(affected == 2) + #expect(driver.didBeginTransaction) + #expect(driver.didCommitTransaction) + #expect(!driver.didRollbackTransaction) + } + + @Test("rolls back when a row fails mid-batch") + func midBatchRollsBack() async throws { + let driver = makeDriver(results: [ok(), .failure(MockDatabaseDriver.MockError.scripted)]) + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + await #expect(throws: (any Error).self) { + _ = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, rows: rows + ) + } + #expect(driver.didBeginTransaction) + #expect(driver.didRollbackTransaction) + #expect(!driver.didCommitTransaction) + } + + @Test("does not open a transaction for a single row") + func singleRowNoTransaction() async throws { + 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 + ) + #expect(affected == 1) + #expect(!driver.didBeginTransaction) + } + + @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 + ) + } + #expect(driver.executedQueries.isEmpty) + } +} diff --git a/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift b/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift new file mode 100644 index 000000000..7975b6a3a --- /dev/null +++ b/TableProMobile/TableProMobileTests/Intents/RowPayloadTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import TableProMobile + +@Suite("RowPayload") +struct RowPayloadTests { + @Test("parses a JSON object into one row") + func jsonObject() async throws { + let rows = try await RowPayload.parse(data: #"{"name":"Ada","age":36}"#, file: nil) + #expect(rows.count == 1) + #expect(rows[0].value(for: "name") == .text("Ada")) + #expect(rows[0].value(for: "age") == .text("36")) + } + + @Test("maps JSON null to a null value") + func jsonNull() async throws { + let rows = try await RowPayload.parse(data: #"{"note":null}"#, file: nil) + #expect(rows[0].value(for: "note") == .null) + } + + @Test("maps JSON booleans to text") + func jsonBool() async throws { + let rows = try await RowPayload.parse(data: #"{"active":true,"deleted":false}"#, file: nil) + #expect(rows[0].value(for: "active") == .text("true")) + #expect(rows[0].value(for: "deleted") == .text("false")) + } + + @Test("parses a JSON array into multiple rows") + func jsonArray() async throws { + let rows = try await RowPayload.parse(data: #"[{"a":"1"},{"a":"2"}]"#, file: nil) + #expect(rows.count == 2) + #expect(rows[0].value(for: "a") == .text("1")) + #expect(rows[1].value(for: "a") == .text("2")) + } + + @Test("parses CSV with a header row") + func csv() async throws { + let rows = try await RowPayload.parse(data: "name,age\nAda,36\nGrace,40", file: nil) + #expect(rows.count == 2) + #expect(rows[0].value(for: "name") == .text("Ada")) + #expect(rows[1].value(for: "age") == .text("40")) + } + + @Test("handles quoted CSV fields with commas") + func csvQuoted() async throws { + let rows = try await RowPayload.parse(data: "label,note\n\"a,b\",\"says \"\"hi\"\"\"", file: nil) + #expect(rows[0].value(for: "label") == .text("a,b")) + #expect(rows[0].value(for: "note") == .text("says \"hi\"")) + } + + @Test("parseSingle rejects multiple rows") + func parseSingleRejectsMany() async throws { + await #expect(throws: IntentDataError.self) { + _ = try await RowPayload.parseSingle(data: #"[{"a":"1"},{"a":"2"}]"#, file: nil) + } + } + + @Test("empty input throws") + func emptyThrows() async throws { + await #expect(throws: IntentDataError.self) { + _ = try await RowPayload.parse(data: " ", file: nil) + } + } + + @Test("malformed JSON throws") + func malformedJsonThrows() async throws { + await #expect(throws: IntentDataError.self) { + _ = try await RowPayload.parse(data: "{not valid", file: nil) + } + } +} diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index f8fe9db5f..69cca1bd3 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -15,6 +15,9 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { private(set) var executedQueries: [String] = [] private(set) var fetchColumnsCalls: Int = 0 private(set) var fetchForeignKeysCalls: Int = 0 + private(set) var didBeginTransaction = false + private(set) var didCommitTransaction = false + private(set) var didRollbackTransaction = false var supportsSchemas: Bool = false var currentSchema: String? = nil @@ -55,9 +58,9 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchSchemas() async throws -> [String] { scriptedSchemas } func switchDatabase(to name: String) async throws {} func switchSchema(to name: String) async throws {} - func beginTransaction() async throws {} - func commitTransaction() async throws {} - func rollbackTransaction() async throws {} + func beginTransaction() async throws { didBeginTransaction = true } + func commitTransaction() async throws { didCommitTransaction = true } + func rollbackTransaction() async throws { didRollbackTransaction = true } } final class MockSecureStore: SecureStore, @unchecked Sendable { diff --git a/docs/docs.json b/docs/docs.json index b9b72a030..558469a62 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -165,6 +165,7 @@ "pages": [ "external-api/index", "external-api/url-scheme", + "external-api/ios-shortcuts", "external-api/mcp-tools", "external-api/mcp-resources", "external-api/pairing", diff --git a/docs/external-api/ios-shortcuts.mdx b/docs/external-api/ios-shortcuts.mdx new file mode 100644 index 000000000..942550e6b --- /dev/null +++ b/docs/external-api/ios-shortcuts.mdx @@ -0,0 +1,74 @@ +--- +title: iOS Shortcuts +description: Add rows to a table from the Shortcuts app on iPhone and iPad +--- + +# iOS Shortcuts + +TablePro for iOS exposes App Intents, so you can add data to a database table from the Shortcuts app, the Share Sheet, or by voice. The actions connect to a saved connection and insert the rows in the background, without opening the app. + +This is iOS only. On macOS, use the [URL scheme](/external-api/url-scheme) and [MCP](/external-api/mcp-tools). + +## Actions + +| Action | Use it for | +| --- | --- | +| **Add Row to Table** | Insert a single row. | +| **Add Rows to Table** | Insert many rows from a JSON array, CSV text, or a file. | + +Both actions share the same pickers: + +- **Connection**: one of your saved connections. Relational databases only (MySQL, MariaDB, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB). +- **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. + +## Data formats + +The row data is matched to the table's columns by name. A column you don't include is left out of the insert, so the database applies its default (an auto-increment primary key, for example). + +### JSON + +A single object is one row: + +```json +{ "title": "TablePro docs", "url": "https://docs.tablepro.app", "tags": "reference" } +``` + +An array of objects is many rows (Add Rows to Table): + +```json +[ + { "title": "First", "url": "https://example.com/1" }, + { "title": "Second", "url": "https://example.com/2" } +] +``` + +`null` inserts a SQL `NULL`. Numbers and booleans are inserted as text. + +Wrap very large or high-precision numbers in quotes so they stay exact. A bare JSON number past 64-bit integer range (a long unsigned ID, for example) is read as a floating-point value and loses digits; `"18446744073709551615"` as a string keeps every digit. + +A Shortcuts **Dictionary** action passes straight into the Data field, so you can build the row visually with key/value pairs instead of typing JSON. + +### CSV + +The first row is the header and names the columns: + +```text +title,url +First,https://example.com/1 +Second,https://example.com/2 +``` + +Quoted fields with commas and embedded quotes follow the usual CSV rules. Pass CSV as text in the Data field, or as a file in the File field. + +## Result + +Each action returns the number of rows inserted, so you can show it or use it in a later step, and speaks a short confirmation ("Added 2 rows to bookmarks."). + +## Notes + +- The action requires the device to be unlocked, since it reads the connection password from the Keychain. +- A read-only connection refuses the insert with an error. A connection set to Confirm Writes asks you to confirm before the rows are added. +- Add Rows to Table runs the batch in one transaction on databases that support it, so a row that fails rolls the whole batch back instead of leaving a partial insert. +- Data that maps to no columns (an empty object, for example) returns an error rather than reporting that it added nothing. +- Up to 10,000 rows per run.