cry-db 2.4.48 → 2.4.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -33
- package/dist/mongo.d.mts +15 -0
- package/dist/mongo.d.mts.map +1 -1
- package/dist/mongo.mjs +328 -85
- package/dist/mongo.mjs.map +1 -1
- package/package.json +1 -1
package/dist/mongo.mjs
CHANGED
|
@@ -923,10 +923,10 @@ export class Mongo extends Db {
|
|
|
923
923
|
const processed = this._applyBracketProcessing(processedBase);
|
|
924
924
|
if (processed.warnings) {
|
|
925
925
|
for (const w of processed.warnings) {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
});
|
|
926
|
+
const msg = w.kind === 'overlap'
|
|
927
|
+
? `same-id overlap at "${w.path}" — terminal-bracket value ${JSON.stringify(w.value)} sets the element base; sub-field overlay applies on top`
|
|
928
|
+
: `dropped nested-bracket ${w.op} path "${w.path}" = ${JSON.stringify(w.value)}`;
|
|
929
|
+
warnings.push({ _id: String(_id), error: msg });
|
|
930
930
|
}
|
|
931
931
|
}
|
|
932
932
|
const opts = {
|
|
@@ -3042,6 +3042,46 @@ export class Mongo extends Db {
|
|
|
3042
3042
|
processOp('$unset');
|
|
3043
3043
|
return extractedAny ? result : undefined;
|
|
3044
3044
|
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Build the filter + concat + map+switch expression that transforms an
|
|
3047
|
+
* array according to a FieldOps spec. Used both at the top level (in
|
|
3048
|
+
* `_applyBracketProcessing`'s main pipeline) and recursively from
|
|
3049
|
+
* `_buildElementMergeExpr` to compose nested-array transformations
|
|
3050
|
+
* inside an element overlay.
|
|
3051
|
+
*
|
|
3052
|
+
* `baseInputPath` is a mongo field path string (e.g. `"$koraki"` for
|
|
3053
|
+
* top-level, `"$$el.postavke"` for nested). It MUST be a string so the
|
|
3054
|
+
* generated expression can read the existing array via `$ifNull`.
|
|
3055
|
+
*/
|
|
3056
|
+
static _buildArrayTransformExpr(baseInputPath, ops) {
|
|
3057
|
+
const dedupedRemoveIds = Mongo._normalizeBracketIds(Array.from(new Set(ops.removeIds)));
|
|
3058
|
+
const filteredInput = {
|
|
3059
|
+
$filter: {
|
|
3060
|
+
input: { $ifNull: [baseInputPath, []] },
|
|
3061
|
+
as: 'el',
|
|
3062
|
+
cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
|
|
3063
|
+
},
|
|
3064
|
+
};
|
|
3065
|
+
const concatExpr = {
|
|
3066
|
+
$concatArrays: [filteredInput, { $literal: ops.insertElements }],
|
|
3067
|
+
};
|
|
3068
|
+
if (ops.elementOps.size === 0)
|
|
3069
|
+
return concatExpr;
|
|
3070
|
+
const branches = [];
|
|
3071
|
+
for (const [id, elOps] of ops.elementOps.entries()) {
|
|
3072
|
+
branches.push({
|
|
3073
|
+
case: { $eq: ['$$el._id', Mongo._normalizeBracketId(id)] },
|
|
3074
|
+
then: Mongo._buildElementMergeExpr('$$el', elOps),
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
return {
|
|
3078
|
+
$map: {
|
|
3079
|
+
input: concatExpr,
|
|
3080
|
+
as: 'el',
|
|
3081
|
+
in: { $switch: { branches, default: '$$el' } },
|
|
3082
|
+
},
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3045
3085
|
/**
|
|
3046
3086
|
* Build an aggregation expression that produces a transformed copy of an
|
|
3047
3087
|
* element, applying the given sets/unsets and (optionally) a whole-element
|
|
@@ -3055,6 +3095,9 @@ export class Mongo extends Db {
|
|
|
3055
3095
|
* `$$REMOVE` inside `$mergeObjects` does NOT drop the field (mongo treats
|
|
3056
3096
|
* it as a missing value and falls through to the previous operand), so
|
|
3057
3097
|
* the explicit kv-filter is required.
|
|
3098
|
+
* - `nestedArrayOps`: per-array-field FieldOps for nested-bracket support.
|
|
3099
|
+
* Each entry produces a `$mergeObjects` overlay that replaces the named
|
|
3100
|
+
* field with the recursive output of `_buildArrayTransformExpr`.
|
|
3058
3101
|
*
|
|
3059
3102
|
* The returned expression is suitable as the `in:` argument of `$map`.
|
|
3060
3103
|
*/
|
|
@@ -3062,12 +3105,65 @@ export class Mongo extends Db {
|
|
|
3062
3105
|
const hasSets = Object.keys(ops.sets).length > 0;
|
|
3063
3106
|
const hasUnsets = ops.unsets.length > 0;
|
|
3064
3107
|
const hasReplace = ops.replace !== undefined;
|
|
3065
|
-
|
|
3108
|
+
const hasNestedArrayOps = !!(ops.nestedArrayOps && ops.nestedArrayOps.size > 0);
|
|
3109
|
+
// Helper: layer the nested-array overlays on top of an existing
|
|
3110
|
+
// merge expression. Used at every return point so nested-bracket
|
|
3111
|
+
// sub-field updates and nested terminal-bracket inserts apply
|
|
3112
|
+
// regardless of whether the outer element also has sets/unsets/replace.
|
|
3113
|
+
//
|
|
3114
|
+
// Removes-only entries (no insertElements, no elementOps) are
|
|
3115
|
+
// conditionally applied — only if the source array exists. This
|
|
3116
|
+
// matches mongo's native "no-op on missing field" semantics for
|
|
3117
|
+
// $pull / $unset and avoids creating spurious empty arrays on
|
|
3118
|
+
// elements that didn't originally carry the field.
|
|
3119
|
+
const wrapWithNestedArrays = (expr, basePath) => {
|
|
3120
|
+
if (!hasNestedArrayOps)
|
|
3121
|
+
return expr;
|
|
3122
|
+
const alwaysOverlay = {};
|
|
3123
|
+
const conditionalEntries = [];
|
|
3124
|
+
for (const [field, nestedFieldOps] of ops.nestedArrayOps.entries()) {
|
|
3125
|
+
const transform = Mongo._buildArrayTransformExpr(`${basePath}.${field}`, nestedFieldOps);
|
|
3126
|
+
const hasInserts = nestedFieldOps.insertElements.length > 0;
|
|
3127
|
+
const hasOverlays = nestedFieldOps.elementOps.size > 0;
|
|
3128
|
+
if (hasInserts || hasOverlays) {
|
|
3129
|
+
alwaysOverlay[field] = transform;
|
|
3130
|
+
}
|
|
3131
|
+
else {
|
|
3132
|
+
conditionalEntries.push({ field, transform });
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
let result = Object.keys(alwaysOverlay).length > 0
|
|
3136
|
+
? { $mergeObjects: [expr, alwaysOverlay] }
|
|
3137
|
+
: expr;
|
|
3138
|
+
for (const { field, transform } of conditionalEntries) {
|
|
3139
|
+
result = {
|
|
3140
|
+
$cond: [
|
|
3141
|
+
{ $isArray: `${basePath}.${field}` },
|
|
3142
|
+
{ $mergeObjects: [result, { [field]: transform }] },
|
|
3143
|
+
result,
|
|
3144
|
+
],
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
return result;
|
|
3148
|
+
};
|
|
3149
|
+
if (!hasSets && !hasUnsets && !hasReplace && !hasNestedArrayOps) {
|
|
3066
3150
|
return baseExpr;
|
|
3151
|
+
}
|
|
3152
|
+
if (!hasSets && !hasUnsets && !hasReplace && hasNestedArrayOps) {
|
|
3153
|
+
return wrapWithNestedArrays(baseExpr, baseExpr);
|
|
3154
|
+
}
|
|
3067
3155
|
if (!hasSets && !hasUnsets) {
|
|
3068
3156
|
// Pure replace — embed as literal so client-supplied field names like
|
|
3069
3157
|
// "_id" don't get interpreted as field paths.
|
|
3070
|
-
|
|
3158
|
+
const replaceExpr = { $literal: ops.replace };
|
|
3159
|
+
if (!hasNestedArrayOps)
|
|
3160
|
+
return replaceExpr;
|
|
3161
|
+
return {
|
|
3162
|
+
$let: {
|
|
3163
|
+
vars: { replaced: replaceExpr },
|
|
3164
|
+
in: wrapWithNestedArrays('$$replaced', '$$replaced'),
|
|
3165
|
+
},
|
|
3166
|
+
};
|
|
3071
3167
|
}
|
|
3072
3168
|
const root = { directs: {}, nested: {}, unsets: [] };
|
|
3073
3169
|
const walkInsert = (path, isUnset, value) => {
|
|
@@ -3131,11 +3227,11 @@ export class Mongo extends Db {
|
|
|
3131
3227
|
return {
|
|
3132
3228
|
$let: {
|
|
3133
3229
|
vars: { replaced: { $literal: ops.replace } },
|
|
3134
|
-
in: overlayExpr,
|
|
3230
|
+
in: wrapWithNestedArrays(overlayExpr, '$$replaced'),
|
|
3135
3231
|
},
|
|
3136
3232
|
};
|
|
3137
3233
|
}
|
|
3138
|
-
return buildOverlay(root, baseExpr);
|
|
3234
|
+
return wrapWithNestedArrays(buildOverlay(root, baseExpr), baseExpr);
|
|
3139
3235
|
}
|
|
3140
3236
|
/**
|
|
3141
3237
|
* Translate an update doc's `$inc` and `$currentDate` operators into
|
|
@@ -3209,9 +3305,42 @@ export class Mongo extends Db {
|
|
|
3209
3305
|
this._validateAndAutoFillTerminalBracketValues(update);
|
|
3210
3306
|
const inserts = this._extractArrayInserts(update);
|
|
3211
3307
|
const removes = this._extractArrayRemoves(update);
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
|
|
3308
|
+
// Detect NESTED terminal-bracket paths (2+ brackets, last token is bracket
|
|
3309
|
+
// with array value or in $unset). Mongo cannot express these via
|
|
3310
|
+
// `arrayFilters` — both `$set arr.$[f0].sub.$[f1]` AND `$unset arr.$[f0].sub.$[f1]`
|
|
3311
|
+
// collide with sibling sub-field paths on the same `arr.$[f0].sub.$[f1]`
|
|
3312
|
+
// positional. Force pipeline form so the nested processing below can
|
|
3313
|
+
// recursively express filter+concat+map+switch inside an element overlay.
|
|
3314
|
+
const hasNestedTerminalBracket = (op) => {
|
|
3315
|
+
if (!op)
|
|
3316
|
+
return false;
|
|
3317
|
+
for (const k of Object.keys(op)) {
|
|
3318
|
+
const tokens = Mongo._tokenizePath(k);
|
|
3319
|
+
if (tokens.length < 2)
|
|
3320
|
+
continue;
|
|
3321
|
+
let bracketCount = 0;
|
|
3322
|
+
for (const t of tokens) {
|
|
3323
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
|
|
3324
|
+
bracketCount++;
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
if (bracketCount < 2)
|
|
3328
|
+
continue;
|
|
3329
|
+
const last = tokens[tokens.length - 1];
|
|
3330
|
+
if (last.length >= 2 && last.charCodeAt(0) === 91 && last.charCodeAt(last.length - 1) === 93) {
|
|
3331
|
+
return true;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return false;
|
|
3335
|
+
};
|
|
3336
|
+
const $setEarlyForNested = update.$set;
|
|
3337
|
+
const $unsetEarlyForNested = update.$unset;
|
|
3338
|
+
const hasNestedTerminal = hasNestedTerminalBracket($setEarlyForNested) || hasNestedTerminalBracket($unsetEarlyForNested);
|
|
3339
|
+
// Pure sub-field updates only (no inserts, no removes, no nested-terminal) —
|
|
3340
|
+
// keep on legacy arrayFilters path (faster, smaller payload, doesn't fight
|
|
3341
|
+
// $inc/$currentDate). Nested sub-field paths like `arr[A].sub[B].field`
|
|
3342
|
+
// ARE supported via nested arrayFilters here.
|
|
3343
|
+
if (!inserts && !removes && !hasNestedTerminal) {
|
|
3215
3344
|
const arrayFilters = this._extractArrayFilters(update);
|
|
3216
3345
|
return arrayFilters ? { update, arrayFilters } : { update };
|
|
3217
3346
|
}
|
|
@@ -3277,52 +3406,42 @@ export class Mongo extends Db {
|
|
|
3277
3406
|
// Extract single-bracket sub-field paths so we can express them as pipeline
|
|
3278
3407
|
// stages instead of arrayFilters (mongo forbids arrayFilters on pipelines).
|
|
3279
3408
|
const subFieldOps = this._extractArraySubFieldUpdates(update);
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3409
|
+
// Build the recursive fieldOps structure. Top-level (single-bracket)
|
|
3410
|
+
// inserts/removes/sub-field updates have already been extracted by
|
|
3411
|
+
// the dedicated helpers; ingest them now. Remaining bracket-containing
|
|
3412
|
+
// keys in $set/$unset are NESTED (e.g. `arr[A].sub[B].field`) and get
|
|
3413
|
+
// processed below into the recursive `nestedArrayOps` structure.
|
|
3284
3414
|
const warnings = [];
|
|
3285
|
-
const stripNestedBracket = (op, opName) => {
|
|
3286
|
-
if (!op)
|
|
3287
|
-
return;
|
|
3288
|
-
for (const k of Object.keys(op)) {
|
|
3289
|
-
if (k.indexOf('[') >= 0) {
|
|
3290
|
-
warnings.push({ path: k, value: op[k], op: opName });
|
|
3291
|
-
delete op[k];
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3294
|
-
};
|
|
3295
|
-
stripNestedBracket(update.$set, '$set');
|
|
3296
|
-
stripNestedBracket(update.$unset, '$unset');
|
|
3297
|
-
if (warnings.length > 0) {
|
|
3298
|
-
fjLog.warn('cry-db: dropping NESTED-bracket sub-field paths combined with terminal-bracket array operations (not expressible in pipeline form):', warnings);
|
|
3299
|
-
}
|
|
3300
|
-
if (update.$set && Object.keys(update.$set).length === 0)
|
|
3301
|
-
delete update.$set;
|
|
3302
|
-
if (update.$unset && Object.keys(update.$unset).length === 0)
|
|
3303
|
-
delete update.$unset;
|
|
3304
3415
|
const fieldOps = new Map();
|
|
3305
|
-
const ensureField = (field) => {
|
|
3306
|
-
if (!
|
|
3307
|
-
|
|
3308
|
-
return
|
|
3416
|
+
const ensureField = (container, field) => {
|
|
3417
|
+
if (!container.has(field))
|
|
3418
|
+
container.set(field, { removeIds: [], insertElements: [], elementOps: new Map() });
|
|
3419
|
+
return container.get(field);
|
|
3420
|
+
};
|
|
3421
|
+
const ensureElement = (fops, id) => {
|
|
3422
|
+
if (!fops.elementOps.has(id))
|
|
3423
|
+
fops.elementOps.set(id, { sets: {}, unsets: [], nestedArrayOps: new Map() });
|
|
3424
|
+
const elOps = fops.elementOps.get(id);
|
|
3425
|
+
if (!elOps.nestedArrayOps)
|
|
3426
|
+
elOps.nestedArrayOps = new Map();
|
|
3427
|
+
return elOps;
|
|
3309
3428
|
};
|
|
3310
3429
|
if (inserts) {
|
|
3311
3430
|
for (const ins of inserts) {
|
|
3312
|
-
const ops = ensureField(ins.field);
|
|
3431
|
+
const ops = ensureField(fieldOps, ins.field);
|
|
3313
3432
|
ops.removeIds.push(...ins.ids);
|
|
3314
3433
|
ops.insertElements.push(...ins.elements);
|
|
3315
3434
|
}
|
|
3316
3435
|
}
|
|
3317
3436
|
if (removes) {
|
|
3318
3437
|
for (const rm of removes) {
|
|
3319
|
-
const ops = ensureField(rm.field);
|
|
3438
|
+
const ops = ensureField(fieldOps, rm.field);
|
|
3320
3439
|
ops.removeIds.push(...rm.ids);
|
|
3321
3440
|
}
|
|
3322
3441
|
}
|
|
3323
3442
|
if (subFieldOps) {
|
|
3324
3443
|
for (const [field, idMap] of subFieldOps.entries()) {
|
|
3325
|
-
const ops = ensureField(field);
|
|
3444
|
+
const ops = ensureField(fieldOps, field);
|
|
3326
3445
|
for (const [id, elOps] of idMap.entries()) {
|
|
3327
3446
|
// Sub-field updates are applied AFTER the post-concat array (i.e.
|
|
3328
3447
|
// they overlay BOTH existing-kept elements and freshly-inserted
|
|
@@ -3330,10 +3449,173 @@ export class Mongo extends Db {
|
|
|
3330
3449
|
// element with the insert as its base and the sub-field update
|
|
3331
3450
|
// overriding matching fields. Order: non-bracket → terminal-bracket
|
|
3332
3451
|
// insert/remove → sub-field overlay.
|
|
3333
|
-
ops
|
|
3452
|
+
const target = ensureElement(ops, id);
|
|
3453
|
+
Object.assign(target.sets, elOps.sets);
|
|
3454
|
+
target.unsets.push(...elOps.unsets);
|
|
3455
|
+
if (elOps.replace !== undefined)
|
|
3456
|
+
target.replace = elOps.replace;
|
|
3334
3457
|
}
|
|
3335
3458
|
}
|
|
3336
3459
|
}
|
|
3460
|
+
// Process REMAINING bracket-containing keys (multi-bracket / nested).
|
|
3461
|
+
// Each path adds entries to the recursive nestedArrayOps structure.
|
|
3462
|
+
const processNestedPaths = (op, opName) => {
|
|
3463
|
+
if (!op)
|
|
3464
|
+
return;
|
|
3465
|
+
const remaining = {};
|
|
3466
|
+
for (const path of Object.keys(op)) {
|
|
3467
|
+
if (path.indexOf('[') < 0) {
|
|
3468
|
+
remaining[path] = op[path];
|
|
3469
|
+
continue;
|
|
3470
|
+
}
|
|
3471
|
+
const tokens = Mongo._tokenizePath(path);
|
|
3472
|
+
const brackets = [];
|
|
3473
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
3474
|
+
const t = tokens[i];
|
|
3475
|
+
if (t.length >= 2 && t.charCodeAt(0) === 91 && t.charCodeAt(t.length - 1) === 93) {
|
|
3476
|
+
brackets.push({ idx: i, id: Mongo._unquoteBracketId(t, path) });
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
if (brackets.length < 2) {
|
|
3480
|
+
// Single-bracket: extractors above should have handled it; if it
|
|
3481
|
+
// remained, it's malformed (e.g. empty parent). Drop with warning.
|
|
3482
|
+
warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
|
|
3483
|
+
continue;
|
|
3484
|
+
}
|
|
3485
|
+
// Walk all but the innermost bracket — descend through nestedArrayOps.
|
|
3486
|
+
let currentMap = fieldOps;
|
|
3487
|
+
let ok = true;
|
|
3488
|
+
for (let bi = 0; bi < brackets.length - 1; bi++) {
|
|
3489
|
+
const bracket = brackets[bi];
|
|
3490
|
+
const fieldStart = bi === 0 ? 0 : brackets[bi - 1].idx + 1;
|
|
3491
|
+
const fieldName = tokens.slice(fieldStart, bracket.idx).join('.');
|
|
3492
|
+
if (!fieldName) {
|
|
3493
|
+
ok = false;
|
|
3494
|
+
break;
|
|
3495
|
+
}
|
|
3496
|
+
const fops = ensureField(currentMap, fieldName);
|
|
3497
|
+
const elOps = ensureElement(fops, bracket.id);
|
|
3498
|
+
currentMap = elOps.nestedArrayOps;
|
|
3499
|
+
}
|
|
3500
|
+
if (!ok) {
|
|
3501
|
+
warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
|
|
3502
|
+
continue;
|
|
3503
|
+
}
|
|
3504
|
+
const lastB = brackets[brackets.length - 1];
|
|
3505
|
+
const fieldStart = brackets[brackets.length - 2].idx + 1;
|
|
3506
|
+
const innerFieldName = tokens.slice(fieldStart, lastB.idx).join('.');
|
|
3507
|
+
const restPath = tokens.slice(lastB.idx + 1).join('.');
|
|
3508
|
+
if (!innerFieldName) {
|
|
3509
|
+
warnings.push({ path, value: op[path], op: opName, kind: 'dropped' });
|
|
3510
|
+
continue;
|
|
3511
|
+
}
|
|
3512
|
+
const innerFOps = ensureField(currentMap, innerFieldName);
|
|
3513
|
+
const value = op[path];
|
|
3514
|
+
if (opName === '$set') {
|
|
3515
|
+
if (restPath === '') {
|
|
3516
|
+
if (Array.isArray(value)) {
|
|
3517
|
+
innerFOps.removeIds.push(lastB.id);
|
|
3518
|
+
innerFOps.insertElements.push(...value);
|
|
3519
|
+
}
|
|
3520
|
+
else {
|
|
3521
|
+
// Whole-element replace at nested level.
|
|
3522
|
+
const elOps = ensureElement(innerFOps, lastB.id);
|
|
3523
|
+
elOps.replace = value;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
else {
|
|
3527
|
+
const elOps = ensureElement(innerFOps, lastB.id);
|
|
3528
|
+
elOps.sets[restPath] = value;
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
else {
|
|
3532
|
+
// $unset
|
|
3533
|
+
if (restPath === '') {
|
|
3534
|
+
innerFOps.removeIds.push(lastB.id);
|
|
3535
|
+
}
|
|
3536
|
+
else {
|
|
3537
|
+
const elOps = ensureElement(innerFOps, lastB.id);
|
|
3538
|
+
elOps.unsets.push(restPath);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
update[opName] = remaining;
|
|
3543
|
+
if (Object.keys(remaining).length === 0)
|
|
3544
|
+
delete update[opName];
|
|
3545
|
+
};
|
|
3546
|
+
processNestedPaths(update.$set, '$set');
|
|
3547
|
+
processNestedPaths(update.$unset, '$unset');
|
|
3548
|
+
// Detect same-id overlap at NESTED levels: a "new base" (terminal-array
|
|
3549
|
+
// insert OR whole-element replace) targeting the same `_id` as a
|
|
3550
|
+
// sub-field set/unset on the same element. Both apply — new base goes
|
|
3551
|
+
// first, sub-field overlay applies on top — but the unusual overlap is
|
|
3552
|
+
// surfaced as a `kind: 'overlap'` warning so callers know.
|
|
3553
|
+
//
|
|
3554
|
+
// Two flavors at the source of the "new base":
|
|
3555
|
+
// - terminal-array (`arr[id]: [...]`): id ends up in `removeIds`
|
|
3556
|
+
// AND `insertElements`. The matching element in elementOps will
|
|
3557
|
+
// carry sets/unsets if a same-id sub-field path was also present.
|
|
3558
|
+
// - whole-element replace (`arr[id]: {...}`): id ends up in
|
|
3559
|
+
// `elementOps[id].replace`. Sets/unsets co-locate on the same
|
|
3560
|
+
// entry when a same-id sub-field path was also present.
|
|
3561
|
+
//
|
|
3562
|
+
// Top-level same-id case is the same semantic but stays silent — it's
|
|
3563
|
+
// a documented, supported merge for backwards compat.
|
|
3564
|
+
const detectOverlaps = (container, parentPathRepr) => {
|
|
3565
|
+
for (const [field, fops] of container.entries()) {
|
|
3566
|
+
const fullField = parentPathRepr ? `${parentPathRepr}.${field}` : field;
|
|
3567
|
+
const isNested = parentPathRepr !== '';
|
|
3568
|
+
// Flavor 1: terminal-array insert + sub-field on same id.
|
|
3569
|
+
const removeIdSet = new Set(fops.removeIds);
|
|
3570
|
+
if (isNested) {
|
|
3571
|
+
for (const id of fops.removeIds) {
|
|
3572
|
+
const elOps = fops.elementOps.get(id);
|
|
3573
|
+
if (!elOps)
|
|
3574
|
+
continue;
|
|
3575
|
+
const hasOps = Object.keys(elOps.sets).length > 0
|
|
3576
|
+
|| elOps.unsets.length > 0
|
|
3577
|
+
|| elOps.replace !== undefined;
|
|
3578
|
+
if (hasOps) {
|
|
3579
|
+
const insertedEl = fops.insertElements.find((e) => String(e._id) === id);
|
|
3580
|
+
warnings.push({
|
|
3581
|
+
path: `${fullField}[${id}]`,
|
|
3582
|
+
value: insertedEl,
|
|
3583
|
+
op: '$set',
|
|
3584
|
+
kind: 'overlap',
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
// Flavor 2: whole-element replace + sub-field on same id.
|
|
3590
|
+
// Skip ids already covered by Flavor 1 above (avoid double-warn).
|
|
3591
|
+
for (const [id, elOps] of fops.elementOps.entries()) {
|
|
3592
|
+
if (isNested && !removeIdSet.has(id)) {
|
|
3593
|
+
const hasReplace = elOps.replace !== undefined;
|
|
3594
|
+
const hasSetsOrUnsets = Object.keys(elOps.sets).length > 0
|
|
3595
|
+
|| elOps.unsets.length > 0;
|
|
3596
|
+
if (hasReplace && hasSetsOrUnsets) {
|
|
3597
|
+
warnings.push({
|
|
3598
|
+
path: `${fullField}[${id}]`,
|
|
3599
|
+
value: elOps.replace,
|
|
3600
|
+
op: '$set',
|
|
3601
|
+
kind: 'overlap',
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
if (elOps.nestedArrayOps && elOps.nestedArrayOps.size > 0) {
|
|
3606
|
+
detectOverlaps(elOps.nestedArrayOps, `${fullField}[${id}]`);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
};
|
|
3611
|
+
detectOverlaps(fieldOps, '');
|
|
3612
|
+
if (warnings.length > 0) {
|
|
3613
|
+
fjLog.warn('cry-db: bracket-by-_id warnings:', warnings);
|
|
3614
|
+
}
|
|
3615
|
+
if (update.$set && Object.keys(update.$set).length === 0)
|
|
3616
|
+
delete update.$set;
|
|
3617
|
+
if (update.$unset && Object.keys(update.$unset).length === 0)
|
|
3618
|
+
delete update.$unset;
|
|
3337
3619
|
const pipeline = [];
|
|
3338
3620
|
if (update.$set && Object.keys(update.$set).length > 0) {
|
|
3339
3621
|
pipeline.push({ $set: update.$set });
|
|
@@ -3344,52 +3626,13 @@ export class Mongo extends Db {
|
|
|
3344
3626
|
// Translate $inc / $currentDate so revisions (_rev/_ts) still update in pipeline form.
|
|
3345
3627
|
for (const stage of Mongo._drainIncAndCurrentDateToPipelineStages(update))
|
|
3346
3628
|
pipeline.push(stage);
|
|
3629
|
+
// Build one $set stage per parent array field. The filter+concat+map+switch
|
|
3630
|
+
// expression (with potentially recursive nested-array transforms inside
|
|
3631
|
+
// per-element overlays) is built by `_buildArrayTransformExpr`.
|
|
3347
3632
|
for (const [field, ops] of fieldOps.entries()) {
|
|
3348
|
-
const dedupedRemoveIds = Mongo._normalizeBracketIds(Array.from(new Set(ops.removeIds)));
|
|
3349
|
-
const filteredInput = {
|
|
3350
|
-
$filter: {
|
|
3351
|
-
input: { $ifNull: [`$${field}`, []] },
|
|
3352
|
-
as: 'el',
|
|
3353
|
-
cond: { $not: { $in: ['$$el._id', dedupedRemoveIds] } },
|
|
3354
|
-
},
|
|
3355
|
-
};
|
|
3356
|
-
// `$literal` wraps the insert array as opaque data, so mongo's
|
|
3357
|
-
// expression parser does not interpret user-supplied field names
|
|
3358
|
-
// with `.` or `$` as expression paths (legitimate-as-stored mongo
|
|
3359
|
-
// doc keys would otherwise crash with
|
|
3360
|
-
// `FieldPath field names may not contain '.'`).
|
|
3361
|
-
const concatExpr = {
|
|
3362
|
-
$concatArrays: [filteredInput, { $literal: ops.insertElements }],
|
|
3363
|
-
};
|
|
3364
|
-
// Apply sub-field updates to the FULL post-concat array (existing-kept
|
|
3365
|
-
// AND newly inserted), so when an update mixes a terminal-bracket
|
|
3366
|
-
// replace (`arr[id]: [el]`) and sub-field updates (`arr[id].field: x`)
|
|
3367
|
-
// for the SAME id, the sub-field update overrides matching fields on
|
|
3368
|
-
// the inserted element. This matches the user-facing semantics
|
|
3369
|
-
// "bracketed fields apply after non-bracketed" applied transitively:
|
|
3370
|
-
// sub-field updates are the most-specific bracket form, so they are
|
|
3371
|
-
// applied LAST.
|
|
3372
|
-
let resultExpr = concatExpr;
|
|
3373
|
-
if (ops.elementOps.size > 0) {
|
|
3374
|
-
// Build $switch with one branch per (id → merged element).
|
|
3375
|
-
const branches = [];
|
|
3376
|
-
for (const [id, elOps] of ops.elementOps.entries()) {
|
|
3377
|
-
branches.push({
|
|
3378
|
-
case: { $eq: ['$$el._id', Mongo._normalizeBracketId(id)] },
|
|
3379
|
-
then: Mongo._buildElementMergeExpr('$$el', elOps),
|
|
3380
|
-
});
|
|
3381
|
-
}
|
|
3382
|
-
resultExpr = {
|
|
3383
|
-
$map: {
|
|
3384
|
-
input: concatExpr,
|
|
3385
|
-
as: 'el',
|
|
3386
|
-
in: { $switch: { branches, default: '$$el' } },
|
|
3387
|
-
},
|
|
3388
|
-
};
|
|
3389
|
-
}
|
|
3390
3633
|
pipeline.push({
|
|
3391
3634
|
$set: {
|
|
3392
|
-
[field]:
|
|
3635
|
+
[field]: Mongo._buildArrayTransformExpr(`$${field}`, ops),
|
|
3393
3636
|
},
|
|
3394
3637
|
});
|
|
3395
3638
|
}
|