Skip to content

Commit 66ebe97

Browse files
DominicGBauerDominicGBauer
and
DominicGBauer
authored
chore(powersync): add max column check (#151)
* chore(powersync): add max column check * chore: fix formatting issues * chore: fix lint issue * chore: reinclude validation * fix: tests * chore: remove dart developer * chore: fix lint issue --------- Co-authored-by: DominicGBauer <[email protected]>
1 parent 505fa24 commit 66ebe97

File tree

4 files changed

+216
-1
lines changed

4 files changed

+216
-1
lines changed

packages/powersync/lib/src/database/native/native_powersync_database.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ class PowerSyncDatabaseImpl
261261
if (disconnecter != null) {
262262
throw AssertionError('Cannot update schema while connected');
263263
}
264+
schema.validate();
264265
this.schema = schema;
265266
return updateSchemaInIsolate(database, schema);
266267
}

packages/powersync/lib/src/database/web/web_powersync_database.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ class PowerSyncDatabaseImpl
207207
if (disconnecter != null) {
208208
throw AssertionError('Cannot update schema while connected');
209209
}
210+
schema.validate();
210211
this.schema = schema;
211212
return database.writeLock((tx) => schema_logic.updateSchema(tx, schema));
212213
}

packages/powersync/lib/src/schema.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ class Schema {
1111
const Schema(this.tables);
1212

1313
Map<String, dynamic> toJson() => {'tables': tables};
14+
15+
void validate() {
16+
for (var table in tables) {
17+
table.validate();
18+
}
19+
}
1420
}
1521

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

42+
/// There is maximum of 127 arguments for any function in SQLite. Currently we use json_object which uses 1 arg per key (column name)
43+
/// and one per value, which limits it to 63 arguments.
44+
final int maxNumberOfColumns = 63;
45+
3646
/// Internal use only.
3747
///
3848
/// Name of the table that stores the underlying data.
@@ -84,9 +94,16 @@ class Table {
8494

8595
/// Check that there are no issues in the table definition.
8696
void validate() {
97+
if (columns.length > maxNumberOfColumns) {
98+
throw AssertionError(
99+
"Table $name has more than $maxNumberOfColumns columns, which is not supported");
100+
}
101+
87102
if (invalidSqliteCharacters.hasMatch(name)) {
88103
throw AssertionError("Invalid characters in table name: $name");
89-
} else if (_viewNameOverride != null &&
104+
}
105+
106+
if (_viewNameOverride != null &&
90107
invalidSqliteCharacters.hasMatch(_viewNameOverride)) {
91108
throw AssertionError(
92109
"Invalid characters in view name: $_viewNameOverride");

packages/powersync/test/schema_test.dart

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,5 +142,201 @@ void main() {
142142

143143
expect(results2[0]['detail'], contains('SCAN'));
144144
});
145+
146+
test('Validation runs on setup', () async {
147+
final schema = Schema([
148+
Table('#assets', [
149+
Column.text('name'),
150+
]),
151+
]);
152+
153+
try {
154+
await testUtils.setupPowerSync(path: path, schema: schema);
155+
} catch (e) {
156+
expect(
157+
e,
158+
isA<AssertionError>().having((e) => e.message, 'message',
159+
'Invalid characters in table name: #assets'));
160+
}
161+
});
162+
163+
test('Validation runs on update', () async {
164+
final schema = Schema([
165+
Table('works', [
166+
Column.text('name'),
167+
]),
168+
]);
169+
170+
final powersync =
171+
await testUtils.setupPowerSync(path: path, schema: schema);
172+
173+
final schema2 = Schema([
174+
Table('#notworking', [
175+
Column.text('created_at'),
176+
]),
177+
]);
178+
179+
try {
180+
powersync.updateSchema(schema2);
181+
} catch (e) {
182+
expect(
183+
e,
184+
isA<AssertionError>().having((e) => e.message, 'message',
185+
'Invalid characters in table name: #notworking'));
186+
}
187+
});
188+
});
189+
190+
group('Table', () {
191+
test('Create a synced table', () {
192+
final table = Table('users', [
193+
Column('name', ColumnType.text),
194+
Column('age', ColumnType.integer),
195+
]);
196+
197+
expect(table.name, equals('users'));
198+
expect(table.columns.length, equals(2));
199+
expect(table.localOnly, isFalse);
200+
expect(table.insertOnly, isFalse);
201+
expect(table.internalName, equals('ps_data__users'));
202+
expect(table.viewName, equals('users'));
203+
});
204+
205+
test('Create a local-only table', () {
206+
final table = Table.localOnly(
207+
'local_users',
208+
[
209+
Column('name', ColumnType.text),
210+
],
211+
viewName: 'local_user_view');
212+
213+
expect(table.name, equals('local_users'));
214+
expect(table.localOnly, isTrue);
215+
expect(table.insertOnly, isFalse);
216+
expect(table.internalName, equals('ps_data_local__local_users'));
217+
expect(table.viewName, equals('local_user_view'));
218+
});
219+
220+
test('Create an insert-only table', () {
221+
final table = Table.insertOnly('logs', [
222+
Column('message', ColumnType.text),
223+
Column('timestamp', ColumnType.integer),
224+
]);
225+
226+
expect(table.name, equals('logs'));
227+
expect(table.localOnly, isFalse);
228+
expect(table.insertOnly, isTrue);
229+
expect(table.internalName, equals('ps_data__logs'));
230+
expect(table.indexes, isEmpty);
231+
});
232+
233+
test('Access column by name', () {
234+
final table = Table('products', [
235+
Column('name', ColumnType.text),
236+
Column('price', ColumnType.real),
237+
]);
238+
239+
expect(table['name'].type, equals(ColumnType.text));
240+
expect(table['price'].type, equals(ColumnType.real));
241+
expect(() => table['nonexistent'], throwsStateError);
242+
});
243+
244+
test('Validate table name', () {
245+
final invalidTableName =
246+
Table('#invalid_table_name', [Column('name', ColumnType.text)]);
247+
248+
expect(
249+
() => invalidTableName.validate(),
250+
throwsA(
251+
isA<AssertionError>().having(
252+
(e) => e.message,
253+
'message',
254+
'Invalid characters in table name: #invalid_table_name',
255+
),
256+
),
257+
);
258+
});
259+
260+
test('Validate view name', () {
261+
final invalidTableName = Table(
262+
'valid_table_name', [Column('name', ColumnType.text)],
263+
viewName: '#invalid_view_name');
264+
265+
expect(
266+
() => invalidTableName.validate(),
267+
throwsA(
268+
isA<AssertionError>().having(
269+
(e) => e.message,
270+
'message',
271+
'Invalid characters in view name: #invalid_view_name',
272+
),
273+
),
274+
);
275+
});
276+
277+
test('Validate table definition', () {
278+
final validTable = Table('valid_table', [
279+
Column('name', ColumnType.text),
280+
Column('age', ColumnType.integer),
281+
]);
282+
283+
expect(() => validTable.validate(), returnsNormally);
284+
});
285+
286+
test('Table with id column', () {
287+
final invalidTable = Table('invalid_table', [
288+
Column('id', ColumnType.integer), // Duplicate 'id' column
289+
Column('name', ColumnType.text),
290+
]);
291+
292+
expect(
293+
() => invalidTable.validate(),
294+
throwsA(
295+
isA<AssertionError>().having(
296+
(e) => e.message,
297+
'message',
298+
'invalid_table: id column is automatically added, custom id columns are not supported',
299+
),
300+
),
301+
);
302+
});
303+
304+
test('Table with too many columns', () {
305+
final List<Column> manyColumns = List.generate(
306+
64, // Exceeds MAX_NUMBER_OF_COLUMNS
307+
(index) => Column('col$index', ColumnType.text),
308+
);
309+
310+
final tableTooManyColumns = Table('too_many_columns', manyColumns);
311+
312+
expect(
313+
() => tableTooManyColumns.validate(),
314+
throwsA(
315+
isA<AssertionError>().having(
316+
(e) => e.message,
317+
'message',
318+
'Table too_many_columns has more than 63 columns, which is not supported',
319+
),
320+
),
321+
);
322+
});
323+
324+
test('toJson method', () {
325+
final table = Table('users', [
326+
Column('name', ColumnType.text),
327+
Column('age', ColumnType.integer),
328+
], indexes: [
329+
Index('name_index', [IndexedColumn('name')])
330+
]);
331+
332+
final json = table.toJson();
333+
334+
expect(json['name'], equals('users'));
335+
expect(json['view_name'], isNull);
336+
expect(json['local_only'], isFalse);
337+
expect(json['insert_only'], isFalse);
338+
expect(json['columns'].length, equals(2));
339+
expect(json['indexes'].length, equals(1));
340+
});
145341
});
146342
}

0 commit comments

Comments
 (0)