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 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../migrator.dart';
|
||||||
import '../utils/sql_builder.dart';
|
import '../utils/sql_builder.dart';
|
||||||
|
|
||||||
/// The name of the HLC function used in the database.
|
/// 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.
|
/// Provides the CRDT control trigger statements for the database.
|
||||||
abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
|
abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
|
||||||
/// Creates a new instance of [OfflineSyncTriggers].
|
/// 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.
|
/// 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',
|
tableAlias: operation == Operation.insert ? 'NEW' : 'OLD',
|
||||||
);
|
);
|
||||||
|
|
||||||
final columnNames = operation == Operation.delete
|
final columnNames = [
|
||||||
? [isDeletedColumnName]
|
if (operation != Operation.delete) ...table.nonGenerated,
|
||||||
: table.nonGenerated;
|
if (operation != Operation.update ||
|
||||||
|
conflictingBias == ConflictingBias.updateWins)
|
||||||
|
isDeletedColumnName,
|
||||||
|
];
|
||||||
|
|
||||||
final columnInserts = columnNames.map((c) {
|
final columnInserts = columnNames.map((c) {
|
||||||
|
final columnValue = c == isDeletedColumnName
|
||||||
|
? operation == Operation.delete
|
||||||
|
? 'TRUE'
|
||||||
|
: 'FALSE'
|
||||||
|
: 'NEW."$c"';
|
||||||
|
|
||||||
return ' SELECT\n${[
|
return ' SELECT\n${[
|
||||||
' \'$c\' AS "column_name"',
|
' \'$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"',
|
if (operation == Operation.update) ' NEW."$c" IS NOT OLD."$c" AS "value_changed"',
|
||||||
].join(',\n')}';
|
].join(',\n')}';
|
||||||
});
|
});
|
||||||
|
|
@ -111,7 +127,7 @@ ${columnInserts.join('\n UNION ALL\n')}
|
||||||
/// Provides the CRDT control trigger statements for the SQLite3 database.
|
/// Provides the CRDT control trigger statements for the SQLite3 database.
|
||||||
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
|
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
|
||||||
/// Creates a new instance of [Sqlite3OfflineSyncTriggers].
|
/// Creates a new instance of [Sqlite3OfflineSyncTriggers].
|
||||||
Sqlite3OfflineSyncTriggers(super.crdtDb);
|
Sqlite3OfflineSyncTriggers(super.crdtDb, [super.conflictingBias]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String wrapAsCreateTriggerStatement({
|
String wrapAsCreateTriggerStatement({
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@ import 'crdt/crdt.dart';
|
||||||
import 'database/database.dart';
|
import 'database/database.dart';
|
||||||
import 'database/triggers.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].
|
/// Runs migrations declared by a [MigrationStrategy].
|
||||||
class OfflineSyncMigrator extends Migrator {
|
class OfflineSyncMigrator extends Migrator {
|
||||||
/// Used internally by drift when opening the database.
|
/// Used internally by drift when opening the database.
|
||||||
|
|
@ -11,6 +20,7 @@ class OfflineSyncMigrator extends Migrator {
|
||||||
super.database, {
|
super.database, {
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.nodeId,
|
required this.nodeId,
|
||||||
|
this.conflictingBias = ConflictingBias.deleteWins,
|
||||||
|
|
||||||
/// Restrict tables to synchronize. If not provided, all tables will be considered.
|
/// Restrict tables to synchronize. If not provided, all tables will be considered.
|
||||||
List<TableInfo>? synchronizedTables,
|
List<TableInfo>? synchronizedTables,
|
||||||
|
|
@ -38,6 +48,11 @@ class OfflineSyncMigrator extends Migrator {
|
||||||
/// Tables to be synchronized with CRDT.
|
/// Tables to be synchronized with CRDT.
|
||||||
final List<TableInfo> synchronizedTables;
|
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.
|
/// The database instance to apply the CRDT schema to.
|
||||||
late final crdtDb = CrdtDatabase(
|
late final crdtDb = CrdtDatabase(
|
||||||
database.executor,
|
database.executor,
|
||||||
|
|
@ -103,7 +118,7 @@ class OfflineSyncMigrator extends Migrator {
|
||||||
|
|
||||||
Future<void> _createTriggers(TableInfo table) async {
|
Future<void> _createTriggers(TableInfo table) async {
|
||||||
final triggerCreator = switch (database.executor.dialect) {
|
final triggerCreator = switch (database.executor.dialect) {
|
||||||
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb),
|
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb, conflictingBias),
|
||||||
// TODO: Implement the triggers for the Postgres dialect.
|
// TODO: Implement the triggers for the Postgres dialect.
|
||||||
_ => throw UnsupportedError('Unsupported dialect: ${database.executor.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
|
// Perform an insert to the database to get the exact changeset that would
|
||||||
// be generated by the CRDT triggers.
|
// be generated by the CRDT triggers.
|
||||||
await database.tableWithEveryColumnType.insertOne(createdData);
|
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;
|
testHlc = expectedChangeset.first.hlcTimestamp;
|
||||||
await crdtDb.crdtDataTable.deleteAll();
|
await crdtDb.crdtDataTable.deleteAll();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,10 @@ void main() {
|
||||||
() async {
|
() async {
|
||||||
final changeset = await crdt.getChangeset();
|
final changeset = await crdt.getChangeset();
|
||||||
final changesetColumns = changeset.map((e) => e.columnName).toSet();
|
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(changeset.length, expectedColumns.length);
|
||||||
expect(changesetColumns, equals(expectedColumns));
|
expect(changesetColumns, equals(expectedColumns));
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,22 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('then the trigger is executed and the CRDT data table', () {
|
group('then the trigger is executed and the CRDT data table', () {
|
||||||
|
late String isDeletedColumnName;
|
||||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
final migrator = database.createMigrator();
|
final migrator = database.createMigrator();
|
||||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||||
|
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has one entry for each column of the target table.', () async {
|
test('has one entry for each column of the target table.', () async {
|
||||||
expect(
|
expect(
|
||||||
allCrdtDataEntries.map((e) => e.columnName).toSet(),
|
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(
|
createdRowId = await database.managers.todosTable.create(
|
||||||
(t) => t(content: initialContent),
|
(t) => t(content: initialContent),
|
||||||
);
|
);
|
||||||
|
rowId = createdRowId.toString();
|
||||||
|
|
||||||
final migrator = database.createMigrator();
|
final migrator = database.createMigrator();
|
||||||
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||||
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
|
||||||
rowId = createdRowId.toString();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('when updating the existing row with a different value', () {
|
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',
|
'then the trigger is executed and the deleted row in the CRDT data table',
|
||||||
() {
|
() {
|
||||||
late List<CrdtDataEntry> allCrdtDataEntries;
|
late List<CrdtDataEntry> allCrdtDataEntries;
|
||||||
late String rowId;
|
late String isDeletedColumnName;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
final migrator = database.createMigrator();
|
final migrator = database.createMigrator();
|
||||||
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
|
||||||
|
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
|
||||||
rowId = createdRowId.toString();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has the deleted flag column set to true.', () async {
|
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);
|
expect(crdtData.unwrappedValue, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has the deleted HLC timestamp greater than the created.', () async {
|
test('has the deleted HLC timestamp greater than the created.', () async {
|
||||||
final deletedEntry = allCrdtDataEntries.getField(
|
final deletedEntry = allCrdtDataEntries.getField(
|
||||||
rowId,
|
rowId,
|
||||||
'__crdt_is_deleted',
|
isDeletedColumnName,
|
||||||
);
|
);
|
||||||
expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc));
|
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> {
|
extension on Iterable<CrdtDataEntry> {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
'status' AS "column_name",
|
'status' AS "column_name",
|
||||||
NEW."status" AS "raw_value"
|
NEW."status" AS "raw_value"
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'__crdt_is_deleted' AS "column_name",
|
||||||
|
FALSE AS "raw_value"
|
||||||
)
|
)
|
||||||
WHERE TRUE
|
WHERE TRUE
|
||||||
ON CONFLICT ("user_id", "table_name", "column_name", "row_id")
|
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