Skip to content

Commit 945ac4c

Browse files
committed
Add new schema options
1 parent 83b82c9 commit 945ac4c

File tree

4 files changed

+261
-19
lines changed

4 files changed

+261
-19
lines changed

packages/powersync_core/lib/src/crud.dart

+31-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'dart:convert';
33
import 'package:collection/collection.dart';
44
import 'package:powersync_core/sqlite3_common.dart' as sqlite;
55

6+
import 'schema.dart';
7+
68
/// A batch of client-side changes.
79
class CrudBatch {
810
/// List of client-side changes.
@@ -68,6 +70,14 @@ class CrudEntry {
6870
/// ID of the changed row.
6971
final String id;
7072

73+
/// An optional metadata string attached to this entry at the time the write
74+
/// has been issued.
75+
///
76+
/// For tables where [Table.includeMetadata] is enabled, a hidden `_metadata`
77+
/// column is added to this table that can be used during updates to attach
78+
/// a hint to the update thas is preserved here.
79+
final String? metadata;
80+
7181
/// Data associated with the change.
7282
///
7383
/// For PUT, this is contains all non-null columns of the row.
@@ -77,8 +87,22 @@ class CrudEntry {
7787
/// For DELETE, this is null.
7888
final Map<String, dynamic>? opData;
7989

80-
CrudEntry(this.clientId, this.op, this.table, this.id, this.transactionId,
81-
this.opData);
90+
/// Old values before an update.
91+
///
92+
/// This is only tracked for tables for which this has been enabled by setting
93+
/// the [Table.includeOld].
94+
final Map<String, dynamic>? oldData;
95+
96+
CrudEntry(
97+
this.clientId,
98+
this.op,
99+
this.table,
100+
this.id,
101+
this.transactionId,
102+
this.opData, {
103+
this.oldData,
104+
this.metadata,
105+
});
82106

83107
factory CrudEntry.fromRow(sqlite.Row row) {
84108
final data = jsonDecode(row['data'] as String);
@@ -89,6 +113,8 @@ class CrudEntry {
89113
data['id'] as String,
90114
row['tx_id'] as int,
91115
data['data'] as Map<String, Object?>?,
116+
oldData: data['old'] as Map<String, Object?>?,
117+
metadata: data['metadata'] as String?,
92118
);
93119
}
94120

@@ -100,7 +126,9 @@ class CrudEntry {
100126
'type': table,
101127
'id': id,
102128
'tx_id': transactionId,
103-
'data': opData
129+
'data': opData,
130+
'metadata': metadata,
131+
'old': oldData,
104132
};
105133
}
106134

packages/powersync_core/lib/src/schema.dart

+74-10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'crud.dart';
12
import 'schema_logic.dart';
23

34
/// The schema used by the database.
@@ -26,8 +27,27 @@ class Schema {
2627
}
2728
}
2829

30+
/// Options to include old values in [CrudEntry] for update statements.
31+
///
32+
/// This options are enabled by passing it to a non-local [Table] constructor.
33+
final class IncludeOldOptions {
34+
/// A filter of column names for which updates should be tracked.
35+
///
36+
/// When set to a non-null value, olumns not included in this list will not
37+
/// appear in [CrudEntry.oldData]. By default, all columns are included.
38+
final List<String>? columnFilter;
39+
40+
/// Whether to only include old values when they were changed by an update,
41+
/// instead of always including all old values.
42+
final bool onlyWhenChanged;
43+
44+
const IncludeOldOptions({this.columnFilter, this.onlyWhenChanged = false});
45+
}
46+
2947
/// A single table in the schema.
3048
class Table {
49+
static const _maxNumberOfColumns = 1999;
50+
3151
/// The synced table name, matching sync rules.
3252
final String name;
3353

@@ -37,20 +57,34 @@ class Table {
3757
/// List of indexes.
3858
final List<Index> indexes;
3959

40-
/// Whether the table only exists only.
60+
/// Whether to add a hidden `_metadata` column that will be enabled for
61+
/// updates to attach custom information about writes that will be reported
62+
/// through [CrudEntry.metadata].
63+
final bool includeMetadata;
64+
65+
/// Whether to track old values of columns for [CrudEntry.oldData].
66+
///
67+
/// See [IncludeOldOptions] for details.
68+
final IncludeOldOptions? includeOld;
69+
70+
/// Whether the table only exists locally.
4171
final bool localOnly;
4272

4373
/// Whether this is an insert-only table.
4474
final bool insertOnly;
4575

76+
/// Whether an `UPDATE` statement that doesn't change any values should be
77+
/// ignored when creating CRUD entries.
78+
final bool ignoreEmptyUpdate;
79+
4680
/// Override the name for the view
4781
final String? _viewNameOverride;
4882

4983
/// powersync-sqlite-core limits the number of columns
5084
/// per table to 1999, due to internal SQLite limits.
5185
///
5286
/// In earlier versions this was limited to 63.
53-
final int maxNumberOfColumns = 1999;
87+
final int maxNumberOfColumns = _maxNumberOfColumns;
5488

5589
/// Internal use only.
5690
///
@@ -66,9 +100,16 @@ class Table {
66100
/// Create a synced table.
67101
///
68102
/// Local changes are recorded, and remote changes are synced to the local table.
69-
const Table(this.name, this.columns,
70-
{this.indexes = const [], String? viewName, this.localOnly = false})
71-
: insertOnly = false,
103+
const Table(
104+
this.name,
105+
this.columns, {
106+
this.indexes = const [],
107+
String? viewName,
108+
this.localOnly = false,
109+
this.ignoreEmptyUpdate = false,
110+
this.includeMetadata = false,
111+
this.includeOld,
112+
}) : insertOnly = false,
72113
_viewNameOverride = viewName;
73114

74115
/// Create a table that only exists locally.
@@ -78,6 +119,9 @@ class Table {
78119
{this.indexes = const [], String? viewName})
79120
: localOnly = true,
80121
insertOnly = false,
122+
includeMetadata = false,
123+
includeOld = null,
124+
ignoreEmptyUpdate = false,
81125
_viewNameOverride = viewName;
82126

83127
/// Create a table that only supports inserts.
@@ -88,8 +132,14 @@ class Table {
88132
///
89133
/// SELECT queries on the table will always return 0 rows.
90134
///
91-
const Table.insertOnly(this.name, this.columns, {String? viewName})
92-
: localOnly = false,
135+
const Table.insertOnly(
136+
this.name,
137+
this.columns, {
138+
String? viewName,
139+
this.ignoreEmptyUpdate = false,
140+
this.includeMetadata = false,
141+
this.includeOld,
142+
}) : localOnly = false,
93143
insertOnly = true,
94144
indexes = const [],
95145
_viewNameOverride = viewName;
@@ -106,9 +156,9 @@ class Table {
106156

107157
/// Check that there are no issues in the table definition.
108158
void validate() {
109-
if (columns.length > maxNumberOfColumns) {
159+
if (columns.length > _maxNumberOfColumns) {
110160
throw AssertionError(
111-
"Table $name has more than $maxNumberOfColumns columns, which is not supported");
161+
"Table $name has more than $_maxNumberOfColumns columns, which is not supported");
112162
}
113163

114164
if (invalidSqliteCharacters.hasMatch(name)) {
@@ -121,6 +171,14 @@ class Table {
121171
"Invalid characters in view name: $_viewNameOverride");
122172
}
123173

174+
if (includeMetadata && localOnly) {
175+
throw AssertionError("Local-only tables can't track metadata");
176+
}
177+
178+
if (includeOld != null && localOnly) {
179+
throw AssertionError("Local-only tables can't track old values");
180+
}
181+
124182
Set<String> columnNames = {"id"};
125183
for (var column in columns) {
126184
if (column.name == 'id') {
@@ -168,7 +226,13 @@ class Table {
168226
'local_only': localOnly,
169227
'insert_only': insertOnly,
170228
'columns': columns,
171-
'indexes': indexes.map((e) => e.toJson(this)).toList(growable: false)
229+
'indexes': indexes.map((e) => e.toJson(this)).toList(growable: false),
230+
'ignore_empty_update': ignoreEmptyUpdate,
231+
'include_metadata': includeMetadata,
232+
if (includeOld case final includeOld?) ...{
233+
'include_old': includeOld.columnFilter ?? true,
234+
'include_old_only_when_changed': includeOld.onlyWhenChanged,
235+
},
172236
};
173237
}
174238

packages/powersync_core/test/crud_test.dart

+92
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ void main() {
139139

140140
test('INSERT-only tables', () async {
141141
await powersync.disconnectAndClear();
142+
await powersync.close();
142143
powersync = await testUtils.setupPowerSync(
143144
path: path,
144145
schema: const Schema([
@@ -269,5 +270,96 @@ void main() {
269270
await tx2.complete();
270271
expect(await powersync.getNextCrudTransaction(), equals(null));
271272
});
273+
274+
test('include metadata', () async {
275+
await powersync.updateSchema(Schema([
276+
Table(
277+
'lists',
278+
[Column.text('name')],
279+
includeMetadata: true,
280+
)
281+
]));
282+
283+
await powersync.execute(
284+
'INSERT INTO lists (id, name, _metadata) VALUES (uuid(), ?, ?)',
285+
['entry', 'so meta']);
286+
287+
final batch = await powersync.getNextCrudTransaction();
288+
expect(batch!.crud[0].metadata, 'so meta');
289+
});
290+
291+
test('include old values', () async {
292+
await powersync.updateSchema(Schema([
293+
Table(
294+
'lists',
295+
[Column.text('name'), Column.text('content')],
296+
includeOld: IncludeOldOptions(),
297+
)
298+
]));
299+
300+
await powersync.execute(
301+
'INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)',
302+
['entry', 'content']);
303+
await powersync.execute('DELETE FROM ps_crud;');
304+
await powersync.execute('UPDATE lists SET name = ?;', ['new name']);
305+
306+
final batch = await powersync.getNextCrudTransaction();
307+
expect(batch!.crud[0].oldData, {'name': 'entry', 'content': 'content'});
308+
});
309+
310+
test('include old values with column filter', () async {
311+
await powersync.updateSchema(Schema([
312+
Table(
313+
'lists',
314+
[Column.text('name'), Column.text('content')],
315+
includeOld: IncludeOldOptions(columnFilter: ['name']),
316+
)
317+
]));
318+
319+
await powersync.execute(
320+
'INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)',
321+
['name', 'content']);
322+
await powersync.execute('DELETE FROM ps_crud;');
323+
await powersync.execute('UPDATE lists SET name = ?, content = ?',
324+
['new name', 'new content']);
325+
326+
final batch = await powersync.getNextCrudTransaction();
327+
expect(batch!.crud[0].oldData, {'name': 'name'});
328+
});
329+
330+
test('include old values when changed', () async {
331+
await powersync.updateSchema(Schema([
332+
Table(
333+
'lists',
334+
[Column.text('name'), Column.text('content')],
335+
includeOld: IncludeOldOptions(onlyWhenChanged: true),
336+
)
337+
]));
338+
339+
await powersync.execute(
340+
'INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)',
341+
['name', 'content']);
342+
await powersync.execute('DELETE FROM ps_crud;');
343+
await powersync.execute('UPDATE lists SET name = ?', ['new name']);
344+
345+
final batch = await powersync.getNextCrudTransaction();
346+
expect(batch!.crud[0].oldData, {'name': 'name'});
347+
});
348+
349+
test('ignore empty update', () async {
350+
await powersync.updateSchema(Schema([
351+
Table(
352+
'lists',
353+
[Column.text('name')],
354+
ignoreEmptyUpdate: true,
355+
)
356+
]));
357+
358+
await powersync
359+
.execute('INSERT INTO lists (id, name) VALUES (uuid(), ?)', ['name']);
360+
await powersync.execute('DELETE FROM ps_crud;');
361+
await powersync.execute('UPDATE lists SET name = ?;', ['name']);
362+
expect(await powersync.getNextCrudTransaction(), isNull);
363+
});
272364
});
273365
}

0 commit comments

Comments
 (0)