Merge branch 'develop' into web-v2

This commit is contained in:
Simon Binder 2023-06-04 22:10:44 +02:00
commit 9f2405be26
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
54 changed files with 972 additions and 306 deletions

View File

@ -1,3 +1,9 @@
## 2.8.1
- Performance improvement: Cache and re-use prepared statements - thanks to [@davidmartos96](https://github.com/davidmartos96/)
- Fix a deadlock after rolling back a transaction in a remote isolate.
- Remove unintended log messages when using `connectToDriftWorker`.
## 2.8.0
- Don't keep databases in an unusable state if the `setup` callback throws an

View File

@ -313,6 +313,11 @@ extension ComputeWithDriftIsolate<DB extends DatabaseConnectionUser> on DB {
/// [computeWithDatabase] is beneficial when an an expensive work unit needs
/// to use the database, or when creating the SQL statements itself is
/// expensive.
/// In particular, note that [computeWithDatabase] does not create a second
/// database connection to sqlite3 - the current one is re-used. So if you're
/// using a synchronous database connection, using this method is unlikely to
/// take significant loads off the main isolate. For that reason, the use of
/// `NativeDatabase.createInBackground` is encouraged.
@experimental
Future<Ret> computeWithDatabase<Ret>({
required FutureOr<Ret> Function(DB) computation,

View File

@ -48,9 +48,19 @@ class NativeDatabase extends DelegatedDatabase {
/// add custom user-defined sql functions or to provide encryption keys in
/// SQLCipher implementations.
/// {@endtemplate}
factory NativeDatabase(File file,
{bool logStatements = false, DatabaseSetup? setup}) {
return NativeDatabase._(_NativeDelegate(file, setup), logStatements);
factory NativeDatabase(
File file, {
bool logStatements = false,
DatabaseSetup? setup,
bool cachePreparedStatements = true,
}) {
return NativeDatabase._(
_NativeDelegate(
file,
setup,
cachePreparedStatements,
),
logStatements);
}
/// Creates a database storing its result in [file].
@ -102,9 +112,15 @@ class NativeDatabase extends DelegatedDatabase {
/// Creates an in-memory database won't persist its changes on disk.
///
/// {@macro drift_vm_database_factory}
factory NativeDatabase.memory(
{bool logStatements = false, DatabaseSetup? setup}) {
return NativeDatabase._(_NativeDelegate(null, setup), logStatements);
factory NativeDatabase.memory({
bool logStatements = false,
DatabaseSetup? setup,
bool cachePreparedStatements = true,
}) {
return NativeDatabase._(
_NativeDelegate(null, setup, cachePreparedStatements),
logStatements,
);
}
/// Creates a drift executor for an opened [database] from the `sqlite3`
@ -119,12 +135,20 @@ class NativeDatabase extends DelegatedDatabase {
/// internally when running [integration tests for migrations](https://drift.simonbinder.eu/docs/advanced-features/migrations/#verifying-migrations).
///
/// {@macro drift_vm_database_factory}
factory NativeDatabase.opened(Database database,
{bool logStatements = false,
DatabaseSetup? setup,
bool closeUnderlyingOnClose = true}) {
factory NativeDatabase.opened(
Database database, {
bool logStatements = false,
DatabaseSetup? setup,
bool closeUnderlyingOnClose = true,
bool cachePreparedStatements = true,
}) {
return NativeDatabase._(
_NativeDelegate.opened(database, setup, closeUnderlyingOnClose),
_NativeDelegate.opened(
database,
setup,
closeUnderlyingOnClose,
cachePreparedStatements,
),
logStatements);
}
@ -181,12 +205,24 @@ class NativeDatabase extends DelegatedDatabase {
class _NativeDelegate extends Sqlite3Delegate<Database> {
final File? file;
_NativeDelegate(this.file, DatabaseSetup? setup) : super(setup);
_NativeDelegate(this.file, DatabaseSetup? setup, bool cachePreparedStatements)
: super(
setup,
cachePreparedStatements: cachePreparedStatements,
);
_NativeDelegate.opened(
Database db, DatabaseSetup? setup, bool closeUnderlyingWhenClosed)
: file = null,
super.opened(db, setup, closeUnderlyingWhenClosed);
Database db,
DatabaseSetup? setup,
bool closeUnderlyingWhenClosed,
bool cachePreparedStatements,
) : file = null,
super.opened(
db,
setup,
closeUnderlyingWhenClosed,
cachePreparedStatements: cachePreparedStatements,
);
@override
Database openDatabase() {
@ -242,6 +278,8 @@ class _NativeDelegate extends Sqlite3Delegate<Database> {
@override
Future<void> close() async {
await super.close();
if (closeUnderlyingWhenClosed) {
try {
tracker.markClosed(database);

View File

@ -209,19 +209,24 @@ class ServerImplementation implements DriftServer {
);
}
try {
switch (action) {
case TransactionControl.commit:
await executor.send();
break;
case TransactionControl.rollback:
switch (action) {
case TransactionControl.commit:
await executor.send();
// The transaction should only be released if the commit doesn't throw.
_releaseExecutor(executorId!);
break;
case TransactionControl.rollback:
// Rollbacks shouldn't fail. Other parts of drift assume the transaction
// to be over after a rollback either way, so we always release the
// executor in this case.
try {
await executor.rollback();
break;
default:
assert(false, 'Unknown TransactionControl');
}
} finally {
_releaseExecutor(executorId!);
} finally {
_releaseExecutor(executorId!);
}
break;
default:
assert(false, 'Unknown TransactionControl');
}
}

View File

@ -343,12 +343,17 @@ abstract class DatabaseConnectionUser {
return result;
}
/// Creates a custom select statement from the given sql [query]. To run the
/// query once, use [Selectable.get]. For an auto-updating streams, set the
/// set of tables the ready [readsFrom] and use [Selectable.watch]. If you
/// know the query will never emit more than one row, you can also use
/// `getSingle` and `SelectableUtils.watchSingle` which return the item
/// directly without wrapping it into a list.
/// Creates a custom select statement from the given sql [query].
///
/// The query can be run once by calling [Selectable.get].
///
/// For an auto-updating query stream, the [readsFrom] parameter needs to be
/// set to the tables the SQL statement reads from - drift can't infer it
/// automatically like for other queries constructed with its Dart API.
/// When, [Selectable.watch] can be used to construct an updating stream.
///
/// For queries that are known to only return a single row,
/// [Selectable.getSingle] and [Selectable.watchSingle] can be used as well.
///
/// If you use variables in your query (for instance with "?"), they will be
/// bound to the [variables] you specify on this query.

View File

@ -80,8 +80,6 @@ class StreamQueryStore {
final StreamController<Set<TableUpdate>> _tableUpdates =
StreamController.broadcast(sync: true);
StreamQueryStore();
/// Creates a new stream from the select statement.
Stream<List<Map<String, Object?>>> registerStream(
QueryStreamFetcher fetcher) {

View File

@ -65,7 +65,10 @@ class Variable<T extends Object> extends Expression<T> {
@override
void writeInto(GenerationContext context) {
if (!context.supportsVariables) {
if (!context.supportsVariables ||
// Workaround for https://github.com/simolus3/drift/issues/2441
// Binding nulls on postgres is currently untyped which causes issues.
(value == null && context.dialect == SqlDialect.postgres)) {
// Write as constant instead.
Constant<T>(value).writeInto(context);
return;

View File

@ -15,6 +15,7 @@ import 'package:drift/src/runtime/exceptions.dart';
import 'package:drift/src/runtime/executor/stream_queries.dart';
import 'package:drift/src/runtime/types/converters.dart';
import 'package:drift/src/runtime/types/mapping.dart';
import 'package:drift/src/utils/async_map.dart';
import 'package:drift/src/utils/single_transformer.dart';
import 'package:meta/meta.dart';

View File

@ -267,7 +267,7 @@ abstract mixin class Selectable<T>
/// Maps this selectable by the [mapper] function.
///
/// Like [map] just async.
Selectable<N> asyncMap<N>(Future<N> Function(T) mapper) {
Selectable<N> asyncMap<N>(FutureOr<N> Function(T) mapper) {
return _AsyncMappedSelectable<T, N>(this, mapper);
}
}
@ -293,7 +293,7 @@ class _MappedSelectable<S, T> extends Selectable<T> {
class _AsyncMappedSelectable<S, T> extends Selectable<T> {
final Selectable<S> _source;
final Future<T> Function(S) _mapper;
final FutureOr<T> Function(S) _mapper;
_AsyncMappedSelectable(this._source, this._mapper);
@ -304,11 +304,13 @@ class _AsyncMappedSelectable<S, T> extends Selectable<T> {
@override
Stream<List<T>> watch() {
return _source.watch().asyncMap(_mapResults);
return AsyncMapPerSubscription(_source.watch())
.asyncMapPerSubscription(_mapResults);
}
Future<List<T>> _mapResults(List<S> results) async =>
[for (final result in results) await _mapper(result)];
Future<List<T>> _mapResults(List<S> results) async {
return [for (final result in results) await _mapper(result)];
}
}
/// Mixin for a [Query] that operates on a single primary table only.

View File

@ -73,7 +73,7 @@ class SimpleSelectStatement<T extends HasResultSet, D> extends Query<T, D>
key: StreamKey(query.sql, query.boundVariables),
);
return database.createStream(fetcher).asyncMap(_mapResponse);
return database.createStream(fetcher).asyncMapPerSubscription(_mapResponse);
}
Future<List<Map<String, Object?>>> _getRaw(GenerationContext ctx) {

View File

@ -233,7 +233,7 @@ class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
return database
.createStream(fetcher)
.asyncMap((rows) => _mapResponse(ctx, rows));
.asyncMapPerSubscription((rows) => _mapResponse(ctx, rows));
}
@override

View File

@ -1,5 +1,6 @@
@internal
import 'dart:async';
import 'dart:collection';
import 'package:meta/meta.dart';
import 'package:sqlite3/common.dart';
@ -24,6 +25,9 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
final void Function(DB)? _setup;
/// Whether prepared statements should be cached.
final bool cachePreparedStatements;
/// Whether the [database] should be closed when [close] is called on this
/// instance.
///
@ -31,13 +35,22 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
/// connections to the same database.
final bool closeUnderlyingWhenClosed;
final PreparedStatementsCache _preparedStmtsCache = PreparedStatementsCache();
/// A delegate that will call [openDatabase] to open the database.
Sqlite3Delegate(this._setup) : closeUnderlyingWhenClosed = true;
Sqlite3Delegate(
this._setup, {
required this.cachePreparedStatements,
}) : closeUnderlyingWhenClosed = true;
/// A delegate using an underlying sqlite3 database object that has already
/// been opened.
Sqlite3Delegate.opened(
this._database, this._setup, this.closeUnderlyingWhenClosed) {
this._database,
this._setup,
this.closeUnderlyingWhenClosed, {
required this.cachePreparedStatements,
}) {
_initializeDatabase();
}
@ -68,6 +81,10 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
_database?.dispose();
_database = null;
// We can call clear instead of disposeAll because disposing the
// database will also dispose all prepared statements on it.
_preparedStmtsCache.clear();
rethrow;
}
}
@ -85,6 +102,12 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
_hasInitializedDatabase = true;
}
@override
@mustCallSuper
Future<void> close() {
return Future(_preparedStmtsCache.disposeAll);
}
/// Synchronously prepares and runs [statements] collected from a batch.
@protected
void runBatchSync(BatchedStatements statements) {
@ -114,23 +137,32 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
if (args.isEmpty) {
database.execute(statement);
} else {
final stmt = database.prepare(statement, checkNoTail: true);
try {
stmt.execute(args);
} finally {
stmt.dispose();
}
final stmt = _getPreparedStatement(statement);
stmt.execute(args);
}
}
@override
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
final stmt = database.prepare(statement, checkNoTail: true);
try {
final result = stmt.select(args);
return QueryResult.fromRows(result.toList());
} finally {
stmt.dispose();
final stmt = _getPreparedStatement(statement);
final result = stmt.select(args);
return QueryResult.fromRows(result.toList());
}
CommonPreparedStatement _getPreparedStatement(String sql) {
if (cachePreparedStatements) {
final cachedStmt = _preparedStmtsCache.use(sql);
if (cachedStmt != null) {
return cachedStmt;
}
final stmt = database.prepare(sql, checkNoTail: true);
_preparedStmtsCache.addNew(sql, stmt);
return stmt;
} else {
final stmt = database.prepare(sql, checkNoTail: true);
return stmt;
}
}
}
@ -149,3 +181,62 @@ class _SqliteVersionDelegate extends DynamicVersionDelegate {
return Future.value();
}
}
/// A cache of prepared statements to avoid having to parse SQL statements
/// multiple time when they're used frequently.
@internal
class PreparedStatementsCache {
/// The maximum amount of cached statements.
final int maxSize;
// The linked map returns entries in the order in which they have been
// inserted (with the first insertion being reported first).
// So, we treat it as a LRU cache with `entries.last` being the MRU and
// `entries.last` being the LRU element.
final LinkedHashMap<String, CommonPreparedStatement> _cachedStatements =
LinkedHashMap();
/// Create a cache of prepared statements with a capacity of [maxSize].
PreparedStatementsCache({this.maxSize = 64});
/// Attempts to look up the cached [sql] statement, if it exists.
///
/// If the statement exists, it is marked as most recently used as well.
CommonPreparedStatement? use(String sql) {
// Remove and add the statement if it was found to move it to the end,
// which marks it as the MRU element.
final foundStatement = _cachedStatements.remove(sql);
if (foundStatement != null) {
_cachedStatements[sql] = foundStatement;
}
return foundStatement;
}
/// Caches a statement that has not been cached yet for subsequent uses.
void addNew(String sql, CommonPreparedStatement statement) {
assert(!_cachedStatements.containsKey(sql));
if (_cachedStatements.length == maxSize) {
final lru = _cachedStatements.remove(_cachedStatements.keys.first)!;
lru.dispose();
}
_cachedStatements[sql] = statement;
}
/// Removes all cached statements.
void disposeAll() {
for (final statement in _cachedStatements.values) {
statement.dispose();
}
_cachedStatements.clear();
}
/// Forgets cached statements without explicitly disposing them.
void clear() {
_cachedStatements.clear();
}
}

View File

@ -0,0 +1,45 @@
import 'dart:async';
/// Extension to make the drift-specific version of [asyncMap] available.
extension AsyncMapPerSubscription<S> on Stream<S> {
/// A variant of [Stream.asyncMap] that forwards each subscription of the
/// returned stream to the source (`this`).
///
/// The `asyncMap` implementation from the SDK uses a broadcast controller
/// when given an input stream that [Stream.isBroadcast]. As broadcast
/// controllers only call `onListen` once, these subscriptions aren't
/// forwarded to the original stream.
///
/// Drift query streams send the current snapshot to each attaching listener,
/// a behavior that is lost when wrapping these streams in a broadcast stream
/// controller. Since we need the behavior of `asyncMap` internally though, we
/// re-implement it in a simple variant that transforms each subscription
/// individually.
Stream<T> asyncMapPerSubscription<T>(Future<T> Function(S) mapper) {
return Stream.multi(
(listener) {
late StreamSubscription<S> subscription;
void onData(S original) {
subscription.pause();
mapper(original)
.then(listener.addSync, onError: listener.addErrorSync)
.whenComplete(subscription.resume);
}
subscription = listen(
onData,
onError: listener.addErrorSync,
onDone: listener.closeSync,
cancelOnError: false, // Determined by downstream subscription
);
listener
..onPause = subscription.pause
..onResume = subscription.resume
..onCancel = subscription.cancel;
},
isBroadcast: isBroadcast,
);
}
}

View File

@ -56,9 +56,12 @@ class WasmDatabase extends DelegatedDatabase {
WasmDatabaseSetup? setup,
IndexedDbFileSystem? fileSystem,
bool logStatements = false,
bool cachePreparedStatements = true,
}) {
return WasmDatabase._(
_WasmDelegate(sqlite3, path, setup, fileSystem), logStatements);
_WasmDelegate(sqlite3, path, setup, fileSystem, cachePreparedStatements),
logStatements,
);
}
/// Creates an in-memory database in the loaded [sqlite3] database.
@ -66,9 +69,12 @@ class WasmDatabase extends DelegatedDatabase {
CommonSqlite3 sqlite3, {
WasmDatabaseSetup? setup,
bool logStatements = false,
bool cachePreparedStatements = true,
}) {
return WasmDatabase._(
_WasmDelegate(sqlite3, null, setup, null), logStatements);
_WasmDelegate(sqlite3, null, setup, null, cachePreparedStatements),
logStatements,
);
}
/// For an in-depth
@ -110,8 +116,15 @@ class _WasmDelegate extends Sqlite3Delegate<CommonDatabase> {
final IndexedDbFileSystem? _fileSystem;
_WasmDelegate(
this._sqlite3, this._path, WasmDatabaseSetup? setup, this._fileSystem)
: super(setup);
this._sqlite3,
this._path,
WasmDatabaseSetup? setup,
this._fileSystem,
bool cachePreparedStatements,
) : super(
setup,
cachePreparedStatements: cachePreparedStatements,
);
@override
CommonDatabase openDatabase() {
@ -163,6 +176,8 @@ class _WasmDelegate extends Sqlite3Delegate<CommonDatabase> {
@override
Future<void> close() async {
await super.close();
if (closeUnderlyingWhenClosed) {
database.dispose();
await _flush();

View File

@ -1,6 +1,6 @@
name: drift
description: Drift is a reactive library to store relational data in Dart and Flutter applications.
version: 2.8.0
version: 2.8.1
repository: https://github.com/simolus3/drift
homepage: https://drift.simonbinder.eu/
issue_tracker: https://github.com/simolus3/drift/issues

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:async/async.dart';
import 'package:drift/drift.dart';
import 'package:drift/src/runtime/api/runtime_api.dart';
import 'package:drift/src/runtime/executor/stream_queries.dart';
@ -177,13 +178,13 @@ void main() {
await first.first; // will listen to stream, then cancel
await pumpEventQueue(times: 1); // give cancel event time to propagate
final checkEmits =
expectLater(second, emitsInOrder([<Object?>[], <Object?>[]]));
final listener = StreamQueue(second);
await expectLater(listener, emits(isEmpty));
db.markTablesUpdated({db.users});
await pumpEventQueue(times: 1);
await expectLater(listener, emits(isEmpty));
await checkEmits;
await listener.cancel();
});
test('same stream instance can be listened to multiple times', () async {

View File

@ -216,7 +216,7 @@ class TodoDb extends _$TodoDb {
DriftDatabaseOptions options = const DriftDatabaseOptions();
@override
int get schemaVersion => 1;
int schemaVersion = 1;
}
@DriftAccessor(

View File

@ -1846,9 +1846,9 @@ class AllTodosWithCategoryResult extends CustomResultSet {
mixin _$SomeDaoMixin on DatabaseAccessor<TodoDb> {
$UsersTable get users => attachedDatabase.users;
$SharedTodosTable get sharedTodos => attachedDatabase.sharedTodos;
$CategoriesTable get categories => attachedDatabase.categories;
$TodosTableTable get todosTable => attachedDatabase.todosTable;
$SharedTodosTable get sharedTodos => attachedDatabase.sharedTodos;
$TodoWithCategoryViewView get todoWithCategoryView =>
attachedDatabase.todoWithCategoryView;
Selectable<TodoEntry> todosForUser({required int user}) {

View File

@ -420,6 +420,47 @@ void main() {
expect(db.validateDatabaseSchema(), completes);
});
});
test('custom schema upgrades', () async {
// I promised this would work in https://github.com/simolus3/drift/discussions/2436,
// so we better make sure this keeps working.
final underlying = sqlite3.openInMemory()
..execute('pragma user_version = 1;');
addTearDown(underlying.dispose);
const maxSchema = 10;
final expectedException = Exception('schema upgrade!');
for (var currentSchema = 1; currentSchema < maxSchema; currentSchema++) {
final db = TodoDb(NativeDatabase.opened(underlying));
db.schemaVersion = maxSchema;
db.migration = MigrationStrategy(
onUpgrade: expectAsync3((m, from, to) async {
// This upgrade callback does one step and then throws. Opening the
// database multiple times should run the individual migrations.
expect(from, currentSchema);
expect(to, maxSchema);
await db.customStatement('CREATE TABLE t$from (id INTEGER);');
await db.customStatement('pragma user_version = ${from + 1}');
if (from != to - 1) {
// Simulate a failed upgrade
throw expectedException;
}
}),
);
if (currentSchema != maxSchema - 1) {
// Opening the database should throw this exception
await expectLater(
db.customSelect('SELECT 1').get(), throwsA(expectedException));
} else {
// The last migration should work
await expectLater(db.customSelect('SELECT 1').get(), completes);
}
}
});
}
class _TestDatabase extends GeneratedDatabase {

View File

@ -1,5 +1,7 @@
@TestOn('vm')
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:test/test.dart';
@ -8,11 +10,12 @@ import '../test_utils/database_vm.dart';
void main() {
preferLocalSqlite3();
test('a failing commit does not block the whole database', () async {
final db = _Database(NativeDatabase.memory());
addTearDown(db.close);
group('a failing commit does not block the whole database', () {
Future<void> testWith(QueryExecutor executor) async {
final db = _Database(executor);
addTearDown(db.close);
await db.customStatement('''
await db.customStatement('''
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL, content TEXT NULL,
@ -22,27 +25,38 @@ CREATE TABLE IF NOT EXISTS todo_items (
GENERATED ALWAYS AS (title || ' (' || content || ')') VIRTUAL
);
''');
await db.customStatement('''
await db.customStatement('''
CREATE TABLE IF NOT EXISTS todo_categories (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
''');
await db.customStatement('PRAGMA foreign_keys = ON;');
await db.customStatement('PRAGMA foreign_keys = ON;');
await expectLater(
db.transaction(() async {
// Thanks to the deferrable clause, this statement will only cause a
// failing COMMIT.
await db.customStatement(
'INSERT INTO todo_items (title, category_id) VALUES (?, ?);',
['a', 100]);
}),
throwsA(isA<SqliteException>()),
);
await expectLater(
db.transaction(() async {
// Thanks to the deferrable clause, this statement will only cause a
// failing COMMIT.
await db.customStatement(
'INSERT INTO todo_items (title, category_id) VALUES (?, ?);',
['a', 100]);
}),
throwsA(anyOf(isA<SqliteException>(), isA<DriftRemoteException>())),
);
expect(
db.customSelect('SELECT * FROM todo_items').get(), completion(isEmpty));
expect(db.customSelect('SELECT * FROM todo_items').get(),
completion(isEmpty));
}
test('sync client', () async {
await testWith(NativeDatabase.memory());
});
test('through isolate', () async {
final isolate = await DriftIsolate.spawn(NativeDatabase.memory);
await testWith(await isolate.connect(singleClientMode: true));
});
});
}

View File

@ -0,0 +1,35 @@
import 'package:drift/native.dart';
import 'package:drift/drift.dart';
import 'package:test/test.dart';
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
for (final suspendBetweenListeners in [true, false]) {
for (final asyncMap in [true, false]) {
test(
'suspendBetweenListeners=$suspendBetweenListeners, asyncMap=$asyncMap',
() async {
final db = TestDb();
final select = db.customSelect('select 1');
final stream = asyncMap
? select.asyncMap(Future.value).watch()
: select.map((row) => row).watch();
final log = <Object>[];
stream.listen(log.add);
if (suspendBetweenListeners) await pumpEventQueue();
stream.listen(log.add);
await pumpEventQueue();
expect(log, hasLength(2));
});
}
}
}
class TestDb extends GeneratedDatabase {
TestDb() : super(NativeDatabase.memory());
@override
final List<TableInfo> allTables = const [];
@override
final int schemaVersion = 1;
}

View File

@ -0,0 +1,47 @@
@TestOn('vm')
import 'package:drift/native.dart';
import 'package:drift/src/sqlite3/database.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:test/test.dart';
import '../../generated/todos.dart';
import '../../test_utils/database_vm.dart';
void main() {
preferLocalSqlite3();
test("lru/mru order and remove callback", () {
final cache = PreparedStatementsCache(maxSize: 3);
final database = sqlite3.openInMemory();
addTearDown(database.dispose);
expect(cache.use('SELECT 1'), isNull);
cache.addNew('SELECT 1', database.prepare('SELECT 1'));
cache.addNew('SELECT 2', database.prepare('SELECT 2'));
cache.addNew('SELECT 3', database.prepare('SELECT 3'));
expect(cache.use('SELECT 3'), isNotNull);
expect(cache.use('SELECT 1'), isNotNull);
// Inserting another statement should remove #2, which is now the LRU
cache.addNew('SELECT 4', database.prepare('SELECT 4'));
expect(cache.use('SELECT 2'), isNull);
expect(cache.use('SELECT 1'), isNotNull);
});
test('returns new columns after recompilation', () async {
// https://github.com/simolus3/drift/issues/2454
final db = TodoDb(NativeDatabase.memory(cachePreparedStatements: true));
await db.customStatement('create table t (c1)');
await db.customInsert('insert into t values (1)');
final before = await db.customSelect('select * from t').getSingle();
expect(before.data, {'c1': 1});
await db.customStatement('alter table t add column c2');
final after = await db.customSelect('select * from t').getSingle();
expect(after.data, {'c1': 1, 'c2': null});
});
}

View File

@ -1,7 +1,9 @@
// Mocks generated by Mockito 5.4.0 from annotations
// Mocks generated by Mockito 5.4.1 from annotations
// in drift/test/test_utils/test_utils.dart.
// Do not manually edit this file.
// @dart=2.19
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4;

View File

@ -1,3 +1,8 @@
## 2.8.3
- Allow Dart-defined tables to reference imported tables through SQL
[#2433](https://github.com/simolus3/drift/issues/2433).
## 2.8.2
- Fix generated to write qualified column references for Dart components in

View File

@ -63,8 +63,12 @@ class DriftAnalysisCache {
yield found;
for (final imported in found.imports ?? const <Uri>[]) {
if (seenUris.add(imported)) {
pending.add(knownFiles[imported]!);
// We might not have a known file for all imports of a Dart file, since
// not all imports are drift-related there.
final knownImport = knownFiles[imported];
if (seenUris.add(imported) && knownImport != null) {
pending.add(knownImport);
}
}
}

View File

@ -56,6 +56,7 @@ class DriftAnalysisDriver {
final DriftBackend backend;
final DriftAnalysisCache cache = DriftAnalysisCache();
final DriftOptions options;
final bool _isTesting;
late final TypeMapping typeMapping = TypeMapping(this);
late final ElementDeserializer deserializer = ElementDeserializer(this);
@ -64,7 +65,8 @@ class DriftAnalysisDriver {
KnownDriftTypes? _knownTypes;
DriftAnalysisDriver(this.backend, this.options);
DriftAnalysisDriver(this.backend, this.options, {bool isTesting = false})
: _isTesting = isTesting;
SqlEngine newSqlEngine() {
return SqlEngine(
@ -155,12 +157,16 @@ class DriftAnalysisDriver {
}
/// Runs the first step (element discovery) on a file with the given [uri].
Future<FileState> prepareFileForAnalysis(Uri uri,
{bool needsDiscovery = true}) async {
Future<FileState> prepareFileForAnalysis(
Uri uri, {
bool needsDiscovery = true,
bool warnIfFileDoesntExist = true,
}) async {
var known = cache.knownFiles[uri] ?? cache.notifyFileChanged(uri);
if (known.discovery == null && needsDiscovery) {
await DiscoverStep(this, known).discover();
await DiscoverStep(this, known)
.discover(warnIfFileDoesntExist: warnIfFileDoesntExist);
cache.postFileDiscoveryResults(known);
// todo: Mark elements that need to be analyzed again
@ -184,6 +190,13 @@ class DriftAnalysisDriver {
);
}
}
} else if (state is DiscoveredDartLibrary) {
for (final import in state.importDependencies) {
// We might import a generated file that doesn't exist yet, that
// should not be a user-visible error. Users will notice because the
// import is reported as an error by the analyzer either way.
await prepareFileForAnalysis(import, warnIfFileDoesntExist: false);
}
}
}
@ -207,6 +220,8 @@ class DriftAnalysisDriver {
} catch (e, s) {
if (e is! CouldNotResolveElementException) {
backend.log.warning('Could not analyze ${discovered.ownId}', e, s);
if (_isTesting) rethrow;
}
}
}

View File

@ -129,10 +129,17 @@ class DriftFileImport {
class DiscoveredDartLibrary extends DiscoveredFileState {
final LibraryElement library;
@override
final List<Uri> importDependencies;
@override
bool get isValidImport => true;
DiscoveredDartLibrary(this.library, super.locallyDefinedElements);
DiscoveredDartLibrary(
this.library,
super.locallyDefinedElements,
this.importDependencies,
);
}
class NotADartLibrary extends DiscoveredFileState {

View File

@ -46,6 +46,9 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
}
}
final tableConstraints =
await _readCustomConstraints(references, columns, element);
final table = DriftTable(
discovered.ownId,
DriftDeclaration.dartElement(element),
@ -60,7 +63,7 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
for (final uniqueKey in uniqueKeys ?? const <Set<DriftColumn>>[])
UniqueColumns(uniqueKey),
],
overrideTableConstraints: await _readCustomConstraints(columns, element),
overrideTableConstraints: tableConstraints,
withoutRowId: await _overrideWithoutRowId(element) ?? false,
);
@ -272,7 +275,7 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
return ColumnParser(this).parse(declaration, element);
}
Future<List<String>> _readCustomConstraints(
Future<List<String>> _readCustomConstraints(Set<DriftElement> references,
List<DriftColumn> localColumns, ClassElement element) async {
final customConstraints =
element.lookUpGetter('customConstraints', element.library);
@ -343,6 +346,7 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
(msg) => DriftAnalysisError.inDartAst(element, source, msg));
if (table != null) {
references.add(table);
final missingColumns = clause.columnNames
.map((e) => e.columnName)
.where((e) => !table.columnBySqlName.containsKey(e));

View File

@ -51,7 +51,7 @@ class DiscoverStep {
return result;
}
Future<void> discover() async {
Future<void> discover({required bool warnIfFileDoesntExist}) async {
final extension = _file.extension;
_file.discovery = UnknownFile();
@ -61,7 +61,7 @@ class DiscoverStep {
try {
library = await _driver.backend.readDart(_file.ownUri);
} catch (e) {
if (e is! NotALibraryException) {
if (e is! NotALibraryException && warnIfFileDoesntExist) {
// Backends are supposed to throw NotALibraryExceptions if the
// library is a part file. For other exceptions, we better report
// the error.
@ -77,8 +77,11 @@ class DiscoverStep {
await finder.find();
_file.errorsDuringDiscovery.addAll(finder.errors);
_file.discovery =
DiscoveredDartLibrary(library, _checkForDuplicates(finder.found));
_file.discovery = DiscoveredDartLibrary(
library,
_checkForDuplicates(finder.found),
finder.imports,
);
break;
case '.drift':
case '.moor':
@ -153,6 +156,8 @@ class _FindDartElements extends RecursiveElementVisitor<void> {
final DiscoverStep _discoverStep;
final LibraryElement _library;
final List<Uri> imports = [];
final TypeChecker _isTable, _isView, _isTableInfo, _isDatabase, _isDao;
final List<Future<void>> _pendingWork = [];
@ -231,6 +236,18 @@ class _FindDartElements extends RecursiveElementVisitor<void> {
super.visitClassElement(element);
}
@override
void visitLibraryImportElement(LibraryImportElement element) {
final imported = element.importedLibrary;
if (imported != null && !imported.isInSdk) {
_pendingWork.add(Future(() async {
final uri = await _discoverStep._driver.backend.uriOfDart(imported);
imports.add(uri);
}));
}
}
String _defaultNameForTableOrView(ClassElement definingElement) {
return _discoverStep._driver.options.caseFromDartToSql
.apply(definingElement.name);

View File

@ -222,7 +222,8 @@ class DartTopLevelSymbol {
factory DartTopLevelSymbol.topLevelElement(Element element,
[String? elementName]) {
assert(element.library?.topLevelElements.contains(element) == true);
assert(element.library?.topLevelElements.contains(element) == true,
'${element.name} is not a top-level element');
// We're using this to recover the right import URI when using
// `package:build`:
@ -437,7 +438,7 @@ class _AddFromAst extends GeneralizingAstVisitor<void> {
_AddFromAst(this._builder, this._excluding);
void _addTopLevelReference(Element? element, Token name2) {
if (element == null) {
if (element == null || (element.isSynthetic && element.library == null)) {
_builder.addText(name2.lexeme);
} else {
_builder.addTopLevel(

View File

@ -1,6 +1,6 @@
name: drift_dev
description: Dev-dependency for users of drift. Contains the generator and development tools.
version: 2.8.2
version: 2.8.3
repository: https://github.com/simolus3/drift
homepage: https://drift.simonbinder.eu/
issue_tracker: https://github.com/simolus3/drift/issues

View File

@ -364,6 +364,48 @@ class WithConstraints extends Table {
]);
});
test('can resolve references from import', () async {
final backend = TestBackend.inTest({
'a|lib/topic.dart': '''
import 'package:drift/drift.dart';
import 'language.dart';
class Topics extends Table {
IntColumn get id => integer()();
TextColumn get langCode => text()();
}
''',
'a|lib/video.dart': '''
import 'package:drift/drift.dart';
import 'topic.dart';
class Videos extends Table {
IntColumn get id => integer()();
IntColumn get topicId => integer()();
TextColumn get topicLang => text()();
@override
List<String> get customConstraints => [
'FOREIGN KEY (topic_id, topic_lang) REFERENCES topics (id, lang_code)',
];
}
''',
});
final state = await backend.analyze('package:a/video.dart');
backend.expectNoErrors();
final table = state.analyzedElements.single as DriftTable;
expect(
table.references,
contains(isA<DriftTable>()
.having((e) => e.schemaName, 'schemaName', 'topics')));
});
test('supports autoIncrement on int64 columns', () async {
final backend = TestBackend.inTest({
'a|lib/a.dart': '''

View File

@ -45,7 +45,7 @@ class TestBackend extends DriftBackend {
for (final entry in sourceContents.entries)
AssetId.parse(entry.key).uri.toString(): entry.value,
} {
driver = DriftAnalysisDriver(this, options);
driver = DriftAnalysisDriver(this, options, isTesting: true);
}
factory TestBackend.inTest(

View File

@ -27,7 +27,3 @@ dev_dependencies:
flutter:
assets:
- test_asset.db
dependency_overrides:
# Flutter's test packages don't support the latest analyzer yet.
test_api: ^0.4.16

View File

@ -5,7 +5,8 @@ publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=2.17.6 <3.0.0"
sdk: ">=2.17.6 <4.0.0"
dependencies:
drift: ^2.0.2+1
sqlcipher_flutter_libs: ^0.5.1
@ -24,7 +25,3 @@ dev_dependencies:
flutter:
uses-material-design: true
dependency_overrides:
# Flutter's test packages don't support the latest analyzer yet.
test_api: ^0.4.16

View File

@ -24,4 +24,8 @@ Future<void> main() async {
}
output.writeAsStringSync(json.encode(tracker.timings));
// Make sure the process exits. Otherwise, unclosed resources from the
// benchmarks will keep the process alive.
exit(0);
}

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:benchmark_harness/benchmark_harness.dart' show ScoreEmitter;
import 'package:intl/intl.dart';
import 'src/moor/cache_prepared_statements.dart';
import 'src/moor/key_value_insert.dart';
import 'src/sqlite/bind_string.dart';
import 'src/sqlparser/parse_drift_file.dart';
@ -22,6 +23,9 @@ List<Reportable> allBenchmarks(ScoreEmitter emitter) {
// sql parser
ParseDriftFile(emitter),
TokenizerBenchmark(emitter),
// prepared statements cache
CachedPreparedStatements(emitter),
NonCachedPreparedStatements(emitter),
];
}

View File

@ -0,0 +1,65 @@
import 'package:benchmarks/benchmarks.dart';
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import 'database.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: invalid_use_of_protected_member
const int _numQueries = 100000;
const Uuid uuid = Uuid();
Future<void> _runQueries(Database _db) async {
// a query with a subquery so that the query planner takes some time
const queryToBench = '''
SELECT * FROM key_values WHERE value IN (SELECT value FROM key_values WHERE value = ?);
''';
final fs = <Future>[];
for (var i = 0; i < _numQueries; i++) {
fs.add(
_db.customSelect(queryToBench, variables: [Variable(uuid.v4())]).get(),
);
}
await Future.wait(fs);
}
class CachedPreparedStatements extends AsyncBenchmarkBase {
final _db = Database(cachePreparedStatements: true);
CachedPreparedStatements(ScoreEmitter emitter)
: super('Running $_numQueries queries (cached prepared statements)',
emitter);
@override
Future<void> setup() async {
// Empty db so that we mostly bench the prepared statement time
}
@override
Future<void> run() async {
await _runQueries(_db);
}
}
class NonCachedPreparedStatements extends AsyncBenchmarkBase {
final _db = Database(cachePreparedStatements: false);
NonCachedPreparedStatements(ScoreEmitter emitter)
: super('Running $_numQueries queries (non-cached prepared statements)',
emitter);
@override
Future<void> setup() async {
// Empty db so that we mostly bench the prepared statement time
}
@override
Future<void> run() async {
await _runQueries(_db);
}
}

View File

@ -17,7 +17,10 @@ class KeyValues extends Table {
@DriftDatabase(tables: [KeyValues])
class Database extends _$Database {
Database() : super(_obtainExecutor());
Database({bool cachePreparedStatements = true})
: super(_obtainExecutor(
cachePreparedStatements: cachePreparedStatements,
));
@override
int get schemaVersion => 1;
@ -25,10 +28,15 @@ class Database extends _$Database {
const _uuid = Uuid();
QueryExecutor _obtainExecutor() {
QueryExecutor _obtainExecutor({
required bool cachePreparedStatements,
}) {
final file =
File(p.join(Directory.systemTemp.path, 'drift_benchmarks', _uuid.v4()));
file.parent.createSync();
return NativeDatabase(file);
return NativeDatabase(
file,
cachePreparedStatements: cachePreparedStatements,
);
}

View File

@ -21,7 +21,3 @@ dev_dependencies:
sdk: flutter
integration_test:
sdk: flutter
dependency_overrides:
# Flutter's test packages don't support the latest analyzer yet.
test_api: ^0.4.16

View File

@ -1,3 +1,12 @@
## 0.30.2
- Fix false-positive "unknown table" errors when the same table is used in a
join with and then without an alias.
## 0.30.1
- Report syntax error for `WITH` clauses in triggers.
## 0.30.0
- Add `previous` and `next` fields for tokens

View File

@ -69,6 +69,7 @@ enum AnalysisErrorType {
starColumnWithoutTable,
compoundColumnCountMismatch,
cteColumnCountMismatch,
circularReference,
valuesSelectCountMismatch,
viewColumnNamesMismatch,
rowValueMisuse,

View File

@ -45,7 +45,8 @@ abstract class ReferenceScope {
/// This is useful to resolve qualified references (e.g. to resolve `foo.bar`
/// the resolver would call [resolveResultSet]("foo") and then look up the
/// `bar` column in that result set).
ResultSetAvailableInStatement? resolveResultSet(String name) => null;
ResultSetAvailableInStatement? resolveResultSetForReference(String name) =>
null;
/// Adds an added result set to this scope.
///
@ -126,8 +127,8 @@ mixin _HasParentScope on ReferenceScope {
_parentScopeForLookups.resultSetAvailableToChildScopes;
@override
ResultSetAvailableInStatement? resolveResultSet(String name) =>
_parentScopeForLookups.resolveResultSet(name);
ResultSetAvailableInStatement? resolveResultSetForReference(String name) =>
_parentScopeForLookups.resolveResultSetForReference(name);
@override
ResultSet? resolveResultSetToAdd(String name) =>
@ -219,8 +220,8 @@ class StatementScope extends ReferenceScope with _HasParentScope {
}
@override
ResultSetAvailableInStatement? resolveResultSet(String name) {
return resultSets[name] ?? parent.resolveResultSet(name);
ResultSetAvailableInStatement? resolveResultSetForReference(String name) {
return resultSets[name] ?? parent.resolveResultSetForReference(name);
}
@override
@ -279,12 +280,19 @@ class StatementScope extends ReferenceScope with _HasParentScope {
}
}
/// A special intermediate scope used for subqueries appearing in a `FROM`
/// clause so that the subquery can't see outer columns and tables being added.
class SubqueryInFromScope extends ReferenceScope with _HasParentScope {
/// A special intermediate scope used for nodes that don't see columns and
/// tables added to the statement they're in.
///
/// An example for this are subqueries appearing in a `FROM` clause, which can't
/// see outer columns and tables of the select statement.
///
/// Another example is the [InsertStatement.source] of an [InsertStatement],
/// which cannot refer to columns of the table being inserted to of course.
/// Things like `INSERT INTO tbl (col) VALUES (tbl.col)` are not allowed.
class SourceScope extends ReferenceScope with _HasParentScope {
final StatementScope enclosingStatement;
SubqueryInFromScope(this.enclosingStatement);
SourceScope(this.enclosingStatement);
@override
RootScope get rootScope => enclosingStatement.rootScope;
@ -321,8 +329,9 @@ class MiscStatementSubScope extends ReferenceScope with _HasParentScope {
RootScope get rootScope => parent.rootScope;
@override
ResultSetAvailableInStatement? resolveResultSet(String name) {
return additionalResultSets[name] ?? parent.resolveResultSet(name);
ResultSetAvailableInStatement? resolveResultSetForReference(String name) {
return additionalResultSets[name] ??
parent.resolveResultSetForReference(name);
}
@override
@ -348,7 +357,7 @@ class SingleTableReferenceScope extends ReferenceScope {
RootScope get rootScope => parent.rootScope;
@override
ResultSetAvailableInStatement? resolveResultSet(String name) {
ResultSetAvailableInStatement? resolveResultSetForReference(String name) {
if (name == addedTableName) {
return addedTable;
} else {

View File

@ -11,17 +11,55 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
@override
void visitSelectStatement(SelectStatement e, ColumnResolverContext arg) {
// visit children first so that common table expressions are resolved
visitChildren(e, arg);
e.withClause?.accept(this, arg);
_resolveSelect(e, arg);
// We've handled the from clause in _resolveSelect, but we still need to
// visit other children to handle things like subquery expressions.
for (final child in e.childNodes) {
if (child != e.withClause && child != e.from) {
visit(child, arg);
}
}
}
@override
void visitCreateIndexStatement(
CreateIndexStatement e, ColumnResolverContext arg) {
_handle(e.on, [], arg);
visitExcept(e, e.on, arg);
}
@override
void visitCreateTriggerStatement(
CreateTriggerStatement e, ColumnResolverContext arg) {
final table = _resolveTableReference(e.onTable, arg);
if (table == null) {
// further analysis is not really possible without knowing the table
super.visitCreateTriggerStatement(e, arg);
return;
}
final scope = e.statementScope;
// Add columns of the target table for when and update of clauses
scope.expansionOfStarColumn = table.resolvedColumns;
if (e.target.introducesNew) {
scope.addAlias(e, table, 'new');
}
if (e.target.introducesOld) {
scope.addAlias(e, table, 'old');
}
visitChildren(e, arg);
}
@override
void visitCompoundSelectStatement(
CompoundSelectStatement e, ColumnResolverContext arg) {
// first, visit all children so that the compound parts have their columns
// resolved
visitChildren(e, arg);
e.base.accept(this, arg);
visitList(e.additional, arg);
_resolveCompoundSelect(e);
}
@ -29,29 +67,75 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
@override
void visitValuesSelectStatement(
ValuesSelectStatement e, ColumnResolverContext arg) {
// visit children to resolve CTEs
visitChildren(e, arg);
e.withClause?.accept(this, arg);
_resolveValuesSelect(e);
// Still visit expressions because they could have subqueries that we need
// to handle.
visitList(e.values, arg);
}
@override
void visitCommonTableExpression(
CommonTableExpression e, ColumnResolverContext arg) {
visitChildren(
e,
const ColumnResolverContext(referencesUseNameOfReferencedColumn: false),
// If we have a compound select statement as a CTE, resolve the initial
// query first because the whole CTE will have those columns in the end.
// This allows subsequent parts of the compound select to refer to the CTE.
final query = e.as;
final contextForFirstChild = ColumnResolverContext(
referencesUseNameOfReferencedColumn: false,
inDefinitionOfCte: [
...arg.inDefinitionOfCte,
e.cteTableName.toLowerCase(),
],
);
final resolved = e.as.resolvedColumns;
final names = e.columnNames;
if (names != null && resolved != null && names.length != resolved.length) {
context.reportError(AnalysisError(
type: AnalysisErrorType.cteColumnCountMismatch,
message: 'This CTE declares ${names.length} columns, but its select '
'statement actually returns ${resolved.length}.',
relevantNode: e,
));
void applyColumns(BaseSelectStatement source) {
final resolved = source.resolvedColumns!;
final names = e.columnNames;
if (names == null) {
e.resolvedColumns = resolved;
} else {
if (names.length != resolved.length) {
context.reportError(AnalysisError(
type: AnalysisErrorType.cteColumnCountMismatch,
message:
'This CTE declares ${names.length} columns, but its select '
'statement actually returns ${resolved.length}.',
relevantNode: e.tableNameToken ?? e,
));
}
final cteColumns = names
.map((name) => CommonTableExpressionColumn(name)..containingSet = e)
.toList();
for (var i = 0; i < cteColumns.length; i++) {
if (i < resolved.length) {
final selectColumn = resolved[i];
cteColumns[i].innerColumn = selectColumn;
}
}
e.resolvedColumns = cteColumns;
}
}
if (query is CompoundSelectStatement) {
// The first nested select statement determines the columns of this CTE.
query.base.accept(this, contextForFirstChild);
applyColumns(query.base);
// Subsequent queries can refer to the CTE though.
final contextForOtherChildren = ColumnResolverContext(
referencesUseNameOfReferencedColumn: false,
inDefinitionOfCte: arg.inDefinitionOfCte,
);
visitList(query.additional, contextForOtherChildren);
_resolveCompoundSelect(query);
} else {
visitChildren(e, contextForFirstChild);
applyColumns(query);
}
}
@ -70,10 +154,9 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
}
@override
void visitTableReference(TableReference e, void arg) {
if (e.resolved == null) {
_resolveTableReference(e);
}
void visitForeignKeyClause(ForeignKeyClause e, ColumnResolverContext arg) {
_resolveTableReference(e.foreignTable, arg);
visitExcept(e, e.foreignTable, arg);
}
@override
@ -100,13 +183,15 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
_resolveReturningClause(e, e.table.resultSet, arg);
}
ResultSet? _addIfResolved(AstNode node, TableReference ref) {
final table = _resolveTableReference(ref);
if (table != null) {
node.statementScope.expansionOfStarColumn = table.resolvedColumns;
}
ResultSet? _addIfResolved(
AstNode node, TableReference ref, ColumnResolverContext arg) {
final availableColumns = <Column>[];
_handle(ref, availableColumns, arg);
return table;
final scope = node.statementScope;
scope.expansionOfStarColumn = availableColumns;
return ref.resultSet;
}
@override
@ -114,11 +199,11 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
// Resolve CTEs first
e.withClause?.accept(this, arg);
final into = _addIfResolved(e, e.table);
_handle(e.table, [], arg);
for (final child in e.childNodes) {
if (child != e.withClause) visit(child, arg);
}
_resolveReturningClause(e, into, arg);
_resolveReturningClause(e, e.table.resultSet, arg);
}
@override
@ -126,7 +211,7 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
// Resolve CTEs first
e.withClause?.accept(this, arg);
final from = _addIfResolved(e, e.from);
final from = _addIfResolved(e, e.from, arg);
for (final child in e.childNodes) {
if (child != e.withClause) visit(child, arg);
}
@ -168,31 +253,10 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
stmt.returnedResultSet = CustomResultSet(columns);
}
@override
void visitCreateTriggerStatement(
CreateTriggerStatement e, ColumnResolverContext arg) {
final table = _resolveTableReference(e.onTable);
if (table == null) {
// further analysis is not really possible without knowing the table
super.visitCreateTriggerStatement(e, arg);
return;
}
final scope = e.statementScope;
// Add columns of the target table for when and update of clauses
scope.expansionOfStarColumn = table.resolvedColumns;
if (e.target.introducesNew) {
scope.addAlias(e, table, 'new');
}
if (e.target.introducesOld) {
scope.addAlias(e, table, 'old');
}
visitChildren(e, arg);
}
/// Visits a [queryable] appearing in a `FROM` clause under the state [state].
///
/// This also adds columns contributed to the resolved source to
/// [availableColumns], which is later used to expand `*` parameters.
void _handle(Queryable queryable, List<Column> availableColumns,
ColumnResolverContext state) {
void addColumns(Iterable<Column> columns) {
@ -209,41 +273,52 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
}
}
final scope = queryable.scope;
void markAvailableResultSet(
Queryable source, ResolvesToResultSet resultSet, String? name) {
final added = ResultSetAvailableInStatement(source, resultSet);
if (source is TableOrSubquery) {
source.availableResultSet = added;
}
scope.addResolvedResultSet(name, added);
}
queryable.when(
isTable: (table) {
final resolved = _resolveTableReference(table);
final resolved = _resolveTableReference(table, state);
markAvailableResultSet(
table, resolved ?? table, table.as ?? table.tableName);
if (resolved != null) {
// an error will be logged when resolved is null, so the != null check
// is fine and avoids crashes
addColumns(table.resultSet!.resolvedColumns!);
}
},
isSelect: (select) {
markAvailableResultSet(select, select.statement, select.as);
// Inside subqueries, references don't take the name of the referenced
// column.
final childState =
ColumnResolverContext(referencesUseNameOfReferencedColumn: false);
// the inner select statement doesn't have access to columns defined in
// the outer statements, which is why we use _resolveSelect instead of
// passing availableColumns down to a recursive call of _handle
final childState = ColumnResolverContext(
referencesUseNameOfReferencedColumn: false,
inDefinitionOfCte: state.inDefinitionOfCte,
);
final stmt = select.statement;
if (stmt is CompoundSelectStatement) {
_resolveCompoundSelect(stmt);
} else if (stmt is SelectStatement) {
_resolveSelect(stmt, childState);
} else if (stmt is ValuesSelectStatement) {
_resolveValuesSelect(stmt);
} else {
throw AssertionError('Unknown type of select statement: $stmt');
}
visit(stmt, childState);
addColumns(stmt.resolvedColumns!);
},
isJoin: (join) {
_handle(join.primary, availableColumns, state);
for (final query in join.joins.map((j) => j.query)) {
_handle(query, availableColumns, state);
isJoin: (joinClause) {
_handle(joinClause.primary, availableColumns, state);
for (final join in joinClause.joins) {
_handle(join.query, availableColumns, state);
final constraint = join.constraint;
if (constraint is OnConstraint) {
visit(constraint.expression, state);
}
}
},
isTableFunction: (function) {
@ -251,6 +326,9 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
.engineOptions.addedTableFunctions[function.name.toLowerCase()];
final resolved = handler?.resolveTableValued(context, function);
markAvailableResultSet(
function, resolved ?? function, function.as ?? function.name);
if (resolved == null) {
context.reportError(AnalysisError(
type: AnalysisErrorType.unknownFunction,
@ -290,7 +368,8 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
Iterable<Column>? visibleColumnsForStar;
if (resultColumn.tableName != null) {
final tableResolver = scope.resolveResultSet(resultColumn.tableName!);
final tableResolver =
scope.resolveResultSetForReference(resultColumn.tableName!);
if (tableResolver == null) {
context.reportError(AnalysisError(
type: AnalysisErrorType.referencedUnknownTable,
@ -361,7 +440,8 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
}
}
} else if (resultColumn is NestedStarResultColumn) {
final target = scope.resolveResultSet(resultColumn.tableName);
final target =
scope.resolveResultSetForReference(resultColumn.tableName);
if (target == null) {
context.reportError(AnalysisError(
@ -458,24 +538,26 @@ class ColumnResolver extends RecursiveVisitor<ColumnResolverContext, void> {
return span;
}
ResultSet? _resolveTableReference(TableReference r) {
ResultSet? _resolveTableReference(
TableReference r, ColumnResolverContext state) {
// Check for circular references
if (state.inDefinitionOfCte.contains(r.tableName.toLowerCase())) {
context.reportError(AnalysisError(
type: AnalysisErrorType.circularReference,
relevantNode: r,
message: 'Circular reference to its own CTE',
));
return null;
}
final scope = r.scope;
// Try resolving to a top-level table in the schema and to a result set that
// may have been added to the table
final resolvedInSchema = scope.resolveResultSetToAdd(r.tableName);
final resolvedInQuery = scope.resolveResultSet(r.tableName);
final createdName = r.as;
// Prefer using a table that has already been added if this isn't the
// definition of the added table reference
if (resolvedInQuery != null && resolvedInQuery.origin != r) {
final resolved = resolvedInQuery.resultSet.resultSet;
if (resolved != null) {
return r.resolved =
createdName != null ? TableAlias(resolved, createdName) : resolved;
}
} else if (resolvedInSchema != null) {
if (resolvedInSchema != null) {
return r.resolved = createdName != null
? TableAlias(resolvedInSchema, createdName)
: resolvedInSchema;
@ -528,6 +610,13 @@ class ColumnResolverContext {
/// column in subqueries or CTEs.
final bool referencesUseNameOfReferencedColumn;
const ColumnResolverContext(
{this.referencesUseNameOfReferencedColumn = true});
/// The common table expressions that are currently being defined.
///
/// This is used to detect forbidden circular references.
final List<String> inDefinitionOfCte;
const ColumnResolverContext({
this.referencesUseNameOfReferencedColumn = true,
this.inDefinitionOfCte = const [],
});
}

View File

@ -544,4 +544,17 @@ class LintingVisitor extends RecursiveVisitor<void, void> {
visitChildren(e, arg);
}
@override
void visitWithClause(WithClause e, void arg) {
if (_isInTopLevelTriggerStatement) {
context.reportError(AnalysisError(
type: AnalysisErrorType.synctactic,
relevantNode: e.withToken ?? e,
message: 'WITH clauses cannot appear in triggers.',
));
}
visitChildren(e, arg);
}
}

View File

@ -19,6 +19,12 @@ class AstPreparingVisitor extends RecursiveVisitor<void, void> {
resolveIndexOfVariables(_foundVariables);
}
@override
void defaultInsertSource(InsertSource e, void arg) {
e.scope = SourceScope(e.parent!.statementScope);
visitChildren(e, arg);
}
@override
void visitCreateTableStatement(CreateTableStatement e, void arg) {
final scope = e.scope = StatementScope.forStatement(context.rootScope, e);
@ -52,6 +58,7 @@ class AstPreparingVisitor extends RecursiveVisitor<void, void> {
// query: "SELECT * FROM demo d1,
// (SELECT * FROM demo i WHERE i.id = d1.id) d2;"
// it won't work.
final isInFROM = e.parent is Queryable;
StatementScope scope;
@ -59,7 +66,7 @@ class AstPreparingVisitor extends RecursiveVisitor<void, void> {
final surroundingSelect = e.parents
.firstWhere((node) => node is HasFrom)
.scope as StatementScope;
scope = StatementScope(SubqueryInFromScope(surroundingSelect));
scope = StatementScope(SourceScope(surroundingSelect));
} else {
scope = StatementScope.forStatement(context.rootScope, e);
}
@ -107,37 +114,6 @@ class AstPreparingVisitor extends RecursiveVisitor<void, void> {
visitChildren(e, arg);
}
@override
void defaultQueryable(Queryable e, void arg) {
final scope = e.scope;
e.when(
isTable: (table) {
final added = ResultSetAvailableInStatement(table, table);
table.availableResultSet = added;
scope.addResolvedResultSet(table.as ?? table.tableName, added);
},
isSelect: (select) {
final added = ResultSetAvailableInStatement(select, select.statement);
select.availableResultSet = added;
scope.addResolvedResultSet(select.as, added);
},
isJoin: (join) {
// the join can contain multiple tables. Luckily for us, all of them are
// Queryables, so we can deal with them by visiting the children and
// dont't need to do anything here.
},
isTableFunction: (function) {
final added = ResultSetAvailableInStatement(function, function);
function.availableResultSet = added;
scope.addResolvedResultSet(function.as ?? function.name, added);
},
);
visitChildren(e, arg);
}
@override
void visitCommonTableExpression(CommonTableExpression e, void arg) {
StatementScope.cast(e.scope).additionalKnownTables[e.cteTableName] = e;

View File

@ -57,7 +57,7 @@ class ReferenceResolver
if (e.entityName != null) {
// first find the referenced table or view,
// then use the column on that table or view.
final entityResolver = scope.resolveResultSet(e.entityName!);
final entityResolver = scope.resolveResultSetForReference(e.entityName!);
final resultSet = entityResolver?.resultSet.resultSet;
if (resultSet == null) {

View File

@ -89,7 +89,7 @@ class JoinModel {
}
// The boolean arg indicates whether a visited queryable is needed for the
// result to have any rows (which, in particular, mean's its non-nullable)
// result to have any rows (which, in particular, means its non-nullable)
class _FindNonNullableJoins extends RecursiveVisitor<bool, void> {
final List<ResultSetAvailableInStatement> nonNullable = [];

View File

@ -49,7 +49,8 @@ class CommonTableExpression extends AstNode with ResultSet {
Token? asToken;
IdentifierToken? tableNameToken;
List<CommonTableExpressionColumn>? _cachedColumns;
@override
List<Column>? resolvedColumns;
CommonTableExpression({
required this.cteTableName,
@ -71,33 +72,6 @@ class CommonTableExpression extends AstNode with ResultSet {
@override
Iterable<AstNode> get childNodes => [as];
@override
List<Column>? get resolvedColumns {
final columnsOfSelect = as.resolvedColumns;
// we don't override column names, so just return the columns declared by
// the select statement
if (columnNames == null) return columnsOfSelect;
final cached = _cachedColumns ??= columnNames!
.map((name) => CommonTableExpressionColumn(name)..containingSet = this)
.toList();
if (columnsOfSelect != null) {
// bind the CommonTableExpressionColumn to the real underlying column
// returned by the select statement
for (var i = 0; i < cached.length; i++) {
if (i < columnsOfSelect.length) {
final selectColumn = columnsOfSelect[i];
cached[i].innerColumn = selectColumn;
}
}
}
return _cachedColumns;
}
@override
bool get visibleToChildren => true;
}

View File

@ -270,22 +270,18 @@ class SqlEngine {
final node = context.root;
node.scope = context.rootScope;
try {
AstPreparingVisitor(context: context).start(node);
AstPreparingVisitor(context: context).start(node);
node
..accept(ColumnResolver(context), const ColumnResolverContext())
..accept(ReferenceResolver(context), const ReferenceResolvingContext());
node
..accept(ColumnResolver(context), const ColumnResolverContext())
..accept(ReferenceResolver(context), const ReferenceResolvingContext());
final session = TypeInferenceSession(context, options);
final resolver = TypeResolver(session);
resolver.run(node);
context.types2 = session.results!;
final session = TypeInferenceSession(context, options);
final resolver = TypeResolver(session);
resolver.run(node);
context.types2 = session.results!;
node.acceptWithoutArg(LintingVisitor(options, context));
} catch (_) {
rethrow;
}
node.acceptWithoutArg(LintingVisitor(options, context));
}
}

View File

@ -1,6 +1,6 @@
name: sqlparser
description: Parses sqlite statements and performs static analysis on them
version: 0.30.0
version: 0.30.1
homepage: https://github.com/simolus3/drift/tree/develop/sqlparser
repository: https://github.com/simolus3/drift
#homepage: https://drift.simonbinder.eu/

View File

@ -100,6 +100,21 @@ END;
});
});
test('resolves index', () {
final context = engine.analyze('CREATE INDEX foo ON demo (content)');
context.expectNoError();
final tableReference =
context.root.allDescendants.whereType<TableReference>().first;
final columnReference = context.root.allDescendants
.whereType<IndexedColumn>()
.first
.expression as Reference;
expect(tableReference.resolved, demoTable);
expect(columnReference.resolvedColumn, isA<AvailableColumn>());
});
test("DO UPDATE action in upsert can refer to 'exluded'", () {
final context = engine.analyze('''
INSERT INTO demo VALUES (?, ?)
@ -110,6 +125,12 @@ INSERT INTO demo VALUES (?, ?)
expect(context.errors, isEmpty);
});
test('columns in an insert cannot refer to table', () {
engine
.analyze('INSERT INTO demo (content) VALUES (demo.content)')
.expectError('demo.content');
});
test('columns from values statement', () {
final context = engine.analyze("VALUES ('foo', 3), ('bar', 5)");
@ -127,6 +148,33 @@ INSERT INTO demo VALUES (?, ?)
expect(context.errors, isEmpty);
});
test('joining table with and without alias', () {
final context = engine.analyze('''
SELECT * FROM demo a
JOIN demo ON demo.id = a.id
''');
context.expectNoError();
});
test("from clause can't use its own table aliases", () {
final context = engine.analyze('''
SELECT * FROM demo a
JOIN a b ON b.id = a.id
''');
expect(context.errors, [
analysisErrorWith(
lexeme: 'a b', type: AnalysisErrorType.referencedUnknownTable),
analysisErrorWith(
lexeme: 'b.id', type: AnalysisErrorType.referencedUnknownTable),
]);
});
test('can use columns from deleted table', () {
engine.analyze('DELETE FROM demo WHERE demo.id = 2').expectNoError();
});
test('gracefully handles tuples of different lengths in VALUES', () {
final context = engine.analyze("VALUES ('foo', 3), ('bar')");
@ -270,4 +318,25 @@ INSERT INTO demo VALUES (?, ?)
.root as SelectStatement;
expect(cte.resolvedColumns?.map((e) => e.name), ['RoWiD']);
});
test('reports error for circular reference', () {
final query = engine.analyze('WITH x AS (SELECT * FROM x) SELECT 1;');
expect(query.errors, [
analysisErrorWith(lexeme: 'x', type: AnalysisErrorType.circularReference),
]);
});
test('regression test for #2453', () {
// https://github.com/simolus3/drift/issues/2453
engine
..registerTableFromSql('CREATE TABLE persons (id INTEGER);')
..registerTableFromSql('CREATE TABLE cars (driver INTEGER);');
final query = engine.analyze('''
SELECT * FROM cars
JOIN persons second_person ON second_person.id = cars.driver
JOIN persons ON persons.id = cars.driver;
''');
query.expectNoError();
});
}

View File

@ -80,6 +80,17 @@ void main() {
.expectError('DEFAULT VALUES', type: AnalysisErrorType.synctactic);
});
test('WITH clauses', () {
// https://sqlite.org/lang_with.html#limitations_and_caveats
engine.analyze('WITH x AS (SELECT 1) SELECT 2').expectNoError();
engine.analyze('''
CREATE TRIGGER tgr AFTER INSERT ON demo BEGIN
WITH x AS (SELECT 1) SELECT 2;
END;
''').expectError('WITH', type: AnalysisErrorType.synctactic);
});
group('aliased source tables', () {
test('insert', () {
engine.analyze('INSERT INTO demo AS d VALUES (?, ?)').expectNoError();

View File

@ -19,17 +19,17 @@ void main() {
final model = JoinModel.of(stmt)!;
expect(
model.isNullableTable(
stmt.scope.resolveResultSet('a1')!.resultSet.resultSet!),
stmt.scope.resolveResultSetForReference('a1')!.resultSet.resultSet!),
isFalse,
);
expect(
model.isNullableTable(
stmt.scope.resolveResultSet('a2')!.resultSet.resultSet!),
stmt.scope.resolveResultSetForReference('a2')!.resultSet.resultSet!),
isTrue,
);
expect(
model.isNullableTable(
stmt.scope.resolveResultSet('a3')!.resultSet.resultSet!),
stmt.scope.resolveResultSetForReference('a3')!.resultSet.resultSet!),
isFalse,
);
});