serverpod-offline-sync/example/offline_sync_demo/lib/src/relationships.dart
2026-06-23 22:53:47 -03:00

188 lines
6.5 KiB
Dart

import 'package:serverpod_database/serverpod_database.dart'
show ColumnType, ForeignKeyAction, TableDefinition, TableRow;
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'
as protocol;
import 'models.dart';
import 'table_ops.dart';
/// The referential action a foreign key takes when its parent is deleted.
///
/// This is the whole point of the FK-conflict demo, so it is surfaced directly
/// on every relationship edge.
enum FkAction {
restrict('Restrict', 'blocks the delete'),
cascade('Cascade', 'deletes children'),
setNull('SetNull', 'nulls the reference'),
setDefault('SetDefault', 'resets to default'),
noAction('NoAction', 'no enforced action');
const FkAction(this.label, this.summary);
final String label;
final String summary;
static FkAction from(ForeignKeyAction? action) => switch (action) {
ForeignKeyAction.cascade => FkAction.cascade,
ForeignKeyAction.restrict => FkAction.restrict,
ForeignKeyAction.setNull => FkAction.setNull,
ForeignKeyAction.setDefault => FkAction.setDefault,
_ => FkAction.noAction,
};
}
/// A single foreign-key edge: [childTable].[fkColumn] → [parentTable], with the
/// [onDelete] action that fires when the parent row is removed.
class Relationship {
Relationship({
required this.childTable,
required this.fkColumn,
required this.parentTable,
required this.onDelete,
});
final String childTable;
final String fkColumn;
final String parentTable;
final FkAction onDelete;
}
/// Metadata-driven view of the synced schema.
///
/// Everything here is computed once from [protocol.Protocol.targetTableDefinitions]
/// (the same table definitions the client already ships), so the relationship
/// menus and edge badges stay in sync with the generated model.
class RelationshipCatalog {
RelationshipCatalog._(this._byName, this._inbound, this._outbound);
final Map<String, TableDefinition> _byName;
final Map<String, List<Relationship>> _inbound;
final Map<String, List<Relationship>> _outbound;
static RelationshipCatalog build(Set<String> syncedTables) {
final byName = <String, TableDefinition>{
for (final table in protocol.Protocol.targetTableDefinitions)
if (syncedTables.contains(table.name)) table.name: table,
};
final outbound = <String, List<Relationship>>{};
final inbound = <String, List<Relationship>>{};
for (final table in byName.values) {
outbound[table.name] = [];
inbound.putIfAbsent(table.name, () => []);
}
for (final table in byName.values) {
for (final fk in table.foreignKeys) {
// Only simple, user-visible relationships: single column, not one of
// the generated `_relationBackref` columns, and pointing at a table we
// actually render.
if (fk.columns.length != 1) continue;
final column = fk.columns.single;
if (column.startsWith('_')) continue;
if (!byName.containsKey(fk.referenceTable)) continue;
final relationship = Relationship(
childTable: table.name,
fkColumn: column,
parentTable: fk.referenceTable,
onDelete: FkAction.from(fk.onDelete),
);
outbound[table.name]!.add(relationship);
inbound[fk.referenceTable]!.add(relationship);
}
}
return RelationshipCatalog._(byName, inbound, outbound);
}
Iterable<String> get tables => _byName.keys;
/// Relationships whose parent is [table] — i.e. the kinds of child rows that
/// can be attached to it. Drives the "+ child" menu.
List<Relationship> childRelationshipsOf(String table) =>
_inbound[table] ?? const [];
/// This table's own foreign keys — the references that can be re-pointed or
/// detached. Drives the "move" picker and tree nesting.
List<Relationship> parentRelationshipsOf(String table) =>
_outbound[table] ?? const [];
/// Tables listed in the "Add data" menu, as declared by
/// [demoTableOps.canCreateRoot].
List<String> get creatableRootTables {
final result = <String>[];
for (final table in _byName.keys) {
if (demoTableOps[table]?.canCreateRoot ?? false) result.add(table);
}
result.sort();
return result;
}
/// The column used as a row's human label (e.g. `name`, `street`, `value`).
String displayColumn(String table) {
final definition = _byName[table];
if (definition == null) return 'id';
const preferred = ['name', 'street', 'aText', 'value', 'title', 'label'];
for (final candidate in preferred) {
if (definition.columns.any((c) => c.name == candidate)) return candidate;
}
for (final column in definition.columns) {
if (_isSystemColumn(column.name)) continue;
if (column.columnType == ColumnType.text) return column.name;
}
for (final column in definition.columns) {
if (_isSystemColumn(column.name)) continue;
if (column.columnType == ColumnType.uuid) return column.name;
}
return 'id';
}
/// A short human label for [row].
String displayLabel(TableRow<protocol.UuidValue?> row) {
final table = row.table.tableName;
final json = row.toJson() as Map<String, dynamic>;
final column = displayColumn(table);
final value = json[column];
if (value == null) return '(${shortId(row.id)})';
final definition = _byName[table];
final isUuid =
definition?.columns
.firstWhere(
(c) => c.name == column,
orElse: () => definition.columns.first,
)
.columnType ==
ColumnType.uuid;
if (isUuid && value is protocol.UuidValue) return shortId(value);
final text = '$value';
if (isUuid && text.length >= 8) return text.substring(text.length - 8);
return text;
}
/// Builds a generated row for [table], optionally assigning one foreign key.
/// Required fields and defaults remain owned by the generated constructors.
TableRow<protocol.UuidValue?>? buildRow(
String table, {
String? fkColumn,
protocol.UuidValue? fkValue,
String? label,
}) {
final create = demoTableOps[table]?.create;
if (create == null) return null;
return create(newId(), label ?? _defaultLabel(table), {
if (fkColumn != null && fkValue != null) fkColumn: fkValue,
});
}
String _defaultLabel(String table) {
final words = table.split('_').map((w) {
if (w.isEmpty) return w;
return '${w[0].toUpperCase()}${w.substring(1)}';
});
return words.join(' ');
}
static bool _isSystemColumn(String name) =>
name == 'id' || name == 'scopeId' || name.startsWith('_');
}