diff --git a/lib/src/services/yust_database_service_dart.dart b/lib/src/services/yust_database_service_dart.dart index c05c5c31..dfc735d9 100644 --- a/lib/src/services/yust_database_service_dart.dart +++ b/lib/src/services/yust_database_service_dart.dart @@ -324,7 +324,11 @@ class YustDatabaseService implements IYustDatabaseService { getQuery( docSetup, filters: filters, - orderBy: orderBy, + orderBy: getOrderBy( + filters: filters, + orderBy: orderBy, + startAfterDocument: startAfterDocument, + ), limit: limit, startAfterDocument: startAfterDocument, ), @@ -1269,6 +1273,46 @@ class YustDatabaseService implements IYustDatabaseService { return _transformDoc(docSetup, document as Document); } + /// Returns the orderBy list to send to Firestore for a `getListFromDB` + /// call. When [startAfterDocument] is `null` the caller's [orderBy] is + /// returned unchanged — behavior for non-paginated queries is + /// preserved. + /// + /// When [startAfterDocument] is set the orderBy must be non-empty: + /// Firestore's REST `runQuery` builds the `startAt` cursor by mapping + /// the query's `orderBy` fields onto the document's field values, and + /// an empty cursor makes Firestore return zero results. Mirrors the + /// pattern used in the lazy-chunked path: + /// - Seed orderBy from inequality-filter fields when the caller didn't + /// provide one (Firestore requires an inequality field to be the + /// first orderBy anyway). + /// - Append `__name__` as the stable tiebreaker so the cursor always + /// has a document reference to anchor on. + static List? getOrderBy({ + required List? filters, + required List? orderBy, + required T? startAfterDocument, + }) { + if (startAfterDocument == null) return orderBy; + + final unequalFilters = (filters ?? []) + .whereNot( + (filter) => + YustFilterComparator.equalityFilters.contains(filter.comparator), + ) + .toSet() + .toList(); + + final resolved = [ + if ((orderBy == null || orderBy.isEmpty) && unequalFilters.isNotEmpty) + ...unequalFilters.map((e) => YustOrderBy(field: e.field)).toSet(), + ...?orderBy, + ]; + + if (resolved.any((o) => o.field == '__name__')) return resolved; + return [...resolved, YustOrderBy(field: '__name__')]; + } + String _getDatabasePath() => 'projects/${Yust.projectId}/databases/(default)'; String _getParentPath(YustDocSetup docSetup) { @@ -1607,7 +1651,8 @@ class YustDatabaseService implements IYustDatabaseService { // the `nullValue` branch below, not here. return map?.map( (key, childValue) => MapEntry(key, _dbValueToValue(childValue)), - ) ?? {}; + ) ?? + {}; } else if (dbValue.booleanValue != null) { return dbValue.booleanValue; } else if (dbValue.integerValue != null) { diff --git a/test/yust_database_service_dart_test.dart b/test/yust_database_service_dart_test.dart new file mode 100644 index 00000000..76dc0610 --- /dev/null +++ b/test/yust_database_service_dart_test.dart @@ -0,0 +1,161 @@ +import 'package:test/test.dart'; +import 'package:yust/src/models/yust_doc.dart'; +import 'package:yust/src/models/yust_filter.dart'; +import 'package:yust/src/models/yust_order_by.dart'; +import 'package:yust/src/services/yust_database_service_dart.dart'; + +class _FakeDoc extends YustDoc { + _FakeDoc({required String id}) { + this.id = id; + } + + @override + Map toJson() => {'id': id}; + + YustDoc fromJson(Map json) => _FakeDoc(id: json['id']); +} + +void main() { + final anchor = _FakeDoc(id: 'anchor'); + + group('YustDatabaseService.getOrderBy — without startAfterDocument', () { + test('returns caller orderBy unchanged (null stays null)', () { + expect( + YustDatabaseService.getOrderBy( + filters: null, + orderBy: null, + startAfterDocument: null, + ), + isNull, + ); + }); + + test('returns caller orderBy unchanged (explicit list preserved)', () { + final orderBy = [YustOrderBy(field: 'createdAt', descending: true)]; + final result = YustDatabaseService.getOrderBy( + filters: [ + YustFilter( + field: 'modifiedAt', + comparator: YustFilterComparator.greaterThanEqual, + value: 'x', + ), + ], + orderBy: orderBy, + startAfterDocument: null, + ); + expect(result, same(orderBy)); + }); + }); + + group('YustDatabaseService.getOrderBy — with startAfterDocument', () { + test('null orderBy + no inequality filter → [__name__]', () { + final result = YustDatabaseService.getOrderBy( + filters: null, + orderBy: null, + startAfterDocument: anchor, + ); + + expect(result, isNotNull); + expect(result!.map((o) => o.field), ['__name__']); + }); + + test('null orderBy + only equality filter → [__name__]', () { + final result = YustDatabaseService.getOrderBy( + filters: [ + YustFilter( + field: 'status', + comparator: YustFilterComparator.equal, + value: 'open', + ), + ], + orderBy: null, + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['__name__']); + }); + + test('null orderBy + inequality filter → [, __name__]', () { + final result = YustDatabaseService.getOrderBy( + filters: [ + YustFilter( + field: 'modifiedAt', + comparator: YustFilterComparator.greaterThanEqual, + value: '2025-07-01T00:00:00Z', + ), + ], + orderBy: null, + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['modifiedAt', '__name__']); + expect(result.first.descending, isFalse); + }); + + test('null orderBy + range on same field (>= and <=) → ' + 'single field + __name__', () { + final result = YustDatabaseService.getOrderBy( + filters: [ + YustFilter( + field: 'modifiedAt', + comparator: YustFilterComparator.greaterThanEqual, + value: 'a', + ), + YustFilter( + field: 'modifiedAt', + comparator: YustFilterComparator.lessThanEqual, + value: 'b', + ), + ], + orderBy: null, + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['modifiedAt', '__name__']); + }); + + test('explicit orderBy + no filter → orderBy + __name__ tiebreaker', () { + final result = YustDatabaseService.getOrderBy( + filters: null, + orderBy: [YustOrderBy(field: 'createdAt', descending: true)], + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['createdAt', '__name__']); + expect(result.first.descending, isTrue); + }); + + test('explicit orderBy already contains __name__ → not appended twice', () { + final result = YustDatabaseService.getOrderBy( + filters: null, + orderBy: [ + YustOrderBy(field: 'createdAt'), + YustOrderBy(field: '__name__'), + ], + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['createdAt', '__name__']); + }); + + test('explicit orderBy present with inequality filter → keep caller ' + 'order, append __name__', () { + // Caller-supplied orderBy takes precedence; we do not add the + // inequality field because Firestore requires the caller to have + // already put it first (this is validated elsewhere). + final result = YustDatabaseService.getOrderBy( + filters: [ + YustFilter( + field: 'modifiedAt', + comparator: YustFilterComparator.greaterThanEqual, + value: 'a', + ), + ], + orderBy: [YustOrderBy(field: 'modifiedAt')], + startAfterDocument: anchor, + ); + + expect(result!.map((o) => o.field), ['modifiedAt', '__name__']); + }); + }); +}