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:
parent
84cfbadc45
commit
38fa00ddfc
3 changed files with 265 additions and 8 deletions
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue