fix: Fix the conversion between CrdtDataEntry and Insertable

This commit is contained in:
Marcelo Soares 2026-01-25 22:52:16 -03:00
commit 825fd4d97c
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
10 changed files with 196 additions and 132 deletions

View file

@ -4,6 +4,7 @@
library; library;
export 'src/crdt.dart'; export 'src/crdt.dart';
export 'src/crdt/extensions/convert.dart';
export 'src/crdt/extensions/entry.dart'; export 'src/crdt/extensions/entry.dart';
export 'src/crdt/extensions/extract.dart'; export 'src/crdt/extensions/extract.dart';
export 'src/crdt_base.dart'; export 'src/crdt_base.dart';

View file

@ -0,0 +1,59 @@
import 'package:drift/drift.dart';
import '../../database.dart';
import '../../hlc/converter.dart';
/// Extensions for [CrdtDatabase] to convert data to [CrdtDataEntry].
extension CrdtDatabaseConvertExtensions on CrdtDatabase {
/// Converts the [Insertable] object to a list of [CrdtDataEntry].
///
/// Note that the generated entries will have the same HLC timestamp for all
/// columns and will overwrite individual column updates if newer.
///
/// Returns a list of [CrdtDataEntry].
Iterable<CrdtDataEntry> convertToCrdtDataEntry<T extends Insertable>(
T data,
Hlc hlcTimestamp,
) {
final json = data.toJsonForDatabase(_generationContext);
final tableInfo = synchronizedTables.whereType<TableInfo<dynamic, T>>().single;
final rowIdValues = [for (final c in tableInfo.$primaryKey) json[c.$name]];
return tableInfo.$columns.map(
(c) => CrdtDataEntry(
userId: userId,
tblName: tableInfo.actualTableName,
columnName: c.$name,
rowId: rowIdValues.join(sqlBuilder.rowIdSeparator),
rawValue: json[c.$name]?.toDriftAny(),
hlcTimestamp: hlcTimestamp,
),
);
}
/// Gets a [GenerationContext] for the database.
GenerationContext get _generationContext => GenerationContext.fromDb(this);
}
extension on Insertable {
Map<String, Object?> toJsonForDatabase(GenerationContext context) {
final expressions = toColumns(false);
return {
for (final e in expressions.entries) e.key: e.value.extractValue(context),
};
}
}
extension on Expression {
Object? extractValue(GenerationContext context) {
final exp = this;
if (exp is! Variable) {
throw ArgumentError.value(exp, 'exp', 'Expected a Variable');
}
return exp.mapToSimpleValue(context);
}
}
extension on Object {
DriftAny? toDriftAny() => DriftAny(this);
}

View file

@ -1,9 +1,13 @@
import 'package:drift/drift.dart';
import '../../database.dart'; import '../../database.dart';
import '../../hlc/converter.dart'; import '../../hlc/converter.dart';
/// Extensions for [Iterable<CrdtDataEntry>] to get the affected tables and row IDs. /// Extensions for the [CrdtDataEntry] class.
extension CrdtDataEntryUnwrapExtensions on CrdtDataEntry {
/// Returns the unwrapped raw value, if any.
Object? get unwrappedValue => rawValue?.rawSqlValue;
}
/// Extensions for an [Iterable] of [CrdtDataEntry].
extension CrdtDataEntryExtensions on Iterable<CrdtDataEntry> { extension CrdtDataEntryExtensions on Iterable<CrdtDataEntry> {
/// Gets the affected tables. /// Gets the affected tables.
Iterable<String> get affectedTables => map((e) => e.tblName).toSet(); Iterable<String> get affectedTables => map((e) => e.tblName).toSet();
@ -14,41 +18,8 @@ extension CrdtDataEntryExtensions on Iterable<CrdtDataEntry> {
/// Gets the entries for a given table. /// Gets the entries for a given table.
Iterable<CrdtDataEntry> forTable(String tableName) => Iterable<CrdtDataEntry> forTable(String tableName) =>
where((e) => e.tblName == tableName); where((e) => e.tblName == tableName);
}
/// Returns the entries with the HLC replaced by the [hlcTimestamp].
/// Extensions to convert Drift [Insertable] objects to [CrdtDataEntry]. Iterable<CrdtDataEntry> withHlc(Hlc hlcTimestamp) =>
extension CrdtDataEntryConversion<T extends DataClass> on Insertable<T> { map((e) => e.copyWith(hlcTimestamp: hlcTimestamp));
/// Converts the [Insertable] object to a list of [CrdtDataEntry].
///
/// Note that the generated entries will have the same HLC timestamp for all
/// columns and will overwrite individual column updates if newer. The
/// [database] parameter is required to find the table information to map
/// the columns.
///
/// Returns a list of [CrdtDataEntry].
Iterable<CrdtDataEntry> toCrdtDataEntry(CrdtDatabase database, Hlc hlcTimestamp) {
final json = (this as DataClass).toJson();
final tableInfo =
database.synchronizedTables.whereType<TableInfo<dynamic, T>>().single;
final rowId = [
for (final c in tableInfo.$primaryKey) json[c.$name],
].join(database.sqlBuilder.rowIdSeparator);
return tableInfo.$columns.map(
(c) => CrdtDataEntry(
userId: database.userId,
tblName: tableInfo.actualTableName,
columnName: c.$name,
rowId: rowId,
rawValue: (json[c.$name] as Object?)?.toDriftAny(),
hlcTimestamp: hlcTimestamp,
),
);
}
}
extension on Object {
DriftAny? toDriftAny() => DriftAny(this);
} }

View file

@ -5,36 +5,44 @@ import '../../database.dart';
/// Extensions for [CrdtDatabase] to extract data from the CRDT data table. /// Extensions for [CrdtDatabase] to extract data from the CRDT data table.
extension CrdtDatabaseExtractExtensions on CrdtDatabase { extension CrdtDatabaseExtractExtensions on CrdtDatabase {
/// Gets a single data entry from the CRDT data table. /// Gets a single data entry from the CRDT data table.
Future<T?> getSingleFromCrdtData<T extends Insertable>({ Future<T?> getSingleFromCrdtData<T extends Insertable>(String rowId) async {
required String tableName, final data = await getFromCrdtData<T>([rowId]);
required String rowId,
}) async {
final data = await getFromCrdtData<T>(tableName: tableName, rowIds: [rowId]);
return data.firstOrNull; return data.firstOrNull;
} }
/// Gets a list of data entries from the CRDT data table. /// Gets a list of data entries from the CRDT data table.
Future<List<T>> getFromCrdtData<T extends Insertable>({ Future<Iterable<T>> getFromCrdtData<T extends Insertable>([
required String tableName, Iterable<String>? rowIds,
required Iterable<String> rowIds, ]) async {
}) async { final tableInfo = synchronizedTables.find<T>();
final tableInfo = synchronizedTables.find(tableName); final tableName = tableInfo.actualTableName;
final crdtDataEntries = await managers.crdtDataTable final crdtDataEntries = await managers.crdtDataTable.filter(
.filter((o) => o.tblName.equals(tableName) & o.rowId.isIn(rowIds)) (o) {
.get(); var condition = o.tblName.equals(tableName);
if (rowIds != null) {
condition &= o.rowId.isIn(rowIds);
}
return condition;
},
).get();
return crdtDataEntries.convert<T>(tableInfo); return tableInfo.fromCrdtDataEntries<T>(crdtDataEntries);
} }
} }
extension on List<CrdtDataEntry> { /// Extensions for [TableInfo] to convert [CrdtDataEntry] to [Insertable].
Future<List<T>> convert<T extends Insertable>(TableInfo tableInfo) async { extension ConvertCrdtDataEntry on TableInfo {
final foundRowIds = map((e) => e.rowId).toSet(); /// Converts [CrdtDataEntry] to [Insertable].
Future<Iterable<T>> fromCrdtDataEntries<T extends Insertable>(
Iterable<CrdtDataEntry> entries,
) async {
final foundRowIds = entries.map((e) => e.rowId).toSet();
return [ return [
for (final rowId in foundRowIds) for (final rowId in foundRowIds)
await tableInfo.map({ await map({
for (final c in tableInfo.$columns) for (final c in $columns)
c.$name: firstWhere((e) => e.rowId == rowId && e.columnName == c.$name) c.$name: entries
.firstWhere((e) => e.rowId == rowId && e.columnName == c.$name)
.rawValue .rawValue
?.rawSqlValue, ?.rawSqlValue,
}) as T, }) as T,

View file

@ -46,6 +46,7 @@ class CrdtDatabase extends _$CrdtDatabase {
/// should ever be run on this database to avoid messing with the user schema. /// should ever be run on this database to avoid messing with the user schema.
@override @override
int schemaVersion = 1; int schemaVersion = 1;
}
/// Extension methods for the [List<TableInfo>] class to find a synchronized table. /// Extension methods for the [List<TableInfo>] class to find a synchronized table.
extension FindSynchronizedTable on List<TableInfo> { extension FindSynchronizedTable on List<TableInfo> {
@ -55,11 +56,17 @@ extension FindSynchronizedTable on List<TableInfo> {
/// Gets the synchronized table information for the given table name. /// Gets the synchronized table information for the given table name.
/// ///
/// If the table is not found, an [ArgumentError] is thrown. /// If the table is not found, an [ArgumentError] is thrown.
TableInfo find(String tableName) { TableInfo find<T extends Insertable>([String? tableName]) {
if (tableName == null && T == dynamic) {
throw ArgumentError('Table name is required when T is dynamic.');
}
return firstWhere( return firstWhere(
(t) => t.actualTableName == tableName, (t) =>
(tableName == null && t is TableInfo<Table, T>) ||
t.actualTableName == tableName,
orElse: () => throw ArgumentError( orElse: () => throw ArgumentError(
'Table "$tableName" not found in synchronized tables.', 'Table ${tableName != null ? '"$tableName"' : 'for type "$T"'} not '
'found in synchronized tables.',
), ),
); );
} }

View file

@ -13,20 +13,7 @@ void main() {
late Hlc testHlc; late Hlc testHlc;
setUp(() async { setUp(() async {
createdData = TableWithEveryColumnTypeData( createdData = _createTestData();
id: const RowId(1),
aBool: true,
aDateTime: DateTime.now(),
aText: 'test',
anInt: 1,
anInt64: BigInt.one,
aReal: 1.0,
aBlob: Uint8List.fromList([1, 2, 3]),
anIntEnum: TodoStatus.open,
aTextWithConverter: MyCustomObject('test'),
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
);
(_, crdtDb, _) = database.crdtContext; (_, crdtDb, _) = database.crdtContext;
testHlc = Hlc.now(crdtDb.nodeId); testHlc = Hlc.now(crdtDb.nodeId);
}); });
@ -35,7 +22,7 @@ void main() {
late Iterable<CrdtDataEntry> entries; late Iterable<CrdtDataEntry> entries;
setUp(() async { setUp(() async {
entries = createdData.toCrdtDataEntry(crdtDb, testHlc).toList(); entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
}); });
test('then it creates an entry for each column.', () async { test('then it creates an entry for each column.', () async {
@ -63,32 +50,69 @@ void main() {
}); });
test('then all entries have expected column names and values.', () async { test('then all entries have expected column names and values.', () async {
final entriesMap = { final mappedData =
for (final entry in entries) entry.columnName: entry.rawValue, await database.tableWithEveryColumnType.fromCrdtDataEntries(entries);
}; expect(mappedData.single, equals(createdData));
});
});
});
expect(entriesMap['a_bool'], equals(createdData.aBool?.driftAny)); group('Given a changeset converted from a data object with every column type', () {
expect(entriesMap['a_date_time'], isNotNull); late OfflineSyncCrdt crdt;
expect(entriesMap['a_text'], equals(createdData.aText?.driftAny)); late CrdtDatabase crdtDb;
expect(entriesMap['an_int'], equals(createdData.anInt?.driftAny)); late TableWithEveryColumnTypeData createdData;
expect(entriesMap['an_int64'], equals(createdData.anInt64?.driftAny)); late List<CrdtDataEntry> expectedChangeset;
expect(entriesMap['a_real'], equals(createdData.aReal?.driftAny)); late Hlc testHlc;
expect(entriesMap['a_blob'], equals(createdData.aBlob?.driftAny));
setUp(() async {
createdData = _createTestData();
(crdt, crdtDb, _) = database.crdtContext;
// 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();
testHlc = expectedChangeset.first.hlcTimestamp;
await crdtDb.crdtDataTable.deleteAll();
});
group('when saving the changeset to the CRDT database', () {
late Iterable<CrdtDataEntry> entries;
setUp(() async {
entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
await crdt.saveChangeset(entries, [testHlc]);
});
test('then the saved data is equal to the generated by CRDT triggers.', () async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
// The list object is not equal to the other objects because it is a
// different instance. So we remove it from the list and compare the
// it separately.
final savedListObject = savedChangeset.removeAt(7);
final expectedListObject = expectedChangeset.removeAt(7);
expect(savedChangeset, equals(expectedChangeset));
expect( expect(
entriesMap['an_int_enum'], savedListObject.unwrappedValue,
equals(createdData.anIntEnum?.index.driftAny), equals(expectedListObject.unwrappedValue),
); );
expect(
// aTextWithConverter uses 'insert' as column name
entriesMap['insert']?.rawSqlValue,
equals(const DriftAny({'data': 'test'}).rawSqlValue),
);
expect(entriesMap['a_uuid'], createdData.aUuid?.driftAny);
}); });
}); });
}); });
} }
extension on Object { TableWithEveryColumnTypeData _createTestData() => TableWithEveryColumnTypeData(
DriftAny? get driftAny => DriftAny(this); id: const RowId(1),
} aBool: true,
aDateTime: DateTime.now(),
aText: 'test',
anInt: 1,
anInt64: BigInt.one,
aReal: 1.0,
aBlob: Uint8List.fromList([1, 2, 3]),
anIntEnum: TodoStatus.open,
aTextWithConverter: MyCustomObject('test'),
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
);

View file

@ -37,8 +37,7 @@ void main() {
'when getting the data from the CRDT data table of the CRDT database ' 'when getting the data from the CRDT data table of the CRDT database '
'then it rebuilds the data perfectly.', () async { 'then it rebuilds the data perfectly.', () async {
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>( final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
tableName: 'table_with_every_column_type', rowId,
rowId: rowId,
); );
expect(data, equals(createdData)); expect(data, equals(createdData));
@ -48,8 +47,7 @@ void main() {
'when trying to get the data from the CRDT data table with a non-existent row id ' 'when trying to get the data from the CRDT data table with a non-existent row id '
'then it returns null.', () async { 'then it returns null.', () async {
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>( final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
tableName: 'table_with_every_column_type', 'non-existent-row-id',
rowId: 'non-existent-row-id',
); );
expect(data, isNull); expect(data, isNull);
@ -59,8 +57,7 @@ void main() {
'when trying to get the data from the CRDT data table with multiple row ids ' 'when trying to get the data from the CRDT data table with multiple row ids '
'then it returns the data for the existing row ids.', () async { 'then it returns the data for the existing row ids.', () async {
final data = await crdtDb.getFromCrdtData<TableWithEveryColumnTypeData>( final data = await crdtDb.getFromCrdtData<TableWithEveryColumnTypeData>(
tableName: 'table_with_every_column_type', [rowId, 'non-existent-row-id'],
rowIds: [rowId, 'non-existent-row-id'],
); );
expect(data, equals([createdData])); expect(data, equals([createdData]));
@ -70,11 +67,14 @@ void main() {
'when trying to get the data from the CRDT data table for a non-synchronized table ' 'when trying to get the data from the CRDT data table for a non-synchronized table '
'then it throws an error.', () async { 'then it throws an error.', () async {
expect( expect(
() => crdtDb.getFromCrdtData<TableWithEveryColumnTypeData>( () => crdtDb.getSingleFromCrdtData<WithCustomTypeData>(rowId),
tableName: 'table_without_pk', throwsA(
rowIds: [rowId], isA<ArgumentError>().having(
(e) => e.message,
'message',
'Table for type "WithCustomTypeData" not found in synchronized tables.',
),
), ),
throwsA(isA<ArgumentError>()),
); );
}); });
}); });

View file

@ -22,7 +22,7 @@ void main() {
targetDate: DateTime.now(), targetDate: DateTime.now(),
); );
changeset = todo.toCrdtDataEntry(crdtDb, Hlc.now(nodeId)); changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
}); });
test( test(
@ -49,10 +49,10 @@ void main() {
targetDate: DateTime.now(), targetDate: DateTime.now(),
); );
firstChangeset = todo.toCrdtDataEntry(crdtDb, Hlc.now(nodeId)); firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
await crdt.merge(firstChangeset); await crdt.merge(firstChangeset);
lateOldChangeset = todo.toCrdtDataEntry(crdtDb, Hlc.zero(nodeId)); lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.zero(nodeId));
}); });
test( test(
@ -79,11 +79,11 @@ void main() {
targetDate: DateTime.now(), targetDate: DateTime.now(),
); );
firstChangeset = todo.toCrdtDataEntry(crdtDb, Hlc.now(nodeId)); firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
await crdt.merge(firstChangeset); await crdt.merge(firstChangeset);
newerChangeset = todo newerChangeset = crdtDb
.toCrdtDataEntry(crdtDb, Hlc.now(nodeId)) .convertToCrdtDataEntry(todo, Hlc.now(nodeId))
.where((e) => e.columnName == 'content'); .where((e) => e.columnName == 'content');
}); });

View file

@ -25,7 +25,7 @@ void main() {
targetDate: DateTime.now(), targetDate: DateTime.now(),
); );
changeset = todo.toCrdtDataEntry(crdtDb, Hlc.now(otherNodeId)); changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(otherNodeId));
lastHlc = Hlc.now(otherNodeId); lastHlc = Hlc.now(otherNodeId);
}); });
@ -65,11 +65,11 @@ void main() {
); );
lastHlc = Hlc.now(otherNodeId); lastHlc = Hlc.now(otherNodeId);
firstChangeset = todo.toCrdtDataEntry(crdtDb, lastHlc); firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]); await crdt.saveChangeset(firstChangeset, [lastHlc]);
lateOldHlc = Hlc.zero(otherNodeId); lateOldHlc = Hlc.zero(otherNodeId);
lateOldChangeset = todo.toCrdtDataEntry(crdtDb, lateOldHlc); lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, lateOldHlc);
}); });
group('when saving a changeset with older HLC', () { group('when saving a changeset with older HLC', () {
@ -108,12 +108,12 @@ void main() {
); );
lastHlc = Hlc.now(otherNodeId); lastHlc = Hlc.now(otherNodeId);
firstChangeset = todo.toCrdtDataEntry(crdtDb, lastHlc); firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]); await crdt.saveChangeset(firstChangeset, [lastHlc]);
newerHlc = Hlc.now(otherNodeId); newerHlc = Hlc.now(otherNodeId);
newerChangeset = todo newerChangeset = crdtDb
.toCrdtDataEntry(crdtDb, newerHlc) .convertToCrdtDataEntry(todo, newerHlc)
.where((e) => e.columnName == 'content'); .where((e) => e.columnName == 'content');
}); });

View file

@ -56,14 +56,11 @@ void main() {
test('has the raw values inserted correctly.', () async { test('has the raw values inserted correctly.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, 'content'); final crdtData = allCrdtDataEntries.getField(rowId, 'content');
expect( expect(crdtData.unwrappedValue, targetContent);
crdtData.rawValue?.rawSqlValue,
targetContent,
);
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date'); final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
expect( expect(
targetDateCrdtData.rawValue?.rawSqlValue, targetDateCrdtData.unwrappedValue,
targetDate.toIso8600StringWithOffset(), targetDate.toIso8600StringWithOffset(),
); );
}); });
@ -132,14 +129,11 @@ void main() {
test('has the raw values updated correctly.', () async { test('has the raw values updated correctly.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, 'content'); final crdtData = allCrdtDataEntries.getField(rowId, 'content');
expect( expect(crdtData.unwrappedValue, targetContent);
crdtData.rawValue?.rawSqlValue,
targetContent,
);
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date'); final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
expect( expect(
targetDateCrdtData.rawValue?.rawSqlValue, targetDateCrdtData.unwrappedValue,
targetDate.toIso8600StringWithOffset(), targetDate.toIso8600StringWithOffset(),
); );
}); });
@ -222,7 +216,7 @@ void main() {
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, '__crdt_is_deleted');
expect(crdtData.rawValue?.rawSqlValue, 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 {