feat: Support specifying the conflicting bias
This commit is contained in:
parent
9dd6cbea3b
commit
abc16d1895
6 changed files with 205 additions and 16 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../migrator.dart';
|
||||
import '../utils/sql_builder.dart';
|
||||
|
||||
/// The name of the HLC function used in the database.
|
||||
|
|
@ -15,7 +16,13 @@ enum Operation { insert, update, delete }
|
|||
/// Provides the CRDT control trigger statements for the database.
|
||||
abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
|
||||
/// Creates a new instance of [OfflineSyncTriggers].
|
||||
OfflineSyncTriggers(super.crdtDb);
|
||||
OfflineSyncTriggers(
|
||||
super.crdtDb, [
|
||||
this.conflictingBias = ConflictingBias.deleteWins,
|
||||
]);
|
||||
|
||||
/// The bias to use when conflicting operations are detected.
|
||||
final ConflictingBias conflictingBias;
|
||||
|
||||
/// Wraps the provided SQL statement as a CREATE TRIGGER statement.
|
||||
///
|
||||
|
|
@ -52,14 +59,23 @@ abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
|
|||
tableAlias: operation == Operation.insert ? 'NEW' : 'OLD',
|
||||
);
|
||||
|
||||
final columnNames = operation == Operation.delete
|
||||
? [isDeletedColumnName]
|
||||
: table.nonGenerated;
|
||||
final columnNames = [
|
||||
if (operation != Operation.delete) ...table.nonGenerated,
|
||||
if (operation != Operation.update ||
|
||||
conflictingBias == ConflictingBias.updateWins)
|
||||
isDeletedColumnName,
|
||||
];
|
||||
|
||||
final columnInserts = columnNames.map((c) {
|
||||
final columnValue = c == isDeletedColumnName
|
||||
? operation == Operation.delete
|
||||
? 'TRUE'
|
||||
: 'FALSE'
|
||||
: 'NEW."$c"';
|
||||
|
||||
return ' SELECT\n${[
|
||||
' \'$c\' AS "column_name"',
|
||||
' ${c == isDeletedColumnName ? 'TRUE' : 'NEW."$c"'} AS "raw_value"',
|
||||
' $columnValue AS "raw_value"',
|
||||
if (operation == Operation.update) ' NEW."$c" IS NOT OLD."$c" AS "value_changed"',
|
||||
].join(',\n')}';
|
||||
});
|
||||
|
|
@ -111,7 +127,7 @@ ${columnInserts.join('\n UNION ALL\n')}
|
|||
/// Provides the CRDT control trigger statements for the SQLite3 database.
|
||||
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
|
||||
/// Creates a new instance of [Sqlite3OfflineSyncTriggers].
|
||||
Sqlite3OfflineSyncTriggers(super.crdtDb);
|
||||
Sqlite3OfflineSyncTriggers(super.crdtDb, [super.conflictingBias]);
|
||||
|
||||
@override
|
||||
String wrapAsCreateTriggerStatement({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ import 'crdt/crdt.dart';
|
|||
import 'database/database.dart';
|
||||
import 'database/triggers.dart';
|
||||
|
||||
/// The bias to use when conflicting operations are detected.
|
||||
enum ConflictingBias {
|
||||
/// Newer updates are overridden by deletes.
|
||||
deleteWins,
|
||||
|
||||
/// Deletes are undone by newer conflicting updates.
|
||||
updateWins,
|
||||
}
|
||||
|
||||
/// Runs migrations declared by a [MigrationStrategy].
|
||||
class OfflineSyncMigrator extends Migrator {
|
||||
/// Used internally by drift when opening the database.
|
||||
|
|
@ -11,6 +20,7 @@ class OfflineSyncMigrator extends Migrator {
|
|||
super.database, {
|
||||
required this.userId,
|
||||
required this.nodeId,
|
||||
this.conflictingBias = ConflictingBias.deleteWins,
|
||||
|
||||
/// Restrict tables to synchronize. If not provided, all tables will be considered.
|
||||
List<TableInfo>? synchronizedTables,
|
||||
|
|
@ -38,6 +48,11 @@ class OfflineSyncMigrator extends Migrator {
|
|||
/// Tables to be synchronized with CRDT.
|
||||
final List<TableInfo> synchronizedTables;
|
||||
|
||||
/// The bias to use when conflicting operations are detected.
|
||||
///
|
||||
/// The default is [ConflictingBias.deleteWins], which is the most common case.
|
||||
final ConflictingBias conflictingBias;
|
||||
|
||||
/// The database instance to apply the CRDT schema to.
|
||||
late final crdtDb = CrdtDatabase(
|
||||
database.executor,
|
||||
|
|
@ -103,7 +118,7 @@ class OfflineSyncMigrator extends Migrator {
|
|||
|
||||
Future<void> _createTriggers(TableInfo table) async {
|
||||
final triggerCreator = switch (database.executor.dialect) {
|
||||
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb),
|
||||
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb, conflictingBias),
|
||||
// TODO: Implement the triggers for the Postgres dialect.
|
||||
_ => throw UnsupportedError('Unsupported dialect: ${database.executor.dialect}'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ void main() {
|
|||
// Perform an insert to the database to get the exact changeset that would
|
||||
// be generated by the CRDT triggers.
|
||||
await database.tableWithEveryColumnType.insertOne(createdData);
|
||||
expectedChangeset = await crdtDb.crdtDataTable.all().get();
|
||||
expectedChangeset = await crdtDb.managers.crdtDataTable
|
||||
.filter(
|
||||
(f) => f.columnName.equals(crdtDb.sqlBuilder.isDeletedColumnName).not(),
|
||||
)
|
||||
.get();
|
||||
testHlc = expectedChangeset.first.hlcTimestamp;
|
||||
await crdtDb.crdtDataTable.deleteAll();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ void main() {
|
|||
() async {
|
||||
final changeset = await crdt.getChangeset();
|
||||
final changesetColumns = changeset.map((e) => e.columnName).toSet();
|
||||
final expectedColumns = database.todosTable.$columns.map((c) => c.$name);
|
||||
final expectedColumns = [
|
||||
...database.todosTable.$columns.map((c) => c.$name),
|
||||
crdtDb.sqlBuilder.isDeletedColumnName,
|
||||
];
|
||||
|
||||
expect(changeset.length, expectedColumns.length);
|
||||
expect(changesetColumns, equals(expectedColumns));
|
||||
|
|
|
|||
|
|
@ -33,17 +33,22 @@ void main() {
|
|||
});
|
||||
|
||||
group('then the trigger is executed and the CRDT data table', () {
|
||||
late String isDeletedColumnName;
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has one entry for each column of the target table.', () async {
|
||||
expect(
|
||||
allCrdtDataEntries.map((e) => e.columnName).toSet(),
|
||||
equals(database.todosTable.$columns.map((c) => c.$name).toSet()),
|
||||
equals(
|
||||
database.todosTable.$columns.map((c) => c.$name).toSet()
|
||||
..add(isDeletedColumnName),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -81,11 +86,11 @@ void main() {
|
|||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: initialContent),
|
||||
);
|
||||
rowId = createdRowId.toString();
|
||||
|
||||
final migrator = database.createMigrator();
|
||||
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||
rowId = createdRowId.toString();
|
||||
});
|
||||
|
||||
group('when updating the existing row with a different value', () {
|
||||
|
|
@ -206,24 +211,23 @@ void main() {
|
|||
'then the trigger is executed and the deleted row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
late String rowId;
|
||||
late String isDeletedColumnName;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
|
||||
rowId = createdRowId.toString();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has the deleted flag column set to true.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, '__crdt_is_deleted');
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
|
||||
expect(crdtData.unwrappedValue, 1);
|
||||
});
|
||||
|
||||
test('has the deleted HLC timestamp greater than the created.', () async {
|
||||
final deletedEntry = allCrdtDataEntries.getField(
|
||||
rowId,
|
||||
'__crdt_is_deleted',
|
||||
isDeletedColumnName,
|
||||
);
|
||||
expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc));
|
||||
});
|
||||
|
|
@ -231,6 +235,93 @@ void main() {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Given a database with a previously deleted row from a synchronized table', () {
|
||||
const initialContent = 'test';
|
||||
late String isDeletedColumnName;
|
||||
late int createdRowId;
|
||||
late Hlc deletedHlc;
|
||||
late String rowId;
|
||||
|
||||
setUp(() async {
|
||||
createdRowId = await database.managers.todosTable.create(
|
||||
(t) => t(content: initialContent),
|
||||
);
|
||||
rowId = createdRowId.toString();
|
||||
|
||||
final migrator = database.createMigrator();
|
||||
|
||||
final deleted = await database.managers.todosTable
|
||||
.filter((t) => t.id.equals(RowId(createdRowId)))
|
||||
.delete();
|
||||
expect(deleted, equals(1));
|
||||
|
||||
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
deletedHlc = allCrdtDataEntries.getField(rowId, isDeletedColumnName).hlcTimestamp;
|
||||
});
|
||||
|
||||
group('when re-inserting the previously deleted row', () {
|
||||
setUp(() async {
|
||||
await database.managers.todosTable.create(
|
||||
(t) => t(
|
||||
id: Value(RowId(createdRowId)),
|
||||
content: initialContent,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'then the row has been re-inserted in the target table with the same data.',
|
||||
() async {
|
||||
final row = await database.managers.todosTable.getSingle();
|
||||
|
||||
expect(row, isNotNull);
|
||||
expect(row.id, equals(createdRowId));
|
||||
expect(row.content, initialContent);
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'then the trigger is executed and the re-inserted row in the CRDT data table',
|
||||
() {
|
||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||
|
||||
setUp(() async {
|
||||
final migrator = database.createMigrator();
|
||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
test('has the deleted flag column set to false.', () async {
|
||||
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
|
||||
expect(crdtData.unwrappedValue, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'has the deleted flag HLC timestamp greater than the deleted.',
|
||||
() async {
|
||||
final deletedEntry = allCrdtDataEntries.getField(
|
||||
rowId,
|
||||
isDeletedColumnName,
|
||||
);
|
||||
expect(deletedEntry.hlcTimestamp, greaterThan(deletedHlc));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'has the HLC timestamp updated for newly inserted columns.',
|
||||
() async {
|
||||
expect(
|
||||
allCrdtDataEntries.map((e) => e.hlcTimestamp),
|
||||
everyElement(greaterThan(deletedHlc)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on Iterable<CrdtDataEntry> {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ BEGIN
|
|||
SELECT
|
||||
'status' AS "column_name",
|
||||
NEW."status" AS "raw_value"
|
||||
UNION ALL
|
||||
SELECT
|
||||
'__crdt_is_deleted' AS "column_name",
|
||||
FALSE AS "raw_value"
|
||||
)
|
||||
WHERE TRUE
|
||||
ON CONFLICT ("user_id", "table_name", "column_name", "row_id")
|
||||
|
|
@ -312,4 +316,60 @@ END;''',
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This test assures that the above tests are for [ConflictingBias.deleteWins]
|
||||
// and avoids the need for duplicating all tests.
|
||||
test('Given a SQLite3 migrator with no specified conflicting bias '
|
||||
'then it generates the triggers with the deleteWins conflicting bias.', () {
|
||||
final migrator = database.createMigrator();
|
||||
expect(migrator.conflictingBias, ConflictingBias.deleteWins);
|
||||
});
|
||||
|
||||
group('Given a SQLite3 database with a table and updateWins conflicting bias', () {
|
||||
late OfflineSyncMigrator migrator;
|
||||
late String isDeletedColumnName;
|
||||
|
||||
setUp(() {
|
||||
migrator = OfflineSyncMigrator(
|
||||
database,
|
||||
userId: testUserId,
|
||||
nodeId: testNodeId,
|
||||
conflictingBias: ConflictingBias.updateWins,
|
||||
synchronizedTables: [
|
||||
database.todosTable,
|
||||
],
|
||||
);
|
||||
|
||||
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||
});
|
||||
|
||||
group('when generating trigger statements', () {
|
||||
late List<String> triggers;
|
||||
|
||||
setUp(() {
|
||||
final generator = Sqlite3OfflineSyncTriggers(
|
||||
migrator.crdtDb,
|
||||
ConflictingBias.updateWins,
|
||||
);
|
||||
triggers = generator.generateCreateTriggerStatements(database.todosTable);
|
||||
});
|
||||
|
||||
test('then the update trigger includes the deleted flag column.', () {
|
||||
final updateStatement = triggers.firstWhere(
|
||||
(trigger) => trigger.contains('AFTER UPDATE ON "todos"'),
|
||||
);
|
||||
|
||||
expect(
|
||||
updateStatement,
|
||||
contains('''
|
||||
UNION ALL
|
||||
SELECT
|
||||
'$isDeletedColumnName' AS "column_name",
|
||||
FALSE AS "raw_value",
|
||||
NEW."__crdt_is_deleted" IS NOT OLD."__crdt_is_deleted" AS "value_changed"
|
||||
'''),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue