Skip to content

Commit

Permalink
Merge pull request #20 from powersync-ja/view-name-override
Browse files Browse the repository at this point in the history
View name override
  • Loading branch information
rkistner authored Nov 17, 2023
2 parents e8b606c + d2b43b2 commit 1461ce5
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 35 deletions.
33 changes: 26 additions & 7 deletions lib/src/powersync_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import 'sync_status.dart';
/// or not. Once connected, the changes are uploaded.
class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
/// Schema used for the local database.
final Schema schema;
Schema schema;

/// The underlying database.
///
Expand Down Expand Up @@ -123,6 +123,19 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
statusStream = _statusStreamController.stream;
await database.initialize();
await migrations.migrate(database);
await updateSchema(schema);
}

/// Replace the schema with a new version.
/// This is for advanced use cases - typically the schema should just be
/// specified once in the constructor.
///
/// Cannot be used while connected - this should only be called before [connect].
Future<void> updateSchema(Schema schema) async {
if (_disconnecter != null) {
throw AssertionError('Cannot update schema while connected');
}
this.schema = schema;
await updateSchemaInIsolate(database, schema);
}

Expand All @@ -144,6 +157,8 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
///
/// Status changes are reported on [statusStream].
connect({required PowerSyncBackendConnector connector}) async {
await initialize();

// Disconnect if connected
await disconnect();
final disconnector = AbortController();
Expand Down Expand Up @@ -259,19 +274,23 @@ class PowerSyncDatabase with SqliteQueries implements SqliteConnection {
///
/// The database can still be queried after this is called, but the tables
/// would be empty.
Future<void> disconnectAndClear() async {
///
/// To preserve data in local-only tables, set [clearLocal] to false.
Future<void> disconnectAndClear({bool clearLocal = true}) async {
await disconnect();

await writeTransaction((tx) async {
await tx.execute('DELETE FROM ps_oplog WHERE 1');
await tx.execute('DELETE FROM ps_crud WHERE 1');
await tx.execute('DELETE FROM ps_buckets WHERE 1');
await tx.execute('DELETE FROM ps_oplog');
await tx.execute('DELETE FROM ps_crud');
await tx.execute('DELETE FROM ps_buckets');

final tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*';
final existingTableRows = await tx.getAll(
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'");
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ?",
[tableGlob]);

for (var row in existingTableRows) {
await tx.execute('DELETE FROM "${row['name']}" WHERE 1');
await tx.execute('DELETE FROM ${quoteIdentifier(row['name'])}');
}
});
}
Expand Down
78 changes: 69 additions & 9 deletions lib/src/schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Schema {

/// A single table in the schema.
class Table {
/// The table name, as used in queries.
/// The synced table name, matching sync rules.
final String name;

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

/// Override the name for the view
final String? _viewNameOverride;

/// Internal use only.
///
/// Name of the table that stores the underlying data.
Expand All @@ -42,33 +45,90 @@ class Table {
/// Create a synced table.
///
/// Local changes are recorded, and remote changes are synced to the local table.
const Table(this.name, this.columns, {this.indexes = const []})
: localOnly = false,
insertOnly = false;
const Table(this.name, this.columns,
{this.indexes = const [], String? viewName, this.localOnly = false})
: insertOnly = false,
_viewNameOverride = viewName;

/// Create a table that only exists locally.
///
/// This table does not record changes, and is not synchronized from the service.
const Table.localOnly(this.name, this.columns, {this.indexes = const []})
const Table.localOnly(this.name, this.columns,
{this.indexes = const [], String? viewName})
: localOnly = true,
insertOnly = false;
insertOnly = false,
_viewNameOverride = viewName;

/// Create a table that only supports inserts.
///
/// This table records INSERT statements, but does not persist data locally.
///
/// SELECT queries on the table will always return 0 rows.
const Table.insertOnly(this.name, this.columns)
const Table.insertOnly(this.name, this.columns, {String? viewName})
: localOnly = false,
insertOnly = true,
indexes = const [];
indexes = const [],
_viewNameOverride = viewName;

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

bool get validName {
return !invalidSqliteCharacters.hasMatch(name);
return !invalidSqliteCharacters.hasMatch(name) &&
(_viewNameOverride == null ||
!invalidSqliteCharacters.hasMatch(_viewNameOverride!));
}

/// Check that there are no issues in the table definition.
void validate() {
if (invalidSqliteCharacters.hasMatch(name)) {
throw AssertionError("Invalid characters in table name: $name");
} else if (_viewNameOverride != null &&
invalidSqliteCharacters.hasMatch(_viewNameOverride!)) {
throw AssertionError(
"Invalid characters in view name: $_viewNameOverride");
}

Set<String> columnNames = {"id"};
for (var column in columns) {
if (column.name == 'id') {
throw AssertionError(
"$name: id column is automatically added, custom id columns are not supported");
} else if (columnNames.contains(column.name)) {
throw AssertionError("Duplicate column $name.${column.name}");
} else if (invalidSqliteCharacters.hasMatch(column.name)) {
throw AssertionError(
"Invalid characters in column name: $name.${column.name}");
}

columnNames.add(column.name);
}
Set<String> indexNames = {};

for (var index in indexes) {
if (indexNames.contains(index.name)) {
throw AssertionError("Duplicate index $name.${index.name}");
} else if (invalidSqliteCharacters.hasMatch(index.name)) {
throw AssertionError(
"Invalid characters in index name: $name.${index.name}");
}

for (var column in index.columns) {
if (!columnNames.contains(column.column)) {
throw AssertionError(
"Column $name.${column.column} not found for index ${index.name}");
}
}

indexNames.add(index.name);
}
}

/// Name for the view, used for queries.
/// Defaults to the synced table name.
String get viewName {
return _viewNameOverride ?? name;
}
}

Expand Down
42 changes: 23 additions & 19 deletions lib/src/schema_logic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ String createViewStatement(Table table) {

if (table.insertOnly) {
final nulls = table.columns.map((column) => 'NULL').join(', ');
return 'CREATE VIEW ${quoteIdentifier(table.name)}("id", $columnNames) AS SELECT NULL, $nulls WHERE 0 $_autoGenerated';
return 'CREATE VIEW ${quoteIdentifier(table.viewName)}("id", $columnNames) AS SELECT NULL, $nulls WHERE 0 $_autoGenerated';
}
final select = table.columns.map(mapColumn).join(', ');
return 'CREATE VIEW ${quoteIdentifier(table.name)}("id", $columnNames) AS SELECT "id", $select FROM ${quoteIdentifier(table.internalName)} $_autoGenerated';
return 'CREATE VIEW ${quoteIdentifier(table.viewName)}("id", $columnNames) AS SELECT "id", $select FROM ${quoteIdentifier(table.internalName)} $_autoGenerated';
}

String mapColumn(Column column) {
Expand All @@ -32,6 +32,7 @@ List<String> createViewTriggerStatements(Table table) {
} else if (table.insertOnly) {
return createViewTriggerStatementsInsert(table);
}
final viewName = table.viewName;
final type = table.name;
final internalNameE = quoteIdentifier(table.internalName);

Expand All @@ -46,16 +47,16 @@ List<String> createViewTriggerStatements(Table table) {
// Names in alphabetical order
return [
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$type')}
INSTEAD OF DELETE ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$viewName')}
INSTEAD OF DELETE ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
DELETE FROM $internalNameE WHERE id = OLD.id;
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;
END""",
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
SELECT CASE
Expand All @@ -76,8 +77,8 @@ BEGIN
INSERT OR REPLACE INTO ps_buckets(name, pending_delete, last_op, target_op) VALUES('\$local', 1, 0, $maxOpId);
END""",
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$type')}
INSTEAD OF UPDATE ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$viewName')}
INSTEAD OF UPDATE ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
SELECT CASE
Expand All @@ -102,7 +103,7 @@ END"""
}

List<String> createViewTriggerStatementsLocal(Table table) {
final type = table.name;
final viewName = table.viewName;
final internalNameE = quoteIdentifier(table.internalName);

final jsonFragment = table.columns
Expand All @@ -112,23 +113,23 @@ List<String> createViewTriggerStatementsLocal(Table table) {
// Names in alphabetical order
return [
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$type')}
INSTEAD OF DELETE ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_delete_$viewName')}
INSTEAD OF DELETE ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
DELETE FROM $internalNameE WHERE id = OLD.id;
END""",
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
INSERT INTO $internalNameE(id, data)
SELECT NEW.id, json_object($jsonFragment);
END""",
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$type')}
INSTEAD OF UPDATE ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_update_$viewName')}
INSTEAD OF UPDATE ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
SELECT CASE
Expand All @@ -144,15 +145,16 @@ END"""

List<String> createViewTriggerStatementsInsert(Table table) {
final type = table.name;
final viewName = table.viewName;

final jsonFragment = table.columns
.map((column) =>
"${quoteString(column.name)}, NEW.${quoteIdentifier(column.name)}")
.join(', ');
return [
"""
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$type')}
INSTEAD OF INSERT ON ${quoteIdentifier(type)}
CREATE TRIGGER ${quoteIdentifier('ps_view_insert_$viewName')}
INSTEAD OF INSERT ON ${quoteIdentifier(viewName)}
FOR EACH ROW
BEGIN
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;
Expand All @@ -164,6 +166,10 @@ END"""
///
/// Must be wrapped in a transaction.
void updateSchema(sqlite.Database db, Schema schema) {
for (var table in schema.tables) {
table.validate();
}

_createTablesAndIndexes(db, schema);

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

for (var table in schema.tables) {
assert(table.validName, "Invalid characters in table name: ${table.name}");

toRemove.remove(table.name);

var createViewOp = createViewStatement(table);
Expand Down
Loading

0 comments on commit 1461ce5

Please sign in to comment.