Skip to content

Commit 80e6bc7

Browse files
authored
Merge pull request #273 from powersync-ja/schema-options
Add new schema options
2 parents 96ea060 + 10fabc3 commit 80e6bc7

File tree

4 files changed

+271
-19
lines changed

4 files changed

+271
-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.trackMetadata] 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.trackPreviousValues].
94+
final Map<String, dynamic>? previousValues;
95+
96+
CrudEntry(
97+
this.clientId,
98+
this.op,
99+
this.table,
100+
this.id,
101+
this.transactionId,
102+
this.opData, {
103+
this.previousValues,
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+
previousValues: 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': previousValues,
104132
};
105133
}
106134

packages/powersync_core/lib/src/schema.dart

+77-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,30 @@ class Schema {
2627
}
2728
}
2829

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

@@ -37,20 +60,34 @@ class Table {
3760
/// List of indexes.
3861
final List<Index> indexes;
3962

40-
/// Whether the table only exists only.
63+
/// Whether to add a hidden `_metadata` column that will be enabled for
64+
/// updates to attach custom information about writes that will be reported
65+
/// through [CrudEntry.metadata].
66+
final bool trackMetadata;
67+
68+
/// Whether to track old values of columns for [CrudEntry.previousValues].
69+
///
70+
/// See [TrackPreviousValuesOptions] for details.
71+
final TrackPreviousValuesOptions? trackPreviousValues;
72+
73+
/// Whether the table only exists locally.
4174
final bool localOnly;
4275

4376
/// Whether this is an insert-only table.
4477
final bool insertOnly;
4578

79+
/// Whether an `UPDATE` statement that doesn't change any values should be
80+
/// ignored when creating CRUD entries.
81+
final bool ignoreEmptyUpdates;
82+
4683
/// Override the name for the view
4784
final String? _viewNameOverride;
4885

4986
/// powersync-sqlite-core limits the number of columns
5087
/// per table to 1999, due to internal SQLite limits.
5188
///
5289
/// In earlier versions this was limited to 63.
53-
final int maxNumberOfColumns = 1999;
90+
final int maxNumberOfColumns = _maxNumberOfColumns;
5491

5592
/// Internal use only.
5693
///
@@ -66,9 +103,16 @@ class Table {
66103
/// Create a synced table.
67104
///
68105
/// 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,
106+
const Table(
107+
this.name,
108+
this.columns, {
109+
this.indexes = const [],
110+
String? viewName,
111+
this.localOnly = false,
112+
this.ignoreEmptyUpdates = false,
113+
this.trackMetadata = false,
114+
this.trackPreviousValues,
115+
}) : insertOnly = false,
72116
_viewNameOverride = viewName;
73117

74118
/// Create a table that only exists locally.
@@ -78,6 +122,9 @@ class Table {
78122
{this.indexes = const [], String? viewName})
79123
: localOnly = true,
80124
insertOnly = false,
125+
trackMetadata = false,
126+
trackPreviousValues = null,
127+
ignoreEmptyUpdates = false,
81128
_viewNameOverride = viewName;
82129

83130
/// Create a table that only supports inserts.
@@ -88,8 +135,14 @@ class Table {
88135
///
89136
/// SELECT queries on the table will always return 0 rows.
90137
///
91-
const Table.insertOnly(this.name, this.columns, {String? viewName})
92-
: localOnly = false,
138+
const Table.insertOnly(
139+
this.name,
140+
this.columns, {
141+
String? viewName,
142+
this.ignoreEmptyUpdates = false,
143+
this.trackMetadata = false,
144+
this.trackPreviousValues,
145+
}) : localOnly = false,
93146
insertOnly = true,
94147
indexes = const [],
95148
_viewNameOverride = viewName;
@@ -106,9 +159,9 @@ class Table {
106159

107160
/// Check that there are no issues in the table definition.
108161
void validate() {
109-
if (columns.length > maxNumberOfColumns) {
162+
if (columns.length > _maxNumberOfColumns) {
110163
throw AssertionError(
111-
"Table $name has more than $maxNumberOfColumns columns, which is not supported");
164+
"Table $name has more than $_maxNumberOfColumns columns, which is not supported");
112165
}
113166

114167
if (invalidSqliteCharacters.hasMatch(name)) {
@@ -121,6 +174,14 @@ class Table {
121174
"Invalid characters in view name: $_viewNameOverride");
122175
}
123176

177+
if (trackMetadata && localOnly) {
178+
throw AssertionError("Local-only tables can't track metadata");
179+
}
180+
181+
if (trackPreviousValues != null && localOnly) {
182+
throw AssertionError("Local-only tables can't track old values");
183+
}
184+
124185
Set<String> columnNames = {"id"};
125186
for (var column in columns) {
126187
if (column.name == 'id') {
@@ -168,7 +229,13 @@ class Table {
168229
'local_only': localOnly,
169230
'insert_only': insertOnly,
170231
'columns': columns,
171-
'indexes': indexes.map((e) => e.toJson(this)).toList(growable: false)
232+
'indexes': indexes.map((e) => e.toJson(this)).toList(growable: false),
233+
'ignore_empty_update': ignoreEmptyUpdates,
234+
'include_metadata': trackMetadata,
235+
if (trackPreviousValues case final trackPreviousValues?) ...{
236+
'include_old': trackPreviousValues.columnFilter ?? true,
237+
'include_old_only_when_changed': trackPreviousValues.onlyWhenChanged,
238+
},
172239
};
173240
}
174241

packages/powersync_core/test/crud_test.dart

+95
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,99 @@ 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+
trackMetadata: 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+
trackPreviousValues: TrackPreviousValuesOptions(),
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].previousValues,
308+
{'name': 'entry', 'content': 'content'});
309+
});
310+
311+
test('include old values with column filter', () async {
312+
await powersync.updateSchema(Schema([
313+
Table(
314+
'lists',
315+
[Column.text('name'), Column.text('content')],
316+
trackPreviousValues:
317+
TrackPreviousValuesOptions(columnFilter: ['name']),
318+
)
319+
]));
320+
321+
await powersync.execute(
322+
'INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)',
323+
['name', 'content']);
324+
await powersync.execute('DELETE FROM ps_crud;');
325+
await powersync.execute('UPDATE lists SET name = ?, content = ?',
326+
['new name', 'new content']);
327+
328+
final batch = await powersync.getNextCrudTransaction();
329+
expect(batch!.crud[0].previousValues, {'name': 'name'});
330+
});
331+
332+
test('include old values when changed', () async {
333+
await powersync.updateSchema(Schema([
334+
Table(
335+
'lists',
336+
[Column.text('name'), Column.text('content')],
337+
trackPreviousValues:
338+
TrackPreviousValuesOptions(onlyWhenChanged: true),
339+
)
340+
]));
341+
342+
await powersync.execute(
343+
'INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)',
344+
['name', 'content']);
345+
await powersync.execute('DELETE FROM ps_crud;');
346+
await powersync.execute('UPDATE lists SET name = ?', ['new name']);
347+
348+
final batch = await powersync.getNextCrudTransaction();
349+
expect(batch!.crud[0].previousValues, {'name': 'name'});
350+
});
351+
352+
test('ignore empty update', () async {
353+
await powersync.updateSchema(Schema([
354+
Table(
355+
'lists',
356+
[Column.text('name')],
357+
ignoreEmptyUpdates: true,
358+
)
359+
]));
360+
361+
await powersync
362+
.execute('INSERT INTO lists (id, name) VALUES (uuid(), ?)', ['name']);
363+
await powersync.execute('DELETE FROM ps_crud;');
364+
await powersync.execute('UPDATE lists SET name = ?;', ['name']);
365+
expect(await powersync.getNextCrudTransaction(), isNull);
366+
});
272367
});
273368
}

0 commit comments

Comments
 (0)