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

feat(neon_framework): add neon_storage library #2487

Merged
merged 1 commit into from
Sep 29, 2024
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
1 change: 1 addition & 0 deletions commitlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ rules:
- neon_http_client
- neon_lints
- neon_lints_test
- neon_storage
- news_app
- nextcloud
- nextcloud_test
Expand Down
1 change: 1 addition & 0 deletions packages/neon_framework/packages/neon_storage/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
include: package:neon_lints/dart.yaml

custom_lint:
rules:
- avoid_exports: false
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// SQLite wrappers for working with multiple tables independently.
library;

export 'src/sqlite/sqlite.dart' hide setupDatabase;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:sqflite_common/sqflite.dart';
import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart';

/// Initializes the sqlite database factory with the [databaseFactoryFfiWeb].
void setupDatabase() {
databaseFactory = databaseFactoryFfiWeb;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

/// Initializes the sqlite database factory with the [databaseFactoryFfi].

void setupDatabase() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:meta/meta.dart';

/// Initializes the sqlite database factory.
@internal
void setupDatabase() {
throw UnsupportedError('Cannot create a database factory without dart:js_interop or dart:io.');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'package:path/path.dart' as p;

/// The default database file extension.
const String databaseExtension = '.db';

/// Builds the database storage path from a directory and file name.
String buildDatabasePath(String? directory, String name) {
if (p.basenameWithoutExtension(name) != name) {
throw ArgumentError.value(name, 'name', 'MUST NOT contain any directory or file extension.');
}

final nameWithExtension = p.setExtension(name, databaseExtension);

if (directory == null) {
return nameWithExtension;
}

return p.join(directory, nameWithExtension);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import 'dart:async';

import 'package:built_collection/built_collection.dart';
import 'package:meta/meta.dart';
import 'package:neon_storage/src/sqlite/sqlite.dart';
import 'package:sqflite_common/sqflite.dart';

/// A wrapper for the SQLite [Database] managing multiple tables.
abstract class MultiTableDatabase {
/// Creates a new database for the given [tables].
///
/// The provided tables must have distinct names or an `ArgumentError` is thrown.
MultiTableDatabase({
required Iterable<Table> tables,
}) : _tables = BuiltList.from(tables) {
final names = _tables.map((t) => t.name).toSet();

if (names.length != _tables.length || names.contains(_metaTable)) {
throw ArgumentError.value(_tables, 'tables', 'contains conflicting table names');
}
}

/// The full storage path of the database.
@protected
FutureOr<String> get path;
provokateurin marked this conversation as resolved.
Show resolved Hide resolved

/// The name of the database without any file extension.
@protected
String get name;

/// When `true` all open parameters are ignored and the database is opened as-is.
@protected
bool get readOnly => false;

/// When `true` (the default), a single database instance is opened for a
/// given path. Subsequent calls to [init] with the same path will
/// return the same instance, and will discard all other parameters such as
/// callbacks for that invocation.
@protected
bool get singleInstance => true;

static bool _sqfliteInitialized = false;

late final _metaTable = '_${name}_meta';

final BuiltList<Table> _tables;

Database? _database;

/// Throws a `StateError` if [init] has not been called or completed before.
@visibleForTesting
@protected
void assertInitialized() {
if (_database == null) {
throw StateError(
'The database "$name" has not been set up. Make sure init() has been called before and completed.',
);
}
}

/// The database instance.
///
/// Throws a `StateError` if [init] has not been called or completed before.
Database get database {
assertInitialized();

return _database!;
}

/// Closes the database.
///
/// A closed database must be initialized again by calling [init].
@visibleForTesting
Future<void> close() async {
await _database?.close();
_database = null;
}

/// Initializes the database and all tables.
///
/// This must called and completed before accessing other methods.
Future<void> init() async {
if (_database != null) {
return;
provokateurin marked this conversation as resolved.
Show resolved Hide resolved
}

if (!_sqfliteInitialized) {
setupDatabase();
_sqfliteInitialized = true;
}

final database = await openDatabase(
await path,
version: 1,
onCreate: _createMetaTable,
readOnly: readOnly,
singleInstance: singleInstance,
);

await _createTables(database);

for (final table in _tables) {
table.controller = this;
}
await Future.wait(
_tables.map((t) => t.onOpen()),
);

_database = database;
}

Future<void> _createMetaTable(Database db, int version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS "$_metaTable" (
"name" TEXT NOT NULL,
"version" INTEGER NOT NULL,
PRIMARY KEY("name")
);
''');
}

Future<void> _createTables(Database db) async {
final rows = await db.query(_metaTable);

final versions = <String, int>{};
for (final row in rows) {
final name = row['name']! as String;
final version = row['version']! as int;

versions[name] = version;
}

final batch = db.batch();

for (final table in _tables) {
final oldVersion = versions[table.name];
final newVersion = table.version;

if (oldVersion == null) {
table.onCreate(batch, newVersion);
batch.insert(
_metaTable,
{
'name': table.name,
'version': newVersion,
},
);
continue;
} else if (oldVersion == newVersion) {
continue;
} else if (oldVersion > newVersion) {
table.onDowngrade(batch, oldVersion, newVersion);
} else if (oldVersion < newVersion) {
table.onUpgrade(batch, oldVersion, newVersion);
}

batch.update(
_metaTable,
{
'name': table.name,
'version': newVersion,
},
where: 'name = ?',
whereArgs: [table.name],
);
}

await batch.commit(noResult: true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export 'database_factory.dart'
if (dart.library.js_interop) '_browser_database_factory.dart'
if (dart.library.io) '_io_database_factory.dart';

export 'database_path_utils.dart';
export 'multi_table_database.dart';
export 'table.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'dart:async';

import 'package:neon_storage/src/sqlite/sqlite.dart';
import 'package:sqflite_common/sqlite_api.dart';

/// A SQLite table abstraction that mimics the `openDatabase` function from the sqflite package.
///
/// Depending on the current [version], [onCreate], [onUpgrade], and [onDowngrade] can
/// be called. These functions are mutually exclusive — only one of them can be
/// called depending on the context.
abstract mixin class Table {
/// The name of the table.
String get name;

/// The value must be a 32-bit integer greater than `0`.
int get version;

/// The owning multi table database.
late MultiTableDatabase controller;

/// A callback for creating the required schema for the table.
///
/// It is only called called if the table did not exist prior.
void onCreate(Batch db, int version) {}

/// A callback for upgrading the schema of a table.
///
/// It is only called called if the database already exists and [version] is
/// higher than the last database version.
void onUpgrade(Batch db, int oldVersion, int newVersion) {}

/// A callback for downgrading the required schema for the table.
///
/// It is only called called when [version] is lower than the last database
/// version. This is a rare case and should only come up if a newer version of
/// your code has created a database that is then interacted with by an older
/// version of your code. You should try to avoid this scenario.
void onDowngrade(Batch db, int oldVersion, int newVersion) {}

/// A callback invoked after all the table versions are set.
Future<void> onOpen() async {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:meta/meta.dart';
import 'package:neon_storage/neon_sqlite.dart';
import 'package:sqflite_common/sqlite_api.dart';

/// A test implementation for testing a [Table].
@visibleForTesting
final class TestTableDatabase extends MultiTableDatabase {
/// Creates a test database for the given [table].
TestTableDatabase(Table table)
: _table = table,
super(tables: [table]);

final Table _table;

@override
String get name => '${_table.name}_database';

@override
String get path => inMemoryDatabasePath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'test_table_database.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// This library contains testing helpers for the neon storage.
@visibleForTesting
library;

import 'package:meta/meta.dart';

export 'src/testing/testing.dart';
34 changes: 34 additions & 0 deletions packages/neon_framework/packages/neon_storage/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: neon_storage
description: Storage management and abstractions for the neon framework.
version: 0.1.0
publish_to: none

environment:
sdk: ^3.0.0

dependencies:
built_collection: ^5.0.0
cookie_store:
git:
url: https://github.com/nextcloud/neon
path: packages/cookie_store
http: ^1.0.0
logging: ^1.0.0
meta: ^1.0.0
nextcloud: ^7.0.0
path: ^1.9.0
sqflite_common: ^2.0.0
sqflite_common_ffi: ^2.3.3
sqflite_common_ffi_web: ^0.4.3+1

dev_dependencies:
cookie_store_conformance_tests:
git:
url: https://github.com/nextcloud/neon
path: packages/cookie_store_conformance_tests
mocktail: ^1.0.4
neon_lints:
git:
url: https://github.com/nextcloud/neon
path: packages/neon_lints
test: ^1.25.8
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# melos_managed_dependency_overrides: neon_lints,cookie_store,cookie_store_conformance_tests,dynamite_runtime,nextcloud
dependency_overrides:
cookie_store:
path: ../../../cookie_store
cookie_store_conformance_tests:
path: ../../../cookie_store/packages/cookie_store_conformance_tests
dynamite_runtime:
path: ../../../dynamite/packages/dynamite_runtime
neon_lints:
path: ../../../neon_lints
nextcloud:
path: ../../../nextcloud
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:neon_storage/neon_sqlite.dart';
import 'package:test/test.dart';

void main() {
test('buildDatabasePath', () {
expect(
() => buildDatabasePath(null, 'database.db'),
throwsArgumentError,
);

expect(
() => buildDatabasePath(null, 'tmp/database.db'),
throwsArgumentError,
);

expect(
buildDatabasePath(null, 'database'),
equals('database.db'),
);

expect(
buildDatabasePath('/tmp', 'database'),
equals('/tmp/database.db'),
);
});
}
Loading