diff --git a/packages/sqlite_async/CHANGELOG.md b/packages/sqlite_async/CHANGELOG.md index f3395b1..0038ad2 100644 --- a/packages/sqlite_async/CHANGELOG.md +++ b/packages/sqlite_async/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.11.4 + +- Add `SqliteConnection.synchronousWrapper` and `SqliteDatabase.singleConnection`. + Together, these can be used to wrap raw `CommonDatabase` instances from `package:sqlite3` + as a `Database` (without an automated worker or isolate setup). This can be useful in tests + where synchronous access to the underlying database is convenient. + ## 0.11.3 - Support being compiled with `package:build_web_compilers`. diff --git a/packages/sqlite_async/lib/src/common/sqlite_database.dart b/packages/sqlite_async/lib/src/common/sqlite_database.dart index f8e0be5..3cb12bb 100644 --- a/packages/sqlite_async/lib/src/common/sqlite_database.dart +++ b/packages/sqlite_async/lib/src/common/sqlite_database.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:sqlite_async/src/common/abstract_open_factory.dart'; import 'package:sqlite_async/src/common/isolate_connection_factory.dart'; +import 'package:sqlite_async/src/impl/single_connection_database.dart'; import 'package:sqlite_async/src/impl/sqlite_database_impl.dart'; import 'package:sqlite_async/src/sqlite_options.dart'; import 'package:sqlite_async/src/sqlite_queries.dart'; @@ -82,4 +83,24 @@ abstract class SqliteDatabase {int maxReaders = SqliteDatabase.defaultMaxReaders}) { return SqliteDatabaseImpl.withFactory(openFactory, maxReaders: maxReaders); } + + /// Opens a [SqliteDatabase] that only wraps an underlying connection. + /// + /// This function may be useful in some instances like tests, but should not + /// typically be used by applications. Compared to the other ways to open + /// databases, it has the following downsides: + /// + /// 1. No connection pool / concurrent readers for native databases. + /// 2. No reliable update notifications on the web. + /// 3. There is no reliable transaction management in Dart, and opening the + /// same database with [SqliteDatabase.singleConnection] multiple times + /// may cause "database is locked" errors. + /// + /// Together with [SqliteConnection.synchronousWrapper], this can be used to + /// open in-memory databases (e.g. via [SqliteOpenFactory.open]). That + /// bypasses most convenience features, but may still be useful for + /// short-lived databases used in tests. + factory SqliteDatabase.singleConnection(SqliteConnection connection) { + return SingleConnectionDatabase(connection); + } } diff --git a/packages/sqlite_async/lib/src/impl/single_connection_database.dart b/packages/sqlite_async/lib/src/impl/single_connection_database.dart new file mode 100644 index 0000000..4cd3144 --- /dev/null +++ b/packages/sqlite_async/lib/src/impl/single_connection_database.dart @@ -0,0 +1,60 @@ +import 'package:sqlite3/common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// A database implementation that delegates everything to a single connection. +/// +/// This doesn't provide an automatic connection pool or the web worker +/// management, but it can still be useful in cases like unit tests where those +/// features might not be necessary. Since only a single sqlite connection is +/// used internally, this also allows using in-memory databases. +final class SingleConnectionDatabase + with SqliteQueries, SqliteDatabaseMixin + implements SqliteDatabase { + final SqliteConnection connection; + + SingleConnectionDatabase(this.connection); + + @override + Future close() => connection.close(); + + @override + bool get closed => connection.closed; + + @override + Future getAutoCommit() => connection.getAutoCommit(); + + @override + Future get isInitialized => Future.value(); + + @override + IsolateConnectionFactory isolateConnectionFactory() { + throw UnsupportedError( + "SqliteDatabase.singleConnection instances can't be used across " + 'isolates.'); + } + + @override + int get maxReaders => 1; + + @override + AbstractDefaultSqliteOpenFactory get openFactory => + throw UnimplementedError(); + + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {Duration? lockTimeout, String? debugContext}) { + return connection.readLock(callback, + lockTimeout: lockTimeout, debugContext: debugContext); + } + + @override + Stream get updates => + connection.updates ?? const Stream.empty(); + + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {Duration? lockTimeout, String? debugContext}) { + return connection.writeLock(callback, + lockTimeout: lockTimeout, debugContext: debugContext); + } +} diff --git a/packages/sqlite_async/lib/src/sqlite_connection.dart b/packages/sqlite_async/lib/src/sqlite_connection.dart index f1b721a..15f4f6a 100644 --- a/packages/sqlite_async/lib/src/sqlite_connection.dart +++ b/packages/sqlite_async/lib/src/sqlite_connection.dart @@ -1,8 +1,12 @@ import 'dart:async'; import 'package:sqlite3/common.dart' as sqlite; +import 'package:sqlite_async/mutex.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/src/update_notification.dart'; +import 'common/connection/sync_sqlite_connection.dart'; + /// Abstract class representing calls available in a read-only or read-write context. abstract class SqliteReadContext { /// Execute a read-only (SELECT) query and return the results. @@ -74,7 +78,26 @@ abstract class SqliteWriteContext extends SqliteReadContext { } /// Abstract class representing a connection to the SQLite database. +/// +/// This package typically pools multiple [SqliteConnection] instances into a +/// managed [SqliteDatabase] automatically. abstract class SqliteConnection extends SqliteWriteContext { + /// Default constructor for subclasses. + SqliteConnection(); + + /// Creates a [SqliteConnection] instance that wraps a raw [CommonDatabase] + /// from the `sqlite3` package. + /// + /// Users should not typically create connections manually at all. Instead, + /// open a [SqliteDatabase] through a factory. In special scenarios where it + /// may be easier to wrap a [raw] databases (like unit tests), this method + /// may be used as an escape hatch for the asynchronous wrappers provided by + /// this package. + factory SqliteConnection.synchronousWrapper(CommonDatabase raw, + {Mutex? mutex}) { + return SyncSqliteConnection(raw, mutex ?? Mutex()); + } + /// Reports table change update notifications Stream? get updates; diff --git a/packages/sqlite_async/pubspec.yaml b/packages/sqlite_async/pubspec.yaml index 8264912..d333166 100644 --- a/packages/sqlite_async/pubspec.yaml +++ b/packages/sqlite_async/pubspec.yaml @@ -1,6 +1,6 @@ name: sqlite_async description: High-performance asynchronous interface for SQLite on Dart and Flutter. -version: 0.11.3 +version: 0.11.4 repository: https://github.com/powersync-ja/sqlite_async.dart environment: sdk: ">=3.5.0 <4.0.0" diff --git a/packages/sqlite_async/test/basic_test.dart b/packages/sqlite_async/test/basic_test.dart index 0aca7bc..6ee038a 100644 --- a/packages/sqlite_async/test/basic_test.dart +++ b/packages/sqlite_async/test/basic_test.dart @@ -188,6 +188,27 @@ void main() { ), ); }); + + test('can use raw database instance', () async { + final factory = await testUtils.testFactory(); + final raw = await factory.openDatabaseForSingleConnection(); + // Creating a fuction ensures that this database is actually used - if + // a connection were set up in a background isolate, it wouldn't have this + // function. + raw.createFunction( + functionName: 'my_function', function: (args) => 'test'); + + final db = SqliteDatabase.singleConnection( + SqliteConnection.synchronousWrapper(raw)); + await createTables(db); + + expect(db.updates, emits(UpdateNotification({'test_data'}))); + await db + .execute('INSERT INTO test_data(description) VALUES (my_function())'); + + expect(await db.get('SELECT description FROM test_data'), + {'description': 'test'}); + }); }); } diff --git a/packages/sqlite_async/test/utils/abstract_test_utils.dart b/packages/sqlite_async/test/utils/abstract_test_utils.dart index e787a45..b388c4d 100644 --- a/packages/sqlite_async/test/utils/abstract_test_utils.dart +++ b/packages/sqlite_async/test/utils/abstract_test_utils.dart @@ -1,3 +1,4 @@ +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; class TestDefaultSqliteOpenFactory extends DefaultSqliteOpenFactory { @@ -5,6 +6,10 @@ class TestDefaultSqliteOpenFactory extends DefaultSqliteOpenFactory { TestDefaultSqliteOpenFactory( {required super.path, super.sqliteOptions, this.sqlitePath = ''}); + + Future openDatabaseForSingleConnection() async { + return openDB(SqliteOpenOptions(primaryConnection: true, readOnly: false)); + } } abstract class AbstractTestUtils { diff --git a/packages/sqlite_async/test/utils/native_test_utils.dart b/packages/sqlite_async/test/utils/native_test_utils.dart index 66bf57c..945529d 100644 --- a/packages/sqlite_async/test/utils/native_test_utils.dart +++ b/packages/sqlite_async/test/utils/native_test_utils.dart @@ -5,6 +5,7 @@ import 'dart:isolate'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; +import 'package:sqlite_async/sqlite3.dart'; import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:sqlite3/open.dart' as sqlite_open; @@ -21,11 +22,15 @@ class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { super.sqlitePath = defaultSqlitePath, initStatements}); - @override - CommonDatabase open(SqliteOpenOptions options) { + void _applyOpenOverrides() { sqlite_open.open.overrideFor(sqlite_open.OperatingSystem.linux, () { return DynamicLibrary.open(sqlitePath); }); + } + + @override + CommonDatabase open(SqliteOpenOptions options) { + _applyOpenOverrides(); final db = super.open(options); db.createFunction( @@ -48,6 +53,12 @@ class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { return db; } + + @override + Future openDatabaseForSingleConnection() async { + _applyOpenOverrides(); + return sqlite3.openInMemory(); + } } class TestUtils extends AbstractTestUtils { diff --git a/packages/sqlite_async/test/utils/web_test_utils.dart b/packages/sqlite_async/test/utils/web_test_utils.dart index ac718f2..33f7d64 100644 --- a/packages/sqlite_async/test/utils/web_test_utils.dart +++ b/packages/sqlite_async/test/utils/web_test_utils.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:math'; +import 'package:sqlite_async/sqlite3_wasm.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; import 'package:web/web.dart' show Blob, BlobPart, BlobPropertyBag; @@ -12,6 +13,20 @@ external String _createObjectURL(Blob blob); String? _dbPath; +class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { + TestSqliteOpenFactory( + {required super.path, super.sqliteOptions, super.sqlitePath = ''}); + + @override + Future openDatabaseForSingleConnection() async { + final sqlite = await WasmSqlite3.loadFromUrl( + Uri.parse(sqliteOptions.webSqliteOptions.wasmUri)); + sqlite.registerVirtualFileSystem(InMemoryFileSystem(), makeDefault: true); + + return sqlite.openInMemory(); + } +} + class TestUtils extends AbstractTestUtils { late Future _isInitialized; late final SqliteOptions webOptions; @@ -57,12 +72,15 @@ class TestUtils extends AbstractTestUtils { @override Future testFactory( {String? path, - String? sqlitePath, + String sqlitePath = '', List initStatements = const [], SqliteOptions options = const SqliteOptions.defaults()}) async { await _isInitialized; - return super.testFactory( - path: path, options: webOptions, initStatements: initStatements); + return TestSqliteOpenFactory( + path: path ?? dbPath(), + sqlitePath: sqlitePath, + sqliteOptions: webOptions, + ); } @override