feat: Expose includeHiddenRows expression to bypass CRDT tombstone filter (#78)

Co-authored-by: Marcelo Soares <marcelo.me.soares@gmail.com>
This commit is contained in:
Copilot 2026-06-20 23:07:05 -03:00 • committed by GitHub
commit 38fa00ddfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 265 additions and 8 deletions

View file

@ -6,5 +6,6 @@ export 'src/database/database.dart';
export 'src/database/recorder.dart'; export 'src/database/recorder.dart';
export 'src/database/schema.dart'; export 'src/database/schema.dart';
export 'src/database/session.dart' show CrdtDatabaseAccess, CrdtDatabaseSession; export 'src/database/session.dart' show CrdtDatabaseAccess, CrdtDatabaseSession;
export 'src/database/tombstone.dart' show IncludeTombstonedRows;
export 'src/managers/hlc.dart'; export 'src/managers/hlc.dart';
export 'src/managers/scope.dart'; export 'src/managers/scope.dart';

View file

@ -14,6 +14,45 @@ typedef CrdtTableIdResolver = int? Function(String tableName);
/// are not isolated and a row is hidden only when its owning scope hides it. /// are not isolated and a row is hidden only when its owning scope hides it.
typedef CrdtScopeIdResolver = int? Function(); typedef CrdtScopeIdResolver = int? Function();
/// Private sentinel class used to signal that the CRDT visibility filter
/// should be bypassed. Detected via `is` type check instead of string
/// comparison.
///
/// It renders as the inert SQL literal `TRUE`, so it is always valid wherever
/// it appears in a `where` clause and never needs to be stripped out before the
/// query reaches the database.
final class _IncludeHiddenSentinel extends Expression<String> {
const _IncludeHiddenSentinel() : super('TRUE');
}
/// Extension on [Table] that exposes [includeHiddenRows] for use in `where`
/// clauses to bypass the CRDT tombstone/visibility filter.
///
/// When included in a `where` clause, all rows are returned — including those
/// that are tombstoned (soft-deleted) — instead of being hidden. The placeholder
/// is stripped before the query reaches the database, so no invalid SQL is
/// generated.
///
/// Example:
/// ```dart
/// // Returns all persons, including tombstoned ones.
/// final allPersons = await Person.db.find(
/// session,
/// where: (t) => t.includeHiddenRows,
/// );
///
/// // Returns all persons named 'Alice', including tombstoned ones.
/// final alicePersons = await Person.db.find(
/// session,
/// where: (t) => t.name.equals('Alice') & t.includeHiddenRows,
/// );
/// ```
extension IncludeTombstonedRows on Table {
/// Placeholder expression that signals the CRDT visibility filter should be
/// skipped, returning tombstoned rows alongside live rows.
Expression get includeHiddenRows => const _IncludeHiddenSentinel();
}
/// Merges [where] with CRDT visibility predicates and mutates [include] in place. /// Merges [where] with CRDT visibility predicates and mutates [include] in place.
/// ///
/// This method ensures that no rows with a hidden [CrdtDataRow.visibility] are /// This method ensures that no rows with a hidden [CrdtDataRow.visibility] are
@ -35,6 +74,11 @@ typedef CrdtScopeIdResolver = int? Function();
/// [IncludeList] - visibility filtering for that path is already handled via the /// [IncludeList] - visibility filtering for that path is already handled via the
/// list subquery [where] (and many-relations are not the same join shape as a /// list subquery [where] (and many-relations are not the same join shape as a
/// single [IncludeObject]). /// single [IncludeObject]).
///
/// If [where] contains the [IncludeTombstonedRows.includeHiddenRows] sentinel
/// anywhere in the expression tree, the visibility filter is skipped for the
/// root table. The sentinel itself renders as the inert SQL literal `TRUE`, so
/// it is left in place without producing invalid SQL.
@internal @internal
Expression? mergeWhereWithTombstone<T extends TableRow>( Expression? mergeWhereWithTombstone<T extends TableRow>(
DatabaseSerializationManager serializationManager, DatabaseSerializationManager serializationManager,
@ -51,10 +95,12 @@ Expression? mergeWhereWithTombstone<T extends TableRow>(
); );
final rootTable = serializationManager.getTableForType(T); final rootTable = serializationManager.getTableForType(T);
var merged = _mergeWhereOptional( var merged = _includesHiddenSentinel(where)
where, ? where
rootTable?.whereVisibleOnCrdtRow(tableIdForName, scopeId), : _mergeWhereOptional(
); where,
rootTable?.whereVisibleOnCrdtRow(tableIdForName, scopeId),
);
merged = _mergeWhereOptional(merged, includeObjectPredicates); merged = _mergeWhereOptional(merged, includeObjectPredicates);
return merged; return merged;
} }
@ -70,10 +116,12 @@ Expression? _walkIncludeGraphForTombstone(
if (inc == null) return includeObjectPredicates; if (inc == null) return includeObjectPredicates;
if (inc is IncludeList) { if (inc is IncludeList) {
// List relation: filter inside the list subquery only. // List relation: filter inside the list subquery only.
inc.where = _mergeWhereOptional( if (!_includesHiddenSentinel(inc.where)) {
inc.where, inc.where = _mergeWhereOptional(
inc.table.whereVisibleOnCrdtRow(tableIdForName, scopeId), inc.where,
); inc.table.whereVisibleOnCrdtRow(tableIdForName, scopeId),
);
}
return _walkIncludeGraphForTombstone( return _walkIncludeGraphForTombstone(
inc.include, inc.include,
includeObjectPredicates, includeObjectPredicates,
@ -122,6 +170,18 @@ Expression? _mergeWhereOptional(Expression? where, Expression? addition) {
return where & addition; return where & addition;
} }
/// Returns `true` when [where] contains the [_IncludeHiddenSentinel] anywhere
/// in its expression tree.
///
/// Walks the tree via [Expression.depthFirst], which every expression type
/// implements, so the sentinel is detected regardless of how deeply it is
/// nested or which operators (`AND`, `OR`, `NOT`) combine it. The sentinel
/// renders as inert SQL (`TRUE`), so no rewriting of [where] is needed.
bool _includesHiddenSentinel(Expression? where) {
if (where == null) return false;
return where.depthFirst.any((e) => e is _IncludeHiddenSentinel);
}
extension on Table { extension on Table {
/// Creates a predicate that filters out hidden rows for the given [Table] /// Creates a predicate that filters out hidden rows for the given [Table]
/// using materialized [CrdtDataRow.visibility] metadata. /// using materialized [CrdtDataRow.visibility] metadata.

View file

@ -0,0 +1,196 @@
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 a person table with a live and a tombstoned row,', () {
late Person liveRow;
late Person deletedRow;
setUp(() async {
liveRow = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(session, Person(name: 'live'), transaction: tx),
);
deletedRow = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(session, Person(name: 'deleted'), transaction: tx),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, deletedRow, transaction: tx),
);
});
test(
'when using find with includeHiddenRows, '
'then both the live and tombstoned rows are returned.',
() async {
final result = await Person.db.find(
session,
where: (t) => t.includeHiddenRows,
);
expect(
result.map((r) => r.id).toSet(),
{liveRow.id, deletedRow.id},
);
},
);
test(
'when using findFirstRow with includeHiddenRows and a name filter, '
'then the tombstoned row is returned.',
() async {
final result = await Person.db.findFirstRow(
session,
where: (t) => t.name.equals('deleted') & t.includeHiddenRows,
);
expect(result, isNotNull);
expect(result!.id, deletedRow.id);
},
);
test(
'when using find with ID filter and includeHiddenRows,'
' then the tombstoned row is returned.',
() async {
final result = await Person.db.find(
session,
where: (t) => t.id.equals(deletedRow.id) & t.includeHiddenRows,
);
expect(result, hasLength(1));
expect(result.single.id, deletedRow.id);
},
);
test(
'when using find without includeHiddenRows, '
'then only the live row is returned.',
() async {
final result = await Person.db.find(session);
expect(result.map((r) => r.id).toSet(), {liveRow.id});
},
);
});
group(
'Given a city with a live citizen and a tombstoned citizen,',
() {
late City city;
late Person liveCitizen;
late Person deletedCitizen;
setUp(() async {
city = await session.db.transactionForUser(
testCrdtUserId,
(tx) =>
City.db.insertRow(session, City(name: 'Springfield'), transaction: tx),
);
liveCitizen = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(session, Person(name: 'live'), transaction: tx),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) =>
City.db.attachRow.citizens(session, city, liveCitizen, transaction: tx),
);
deletedCitizen = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'deleted'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => City.db.attachRow.citizens(
session,
city,
deletedCitizen,
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, deletedCitizen, transaction: tx),
);
});
test(
'when fetching the city with default citizens include, '
'then only the live citizen is returned.',
() async {
final result = await City.db.findById(
session,
city.id!,
include: City.include(citizens: Person.includeList()),
);
expect(result, isNotNull);
expect(result!.citizens?.map((c) => c.id).toSet(), {liveCitizen.id});
},
);
test(
'when fetching the city with includeHiddenRows in citizens list where, '
'then both live and tombstoned citizens are returned.',
() async {
final result = await City.db.findById(
session,
city.id!,
include: City.include(
citizens: Person.includeList(
where: (t) => t.includeHiddenRows,
),
),
);
expect(result, isNotNull);
expect(
result!.citizens?.map((c) => c.id).toSet(),
{liveCitizen.id, deletedCitizen.id},
);
},
);
test(
'when fetching the city with a name filter and includeHiddenRows in '
'citizens list where, then only the matching tombstoned citizen is returned.',
() async {
final result = await City.db.findById(
session,
city.id!,
include: City.include(
citizens: Person.includeList(
where: (t) => t.name.equals('deleted') & t.includeHiddenRows,
),
),
);
expect(result, isNotNull);
expect(
result!.citizens?.map((c) => c.id).toSet(),
{deletedCitizen.id},
);
},
);
},
);
}