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) {
|
||||
final baselineResult = await runWithProgress(
|
||||
'Running ${operation.name} benchmark (baseline)',
|
||||
'Running ${operation.label} benchmark (baseline)',
|
||||
() => TypesTableBenchmark(
|
||||
'${operation.name} (baseline)',
|
||||
'${operation.label} (baseline)',
|
||||
crdtEnabled: false,
|
||||
operation: operation,
|
||||
rowCount: rowCount,
|
||||
|
|
@ -41,9 +41,9 @@ Future<void> main(List<String> args) async {
|
|||
);
|
||||
|
||||
final crdtResult = await runWithProgress(
|
||||
'Running ${operation.name} benchmark (CRDT)',
|
||||
'Running ${operation.label} benchmark (CRDT)',
|
||||
() => TypesTableBenchmark(
|
||||
'${operation.name} (CRDT)',
|
||||
'${operation.label} (CRDT)',
|
||||
crdtEnabled: true,
|
||||
operation: operation,
|
||||
rowCount: rowCount,
|
||||
|
|
@ -79,7 +79,7 @@ Future<void> main(List<String> args) async {
|
|||
final baselineDelay = result.baseline.toFormattedDuration();
|
||||
final crdtDelay = result.crdt.toFormattedDuration();
|
||||
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';
|
||||
|
||||
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 }
|
||||
|
||||
|
|
@ -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 {
|
||||
final rows = await Types.db.find(crdtEnabled ? _crdtSession! : _plainSession);
|
||||
if (rows.length != rowCount) {
|
||||
|
|
@ -228,6 +304,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
|
|||
case Operation.update:
|
||||
_seededRows = await _insertTypes(rowCount);
|
||||
return;
|
||||
case Operation.upsertInsert:
|
||||
return;
|
||||
case Operation.upsertUpdate:
|
||||
_seededRows = await _insertTypes(rowCount);
|
||||
return;
|
||||
case Operation.delete:
|
||||
_seededRows = await _insertTypes(rowCount);
|
||||
return;
|
||||
|
|
@ -305,6 +386,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
|
|||
await _updateTypesInPlace();
|
||||
_lastRowsCount = _seededRows.length;
|
||||
return;
|
||||
case Operation.upsertInsert:
|
||||
case Operation.upsertUpdate:
|
||||
await _upsertTypes();
|
||||
_lastRowsCount = _seededRows.length;
|
||||
return;
|
||||
case Operation.delete:
|
||||
await _deleteTypes(_seededRows);
|
||||
_seededRows = [];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ void printPerformanceImpact(
|
|||
}) {
|
||||
print(
|
||||
'${runningInCI ? '```' : ''}'
|
||||
'\n📊 ${results.operation.name.toUpperCase()} performance impact:',
|
||||
'\n📊 ${results.operation.label.toUpperCase()} performance impact:',
|
||||
);
|
||||
final baselineTime = results.baseline;
|
||||
final crdtTime = results.crdt;
|
||||
|
|
@ -27,7 +27,7 @@ void printPerformanceImpact(
|
|||
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
|
||||
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
|
||||
if (results.operation != Operation.select) {
|
||||
print(' Delay per ${results.operation.name}: $runDelayPerOperation');
|
||||
print(' Delay per ${results.operation.label}: $runDelayPerOperation');
|
||||
}
|
||||
if (runningInCI) print('```');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,9 +398,132 @@ class CrdtDatabase implements Database {
|
|||
Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
if (rows.isEmpty) return [];
|
||||
await _ensureInitialized();
|
||||
// TODO: Implement CRDT upsert.
|
||||
throw UnimplementedError('CRDT upsert is not implemented.');
|
||||
if (!_recorder.isCrdtTracked<T>(rows.first.table)) {
|
||||
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
|
||||
|
|
@ -412,17 +535,21 @@ class CrdtDatabase implements Database {
|
|||
Transaction? transaction,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
if (T == CrdtSyncIntegrityViolation) {
|
||||
return _delegate.upsertRow<T>(
|
||||
row,
|
||||
conflictColumns: conflictColumns,
|
||||
updateColumns: updateColumns,
|
||||
updateWhere: updateWhere,
|
||||
transaction: transaction,
|
||||
final result = await upsert<T>(
|
||||
[row],
|
||||
conflictColumns: conflictColumns,
|
||||
updateColumns: updateColumns,
|
||||
updateWhere: updateWhere,
|
||||
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.
|
||||
throw UnimplementedError('CRDT upsert is not implemented.');
|
||||
return result.firstOrNull;
|
||||
}
|
||||
|
||||
@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