@lossless.org/client 1.16.0 → 1.17.1
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/.smartconfig.json +20 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.collection.d.ts +6 -1
- package/dist_ts/nosqldb/classes.collection.js +13 -2
- package/dist_ts/nosqldb/classes.distributedcoordinator.js +7 -7
- package/dist_ts/nosqldb/classes.doc.d.ts +79 -14
- package/dist_ts/nosqldb/classes.doc.js +395 -37
- package/dist_ts/nosqldb/classes.easystore.js +2 -2
- package/dist_ts/nosqldb/classes.watcher.js +2 -2
- package/dist_ts/nosqldb/plugins.d.ts +1 -2
- package/dist_ts/nosqldb/plugins.js +2 -3
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.js +2 -2
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.js +2 -2
- package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.d.ts +1 -2
- package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.js +2 -3
- package/licenses/SOURCES.md +13 -0
- package/licenses/aws-sdk-js-v3.txt +201 -0
- package/licenses/nodable-entities.txt +21 -0
- package/package.json +4 -3
- package/readme.md +4 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.collection.ts +15 -1
- package/ts/nosqldb/classes.distributedcoordinator.ts +7 -7
- package/ts/nosqldb/classes.doc.ts +594 -61
- package/ts/nosqldb/classes.easystore.ts +1 -1
- package/ts/nosqldb/classes.watcher.ts +1 -1
- package/ts/nosqldb/plugins.ts +0 -2
- package/ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.ts +1 -1
- package/ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.ts +1 -1
- package/ts/sqldb/clickhouse/smartclickhouse.plugins.ts +1 -2
|
@@ -827,9 +827,23 @@ export interface ISmartdataGroupedTotalsOptions<
|
|
|
827
827
|
* counts elements and a document contributes its `sums` once per element;
|
|
828
828
|
* a document whose array is missing, null or empty contributes nothing.
|
|
829
829
|
* The `filter` still selects whole documents before the array is unwound,
|
|
830
|
-
* and no `groupBy` or `sums` path may contain the unwound array
|
|
830
|
+
* and no `groupBy` or `sums` path may contain the unwound array; use
|
|
831
|
+
* `elementFilter` to select elements.
|
|
831
832
|
*/
|
|
832
833
|
unwind?: TUnwind;
|
|
834
|
+
/**
|
|
835
|
+
* Selects the unwound elements that contribute, applied after the array is
|
|
836
|
+
* unwound and before grouping: only an element that matches is counted and
|
|
837
|
+
* summed. Requires `unwind`. Every path is the unwound array itself (its
|
|
838
|
+
* scalar element) or a declared nested path below it
|
|
839
|
+
* (`lines.accountNumber` below `unwind: 'lines'`), combined through
|
|
840
|
+
* `$and`, `$or` and `$nor`, with the operators of atomic filters: `$eq`,
|
|
841
|
+
* `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`, `$type`,
|
|
842
|
+
* `$regex` with `$options`, and `$not`. Any other path or operator is
|
|
843
|
+
* refused with `invalid_argument`. The document `filter` can carry the same
|
|
844
|
+
* condition to preselect documents through an index.
|
|
845
|
+
*/
|
|
846
|
+
elementFilter?: TSmartdataGroupedTotalsElementFilter<NoInfer<TUnwind>>;
|
|
833
847
|
/**
|
|
834
848
|
* Declared numeric top-level fields, or declared nested paths holding
|
|
835
849
|
* numbers, to sum per group.
|
|
@@ -839,12 +853,21 @@ export interface ISmartdataGroupedTotalsOptions<
|
|
|
839
853
|
>;
|
|
840
854
|
filter?: MongoFilter<T>;
|
|
841
855
|
/**
|
|
842
|
-
* Required bound on the number of returned groups (1..10000).
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
856
|
+
* Required bound on the number of returned groups (1..10000). Groups are
|
|
857
|
+
* ordered by their key — the `groupBy` values in the given order — and the
|
|
858
|
+
* first `limit` groups in that order are returned. Use
|
|
859
|
+
* `getGroupedTotalsPage()` to detect more groups and read them through
|
|
860
|
+
* `nextCursor`, or `iterateGroupedTotals()` to read every group.
|
|
846
861
|
*/
|
|
847
862
|
limit: number;
|
|
863
|
+
/**
|
|
864
|
+
* Continues after the last group of an earlier page: the `nextCursor` that
|
|
865
|
+
* `getGroupedTotalsPage()` returned for the same `groupBy`, `unwind`,
|
|
866
|
+
* `sums`, `filter` and `elementFilter`. The page holds the groups whose key
|
|
867
|
+
* sorts after that group's key. A cursor that does not decode, or that
|
|
868
|
+
* another query shape produced, is refused with `invalid_argument`.
|
|
869
|
+
*/
|
|
870
|
+
cursor?: string;
|
|
848
871
|
maxTimeMS?: number;
|
|
849
872
|
/**
|
|
850
873
|
* Client-side deadline for the aggregation cursor up to its last reply. More
|
|
@@ -889,8 +912,32 @@ export interface ISmartdataGroupedTotalsPage<
|
|
|
889
912
|
> {
|
|
890
913
|
rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
|
|
891
914
|
hasMore: boolean;
|
|
915
|
+
/**
|
|
916
|
+
* Present exactly when `hasMore` is true: pass it back as `cursor`, with the
|
|
917
|
+
* same query, to read the next page. Opaque; it carries the query's shape
|
|
918
|
+
* and the last group's key.
|
|
919
|
+
*/
|
|
920
|
+
nextCursor?: string;
|
|
892
921
|
}
|
|
893
922
|
|
|
923
|
+
/**
|
|
924
|
+
* The `elementFilter` of grouped totals: conditions on the unwound array
|
|
925
|
+
* `TUnwind` itself or on paths below it, in the shape of atomic filter
|
|
926
|
+
* conditions, combined through `$and`, `$or` and `$nor`.
|
|
927
|
+
*/
|
|
928
|
+
export type TSmartdataGroupedTotalsElementFilter<TUnwind extends string> =
|
|
929
|
+
& Partial<
|
|
930
|
+
Record<
|
|
931
|
+
TUnwind | `${TUnwind}.${string}`,
|
|
932
|
+
TSmartdataAtomicFilterCondition<unknown>
|
|
933
|
+
>
|
|
934
|
+
>
|
|
935
|
+
& {
|
|
936
|
+
$and?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
|
|
937
|
+
$or?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
|
|
938
|
+
$nor?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
|
|
939
|
+
};
|
|
940
|
+
|
|
894
941
|
export type TSmartdataAtomicArrayPath<T> = {
|
|
895
942
|
[TKey in TSmartdataDocumentPath<T>]:
|
|
896
943
|
NonNullable<T[TKey]> extends ReadonlyArray<unknown> ? TKey : never;
|
|
@@ -2813,6 +2860,50 @@ const normalizeAtomicNumericExpression = (
|
|
|
2813
2860
|
: guardedComparison;
|
|
2814
2861
|
};
|
|
2815
2862
|
|
|
2863
|
+
/** Requires a non-empty, ordinary, inert array of plain filter objects for `$and`, `$or` or `$nor`. */
|
|
2864
|
+
const requireInertLogicalFilterArray: (
|
|
2865
|
+
valueArg: unknown,
|
|
2866
|
+
labelArg: string,
|
|
2867
|
+
) => asserts valueArg is Array<Record<string, unknown>> = (
|
|
2868
|
+
valueArg,
|
|
2869
|
+
labelArg,
|
|
2870
|
+
) => {
|
|
2871
|
+
if (
|
|
2872
|
+
!Array.isArray(valueArg)
|
|
2873
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
2874
|
+
|| Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
2875
|
+
|| valueArg.length === 0
|
|
2876
|
+
) {
|
|
2877
|
+
throw new SmartdataPersistenceError(
|
|
2878
|
+
'invalid_argument',
|
|
2879
|
+
`${labelArg} must be a non-empty ordinary array of filters.`,
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
assertOnlyDataProperties(valueArg, labelArg, true);
|
|
2883
|
+
for (let index = 0; index < valueArg.length; index++) {
|
|
2884
|
+
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
2885
|
+
throw new SmartdataPersistenceError(
|
|
2886
|
+
'invalid_argument',
|
|
2887
|
+
`${labelArg} may not contain sparse array positions.`,
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
const entry = valueArg[index];
|
|
2891
|
+
if (
|
|
2892
|
+
typeof entry !== 'object'
|
|
2893
|
+
|| entry === null
|
|
2894
|
+
|| Array.isArray(entry)
|
|
2895
|
+
|| plugins.nodeUtil.types.isProxy(entry)
|
|
2896
|
+
|| !isPlainObject(entry)
|
|
2897
|
+
) {
|
|
2898
|
+
throw new SmartdataPersistenceError(
|
|
2899
|
+
'invalid_argument',
|
|
2900
|
+
`${labelArg} entries must be plain filter objects.`,
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
assertOnlyDataProperties(entry, `${labelArg} entry`, false);
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
|
|
2816
2907
|
const normalizeStrictFilter = (
|
|
2817
2908
|
filterArg: Record<string, unknown>,
|
|
2818
2909
|
declaredRootsArg: Set<string>,
|
|
@@ -2849,48 +2940,6 @@ const normalizeStrictFilter = (
|
|
|
2849
2940
|
`${operationLabelArg} filter`,
|
|
2850
2941
|
false,
|
|
2851
2942
|
);
|
|
2852
|
-
const requireInertLogicalFilterArray: (
|
|
2853
|
-
valueArg: unknown,
|
|
2854
|
-
labelArg: string,
|
|
2855
|
-
) => asserts valueArg is Array<Record<string, unknown>> = (
|
|
2856
|
-
valueArg,
|
|
2857
|
-
labelArg,
|
|
2858
|
-
) => {
|
|
2859
|
-
if (
|
|
2860
|
-
!Array.isArray(valueArg)
|
|
2861
|
-
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
2862
|
-
|| Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
2863
|
-
|| valueArg.length === 0
|
|
2864
|
-
) {
|
|
2865
|
-
throw new SmartdataPersistenceError(
|
|
2866
|
-
'invalid_argument',
|
|
2867
|
-
`${labelArg} must be a non-empty ordinary array of filters.`,
|
|
2868
|
-
);
|
|
2869
|
-
}
|
|
2870
|
-
assertOnlyDataProperties(valueArg, labelArg, true);
|
|
2871
|
-
for (let index = 0; index < valueArg.length; index++) {
|
|
2872
|
-
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
2873
|
-
throw new SmartdataPersistenceError(
|
|
2874
|
-
'invalid_argument',
|
|
2875
|
-
`${labelArg} may not contain sparse array positions.`,
|
|
2876
|
-
);
|
|
2877
|
-
}
|
|
2878
|
-
const entry = valueArg[index];
|
|
2879
|
-
if (
|
|
2880
|
-
typeof entry !== 'object'
|
|
2881
|
-
|| entry === null
|
|
2882
|
-
|| Array.isArray(entry)
|
|
2883
|
-
|| plugins.nodeUtil.types.isProxy(entry)
|
|
2884
|
-
|| !isPlainObject(entry)
|
|
2885
|
-
) {
|
|
2886
|
-
throw new SmartdataPersistenceError(
|
|
2887
|
-
'invalid_argument',
|
|
2888
|
-
`${labelArg} entries must be plain filter objects.`,
|
|
2889
|
-
);
|
|
2890
|
-
}
|
|
2891
|
-
assertOnlyDataProperties(entry, `${labelArg} entry`, false);
|
|
2892
|
-
}
|
|
2893
|
-
};
|
|
2894
2943
|
/**
|
|
2895
2944
|
* Plural intent is explicit in the plural method names, so an explicitly
|
|
2896
2945
|
* enumerated, non-empty `$in` over equality scalars anchors those filters as
|
|
@@ -3128,6 +3177,341 @@ const normalizeStrictFilter = (
|
|
|
3128
3177
|
) as plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
3129
3178
|
};
|
|
3130
3179
|
|
|
3180
|
+
/**
|
|
3181
|
+
* Validates the `elementFilter` of grouped totals, which runs after `$unwind`:
|
|
3182
|
+
* every path is the unwound array itself or a declared nested path below it,
|
|
3183
|
+
* and every condition uses the operator subset of atomic filters.
|
|
3184
|
+
*/
|
|
3185
|
+
const normalizeGroupedTotalsElementFilter = (
|
|
3186
|
+
filterArg: unknown,
|
|
3187
|
+
unwindPathArg: string,
|
|
3188
|
+
declaredRootsArg: Set<string>,
|
|
3189
|
+
declaredNestedPathsArg: Set<string>,
|
|
3190
|
+
): plugins.mongodb.Filter<plugins.mongodb.Document> => {
|
|
3191
|
+
const label = 'Grouped totals elementFilter';
|
|
3192
|
+
if (
|
|
3193
|
+
typeof filterArg !== 'object'
|
|
3194
|
+
|| filterArg === null
|
|
3195
|
+
|| Array.isArray(filterArg)
|
|
3196
|
+
|| plugins.nodeUtil.types.isProxy(filterArg)
|
|
3197
|
+
|| !isPlainObject(filterArg)
|
|
3198
|
+
|| Object.keys(filterArg).length === 0
|
|
3199
|
+
) {
|
|
3200
|
+
throw new SmartdataPersistenceError(
|
|
3201
|
+
'invalid_argument',
|
|
3202
|
+
`${label} must be a non-empty plain filter object.`,
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
3205
|
+
const normalizeFilterObject = (
|
|
3206
|
+
filterObjectArg: Record<string, unknown>,
|
|
3207
|
+
): Record<string, unknown> => {
|
|
3208
|
+
assertOnlyDataProperties(filterObjectArg, label, false);
|
|
3209
|
+
const normalized: Record<string, unknown> = {};
|
|
3210
|
+
for (const [key, value] of Object.entries(filterObjectArg)) {
|
|
3211
|
+
if (key === '$and' || key === '$or' || key === '$nor') {
|
|
3212
|
+
requireInertLogicalFilterArray(value, `${label} ${key}`);
|
|
3213
|
+
normalized[key] = value.map((entryArg) => {
|
|
3214
|
+
if (Object.keys(entryArg).length === 0) {
|
|
3215
|
+
throw new SmartdataPersistenceError(
|
|
3216
|
+
'invalid_argument',
|
|
3217
|
+
`${label} ${key} entries must be non-empty filter objects.`,
|
|
3218
|
+
);
|
|
3219
|
+
}
|
|
3220
|
+
return normalizeFilterObject(entryArg);
|
|
3221
|
+
});
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
if (key.startsWith('$')) {
|
|
3225
|
+
throw new SmartdataPersistenceError(
|
|
3226
|
+
'invalid_argument',
|
|
3227
|
+
`${label} uses unsupported operator "${key}".`,
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
if (key !== unwindPathArg && !key.startsWith(unwindPathArg + '.')) {
|
|
3231
|
+
throw new SmartdataPersistenceError(
|
|
3232
|
+
'invalid_argument',
|
|
3233
|
+
`${label} path "${key}" is not an element path of the unwound array "${unwindPathArg}".`,
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3236
|
+
requireDeclaredPath(key, declaredRootsArg, label, declaredNestedPathsArg);
|
|
3237
|
+
normalized[key] = normalizeAtomicFilterCondition(
|
|
3238
|
+
value,
|
|
3239
|
+
`${label} path "${key}"`,
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
return normalized;
|
|
3243
|
+
};
|
|
3244
|
+
return normalizeFilterObject(
|
|
3245
|
+
filterArg as Record<string, unknown>,
|
|
3246
|
+
) as plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
3247
|
+
};
|
|
3248
|
+
|
|
3249
|
+
/**
|
|
3250
|
+
* The group value types grouped-totals paging orders, ascending. Missing values
|
|
3251
|
+
* merge into null; numbers compare by value across int, long and double;
|
|
3252
|
+
* strings compare by their UTF-8 bytes; ObjectIds by their bytes; false sorts
|
|
3253
|
+
* before true. NoSQLDB and MongoDB order these types identically, and their
|
|
3254
|
+
* range operators compare within each type the same way.
|
|
3255
|
+
*/
|
|
3256
|
+
const groupedTotalsOrderedTypes = ['null', 'number', 'string', 'objectId', 'bool', 'date'] as const;
|
|
3257
|
+
|
|
3258
|
+
type TGroupedTotalsOrderedType = typeof groupedTotalsOrderedTypes[number];
|
|
3259
|
+
|
|
3260
|
+
/** A group value paging can order, or undefined for any other value. */
|
|
3261
|
+
const groupedTotalsOrderedTypeOf = (
|
|
3262
|
+
valueArg: unknown,
|
|
3263
|
+
): TGroupedTotalsOrderedType | undefined => {
|
|
3264
|
+
if (valueArg === null) {
|
|
3265
|
+
return 'null';
|
|
3266
|
+
}
|
|
3267
|
+
if (typeof valueArg === 'number') {
|
|
3268
|
+
// NaN sorts below every number on both engines, but a range operator
|
|
3269
|
+
// matches it on NoSQLDB and not on MongoDB.
|
|
3270
|
+
return Number.isNaN(valueArg) ? undefined : 'number';
|
|
3271
|
+
}
|
|
3272
|
+
if (typeof valueArg === 'string') {
|
|
3273
|
+
return 'string';
|
|
3274
|
+
}
|
|
3275
|
+
if (typeof valueArg === 'boolean') {
|
|
3276
|
+
return 'bool';
|
|
3277
|
+
}
|
|
3278
|
+
if (valueArg instanceof plugins.mongodb.Long) {
|
|
3279
|
+
return 'number';
|
|
3280
|
+
}
|
|
3281
|
+
if (valueArg instanceof plugins.mongodb.ObjectId) {
|
|
3282
|
+
return 'objectId';
|
|
3283
|
+
}
|
|
3284
|
+
if (valueArg instanceof Date) {
|
|
3285
|
+
return Number.isNaN(valueArg.getTime()) ? undefined : 'date';
|
|
3286
|
+
}
|
|
3287
|
+
return undefined;
|
|
3288
|
+
};
|
|
3289
|
+
|
|
3290
|
+
const groupedTotalsExactNumber = (
|
|
3291
|
+
valueArg: number | plugins.mongodb.Long,
|
|
3292
|
+
): number | bigint => typeof valueArg === 'number' ? valueArg : BigInt(valueArg.toString());
|
|
3293
|
+
|
|
3294
|
+
/** Compares a finite or infinite double with an exact integer. */
|
|
3295
|
+
const compareGroupedTotalsDoubleWithInteger = (doubleArg: number, integerArg: bigint): number => {
|
|
3296
|
+
if (!Number.isFinite(doubleArg)) {
|
|
3297
|
+
return doubleArg > 0 ? 1 : -1;
|
|
3298
|
+
}
|
|
3299
|
+
const floor = Math.floor(doubleArg);
|
|
3300
|
+
const floorInteger = BigInt(floor);
|
|
3301
|
+
if (floorInteger !== integerArg) {
|
|
3302
|
+
return floorInteger < integerArg ? -1 : 1;
|
|
3303
|
+
}
|
|
3304
|
+
return doubleArg === floor ? 0 : 1;
|
|
3305
|
+
};
|
|
3306
|
+
|
|
3307
|
+
const compareGroupedTotalsNumbers = (
|
|
3308
|
+
leftArg: number | plugins.mongodb.Long,
|
|
3309
|
+
rightArg: number | plugins.mongodb.Long,
|
|
3310
|
+
): number => {
|
|
3311
|
+
const left = groupedTotalsExactNumber(leftArg);
|
|
3312
|
+
const right = groupedTotalsExactNumber(rightArg);
|
|
3313
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
3314
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3315
|
+
}
|
|
3316
|
+
if (typeof left === 'bigint' && typeof right === 'bigint') {
|
|
3317
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3318
|
+
}
|
|
3319
|
+
return typeof left === 'number'
|
|
3320
|
+
? compareGroupedTotalsDoubleWithInteger(left, right as bigint)
|
|
3321
|
+
: -compareGroupedTotalsDoubleWithInteger(right as number, left);
|
|
3322
|
+
};
|
|
3323
|
+
|
|
3324
|
+
/** Compares two ordered group values in the documented order. */
|
|
3325
|
+
const compareGroupedTotalsValues = (leftArg: unknown, rightArg: unknown): number => {
|
|
3326
|
+
const leftType = groupedTotalsOrderedTypeOf(leftArg)!;
|
|
3327
|
+
const rightType = groupedTotalsOrderedTypeOf(rightArg)!;
|
|
3328
|
+
if (leftType !== rightType) {
|
|
3329
|
+
return groupedTotalsOrderedTypes.indexOf(leftType) - groupedTotalsOrderedTypes.indexOf(rightType);
|
|
3330
|
+
}
|
|
3331
|
+
switch (leftType) {
|
|
3332
|
+
case 'null':
|
|
3333
|
+
return 0;
|
|
3334
|
+
case 'number':
|
|
3335
|
+
return compareGroupedTotalsNumbers(
|
|
3336
|
+
leftArg as number | plugins.mongodb.Long,
|
|
3337
|
+
rightArg as number | plugins.mongodb.Long,
|
|
3338
|
+
);
|
|
3339
|
+
case 'string':
|
|
3340
|
+
return Buffer.compare(Buffer.from(leftArg as string, 'utf8'), Buffer.from(rightArg as string, 'utf8'));
|
|
3341
|
+
case 'objectId':
|
|
3342
|
+
return Buffer.compare(
|
|
3343
|
+
Buffer.from((leftArg as plugins.mongodb.ObjectId).toHexString(), 'hex'),
|
|
3344
|
+
Buffer.from((rightArg as plugins.mongodb.ObjectId).toHexString(), 'hex'),
|
|
3345
|
+
);
|
|
3346
|
+
case 'bool':
|
|
3347
|
+
return Number(leftArg) - Number(rightArg);
|
|
3348
|
+
case 'date':
|
|
3349
|
+
return Math.sign((leftArg as Date).getTime() - (rightArg as Date).getTime());
|
|
3350
|
+
}
|
|
3351
|
+
};
|
|
3352
|
+
|
|
3353
|
+
/** Compares two group keys member by member. */
|
|
3354
|
+
const compareGroupedTotalsKeys = (
|
|
3355
|
+
leftArg: readonly unknown[],
|
|
3356
|
+
rightArg: readonly unknown[],
|
|
3357
|
+
): number => {
|
|
3358
|
+
for (const [index, leftValue] of leftArg.entries()) {
|
|
3359
|
+
const order = compareGroupedTotalsValues(leftValue, rightArg[index]);
|
|
3360
|
+
if (order !== 0) {
|
|
3361
|
+
return order;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
return 0;
|
|
3365
|
+
};
|
|
3366
|
+
|
|
3367
|
+
const groupedTotalsTypeClause = (
|
|
3368
|
+
pathArg: string,
|
|
3369
|
+
typeArg: TGroupedTotalsOrderedType,
|
|
3370
|
+
): plugins.mongodb.Document =>
|
|
3371
|
+
typeArg === 'null' ? { [pathArg]: null } : { [pathArg]: { $type: typeArg } };
|
|
3372
|
+
|
|
3373
|
+
/**
|
|
3374
|
+
* Clauses matching a group key member that sorts before `valueArg`, or at it
|
|
3375
|
+
* when `inclusiveArg` is set. A range operator only compares within one type,
|
|
3376
|
+
* so every lower type is matched by its `$type`.
|
|
3377
|
+
*/
|
|
3378
|
+
const groupedTotalsBelowClauses = (
|
|
3379
|
+
pathArg: string,
|
|
3380
|
+
valueArg: unknown,
|
|
3381
|
+
inclusiveArg: boolean,
|
|
3382
|
+
): plugins.mongodb.Document[] => {
|
|
3383
|
+
const type = groupedTotalsOrderedTypeOf(valueArg)!;
|
|
3384
|
+
const clauses = groupedTotalsOrderedTypes
|
|
3385
|
+
.slice(0, groupedTotalsOrderedTypes.indexOf(type))
|
|
3386
|
+
.map((typeArg) => groupedTotalsTypeClause(pathArg, typeArg));
|
|
3387
|
+
if (type === 'null') {
|
|
3388
|
+
if (inclusiveArg) {
|
|
3389
|
+
clauses.push({ [pathArg]: null });
|
|
3390
|
+
}
|
|
3391
|
+
} else {
|
|
3392
|
+
clauses.push({ [pathArg]: { [inclusiveArg ? '$lte' : '$lt']: valueArg } });
|
|
3393
|
+
}
|
|
3394
|
+
return clauses;
|
|
3395
|
+
};
|
|
3396
|
+
|
|
3397
|
+
/**
|
|
3398
|
+
* Clauses matching a group key at or before `afterArg` in lexicographic order
|
|
3399
|
+
* over the members: a lower first member, or an equal first member and the
|
|
3400
|
+
* rest at or before the rest of `afterArg`.
|
|
3401
|
+
*/
|
|
3402
|
+
const groupedTotalsAtOrBeforeClauses = (
|
|
3403
|
+
memberPathsArg: readonly string[],
|
|
3404
|
+
afterArg: readonly unknown[],
|
|
3405
|
+
): plugins.mongodb.Document[] => {
|
|
3406
|
+
const [path, ...restPaths] = memberPathsArg;
|
|
3407
|
+
const [value, ...restValues] = afterArg;
|
|
3408
|
+
if (restPaths.length === 0) {
|
|
3409
|
+
return groupedTotalsBelowClauses(path, value, true);
|
|
3410
|
+
}
|
|
3411
|
+
return [
|
|
3412
|
+
...groupedTotalsBelowClauses(path, value, false),
|
|
3413
|
+
{
|
|
3414
|
+
$and: [
|
|
3415
|
+
value === null ? { [path]: null } : { [path]: { $eq: value } },
|
|
3416
|
+
{ $or: groupedTotalsAtOrBeforeClauses(restPaths, restValues) },
|
|
3417
|
+
],
|
|
3418
|
+
},
|
|
3419
|
+
];
|
|
3420
|
+
};
|
|
3421
|
+
|
|
3422
|
+
/**
|
|
3423
|
+
* Clauses matching a group key member paging cannot order: an array, a
|
|
3424
|
+
* document, a Decimal128, NaN or any type outside the ordered types. Such a
|
|
3425
|
+
* group always stays a candidate for the next page, so the page that reaches
|
|
3426
|
+
* it refuses it instead of skipping it silently.
|
|
3427
|
+
*/
|
|
3428
|
+
const groupedTotalsUnorderedClauses = (pathArg: string): plugins.mongodb.Document[] => [
|
|
3429
|
+
{ [pathArg]: { $type: 'array' } },
|
|
3430
|
+
{ [pathArg]: { $type: 'object' } },
|
|
3431
|
+
{ [pathArg]: { $type: 'decimal' } },
|
|
3432
|
+
{ [pathArg]: Number.NaN },
|
|
3433
|
+
{ $nor: groupedTotalsOrderedTypes.map((typeArg) => groupedTotalsTypeClause(pathArg, typeArg)) },
|
|
3434
|
+
];
|
|
3435
|
+
|
|
3436
|
+
/** The post-group `$match` that keeps the groups whose key sorts after `afterArg`. */
|
|
3437
|
+
const groupedTotalsAfterFilter = (
|
|
3438
|
+
memberPathsArg: readonly string[],
|
|
3439
|
+
afterArg: readonly unknown[],
|
|
3440
|
+
): plugins.mongodb.Document => ({
|
|
3441
|
+
$or: [
|
|
3442
|
+
...memberPathsArg.flatMap((pathArg) => groupedTotalsUnorderedClauses(pathArg)),
|
|
3443
|
+
{ $nor: groupedTotalsAtOrBeforeClauses(memberPathsArg, afterArg) },
|
|
3444
|
+
],
|
|
3445
|
+
});
|
|
3446
|
+
|
|
3447
|
+
/** Bounds a decoded grouped-totals cursor; a group key is at most two values. */
|
|
3448
|
+
const groupedTotalsCursorMaximumLength = 65_536;
|
|
3449
|
+
|
|
3450
|
+
const encodeGroupedTotalsCursor = (
|
|
3451
|
+
shapeArg: string,
|
|
3452
|
+
afterArg: readonly unknown[],
|
|
3453
|
+
): string => Buffer.from(
|
|
3454
|
+
plugins.mongodb.BSON.EJSON.stringify({ shape: shapeArg, after: afterArg }, { relaxed: false }),
|
|
3455
|
+
'utf8',
|
|
3456
|
+
).toString('base64url');
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* Decodes a grouped-totals cursor for the query shape `shapeArg`. The cursor
|
|
3460
|
+
* grants nothing a caller could not ask for directly — every page still applies
|
|
3461
|
+
* the caller's filters — so it is checked, not signed: it must decode, carry
|
|
3462
|
+
* this query's shape and one ordered value per group key member.
|
|
3463
|
+
*/
|
|
3464
|
+
const decodeGroupedTotalsCursor = (
|
|
3465
|
+
cursorArg: unknown,
|
|
3466
|
+
shapeArg: string,
|
|
3467
|
+
memberCountArg: number,
|
|
3468
|
+
): unknown[] => {
|
|
3469
|
+
const invalid = (reasonArg: string): SmartdataPersistenceError =>
|
|
3470
|
+
new SmartdataPersistenceError('invalid_argument', `Grouped totals cursor ${reasonArg}.`);
|
|
3471
|
+
if (
|
|
3472
|
+
typeof cursorArg !== 'string'
|
|
3473
|
+
|| cursorArg.length === 0
|
|
3474
|
+
|| cursorArg.length > groupedTotalsCursorMaximumLength
|
|
3475
|
+
|| !/^[A-Za-z0-9_-]+$/.test(cursorArg)
|
|
3476
|
+
) {
|
|
3477
|
+
throw invalid('must be a nextCursor returned by getGroupedTotalsPage()');
|
|
3478
|
+
}
|
|
3479
|
+
let decoded: unknown;
|
|
3480
|
+
try {
|
|
3481
|
+
decoded = plugins.mongodb.BSON.EJSON.parse(
|
|
3482
|
+
Buffer.from(cursorArg, 'base64url').toString('utf8'),
|
|
3483
|
+
{ relaxed: false },
|
|
3484
|
+
);
|
|
3485
|
+
} catch {
|
|
3486
|
+
throw invalid('does not decode');
|
|
3487
|
+
}
|
|
3488
|
+
if (
|
|
3489
|
+
!isPlainObject(decoded)
|
|
3490
|
+
|| Object.keys(decoded).length !== 2
|
|
3491
|
+
|| typeof decoded.shape !== 'string'
|
|
3492
|
+
|| !Array.isArray(decoded.after)
|
|
3493
|
+
) {
|
|
3494
|
+
throw invalid('does not decode');
|
|
3495
|
+
}
|
|
3496
|
+
if (decoded.shape !== shapeArg) {
|
|
3497
|
+
throw invalid(
|
|
3498
|
+
'belongs to another query: groupBy, unwind, sums, filter and elementFilter must match the page that returned it',
|
|
3499
|
+
);
|
|
3500
|
+
}
|
|
3501
|
+
if (decoded.after.length !== memberCountArg) {
|
|
3502
|
+
throw invalid('does not hold one value per groupBy path');
|
|
3503
|
+
}
|
|
3504
|
+
return decoded.after.map((valueArg: unknown) => {
|
|
3505
|
+
const value = valueArg instanceof plugins.mongodb.Int32 || valueArg instanceof plugins.mongodb.Double
|
|
3506
|
+
? valueArg.valueOf()
|
|
3507
|
+
: valueArg;
|
|
3508
|
+
if (groupedTotalsOrderedTypeOf(value) === undefined) {
|
|
3509
|
+
throw invalid('holds a group value paging does not order');
|
|
3510
|
+
}
|
|
3511
|
+
return value;
|
|
3512
|
+
});
|
|
3513
|
+
};
|
|
3514
|
+
|
|
3131
3515
|
const pathsConflict = (leftArg: string, rightArg: string): boolean =>
|
|
3132
3516
|
leftArg === rightArg ||
|
|
3133
3517
|
leftArg.startsWith(`${rightArg}.`) ||
|
|
@@ -5098,12 +5482,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5098
5482
|
* Groups by one or two declared paths — top-level fields or nested paths
|
|
5099
5483
|
* declared through `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })` —
|
|
5100
5484
|
* optionally per element of one declared array (`unwind`), whose element
|
|
5101
|
-
* fields `groupBy` and `sums` may then name
|
|
5102
|
-
* group, and sums only declared numeric paths.
|
|
5103
|
-
*
|
|
5104
|
-
*
|
|
5105
|
-
*
|
|
5106
|
-
*
|
|
5485
|
+
* fields `groupBy` and `sums` may then name and `elementFilter` may select,
|
|
5486
|
+
* always returns a `count` per group, and sums only declared numeric paths.
|
|
5487
|
+
* Rows come ordered by group key, and the first `limit` groups in that
|
|
5488
|
+
* order are returned. Use `getGroupedTotalsPage()` when the caller must
|
|
5489
|
+
* detect or read further groups. The aggregation cursor never leaves
|
|
5490
|
+
* SmartData.
|
|
5107
5491
|
*/
|
|
5108
5492
|
public static async getGroupedTotals<
|
|
5109
5493
|
T,
|
|
@@ -5112,15 +5496,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5112
5496
|
this: plugins.tsclass.typeFest.Class<T>,
|
|
5113
5497
|
optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
|
|
5114
5498
|
): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
|
|
5115
|
-
const page = await (this as any).
|
|
5499
|
+
const page = await (this as any).readGroupedTotalsPage(optionsArg, false) as
|
|
5116
5500
|
ISmartdataGroupedTotalsPage<T, TUnwind>;
|
|
5117
5501
|
return page.rows;
|
|
5118
5502
|
}
|
|
5119
5503
|
|
|
5120
5504
|
/**
|
|
5121
|
-
* Computes the same
|
|
5122
|
-
*
|
|
5123
|
-
*
|
|
5505
|
+
* Computes the same grouped totals as `getGroupedTotals()`, reports whether
|
|
5506
|
+
* at least one further group exists beyond the requested row limit and, when
|
|
5507
|
+
* one does, returns `nextCursor` to read the next page through `cursor`.
|
|
5508
|
+
* Pages follow the group key order, so reading them to the end reports
|
|
5509
|
+
* every group exactly once while the data does not change. Under concurrent
|
|
5510
|
+
* writes every page reads the data as it is at that page's read: the cursor
|
|
5511
|
+
* only moves forward, so no group is reported twice, but a group created
|
|
5512
|
+
* with a key before the cursor is not reported, and totals on different
|
|
5513
|
+
* pages can reflect different moments. A caller that needs one consistent
|
|
5514
|
+
* view reads every page inside one transaction session, as far as the
|
|
5515
|
+
* engine's transaction isolation provides one. Paging orders group values that are null or missing,
|
|
5516
|
+
* numbers, strings, ObjectIds, booleans and dates; a page that returns a
|
|
5517
|
+
* cursor or continues one refuses any other group value — an array, a
|
|
5518
|
+
* document, a Decimal128, NaN — with `unsupported_operation` instead of
|
|
5519
|
+
* skipping it.
|
|
5124
5520
|
*/
|
|
5125
5521
|
public static async getGroupedTotalsPage<
|
|
5126
5522
|
T,
|
|
@@ -5128,6 +5524,53 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5128
5524
|
>(
|
|
5129
5525
|
this: plugins.tsclass.typeFest.Class<T>,
|
|
5130
5526
|
optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
|
|
5527
|
+
): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
|
|
5528
|
+
return await (this as any).readGroupedTotalsPage(optionsArg, true) as
|
|
5529
|
+
ISmartdataGroupedTotalsPage<T, TUnwind>;
|
|
5530
|
+
}
|
|
5531
|
+
|
|
5532
|
+
/**
|
|
5533
|
+
* Reads every group of a grouped-totals query in group key order, one page
|
|
5534
|
+
* of `limit` groups per database round trip through
|
|
5535
|
+
* `getGroupedTotalsPage()`, with the same exactness, ordering and
|
|
5536
|
+
* concurrency rules. `cursor` resumes after an earlier page; `signal`,
|
|
5537
|
+
* `timeoutMS` and `maxTimeMS` apply to each page.
|
|
5538
|
+
*/
|
|
5539
|
+
public static async *iterateGroupedTotals<
|
|
5540
|
+
T,
|
|
5541
|
+
TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
|
|
5542
|
+
>(
|
|
5543
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
5544
|
+
optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
|
|
5545
|
+
): AsyncGenerator<ISmartdataGroupedTotalsRow<T, TUnwind>, void, undefined> {
|
|
5546
|
+
// The first page validates the caller's options, so every later page
|
|
5547
|
+
// copies an inert plain object.
|
|
5548
|
+
let page = await (this as any).readGroupedTotalsPage(optionsArg, true) as
|
|
5549
|
+
ISmartdataGroupedTotalsPage<T, TUnwind>;
|
|
5550
|
+
while (true) {
|
|
5551
|
+
yield* page.rows;
|
|
5552
|
+
if (page.nextCursor === undefined) {
|
|
5553
|
+
return;
|
|
5554
|
+
}
|
|
5555
|
+
page = await (this as any).readGroupedTotalsPage(
|
|
5556
|
+
{ ...optionsArg, cursor: page.nextCursor },
|
|
5557
|
+
true,
|
|
5558
|
+
) as ISmartdataGroupedTotalsPage<T, TUnwind>;
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
|
|
5562
|
+
/**
|
|
5563
|
+
* Validates grouped-totals options and reads one page. `mintCursorArg` is
|
|
5564
|
+
* false for `getGroupedTotals()`, which returns rows only and so never
|
|
5565
|
+
* needs the next page's cursor.
|
|
5566
|
+
*/
|
|
5567
|
+
private static async readGroupedTotalsPage<
|
|
5568
|
+
T,
|
|
5569
|
+
TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
|
|
5570
|
+
>(
|
|
5571
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
5572
|
+
optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
|
|
5573
|
+
mintCursorArg: boolean,
|
|
5131
5574
|
): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
|
|
5132
5575
|
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
5133
5576
|
throw new SmartdataPersistenceError(
|
|
@@ -5149,9 +5592,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5149
5592
|
const allowedKeys = new Set([
|
|
5150
5593
|
'groupBy',
|
|
5151
5594
|
'unwind',
|
|
5595
|
+
'elementFilter',
|
|
5152
5596
|
'sums',
|
|
5153
5597
|
'filter',
|
|
5154
5598
|
'limit',
|
|
5599
|
+
'cursor',
|
|
5155
5600
|
'maxTimeMS',
|
|
5156
5601
|
'timeoutMS',
|
|
5157
5602
|
'signal',
|
|
@@ -5178,9 +5623,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5178
5623
|
}
|
|
5179
5624
|
const groupByOption = optionsArg.groupBy;
|
|
5180
5625
|
const unwindOption = optionsArg.unwind;
|
|
5626
|
+
const elementFilterOption = optionsArg.elementFilter;
|
|
5181
5627
|
const sumsOption = optionsArg.sums;
|
|
5182
5628
|
const filterOption = optionsArg.filter;
|
|
5183
5629
|
const limitOption = optionsArg.limit;
|
|
5630
|
+
const cursorOption = optionsArg.cursor;
|
|
5184
5631
|
const maxTimeMSOption = optionsArg.maxTimeMS;
|
|
5185
5632
|
const timeoutMSOption = optionsArg.timeoutMS;
|
|
5186
5633
|
const signalOption = optionsArg.signal;
|
|
@@ -5304,6 +5751,21 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5304
5751
|
}
|
|
5305
5752
|
unwindField = unwindOption;
|
|
5306
5753
|
}
|
|
5754
|
+
let elementMatch: plugins.mongodb.Filter<plugins.mongodb.Document> | undefined;
|
|
5755
|
+
if (elementFilterOption !== undefined) {
|
|
5756
|
+
if (unwindField === undefined) {
|
|
5757
|
+
throw new SmartdataPersistenceError(
|
|
5758
|
+
'invalid_argument',
|
|
5759
|
+
'Grouped totals elementFilter requires unwind: it selects elements of the unwound array.',
|
|
5760
|
+
);
|
|
5761
|
+
}
|
|
5762
|
+
elementMatch = normalizeGroupedTotalsElementFilter(
|
|
5763
|
+
elementFilterOption,
|
|
5764
|
+
unwindField,
|
|
5765
|
+
declaredRoots,
|
|
5766
|
+
declaredNestedPaths,
|
|
5767
|
+
);
|
|
5768
|
+
}
|
|
5307
5769
|
const limit = requireBoundedInteger(
|
|
5308
5770
|
limitOption,
|
|
5309
5771
|
'Grouped totals limit',
|
|
@@ -5338,13 +5800,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5338
5800
|
// A composite key names its members by position: a group path may be
|
|
5339
5801
|
// dotted, and an expression object's field names may not contain a dot.
|
|
5340
5802
|
const compositeKeyName = (indexArg: number): string => 'k' + indexArg;
|
|
5803
|
+
// MongoDB omits a missing member from a composite key, which would split
|
|
5804
|
+
// missing and null into two groups reported alike; NoSQLDB evaluates a
|
|
5805
|
+
// missing member as null. `$ifNull` merges them on MongoDB as well.
|
|
5806
|
+
const compositeMember = (fieldArg: string): unknown =>
|
|
5807
|
+
collection.smartdataDb.capabilities?.backend === 'mongodb'
|
|
5808
|
+
? { $ifNull: ['$' + fieldArg, null] }
|
|
5809
|
+
: '$' + fieldArg;
|
|
5341
5810
|
const groupStage: Record<string, unknown> = {
|
|
5342
5811
|
_id: groupByFields.length === 1
|
|
5343
5812
|
? '$' + groupByFields[0]
|
|
5344
5813
|
: Object.fromEntries(
|
|
5345
5814
|
groupByFields.map((fieldArg, indexArg) => [
|
|
5346
5815
|
compositeKeyName(indexArg),
|
|
5347
|
-
|
|
5816
|
+
compositeMember(fieldArg),
|
|
5348
5817
|
]),
|
|
5349
5818
|
),
|
|
5350
5819
|
__groupedTotalsCount: { $sum: 1 },
|
|
@@ -5352,10 +5821,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5352
5821
|
for (const [index, sumField] of sumFields.entries()) {
|
|
5353
5822
|
groupStage['__groupedTotalsSum' + index] = { $sum: '$' + sumField };
|
|
5354
5823
|
}
|
|
5824
|
+
const normalizedFilter = filterOption !== undefined
|
|
5825
|
+
? (this as any).normalizeReadFilter(filterOption) as plugins.mongodb.Filter<plugins.mongodb.Document>
|
|
5826
|
+
: undefined;
|
|
5827
|
+
// The group key's members as the post-group stages address them.
|
|
5828
|
+
const keyMemberPaths = groupByFields.length === 1
|
|
5829
|
+
? ['_id']
|
|
5830
|
+
: groupByFields.map((_fieldArg, indexArg) => '_id.' + compositeKeyName(indexArg));
|
|
5831
|
+
const readKeyMembers = (rowArg: plugins.mongodb.Document): unknown[] => {
|
|
5832
|
+
if (groupByFields.length === 1) {
|
|
5833
|
+
return [rowArg._id ?? null];
|
|
5834
|
+
}
|
|
5835
|
+
const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
|
|
5836
|
+
return groupByFields.map((_fieldArg, indexArg) => compoundKey[compositeKeyName(indexArg)] ?? null);
|
|
5837
|
+
};
|
|
5838
|
+
// Binds a cursor to the query it continues. The limit may change between
|
|
5839
|
+
// pages; everything that decides which groups exist may not.
|
|
5840
|
+
const queryShape = (): string => plugins.nodeCrypto
|
|
5841
|
+
.createHash('sha256')
|
|
5842
|
+
.update(plugins.mongodb.BSON.EJSON.stringify({
|
|
5843
|
+
collection: collection.collectionName,
|
|
5844
|
+
groupBy: groupByFields,
|
|
5845
|
+
unwind: unwindField ?? null,
|
|
5846
|
+
sums: sumFields,
|
|
5847
|
+
filter: normalizedFilter ?? null,
|
|
5848
|
+
elementFilter: elementMatch ?? null,
|
|
5849
|
+
}, { relaxed: false }))
|
|
5850
|
+
.digest('base64url');
|
|
5851
|
+
const after = cursorOption !== undefined
|
|
5852
|
+
? decodeGroupedTotalsCursor(cursorOption, queryShape(), keyMemberPaths.length)
|
|
5853
|
+
: undefined;
|
|
5355
5854
|
const rows = await collection.aggregateGroupedTotals(
|
|
5356
|
-
|
|
5357
|
-
? (this as any).normalizeReadFilter(filterOption)
|
|
5358
|
-
: undefined,
|
|
5855
|
+
normalizedFilter,
|
|
5359
5856
|
groupStage,
|
|
5360
5857
|
limit + 1,
|
|
5361
5858
|
{
|
|
@@ -5364,10 +5861,43 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5364
5861
|
...(timeoutMS !== undefined ? { timeoutMS } : {}),
|
|
5365
5862
|
...(signalOption !== undefined ? { signal: signalOption } : {}),
|
|
5366
5863
|
...(unwindField !== undefined ? { unwindPath: unwindField } : {}),
|
|
5864
|
+
...(elementMatch !== undefined ? { elementMatch } : {}),
|
|
5865
|
+
...(after !== undefined
|
|
5866
|
+
? { groupMatch: groupedTotalsAfterFilter(keyMemberPaths, after) }
|
|
5867
|
+
: {}),
|
|
5868
|
+
sort: Object.fromEntries(keyMemberPaths.map((pathArg) => [pathArg, 1])),
|
|
5367
5869
|
},
|
|
5368
5870
|
);
|
|
5369
5871
|
const hasMore = rows.length > limit;
|
|
5370
5872
|
const returnedRows = rows.slice(0, limit);
|
|
5873
|
+
const mintsCursor = mintCursorArg && hasMore;
|
|
5874
|
+
let lastKey: unknown[] | undefined;
|
|
5875
|
+
if (mintsCursor || after !== undefined) {
|
|
5876
|
+
// A page that continues or is continued claims exact paging, so every
|
|
5877
|
+
// key on it must be an ordered value, and the engine must have returned
|
|
5878
|
+
// the keys strictly ascending after the cursor.
|
|
5879
|
+
let previousKey = after;
|
|
5880
|
+
for (const row of returnedRows) {
|
|
5881
|
+
const key = readKeyMembers(row);
|
|
5882
|
+
for (const [index, value] of key.entries()) {
|
|
5883
|
+
if (groupedTotalsOrderedTypeOf(value) === undefined) {
|
|
5884
|
+
throw new SmartdataPersistenceError(
|
|
5885
|
+
'unsupported_operation',
|
|
5886
|
+
`Grouped totals cannot page over group values of "${groupByFields[index]}" that are not `
|
|
5887
|
+
+ 'null, numbers, strings, ObjectIds, booleans or dates.',
|
|
5888
|
+
);
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
if (previousKey !== undefined && compareGroupedTotalsKeys(previousKey, key) >= 0) {
|
|
5892
|
+
throw new SmartdataPersistenceError(
|
|
5893
|
+
'unsupported_operation',
|
|
5894
|
+
'Grouped totals paging received groups out of group key order from this backend.',
|
|
5895
|
+
);
|
|
5896
|
+
}
|
|
5897
|
+
previousKey = key;
|
|
5898
|
+
}
|
|
5899
|
+
lastKey = previousKey;
|
|
5900
|
+
}
|
|
5371
5901
|
const requireFiniteNumber = (
|
|
5372
5902
|
valueArg: unknown,
|
|
5373
5903
|
labelArg: string,
|
|
@@ -5411,6 +5941,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5411
5941
|
return {
|
|
5412
5942
|
rows: mappedRows,
|
|
5413
5943
|
hasMore,
|
|
5944
|
+
...(mintsCursor && lastKey !== undefined
|
|
5945
|
+
? { nextCursor: encodeGroupedTotalsCursor(queryShape(), lastKey) }
|
|
5946
|
+
: {}),
|
|
5414
5947
|
};
|
|
5415
5948
|
}
|
|
5416
5949
|
|