@lossless.org/client 1.15.0 → 1.17.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/.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.doc.d.ts +90 -20
- package/dist_ts/nosqldb/classes.doc.js +412 -39
- package/dist_ts/objectstorage/classes.bucket.d.ts +16 -0
- package/dist_ts/objectstorage/classes.bucket.js +77 -3
- package/dist_ts/objectstorage/classes.objectrangeerror.d.ts +5 -0
- package/dist_ts/objectstorage/classes.objectrangeerror.js +9 -0
- package/dist_ts/objectstorage/index.d.ts +1 -0
- package/dist_ts/objectstorage/index.js +2 -1
- package/dist_ts/objectstorage/interfaces.d.ts +33 -0
- package/dist_ts/objectstorage/internal.bucketrequests.d.ts +4 -0
- package/dist_ts/objectstorage/internal.bucketrequests.js +2 -2
- 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 +3 -1
- package/readme.md +8 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.collection.ts +15 -1
- package/ts/nosqldb/classes.doc.ts +633 -69
- package/ts/objectstorage/classes.bucket.ts +122 -2
- package/ts/objectstorage/classes.objectrangeerror.ts +11 -0
- package/ts/objectstorage/index.ts +1 -0
- package/ts/objectstorage/interfaces.ts +37 -0
- package/ts/objectstorage/internal.bucketrequests.ts +1 -1
|
@@ -817,15 +817,33 @@ export interface ISmartdataGroupedTotalsOptions<
|
|
|
817
817
|
*/
|
|
818
818
|
groupBy: ReadonlyArray<TSmartdataGroupedTotalsPath<T>>;
|
|
819
819
|
/**
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* element
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
820
|
+
* Aggregates per element of one array instead of per document: names a
|
|
821
|
+
* declared top-level array field or a nested path declared through
|
|
822
|
+
* `@svDb({ arrayPaths })`, which is itself a `groupBy` or `sums` path or
|
|
823
|
+
* holds the element fields that `groupBy` and `sums` paths name below it
|
|
824
|
+
* (`lines.accountNumber` below `unwind: 'lines'`). Element fields are
|
|
825
|
+
* declared nested paths like any other, through `@svDb({ atomicPaths })`
|
|
826
|
+
* on the array's field. Every element contributes on its own, so `count`
|
|
827
|
+
* counts elements and a document contributes its `sums` once per element;
|
|
828
|
+
* a document whose array is missing, null or empty contributes nothing.
|
|
829
|
+
* The `filter` still selects whole documents before the array is unwound,
|
|
830
|
+
* and no `groupBy` or `sums` path may contain the unwound array; use
|
|
831
|
+
* `elementFilter` to select elements.
|
|
827
832
|
*/
|
|
828
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>>;
|
|
829
847
|
/**
|
|
830
848
|
* Declared numeric top-level fields, or declared nested paths holding
|
|
831
849
|
* numbers, to sum per group.
|
|
@@ -835,12 +853,21 @@ export interface ISmartdataGroupedTotalsOptions<
|
|
|
835
853
|
>;
|
|
836
854
|
filter?: MongoFilter<T>;
|
|
837
855
|
/**
|
|
838
|
-
* Required bound on the number of returned groups (1..10000).
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
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.
|
|
842
861
|
*/
|
|
843
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;
|
|
844
871
|
maxTimeMS?: number;
|
|
845
872
|
/**
|
|
846
873
|
* Client-side deadline for the aggregation cursor up to its last reply. More
|
|
@@ -885,8 +912,32 @@ export interface ISmartdataGroupedTotalsPage<
|
|
|
885
912
|
> {
|
|
886
913
|
rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
|
|
887
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;
|
|
888
921
|
}
|
|
889
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
|
+
|
|
890
941
|
export type TSmartdataAtomicArrayPath<T> = {
|
|
891
942
|
[TKey in TSmartdataDocumentPath<T>]:
|
|
892
943
|
NonNullable<T[TKey]> extends ReadonlyArray<unknown> ? TKey : never;
|
|
@@ -2809,6 +2860,50 @@ const normalizeAtomicNumericExpression = (
|
|
|
2809
2860
|
: guardedComparison;
|
|
2810
2861
|
};
|
|
2811
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
|
+
|
|
2812
2907
|
const normalizeStrictFilter = (
|
|
2813
2908
|
filterArg: Record<string, unknown>,
|
|
2814
2909
|
declaredRootsArg: Set<string>,
|
|
@@ -2845,48 +2940,6 @@ const normalizeStrictFilter = (
|
|
|
2845
2940
|
`${operationLabelArg} filter`,
|
|
2846
2941
|
false,
|
|
2847
2942
|
);
|
|
2848
|
-
const requireInertLogicalFilterArray: (
|
|
2849
|
-
valueArg: unknown,
|
|
2850
|
-
labelArg: string,
|
|
2851
|
-
) => asserts valueArg is Array<Record<string, unknown>> = (
|
|
2852
|
-
valueArg,
|
|
2853
|
-
labelArg,
|
|
2854
|
-
) => {
|
|
2855
|
-
if (
|
|
2856
|
-
!Array.isArray(valueArg)
|
|
2857
|
-
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
2858
|
-
|| Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
2859
|
-
|| valueArg.length === 0
|
|
2860
|
-
) {
|
|
2861
|
-
throw new SmartdataPersistenceError(
|
|
2862
|
-
'invalid_argument',
|
|
2863
|
-
`${labelArg} must be a non-empty ordinary array of filters.`,
|
|
2864
|
-
);
|
|
2865
|
-
}
|
|
2866
|
-
assertOnlyDataProperties(valueArg, labelArg, true);
|
|
2867
|
-
for (let index = 0; index < valueArg.length; index++) {
|
|
2868
|
-
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
2869
|
-
throw new SmartdataPersistenceError(
|
|
2870
|
-
'invalid_argument',
|
|
2871
|
-
`${labelArg} may not contain sparse array positions.`,
|
|
2872
|
-
);
|
|
2873
|
-
}
|
|
2874
|
-
const entry = valueArg[index];
|
|
2875
|
-
if (
|
|
2876
|
-
typeof entry !== 'object'
|
|
2877
|
-
|| entry === null
|
|
2878
|
-
|| Array.isArray(entry)
|
|
2879
|
-
|| plugins.nodeUtil.types.isProxy(entry)
|
|
2880
|
-
|| !isPlainObject(entry)
|
|
2881
|
-
) {
|
|
2882
|
-
throw new SmartdataPersistenceError(
|
|
2883
|
-
'invalid_argument',
|
|
2884
|
-
`${labelArg} entries must be plain filter objects.`,
|
|
2885
|
-
);
|
|
2886
|
-
}
|
|
2887
|
-
assertOnlyDataProperties(entry, `${labelArg} entry`, false);
|
|
2888
|
-
}
|
|
2889
|
-
};
|
|
2890
2943
|
/**
|
|
2891
2944
|
* Plural intent is explicit in the plural method names, so an explicitly
|
|
2892
2945
|
* enumerated, non-empty `$in` over equality scalars anchors those filters as
|
|
@@ -3124,6 +3177,341 @@ const normalizeStrictFilter = (
|
|
|
3124
3177
|
) as plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
3125
3178
|
};
|
|
3126
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
|
+
|
|
3127
3515
|
const pathsConflict = (leftArg: string, rightArg: string): boolean =>
|
|
3128
3516
|
leftArg === rightArg ||
|
|
3129
3517
|
leftArg.startsWith(`${rightArg}.`) ||
|
|
@@ -5093,12 +5481,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5093
5481
|
* Computes bounded grouped counts and optional numeric sums server-side.
|
|
5094
5482
|
* Groups by one or two declared paths — top-level fields or nested paths
|
|
5095
5483
|
* declared through `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })` —
|
|
5096
|
-
* optionally
|
|
5097
|
-
*
|
|
5098
|
-
*
|
|
5099
|
-
*
|
|
5100
|
-
* `getGroupedTotalsPage()` when the caller must
|
|
5101
|
-
* aggregation cursor never leaves
|
|
5484
|
+
* optionally per element of one declared array (`unwind`), whose element
|
|
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.
|
|
5102
5491
|
*/
|
|
5103
5492
|
public static async getGroupedTotals<
|
|
5104
5493
|
T,
|
|
@@ -5107,15 +5496,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5107
5496
|
this: plugins.tsclass.typeFest.Class<T>,
|
|
5108
5497
|
optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
|
|
5109
5498
|
): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
|
|
5110
|
-
const page = await (this as any).
|
|
5499
|
+
const page = await (this as any).readGroupedTotalsPage(optionsArg, false) as
|
|
5111
5500
|
ISmartdataGroupedTotalsPage<T, TUnwind>;
|
|
5112
5501
|
return page.rows;
|
|
5113
5502
|
}
|
|
5114
5503
|
|
|
5115
5504
|
/**
|
|
5116
|
-
* Computes the same
|
|
5117
|
-
*
|
|
5118
|
-
*
|
|
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.
|
|
5119
5520
|
*/
|
|
5120
5521
|
public static async getGroupedTotalsPage<
|
|
5121
5522
|
T,
|
|
@@ -5123,6 +5524,53 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5123
5524
|
>(
|
|
5124
5525
|
this: plugins.tsclass.typeFest.Class<T>,
|
|
5125
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,
|
|
5126
5574
|
): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
|
|
5127
5575
|
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
5128
5576
|
throw new SmartdataPersistenceError(
|
|
@@ -5144,9 +5592,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5144
5592
|
const allowedKeys = new Set([
|
|
5145
5593
|
'groupBy',
|
|
5146
5594
|
'unwind',
|
|
5595
|
+
'elementFilter',
|
|
5147
5596
|
'sums',
|
|
5148
5597
|
'filter',
|
|
5149
5598
|
'limit',
|
|
5599
|
+
'cursor',
|
|
5150
5600
|
'maxTimeMS',
|
|
5151
5601
|
'timeoutMS',
|
|
5152
5602
|
'signal',
|
|
@@ -5173,9 +5623,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5173
5623
|
}
|
|
5174
5624
|
const groupByOption = optionsArg.groupBy;
|
|
5175
5625
|
const unwindOption = optionsArg.unwind;
|
|
5626
|
+
const elementFilterOption = optionsArg.elementFilter;
|
|
5176
5627
|
const sumsOption = optionsArg.sums;
|
|
5177
5628
|
const filterOption = optionsArg.filter;
|
|
5178
5629
|
const limitOption = optionsArg.limit;
|
|
5630
|
+
const cursorOption = optionsArg.cursor;
|
|
5179
5631
|
const maxTimeMSOption = optionsArg.maxTimeMS;
|
|
5180
5632
|
const timeoutMSOption = optionsArg.timeoutMS;
|
|
5181
5633
|
const signalOption = optionsArg.signal;
|
|
@@ -5254,10 +5706,36 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5254
5706
|
}
|
|
5255
5707
|
let unwindField: string | undefined;
|
|
5256
5708
|
if (unwindOption !== undefined) {
|
|
5257
|
-
if (typeof unwindOption !== 'string'
|
|
5709
|
+
if (typeof unwindOption !== 'string') {
|
|
5710
|
+
throw new SmartdataPersistenceError(
|
|
5711
|
+
'invalid_argument',
|
|
5712
|
+
'Grouped totals unwind must name a declared array path.',
|
|
5713
|
+
);
|
|
5714
|
+
}
|
|
5715
|
+
requireDeclaredGroupField(unwindOption, 'Grouped totals unwind path');
|
|
5716
|
+
const aggregatedFields = [...groupByFields, ...sumFields];
|
|
5717
|
+
// The unwound array either is a groupBy or sum path, or holds the
|
|
5718
|
+
// element fields a groupBy or sum path names below it.
|
|
5719
|
+
if (
|
|
5720
|
+
!aggregatedFields.some((fieldArg) =>
|
|
5721
|
+
fieldArg === unwindOption || fieldArg.startsWith(unwindOption + '.'),
|
|
5722
|
+
)
|
|
5723
|
+
) {
|
|
5724
|
+
throw new SmartdataPersistenceError(
|
|
5725
|
+
'invalid_argument',
|
|
5726
|
+
'Grouped totals unwind must name a groupBy or sum path, or the array holding one of them.',
|
|
5727
|
+
);
|
|
5728
|
+
}
|
|
5729
|
+
// A path that contains the unwound array would read a document whose
|
|
5730
|
+
// array has been replaced by one element: neither the document's value
|
|
5731
|
+
// nor the element's.
|
|
5732
|
+
const containingField = aggregatedFields.find((fieldArg) =>
|
|
5733
|
+
unwindOption.startsWith(fieldArg + '.'),
|
|
5734
|
+
);
|
|
5735
|
+
if (containingField !== undefined) {
|
|
5258
5736
|
throw new SmartdataPersistenceError(
|
|
5259
5737
|
'invalid_argument',
|
|
5260
|
-
|
|
5738
|
+
`Grouped totals path "${containingField}" contains the unwound array "${unwindOption}".`,
|
|
5261
5739
|
);
|
|
5262
5740
|
}
|
|
5263
5741
|
if (
|
|
@@ -5273,6 +5751,21 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5273
5751
|
}
|
|
5274
5752
|
unwindField = unwindOption;
|
|
5275
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
|
+
}
|
|
5276
5769
|
const limit = requireBoundedInteger(
|
|
5277
5770
|
limitOption,
|
|
5278
5771
|
'Grouped totals limit',
|
|
@@ -5307,13 +5800,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5307
5800
|
// A composite key names its members by position: a group path may be
|
|
5308
5801
|
// dotted, and an expression object's field names may not contain a dot.
|
|
5309
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;
|
|
5310
5810
|
const groupStage: Record<string, unknown> = {
|
|
5311
5811
|
_id: groupByFields.length === 1
|
|
5312
5812
|
? '$' + groupByFields[0]
|
|
5313
5813
|
: Object.fromEntries(
|
|
5314
5814
|
groupByFields.map((fieldArg, indexArg) => [
|
|
5315
5815
|
compositeKeyName(indexArg),
|
|
5316
|
-
|
|
5816
|
+
compositeMember(fieldArg),
|
|
5317
5817
|
]),
|
|
5318
5818
|
),
|
|
5319
5819
|
__groupedTotalsCount: { $sum: 1 },
|
|
@@ -5321,10 +5821,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5321
5821
|
for (const [index, sumField] of sumFields.entries()) {
|
|
5322
5822
|
groupStage['__groupedTotalsSum' + index] = { $sum: '$' + sumField };
|
|
5323
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;
|
|
5324
5854
|
const rows = await collection.aggregateGroupedTotals(
|
|
5325
|
-
|
|
5326
|
-
? (this as any).normalizeReadFilter(filterOption)
|
|
5327
|
-
: undefined,
|
|
5855
|
+
normalizedFilter,
|
|
5328
5856
|
groupStage,
|
|
5329
5857
|
limit + 1,
|
|
5330
5858
|
{
|
|
@@ -5333,10 +5861,43 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5333
5861
|
...(timeoutMS !== undefined ? { timeoutMS } : {}),
|
|
5334
5862
|
...(signalOption !== undefined ? { signal: signalOption } : {}),
|
|
5335
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])),
|
|
5336
5869
|
},
|
|
5337
5870
|
);
|
|
5338
5871
|
const hasMore = rows.length > limit;
|
|
5339
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
|
+
}
|
|
5340
5901
|
const requireFiniteNumber = (
|
|
5341
5902
|
valueArg: unknown,
|
|
5342
5903
|
labelArg: string,
|
|
@@ -5380,6 +5941,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
5380
5941
|
return {
|
|
5381
5942
|
rows: mappedRows,
|
|
5382
5943
|
hasMore,
|
|
5944
|
+
...(mintsCursor && lastKey !== undefined
|
|
5945
|
+
? { nextCursor: encodeGroupedTotalsCursor(queryShape(), lastKey) }
|
|
5946
|
+
: {}),
|
|
5383
5947
|
};
|
|
5384
5948
|
}
|
|
5385
5949
|
|