flongo 2.2.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.
@@ -92,12 +92,13 @@ 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;
101
102
  /**
102
103
  * Retrieves only the `_id`s of matching documents, as strings (the same
103
104
  * normalization `toEntity` applies). Uses a server-side projection so full
@@ -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,13 +145,16 @@ 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));
@@ -181,10 +185,12 @@ class FlongoCollection {
181
185
  * @returns Promise resolving to the matching document ids as strings
182
186
  */
183
187
  async getIds(query, pagination) {
184
- // Mirror getAll: a seeded random sort must execute via the aggregation
188
+ // Mirror getAll: computed sort keys must execute via the aggregation
185
189
  // pipeline. Only the id column is materialized on either path.
186
- if (query?.hasRandomSort()) {
187
- await this.assertRandomSortSupported();
190
+ if (query?.needsPipeline()) {
191
+ if (query.hasRandomSort()) {
192
+ await this.assertRandomSortSupported();
193
+ }
188
194
  const pipeline = query.buildPipeline(pagination);
189
195
  pipeline.push({ $project: { _id: 1 } });
190
196
  const res = await this.collection.aggregate(pipeline).toArray();
@@ -237,9 +243,9 @@ class FlongoCollection {
237
243
  * @returns Promise resolving to array of documents
238
244
  */
239
245
  async getSome(query, pagination) {
240
- // Mirror getAll: a seeded random sort routes through the aggregation path.
241
- if (query?.hasRandomSort()) {
242
- 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);
243
249
  }
244
250
  const mongodbQuery = query?.build();
245
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
  // ===========================================
@@ -587,8 +646,10 @@ class FlongoQuery {
587
646
  // Add sorting if specified. Iterate sort keys in order so MongoDB honors the
588
647
  // insertion order of the resulting `sort` object's keys (primary first, then
589
648
  // tiebreakers). Entries without a direction are skipped, preserving the prior
590
- // behavior where a field set with no direction produced no sort.
591
- 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);
592
653
  if (sortKeys.length) {
593
654
  const sort = {};
594
655
  for (const s of sortKeys) {
@@ -601,8 +662,8 @@ class FlongoQuery {
601
662
  /**
602
663
  * Builds the ordered `$sort` specification for the aggregation path, weaving
603
664
  * the internal shuffle field in at its call position amongst the explicit
604
- * sort keys and always appending `_id` as a final tiebreaker. Only used when
605
- * 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.
606
667
  * @private
607
668
  */
608
669
  buildAggregationSort() {
@@ -632,42 +693,60 @@ class FlongoQuery {
632
693
  }
633
694
  /**
634
695
  * Compiles this query into an aggregation pipeline that materializes the same
635
- * results as `find` would, but ordered by a deterministic per-document shuffle
636
- * (see `orderByRandom`). The `$match` stage is exactly what `find` receives
637
- * today (`build()`), so all filter logic and indexes carry over unchanged;
638
- * `$skip`/`$limit` are applied *after* the deterministic sort, which is what
639
- * 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.
640
702
  *
641
- * **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.
642
705
  *
643
706
  * @param pagination - Optional pagination settings
644
707
  * @returns MongoDB aggregation pipeline stages
645
708
  */
646
709
  buildPipeline(pagination) {
647
710
  const pipeline = [{ $match: this.build() }];
648
- // Hash the full `_id` string mixed with the seed. Hashing the whole _id
649
- // decorrelates the order from the ObjectId timestamp prefix, so newer docs
650
- // aren't biased toward one end of the shuffle.
651
- pipeline.push({
652
- $addFields: {
653
- [FlongoQuery.SHUFFLE_FIELD]: {
654
- $toHashedIndexKey: { $concat: [{ $toString: "$_id" }, ":", this.randomSeed] }
655
- }
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;
656
716
  }
657
- });
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
+ }
658
729
  pipeline.push({ $sort: this.buildAggregationSort() });
659
730
  if (pagination) {
660
731
  pipeline.push({ $skip: pagination.offset });
661
732
  pipeline.push({ $limit: pagination.count });
662
733
  }
663
- // Don't leak the internal shuffle field to callers.
664
- 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
+ }
665
742
  return pipeline;
666
743
  }
667
744
  }
668
745
  exports.FlongoQuery = FlongoQuery;
669
746
  /** Internal computed field used to carry the per-document shuffle hash. */
670
747
  FlongoQuery.SHUFFLE_FIELD = "__flongoShuffle";
748
+ /** Prefix of internal computed fields backing expression sort keys. */
749
+ FlongoQuery.EXPR_FIELD_PREFIX = "__flongoSortExpr";
671
750
  /**
672
751
  * FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
673
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.2.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",