Skip to content

Commit

Permalink
feat(neon_storage): add neon_storage library
Browse files Browse the repository at this point in the history
Signed-off-by: Nikolas Rimikis <[email protected]>
  • Loading branch information
Leptopoda committed Sep 21, 2024
1 parent 791f4f6 commit 43e5390
Show file tree
Hide file tree
Showing 19 changed files with 566 additions and 0 deletions.
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,4 @@
include: package:neon_lints/dart.yaml

custom_lint:
enable_all_lint_rules: 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,167 @@
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] that manages 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;

/// 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.
///
/// Once completed [init] must be called again.
@visibleForTesting
Future<void> resetDatabase() 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;
}

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;
}

_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-bits 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 version is set.
FutureOr<void> onOpen() {}
}
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

0 comments on commit 43e5390

Please sign in to comment.