test: Make the DST more robust (#116)
This commit is contained in:
parent
2a6d66c79e
commit
574f8d067a
7 changed files with 63267 additions and 720 deletions
|
|
@ -343,8 +343,27 @@ class CrdtDatabase implements Database {
|
|||
|
||||
final result = plannedInsert.rows.isEmpty
|
||||
? <T>[]
|
||||
: await _delegate.insert<T>(
|
||||
: await insertWithExplicitNulls<T>(
|
||||
_delegate,
|
||||
plannedInsert.rows,
|
||||
explicitNulls: {
|
||||
for (final row in plannedInsert.rows)
|
||||
if ({
|
||||
for (final column in row.table.crdtSyncableColumns)
|
||||
if (column.hasDefault &&
|
||||
row.id != null &&
|
||||
plannedInsert.attempts.containsKey((
|
||||
row.table.tableName,
|
||||
row.id as UuidValue,
|
||||
column.columnName,
|
||||
)) &&
|
||||
(row.toJsonForDatabase() as Map)[column.columnName] ==
|
||||
null)
|
||||
column.columnName,
|
||||
}
|
||||
case final columns when columns.isNotEmpty)
|
||||
row.id as UuidValue: columns,
|
||||
},
|
||||
transaction: tx,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: skipReturn,
|
||||
|
|
@ -421,7 +440,7 @@ class CrdtDatabase implements Database {
|
|||
// 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,
|
||||
_recorder.withForeignKeyInsertDefaults(prepared.rows),
|
||||
conflictColumns: conflictColumns,
|
||||
updateColumns: updateColumns,
|
||||
updateWhere: await _whereVisibleWithTombstone<T>(
|
||||
|
|
|
|||
|
|
@ -877,7 +877,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
await DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_db,
|
||||
transaction,
|
||||
(savepoint) => _db.insertRow(scopedRow, transaction: savepoint),
|
||||
(savepoint) => _db.insertRow(
|
||||
withExplicitInsertNulls(scopedRow, {
|
||||
for (final column in scopedRow.table.crdtSyncableColumns)
|
||||
if (column.hasDefault && data[column.columnName] == null)
|
||||
column.columnName,
|
||||
}),
|
||||
transaction: savepoint,
|
||||
),
|
||||
);
|
||||
} on DatabaseQueryException {
|
||||
final owner = await _readDomainRowOwner(
|
||||
|
|
|
|||
|
|
@ -112,6 +112,83 @@ extension CrdtTableRowExtension<T extends TableRow> on T {
|
|||
T copyWithScopeId(int scopeId) => (this as dynamic).copyWith(scopeId: scopeId) as T;
|
||||
}
|
||||
|
||||
/// Adapts a materialized domain row for an ORM insert. A null chosen by
|
||||
/// projection is a stored value, even when the column has a database default.
|
||||
/// Serverpod otherwise treats null insert values as requests for that default.
|
||||
@internal
|
||||
TableRow<UuidValue?> withExplicitInsertNulls(
|
||||
TableRow row,
|
||||
Set<String> columns,
|
||||
) => columns.isEmpty ? row as TableRow<UuidValue?> : _ExplicitNullRow(row, columns);
|
||||
|
||||
/// Keeps per-row default semantics when a batch contains projected nulls.
|
||||
/// PostgreSQL builds a batch using the first row's table, so rows with different
|
||||
/// null overrides must be written separately within the caller's transaction.
|
||||
@internal
|
||||
Future<List<T>> insertWithExplicitNulls<T extends TableRow>(
|
||||
Database database,
|
||||
List<T> rows, {
|
||||
required Map<UuidValue, Set<String>> explicitNulls,
|
||||
required Transaction transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
if (explicitNulls.isEmpty) {
|
||||
return database.insert<T>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
return [
|
||||
for (final row in rows)
|
||||
...(await database.insert<TableRow<UuidValue?>>(
|
||||
[withExplicitInsertNulls(row, explicitNulls[row.id] ?? const {})],
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
)).cast<T>(),
|
||||
];
|
||||
}
|
||||
|
||||
class _ExplicitNullRow implements TableRow<UuidValue?> {
|
||||
_ExplicitNullRow(this.row, Set<String> columns)
|
||||
: table = _ExplicitNullTable(row.table, columns);
|
||||
|
||||
final TableRow row;
|
||||
|
||||
@override
|
||||
UuidValue? get id => row.id as UuidValue?;
|
||||
|
||||
@override
|
||||
final Table<UuidValue?> table;
|
||||
|
||||
@override
|
||||
dynamic toJson() => row.toJson();
|
||||
}
|
||||
|
||||
class _ExplicitNullTable extends Table<UuidValue?> {
|
||||
_ExplicitNullTable(Table source, Set<String> explicitNulls)
|
||||
: super(tableName: source.tableName) {
|
||||
columns = [
|
||||
for (final column in source.columns)
|
||||
if (explicitNulls.contains(column.columnName))
|
||||
_ExplicitNullColumn(column)
|
||||
else
|
||||
column,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
late final List<Column> columns;
|
||||
}
|
||||
|
||||
class _ExplicitNullColumn extends Column<Object?> {
|
||||
_ExplicitNullColumn(Column source)
|
||||
: super(source.columnName, source.table, fieldName: source.fieldName);
|
||||
}
|
||||
|
||||
/// Helpers over lists of domain [TableRow]s used by the CRDT recorder.
|
||||
@internal
|
||||
extension TableRowListExtension on List<TableRow> {
|
||||
|
|
|
|||
|
|
@ -194,21 +194,36 @@ class CrdtForeignKeyProjector {
|
|||
transaction,
|
||||
);
|
||||
case ForeignKeyAction.setDefault:
|
||||
final defaultValue = _context.defaultValueForColumn(
|
||||
reference.childTableName,
|
||||
reference.childColumn,
|
||||
final edge = _foreignKeys.edgesByChildTable[reference.childTableName]!
|
||||
.firstWhere((edge) => edge.childColumn == reference.childColumn);
|
||||
final repair = await _defaultProjectionValueFromDatabase(
|
||||
edge,
|
||||
transaction,
|
||||
);
|
||||
if (defaultValue == null) {
|
||||
throw StateError(
|
||||
'No default value found for '
|
||||
'${reference.childTableName}.${reference.childColumn}.',
|
||||
// Soft deletion leaves the physical target in place, so the SQL
|
||||
// FK cannot reject a hidden or cross-scope default for us. A target
|
||||
// in this delete batch is still visible now but cannot be a repair.
|
||||
final deletesDefault =
|
||||
repair.value != null &&
|
||||
(reference.parentColumn == 'id'
|
||||
? parentIds.contains(repair.value)
|
||||
: parentValuesById!.values.any(
|
||||
(values) =>
|
||||
values[reference.parentColumn].toUuidValue() ==
|
||||
repair.value,
|
||||
));
|
||||
if (!repair.valid || deletesDefault) {
|
||||
throw Exception(
|
||||
'Cannot delete $parentTableName row because '
|
||||
'${reference.childTableName}.${reference.childColumn} '
|
||||
'has no legal set-default target.',
|
||||
);
|
||||
}
|
||||
await _updateChildColumnTo(
|
||||
reference.childTableName,
|
||||
childIds,
|
||||
reference.childColumn,
|
||||
defaultValue,
|
||||
repair.value,
|
||||
transaction,
|
||||
);
|
||||
case ForeignKeyAction.cascade:
|
||||
|
|
@ -1056,6 +1071,10 @@ class CrdtForeignKeyProjector {
|
|||
|
||||
/// Loads the rows the seeds can reach instead of every row of their tables.
|
||||
///
|
||||
/// Defaults are loaded as evaluation context. Their implicit dependents join
|
||||
/// only when the default itself or its possible visibility is affected, not
|
||||
/// merely because an unrelated child needs to inspect its fallback.
|
||||
///
|
||||
/// Cascade, restrict and repair all travel along foreign key edges, and a
|
||||
/// row reached by no edge from a seed cannot change, so the closure is the
|
||||
/// seeds plus their foreign key component: children of a loaded row, because
|
||||
|
|
@ -1102,49 +1121,283 @@ class CrdtForeignKeyProjector {
|
|||
enqueue(rowKey.$1, [rowKey.$2]);
|
||||
(frontier[rowKey.$1] ??= <UuidValue>{}).add(rowKey.$2);
|
||||
}
|
||||
await _enqueueSetDefaultTargets(
|
||||
tablesToLoad: tablesToLoad,
|
||||
enqueue: enqueue,
|
||||
transaction: transaction,
|
||||
);
|
||||
final defaultEdges = [
|
||||
for (final edge in _foreignKeys.edges)
|
||||
if (edge.action == ForeignKeyAction.setDefault &&
|
||||
edge.defaultValue.toUuidValue() != null &&
|
||||
tablesToLoad.contains(edge.childTableName))
|
||||
edge,
|
||||
];
|
||||
await _enqueueSetDefaultTargets(defaultEdges, enqueue, transaction);
|
||||
|
||||
while (queued.isNotEmpty || frontier.isNotEmpty) {
|
||||
final wave = queued;
|
||||
queued = <String, Set<UuidValue>>{};
|
||||
final affectedSeeds = {...seedRows, ...unwrittenValues.keys};
|
||||
final expandedDefaults = <ForeignKeyEdge>{};
|
||||
while (true) {
|
||||
while (queued.isNotEmpty || frontier.isNotEmpty) {
|
||||
final wave = queued;
|
||||
queued = <String, Set<UuidValue>>{};
|
||||
|
||||
final loadedNow = <String, Set<UuidValue>>{};
|
||||
for (final MapEntry(key: tableName, value: ids) in wave.entries) {
|
||||
final loaded = await _loadTableRowsInto(
|
||||
tableName: tableName,
|
||||
rowIds: ids,
|
||||
columnNames: columnsByTable[tableName] ?? const <String>{},
|
||||
final loadedNow = <String, Set<UuidValue>>{};
|
||||
for (final MapEntry(key: tableName, value: ids) in wave.entries) {
|
||||
final loaded = await _loadTableRowsInto(
|
||||
tableName: tableName,
|
||||
rowIds: ids,
|
||||
columnNames: columnsByTable[tableName] ?? const <String>{},
|
||||
rows: rows,
|
||||
fieldIds: fieldIds,
|
||||
attemptedValues: attemptedValues,
|
||||
fieldHlcs: fieldHlcs,
|
||||
transaction: transaction,
|
||||
);
|
||||
if (loaded.isNotEmpty) loadedNow[tableName] = loaded;
|
||||
}
|
||||
|
||||
for (final MapEntry(key: tableName, value: ids) in frontier.entries) {
|
||||
(loadedNow[tableName] ??= <UuidValue>{}).addAll(ids);
|
||||
}
|
||||
frontier.clear();
|
||||
if (loadedNow.isEmpty) break;
|
||||
|
||||
await _expandRowClosure(
|
||||
loadedNow: loadedNow,
|
||||
tablesToLoad: tablesToLoad,
|
||||
rows: rows,
|
||||
fieldIds: fieldIds,
|
||||
unwrittenValues: unwrittenValues,
|
||||
attemptedValues: attemptedValues,
|
||||
fieldHlcs: fieldHlcs,
|
||||
enqueue: enqueue,
|
||||
transaction: transaction,
|
||||
);
|
||||
if (loaded.isNotEmpty) loadedNow[tableName] = loaded;
|
||||
}
|
||||
|
||||
for (final MapEntry(key: tableName, value: ids) in frontier.entries) {
|
||||
(loadedNow[tableName] ??= <UuidValue>{}).addAll(ids);
|
||||
}
|
||||
frontier.clear();
|
||||
if (loadedNow.isEmpty) break;
|
||||
|
||||
await _expandRowClosure(
|
||||
loadedNow: loadedNow,
|
||||
tablesToLoad: tablesToLoad,
|
||||
if (expandedDefaults.length == defaultEdges.length) break;
|
||||
final defaultsToExpand = _affectedDefaults(
|
||||
defaultEdges: defaultEdges,
|
||||
expandedDefaults: expandedDefaults,
|
||||
affectedSeeds: affectedSeeds,
|
||||
rows: rows,
|
||||
unwrittenValues: unwrittenValues,
|
||||
attemptedValues: attemptedValues,
|
||||
enqueue: enqueue,
|
||||
transaction: transaction,
|
||||
pendingInserts: pendingInserts,
|
||||
);
|
||||
if (defaultsToExpand.isEmpty) break;
|
||||
for (final edge in defaultsToExpand) {
|
||||
expandedDefaults.add(edge);
|
||||
final dependents = await _findSetDefaultDependents(edge, transaction);
|
||||
affectedSeeds.addAll(dependents);
|
||||
for (final key in dependents) {
|
||||
enqueue(key.$1, [key.$2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enqueueSetDefaultTargets(
|
||||
List<ForeignKeyEdge> defaultEdges,
|
||||
void Function(String, Iterable<UuidValue>) enqueue,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
for (final edge in defaultEdges) {
|
||||
await _enqueueParentRowsByValue(
|
||||
edge,
|
||||
{edge.defaultValue.toUuidValue()!},
|
||||
enqueue,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fallback matters to children whose authored parent can be hidden or
|
||||
/// missing, or whose FK already has a projection override. Seed those sparse
|
||||
/// states and their cascade roots, then use the normal indexed reference walk.
|
||||
Future<Set<MergeRowKey>> _findSetDefaultDependents(
|
||||
ForeignKeyEdge edge,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
final ancestors = <String>{edge.parentTableName};
|
||||
final pendingTables = [edge.parentTableName];
|
||||
while (pendingTables.isNotEmpty) {
|
||||
final table = pendingTables.removeLast();
|
||||
for (final parentEdge
|
||||
in _foreignKeys.edgesByChildTable[table] ?? <ForeignKeyEdge>[]) {
|
||||
if (parentEdge.action == ForeignKeyAction.cascade &&
|
||||
ancestors.add(parentEdge.parentTableName)) {
|
||||
pendingTables.add(parentEdge.parentTableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _context.findSetDefaultDependencyRows(
|
||||
ancestorTableNames: ancestors,
|
||||
childTableName: edge.childTableName,
|
||||
childColumn: edge.childColumn,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Defaults whose implicit outgoing dependencies may have changed.
|
||||
///
|
||||
/// A projected C -> D is not an authored edge: if C still wants A, restoring
|
||||
/// A does not change D's deletion blockers. Following that projected edge
|
||||
/// here would invalidate every unrelated default consumer on ordinary merges.
|
||||
/// The stored authored reference and any new overlay do both participate.
|
||||
Set<ForeignKeyEdge> _affectedDefaults({
|
||||
required List<ForeignKeyEdge> defaultEdges,
|
||||
required Set<ForeignKeyEdge> expandedDefaults,
|
||||
required Set<MergeRowKey> affectedSeeds,
|
||||
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows,
|
||||
required Map<MergeRowKey, Map<String, Object?>> unwrittenValues,
|
||||
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
|
||||
required List<PendingProjectionRow> pendingInserts,
|
||||
}) {
|
||||
Set<UuidValue> references(
|
||||
MergeRowKey key,
|
||||
String column, {
|
||||
bool includeProjected = false,
|
||||
}) {
|
||||
if (column == 'id') return {key.$2};
|
||||
final attempted = attemptedValues[(key.$1, key.$2, column)];
|
||||
return {
|
||||
if (attempted != null) ?tryUuidValue(attempted.value),
|
||||
if (attempted == null || includeProjected)
|
||||
?tryUuidValue(rows[key]?.values[column]),
|
||||
?tryUuidValue(unwrittenValues[key]?[column]),
|
||||
};
|
||||
}
|
||||
|
||||
// A live default with no authored parents cannot change visibility unless
|
||||
// it is changed directly or already has a delete tombstone to arbitrate.
|
||||
// Most defaults are standalone rows, so ordinary FK writes avoid even the
|
||||
// in-memory dependency walk. A missing default also stays missing until an
|
||||
// insert of that key seeds the pass. Non-PK references use the general walk
|
||||
// because a different row can gain or lose the referenced value.
|
||||
final mayChangeDefault = defaultEdges.any((edge) {
|
||||
if (expandedDefaults.contains(edge)) return false;
|
||||
if (edge.parentColumn != 'id') return true;
|
||||
final key = (edge.parentTableName, edge.defaultValue.toUuidValue()!);
|
||||
if (affectedSeeds.contains(key)) return true;
|
||||
final row = rows[key];
|
||||
if (row == null) return false;
|
||||
if (row.crdtRow.isHidden || (row.crdtRow.deleted?.isDeleted ?? false)) {
|
||||
return true;
|
||||
}
|
||||
return (_foreignKeys.edgesByChildTable[key.$1] ?? <ForeignKeyEdge>[]).any(
|
||||
(parentEdge) => references(key, parentEdge.childColumn).isNotEmpty,
|
||||
);
|
||||
});
|
||||
if (!mayChangeDefault) return {};
|
||||
|
||||
final graph = _buildDefaultDependencyGraph(
|
||||
rows: rows,
|
||||
unwrittenValues: unwrittenValues,
|
||||
pendingInserts: pendingInserts,
|
||||
references: references,
|
||||
);
|
||||
|
||||
// This is deliberately an overapproximation: a restrict or nullable edge
|
||||
// may keep its child visible. The important fast path is a live default
|
||||
// with no possible deletion/missing-parent cause, which cannot change its
|
||||
// visibility merely because an authored child reference changes.
|
||||
final hiddenCandidates = _reachableRows(
|
||||
graph.possiblyHidden,
|
||||
graph.childrenByParent,
|
||||
);
|
||||
final affected = _reachableRows(affectedSeeds, graph.neighbors);
|
||||
return {
|
||||
for (final edge in defaultEdges)
|
||||
if (!expandedDefaults.contains(edge))
|
||||
for (final key in affected)
|
||||
if (key.$1 == edge.parentTableName &&
|
||||
references(
|
||||
key,
|
||||
edge.parentColumn,
|
||||
includeProjected: true,
|
||||
).contains(edge.defaultValue.toUuidValue()) &&
|
||||
(affectedSeeds.contains(key) || hiddenCandidates.contains(key)))
|
||||
edge,
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds authored dependency edges; projected values only identify parents.
|
||||
/// Keep this distinct from the stored-value walk in [_expandRowClosure].
|
||||
_DefaultDependencyGraph _buildDefaultDependencyGraph({
|
||||
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows,
|
||||
required Map<MergeRowKey, Map<String, Object?>> unwrittenValues,
|
||||
required List<PendingProjectionRow> pendingInserts,
|
||||
required Set<UuidValue> Function(
|
||||
MergeRowKey key,
|
||||
String column, {
|
||||
bool includeProjected,
|
||||
})
|
||||
references,
|
||||
}) {
|
||||
final keys = {...rows.keys, ...unwrittenValues.keys};
|
||||
final keysByTable = <String, List<MergeRowKey>>{};
|
||||
for (final key in keys) {
|
||||
keysByTable.putIfAbsent(key.$1, () => []).add(key);
|
||||
}
|
||||
final neighbors = <MergeRowKey, Set<MergeRowKey>>{};
|
||||
final childrenByParent = <MergeRowKey, Set<MergeRowKey>>{};
|
||||
final possiblyHidden = <MergeRowKey>{
|
||||
for (final row in rows.values)
|
||||
if (row.crdtRow.isHidden || (row.crdtRow.deleted?.isDeleted ?? false)) row.key,
|
||||
for (final pending in pendingInserts)
|
||||
if (pending.hidden) (pending.tableName, pending.rowId),
|
||||
};
|
||||
for (final edge in _foreignKeys.edges) {
|
||||
final children = keysByTable[edge.childTableName];
|
||||
if (children == null) continue;
|
||||
final parents = <UuidValue, Set<MergeRowKey>>{};
|
||||
for (final key in keysByTable[edge.parentTableName] ?? <MergeRowKey>[]) {
|
||||
for (final value in references(
|
||||
key,
|
||||
edge.parentColumn,
|
||||
includeProjected: true,
|
||||
)) {
|
||||
parents.putIfAbsent(value, () => {}).add(key);
|
||||
}
|
||||
}
|
||||
for (final child in children) {
|
||||
for (final reference in references(child, edge.childColumn)) {
|
||||
final targets = parents[reference];
|
||||
if (targets == null) {
|
||||
// Missing parents can hide a default too, without a tombstone.
|
||||
possiblyHidden.add(child);
|
||||
continue;
|
||||
}
|
||||
for (final parent in targets) {
|
||||
neighbors.putIfAbsent(child, () => {}).add(parent);
|
||||
neighbors.putIfAbsent(parent, () => {}).add(child);
|
||||
childrenByParent.putIfAbsent(parent, () => {}).add(child);
|
||||
if (edge.parentColumn != 'id' &&
|
||||
(unwrittenValues[parent]?.containsKey(edge.parentColumn) ?? false)) {
|
||||
// Changing a referenced unique value can remove its old target.
|
||||
possiblyHidden.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
neighbors: neighbors,
|
||||
childrenByParent: childrenByParent,
|
||||
possiblyHidden: possiblyHidden,
|
||||
);
|
||||
}
|
||||
|
||||
static Set<MergeRowKey> _reachableRows(
|
||||
Set<MergeRowKey> seeds,
|
||||
Map<MergeRowKey, Set<MergeRowKey>> graph,
|
||||
) {
|
||||
final reached = {...seeds};
|
||||
final queue = seeds.toList();
|
||||
for (var index = 0; index < queue.length; index++) {
|
||||
for (final key in graph[queue[index]] ?? <MergeRowKey>{}) {
|
||||
if (reached.add(key)) queue.add(key);
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/// The values the pass will write but has not written yet, by row.
|
||||
///
|
||||
/// A pending insert has no row yet, and an overlay's new value is applied
|
||||
|
|
@ -1167,35 +1420,6 @@ class CrdtForeignKeyProjector {
|
|||
return unwrittenValues;
|
||||
}
|
||||
|
||||
/// Enqueues the rows a set-default repair would fall back to.
|
||||
///
|
||||
/// A set-default edge names its target in the schema, so no row points at it
|
||||
/// until the repair runs and no edge query would reach it.
|
||||
Future<void> _enqueueSetDefaultTargets({
|
||||
required Set<String> tablesToLoad,
|
||||
required void Function(String tableName, Iterable<UuidValue> ids) enqueue,
|
||||
required Transaction transaction,
|
||||
}) async {
|
||||
for (final edge in _foreignKeys.edges) {
|
||||
if (!tablesToLoad.contains(edge.childTableName)) continue;
|
||||
final defaultValue = edge.defaultValue.toUuidValue();
|
||||
if (defaultValue == null) continue;
|
||||
if (edge.parentColumn == 'id') {
|
||||
enqueue(edge.parentTableName, [defaultValue]);
|
||||
continue;
|
||||
}
|
||||
enqueue(
|
||||
edge.parentTableName,
|
||||
await _context.findDomainRowIdsWhereColumnIn(
|
||||
tableName: edge.parentTableName,
|
||||
columnName: edge.parentColumn,
|
||||
values: {defaultValue},
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _expandRowClosure({
|
||||
required Map<String, Set<UuidValue>> loadedNow,
|
||||
required Set<String> tablesToLoad,
|
||||
|
|
@ -1317,6 +1541,15 @@ class CrdtForeignKeyProjector {
|
|||
}
|
||||
if (references.isEmpty) return;
|
||||
|
||||
await _enqueueParentRowsByValue(edge, references, enqueue, transaction);
|
||||
}
|
||||
|
||||
Future<void> _enqueueParentRowsByValue(
|
||||
ForeignKeyEdge edge,
|
||||
Set<UuidValue> references,
|
||||
void Function(String, Iterable<UuidValue>) enqueue,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
if (edge.parentColumn == 'id') {
|
||||
enqueue(edge.parentTableName, references);
|
||||
return;
|
||||
|
|
@ -1488,6 +1721,10 @@ class CrdtForeignKeyProjector {
|
|||
_ForeignKeyProjectionState state,
|
||||
Set<MergeRowKey> userHidden,
|
||||
) {
|
||||
// Restart from authored deletions, never the previous projected winners.
|
||||
// Each round withdraws all blocked roots together and never reintroduces
|
||||
// them within this pass. This terminates even with circular dependencies;
|
||||
// a later pass with the same facts makes exactly the same withdrawals.
|
||||
final acceptedRoots = userHidden.toSet();
|
||||
|
||||
while (true) {
|
||||
|
|
@ -1949,8 +2186,10 @@ class CrdtForeignKeyProjector {
|
|||
final parkUpdates = <MergeRowKey, Map<String, Object?>>{};
|
||||
for (final MapEntry(key: rowKey, value: updates) in changed.entries) {
|
||||
final park = <String, Object?>{};
|
||||
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(rowKey.$1)) {
|
||||
if (!updates.containsKey(column.columnName)) continue;
|
||||
for (final column in _uniqueResolver.uniqueReleaseColumnsFor(
|
||||
rowKey.$1,
|
||||
updates.keys.toSet(),
|
||||
)) {
|
||||
park[column.columnName] = _context.conflictFreeValue(
|
||||
column,
|
||||
originalDomain[rowKey]?[column.columnName],
|
||||
|
|
@ -1958,6 +2197,10 @@ class CrdtForeignKeyProjector {
|
|||
rowKey.$2,
|
||||
'park',
|
||||
);
|
||||
// A discriminator-only change still moves the unique tuple. Restore
|
||||
// its temporarily parked release column even if its final value did
|
||||
// not change.
|
||||
updates[column.columnName] = finalDomain[rowKey]![column.columnName];
|
||||
}
|
||||
if (park.isNotEmpty) parkUpdates[rowKey] = park;
|
||||
}
|
||||
|
|
@ -2346,3 +2589,9 @@ class CrdtForeignKeyProjector {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef _DefaultDependencyGraph = ({
|
||||
Map<MergeRowKey, Set<MergeRowKey>> neighbors,
|
||||
Map<MergeRowKey, Set<MergeRowKey>> childrenByParent,
|
||||
Set<MergeRowKey> possiblyHidden,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -427,6 +427,49 @@ WHERE r."scopeId" = $scopeId
|
|||
return _rowIds(result);
|
||||
}
|
||||
|
||||
/// Sparse roots and children that may depend on a changed SET DEFAULT target.
|
||||
///
|
||||
/// A blocked original-parent deletion has no child override, so include
|
||||
/// authored tombstones even when projection currently keeps the row visible.
|
||||
/// Cascade ancestors find those blocked indirectly; hidden rows and existing
|
||||
/// overrides also cover missing-parent repair and unique FK projection.
|
||||
Future<Set<MergeRowKey>> findSetDefaultDependencyRows({
|
||||
required Set<String> ancestorTableNames,
|
||||
required String childTableName,
|
||||
required String childColumn,
|
||||
required Transaction transaction,
|
||||
}) async {
|
||||
final tablesById = {
|
||||
for (final name in {...ancestorTableNames, childTableName})
|
||||
schema[name]!.$1: name,
|
||||
};
|
||||
final (childTableId, columns) = schema[childTableName]!;
|
||||
final columnId = columns[childColumn]!.id!;
|
||||
final scopeId = hlcManagerFor(transaction).normalizedScopeId;
|
||||
final result = await database.unsafeQuery(
|
||||
'''SELECT r."tblId", r."uuidRowId"
|
||||
FROM "crdt_data_rows" r
|
||||
LEFT JOIN "crdt_data_tombstone" d ON d."rowId" = r."id"
|
||||
WHERE r."scopeId" = $scopeId
|
||||
AND r."tblId" IN (${tablesById.keys.join(', ')})
|
||||
AND ($_rowHidden OR d."clFlag" % 2 = 0)
|
||||
UNION
|
||||
SELECT r."tblId", r."uuidRowId"
|
||||
FROM "crdt_data_attempted_value" a
|
||||
JOIN "crdt_data_fields" f ON f."id" = a."fieldId"
|
||||
JOIN "crdt_data_rows" r ON r."id" = f."rowId"
|
||||
WHERE r."scopeId" = $scopeId
|
||||
AND r."tblId" = $childTableId
|
||||
AND f."columnId" = $columnId
|
||||
''',
|
||||
transaction: transaction,
|
||||
);
|
||||
return {
|
||||
for (final row in result)
|
||||
(tablesById[row[0]]!, UuidValueJsonExtension.fromJson(row[1])),
|
||||
};
|
||||
}
|
||||
|
||||
/// Row ids in [tableName] whose columns hold one of the listed values.
|
||||
///
|
||||
/// A composite unique index is matched column by column, so the result is a
|
||||
|
|
@ -490,6 +533,9 @@ LEFT JOIN "crdt_data_rows" r
|
|||
ON r."scopeId" = $scopeId AND r."tblId" = $tableId AND r."uuidRowId" = d."id"''';
|
||||
}
|
||||
|
||||
/// Whether tracked row alias `r` is hidden; shared by metadata/domain lookups.
|
||||
static final _rowHidden = 'r."visibility" > $crdtRowLastVisibleVisibilityIndex';
|
||||
|
||||
/// Whether the row joined by [_visibilityJoin] is visible in this scope.
|
||||
static final _rowVisible =
|
||||
'r."id" IS NULL OR r."visibility" <= $crdtRowLastVisibleVisibilityIndex';
|
||||
|
|
@ -753,7 +799,7 @@ WHERE c."id" IN ($whereRowIds)
|
|||
AND (
|
||||
p."id" IS NULL OR
|
||||
p."scopeId" <> $scopeId OR
|
||||
r."visibility" > $crdtRowLastVisibleVisibilityIndex
|
||||
$_rowHidden
|
||||
)
|
||||
''';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,13 +45,18 @@ class CrdtUniqueConflictResolver {
|
|||
return uniqueColumnNamesFor(tableName).contains(columnName);
|
||||
}
|
||||
|
||||
/// Releasable unique columns for [tableName], used by two-phase parking.
|
||||
/// Releasable columns of indexes affected by [changedColumns], used by
|
||||
/// two-phase parking even when only a fixed discriminator changes.
|
||||
///
|
||||
/// A column can be indexed more than once, and releasing it is one act, so
|
||||
/// the columns are deduplicated by name.
|
||||
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(String tableName) {
|
||||
List<UniqueColumnConflictRelease> uniqueReleaseColumnsFor(
|
||||
String tableName,
|
||||
Set<String> changedColumns,
|
||||
) {
|
||||
final byName = <String, UniqueColumnConflictRelease>{};
|
||||
for (final uniqueIndex in uniqueIndexesFor(tableName)) {
|
||||
if (!uniqueIndex.indexedColumns.any(changedColumns.contains)) continue;
|
||||
for (final column in uniqueIndex.releaseColumns) {
|
||||
byName[column.columnName] = column;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,6 +551,25 @@ class CrdtMutationRecorder {
|
|||
as T;
|
||||
}
|
||||
|
||||
/// Supplies fixed UUID FK defaults before building a local upsert. SQLite's
|
||||
/// upsert builder cannot emit DEFAULT inside VALUES. Resolve the same schema
|
||||
/// default that an insert would use, leaving other fields to the ORM.
|
||||
List<T> withForeignKeyInsertDefaults<T extends TableRow>(List<T> rows) => [
|
||||
for (final row in rows)
|
||||
_withPlannedDomainValues(row, {
|
||||
for (final edge
|
||||
in _foreignKeys.edgesByChildTable[row.table.tableName] ??
|
||||
const <ForeignKeyEdge>[])
|
||||
if ((row.toJsonForDatabase() as Map)[edge.childColumn] == null)
|
||||
if (_context.defaultValueForColumn(
|
||||
row.table.tableName,
|
||||
edge.childColumn,
|
||||
)
|
||||
case final UuidValue value)
|
||||
edge.childColumn: value,
|
||||
}),
|
||||
];
|
||||
|
||||
Future<Map<UuidValue, Map<String, Object?>>> _readPlannedDomainValues(
|
||||
String tableName,
|
||||
Set<UuidValue> rowIds,
|
||||
|
|
|
|||
Loading…
Reference in a new issue