Skip to content

Commit 1461ce5

Browse files
authored
Merge pull request #20 from powersync-ja/view-name-override
View name override
2 parents e8b606c + d2b43b2 commit 1461ce5

File tree

4 files changed

+248
-35
lines changed

4 files changed

+248
-35
lines changed

lib/src/powersync_database.dart

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import 'sync_status.dart';
3131
/// or not. Once connected, the changes are uploaded.
3232
class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
3333
/// Schema used for the local database.
34-
final Schema schema;
34+
Schema schema;
3535

3636
/// The underlying database.
3737
///
@@ -123,6 +123,19 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
123123
statusStream = _statusStreamController.stream;
124124
await database.initialize();
125125
await migrations.migrate(database);
126+
await updateSchema(schema);
127+
}
128+
129+
/// Replace the schema with a new version.
130+
/// This is for advanced use cases - typically the schema should just be
131+
/// specified once in the constructor.
132+
///
133+
/// Cannot be used while connected - this should only be called before [connect].
134+
Future<void> updateSchema(Schema schema) async {
135+
if (_disconnecter != null) {
136+
throw AssertionError('Cannot update schema while connected');
137+
}
138+
this.schema = schema;
126139
await updateSchemaInIsolate(database, schema);
127140
}
128141

@@ -144,6 +157,8 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
144157
///
145158
/// Status changes are reported on [statusStream].
146159
connect({required PowerSyncBackendConnector connector}) async {
160+
await initialize();
161+
147162
// Disconnect if connected
148163
await disconnect();
149164
final disconnector = AbortController();
@@ -259,19 +274,23 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
259274
///
260275
/// The database can still be queried after this is called, but the tables
261276
/// would be empty.
262-
Future<void> disconnectAndClear() async {
277+
///
278+
/// To preserve data in local-only tables, set [clearLocal] to false.
279+
Future<void> disconnectAndClear({bool clearLocal = true}) async {
263280
await disconnect();
264281

265282
await writeTransaction((tx) async {
266-
await tx.execute('DELETE FROM ps_oplog WHERE 1');
267-
await tx.execute('DELETE FROM ps_crud WHERE 1');
268-
await tx.execute('DELETE FROM ps_buckets WHERE 1');
283+
await tx.execute('DELETE FROM ps_oplog');
284+
await tx.execute('DELETE FROM ps_crud');
285+
await tx.execute('DELETE FROM ps_buckets');
269286

287+
final tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*';
270288
final existingTableRows = await tx.getAll(
271-
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'");
289+
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ?",
290+
[tableGlob]);
272291

273292
for (var row in existingTableRows) {
274-
await tx.execute('DELETE FROM "${row['name']}" WHERE 1');
293+
await tx.execute('DELETE FROM ${quoteIdentifier(row['name'])}');
275294
}
276295
});
277296
}

lib/src/schema.dart

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class Schema {
1313

1414
/// A single table in the schema.
1515
class Table {
16-
/// The table name, as used in queries.
16+
/// The synced table name, matching sync rules.
1717
final String name;
1818

1919
/// List of columns.
@@ -28,6 +28,9 @@ class Table {
2828
/// Whether this is an insert-only table.
2929
final bool insertOnly;
3030

31+
/// Override the name for the view
32+
final String? _viewNameOverride;
33+
3134
/// Internal use only.
3235
///
3336
/// Name of the table that stores the underlying data.
@@ -42,33 +45,90 @@ class Table {
4245
/// Create a synced table.
4346
///
4447
/// Local changes are recorded, and remote changes are synced to the local table.
45-
const Table(this.name, this.columns, {this.indexes = const []})
46-
: localOnly = false,
47-
insertOnly = false;
48+
const Table(this.name, this.columns,
49+
{this.indexes = const [], String? viewName, this.localOnly = false})
50+
: insertOnly = false,
51+
_viewNameOverride = viewName;
4852

4953
/// Create a table that only exists locally.
5054
///
5155
/// This table does not record changes, and is not synchronized from the service.
52-
const Table.localOnly(this.name, this.columns, {this.indexes = const []})
56+
const Table.localOnly(this.name, this.columns,
57+
{this.indexes = const [], String? viewName})
5358
: localOnly = true,
54-
insertOnly = false;
59+
insertOnly = false,
60+
_viewNameOverride = viewName;
5561

5662
/// Create a table that only supports inserts.
5763
///
5864
/// This table records INSERT statements, but does not persist data locally.
5965
///
6066
/// SELECT queries on the table will always return 0 rows.
61-
const Table.insertOnly(this.name, this.columns)
67+
const Table.insertOnly(this.name, this.columns, {String? viewName})
6268
: localOnly = false,
6369
insertOnly = true,
64-
indexes = const [];
70+
indexes = const [],
71+
_viewNameOverride = viewName;
6572

6673
Column operator [](String columnName) {
6774
return columns.firstWhere((element) => element.name == columnName);
6875
}
6976

7077
bool get validName {
71-
return !invalidSqliteCharacters.hasMatch(name);
78+
return !invalidSqliteCharacters.hasMatch(name) &&
79+
(_viewNameOverride == null ||
80+
!invalidSqliteCharacters.hasMatch(_viewNameOverride!));
81+
}
82+
83+
/// Check that there are no issues in the table definition.
84+
void validate() {
85+
if (invalidSqliteCharacters.hasMatch(name)) {
86+
throw AssertionError("Invalid characters in table name: $name");
87+
} else if (_viewNameOverride != null &&
88+
invalidSqliteCharacters.hasMatch(_viewNameOverride!)) {
89+
throw AssertionError(
90+
"Invalid characters in view name: $_viewNameOverride");
91+
}
92+
93+
Set<String> columnNames = {"id"};
94+
for (var column in columns) {
95+
if (column.name == 'id') {
96+
throw AssertionError(
97+
"$name: id column is automatically added, custom id columns are not supported");
98+
} else if (columnNames.contains(column.name)) {
99+
throw AssertionError("Duplicate column $name.${column.name}");
100+
} else if (invalidSqliteCharacters.hasMatch(column.name)) {
101+
throw AssertionError(
102+
"Invalid characters in column name: $name.${column.name}");
103+
}
104+
105+
columnNames.add(column.name);
106+
}
107+
Set<String> indexNames = {};
108+
109+
for (var index in indexes) {
110+
if (indexNames.contains(index.name)) {
111+
throw AssertionError("Duplicate index $name.${index.name}");
112+
} else if (invalidSqliteCharacters.hasMatch(index.name)) {
113+
throw AssertionError(
114+
"Invalid characters in index name: $name.${index.name}");
115+
}
116+
117+
for (var column in index.columns) {
118+
if (!columnNames.contains(column.column)) {
119+
throw AssertionError(
120+
"Column $name.${column.column} not found for index ${index.name}");
121+
}
122+
}
123+
124+
indexNames.add(index.name);
125+
}
126+
}
127+
128+
/// Name for the view, used for queries.
129+
/// Defaults to the synced table name.
130+
String get viewName {
131+
return _viewNameOverride ?? name;
72132
}
73133
}
74134

lib/src/schema_logic.dart

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ String createViewStatement(Table table) {
1616

1717
if (table.insertOnly) {
1818
final nulls = table.columns.map((column) => 'NULL').join(', ');
19-
return 'CREATE VIEW ${quoteIdentifier(table.name)}("id", $columnNames) AS SELECT NULL, $nulls WHERE 0 $_autoGenerated';
19+
return 'CREATE VIEW ${quoteIdentifier(table.viewName)}("id", $columnNames) AS SELECT NULL, $nulls WHERE 0 $_autoGenerated';
2020
}
2121
final select = table.columns.map(mapColumn).join(', ');
22-
return 'CREATE VIEW ${quoteIdentifier(table.name)}("id", $columnNames) AS SELECT "id", $select FROM ${quoteIdentifier(table.internalName)} $_autoGenerated';
22+
return 'CREATE VIEW ${quoteIdentifier(table.viewName)}("id", $columnNames) AS SELECT "id", $select FROM ${quoteIdentifier(table.internalName)} $_autoGenerated';
2323
}
2424

2525
String mapColumn(Column column) {
@@ -32,6 +32,7 @@ List<String> createViewTriggerStatements(Table table) {
3232
} else if (table.insertOnly) {
3333
return createViewTriggerStatementsInsert(table);
3434
}
35+
final viewName = table.viewName;
3536
final type = table.name;
3637
final internalNameE = quoteIdentifier(table.internalName);
3738

@@ -46,16 +47,16 @@ List<String> createViewTriggerStatements(Table table) {
4647
// Names in alphabetical order
4748
return [
4849
"""
49-
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$type')}
50-
INSTEAD OF DELETE ON ${quoteIdentifier(type)}
50+
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$viewName')}
51+
INSTEAD OF DELETE ON ${quoteIdentifier(viewName)}
5152
FOR EACH ROW
5253
BEGIN
5354
DELETE FROM $internalNameE WHERE id = OLD.id;
5455
INSERT INTO ps_crud(tx_id, data) SELECT current_tx, json_object('op', 'DELETE', 'type', ${quoteString(type)}, 'id', OLD.id) FROM ps_tx WHERE id = 1;
5556
END""",
5657
"""
57-
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
58-
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
58+
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
59+
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
5960
FOR EACH ROW
6061
BEGIN
6162
SELECT CASE
@@ -76,8 +77,8 @@ BEGIN
7677
INSERT OR REPLACE INTO ps_buckets(name, pending_delete, last_op, target_op) VALUES('\$local', 1, 0, $maxOpId);
7778
END""",
7879
"""
79-
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$type')}
80-
INSTEAD OF UPDATE ON ${quoteIdentifier(type)}
80+
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$viewName')}
81+
INSTEAD OF UPDATE ON ${quoteIdentifier(viewName)}
8182
FOR EACH ROW
8283
BEGIN
8384
SELECT CASE
@@ -102,7 +103,7 @@ END"""
102103
}
103104

104105
List<String> createViewTriggerStatementsLocal(Table table) {
105-
final type = table.name;
106+
final viewName = table.viewName;
106107
final internalNameE = quoteIdentifier(table.internalName);
107108

108109
final jsonFragment = table.columns
@@ -112,23 +113,23 @@ List<String> createViewTriggerStatementsLocal(Table table) {
112113
// Names in alphabetical order
113114
return [
114115
"""
115-
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$type')}
116-
INSTEAD OF DELETE ON ${quoteIdentifier(type)}
116+
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$viewName')}
117+
INSTEAD OF DELETE ON ${quoteIdentifier(viewName)}
117118
FOR EACH ROW
118119
BEGIN
119120
DELETE FROM $internalNameE WHERE id = OLD.id;
120121
END""",
121122
"""
122-
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
123-
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
123+
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
124+
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
124125
FOR EACH ROW
125126
BEGIN
126127
INSERT INTO $internalNameE(id, data)
127128
SELECT NEW.id, json_object($jsonFragment);
128129
END""",
129130
"""
130-
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$type')}
131-
INSTEAD OF UPDATE ON ${quoteIdentifier(type)}
131+
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$viewName')}
132+
INSTEAD OF UPDATE ON ${quoteIdentifier(viewName)}
132133
FOR EACH ROW
133134
BEGIN
134135
SELECT CASE
@@ -144,15 +145,16 @@ END"""
144145

145146
List<String> createViewTriggerStatementsInsert(Table table) {
146147
final type = table.name;
148+
final viewName = table.viewName;
147149

148150
final jsonFragment = table.columns
149151
.map((column) =>
150152
"${quoteString(column.name)}, NEW.${quoteIdentifier(column.name)}")
151153
.join(', ');
152154
return [
153155
"""
154-
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
155-
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
156+
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
157+
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
156158
FOR EACH ROW
157159
BEGIN
158160
INSERT INTO ps_crud(tx_id, data) SELECT current_tx, json_object('op', 'PUT', 'type', ${quoteString(type)}, 'id', NEW.id, 'data', json(powersync_diff('{}', json_object($jsonFragment)))) FROM ps_tx WHERE id = 1;
@@ -164,6 +166,10 @@ END"""
164166
///
165167
/// Must be wrapped in a transaction.
166168
void updateSchema(sqlite.Database db, Schema schema) {
169+
for (var table in schema.tables) {
170+
table.validate();
171+
}
172+
167173
_createTablesAndIndexes(db, schema);
168174

169175
final existingViewRows = db.select(
@@ -172,8 +178,6 @@ void updateSchema(sqlite.Database db, Schema schema) {
172178
Set<String> toRemove = {for (var row in existingViewRows) row['name']};
173179

174180
for (var table in schema.tables) {
175-
assert(table.validName, "Invalid characters in table name: ${table.name}");
176-
177181
toRemove.remove(table.name);
178182

179183
var createViewOp = createViewStatement(table);

0 commit comments

Comments
 (0)