Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow wrapping raw synchronous databases as SqliteDatabase #86

Merged
merged 1 commit into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/sqlite_async/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
21 changes: 21 additions & 0 deletions packages/sqlite_async/lib/src/common/sqlite_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
60 changes: 60 additions & 0 deletions packages/sqlite_async/lib/src/impl/single_connection_database.dart
Original file line number Diff line number Diff line change
@@ -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<void> close() => connection.close();

@override
bool get closed => connection.closed;

@override
Future<bool> getAutoCommit() => connection.getAutoCommit();

@override
Future<void> get isInitialized => Future.value();

@override
IsolateConnectionFactory<CommonDatabase> isolateConnectionFactory() {
throw UnsupportedError(
"SqliteDatabase.singleConnection instances can't be used across "
'isolates.');
}

@override
int get maxReaders => 1;

@override
AbstractDefaultSqliteOpenFactory<CommonDatabase> get openFactory =>
throw UnimplementedError();

@override
Future<T> readLock<T>(Future<T> Function(SqliteReadContext tx) callback,
{Duration? lockTimeout, String? debugContext}) {
return connection.readLock(callback,
lockTimeout: lockTimeout, debugContext: debugContext);
}

@override
Stream<UpdateNotification> get updates =>
connection.updates ?? const Stream.empty();

@override
Future<T> writeLock<T>(Future<T> Function(SqliteWriteContext tx) callback,
{Duration? lockTimeout, String? debugContext}) {
return connection.writeLock(callback,
lockTimeout: lockTimeout, debugContext: debugContext);
}
}
23 changes: 23 additions & 0 deletions packages/sqlite_async/lib/src/sqlite_connection.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<UpdateNotification>? get updates;

Expand Down
2 changes: 1 addition & 1 deletion packages/sqlite_async/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
21 changes: 21 additions & 0 deletions packages/sqlite_async/test/basic_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'});
});
});
}

Expand Down
5 changes: 5 additions & 0 deletions packages/sqlite_async/test/utils/abstract_test_utils.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import 'package:sqlite_async/sqlite3_common.dart';
import 'package:sqlite_async/sqlite_async.dart';

class TestDefaultSqliteOpenFactory extends DefaultSqliteOpenFactory {
final String sqlitePath;

TestDefaultSqliteOpenFactory(
{required super.path, super.sqliteOptions, this.sqlitePath = ''});

Future<CommonDatabase> openDatabaseForSingleConnection() async {
return openDB(SqliteOpenOptions(primaryConnection: true, readOnly: false));
}
}

abstract class AbstractTestUtils {
Expand Down
15 changes: 13 additions & 2 deletions packages/sqlite_async/test/utils/native_test_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -48,6 +53,12 @@ class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory {

return db;
}

@override
Future<CommonDatabase> openDatabaseForSingleConnection() async {
_applyOpenOverrides();
return sqlite3.openInMemory();
}
}

class TestUtils extends AbstractTestUtils {
Expand Down
24 changes: 21 additions & 3 deletions packages/sqlite_async/test/utils/web_test_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<CommonDatabase> 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<void> _isInitialized;
late final SqliteOptions webOptions;
Expand Down Expand Up @@ -57,12 +72,15 @@ class TestUtils extends AbstractTestUtils {
@override
Future<TestDefaultSqliteOpenFactory> testFactory(
{String? path,
String? sqlitePath,
String sqlitePath = '',
List<String> 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
Expand Down