fix: Repair foreign keys on columns that also carry a unique claim (#151)
This commit is contained in:
parent
f3c462b5d3
commit
51ce999af0
6 changed files with 435 additions and 12 deletions
|
|
@ -477,6 +477,9 @@ class OfflineSyncDatabase implements Database {
|
|||
tx,
|
||||
projectionUnchanged: projection.projectionUnchanged,
|
||||
domainBeforeUpsert: projection.domain,
|
||||
upsertRows: updateColumns == null && projection.domain.isNotEmpty
|
||||
? prepared.rows
|
||||
: const [],
|
||||
);
|
||||
if (noReturn) return <T>[];
|
||||
_stripStampedRows(result, prepared);
|
||||
|
|
|
|||
|
|
@ -2120,12 +2120,24 @@ class CrdtForeignKeyProjector {
|
|||
if (state.pendingInsertKeys.contains(row.key)) continue;
|
||||
for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) {
|
||||
final key = (row.key.$1, row.key.$2, column);
|
||||
row.values[column] = canonicalDomainValue(
|
||||
final localValue = canonicalDomainValue(
|
||||
authoredOverlays.containsKey(key)
|
||||
? authoredOverlays[key]
|
||||
: state.originalDomain[row.key]?[column],
|
||||
_context.columnsByTableAndName[row.key.$1]?[column],
|
||||
);
|
||||
// An authored unique claim still needs its FK repair when a local
|
||||
// edit makes the parent hidden. Only a released value may replace it.
|
||||
if (foreignKeys.reasons.containsKey(key) &&
|
||||
!authoredOverlays.containsKey(key) &&
|
||||
projectionValuesEqual(localValue, authoredByField[key])) {
|
||||
continue;
|
||||
}
|
||||
if (!projectionValuesEqual(localValue, row.values[column])) {
|
||||
// Retaining a unique release supersedes the FK candidate's reason.
|
||||
foreignKeys.reasons.remove(key);
|
||||
}
|
||||
row.values[column] = localValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -851,6 +851,7 @@ class CrdtMutationRecorder {
|
|||
bool projectionUnchanged = false,
|
||||
bool authoredColumnValues = false,
|
||||
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
|
||||
List<TableRow> upsertRows = const [],
|
||||
}) async {
|
||||
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
|
||||
|
||||
|
|
@ -865,6 +866,33 @@ class CrdtMutationRecorder {
|
|||
'updated',
|
||||
transaction,
|
||||
);
|
||||
final implicitForeignKeyRepairFields = columns == null
|
||||
? await _foreignKeyProjector.findImplicitRepairFields(
|
||||
tableName: tableName,
|
||||
rowIds: rowIds,
|
||||
transaction: transaction,
|
||||
)
|
||||
: const <MergeFieldKey>{};
|
||||
final upsertInputs = upsertRows.isEmpty || implicitForeignKeyRepairFields.isEmpty
|
||||
? const <MergeRowKey, Map<String, Object?>>{}
|
||||
: {
|
||||
for (final row in upsertRows)
|
||||
if (row.id case final UuidValue rowId)
|
||||
(row.table.tableName, rowId):
|
||||
row.toJsonForDatabase() as Map<String, dynamic>,
|
||||
};
|
||||
// Insert defaults must not author over an echoed null that is holding
|
||||
// a projected FK claim.
|
||||
bool echoesProjectedNull(UuidValue rowId, String columnName) {
|
||||
if (!implicitForeignKeyRepairFields.contains((tableName, rowId, columnName))) {
|
||||
return false;
|
||||
}
|
||||
final input = upsertInputs[(tableName, rowId)];
|
||||
return input != null &&
|
||||
input[columnName] == null &&
|
||||
domainBeforeUpsert[(tableName, rowId)]?[columnName] == null;
|
||||
}
|
||||
|
||||
// Explicit columns author even an unchanged null; full-row passthrough
|
||||
// keeps the claim behind an unchanged displayed alternative.
|
||||
final authoredOverlays = <MergeFieldKey, Object?>{
|
||||
|
|
@ -883,20 +911,14 @@ class CrdtMutationRecorder {
|
|||
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
|
||||
false) &&
|
||||
(columns != null ||
|
||||
!projectionValuesEqual(
|
||||
domainBeforeUpsert[(tableName, row.id)]![columnName],
|
||||
value,
|
||||
)))
|
||||
(!projectionValuesEqual(
|
||||
domainBeforeUpsert[(tableName, row.id)]![columnName],
|
||||
value,
|
||||
) &&
|
||||
!echoesProjectedNull(row.id as UuidValue, columnName))))
|
||||
(tableName, row.id as UuidValue, columnName): value,
|
||||
};
|
||||
_uniqueResolver.validateAuthoredFields(authoredOverlays);
|
||||
final implicitForeignKeyRepairFields = columns == null
|
||||
? await _foreignKeyProjector.findImplicitRepairFields(
|
||||
tableName: tableName,
|
||||
rowIds: rowIds,
|
||||
transaction: transaction,
|
||||
)
|
||||
: const <MergeFieldKey>{};
|
||||
await _recordUpdatedFields(
|
||||
updatedRows,
|
||||
crdtDataRows,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group(
|
||||
'Given a parent deletion blocked by a restrict child and a unique SET NULL child,',
|
||||
() {
|
||||
late SyncNode node;
|
||||
late Person parent;
|
||||
late RestrictChild blocker;
|
||||
late UniqueSetNullChild child;
|
||||
late Hlc childHlc;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
parent = Person(id: const Uuid().v7obj(), name: 'parent');
|
||||
blocker = RestrictChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'blocker',
|
||||
parentId: parent.id,
|
||||
);
|
||||
child = UniqueSetNullChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'child',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Person.db.insertRow(node.offlineSync, parent, transaction: tx);
|
||||
await RestrictChild.db.insertRow(node.offlineSync, blocker, transaction: tx);
|
||||
await UniqueSetNullChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
child,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
childHlc = await rowHlc(child.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeDelete(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: Person.t.tableName,
|
||||
uuidRowId: parent.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
clFlag: 2,
|
||||
reason: CrdtDataDeletedReason.userDelete,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
});
|
||||
|
||||
group('when the restrict child is detached locally,', () {
|
||||
late Person? visibleParent;
|
||||
late UniqueSetNullChild? visibleChild;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await RestrictChild.db.updateRow(
|
||||
node.offlineSync,
|
||||
blocker.copyWith(parentId: null),
|
||||
columns: (t) => [t.parentId],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
visibleParent = await Person.db.findById(node.offlineSync, parent.id!);
|
||||
visibleChild = await UniqueSetNullChild.db.findById(
|
||||
node.offlineSync,
|
||||
child.id!,
|
||||
);
|
||||
attempt = await attemptedValue(
|
||||
rowId: child.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
|
||||
test(
|
||||
'then the visible child is repaired to null while its parent is hidden.',
|
||||
() {
|
||||
expect(visibleParent, isNull);
|
||||
expect(visibleChild, isNotNull);
|
||||
expect(visibleChild!.parentId, isNull);
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.foreignKeySetNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'then the original reference survives without an authored repair clock.',
|
||||
() {
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == child.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetNullChild).parentId, parent.id);
|
||||
expect(insert.hlc, childHlc);
|
||||
expect(
|
||||
changes.updates.where((change) => change.uuidRowId == child.id),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group(
|
||||
'Given a SET DEFAULT repair released to null because another child owns the default,',
|
||||
() {
|
||||
late SyncNode node;
|
||||
late Town parent;
|
||||
late Town defaultTown;
|
||||
late Address address;
|
||||
late UniqueSetDefaultChild loser;
|
||||
late UniqueSetDefaultChild winner;
|
||||
late Hlc childHlc;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
final mayor = Person(id: const Uuid().v7obj(), name: 'mayor');
|
||||
parent = Town(id: const Uuid().v7obj(), name: 'parent', mayorId: mayor.id);
|
||||
defaultTown = Town(
|
||||
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||
name: 'default',
|
||||
);
|
||||
address = Address(
|
||||
id: const Uuid().v7obj(),
|
||||
street: 'street',
|
||||
inhabitantId: mayor.id,
|
||||
);
|
||||
winner = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'winner',
|
||||
parentId: defaultTown.id,
|
||||
);
|
||||
loser = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'loser',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Person.db.insertRow(node.offlineSync, mayor, transaction: tx);
|
||||
await Town.db.insert(node.offlineSync, [
|
||||
parent,
|
||||
defaultTown,
|
||||
], transaction: tx);
|
||||
await Address.db.insertRow(node.offlineSync, address, transaction: tx);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
loser,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
childHlc = await rowHlc(loser.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeDelete(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: Town.t.tableName,
|
||||
uuidRowId: parent.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
clFlag: 2,
|
||||
reason: CrdtDataDeletedReason.userDelete,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
});
|
||||
|
||||
group('when an address is detached from the deleted town mayor locally,', () {
|
||||
late UniqueSetDefaultChild? stored;
|
||||
late UniqueSetDefaultChild? owner;
|
||||
late Town? visibleParent;
|
||||
late Town? visibleDefault;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Address.db.updateRow(
|
||||
node.offlineSync,
|
||||
address.copyWith(inhabitantId: null),
|
||||
columns: (t) => [t.inhabitantId],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||
owner = await UniqueSetDefaultChild.db.findById(node.offlineSync, winner.id!);
|
||||
visibleParent = await Town.db.findById(node.offlineSync, parent.id!);
|
||||
visibleDefault = await Town.db.findById(node.offlineSync, defaultTown.id!);
|
||||
attempt = await attemptedValue(
|
||||
rowId: loser.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
|
||||
test('then the null release retains its terminal unique-conflict reason.', () {
|
||||
expect(visibleParent, isNull);
|
||||
expect(visibleDefault, isNotNull);
|
||||
expect(owner!.parentId, defaultTown.id);
|
||||
expect(stored, isNotNull);
|
||||
expect(stored!.parentId, isNull);
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == loser.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||
expect(insert.hlc, childHlc);
|
||||
expect(
|
||||
changes.updates.where((change) => change.uuidRowId == loser.id),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group('Given a unique SET DEFAULT loser displaying null for its authored parent,', () {
|
||||
late SyncNode node;
|
||||
late Town parent;
|
||||
late UniqueSetDefaultChild winner;
|
||||
late UniqueSetDefaultChild loser;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
parent = Town(id: const Uuid().v7obj(), name: 'parent');
|
||||
winner = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'winner',
|
||||
parentId: parent.id,
|
||||
);
|
||||
loser = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'loser',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Town.db.insert(node.offlineSync, [
|
||||
parent,
|
||||
Town(
|
||||
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||
name: 'default',
|
||||
),
|
||||
], transaction: tx);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
final winnerHlc = await rowHlc(winner.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeInsert(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: UniqueSetDefaultChild.t.tableName,
|
||||
uuidRowId: loser.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: winnerHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
data: loser,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
loser = (await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!))!;
|
||||
});
|
||||
|
||||
group(
|
||||
'when a full-row upsert echoes the released null and the winner is later deleted,',
|
||||
() {
|
||||
late UniqueSetDefaultChild? stored;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
late UniqueSetDefaultChild? restored;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await UniqueSetDefaultChild.db.upsertRow(
|
||||
node.offlineSync,
|
||||
loser.copyWith(name: 'edited'),
|
||||
conflictColumns: (t) => [t.id],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||
attempt = await attemptedValue(
|
||||
rowId: loser.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await UniqueSetDefaultChild.db.deleteRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
restored = await UniqueSetDefaultChild.db.findById(
|
||||
node.offlineSync,
|
||||
loser.id!,
|
||||
);
|
||||
});
|
||||
|
||||
test('then the authored parent survives behind the unique release.', () {
|
||||
expect(loser.parentId, isNull);
|
||||
expect(stored!.name, 'edited');
|
||||
expect(stored!.parentId, isNull);
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||
});
|
||||
|
||||
test('then outbound sync never authors the column default.', () {
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == loser.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||
expect(
|
||||
changes.updates
|
||||
.where(
|
||||
(change) =>
|
||||
change.uuidRowId == loser.id && change.columnName == 'parentId',
|
||||
)
|
||||
.every((change) => change.value.toString() == parent.id.toString()),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('then the original authored parent is reclaimed.', () {
|
||||
expect(restored!.parentId, parent.id);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue