Support PowerSync extension when analyzing SQL

This commit is contained in:
Simon Binder
2025-10-07 18:07:19 +02:00
parent f01899e049
commit 5724986a9e
11 changed files with 266 additions and 3 deletions
+2
View File
@@ -194,6 +194,8 @@ We currently support the following extensions:
- `spellfix1`: Assumes that the [spellfix1](https://www.sqlite.org/spellfix1.html)
module is available. Note that this is not the case for most sqlite3 builds,
including the ones shipping with `sqlite3_flutter_libs`.
- `powersync`: Assumes that the PowerSync SQLite extension is available, allowing references to e.g.
`powersync_crud` in `CREATE TABLE` statements.
### Known custom functions
@@ -86,6 +86,8 @@ class DriftAnalysisDriver {
if (options.hasModule(SqlModule.spellfix1))
const Spellfix1Extension(),
if (options.hasModule(SqlModule.geopoly)) const GeopolyExtension(),
if (options.hasModule(SqlModule.powersync))
const PowerSyncSqliteExtension(),
],
version: options.sqliteVersion,
),
+4
View File
@@ -451,6 +451,10 @@ enum SqlModule {
/// Enables the dbstat table providing insights into the disk state occupied
/// by certain tables.
dbstat,
/// Enables static analysis support for PowerSync, providing the
/// `powersync_crud` virtual table.
powersync,
}
/// The possible values for the case of the table and column names.
@@ -211,6 +211,7 @@ const _$SqlModuleEnumMap = {
SqlModule.spellfix1: 'spellfix1',
SqlModule.geopoly: 'geopoly',
SqlModule.dbstat: 'dbstat',
SqlModule.powersync: 'powersync',
};
const _$CaseFromDartToSqlEnumMap = {
+1 -1
View File
@@ -37,7 +37,7 @@ dependencies:
# Drift-specific analysis and apis
drift: ">=2.28.0 <2.29.0"
sqlite3: ^2.4.6
sqlparser: ^0.41.2
sqlparser: ^0.42.0-0
# Dart analysis
# Note: When changing this to a lower bound of 8.x, please re-enable
@@ -0,0 +1,58 @@
import 'package:drift_dev/src/analysis/options.dart';
import 'package:test/test.dart';
import '../../test_utils.dart';
void main() {
test('can define triggers referencing powersync vtab', () async {
final state = await TestBackend.inTest({
'a|lib/main.drift': '''
CREATE TABLE todo_lists (
id TEXT NOT NULL PRIMARY KEY,
created_by TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT
) STRICT;
CREATE TRIGGER todo_lists_insert
AFTER INSERT ON todo_lists
FOR EACH ROW
BEGIN
INSERT INTO powersync_crud (op, id, type, data) VALUES ('PUT', NEW.id, 'todo_lists', json_object(
'created_by', NEW.created_by,
'title', NEW.title,
'content', NEW.content
));
END;
CREATE TRIGGER todo_lists_update
AFTER UPDATE ON todo_lists
FOR EACH ROW
BEGIN
SELECT CASE
WHEN (OLD.id != NEW.id)
THEN RAISE (FAIL, 'Cannot update id')
END;
-- TODO: You may want to replace the json_object with a powersync_diff call of the old and new values, or
-- use your own diff logic to avoid marking unchanged columns as updated.
INSERT INTO powersync_crud (op, id, type, data) VALUES ('PATCH', NEW.id, 'todo_lists', json_object(
'created_by', NEW.created_by,
'title', NEW.title,
'content', NEW.content
));
END;
CREATE TRIGGER todo_lists_delete
AFTER DELETE ON todo_lists
FOR EACH ROW
BEGIN
INSERT INTO powersync_crud (op, id, type) VALUES ('DELETE', OLD.id, 'todo_lists');
END;
'''
}, options: DriftOptions.defaults(modules: [SqlModule.powersync]));
await state.analyze('package:a/main.drift');
state.expectNoErrors();
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
## 0.41.3-dev
## 0.42.0-dev
- Fix unresolved references in CTEs resolving against the scope of the main query.
- Static analysis support for the PowerSync SQLite extension.
## 0.41.2
+1
View File
@@ -10,6 +10,7 @@ export 'src/engine/module/fts5.dart' show Fts5Extension, Fts5Table;
export 'src/engine/module/geopoly.dart' show GeopolyExtension;
export 'src/engine/module/json1.dart' show Json1Extension;
export 'src/engine/module/math.dart' show BuiltInMathExtension;
export 'src/engine/module/powersync.dart' show PowerSyncSqliteExtension;
export 'src/engine/module/rtree.dart' show RTreeExtension;
export 'src/engine/module/spellfix1.dart' show Spellfix1Extension;
export 'src/engine/module/module.dart';
@@ -0,0 +1,95 @@
import 'package:sqlparser/src/analysis/types/types.dart';
import 'package:sqlparser/src/ast/ast.dart';
import 'package:sqlparser/src/engine/sql_engine.dart';
import '../../analysis/analysis.dart';
import 'module.dart';
/// Provides static analysis support for the [PowerSync SQLite core extension].
///
/// This provides the `powersync_crud` eponymous virtual table to store local
/// mutation as well as some JSON / UUID helper functions.
///
/// [PowerSync SQLite core extension]: https://github.com/powersync-ja/powersync-sqlite-core/
final class PowerSyncSqliteExtension implements Extension {
const PowerSyncSqliteExtension();
@override
void register(SqlEngine engine) {
engine
..registerTable(_powersyncCrudLegacy)
..registerTable(_powersyncCrud)
..registerFunctionHandler(const _PowerSyncFunctionHandler());
}
}
final class _PowerSyncFunctionHandler implements FunctionHandler {
const _PowerSyncFunctionHandler();
@override
Set<String> get functionNames => {
'powersync_diff', // (TEXT, TEXT) -> TEXT
'powersync_client_id', // () -> TEXT
'powersync_in_sync_operation', // () -> BOOLEAN
'gen_random_uuid', // () -> TEXT
'uuid', // () -> TEXT
};
@override
ResolveResult inferArgumentType(
TypeInferenceSession session, SqlInvocation call, Expression argument) {
switch (call.name.toLowerCase()) {
case 'powersync_diff':
return ResolveResult(_text);
default:
return ResolveResult.unknown();
}
}
@override
ResolveResult inferReturnType(TypeInferenceSession session,
SqlInvocation call, List<Typeable> expandedArgs) {
switch (call.name.toLowerCase()) {
case 'powersync_diff':
case 'powersync_client_id':
case 'gen_random_uuid':
case 'uuid':
return const ResolveResult(_text);
case 'powersync_in_sync_operation':
return const ResolveResult(ResolvedType.bool());
default:
return ResolveResult.unknown();
}
}
@override
void reportErrors(SqlInvocation call, AnalysisContext context) {}
}
/// `CREATE TABLE powersync_crud_(data TEXT, options INT HIDDEN);`
///
/// Source: https://github.com/powersync-ja/powersync-sqlite-core/blob/637fda0f1d84c46964736a4f8c59fab5ae27e304/crates/core/src/crud_vtab.rs#L26
Table get _powersyncCrudLegacy {
return Table(name: 'powersync_crud', isVirtual: true, resolvedColumns: [
TableColumn('data', _text),
TableColumn('options', _int, isHidden: true),
]);
}
/// `CREATE TABLE powersync_crud(op TEXT, id TEXT, type TEXT, data TEXT, old_values TEXT, metadata TEXT, options INT HIDDEN);`
///
/// Source: https://github.com/powersync-ja/powersync-sqlite-core/blob/637fda0f1d84c46964736a4f8c59fab5ae27e304/crates/core/src/crud_vtab.rs#L27C6-L27C126
Table get _powersyncCrud {
return Table(name: 'powersync_crud', isVirtual: true, resolvedColumns: [
TableColumn('op', _text),
TableColumn('id', _text),
TableColumn('type', _text),
TableColumn('data', _text),
TableColumn('old_values', _text),
TableColumn('metadata', _text),
TableColumn('options', _int, isHidden: true),
]);
}
const _text = ResolvedType(type: BasicType.text);
const _int = ResolvedType(type: BasicType.int);
+1 -1
View File
@@ -1,6 +1,6 @@
name: sqlparser
description: Parses sqlite statements and performs static analysis on them
version: 0.41.3-dev
version: 0.42.0-dev
homepage: https://github.com/simolus3/drift/tree/develop/sqlparser
repository: https://github.com/simolus3/drift
#homepage: https://drift.simonbinder.eu/
@@ -0,0 +1,99 @@
import 'package:sqlparser/sqlparser.dart';
import 'package:test/test.dart';
import '../../analysis/data.dart';
import '../../analysis/errors/utils.dart';
void main() {
final options = EngineOptions(
version: SqliteVersion.current,
enabledExtensions: [const PowerSyncSqliteExtension()]);
late SqlEngine engine;
setUp(() {
engine = SqlEngine(options)..registerTableFromSql('''
CREATE TABLE todo_lists (
id TEXT NOT NULL PRIMARY KEY,
created_by TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT
) STRICT;
''');
});
group('crud vtab via triggers', () {
// This captures suggestions from https://docs.powersync.com/usage/use-case-examples/raw-tables#capture-local-writes-with-triggers
test('insert', () {
final result = engine.analyze('''
CREATE TRIGGER todo_lists_insert
AFTER INSERT ON todo_lists
FOR EACH ROW
BEGIN
INSERT INTO powersync_crud (op, id, type, data) VALUES ('PUT', NEW.id, 'todo_lists', json_object(
'created_by', NEW.created_by,
'title', NEW.title,
'content', NEW.content
));
END;
''');
result.expectNoError();
});
test('update', () {
final result = engine.analyze('''
CREATE TRIGGER todo_lists_update
AFTER UPDATE ON todo_lists
FOR EACH ROW
BEGIN
SELECT CASE
WHEN (OLD.id != NEW.id)
THEN RAISE (FAIL, 'Cannot update id')
END;
-- TODO: You may want to replace the json_object with a powersync_diff call of the old and new values, or
-- use your own diff logic to avoid marking unchanged columns as updated.
INSERT INTO powersync_crud (op, id, type, data) VALUES ('PATCH', NEW.id, 'todo_lists', json_object(
'created_by', NEW.created_by,
'title', NEW.title,
'content', NEW.content
));
END;
''');
result.expectNoError();
});
test('delete', () {
final result = engine.analyze('''
CREATE TRIGGER todo_lists_delete
AFTER DELETE ON todo_lists
FOR EACH ROW
BEGIN
INSERT INTO powersync_crud (op, id, type) VALUES ('DELETE', OLD.id, 'todo_lists');
END;
''');
result.expectNoError();
});
});
test('infers function return type', () {
final functions = {
"powersync_diff('{}', '{}')": BasicType.text,
'powersync_client_id()': BasicType.text,
'powersync_in_sync_operation()': BasicType.int,
'gen_random_uuid()': BasicType.text,
'uuid()': BasicType.text,
};
functions.forEach((call, type) {
final analyzed = engine.analyze('SELECT $call;');
analyzed.expectNoError();
final [column] = (analyzed.root as SelectStatement).resolvedColumns!;
expect(analyzed.typeOf(column).type?.type, type,
reason: '$call should resolve to $type');
});
});
}