Skip to content

Commit

Permalink
Support foreign keys and unique constraints
Browse files Browse the repository at this point in the history
  • Loading branch information
tp committed Nov 11, 2024
1 parent f35b557 commit 3fb9bb2
Show file tree
Hide file tree
Showing 10 changed files with 516 additions and 54 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* `get` is now `read` to be symmetric to write (but instead of entities it takes keys, of course)
* `query` now takes an optional `where` parameter, so instead of `getAll()` you can now just use `query()` to get the same result
* `delete` now bundles multiple ways to identify entities to delete, offering a simpler API surface for the overall store
* Support foreign key (`referencing`) and `unique` constraints on indices
* With `referencing` one can ensure that the index's value points to an entityt with the same primary key in another store
* A `unique` index ensures that only one entity in the store uses the same value for that key

## 1.4.7

Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ packages:
path: ".."
relative: true
source: path
version: "2.0.0-dev2"
version: "2.0.0-dev3"
leak_tracker:
dependency: transitive
description:
Expand Down
37 changes: 37 additions & 0 deletions lib/src/index_collector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
part of 'index_entity_store.dart';

// NOTE(tp): This is implemented as a `class` with `call` such that we can
// correctly capture the index type `I` and forward that to `IndexColumn`
class IndexCollector<T> {
IndexCollector._(this._entityKey);

final String _entityKey;

final _indices = <IndexColumn<T, dynamic>>[];

/// Adds a new index defined by the mapping [index] and stores it in [as]
void call<I>(
I Function(T e) index, {
required String as,

/// If non-`null` this index points to the to the specified entity, like a foreign key contraints on the referenced entity's primary key
///
/// When inserting an entry in this store then, the referenced entity must already exist in the database.
/// When deleting the referenced entity, the referencing entities in this store must be removed beforehand (they will not get automatically deleted).
/// The index is not allowed to return `null`, but rather must always return a valid primary of the referenced entity.
String? referencing,

/// If `true`, the value for this index (`as`) must be unique in the entire store
bool unique = false,
}) {
_indices.add(
IndexColumn<T, I>._(
entity: _entityKey,
field: as,
getIndexValue: index,
referencedEntity: referencing,
unique: unique,
),
);
}
}
22 changes: 21 additions & 1 deletion lib/src/index_column.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ class IndexColumn<T /* entity type */, I /* index type */ > {
required String entity,
required String field,
required I Function(T e) getIndexValue,
required String? referencedEntity,
required bool unique,
}) : _entity = entity,
_field = field,
_getIndexValueFunc = getIndexValue {
_getIndexValueFunc = getIndexValue,
_referencedEntity = referencedEntity,
_unique = unique {
if (!_typeEqual<I, String>() &&
!_typeEqual<I, String?>() &&
!_typeEqual<I, num>() &&
Expand All @@ -28,6 +32,18 @@ class IndexColumn<T /* entity type */, I /* index type */ > {
'Can not create index for field "$field", as type can not be asserted. Type is $I.',
);
}

if (referencedEntity != null &&
(_typeEqual<I, String?>() ||
_typeEqual<I, num?>() ||
_typeEqual<I, int?>() ||
_typeEqual<I, double?>() ||
_typeEqual<I, bool?>() ||
_typeEqual<I, DateTime?>())) {
throw Exception(
'Can not create index for field "$field" referencing "$referencedEntity" where the "value" is nullable. Type is $I.',
);
}
}

final String _entity;
Expand All @@ -36,6 +52,10 @@ class IndexColumn<T /* entity type */, I /* index type */ > {

final I Function(T e) _getIndexValueFunc;

final String? _referencedEntity;

final bool _unique;

// Usually I, just for `DateTime` we have some special handling to support that out of the box (by converting to int)
dynamic _getIndexValue(T e) {
final v = _getIndexValueFunc(e);
Expand Down
84 changes: 44 additions & 40 deletions lib/src/index_entity_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:indexed_entity_store/indexed_entity_store.dart';
import 'package:sqlite3/sqlite3.dart';

part 'index_collector.dart';
part 'index_column.dart';
part 'index_columns.dart';
part 'query.dart';
Expand Down Expand Up @@ -268,7 +269,7 @@ class IndexedEntityStore<T, K> {
}

late final _insertIndexStatement = _database.prepare(
'INSERT INTO `index` (`type`, `entity`, `field`, `value`) VALUES (?, ?, ?, ?)',
'INSERT INTO `index` (`type`, `entity`, `field`, `value`, `referenced_type`, `unique`) VALUES (?, ?, ?, ?, ?, ?)',
persistent: true,
);

Expand All @@ -280,6 +281,8 @@ class IndexedEntityStore<T, K> {
_connector.getPrimaryKey(e),
indexColumn._field,
indexColumn._getIndexValue(e),
indexColumn._referencedEntity,
indexColumn._unique,
],
);
}
Expand Down Expand Up @@ -366,25 +369,47 @@ class IndexedEntityStore<T, K> {
}

void _ensureIndexIsUpToDate() {
final currentlyIndexedFields = _database
.select(
'SELECT DISTINCT `field` FROM `index` WHERE `type` = ?',
[this._entityKey],
)
.map((r) => r['field'] as String)
.toSet();

final currentEntityIndexedFields = _indexColumns._indexColumns.keys.toSet();

final missingFields =
currentEntityIndexedFields.difference(currentlyIndexedFields);

if (currentEntityIndexedFields.length != currentlyIndexedFields.length ||
missingFields.isNotEmpty) {
debugPrint(
'Need to update index as fields where changed or added',
);
final List<({String field, bool usesUnique, bool usesReference})>
currentDatabaseIndices = _database
.select(
'SELECT DISTINCT `field`, `unique` = 1 AS usesUnique, `referenced_type` IS NOT NULL AS usesReference FROM `index` WHERE `type` = ?',
[this._entityKey],
)
.map(
(row) => (
field: row['field'] as String,
usesUnique: row['usesUnique'] == 1,
usesReference: row['usesReference'] == 1,
),
)
.toList();

var needsIndexUpdate = false;
if (currentDatabaseIndices.length != _indexColumns._indexColumns.length) {
needsIndexUpdate = true;
} else {
for (final storeIndex in _indexColumns._indexColumns.values) {
final databaseIndex = currentDatabaseIndices
.where(
(dbIndex) =>
dbIndex.field == storeIndex._field &&
dbIndex.usesReference ==
(storeIndex._referencedEntity != null) &&
dbIndex.usesUnique == storeIndex._unique,
)
.firstOrNull;

if (databaseIndex == null) {
debugPrint(
'Index "${storeIndex._field}" (referencing "${storeIndex._referencedEntity}", unique "${storeIndex._unique}") was not found in the database and will now be created.',
);

needsIndexUpdate = true;
}
}
}

if (needsIndexUpdate) {
try {
_database.execute('BEGIN');

Expand Down Expand Up @@ -444,26 +469,5 @@ class IndexedEntityStore<T, K> {
}
}

// NOTE(tp): This is implemented as a `class` with `call` such that we can
// correctly capture the index type `I` and forward that to `IndexColumn`
class IndexCollector<T> {
IndexCollector._(this._entityKey);

final String _entityKey;

final _indices = <IndexColumn<T, dynamic>>[];

/// Adds a new index defined by the mapping [index] and stores it in [as]
void call<I>(I Function(T e) index, {required String as}) {
_indices.add(
IndexColumn<T, I>._(
entity: _entityKey,
field: as,
getIndexValue: index,
),
);
}
}

/// Specifies how the result should be sorted
typedef OrderByClause = (String column, SortOrder direction);
77 changes: 67 additions & 10 deletions lib/src/indexed_entity_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,42 @@ import 'package:indexed_entity_store/indexed_entity_store.dart';
import 'package:sqlite3/sqlite3.dart';

class IndexedEntityDabase {
factory IndexedEntityDabase.open(String path) {
return IndexedEntityDabase._(path);
factory IndexedEntityDabase.open(
String path, {
@visibleForTesting int targetSchemaVersion = 4,
}) {
return IndexedEntityDabase._(
path,
targetSchemaVersion: targetSchemaVersion,
);
}

IndexedEntityDabase._(String path) : _database = sqlite3.open(path) {
IndexedEntityDabase._(
String path, {
required int targetSchemaVersion,
}) : _database = sqlite3.open(path) {
final res = _database.select(
"SELECT name FROM sqlite_master WHERE type='table' AND name='entity';",
);
if (res.isEmpty) {
debugPrint('Creating new DB');

_initialDBSetup();
_v2Migration();
_v3Migration();
} else if (_dbVersion == 1) {
debugPrint('Migrating DB to v2');
}

if (_dbVersion < targetSchemaVersion) {
_v2Migration();
}

if (_dbVersion < targetSchemaVersion) {
_v3Migration();
} else if (_dbVersion == 2) {
_v3Migration();
}

assert(_dbVersion == 3);
if (_dbVersion < targetSchemaVersion) {
_v4Migration();
}

assert(_dbVersion == targetSchemaVersion);

// Foreign keys need to be re-enable on every open (session)
// https://www.sqlite.org/foreignkeys.html#fk_enable
Expand Down Expand Up @@ -99,6 +111,51 @@ class IndexedEntityDabase {
);
}

void _v4Migration() {
// New `index` table schema supporting unique and and foreign key constraints

_database.execute('DROP TABLE `index`');

_database.execute(
'CREATE TABLE `index` ( '
' `type` TEXT NOT NULL, '
' `entity` NOT NULL, '
' `field` TEXT NOT NULL, '
' `value`, '
' `referenced_type` TEXT, '
' `unique` BOOLEAN NOT NULL DEFAULT FALSE, '
' FOREIGN KEY (`type`, `entity`) REFERENCES `entity` (`type`, `key`) ON DELETE CASCADE, '
' FOREIGN KEY (`referenced_type`, `value`) REFERENCES `entity` (`type`, `key`), '
' PRIMARY KEY ( `type`, `entity`, `field` )'
')',
);

_database.execute(
'CREATE INDEX index_field_values '
'ON `index` ( `type`, `field`, `value` )',
);

// This index is needed to not pay a performance penalty for the new foreign key constraint (between entities)
// Otherwise the `insertMany` update duration would increase 5x (even without making use of the reference, passing `null`).
// With this index, even though it only tracks non-`null` references (which would be rare), overall insert performance stays the same as before.
_database.execute(
'CREATE INDEX index_field_values_FK '
'ON `index` ( `referenced_type`, `value` )'
'WHERE `referenced_type` IS NOT NULL ',
);

_database.execute(
'CREATE UNIQUE INDEX index_type_entity_field_unique_index '
'ON `index` ( `type`, `field`, `value` ) '
'WHERE `unique` = 1 ',
);

_database.execute(
'UPDATE `metadata` SET `value` = ? WHERE `key` = ?',
[4, 'version'],
);
}

IndexedEntityStore<T, K> entityStore<T, K, S>(
IndexedEntityConnector<T, K, S> connector,
) {
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: indexed_entity_store
description: A fast, simple, and synchronous entity store for Flutter applications.
version: 2.0.0-dev2
version: 2.0.0-dev3
repository: https://github.com/LunaONE/indexed_entity_store

environment:
Expand Down
Loading

0 comments on commit 3fb9bb2

Please sign in to comment.