feat: Add support for the upsert and upsertRow ORM methods (#94)
This commit is contained in:
parent
c6a873d04c
commit
467df68a69
5 changed files with 796 additions and 19 deletions
|
|
@ -30,9 +30,9 @@ Future<void> main(List<String> args) async {
|
||||||
|
|
||||||
for (final operation in Operation.values) {
|
for (final operation in Operation.values) {
|
||||||
final baselineResult = await runWithProgress(
|
final baselineResult = await runWithProgress(
|
||||||
'Running ${operation.name} benchmark (baseline)',
|
'Running ${operation.label} benchmark (baseline)',
|
||||||
() => TypesTableBenchmark(
|
() => TypesTableBenchmark(
|
||||||
'${operation.name} (baseline)',
|
'${operation.label} (baseline)',
|
||||||
crdtEnabled: false,
|
crdtEnabled: false,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
rowCount: rowCount,
|
rowCount: rowCount,
|
||||||
|
|
@ -41,9 +41,9 @@ Future<void> main(List<String> args) async {
|
||||||
);
|
);
|
||||||
|
|
||||||
final crdtResult = await runWithProgress(
|
final crdtResult = await runWithProgress(
|
||||||
'Running ${operation.name} benchmark (CRDT)',
|
'Running ${operation.label} benchmark (CRDT)',
|
||||||
() => TypesTableBenchmark(
|
() => TypesTableBenchmark(
|
||||||
'${operation.name} (CRDT)',
|
'${operation.label} (CRDT)',
|
||||||
crdtEnabled: true,
|
crdtEnabled: true,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
rowCount: rowCount,
|
rowCount: rowCount,
|
||||||
|
|
@ -79,7 +79,7 @@ Future<void> main(List<String> args) async {
|
||||||
final baselineDelay = result.baseline.toFormattedDuration();
|
final baselineDelay = result.baseline.toFormattedDuration();
|
||||||
final crdtDelay = result.crdt.toFormattedDuration();
|
final crdtDelay = result.crdt.toFormattedDuration();
|
||||||
print(
|
print(
|
||||||
' - ${result.operation.name.toUpperCase()}: $baselineDelay > $crdtDelay',
|
' - ${result.operation.label.toUpperCase()}: $baselineDelay > $crdtDelay',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_c
|
||||||
|
|
||||||
import 'tables.dart';
|
import 'tables.dart';
|
||||||
|
|
||||||
enum Operation { select, insert, update, delete }
|
enum Operation { select, insert, update, upsertInsert, upsertUpdate, delete }
|
||||||
|
|
||||||
|
extension OperationLabel on Operation {
|
||||||
|
String get label => switch (this) {
|
||||||
|
Operation.upsertInsert => 'upsert (insert)',
|
||||||
|
Operation.upsertUpdate => 'upsert (update)',
|
||||||
|
_ => name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
enum StorageStage { insert, update, delete }
|
enum StorageStage { insert, update, delete }
|
||||||
|
|
||||||
|
|
@ -175,6 +183,74 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upserts a batch of updated copies of the seeded rows plus fresh rows
|
||||||
|
/// without ids up to [rowCount], so the seeded count set by [_prepareCycle]
|
||||||
|
/// determines the conflict-update vs insert mix.
|
||||||
|
Future<void> _upsertTypes() async {
|
||||||
|
final baseTimestamp = DateTime.now();
|
||||||
|
final updated = _seededRows.map((row) {
|
||||||
|
final i = _valueSeq++;
|
||||||
|
return row.copyWith(
|
||||||
|
aBool: i.isEven,
|
||||||
|
aDateTime: baseTimestamp.add(Duration(seconds: i)),
|
||||||
|
aText: 'Text $i',
|
||||||
|
anInt: i,
|
||||||
|
anInt64: BigInt.from(i),
|
||||||
|
aReal: i.toDouble(),
|
||||||
|
aBlob: _emptyBlob,
|
||||||
|
optionalUuid: const Uuid().v7obj(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final freshCount = rowCount - updated.length;
|
||||||
|
final start = _valueSeq;
|
||||||
|
_valueSeq += freshCount;
|
||||||
|
final batch = [
|
||||||
|
...updated,
|
||||||
|
for (var j = 0; j < freshCount; j++) _createTypesRow(start + j, baseTimestamp),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (crdtEnabled) {
|
||||||
|
_seededRows = await _crdtSession!.db.transactionForUser(
|
||||||
|
_userId,
|
||||||
|
(tx) async {
|
||||||
|
return Types.db.upsert(
|
||||||
|
_crdtSession!,
|
||||||
|
batch,
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [
|
||||||
|
t.aBool,
|
||||||
|
t.aDateTime,
|
||||||
|
t.aText,
|
||||||
|
t.anInt,
|
||||||
|
t.anInt64,
|
||||||
|
t.aReal,
|
||||||
|
t.aBlob,
|
||||||
|
t.optionalUuid,
|
||||||
|
],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_seededRows = await Types.db.upsert(
|
||||||
|
_plainSession,
|
||||||
|
batch,
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [
|
||||||
|
t.aBool,
|
||||||
|
t.aDateTime,
|
||||||
|
t.aText,
|
||||||
|
t.anInt,
|
||||||
|
t.anInt64,
|
||||||
|
t.aReal,
|
||||||
|
t.aBlob,
|
||||||
|
t.optionalUuid,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Types>> _selectTypes() async {
|
Future<List<Types>> _selectTypes() async {
|
||||||
final rows = await Types.db.find(crdtEnabled ? _crdtSession! : _plainSession);
|
final rows = await Types.db.find(crdtEnabled ? _crdtSession! : _plainSession);
|
||||||
if (rows.length != rowCount) {
|
if (rows.length != rowCount) {
|
||||||
|
|
@ -228,6 +304,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
|
||||||
case Operation.update:
|
case Operation.update:
|
||||||
_seededRows = await _insertTypes(rowCount);
|
_seededRows = await _insertTypes(rowCount);
|
||||||
return;
|
return;
|
||||||
|
case Operation.upsertInsert:
|
||||||
|
return;
|
||||||
|
case Operation.upsertUpdate:
|
||||||
|
_seededRows = await _insertTypes(rowCount);
|
||||||
|
return;
|
||||||
case Operation.delete:
|
case Operation.delete:
|
||||||
_seededRows = await _insertTypes(rowCount);
|
_seededRows = await _insertTypes(rowCount);
|
||||||
return;
|
return;
|
||||||
|
|
@ -305,6 +386,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
|
||||||
await _updateTypesInPlace();
|
await _updateTypesInPlace();
|
||||||
_lastRowsCount = _seededRows.length;
|
_lastRowsCount = _seededRows.length;
|
||||||
return;
|
return;
|
||||||
|
case Operation.upsertInsert:
|
||||||
|
case Operation.upsertUpdate:
|
||||||
|
await _upsertTypes();
|
||||||
|
_lastRowsCount = _seededRows.length;
|
||||||
|
return;
|
||||||
case Operation.delete:
|
case Operation.delete:
|
||||||
await _deleteTypes(_seededRows);
|
await _deleteTypes(_seededRows);
|
||||||
_seededRows = [];
|
_seededRows = [];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ void printPerformanceImpact(
|
||||||
}) {
|
}) {
|
||||||
print(
|
print(
|
||||||
'${runningInCI ? '```' : ''}'
|
'${runningInCI ? '```' : ''}'
|
||||||
'\n📊 ${results.operation.name.toUpperCase()} performance impact:',
|
'\n📊 ${results.operation.label.toUpperCase()} performance impact:',
|
||||||
);
|
);
|
||||||
final baselineTime = results.baseline;
|
final baselineTime = results.baseline;
|
||||||
final crdtTime = results.crdt;
|
final crdtTime = results.crdt;
|
||||||
|
|
@ -27,7 +27,7 @@ void printPerformanceImpact(
|
||||||
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
|
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
|
||||||
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
|
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
|
||||||
if (results.operation != Operation.select) {
|
if (results.operation != Operation.select) {
|
||||||
print(' Delay per ${results.operation.name}: $runDelayPerOperation');
|
print(' Delay per ${results.operation.label}: $runDelayPerOperation');
|
||||||
}
|
}
|
||||||
if (runningInCI) print('```');
|
if (runningInCI) print('```');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,9 +398,132 @@ class CrdtDatabase implements Database {
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
bool noReturn = false,
|
bool noReturn = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (rows.isEmpty) return [];
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
// TODO: Implement CRDT upsert.
|
if (!_recorder.isCrdtTracked<T>(rows.first.table)) {
|
||||||
throw UnimplementedError('CRDT upsert is not implemented.');
|
return _delegate.upsert<T>(
|
||||||
|
rows,
|
||||||
|
conflictColumns: conflictColumns,
|
||||||
|
updateColumns: updateColumns,
|
||||||
|
updateWhere: updateWhere,
|
||||||
|
transaction: transaction,
|
||||||
|
noReturn: noReturn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||||
|
_delegate,
|
||||||
|
transaction,
|
||||||
|
(tx) async {
|
||||||
|
final prepared = _prepareRowsForInsert(rows, tx);
|
||||||
|
|
||||||
|
// CRDT metadata needs the affected rows even when the public call uses
|
||||||
|
// noReturn, because inserted rows may have database-generated ids.
|
||||||
|
final result = await _delegate.upsert<T>(
|
||||||
|
prepared.rows,
|
||||||
|
conflictColumns: conflictColumns,
|
||||||
|
updateColumns: updateColumns,
|
||||||
|
updateWhere: await _whereVisibleWithTombstone<T>(
|
||||||
|
updateWhere,
|
||||||
|
null,
|
||||||
|
tx,
|
||||||
|
membershipWide: false,
|
||||||
|
),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
|
||||||
|
final reinsertedRows = await _reinsertTombstonedRows(
|
||||||
|
prepared.rows,
|
||||||
|
result,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
|
final insertedRows = await _rowsWithoutCrdtMetadata(result, tx);
|
||||||
|
await _recorder.afterInsert(insertedRows, tx);
|
||||||
|
|
||||||
|
final insertedRowIds = {
|
||||||
|
for (final row in insertedRows)
|
||||||
|
if (row.id is UuidValue) row.id as UuidValue,
|
||||||
|
};
|
||||||
|
final updatedRows = [
|
||||||
|
for (final row in result)
|
||||||
|
if (row.id is! UuidValue || !insertedRowIds.contains(row.id)) row,
|
||||||
|
];
|
||||||
|
await _recorder.afterUpdate(updatedRows, updateColumns, tx);
|
||||||
|
if (noReturn) return <T>[];
|
||||||
|
_stripStampedRows(result, prepared);
|
||||||
|
for (final row in reinsertedRows) {
|
||||||
|
final rowId = row.id;
|
||||||
|
if (rowId == null || !prepared.explicitRowIds.contains(rowId)) {
|
||||||
|
_stripScopeId(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...result, ...reinsertedRows];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinserts upserted rows whose conflict target is a tombstoned row.
|
||||||
|
///
|
||||||
|
/// The visibility filter merged into `updateWhere` keeps the delegate upsert
|
||||||
|
/// from updating hidden rows, so they come back missing from [result]. From
|
||||||
|
/// the caller's perspective a deleted row does not exist, so the upsert must
|
||||||
|
/// behave as an insert: the domain row is overwritten with the incoming
|
||||||
|
/// values — ignoring `updateColumns` and `updateWhere` — and the tombstone
|
||||||
|
/// is lifted, mirroring the [insertRow] reinsert path.
|
||||||
|
Future<List<T>> _reinsertTombstonedRows<T extends TableRow>(
|
||||||
|
List<T> preparedRows,
|
||||||
|
List<T> result,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final returnedIds = <Object>{
|
||||||
|
for (final row in result)
|
||||||
|
if (row.id != null) row.id as Object,
|
||||||
|
};
|
||||||
|
|
||||||
|
final rowsToReinsert = <T>[];
|
||||||
|
for (final row in preparedRows) {
|
||||||
|
final rowId = row.id;
|
||||||
|
if (rowId == null || returnedIds.contains(rowId)) continue;
|
||||||
|
if (await _recorder.isDeleted(row, transaction)) rowsToReinsert.add(row);
|
||||||
|
}
|
||||||
|
if (rowsToReinsert.isEmpty) return [];
|
||||||
|
|
||||||
|
final reinsertedRows = await _delegate.update<T>(
|
||||||
|
rowsToReinsert,
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
await _recorder.afterReinsert(reinsertedRows, transaction);
|
||||||
|
return reinsertedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> _rowsWithoutCrdtMetadata<T extends TableRow>(
|
||||||
|
List<T> rows,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
if (rows.isEmpty) return [];
|
||||||
|
|
||||||
|
final rowIds = {
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.id is UuidValue) row.id as UuidValue,
|
||||||
|
};
|
||||||
|
if (rowIds.isEmpty) return [];
|
||||||
|
|
||||||
|
final tableId = _recorder.tableIdForName(rows.first.table.tableName)!;
|
||||||
|
final scopeId = _requireEffectiveScope(transaction).id!;
|
||||||
|
final crdtRows = await CrdtDataRow.db.find(
|
||||||
|
_delegate.session,
|
||||||
|
where: (t) =>
|
||||||
|
t.scopeId.equals(scopeId) &
|
||||||
|
t.tblId.equals(tableId) &
|
||||||
|
t.uuidRowId.inSet(rowIds),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
final existingRowIds = crdtRows.map((row) => row.uuidRowId).toSet();
|
||||||
|
|
||||||
|
return [
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.id is UuidValue && !existingRowIds.contains(row.id)) row,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -412,17 +535,21 @@ class CrdtDatabase implements Database {
|
||||||
Transaction? transaction,
|
Transaction? transaction,
|
||||||
}) async {
|
}) async {
|
||||||
await _ensureInitialized();
|
await _ensureInitialized();
|
||||||
if (T == CrdtSyncIntegrityViolation) {
|
final result = await upsert<T>(
|
||||||
return _delegate.upsertRow<T>(
|
[row],
|
||||||
row,
|
conflictColumns: conflictColumns,
|
||||||
conflictColumns: conflictColumns,
|
updateColumns: updateColumns,
|
||||||
updateColumns: updateColumns,
|
updateWhere: updateWhere,
|
||||||
updateWhere: updateWhere,
|
transaction: transaction,
|
||||||
transaction: transaction,
|
);
|
||||||
|
if (result.length > 1) {
|
||||||
|
// FIXME: We can't use the proper `DatabaseUpsertRowException` because
|
||||||
|
// it is declared as a base type on the `serverpod_database` package.
|
||||||
|
throw Exception(
|
||||||
|
'Failed to upsert row, affected number of rows is ${result.length} != 1',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// TODO: Implement CRDT upsert.
|
return result.firstOrNull;
|
||||||
throw UnimplementedError('CRDT upsert is not implemented.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,564 @@
|
||||||
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
|
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession();
|
||||||
|
|
||||||
|
group('Given an empty person table,', () {
|
||||||
|
group('when upserting a Person with upsertRow,', () {
|
||||||
|
late UuidValue personId;
|
||||||
|
late Person? person;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
personId = const Uuid().v7obj();
|
||||||
|
person = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
Person(id: personId, name: 'created'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the returned row keeps scopeId null.', () async {
|
||||||
|
expect(person, isNotNull);
|
||||||
|
expect(person!.scopeId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the row exists in the person table.', () async {
|
||||||
|
final row = await Person.db.findById(session, personId);
|
||||||
|
expect(row?.name, 'created');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then CRDT metadata is recorded for the row.', () async {
|
||||||
|
final crdtRow = await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(personId),
|
||||||
|
);
|
||||||
|
expect(crdtRow, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then no CRDT field is created for the inserted row.', () async {
|
||||||
|
final fieldCount = await CrdtDataField.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(personId),
|
||||||
|
);
|
||||||
|
expect(fieldCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting a Person without an id with upsertRow,', () {
|
||||||
|
late Person? created;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
created = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
Person(name: 'created without id'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the returned row has a generated id and null scopeId.', () {
|
||||||
|
expect(created, isNotNull);
|
||||||
|
expect(created!.id, isNotNull);
|
||||||
|
expect(created!.scopeId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the row exists in the person table.', () async {
|
||||||
|
final row = await Person.db.findById(session, created!.id!);
|
||||||
|
expect(row?.name, 'created without id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then CRDT metadata is recorded for the row.', () async {
|
||||||
|
final crdtRow = await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(created!.id),
|
||||||
|
);
|
||||||
|
expect(crdtRow, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then no CRDT field is created for the inserted row.', () async {
|
||||||
|
final fieldCount = await CrdtDataField.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(created!.id),
|
||||||
|
);
|
||||||
|
expect(fieldCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a person table with an existing row,', () {
|
||||||
|
late Person person;
|
||||||
|
late CrdtDataRow insertedCrdtRow;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
person = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.insertRow(
|
||||||
|
session,
|
||||||
|
Person(name: 'original'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
insertedCrdtRow = (await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(person.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting the Person name column with upsertRow,', () {
|
||||||
|
late Person? updatedPerson;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
updatedPerson = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
person.copyWith(name: 'updated'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the returned row keeps scopeId null.', () async {
|
||||||
|
expect(updatedPerson, isNotNull);
|
||||||
|
expect(updatedPerson!.scopeId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the person row reflects the new values.', () async {
|
||||||
|
final row = await Person.db.findById(session, person.id!);
|
||||||
|
expect(row?.name, 'updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the previously existing CRDT row is not affected.', () async {
|
||||||
|
final crdtRow = await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(person.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(crdtRow!.uuidRowId, insertedCrdtRow.uuidRowId);
|
||||||
|
expect(crdtRow.hlc, insertedCrdtRow.hlc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then a CRDT field is created for the name column.', () async {
|
||||||
|
final field = await CrdtDataField.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(person.id),
|
||||||
|
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(field, isNotNull);
|
||||||
|
expect(field!.column!.name, 'name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting the Person with a non-matching updateWhere,', () {
|
||||||
|
late Person? updatedPerson;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
updatedPerson = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
person.copyWith(name: 'skipped'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
updateWhere: (t) => t.name.equals('other'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then no row is returned.', () async {
|
||||||
|
expect(updatedPerson, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the person row is unchanged.', () async {
|
||||||
|
final row = await Person.db.findById(session, person.id!);
|
||||||
|
expect(row?.name, 'original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then no CRDT field is created for the skipped update.', () async {
|
||||||
|
final fieldCount = await CrdtDataField.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(person.id),
|
||||||
|
);
|
||||||
|
expect(fieldCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting an existing Person and a new Person without an id,', () {
|
||||||
|
late List<Person> upserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
upserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsert(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
person.copyWith(name: 'batch updated'),
|
||||||
|
Person(name: 'batch created'),
|
||||||
|
],
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then both rows are returned with scopeId null.', () {
|
||||||
|
expect(upserted, hasLength(2));
|
||||||
|
expect(upserted.map((e) => e.scopeId), everyElement(isNull));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the existing row keeps its id and reflects the new values.', () {
|
||||||
|
final updated = upserted.singleWhere((e) => e.name == 'batch updated');
|
||||||
|
expect(updated.id, person.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the created row has a generated id and is persisted.', () async {
|
||||||
|
final created = upserted.singleWhere((e) => e.name == 'batch created');
|
||||||
|
expect(created.id, isNotNull);
|
||||||
|
|
||||||
|
final row = await Person.db.findById(session, created.id!);
|
||||||
|
expect(row?.name, 'batch created');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then CRDT row metadata is recorded for both affected rows.', () async {
|
||||||
|
final created = upserted.singleWhere((e) => e.name == 'batch created');
|
||||||
|
final crdtRows = await CrdtDataRow.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.inSet({person.id!, created.id!}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(crdtRows.map((e) => e.uuidRowId).toSet(), {
|
||||||
|
person.id,
|
||||||
|
created.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then CRDT fields are recorded only for the existing updated row.',
|
||||||
|
() async {
|
||||||
|
final created = upserted.singleWhere((e) => e.name == 'batch created');
|
||||||
|
final fields = await CrdtDataField.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.inSet({person.id!, created.id!}),
|
||||||
|
include: CrdtDataField.include(
|
||||||
|
column: CrdtSchemaColumn.include(),
|
||||||
|
row: CrdtDataRow.include(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fields.map((e) => e.row!.uuidRowId).toSet(), {
|
||||||
|
person.id,
|
||||||
|
});
|
||||||
|
expect(fields.map((e) => e.column!.name).toSet(), {'name'});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting an existing Person and a new Person with noReturn,', () {
|
||||||
|
late Person? createdPerson;
|
||||||
|
late List<Person> upserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
upserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsert(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
person.copyWith(name: 'batch updated'),
|
||||||
|
Person(name: 'batch created'),
|
||||||
|
],
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
noReturn: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
createdPerson = await Person.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.name.equals('batch created'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then an empty list is returned.', () async {
|
||||||
|
expect(upserted, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the existing person row reflects the new values.', () async {
|
||||||
|
final row = await Person.db.findById(session, person.id!);
|
||||||
|
expect(row?.name, 'batch updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the new person row exists in the person table.', () async {
|
||||||
|
expect(createdPerson, isNotNull);
|
||||||
|
expect(createdPerson!.id, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then CRDT row metadata is recorded for both affected rows.', () async {
|
||||||
|
final crdtRows = await CrdtDataRow.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.inSet({person.id!, createdPerson!.id!}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(crdtRows.map((e) => e.uuidRowId).toSet(), {
|
||||||
|
person.id,
|
||||||
|
createdPerson!.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then CRDT fields are recorded only for the existing updated row.',
|
||||||
|
() async {
|
||||||
|
final fields = await CrdtDataField.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.inSet({person.id!, createdPerson!.id!}),
|
||||||
|
include: CrdtDataField.include(
|
||||||
|
column: CrdtSchemaColumn.include(),
|
||||||
|
row: CrdtDataRow.include(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fields.map((e) => e.row!.uuidRowId).toSet(), {
|
||||||
|
person.id,
|
||||||
|
});
|
||||||
|
expect(fields.map((e) => e.column!.name).toSet(), {'name'});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a person table with a deleted row,', () {
|
||||||
|
late Person person;
|
||||||
|
late CrdtDataRow insertedCrdtRow;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
person = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.insertRow(
|
||||||
|
session,
|
||||||
|
Person(name: 'original'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
insertedCrdtRow = (await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(person.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.deleteRow(session, person, transaction: tx),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting the Person with upsertRow,', () {
|
||||||
|
late Person? reinserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
reinserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
person.copyWith(name: 'reinserted'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the row is returned with the new values and null scopeId.', () {
|
||||||
|
expect(reinserted, isNotNull);
|
||||||
|
expect(reinserted!.name, 'reinserted');
|
||||||
|
expect(reinserted!.scopeId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the row is visible again.', () async {
|
||||||
|
final row = await Person.db.findById(session, person.id!);
|
||||||
|
expect(row?.name, 'reinserted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the CRDT metadata row is updated.', () async {
|
||||||
|
final crdtRow = await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(person.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
expect(crdtRow!.hlc, greaterThan(insertedCrdtRow.hlc));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting the Person with a non-matching updateWhere,', () {
|
||||||
|
late Person? reinserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
reinserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsertRow(
|
||||||
|
session,
|
||||||
|
person.copyWith(name: 'reinserted'),
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
updateWhere: (t) => t.name.equals('other'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the row is reinserted anyway, as it does not exist for the caller.',
|
||||||
|
() async {
|
||||||
|
expect(reinserted, isNotNull);
|
||||||
|
expect(reinserted!.name, 'reinserted');
|
||||||
|
|
||||||
|
final row = await Person.db.findById(session, person.id!);
|
||||||
|
expect(row?.name, 'reinserted');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a person table with a live and a deleted row,', () {
|
||||||
|
late Person deletedPerson;
|
||||||
|
late Person livePerson;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
deletedPerson = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.insertRow(
|
||||||
|
session,
|
||||||
|
Person(name: 'deleted'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
livePerson = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.insertRow(
|
||||||
|
session,
|
||||||
|
Person(name: 'live'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.deleteRow(session, deletedPerson, transaction: tx),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting the live, the deleted, and a new row in one batch,', () {
|
||||||
|
late List<Person> upserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
upserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Person.db.upsert(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
deletedPerson.copyWith(name: 'batch reinserted'),
|
||||||
|
livePerson.copyWith(name: 'batch updated'),
|
||||||
|
Person(name: 'batch created'),
|
||||||
|
],
|
||||||
|
conflictColumns: (t) => [t.id],
|
||||||
|
updateColumns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then all three rows are returned with scopeId null.', () {
|
||||||
|
expect(upserted, hasLength(3));
|
||||||
|
expect(upserted.map((e) => e.scopeId), everyElement(isNull));
|
||||||
|
expect(upserted.map((e) => e.name).toSet(), {
|
||||||
|
'batch reinserted',
|
||||||
|
'batch updated',
|
||||||
|
'batch created',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the deleted row is visible again with the new values.', () async {
|
||||||
|
final row = await Person.db.findById(session, deletedPerson.id!);
|
||||||
|
expect(row?.name, 'batch reinserted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the live and new rows are persisted with the new values.', () async {
|
||||||
|
final updated = await Person.db.findById(session, livePerson.id!);
|
||||||
|
expect(updated?.name, 'batch updated');
|
||||||
|
|
||||||
|
final created = upserted.singleWhere((e) => e.name == 'batch created');
|
||||||
|
final row = await Person.db.findById(session, created.id!);
|
||||||
|
expect(row, isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a unique composite table with an existing row,', () {
|
||||||
|
late UniqueComposite uniqueComposite;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
uniqueComposite = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => UniqueComposite.db.insertRow(
|
||||||
|
session,
|
||||||
|
UniqueComposite(scope: 'domain', value: 'key'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when upserting another row with the same scoped unique key,', () {
|
||||||
|
late UniqueComposite? upserted;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
upserted = await session.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => UniqueComposite.db.upsertRow(
|
||||||
|
session,
|
||||||
|
UniqueComposite(scope: 'domain', value: 'key'),
|
||||||
|
conflictColumns: (t) => [t.scopeId, t.scope, t.value],
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the existing row is returned.', () async {
|
||||||
|
expect(upserted, isNotNull);
|
||||||
|
expect(upserted!.id, uniqueComposite.id);
|
||||||
|
expect(upserted!.scopeId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then no duplicate row is inserted.', () async {
|
||||||
|
final rows = await UniqueComposite.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.scope.equals('domain') & t.value.equals('key'),
|
||||||
|
);
|
||||||
|
expect(rows, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then CRDT fields are tracked for the default updated columns.', () async {
|
||||||
|
final fields = await CrdtDataField.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(uniqueComposite.id),
|
||||||
|
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fields.map((e) => e.column!.name).toSet(), {'scope', 'value'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue