fix: Preserve shared model and SQLite value types during sync (#118)
This commit is contained in:
parent
27d875ee39
commit
1f5501ff97
4 changed files with 25604 additions and 51 deletions
|
|
@ -659,8 +659,18 @@ WHERE (${domainColumnPredicate('spaceId', spaceId)})
|
|||
) async {
|
||||
if (rowIds.isEmpty || updates.isEmpty) return;
|
||||
|
||||
final columns = {
|
||||
for (final column in syncTableByName[tableName]!.columns)
|
||||
column.columnName: column,
|
||||
};
|
||||
final assignments = updates.entries
|
||||
.map((e) => '"${e.key.escapeIdentifier()}" = ${e.value.sqlLiteral()}')
|
||||
.map((entry) {
|
||||
final value = ValueEncoder.instance.encodeColumnValue(
|
||||
columns[entry.key]!,
|
||||
entry.value,
|
||||
);
|
||||
return '"${entry.key.escapeIdentifier()}" = $value';
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
await database.unsafeExecute(
|
||||
|
|
|
|||
|
|
@ -259,6 +259,21 @@ String canonicalProjectionValue(Object? value) {
|
|||
@internal
|
||||
bool projectionValuesEqual(Object? left, Object? right) {
|
||||
if (left == null || right == null) return left == right;
|
||||
if (left is Map && right is Map) {
|
||||
return left.length == right.length &&
|
||||
left.entries.every(
|
||||
(entry) =>
|
||||
right.containsKey(entry.key) &&
|
||||
projectionValuesEqual(entry.value, right[entry.key]),
|
||||
);
|
||||
}
|
||||
if (left is List && right is List) {
|
||||
if (left.length != right.length) return false;
|
||||
for (var index = 0; index < left.length; index++) {
|
||||
if (!projectionValuesEqual(left[index], right[index])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
final leftUuid = tryUuidValue(left);
|
||||
final rightUuid = tryUuidValue(right);
|
||||
if (leftUuid != null && rightUuid != null) {
|
||||
|
|
|
|||
|
|
@ -598,6 +598,9 @@ class CrdtMutationRecorder {
|
|||
data[column.fieldName] = planned[column.columnName];
|
||||
}
|
||||
}
|
||||
// Ask the serialization manager for the name it resolves this row by. A
|
||||
// model from a shared package writes its own unprefixed `__className__`,
|
||||
// while the host protocol only answers to the prefixed form.
|
||||
final className =
|
||||
_session.db.serializationManager.getClassNameForObject(row) ??
|
||||
data['__className__'] as String?;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:serverpod_database/serverpod_database.dart';
|
||||
|
|
@ -97,14 +98,6 @@ class OfflineSyncEngine {
|
|||
if (definition.dartName != null) definition.name: definition.dartName!,
|
||||
};
|
||||
|
||||
late final Map<String, Map<String, ColumnDefinition>> _columnDefinitionsByTableName =
|
||||
{
|
||||
for (final definition in _serializationManager.getTargetTableDefinitions())
|
||||
definition.name: {
|
||||
for (final column in definition.columns) column.name: column,
|
||||
},
|
||||
};
|
||||
|
||||
/// The deterministic hash representing the current synchronized schema.
|
||||
late final String currentSyncTablesHash = computeSyncTablesHash(
|
||||
_syncTables,
|
||||
|
|
@ -835,7 +828,10 @@ class OfflineSyncEngine {
|
|||
DomainRowOwnerCache ownerCache,
|
||||
) async {
|
||||
final cols = table.columns
|
||||
.map((column) => '"${column.columnName.escapeIdentifier()}"')
|
||||
.map(
|
||||
(column) =>
|
||||
'${_outboundColumnExpression(session, column)} AS "${column.columnName.escapeIdentifier()}"',
|
||||
)
|
||||
.join(', ');
|
||||
final encodedRowId = rowId.sqlLiteral();
|
||||
final encodedSpaceId = spaceId.sqlLiteral();
|
||||
|
|
@ -851,16 +847,32 @@ class OfflineSyncEngine {
|
|||
}
|
||||
ownerCache[(tableName, rowId)] = (exists: true, spaceId: spaceId);
|
||||
|
||||
final columnMap = result.first.toColumnMap()
|
||||
// Domain columns hold visible/materialized FK values; restore attempted
|
||||
// values for override columns before building the outbound merge payload.
|
||||
..applyAuthoredAttemptedValues(attemptedValueFields)
|
||||
// spaceId is local ownership metadata; it is never emitted on the wire.
|
||||
..remove('spaceId');
|
||||
final rawColumns = result.first.toColumnMap();
|
||||
final columnMap =
|
||||
<String, dynamic>{
|
||||
for (final column in table.columns)
|
||||
column.columnName: _decodeStructuredValue(
|
||||
session,
|
||||
column,
|
||||
rawColumns[column.columnName],
|
||||
),
|
||||
}
|
||||
// Domain columns hold visible/materialized FK values; restore attempted
|
||||
// values for override columns before building the outbound merge payload.
|
||||
..applyAuthoredAttemptedValues(attemptedValueFields)
|
||||
// spaceId is local ownership metadata; it is never emitted on the wire.
|
||||
..remove('spaceId');
|
||||
|
||||
final row = session.db.serializationManager.deserializeByClassName({
|
||||
'className': dartName,
|
||||
'data': columnMap,
|
||||
// A table definition names its class the way its own package spells it, so
|
||||
// a model owned by a shared package reports the unprefixed name while the
|
||||
// host protocol answers only to the package-prefixed one. Carrying the name
|
||||
// in the payload lets the host fall through to the protocol that owns the
|
||||
// model rather than failing on a name it does not know.
|
||||
// `Object` rather than `dynamic`: a dynamic target is read as a wrapped
|
||||
// dynamic field instead of a model payload.
|
||||
final row = session.db.serializationManager.deserialize<Object>({
|
||||
...columnMap,
|
||||
'__className__': dartName,
|
||||
});
|
||||
return (exists: true, ownerSpaceId: spaceId, row: row);
|
||||
}
|
||||
|
|
@ -895,8 +907,11 @@ class OfflineSyncEngine {
|
|||
final encodedRowId = rowId.sqlLiteral();
|
||||
final encodedSpaceId = spaceId.sqlLiteral();
|
||||
final escapedTableName = tableName.escapeIdentifier();
|
||||
final column = _syncTablesByName[tableName]!.columns.singleWhere(
|
||||
(c) => c.columnName == columnName,
|
||||
);
|
||||
final result = await session.db.unsafeQuery(
|
||||
'SELECT "${columnName.escapeIdentifier()}" '
|
||||
'SELECT ${_outboundColumnExpression(session, column)} '
|
||||
'FROM "$escapedTableName" '
|
||||
'WHERE "id" = $encodedRowId AND "spaceId" = $encodedSpaceId '
|
||||
'LIMIT 1',
|
||||
|
|
@ -906,7 +921,11 @@ class OfflineSyncEngine {
|
|||
return (
|
||||
exists: true,
|
||||
ownerSpaceId: spaceId,
|
||||
value: _decodeColumnValue(tableName, columnName, result.first[0]),
|
||||
value: _decodeColumnValue(
|
||||
tableName,
|
||||
columnName,
|
||||
_decodeStructuredValue(session, column, result.first[0]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1031,28 +1050,45 @@ class OfflineSyncEngine {
|
|||
return fieldsByRowId;
|
||||
}
|
||||
|
||||
dynamic _decodeColumnValue(String tableName, String columnName, Object? value) {
|
||||
if (value == null) return null;
|
||||
|
||||
final definition = _columnDefinitionsByTableName[tableName]?[columnName];
|
||||
final dartType = definition?.dartType;
|
||||
if (dartType == null) return value;
|
||||
|
||||
final className = _classNameForDartType(dartType);
|
||||
return switch (className) {
|
||||
'bool' || 'double' || 'int' || 'String' => value,
|
||||
_ => _serializationManager.deserializeByClassName({
|
||||
'className': className,
|
||||
'data': value,
|
||||
}),
|
||||
};
|
||||
String _outboundColumnExpression(DatabaseSession session, Column column) {
|
||||
final identifier = '"${column.columnName.escapeIdentifier()}"';
|
||||
if (session.db.dialect == DatabaseDialect.sqlite && column is ColumnStructured) {
|
||||
return 'json($identifier)';
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
String _classNameForDartType(String dartType) {
|
||||
final withoutNullable = dartType.endsWith('?')
|
||||
? dartType.substring(0, dartType.length - 1)
|
||||
: dartType;
|
||||
return withoutNullable.split(':').last;
|
||||
/// Temporary workaround for raw queries bypassing Serverpod's column-aware
|
||||
/// result normalization. [_outboundColumnExpression] converts SQLite JSONB to
|
||||
/// JSON text so this method can decode both JSON and JSONB before deserialization.
|
||||
///
|
||||
/// TODO: Serverpod needs a public API for adapter-specific, column-aware decoding
|
||||
/// of raw query results across supported types and databases. Replace this helper
|
||||
/// and [_outboundColumnExpression] with that upstream API when it is available.
|
||||
dynamic _decodeStructuredValue(
|
||||
DatabaseSession session,
|
||||
Column column,
|
||||
Object? value,
|
||||
) {
|
||||
if (session.db.dialect == DatabaseDialect.sqlite &&
|
||||
(column is ColumnStructured || column is ColumnSerializable) &&
|
||||
value is String) {
|
||||
return jsonDecode(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
dynamic _decodeColumnValue(
|
||||
String tableName,
|
||||
String columnName,
|
||||
Object? value,
|
||||
) {
|
||||
if (value == null) return null;
|
||||
|
||||
final column = _syncTablesByName[tableName]!.columns.singleWhere(
|
||||
(column) => column.columnName == columnName,
|
||||
);
|
||||
return _serializationManager.deserialize<dynamic>(value, column.type);
|
||||
}
|
||||
|
||||
static String _computeCanonicalSyncTablesSignature(
|
||||
|
|
|
|||
Loading…
Reference in a new issue