diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1a7d0..e59dba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ # Changelog -## 1.11.1 (unreleased) +## 1.12.0 (unreleased) +* Make raw tables easier to use: + * Introduce the `RawTableSchema` struct storing the name of a raw table in the database. When set, + `put` and `delete` statements can be inferred automatically. + * Add `RawTable//jsonDescription`, which can be passed to the `powersync_create_raw_table_crud_trigger` + SQL function to auto-create triggers forwarding writes to `ps_crud`. * Fix `SyncStreamStatus` fields not being visible. * Update PowerSync core extension to version 0.4.11. diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift index a294b81..0fe834f 100644 --- a/Sources/PowerSync/Kotlin/KotlinAdapter.swift +++ b/Sources/PowerSync/Kotlin/KotlinAdapter.swift @@ -23,37 +23,72 @@ enum KotlinAdapter { struct Table { static func toKotlin(_ table: TableProtocol) -> PowerSyncKotlin.Table { - let trackPreviousKotlin: PowerSyncKotlin.TrackPreviousValuesOptions? = if let track = table.trackPreviousValues { - PowerSyncKotlin.TrackPreviousValuesOptions( - columnFilter: track.columnFilter, - onlyWhenChanged: track.onlyWhenChanged - ) - } else { - nil - } - return PowerSyncKotlin.Table( name: table.name, columns: table.columns.map { Column.toKotlin($0) }, indexes: table.indexes.map { Index.toKotlin($0) }, - localOnly: table.localOnly, - insertOnly: table.insertOnly, + options: translateTableOptions(table), viewNameOverride: table.viewNameOverride, - trackMetadata: table.trackMetadata, - trackPreviousValues: trackPreviousKotlin, - ignoreEmptyUpdates: table.ignoreEmptyUpdates ) } static func toKotlin(_ table: RawTable) -> PowerSyncKotlin.RawTable { + if let schema = table.schema { + var put: PowerSyncKotlin.PendingStatement? = nil + var delete: PowerSyncKotlin.PendingStatement? = nil + if let definedStmt = table.put { + put = translateStatement(definedStmt) + } + if let definedStmt = table.delete { + delete = translateStatement(definedStmt) + } + + return PowerSyncKotlin.RawTable( + name: table.name, + schema: translateRawTableSchema(schema), + put: put, + delete: delete, + clear: table.clear, + ) + } + + // If no schema is given, put and delete statements must be present (an invariant + // matched by constructor overloads on the RawTable struct). return PowerSyncKotlin.RawTable( name: table.name, - put: translateStatement(table.put), - delete: translateStatement(table.delete), + put: translateStatement(table.put!), + delete: translateStatement(table.delete!), clear: table.clear ); } + private static func translateTableOptions(_ options: TableOptionsProtocol) -> PowerSyncKotlin.TableOptions { + let trackPreviousKotlin: PowerSyncKotlin.TrackPreviousValuesOptions? = if let track = options.trackPreviousValues { + PowerSyncKotlin.TrackPreviousValuesOptions( + columnFilter: track.columnFilter, + onlyWhenChanged: track.onlyWhenChanged + ) + } else { + nil + } + + return PowerSyncKotlin.TableOptions( + localOnly: options.localOnly, + insertOnly: options.insertOnly, + trackMetadata: options.trackMetadata, + trackPreviousValues: trackPreviousKotlin, + ignoreEmptyUpdates: options.ignoreEmptyUpdates, + ) + } + + private static func translateRawTableSchema(_ schema: RawTableSchema) -> PowerSyncKotlin.RawTableSchema { + return PowerSyncKotlin.RawTableSchema.init( + tableName: schema.tableName, + syncedColumns: schema.syncedColumns, + options: translateTableOptions(schema.options) + ) + } + private static func translateStatement(_ stmt: PendingStatement) -> PowerSyncKotlin.PendingStatement { return PowerSyncKotlin.PendingStatement( sql: stmt.sql, @@ -67,6 +102,8 @@ enum KotlinAdapter { return PowerSyncKotlin.PendingStatementParameterId.shared case .column(let name): return PowerSyncKotlin.PendingStatementParameterColumn(name: name) + case .rest: + return PowerSyncKotlin.PendingStatementParameterRest.shared } } } diff --git a/Sources/PowerSync/Protocol/Schema/RawTable.swift b/Sources/PowerSync/Protocol/Schema/RawTable.swift index f8fbcd0..1bc7c44 100644 --- a/Sources/PowerSync/Protocol/Schema/RawTable.swift +++ b/Sources/PowerSync/Protocol/Schema/RawTable.swift @@ -19,21 +19,77 @@ public struct RawTable: BaseTableProtocol { /// Instead, it is used by the sync client to identify which operations need to use which raw table definition. public let name: String + public let schema: RawTableSchema? + /// The statement to run when the sync client has to insert or update a row. - public let put: PendingStatement + public let put: PendingStatement? /// The statement to run when the sync client has to delete a row. - public let delete: PendingStatement + public let delete: PendingStatement? /// An optional statement to run when the database is cleared. public let clear: String? + /// Creates a raw table from explicit `put` and `delete` statements. + /// + /// Alternatively, raw tables can also be constructed with a ``RawTableSchema`` to infer those statements. public init(name: String, put: PendingStatement, delete: PendingStatement, clear: String? = nil) { self.name = name + self.schema = nil + self.put = put + self.delete = delete + self.clear = clear + } + + /// Creates a raw table where `put` and `delete` statements for the sync client are inferred from a + /// ``RawTableSchema``. + /// + /// The statements can still be customized if necessary. + public init(name: String, schema: RawTableSchema, put: PendingStatement? = nil, delete: PendingStatement? = nil, clear: String? = nil) { + self.name = name + self.schema = schema self.put = put self.delete = delete self.clear = clear } + + /// A JSON-serialized representation of this raw table. + /// + /// The output of this can be passed to the `powersync_create_raw_table_crud_trigger` SQL + /// function to define triggers for this table. + public func jsonDescription() -> String { + return KotlinAdapter.Table.toKotlin(self).jsonDescription() + } +} + +/// THe schema of a ``RawTable`` in the local database. +/// +/// This information is optional when declaring raw tables. However, providing it allows the sync +/// client to infer ``RawTable/put`` and ``RawTable/delete`` statements automatically. +public struct RawTableSchema: Sendable { + /// The actual name of the raw table in the local schema. + /// + /// Unlike ``RawTable/name``, which describes the name of synced tables to match, this reflects + /// the SQLite table name. This is used to infer ``RawTable/put`` and ``RawTable/delete`` statements + /// for the sync client. It can also be used to auto-generate triggers forwarding writes on raw + /// tables into the CRUD upload queue (using the `powersync_create_raw_table_crud_trigger` SQL function). + public let tableName: String + + /// An optional filter of columns that should be synced. + /// + /// By default, all columns in a raw table are considered for sync. If a filter is specified, + /// PowerSync treats unmatched columns as local-only and will not attempt to sync them. + public let syncedColumns: [String]? + + /// Common options affecting how the `powersync_create_raw_table_crud_trigger` SQL function generates + /// triggers. + public let options: TableOptions + + public init(tableName: String, syncedColumns: [String]? = nil, options: TableOptions = TableOptions()) { + self.tableName = tableName + self.syncedColumns = syncedColumns + self.options = options + } } /// A statement to run to sync server-side changes into a local raw table. @@ -61,4 +117,7 @@ public enum PendingStatementParameter: Sendable { /// Note that using this parameter is not allowed for ``RawTable/delete`` statements, which only have access /// to the row's ``PendingStatementParameter/id``. case column(String) + /// Resolves to a JSON object containing all columns from the synced row that haven't been matched + /// by a ``PendingStatementParameter/column`` value in the same statement. + case rest } diff --git a/Sources/PowerSync/Protocol/Schema/Table.swift b/Sources/PowerSync/Protocol/Schema/Table.swift index 1d7d668..8ce60d3 100644 --- a/Sources/PowerSync/Protocol/Schema/Table.swift +++ b/Sources/PowerSync/Protocol/Schema/Table.swift @@ -9,7 +9,7 @@ public protocol BaseTableProtocol: Sendable { } /// Protocol for describing ``Table``s managed by PowerSync. -public protocol TableProtocol: BaseTableProtocol { +public protocol TableProtocol: BaseTableProtocol, TableOptionsProtocol { /// /// List of columns. /// @@ -19,58 +19,10 @@ public protocol TableProtocol: BaseTableProtocol { /// var indexes: [Index] { get } /// - /// Whether the table only exists locally. - /// - var localOnly: Bool { get } - /// - /// Whether this is an insert-only table. - /// - var insertOnly: Bool { get } - /// /// Override the name for the view /// var viewNameOverride: String? { get } var viewName: String { get } - - /// Whether to add a hidden `_metadata` column that will ne abled for updates to - /// attach custom information about writes. - /// - /// When the `_metadata` column is written to for inserts or updates, its value will not be - /// part of ``CrudEntry/opData``. Instead, it is reported as ``CrudEntry/metadata``, - /// allowing ``PowerSyncBackendConnector``s to handle these updates specially. - var trackMetadata: Bool { get } - - /// When set to a non-`nil` value, track old values of columns for ``CrudEntry/previousValues``. - /// - /// See ``TrackPreviousValuesOptions`` for details - var trackPreviousValues: TrackPreviousValuesOptions? { get } - - /// Whether an `UPDATE` statement that doesn't change any values should be ignored entirely when - /// creating CRUD entries. - /// - /// This is disabled by default, meaning that an `UPDATE` on a row that doesn't change values would - /// create a ``CrudEntry`` with an empty ``CrudEntry/opData`` and ``UpdateType/patch``. - var ignoreEmptyUpdates: Bool { get } -} - -/// Options to include old values in ``CrudEntry/previousValues`` for update statements. -/// -/// These options are enabled by passing them to a non-local ``Table`` constructor. -public struct TrackPreviousValuesOptions: Sendable { - /// A filter of column names for which updates should be tracked. - /// - /// When set to a non-`nil` value, columns not included in this list will not appear in - /// ``CrudEntry/previousValues``. By default, all columns are included. - public let columnFilter: [String]? - - /// Whether to only include old values when they were changed by an update, instead of always including - /// all old values. - public let onlyWhenChanged: Bool - - public init(columnFilter: [String]? = nil, onlyWhenChanged: Bool = false) { - self.columnFilter = columnFilter - self.onlyWhenChanged = onlyWhenChanged - } } private let MAX_AMOUNT_OF_COLUMNS = 63 @@ -82,12 +34,34 @@ public struct Table: TableProtocol { public let name: String public let columns: [Column] public let indexes: [Index] - public let localOnly: Bool - public let insertOnly: Bool + public let options: TableOptions public let viewNameOverride: String? - public let trackMetadata: Bool - public let trackPreviousValues: TrackPreviousValuesOptions? - public let ignoreEmptyUpdates: Bool + + public var localOnly: Bool { + get { + return self.options.localOnly; + } + } + public var insertOnly: Bool { + get { + return self.options.insertOnly + } + } + public var trackMetadata: Bool { + get { + return self.options.trackMetadata + } + } + public var trackPreviousValues: TrackPreviousValuesOptions? { + get { + return self.options.trackPreviousValues + } + } + public var ignoreEmptyUpdates: Bool { + get { + return self.options.ignoreEmptyUpdates + } + } public var viewName: String { viewNameOverride ?? name @@ -116,12 +90,14 @@ public struct Table: TableProtocol { self.name = name self.columns = columns self.indexes = indexes - self.localOnly = localOnly - self.insertOnly = insertOnly self.viewNameOverride = viewNameOverride - self.trackMetadata = trackMetadata - self.trackPreviousValues = trackPreviousValues - self.ignoreEmptyUpdates = ignoreEmptyUpdates + self.options = TableOptions( + localOnly: localOnly, + insertOnly: insertOnly, + trackMetadata: trackMetadata, + trackPreviousValues: trackPreviousValues, + ignoreEmptyUpdates: ignoreEmptyUpdates + ) } private func hasInvalidSqliteCharacters(_ string: String) -> Bool { diff --git a/Sources/PowerSync/Protocol/Schema/TableOptions.swift b/Sources/PowerSync/Protocol/Schema/TableOptions.swift new file mode 100644 index 0000000..bc57105 --- /dev/null +++ b/Sources/PowerSync/Protocol/Schema/TableOptions.swift @@ -0,0 +1,94 @@ +public protocol TableOptionsProtocol: Sendable { + /// + /// Whether the table only exists locally. + /// + var localOnly: Bool { get } + /// + /// Whether this is an insert-only table. + /// + var insertOnly: Bool { get } + + /// Whether to add a hidden `_metadata` column that will ne abled for updates to + /// attach custom information about writes. + /// + /// When the `_metadata` column is written to for inserts or updates, its value will not be + /// part of ``CrudEntry/opData``. Instead, it is reported as ``CrudEntry/metadata``, + /// allowing ``PowerSyncBackendConnector``s to handle these updates specially. + var trackMetadata: Bool { get } + + /// When set to a non-`nil` value, track old values of columns for ``CrudEntry/previousValues``. + /// + /// See ``TrackPreviousValuesOptions`` for details + var trackPreviousValues: TrackPreviousValuesOptions? { get } + + /// Whether an `UPDATE` statement that doesn't change any values should be ignored entirely when + /// creating CRUD entries. + /// + /// This is disabled by default, meaning that an `UPDATE` on a row that doesn't change values would + /// create a ``CrudEntry`` with an empty ``CrudEntry/opData`` and ``UpdateType/patch``. + var ignoreEmptyUpdates: Bool { get } +} + +public struct TableOptions: TableOptionsProtocol { + /// + /// Whether the table only exists locally. + /// + public let localOnly: Bool + /// + /// Whether this is an insert-only table. + /// + public let insertOnly: Bool + + /// Whether to add a hidden `_metadata` column that will ne abled for updates to + /// attach custom information about writes. + /// + /// When the `_metadata` column is written to for inserts or updates, its value will not be + /// part of ``CrudEntry/opData``. Instead, it is reported as ``CrudEntry/metadata``, + /// allowing ``PowerSyncBackendConnector``s to handle these updates specially. + public let trackMetadata: Bool + + /// When set to a non-`nil` value, track old values of columns for ``CrudEntry/previousValues``. + /// + /// See ``TrackPreviousValuesOptions`` for details + public let trackPreviousValues: TrackPreviousValuesOptions? + /// Whether an `UPDATE` statement that doesn't change any values should be ignored entirely when + /// creating CRUD entries. + /// + /// This is disabled by default, meaning that an `UPDATE` on a row that doesn't change values would + /// create a ``CrudEntry`` with an empty ``CrudEntry/opData`` and ``UpdateType/patch``. + public let ignoreEmptyUpdates: Bool + + public init( + localOnly: Bool = false, + insertOnly: Bool = false, + trackMetadata: Bool = false, + trackPreviousValues: TrackPreviousValuesOptions? = nil, + ignoreEmptyUpdates: Bool = false + ) { + self.localOnly = localOnly + self.insertOnly = insertOnly + self.trackMetadata = trackMetadata + self.trackPreviousValues = trackPreviousValues + self.ignoreEmptyUpdates = ignoreEmptyUpdates + } +} + +/// Options to include old values in ``CrudEntry/previousValues`` for update statements. +/// +/// These options are enabled by passing them to a non-local ``Table`` constructor. +public struct TrackPreviousValuesOptions: Sendable { + /// A filter of column names for which updates should be tracked. + /// + /// When set to a non-`nil` value, columns not included in this list will not appear in + /// ``CrudEntry/previousValues``. By default, all columns are included. + public let columnFilter: [String]? + + /// Whether to only include old values when they were changed by an update, instead of always including + /// all old values. + public let onlyWhenChanged: Bool + + public init(columnFilter: [String]? = nil, onlyWhenChanged: Bool = false) { + self.columnFilter = columnFilter + self.onlyWhenChanged = onlyWhenChanged + } +} diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 4c23d2c..6d188ba 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -253,4 +253,71 @@ final class CrudTests: XCTestCase { let newEntries = try await database.getAll("SELECT name FROM ps_buckets", mapper: { cursor in try cursor.getString(index: 0) }) XCTAssertEqual(newEntries.count, 0) } + + func testRawTableInferredCrudTrigger() async throws { + let table = RawTable(name: "sync_name", schema: RawTableSchema(tableName: "users")) + try await database.updateSchema(schema: Schema(table)) + + try await database.execute("CREATE TABLE users (id TEXT, name TEXT);") + try await database.execute(sql: "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", parameters: [ + table.jsonDescription(), + "users_insert", + "INSERT" + ]) + try await database.execute(sql: "INSERT INTO users (id, name) VALUES (?, ?)", parameters: [ + "id", + "user" + ]) + + let tx = try await database.getNextCrudTransaction() + XCTAssertEqual(tx?.crud.count, 1) + let write = tx!.crud[0] + XCTAssertEqual(write.op, .put) + XCTAssertEqual(write.table, "sync_name") + XCTAssertEqual(write.id, "id") + let opData = write.opData?["name"] + XCTAssertEqual(opData, "user") + } + + func testRawTableInferredCrudTriggerWithOptions() async throws { + return; + try await database.updateSchema(schema: Schema()) + return; + let table = RawTable( + name: "sync_name", + schema: RawTableSchema( + tableName: "users", + syncedColumns: ["name"], + options: TableOptions( + trackPreviousValues: TrackPreviousValuesOptions(), + ignoreEmptyUpdates: true, + ), + ) + ) + + try await database.execute("CREATE TABLE users (id TEXT, name TEXT, local TEXT);") + try await database.execute(sql: "INSERT INTO users (id, name, local) VALUES (?, ?, ?)", parameters: [ + "id", + "user", + "local" + ]) + try await database.execute(sql: "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", parameters: [ + table.jsonDescription(), + "users_update", + "UPDATE" + ]) + + try await database.execute(sql: "UPDATE users SET name = ?, local = ?", parameters: ["updated_name", "updated_local"]) + + // This should not generate a CRUD entry because the only synced column is not affected. + try await database.execute(sql: "UPDATE users SET name = ?, local = ?", parameters: ["updated_name", "updated_local_2"]) + + let tx = try await database.getNextCrudTransaction() + XCTAssertEqual(tx?.crud.count, 1) + let write = tx!.crud[0] + XCTAssertEqual(write.op, .patch) + XCTAssertEqual(write.id, "id") + XCTAssertEqual(write.opData?["name"], "updated_name") + XCTAssertEqual(write.previousValues?["name"], "name") + } }