flongo 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -168,6 +168,32 @@ const topRated = await products.aggregate([
168
168
  ]);
169
169
  ```
170
170
 
171
+ #### Id projection & semi-joins (`getIds`)
172
+
173
+ When you only need to know **which** documents match — not their contents —
174
+ `getIds(query, pagination?)` returns matching `_id`s as strings. It uses a
175
+ server-side projection, so full documents never leave the server. This is the
176
+ natural fit for a semi-join: "of these N candidate ids, which pass this filter?"
177
+
178
+ ```typescript
179
+ // Of these candidate stayIds, which are publicly visible?
180
+ const visibleIds = await stays.getIds(
181
+ new FlongoQuery()
182
+ .where('_id').in(candidateStayIds)
183
+ .and('status').eq('Accepted')
184
+ .and('enable').eq(true)
185
+ );
186
+ ```
187
+
188
+ `getIds` honors the same query and pagination semantics as `getAll`, including
189
+ sort clauses — ordered ids are useful for id-first pagination.
190
+
191
+ > **Note (2.2.0):** inline `_id` clauses now compose with other field clauses,
192
+ > as the example above relies on. Previously an `_id` clause caused every other
193
+ > clause in the same clause list to be silently dropped, and filters had to be
194
+ > attached via `.andQuery(new FlongoQuery().where('_id').in(ids))` — that
195
+ > pattern still works unchanged.
196
+
171
197
  ### Collection Operations
172
198
 
173
199
  ```typescript
@@ -184,6 +210,7 @@ const user = await collection.create({
184
210
  // Read
185
211
  const user = await collection.get('user123');
186
212
  const users = await collection.getAll(query);
213
+ const ids = await collection.getIds(query); // matching _ids only (server-side projection)
187
214
  const first = await collection.getFirst(query);
188
215
  const count = await collection.count(query);
189
216
  const exists = await collection.exists(query);
@@ -502,6 +529,25 @@ const users = await collection.getAll(
502
529
  );
503
530
  ```
504
531
 
532
+ ## Changelog
533
+
534
+ ### 2.2.0
535
+
536
+ - **`FlongoCollection.getIds(query?, pagination?)`** — returns matching `_id`s
537
+ as strings via a server-side projection (`{_id: 1}` on `find`, `$project` on
538
+ the aggregation path), so full documents never cross the wire. Honors the
539
+ same query, sort, and pagination semantics as `getAll`.
540
+ - **Inline `_id` clauses now compose in `build()`.** Previously an `_id`
541
+ expression in the main clause list short-circuited the build and silently
542
+ discarded every other clause; combining `_id` with other filters required the
543
+ `.andQuery(new FlongoQuery().where('_id').in(ids))` workaround. Now `_id`
544
+ composes like any other field (string → ObjectId conversion is preserved for
545
+ both single values and `$in` arrays). Queries with **only** an `_id` clause
546
+ build identical output to before, and the `andQuery` pattern still works.
547
+ *Behavior note:* if a query combined `_id` with other clauses **and** relied
548
+ on those other clauses being ignored, it now returns only documents matching
549
+ all clauses — i.e., what the query literally says.
550
+
505
551
  ## License
506
552
 
507
553
  MIT
@@ -92,12 +92,39 @@ export declare class FlongoCollection<T> {
92
92
  */
93
93
  getAll(query?: FlongoQuery, pagination?: Pagination): Promise<(Entity & T)[]>;
94
94
  /**
95
- * Executes a query carrying an `orderByRandom` via an aggregation pipeline.
96
- * Verifies the server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so
97
- * callers get an actionable error rather than a raw driver failure.
95
+ * Executes a query carrying computed sort keys (`orderByRandom` and/or
96
+ * `orderByExpr`) via an aggregation pipeline. For random sorts, verifies the
97
+ * server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so callers get an
98
+ * actionable error rather than a raw driver failure.
98
99
  * @private
99
100
  */
100
- private getAllRandom;
101
+ private getAllViaPipeline;
102
+ /**
103
+ * Retrieves only the `_id`s of matching documents, as strings (the same
104
+ * normalization `toEntity` applies). Uses a server-side projection so full
105
+ * documents never leave the server — ideal for semi-joins ("which of these N
106
+ * ids match this filter?") and other membership checks where hydrating whole
107
+ * documents is wasted work.
108
+ *
109
+ * Honors the same query and pagination semantics as `getAll`, including sort
110
+ * clauses (ordered ids are useful for id-first pagination).
111
+ *
112
+ * Example usage:
113
+ * ```typescript
114
+ * // Semi-join: of these candidate stayIds, which are publicly visible?
115
+ * const visibleIds = await stays.getIds(
116
+ * new FlongoQuery()
117
+ * .where('_id').in(candidateStayIds)
118
+ * .and('status').eq('Accepted')
119
+ * .and('enable').eq(true)
120
+ * );
121
+ * ```
122
+ *
123
+ * @param query - Optional FlongoQuery for filtering
124
+ * @param pagination - Optional pagination settings
125
+ * @returns Promise resolving to the matching document ids as strings
126
+ */
127
+ getIds(query?: FlongoQuery, pagination?: Pagination): Promise<string[]>;
101
128
  /**
102
129
  * Runs a raw aggregation pipeline against this collection and returns the
103
130
  * documents with their `_id` normalized to a string (Entity form). A low-level
@@ -128,10 +128,11 @@ class FlongoCollection {
128
128
  * @returns Promise resolving to array of documents
129
129
  */
130
130
  async getAll(query, pagination) {
131
- // A seeded random sort can't be expressed with find().sort() — it sorts by a
132
- // computed per-document value, so it routes through an aggregation pipeline.
133
- if (query?.hasRandomSort()) {
134
- return this.getAllRandom(query, pagination);
131
+ // Seeded random and expression sorts can't be expressed with find().sort() —
132
+ // they sort by computed per-document values, so they route through an
133
+ // aggregation pipeline.
134
+ if (query?.needsPipeline()) {
135
+ return this.getAllViaPipeline(query, pagination);
135
136
  }
136
137
  const mongodbQuery = query?.build() ?? {};
137
138
  const mongodbOptions = query?.buildOptions(pagination) ?? new flongoQuery_1.FlongoQuery().buildOptions(pagination);
@@ -144,17 +145,65 @@ class FlongoCollection {
144
145
  }
145
146
  }
146
147
  /**
147
- * Executes a query carrying an `orderByRandom` via an aggregation pipeline.
148
- * Verifies the server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so
149
- * callers get an actionable error rather than a raw driver failure.
148
+ * Executes a query carrying computed sort keys (`orderByRandom` and/or
149
+ * `orderByExpr`) via an aggregation pipeline. For random sorts, verifies the
150
+ * server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so callers get an
151
+ * actionable error rather than a raw driver failure.
150
152
  * @private
151
153
  */
152
- async getAllRandom(query, pagination) {
153
- await this.assertRandomSortSupported();
154
+ async getAllViaPipeline(query, pagination) {
155
+ if (query.hasRandomSort()) {
156
+ await this.assertRandomSortSupported();
157
+ }
154
158
  const pipeline = query.buildPipeline(pagination);
155
159
  const res = await this.collection.aggregate(pipeline).toArray();
156
160
  return res.map((d) => this.toEntity(d));
157
161
  }
162
+ /**
163
+ * Retrieves only the `_id`s of matching documents, as strings (the same
164
+ * normalization `toEntity` applies). Uses a server-side projection so full
165
+ * documents never leave the server — ideal for semi-joins ("which of these N
166
+ * ids match this filter?") and other membership checks where hydrating whole
167
+ * documents is wasted work.
168
+ *
169
+ * Honors the same query and pagination semantics as `getAll`, including sort
170
+ * clauses (ordered ids are useful for id-first pagination).
171
+ *
172
+ * Example usage:
173
+ * ```typescript
174
+ * // Semi-join: of these candidate stayIds, which are publicly visible?
175
+ * const visibleIds = await stays.getIds(
176
+ * new FlongoQuery()
177
+ * .where('_id').in(candidateStayIds)
178
+ * .and('status').eq('Accepted')
179
+ * .and('enable').eq(true)
180
+ * );
181
+ * ```
182
+ *
183
+ * @param query - Optional FlongoQuery for filtering
184
+ * @param pagination - Optional pagination settings
185
+ * @returns Promise resolving to the matching document ids as strings
186
+ */
187
+ async getIds(query, pagination) {
188
+ // Mirror getAll: computed sort keys must execute via the aggregation
189
+ // pipeline. Only the id column is materialized on either path.
190
+ if (query?.needsPipeline()) {
191
+ if (query.hasRandomSort()) {
192
+ await this.assertRandomSortSupported();
193
+ }
194
+ const pipeline = query.buildPipeline(pagination);
195
+ pipeline.push({ $project: { _id: 1 } });
196
+ const res = await this.collection.aggregate(pipeline).toArray();
197
+ return res.map((d) => String(d._id));
198
+ }
199
+ const mongodbQuery = query?.build() ?? {};
200
+ const mongodbOptions = {
201
+ ...(query?.buildOptions(pagination) ?? new flongoQuery_1.FlongoQuery().buildOptions(pagination)),
202
+ projection: { _id: 1 }
203
+ };
204
+ const res = await this.collection.find(mongodbQuery, mongodbOptions).toArray();
205
+ return res.map((d) => String(d._id));
206
+ }
158
207
  /**
159
208
  * Runs a raw aggregation pipeline against this collection and returns the
160
209
  * documents with their `_id` normalized to a string (Entity form). A low-level
@@ -194,9 +243,9 @@ class FlongoCollection {
194
243
  * @returns Promise resolving to array of documents
195
244
  */
196
245
  async getSome(query, pagination) {
197
- // Mirror getAll: a seeded random sort routes through the aggregation path.
198
- if (query?.hasRandomSort()) {
199
- return this.getAllRandom(query, pagination);
246
+ // Mirror getAll: computed sort keys route through the aggregation path.
247
+ if (query?.needsPipeline()) {
248
+ return this.getAllViaPipeline(query, pagination);
200
249
  }
201
250
  const mongodbQuery = query?.build();
202
251
  const mongodbOptions = query?.buildOptions(pagination);
@@ -40,6 +40,8 @@ export declare class FlongoQuery implements ICollectionQuery {
40
40
  randomSortPosition?: number;
41
41
  /** Internal computed field used to carry the per-document shuffle hash. */
42
42
  static readonly SHUFFLE_FIELD = "__flongoShuffle";
43
+ /** Prefix of internal computed fields backing expression sort keys. */
44
+ static readonly EXPR_FIELD_PREFIX = "__flongoSortExpr";
43
45
  /**
44
46
  * Primary sort field, derived from `sorts[0]`.
45
47
  * @deprecated Retained for backward compatibility; use `sorts`.
@@ -279,6 +281,48 @@ export declare class FlongoQuery implements ICollectionQuery {
279
281
  * @returns This query instance for chaining
280
282
  */
281
283
  thenBy(field?: string, direction?: SortDirection): FlongoQuery;
284
+ /**
285
+ * Sets the primary sort to a computed aggregation expression, resetting any
286
+ * previously configured sort keys (the expression analog of `orderBy`).
287
+ *
288
+ * The expression is materialized into an internal field via `$addFields`, so a
289
+ * query carrying an expression sort executes via an aggregation pipeline
290
+ * instead of `find` (same execution path as `orderByRandom`, but with no
291
+ * server-version requirement). The internal field never leaks to callers.
292
+ *
293
+ * The canonical use is normalizing a field before sorting on it. MongoDB's
294
+ * BSON type order ranks Boolean above Null/missing, so
295
+ * `orderBy('featured', Descending)` puts an explicit `featured: false` *above*
296
+ * documents missing the field entirely. Normalizing fixes that:
297
+ *
298
+ * @example
299
+ * // Pin featured docs on top; explicit false and missing tie.
300
+ * new FlongoQuery()
301
+ * .orderByExpr({ $eq: ['$featured', true] }, SortDirection.Descending)
302
+ * .thenBy('_id', SortDirection.Ascending);
303
+ *
304
+ * Composes with `thenBy`/`orderByRandom` exactly like a field sort key: keys
305
+ * apply in call order.
306
+ *
307
+ * @param expr - MongoDB aggregation expression to sort by
308
+ * @param direction - Sort direction (ascending or descending)
309
+ * @returns This query instance for chaining
310
+ */
311
+ orderByExpr(expr: Document, direction: SortDirection): FlongoQuery;
312
+ /**
313
+ * Appends a computed aggregation expression as a tiebreaker sort key (the
314
+ * expression analog of `thenBy`). See `orderByExpr` for semantics.
315
+ *
316
+ * @param expr - MongoDB aggregation expression to sort by
317
+ * @param direction - Sort direction (ascending or descending)
318
+ * @returns This query instance for chaining
319
+ */
320
+ thenByExpr(expr: Document, direction: SortDirection): FlongoQuery;
321
+ /**
322
+ * Whether this query carries any expression sort keys (`orderByExpr`/
323
+ * `thenByExpr`) and must therefore execute via an aggregation pipeline.
324
+ */
325
+ hasExprSort(): boolean;
282
326
  /**
283
327
  * Adds a deterministic, seeded random sort. Given the same seed the produced
284
328
  * order is identical on every call, so `$skip`/`$limit` pagination stays
@@ -316,6 +360,12 @@ export declare class FlongoQuery implements ICollectionQuery {
316
360
  * pipeline (`buildPipeline`) rather than the normal `find` path.
317
361
  */
318
362
  hasRandomSort(): boolean;
363
+ /**
364
+ * Whether this query must execute via an aggregation pipeline
365
+ * (`buildPipeline`) rather than the normal `find` path — true when it carries
366
+ * a random sort and/or any expression sort keys.
367
+ */
368
+ needsPipeline(): boolean;
319
369
  /**
320
370
  * Adds a sub-query with AND logic
321
371
  * @param query - Sub-query to combine with AND logic
@@ -343,20 +393,22 @@ export declare class FlongoQuery implements ICollectionQuery {
343
393
  /**
344
394
  * Builds the ordered `$sort` specification for the aggregation path, weaving
345
395
  * the internal shuffle field in at its call position amongst the explicit
346
- * sort keys and always appending `_id` as a final tiebreaker. Only used when
347
- * a random sort is present.
396
+ * sort keys (expression keys sort by their computed internal field) and always
397
+ * appending `_id` as a final tiebreaker. Only used on the pipeline path.
348
398
  * @private
349
399
  */
350
400
  private buildAggregationSort;
351
401
  /**
352
402
  * Compiles this query into an aggregation pipeline that materializes the same
353
- * results as `find` would, but ordered by a deterministic per-document shuffle
354
- * (see `orderByRandom`). The `$match` stage is exactly what `find` receives
355
- * today (`build()`), so all filter logic and indexes carry over unchanged;
356
- * `$skip`/`$limit` are applied *after* the deterministic sort, which is what
357
- * makes paging stable.
403
+ * results as `find` would, but ordered by computed sort values — the
404
+ * deterministic per-document shuffle (`orderByRandom`) and/or expression sort
405
+ * keys (`orderByExpr`/`thenByExpr`). The `$match` stage is exactly what `find`
406
+ * receives today (`build()`), so all filter logic and indexes carry over
407
+ * unchanged; `$skip`/`$limit` are applied *after* the deterministic sort,
408
+ * which is what makes paging stable.
358
409
  *
359
- * **Requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`).
410
+ * **A random sort requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`);
411
+ * expression sorts have no version requirement.
360
412
  *
361
413
  * @param pagination - Optional pagination settings
362
414
  * @returns MongoDB aggregation pipeline stages
@@ -414,6 +414,57 @@ class FlongoQuery {
414
414
  }
415
415
  return this;
416
416
  }
417
+ /**
418
+ * Sets the primary sort to a computed aggregation expression, resetting any
419
+ * previously configured sort keys (the expression analog of `orderBy`).
420
+ *
421
+ * The expression is materialized into an internal field via `$addFields`, so a
422
+ * query carrying an expression sort executes via an aggregation pipeline
423
+ * instead of `find` (same execution path as `orderByRandom`, but with no
424
+ * server-version requirement). The internal field never leaks to callers.
425
+ *
426
+ * The canonical use is normalizing a field before sorting on it. MongoDB's
427
+ * BSON type order ranks Boolean above Null/missing, so
428
+ * `orderBy('featured', Descending)` puts an explicit `featured: false` *above*
429
+ * documents missing the field entirely. Normalizing fixes that:
430
+ *
431
+ * @example
432
+ * // Pin featured docs on top; explicit false and missing tie.
433
+ * new FlongoQuery()
434
+ * .orderByExpr({ $eq: ['$featured', true] }, SortDirection.Descending)
435
+ * .thenBy('_id', SortDirection.Ascending);
436
+ *
437
+ * Composes with `thenBy`/`orderByRandom` exactly like a field sort key: keys
438
+ * apply in call order.
439
+ *
440
+ * @param expr - MongoDB aggregation expression to sort by
441
+ * @param direction - Sort direction (ascending or descending)
442
+ * @returns This query instance for chaining
443
+ */
444
+ orderByExpr(expr, direction) {
445
+ this.sorts = [];
446
+ return this.thenByExpr(expr, direction);
447
+ }
448
+ /**
449
+ * Appends a computed aggregation expression as a tiebreaker sort key (the
450
+ * expression analog of `thenBy`). See `orderByExpr` for semantics.
451
+ *
452
+ * @param expr - MongoDB aggregation expression to sort by
453
+ * @param direction - Sort direction (ascending or descending)
454
+ * @returns This query instance for chaining
455
+ */
456
+ thenByExpr(expr, direction) {
457
+ const index = this.sorts.filter((s) => s.expr !== undefined).length;
458
+ this.sorts.push({ field: `${FlongoQuery.EXPR_FIELD_PREFIX}${index}`, direction, expr });
459
+ return this;
460
+ }
461
+ /**
462
+ * Whether this query carries any expression sort keys (`orderByExpr`/
463
+ * `thenByExpr`) and must therefore execute via an aggregation pipeline.
464
+ */
465
+ hasExprSort() {
466
+ return this.sorts.some((s) => s.expr !== undefined);
467
+ }
417
468
  /**
418
469
  * Adds a deterministic, seeded random sort. Given the same seed the produced
419
470
  * order is identical on every call, so `$skip`/`$limit` pagination stays
@@ -457,6 +508,14 @@ class FlongoQuery {
457
508
  hasRandomSort() {
458
509
  return this.randomSeed !== undefined;
459
510
  }
511
+ /**
512
+ * Whether this query must execute via an aggregation pipeline
513
+ * (`buildPipeline`) rather than the normal `find` path — true when it carries
514
+ * a random sort and/or any expression sort keys.
515
+ */
516
+ needsPipeline() {
517
+ return this.hasRandomSort() || this.hasExprSort();
518
+ }
460
519
  // ===========================================
461
520
  // LOGICAL OPERATORS
462
521
  // ===========================================
@@ -492,25 +551,24 @@ class FlongoQuery {
492
551
  // Process main expressions
493
552
  if (this.expressions) {
494
553
  for (const expression of this.expressions) {
495
- // Special handling for _id field - convert strings to ObjectIds
554
+ // Build query object for this clause
555
+ let fieldValue;
496
556
  if (expression.key === "_id") {
557
+ // _id keeps its string -> ObjectId conversion but composes with the
558
+ // other clauses like any field (it used to short-circuit the build,
559
+ // silently discarding every co-existing clause).
497
560
  if (Array.isArray(expression.val)) {
498
561
  // Multiple IDs: {_id: {$in: [ObjectId(...), ObjectId(...)]}}
499
- mongodbQuery = {
500
- _id: {
501
- [expression.op ?? "$in"]: expression.val.map((element) => new mongodb_1.ObjectId(element))
502
- }
562
+ fieldValue = {
563
+ [expression.op ?? "$in"]: expression.val.map((element) => new mongodb_1.ObjectId(element))
503
564
  };
504
565
  }
505
566
  else {
506
567
  // Single ID: {_id: ObjectId(...)}
507
- mongodbQuery = { _id: new mongodb_1.ObjectId(expression.val) };
568
+ fieldValue = new mongodb_1.ObjectId(expression.val);
508
569
  }
509
- break; // _id queries are typically exclusive
510
570
  }
511
- // Build query object for non-_id fields
512
- let fieldValue;
513
- if (!expression.op) {
571
+ else if (!expression.op) {
514
572
  // Direct value assignment for simple equality (arrContains)
515
573
  fieldValue = expression.val;
516
574
  }
@@ -528,10 +586,14 @@ class FlongoQuery {
528
586
  // Standard operator
529
587
  fieldValue = { [expression.op]: expression.val };
530
588
  }
531
- // Merge operators for the same field (e.g., range queries)
589
+ // Merge operators for the same field (e.g., range queries).
590
+ // ObjectId instances are direct values, not operator maps — spreading
591
+ // one would destroy it, so they always assign.
532
592
  if (mongodbQuery[expression.key] &&
533
593
  typeof mongodbQuery[expression.key] === "object" &&
534
- typeof fieldValue === "object") {
594
+ !(mongodbQuery[expression.key] instanceof mongodb_1.ObjectId) &&
595
+ typeof fieldValue === "object" &&
596
+ !(fieldValue instanceof mongodb_1.ObjectId)) {
535
597
  mongodbQuery[expression.key] = { ...mongodbQuery[expression.key], ...fieldValue };
536
598
  }
537
599
  else {
@@ -584,8 +646,10 @@ class FlongoQuery {
584
646
  // Add sorting if specified. Iterate sort keys in order so MongoDB honors the
585
647
  // insertion order of the resulting `sort` object's keys (primary first, then
586
648
  // tiebreakers). Entries without a direction are skipped, preserving the prior
587
- // behavior where a field set with no direction produced no sort.
588
- const sortKeys = this.sorts.filter((s) => s.field && s.direction);
649
+ // behavior where a field set with no direction produced no sort. Expression
650
+ // keys are computed fields that only exist on the pipeline path, so they are
651
+ // excluded here rather than sorting on a field no document has.
652
+ const sortKeys = this.sorts.filter((s) => s.field && s.direction && s.expr === undefined);
589
653
  if (sortKeys.length) {
590
654
  const sort = {};
591
655
  for (const s of sortKeys) {
@@ -598,8 +662,8 @@ class FlongoQuery {
598
662
  /**
599
663
  * Builds the ordered `$sort` specification for the aggregation path, weaving
600
664
  * the internal shuffle field in at its call position amongst the explicit
601
- * sort keys and always appending `_id` as a final tiebreaker. Only used when
602
- * a random sort is present.
665
+ * sort keys (expression keys sort by their computed internal field) and always
666
+ * appending `_id` as a final tiebreaker. Only used on the pipeline path.
603
667
  * @private
604
668
  */
605
669
  buildAggregationSort() {
@@ -629,42 +693,60 @@ class FlongoQuery {
629
693
  }
630
694
  /**
631
695
  * Compiles this query into an aggregation pipeline that materializes the same
632
- * results as `find` would, but ordered by a deterministic per-document shuffle
633
- * (see `orderByRandom`). The `$match` stage is exactly what `find` receives
634
- * today (`build()`), so all filter logic and indexes carry over unchanged;
635
- * `$skip`/`$limit` are applied *after* the deterministic sort, which is what
636
- * makes paging stable.
696
+ * results as `find` would, but ordered by computed sort values — the
697
+ * deterministic per-document shuffle (`orderByRandom`) and/or expression sort
698
+ * keys (`orderByExpr`/`thenByExpr`). The `$match` stage is exactly what `find`
699
+ * receives today (`build()`), so all filter logic and indexes carry over
700
+ * unchanged; `$skip`/`$limit` are applied *after* the deterministic sort,
701
+ * which is what makes paging stable.
637
702
  *
638
- * **Requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`).
703
+ * **A random sort requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`);
704
+ * expression sorts have no version requirement.
639
705
  *
640
706
  * @param pagination - Optional pagination settings
641
707
  * @returns MongoDB aggregation pipeline stages
642
708
  */
643
709
  buildPipeline(pagination) {
644
710
  const pipeline = [{ $match: this.build() }];
645
- // Hash the full `_id` string mixed with the seed. Hashing the whole _id
646
- // decorrelates the order from the ObjectId timestamp prefix, so newer docs
647
- // aren't biased toward one end of the shuffle.
648
- pipeline.push({
649
- $addFields: {
650
- [FlongoQuery.SHUFFLE_FIELD]: {
651
- $toHashedIndexKey: { $concat: [{ $toString: "$_id" }, ":", this.randomSeed] }
652
- }
711
+ // Materialize every computed sort value in one $addFields stage.
712
+ const computed = {};
713
+ for (const s of this.sorts) {
714
+ if (s.expr !== undefined) {
715
+ computed[s.field] = s.expr;
653
716
  }
654
- });
717
+ }
718
+ if (this.randomSeed !== undefined) {
719
+ // Hash the full `_id` string mixed with the seed. Hashing the whole _id
720
+ // decorrelates the order from the ObjectId timestamp prefix, so newer docs
721
+ // aren't biased toward one end of the shuffle.
722
+ computed[FlongoQuery.SHUFFLE_FIELD] = {
723
+ $toHashedIndexKey: { $concat: [{ $toString: "$_id" }, ":", this.randomSeed] }
724
+ };
725
+ }
726
+ if (Object.keys(computed).length > 0) {
727
+ pipeline.push({ $addFields: computed });
728
+ }
655
729
  pipeline.push({ $sort: this.buildAggregationSort() });
656
730
  if (pagination) {
657
731
  pipeline.push({ $skip: pagination.offset });
658
732
  pipeline.push({ $limit: pagination.count });
659
733
  }
660
- // Don't leak the internal shuffle field to callers.
661
- pipeline.push({ $project: { [FlongoQuery.SHUFFLE_FIELD]: 0 } });
734
+ // Don't leak the internal computed fields to callers.
735
+ if (Object.keys(computed).length > 0) {
736
+ const exclusions = {};
737
+ for (const field of Object.keys(computed)) {
738
+ exclusions[field] = 0;
739
+ }
740
+ pipeline.push({ $project: exclusions });
741
+ }
662
742
  return pipeline;
663
743
  }
664
744
  }
665
745
  exports.FlongoQuery = FlongoQuery;
666
746
  /** Internal computed field used to carry the per-document shuffle hash. */
667
747
  FlongoQuery.SHUFFLE_FIELD = "__flongoShuffle";
748
+ /** Prefix of internal computed fields backing expression sort keys. */
749
+ FlongoQuery.EXPR_FIELD_PREFIX = "__flongoSortExpr";
668
750
  /**
669
751
  * FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
670
752
  * This can be useful for more complex query construction scenarios
package/dist/types.d.ts CHANGED
@@ -62,6 +62,12 @@ export declare class ColExpression {
62
62
  export interface Sort {
63
63
  field: string;
64
64
  direction?: SortDirection;
65
+ /**
66
+ * Aggregation expression this sort key is computed from (set by
67
+ * `orderByExpr`/`thenByExpr`). When present, `field` is an internal
68
+ * generated name materialized via `$addFields` on the pipeline path.
69
+ */
70
+ expr?: unknown;
65
71
  }
66
72
  export type ICollectionQuery = {
67
73
  expressions: ColExpression[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flongo",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Firestore-like fluent query API for MongoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",