Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ class PowerSyncDatabaseImpl
if (disconnecter != null) {
throw AssertionError('Cannot update schema while connected');
}
schema.validate();
this.schema = schema;
return updateSchemaInIsolate(database, schema);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ class PowerSyncDatabaseImpl
if (disconnecter != null) {
throw AssertionError('Cannot update schema while connected');
}
schema.validate();
this.schema = schema;
return database.writeLock((tx) => schema_logic.updateSchema(tx, schema));
}
Expand Down
19 changes: 18 additions & 1 deletion packages/powersync/lib/src/schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ class Schema {
const Schema(this.tables);

Map<String, dynamic> toJson() => {'tables': tables};

void validate() {
for (var table in tables) {
table.validate();
}
}
}

/// A single table in the schema.
Expand All @@ -33,6 +39,10 @@ class Table {
/// Override the name for the view
final String? _viewNameOverride;

/// There is maximum of 127 arguments for any function in SQLite. Currently we use json_object which uses 1 arg per key (column name)
/// and one per value, which limits it to 63 arguments.
final int maxNumberOfColumns = 63;

/// Internal use only.
///
/// Name of the table that stores the underlying data.
Expand Down Expand Up @@ -84,9 +94,16 @@ class Table {

/// Check that there are no issues in the table definition.
void validate() {
if (columns.length > maxNumberOfColumns) {
throw AssertionError(
"Table $name has more than $maxNumberOfColumns columns, which is not supported");
}

if (invalidSqliteCharacters.hasMatch(name)) {
throw AssertionError("Invalid characters in table name: $name");
} else if (_viewNameOverride != null &&
}

if (_viewNameOverride != null &&
invalidSqliteCharacters.hasMatch(_viewNameOverride)) {
throw AssertionError(
"Invalid characters in view name: $_viewNameOverride");
Expand Down
196 changes: 196 additions & 0 deletions packages/powersync/test/schema_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,201 @@ void main() {

expect(results2[0]['detail'], contains('SCAN'));
});

test('Validation runs on setup', () async {
final schema = Schema([
Table('#assets', [
Column.text('name'),
]),
]);

try {
await testUtils.setupPowerSync(path: path, schema: schema);
} catch (e) {
expect(
e,
isA<AssertionError>().having((e) => e.message, 'message',
'Invalid characters in table name: #assets'));
}
});

test('Validation runs on update', () async {
final schema = Schema([
Table('works', [
Column.text('name'),
]),
]);

final powersync =
await testUtils.setupPowerSync(path: path, schema: schema);

final schema2 = Schema([
Table('#notworking', [
Column.text('created_at'),
]),
]);

try {
powersync.updateSchema(schema2);
} catch (e) {
expect(
e,
isA<AssertionError>().having((e) => e.message, 'message',
'Invalid characters in table name: #notworking'));
}
});
});

group('Table', () {
test('Create a synced table', () {
final table = Table('users', [
Column('name', ColumnType.text),
Column('age', ColumnType.integer),
]);

expect(table.name, equals('users'));
expect(table.columns.length, equals(2));
expect(table.localOnly, isFalse);
expect(table.insertOnly, isFalse);
expect(table.internalName, equals('ps_data__users'));
expect(table.viewName, equals('users'));
});

test('Create a local-only table', () {
final table = Table.localOnly(
'local_users',
[
Column('name', ColumnType.text),
],
viewName: 'local_user_view');

expect(table.name, equals('local_users'));
expect(table.localOnly, isTrue);
expect(table.insertOnly, isFalse);
expect(table.internalName, equals('ps_data_local__local_users'));
expect(table.viewName, equals('local_user_view'));
});

test('Create an insert-only table', () {
final table = Table.insertOnly('logs', [
Column('message', ColumnType.text),
Column('timestamp', ColumnType.integer),
]);

expect(table.name, equals('logs'));
expect(table.localOnly, isFalse);
expect(table.insertOnly, isTrue);
expect(table.internalName, equals('ps_data__logs'));
expect(table.indexes, isEmpty);
});

test('Access column by name', () {
final table = Table('products', [
Column('name', ColumnType.text),
Column('price', ColumnType.real),
]);

expect(table['name'].type, equals(ColumnType.text));
expect(table['price'].type, equals(ColumnType.real));
expect(() => table['nonexistent'], throwsStateError);
});

test('Validate table name', () {
final invalidTableName =
Table('#invalid_table_name', [Column('name', ColumnType.text)]);

expect(
() => invalidTableName.validate(),
throwsA(
isA<AssertionError>().having(
(e) => e.message,
'message',
'Invalid characters in table name: #invalid_table_name',
),
),
);
});

test('Validate view name', () {
final invalidTableName = Table(
'valid_table_name', [Column('name', ColumnType.text)],
viewName: '#invalid_view_name');

expect(
() => invalidTableName.validate(),
throwsA(
isA<AssertionError>().having(
(e) => e.message,
'message',
'Invalid characters in view name: #invalid_view_name',
),
),
);
});

test('Validate table definition', () {
final validTable = Table('valid_table', [
Column('name', ColumnType.text),
Column('age', ColumnType.integer),
]);

expect(() => validTable.validate(), returnsNormally);
});

test('Table with id column', () {
final invalidTable = Table('invalid_table', [
Column('id', ColumnType.integer), // Duplicate 'id' column
Column('name', ColumnType.text),
]);

expect(
() => invalidTable.validate(),
throwsA(
isA<AssertionError>().having(
(e) => e.message,
'message',
'invalid_table: id column is automatically added, custom id columns are not supported',
),
),
);
});

test('Table with too many columns', () {
final List<Column> manyColumns = List.generate(
64, // Exceeds MAX_NUMBER_OF_COLUMNS
(index) => Column('col$index', ColumnType.text),
);

final tableTooManyColumns = Table('too_many_columns', manyColumns);

expect(
() => tableTooManyColumns.validate(),
throwsA(
isA<AssertionError>().having(
(e) => e.message,
'message',
'Table too_many_columns has more than 63 columns, which is not supported',
),
),
);
});

test('toJson method', () {
final table = Table('users', [
Column('name', ColumnType.text),
Column('age', ColumnType.integer),
], indexes: [
Index('name_index', [IndexedColumn('name')])
]);

final json = table.toJson();

expect(json['name'], equals('users'));
expect(json['view_name'], isNull);
expect(json['local_only'], isFalse);
expect(json['insert_only'], isFalse);
expect(json['columns'].length, equals(2));
expect(json['indexes'].length, equals(1));
});
});
}
Loading