Skip to content

View name override #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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