@atscript/db-mongo 0.1.145 → 0.1.147
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/dist/agg.cjs +20 -17
- package/dist/agg.d.cts +7 -4
- package/dist/agg.d.mts +7 -4
- package/dist/agg.mjs +20 -17
- package/dist/index.cjs +472 -177
- package/dist/index.d.cts +90 -22
- package/dist/index.d.mts +90 -22
- package/dist/index.mjs +469 -179
- package/dist/mongo-accumulator-BJcQs2OV.cjs +605 -0
- package/dist/mongo-accumulator-Y_xVYo3C.mjs +534 -0
- package/dist/mongo-filter-CGd9ryOD.d.cts +123 -0
- package/dist/mongo-filter-CGd9ryOD.d.mts +123 -0
- package/package.json +9 -9
- package/dist/mongo-accumulator-BalE9LkU.cjs +0 -265
- package/dist/mongo-accumulator-Dc0w4t0l.mjs +0 -236
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as queryNodeToExpr, i as
|
|
2
|
-
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace,
|
|
1
|
+
import { a as buildMongoQuery, c as planStages, d as queryNodeToExpr, f as correlate, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator, u as orNull } from "./mongo-accumulator-Y_xVYo3C.mjs";
|
|
2
|
+
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
3
3
|
import { MongoClient, MongoServerError, ObjectId } from "mongodb";
|
|
4
4
|
import { resolveAggregateSearch } from "@atscript/db/agg";
|
|
5
5
|
//#region src/lib/path-utils.ts
|
|
@@ -373,47 +373,67 @@ function mongoIndexKey(type, name) {
|
|
|
373
373
|
}
|
|
374
374
|
//#endregion
|
|
375
375
|
//#region src/lib/mongo-relations.ts
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const val = doc[primaryKeys[0]];
|
|
379
|
-
return val == null ? "" : `${val}`;
|
|
380
|
-
}
|
|
376
|
+
/** Join key of a row's primary key values (`fields` = the row's own names for them). */
|
|
377
|
+
function buildPKKey(fields, doc) {
|
|
381
378
|
let key = "";
|
|
382
|
-
for (let i = 0; i <
|
|
379
|
+
for (let i = 0; i < fields.length; i++) {
|
|
383
380
|
if (i > 0) key += "\0";
|
|
384
|
-
const val = doc[
|
|
381
|
+
const val = doc[fields[i]];
|
|
385
382
|
key += val == null ? "" : `${val}`;
|
|
386
383
|
}
|
|
387
384
|
return key;
|
|
388
385
|
}
|
|
386
|
+
function resolveReadable(tableResolver, type) {
|
|
387
|
+
if (!type || !tableResolver) return;
|
|
388
|
+
return tableResolver(type);
|
|
389
|
+
}
|
|
390
|
+
/** The collection a readable reads (a view's name for a `@db.view`). */
|
|
391
|
+
function collectionOf(readable) {
|
|
392
|
+
return readable.getAdapter().resolveTableName(false);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Native `$with` loading: one aggregation over the source collection
|
|
396
|
+
* (re-selected by primary key) with a `$lookup` per relation.
|
|
397
|
+
*
|
|
398
|
+
* Every name is physical (since 0.1.147): join fields go through each
|
|
399
|
+
* side's `physicalPath`, and the `$with` sub-query (filter — relational
|
|
400
|
+
* predicates included —, `$sort`, `$select`) through the related table's
|
|
401
|
+
* own translation. The relation's `@db.rel.filter` is part of the lookup
|
|
402
|
+
* (target conditions ANDed into the sub-filter, junction conditions on the
|
|
403
|
+
* junction rows). Loaded documents come back as logical rows (renames,
|
|
404
|
+
* value formatters, decryption) before nested `$with` loads on them.
|
|
405
|
+
*/
|
|
389
406
|
async function loadRelationsImpl(host, rows, withRelations, relations, foreignKeys, tableResolver) {
|
|
390
407
|
if (rows.length === 0 || withRelations.length === 0) return;
|
|
391
|
-
const
|
|
408
|
+
const source = host._table;
|
|
409
|
+
const primaryKeys = source.primaryKeys;
|
|
392
410
|
const relMeta = [];
|
|
393
411
|
for (const withRel of withRelations) {
|
|
394
412
|
if (withRel.name.includes(".")) continue;
|
|
395
413
|
const relation = relations.get(withRel.name);
|
|
396
414
|
if (!relation) throw new Error(`Unknown relation "${withRel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
relMeta.push({
|
|
400
|
-
name: withRel.name,
|
|
401
|
-
isArray: lookupResult.isArray,
|
|
402
|
-
relation,
|
|
403
|
-
nestedWith: extractNestedWith(withRel),
|
|
404
|
-
stages: lookupResult.stages
|
|
405
|
-
});
|
|
415
|
+
const lookup = buildRelationLookup(source, withRel, relation, foreignKeys, tableResolver);
|
|
416
|
+
if (lookup) relMeta.push(lookup);
|
|
406
417
|
}
|
|
407
418
|
if (relMeta.length === 0) return;
|
|
408
419
|
const pkMatchFilter = buildPKMatchFilter(rows, primaryKeys);
|
|
409
420
|
if (pkMatchFilter) {
|
|
410
|
-
const
|
|
421
|
+
const { filter } = source._translateForAdapter({ filter: pkMatchFilter });
|
|
422
|
+
const sourceMeta = source.getMetadata();
|
|
423
|
+
const physicalKeys = primaryKeys.map((pk) => sourceMeta.physicalPath(pk));
|
|
424
|
+
const project = {};
|
|
425
|
+
for (const key of physicalKeys) project[key] = 1;
|
|
426
|
+
for (const meta of relMeta) project[meta.name] = 1;
|
|
427
|
+
const pipeline = [{ $match: buildMongoFilter(filter) }];
|
|
411
428
|
for (const meta of relMeta) pipeline.push(...meta.stages);
|
|
412
|
-
|
|
429
|
+
pipeline.push({ $project: project });
|
|
430
|
+
const results = await host.collection.aggregate(pipeline, host._getSessionOpts()).toArray();
|
|
431
|
+
await Promise.all(relMeta.map((meta) => toLogicalRows(results, meta)));
|
|
432
|
+
mergeRelationResults(rows, results, primaryKeys, physicalKeys, relMeta);
|
|
413
433
|
} else for (const row of rows) for (const meta of relMeta) row[meta.name] = meta.isArray ? [] : null;
|
|
414
|
-
await loadNestedRelations(rows, relMeta
|
|
434
|
+
await loadNestedRelations(rows, relMeta);
|
|
415
435
|
}
|
|
416
|
-
/** Builds a $match filter to re-select source rows by PK. */
|
|
436
|
+
/** Builds a $match filter to re-select source rows by PK (logical names and values). */
|
|
417
437
|
function buildPKMatchFilter(rows, primaryKeys) {
|
|
418
438
|
if (primaryKeys.length === 1) {
|
|
419
439
|
const pk = primaryKeys[0];
|
|
@@ -447,137 +467,164 @@ function buildPKMatchFilter(rows, primaryKeys) {
|
|
|
447
467
|
return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
|
|
448
468
|
}
|
|
449
469
|
/** Dispatches to the correct $lookup builder based on relation direction. */
|
|
450
|
-
function buildRelationLookup(
|
|
470
|
+
function buildRelationLookup(source, withRel, relation, foreignKeys, tableResolver) {
|
|
471
|
+
const target = resolveReadable(tableResolver, relation.targetType());
|
|
472
|
+
if (!target) return;
|
|
473
|
+
let built;
|
|
451
474
|
switch (relation.direction) {
|
|
452
|
-
case "to":
|
|
453
|
-
|
|
454
|
-
|
|
475
|
+
case "to":
|
|
476
|
+
built = buildToLookup(source, target, withRel, relation, foreignKeys);
|
|
477
|
+
break;
|
|
478
|
+
case "from":
|
|
479
|
+
built = buildFromLookup(source, target, withRel, relation);
|
|
480
|
+
break;
|
|
481
|
+
case "via":
|
|
482
|
+
built = buildViaLookup(source, target, withRel, relation, tableResolver);
|
|
483
|
+
break;
|
|
455
484
|
default: return;
|
|
456
485
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
exprMatch: { $eq: [`$${remoteFields[0]}`, `$$${varPrefix}0`] }
|
|
465
|
-
};
|
|
466
|
-
const andClauses = [];
|
|
467
|
-
for (let i = 0; i < remoteFields.length; i++) andClauses.push({ $eq: [`$${remoteFields[i]}`, `$$${varPrefix}${i}`] });
|
|
468
|
-
return {
|
|
469
|
-
letVars,
|
|
470
|
-
exprMatch: { $and: andClauses }
|
|
486
|
+
return built && {
|
|
487
|
+
name: withRel.name,
|
|
488
|
+
isArray: built.isArray,
|
|
489
|
+
target,
|
|
490
|
+
readControls: built.readControls,
|
|
491
|
+
nestedWith: extractNestedWith(withRel),
|
|
492
|
+
stages: built.stages
|
|
471
493
|
};
|
|
472
494
|
}
|
|
495
|
+
/** Pairs of physical join fields: `outer` on the looking-up side, `inner` on the looked-up one. */
|
|
496
|
+
function joinPairs(outerMeta, outerFields, innerMeta, innerFields) {
|
|
497
|
+
return outerFields.map((field, i) => ({
|
|
498
|
+
outer: outerMeta.physicalPath(field),
|
|
499
|
+
inner: innerMeta.physicalPath(innerFields[i])
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
473
502
|
/** $lookup for TO relations (FK is on this table → target). Always single-valued. */
|
|
474
|
-
function buildToLookup(withRel, relation, foreignKeys) {
|
|
503
|
+
function buildToLookup(source, target, withRel, relation, foreignKeys) {
|
|
475
504
|
const fk = findFKForRelation(relation, foreignKeys);
|
|
476
|
-
|
|
477
|
-
const innerPipeline = buildLookupInnerPipeline(withRel, fk.targetFields);
|
|
478
|
-
const { letVars, exprMatch } = buildLookupJoin(fk.localFields, fk.targetFields, "fk_");
|
|
479
|
-
return {
|
|
480
|
-
stages: [{ $lookup: {
|
|
481
|
-
from: fk.targetTable,
|
|
482
|
-
let: letVars,
|
|
483
|
-
pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
|
|
484
|
-
as: withRel.name
|
|
485
|
-
} }, { $unwind: {
|
|
486
|
-
path: `$${withRel.name}`,
|
|
487
|
-
preserveNullAndEmptyArrays: true
|
|
488
|
-
} }],
|
|
489
|
-
isArray: false
|
|
490
|
-
};
|
|
505
|
+
return fk ? directLookup(source, target, withRel, relation, fk.localFields, fk.targetFields, "fk_", false) : void 0;
|
|
491
506
|
}
|
|
492
507
|
/** $lookup for FROM relations (FK is on target → this table). */
|
|
493
|
-
function buildFromLookup(
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
508
|
+
function buildFromLookup(source, target, withRel, relation) {
|
|
509
|
+
const remoteFK = findRemoteFK(target, source.tableName, relation.alias);
|
|
510
|
+
return remoteFK ? directLookup(source, target, withRel, relation, remoteFK.targetFields, remoteFK.fields, "pk_", relation.isArray) : void 0;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* The correlated `$lookup` of a TO / FROM relation: `sourceFields` of this
|
|
514
|
+
* table paired with `targetFields` of the target, unwound when single-valued.
|
|
515
|
+
*/
|
|
516
|
+
function directLookup(source, target, withRel, relation, sourceFields, targetFields, prefix, isArray) {
|
|
517
|
+
const pairs = joinPairs(source.getMetadata(), sourceFields, target.getMetadata(), targetFields);
|
|
518
|
+
const inner = buildLookupInnerPipeline(target, withRel, relation, pairs.map((p) => p.inner));
|
|
519
|
+
const join = correlate(prefix, pairs);
|
|
503
520
|
const stages = [{ $lookup: {
|
|
504
|
-
from:
|
|
505
|
-
let:
|
|
506
|
-
pipeline: [
|
|
521
|
+
from: collectionOf(target),
|
|
522
|
+
let: join.let,
|
|
523
|
+
pipeline: [
|
|
524
|
+
...join.stages,
|
|
525
|
+
...inner.filter,
|
|
526
|
+
...inner.page
|
|
527
|
+
],
|
|
507
528
|
as: withRel.name
|
|
508
529
|
} }];
|
|
509
|
-
if (!
|
|
530
|
+
if (!isArray) stages.push({ $unwind: {
|
|
510
531
|
path: `$${withRel.name}`,
|
|
511
532
|
preserveNullAndEmptyArrays: true
|
|
512
533
|
} });
|
|
513
534
|
return {
|
|
514
535
|
stages,
|
|
515
|
-
isArray
|
|
536
|
+
isArray,
|
|
537
|
+
readControls: inner.readControls
|
|
516
538
|
};
|
|
517
539
|
}
|
|
518
540
|
/** $lookup for VIA relations (M:N through junction table). Always array. */
|
|
519
|
-
function buildViaLookup(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const junctionMeta = tableResolver(junctionType);
|
|
524
|
-
if (!junctionMeta) return;
|
|
525
|
-
const junctionTableName = tableNameOf(junctionType);
|
|
526
|
-
const targetTableName = tableNameOf(relation.targetType());
|
|
527
|
-
const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
|
|
541
|
+
function buildViaLookup(source, target, withRel, relation, tableResolver) {
|
|
542
|
+
const junction = resolveReadable(tableResolver, relation.viaType?.());
|
|
543
|
+
if (!junction) return;
|
|
544
|
+
const fkToThis = findRemoteFK(junction, source.tableName);
|
|
528
545
|
if (!fkToThis) return;
|
|
529
|
-
const fkToTarget = findRemoteFK(
|
|
546
|
+
const fkToTarget = findRemoteFK(junction, tableNameOf(relation.targetType()));
|
|
530
547
|
if (!fkToTarget) return;
|
|
531
|
-
const
|
|
532
|
-
const
|
|
548
|
+
const sourceMeta = source.getMetadata();
|
|
549
|
+
const junctionMeta = junction.getMetadata();
|
|
550
|
+
const targetMeta = target.getMetadata();
|
|
551
|
+
const toSource = correlate("pk_", joinPairs(sourceMeta, fkToThis.targetFields, junctionMeta, fkToThis.fields));
|
|
552
|
+
const toTargetPairs = joinPairs(junctionMeta, fkToTarget.fields, targetMeta, fkToTarget.targetFields);
|
|
553
|
+
const toTarget = correlate("fk_", toTargetPairs);
|
|
554
|
+
const inner = buildLookupInnerPipeline(target, withRel, relation, toTargetPairs.map((p) => p.inner));
|
|
555
|
+
const junctionFilter = relationStaticFilter(relation, withRel.name).junction;
|
|
556
|
+
const junctionStages = junctionFilter ? filterStages(junction, junction._translateForAdapter({ filter: junctionFilter }).filter) : [];
|
|
533
557
|
return {
|
|
534
558
|
stages: [{ $lookup: {
|
|
535
|
-
from:
|
|
536
|
-
let:
|
|
559
|
+
from: collectionOf(junction),
|
|
560
|
+
let: toSource.let,
|
|
537
561
|
pipeline: [
|
|
538
|
-
|
|
562
|
+
...toSource.stages,
|
|
563
|
+
...junctionStages,
|
|
539
564
|
{ $lookup: {
|
|
540
|
-
from:
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
pipeline: innerPipeline,
|
|
565
|
+
from: collectionOf(target),
|
|
566
|
+
let: toTarget.let,
|
|
567
|
+
pipeline: [...toTarget.stages, ...inner.filter],
|
|
544
568
|
as: "__target"
|
|
545
569
|
} },
|
|
546
570
|
{ $unwind: {
|
|
547
571
|
path: "$__target",
|
|
548
572
|
preserveNullAndEmptyArrays: false
|
|
549
573
|
} },
|
|
550
|
-
{ $replaceRoot: { newRoot: "$__target" } }
|
|
574
|
+
{ $replaceRoot: { newRoot: "$__target" } },
|
|
575
|
+
...inner.page
|
|
551
576
|
],
|
|
552
577
|
as: withRel.name
|
|
553
578
|
} }],
|
|
554
|
-
isArray: true
|
|
579
|
+
isArray: true,
|
|
580
|
+
readControls: inner.readControls
|
|
555
581
|
};
|
|
556
582
|
}
|
|
557
|
-
/**
|
|
558
|
-
function
|
|
559
|
-
|
|
583
|
+
/** Stages applying a translated filter on `readable`'s collection (none when empty). */
|
|
584
|
+
function filterStages(readable, filter) {
|
|
585
|
+
if (!filter || Object.keys(filter).length === 0) return [];
|
|
586
|
+
return mongoFilterStages(filter, { collation: collationOfAdapter(readable.getAdapter()) });
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The `$with` sub-query on the related table: its filter AND the target part
|
|
590
|
+
* of the relation's `@db.rel.filter`, plus `$sort` / `$skip` / `$limit` /
|
|
591
|
+
* `$select` — translated by the TARGET readable (physical names, value
|
|
592
|
+
* formatters, relational predicates resolved). `requiredFields` (physical
|
|
593
|
+
* join keys) are always projected.
|
|
594
|
+
*/
|
|
595
|
+
function buildLookupInnerPipeline(target, withRel, relation, requiredFields) {
|
|
560
596
|
const flatRel = withRel;
|
|
561
597
|
const nested = withRel.controls || {};
|
|
562
|
-
const filter = withRel.filter;
|
|
563
598
|
const sort = nested.$sort || flatRel.$sort;
|
|
564
599
|
const limit = nested.$limit ?? flatRel.$limit;
|
|
565
600
|
const skip = nested.$skip ?? flatRel.$skip;
|
|
566
601
|
const select = nested.$select || flatRel.$select;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (
|
|
570
|
-
if (
|
|
571
|
-
const
|
|
602
|
+
const statics = relationStaticFilter(relation, withRel.name);
|
|
603
|
+
const controls = {};
|
|
604
|
+
if (sort) controls.$sort = sort;
|
|
605
|
+
if (select) controls.$select = select;
|
|
606
|
+
const translated = target._translateForAdapter({
|
|
607
|
+
filter: andFilters(statics.target, withRel.filter),
|
|
608
|
+
controls
|
|
609
|
+
});
|
|
610
|
+
const page = [];
|
|
611
|
+
if (translated.controls?.$sort) page.push({ $sort: translated.controls.$sort });
|
|
612
|
+
if (skip) page.push({ $skip: skip });
|
|
613
|
+
if (limit !== null && limit !== void 0) page.push({ $limit: limit });
|
|
614
|
+
const projection = translated.controls?.$select?.asProjection;
|
|
572
615
|
if (projection) {
|
|
573
616
|
const project = { ...projection };
|
|
574
617
|
if (Object.values(project).some((flag) => flag === 1 || flag === true)) {
|
|
575
618
|
for (const f of requiredFields) project[f] = 1;
|
|
576
619
|
if (!("_id" in projection) && !requiredFields.includes("_id")) project._id = 0;
|
|
577
620
|
} else for (const f of requiredFields) delete project[f];
|
|
578
|
-
if (Object.keys(project).length > 0)
|
|
621
|
+
if (Object.keys(project).length > 0) page.push({ $project: dedupeProjection(project) });
|
|
579
622
|
}
|
|
580
|
-
return
|
|
623
|
+
return {
|
|
624
|
+
filter: filterStages(target, translated.filter),
|
|
625
|
+
page,
|
|
626
|
+
readControls: select ? { $select: select } : {}
|
|
627
|
+
};
|
|
581
628
|
}
|
|
582
629
|
/** Extracts nested $with from a WithRelation's controls. */
|
|
583
630
|
function extractNestedWith(withRel) {
|
|
@@ -586,15 +633,10 @@ function extractNestedWith(withRel) {
|
|
|
586
633
|
return nestedWith && nestedWith.length > 0 ? nestedWith : void 0;
|
|
587
634
|
}
|
|
588
635
|
/** Post-processes nested $with by delegating to the target table's own relation loading. */
|
|
589
|
-
async function loadNestedRelations(rows, relMeta
|
|
590
|
-
if (!tableResolver) return;
|
|
636
|
+
async function loadNestedRelations(rows, relMeta) {
|
|
591
637
|
const tasks = [];
|
|
592
638
|
for (const meta of relMeta) {
|
|
593
639
|
if (!meta.nestedWith || meta.nestedWith.length === 0) continue;
|
|
594
|
-
const targetType = meta.relation.targetType();
|
|
595
|
-
if (!targetType) continue;
|
|
596
|
-
const targetTable = tableResolver(targetType);
|
|
597
|
-
if (!targetTable) continue;
|
|
598
640
|
const subRows = [];
|
|
599
641
|
for (const row of rows) {
|
|
600
642
|
const val = row[meta.name];
|
|
@@ -602,21 +644,44 @@ async function loadNestedRelations(rows, relMeta, tableResolver) {
|
|
|
602
644
|
else if (val && typeof val === "object") subRows.push(val);
|
|
603
645
|
}
|
|
604
646
|
if (subRows.length === 0) continue;
|
|
605
|
-
tasks.push(
|
|
647
|
+
tasks.push(meta.target.loadRelations(subRows, meta.nestedWith));
|
|
606
648
|
}
|
|
607
649
|
await Promise.all(tasks);
|
|
608
650
|
}
|
|
609
|
-
/**
|
|
610
|
-
|
|
651
|
+
/**
|
|
652
|
+
* Replaces one relation's loaded documents on every aggregation result with
|
|
653
|
+
* the related table's LOGICAL rows (one conversion call for all of them).
|
|
654
|
+
*/
|
|
655
|
+
async function toLogicalRows(results, meta) {
|
|
656
|
+
const physical = [];
|
|
657
|
+
const spans = [];
|
|
658
|
+
for (const doc of results) {
|
|
659
|
+
const value = doc[meta.name];
|
|
660
|
+
const list = Array.isArray(value) ? value : value && typeof value === "object" ? [value] : [];
|
|
661
|
+
spans.push([
|
|
662
|
+
doc,
|
|
663
|
+
physical.length,
|
|
664
|
+
list.length
|
|
665
|
+
]);
|
|
666
|
+
physical.push(...list);
|
|
667
|
+
}
|
|
668
|
+
const logical = physical.length > 0 ? await meta.target._rowsFromAdapter(physical, meta.readControls) : [];
|
|
669
|
+
for (const [doc, start, length] of spans) doc[meta.name] = meta.isArray ? logical.slice(start, start + length) : length > 0 ? logical[start] : null;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Merges aggregation results back onto the original rows by PK — the
|
|
673
|
+
* results carry the PHYSICAL key fields (`physicalKeys`), the rows the
|
|
674
|
+
* logical ones (`primaryKeys`).
|
|
675
|
+
*/
|
|
676
|
+
function mergeRelationResults(rows, results, primaryKeys, physicalKeys, relMeta) {
|
|
611
677
|
const resultIndex = /* @__PURE__ */ new Map();
|
|
612
|
-
for (const doc of results) resultIndex.set(buildPKKey(
|
|
678
|
+
for (const doc of results) resultIndex.set(buildPKKey(physicalKeys, doc), doc);
|
|
613
679
|
for (const row of rows) {
|
|
614
680
|
const enriched = resultIndex.get(buildPKKey(primaryKeys, row));
|
|
615
|
-
for (const meta of relMeta)
|
|
616
|
-
const value = enriched[meta.name];
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
} else row[meta.name] = meta.isArray ? [] : null;
|
|
681
|
+
for (const meta of relMeta) {
|
|
682
|
+
const value = enriched?.[meta.name];
|
|
683
|
+
row[meta.name] = value ?? (meta.isArray ? [] : null);
|
|
684
|
+
}
|
|
620
685
|
}
|
|
621
686
|
}
|
|
622
687
|
/** Finds FK entry for a TO relation from this table's foreignKeys map. */
|
|
@@ -643,6 +708,10 @@ function findRemoteFK(target, thisTableName, alias) {
|
|
|
643
708
|
}
|
|
644
709
|
//#endregion
|
|
645
710
|
//#region src/lib/mongo-search.ts
|
|
711
|
+
/** Renders a host's filter with relational predicates — its 'nocase' fields per field. */
|
|
712
|
+
function filterOptionsOf(host) {
|
|
713
|
+
return { collation: collationOfAdapter(host) };
|
|
714
|
+
}
|
|
646
715
|
/**
|
|
647
716
|
* Returns available search indexes as generic metadata for UI. The text
|
|
648
717
|
* default is the `DEFAULT` entry (an alias of the index `search()` answers
|
|
@@ -735,7 +804,7 @@ const DISTANCE_FIELD = "__atscript_distance";
|
|
|
735
804
|
/** Distance-ranked geo search via a leading `$geoNear` aggregation stage. */
|
|
736
805
|
async function geoSearchImpl(host, point, query, indexName) {
|
|
737
806
|
const controls = query.controls || {};
|
|
738
|
-
const pipeline =
|
|
807
|
+
const pipeline = buildGeoNearStages(host, point, query, indexName);
|
|
739
808
|
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
740
809
|
pipeline.push({ $limit: controls.$limit || 1e3 });
|
|
741
810
|
pushGeoProjection(pipeline, query.controls);
|
|
@@ -749,7 +818,7 @@ async function geoSearchWithCountImpl(host, point, query, indexName) {
|
|
|
749
818
|
if (controls.$skip) dataStages.push({ $skip: controls.$skip });
|
|
750
819
|
if (controls.$limit) dataStages.push({ $limit: controls.$limit });
|
|
751
820
|
pushGeoProjection(dataStages, query.controls);
|
|
752
|
-
const pipeline = [
|
|
821
|
+
const pipeline = [...buildGeoNearStages(host, point, query, indexName), { $facet: {
|
|
753
822
|
data: dataStages,
|
|
754
823
|
meta: [{ $count: "count" }]
|
|
755
824
|
} }];
|
|
@@ -760,9 +829,15 @@ async function geoSearchWithCountImpl(host, point, query, indexName) {
|
|
|
760
829
|
count: result[0]?.meta?.[0]?.count || 0
|
|
761
830
|
};
|
|
762
831
|
}
|
|
763
|
-
/**
|
|
764
|
-
|
|
832
|
+
/**
|
|
833
|
+
* Builds the leading `$geoNear` stage; the filter rides in its `query`
|
|
834
|
+
* option. A filter with relational predicates keeps its predicate-free
|
|
835
|
+
* conjuncts there and applies the rest (predicate `$lookup`s + `$match`)
|
|
836
|
+
* right after — exact, since `$geoNear` has no result cut-off of its own.
|
|
837
|
+
*/
|
|
838
|
+
function buildGeoNearStages(host, point, query, indexName) {
|
|
765
839
|
const controls = query.controls || {};
|
|
840
|
+
const plan = containsRelationFilter(query.filter) ? buildMongoQuery(query.filter, filterOptionsOf(host)) : void 0;
|
|
766
841
|
const geoNear = {
|
|
767
842
|
near: {
|
|
768
843
|
type: "Point",
|
|
@@ -771,11 +846,14 @@ function buildGeoNearStage(host, point, query, indexName) {
|
|
|
771
846
|
distanceField: DISTANCE_FIELD,
|
|
772
847
|
spherical: true,
|
|
773
848
|
key: resolveGeoKeyPath(host, indexName),
|
|
774
|
-
query: buildMongoFilter(query.filter)
|
|
849
|
+
query: plan ? plan.pre ?? {} : buildMongoFilter(query.filter)
|
|
775
850
|
};
|
|
776
851
|
if (typeof controls.$maxDistance === "number") geoNear.maxDistance = controls.$maxDistance;
|
|
777
852
|
if (typeof controls.$minDistance === "number") geoNear.minDistance = controls.$minDistance;
|
|
778
|
-
return { $geoNear: geoNear }
|
|
853
|
+
return plan ? [{ $geoNear: geoNear }, ...planStages({
|
|
854
|
+
...plan,
|
|
855
|
+
pre: void 0
|
|
856
|
+
})] : [{ $geoNear: geoNear }];
|
|
779
857
|
}
|
|
780
858
|
/** Resolves the physical field path of the targeted geo index. */
|
|
781
859
|
function resolveGeoKeyPath(host, indexName) {
|
|
@@ -984,14 +1062,13 @@ function buildVectorSearchStage(host, vector, indexName, limit) {
|
|
|
984
1062
|
}
|
|
985
1063
|
/** Runs a search/vector pipeline and returns results. Shared by search + vectorSearch. */
|
|
986
1064
|
async function runSearchPipeline(host, stage, query, label, threshold, classicText = false) {
|
|
987
|
-
const filter = buildMongoFilter(query.filter);
|
|
988
1065
|
const controls = query.controls || {};
|
|
989
1066
|
const pipeline = [stage];
|
|
990
1067
|
if (threshold !== void 0) {
|
|
991
1068
|
pipeline.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
|
|
992
1069
|
pipeline.push({ $match: { _score: { $gte: threshold } } });
|
|
993
1070
|
} else if (classicText) pipeline.push({ $addFields: { _score: { $meta: "textScore" } } });
|
|
994
|
-
pipeline.push(
|
|
1071
|
+
pipeline.push(...mongoFilterStages(query.filter, filterOptionsOf(host)));
|
|
995
1072
|
if (controls.$sort) pipeline.push({ $sort: controls.$sort });
|
|
996
1073
|
else if (classicText) pipeline.push({ $sort: { _score: -1 } });
|
|
997
1074
|
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
@@ -1006,7 +1083,6 @@ async function runSearchPipeline(host, stage, query, label, threshold, classicTe
|
|
|
1006
1083
|
}
|
|
1007
1084
|
/** Runs a search/vector pipeline with $facet for count. Shared by searchWithCount + vectorSearchWithCount. */
|
|
1008
1085
|
async function runSearchWithCountPipeline(host, stage, query, label, threshold, classicText = false) {
|
|
1009
|
-
const filter = buildMongoFilter(query.filter);
|
|
1010
1086
|
const controls = query.controls || {};
|
|
1011
1087
|
const preStages = [];
|
|
1012
1088
|
if (threshold !== void 0) {
|
|
@@ -1025,7 +1101,7 @@ async function runSearchWithCountPipeline(host, stage, query, label, threshold,
|
|
|
1025
1101
|
const pipeline = [
|
|
1026
1102
|
stage,
|
|
1027
1103
|
...preStages,
|
|
1028
|
-
|
|
1104
|
+
...mongoFilterStages(query.filter, filterOptionsOf(host)),
|
|
1029
1105
|
{ $facet: {
|
|
1030
1106
|
data: dataStages,
|
|
1031
1107
|
meta: [{ $count: "count" }]
|
|
@@ -1088,11 +1164,28 @@ function buildViewPipeline(view) {
|
|
|
1088
1164
|
/** A column's source field as an aggregation operand. */
|
|
1089
1165
|
const colSourceField = (col) => `$${prefixes.get(col.sourceTable) ?? ""}${col.sourceColumn}`;
|
|
1090
1166
|
const colValue = (col) => col.nullable ? orNull(colSourceField(col)) : colSourceField(col);
|
|
1167
|
+
const byPath = new Map(columns.map((c) => [c.viewPath, c]));
|
|
1168
|
+
const rendered = /* @__PURE__ */ new Map();
|
|
1169
|
+
const computedExpr = (col, leaf) => {
|
|
1170
|
+
let out = rendered.get(col.viewPath);
|
|
1171
|
+
if (out === void 0) {
|
|
1172
|
+
out = exprToMongo(col.expr, (path) => {
|
|
1173
|
+
const c = byPath.get(path);
|
|
1174
|
+
return c.expr === void 0 ? { $toDouble: leaf(c) } : computedExpr(c, leaf);
|
|
1175
|
+
});
|
|
1176
|
+
rendered.set(col.viewPath, out);
|
|
1177
|
+
}
|
|
1178
|
+
return out;
|
|
1179
|
+
};
|
|
1091
1180
|
if (columns.some((c) => c.aggFn)) {
|
|
1092
1181
|
const group = { _id: {} };
|
|
1093
1182
|
const project = { _id: 0 };
|
|
1094
1183
|
const distinctSizes = {};
|
|
1095
|
-
|
|
1184
|
+
const computed = {};
|
|
1185
|
+
for (const col of columns) if (col.expr !== void 0) {
|
|
1186
|
+
computed[col.viewColumn] = computedExpr(col, (c) => c.aggFn ? `$${c.viewColumn}` : `$_id.${c.viewColumn}`);
|
|
1187
|
+
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
1188
|
+
} else if (col.aggFn) {
|
|
1096
1189
|
const predicate = col.aggFilter ? queryNodeToExpr(col.aggFilter, (ref) => `$${pathOf(ref)}`) : void 0;
|
|
1097
1190
|
group[col.viewColumn] = buildAccumulator(col.aggFn, col.aggField === "*" ? "*" : colSourceField(col), predicate, col.viewColumn);
|
|
1098
1191
|
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
@@ -1103,11 +1196,12 @@ function buildViewPipeline(view) {
|
|
|
1103
1196
|
}
|
|
1104
1197
|
pipeline.push({ $group: group });
|
|
1105
1198
|
if (Object.keys(distinctSizes).length > 0) pipeline.push({ $addFields: distinctSizes });
|
|
1106
|
-
if (
|
|
1199
|
+
if (Object.keys(computed).length > 0) pipeline.push({ $addFields: computed });
|
|
1200
|
+
if (plan.having) pipeline.push({ $match: havingMatch(plan.having, byPath) });
|
|
1107
1201
|
pipeline.push({ $project: project });
|
|
1108
1202
|
} else {
|
|
1109
1203
|
const project = { _id: 0 };
|
|
1110
|
-
for (const col of columns) project[col.viewColumn] = colValue(col);
|
|
1204
|
+
for (const col of columns) project[col.viewColumn] = col.expr === void 0 ? colValue(col) : computedExpr(col, colValue);
|
|
1111
1205
|
pipeline.push({ $project: project });
|
|
1112
1206
|
}
|
|
1113
1207
|
return pipeline;
|
|
@@ -1128,7 +1222,7 @@ function buildViewPipeline(view) {
|
|
|
1128
1222
|
function buildLookup(view, join, outer) {
|
|
1129
1223
|
const scope = join.scope;
|
|
1130
1224
|
const as = joinedAs(scope);
|
|
1131
|
-
const simple = simpleJoinFields(view, join, outer);
|
|
1225
|
+
const simple = join.first ? void 0 : simpleJoinFields(view, join, outer);
|
|
1132
1226
|
if (simple) return { $lookup: {
|
|
1133
1227
|
from: join.targetTable,
|
|
1134
1228
|
...simple,
|
|
@@ -1150,11 +1244,16 @@ function buildLookup(view, join, outer) {
|
|
|
1150
1244
|
}
|
|
1151
1245
|
return `$$${varName}`;
|
|
1152
1246
|
};
|
|
1153
|
-
const
|
|
1247
|
+
const pipeline = [{ $match: { $expr: queryNodeToExpr(join.condition, pathOf) } }];
|
|
1248
|
+
if (join.first) {
|
|
1249
|
+
const sort = {};
|
|
1250
|
+
for (const { ref, desc } of join.first.order) sort[view.resolveRefSource(ref).source.column] = desc ? -1 : 1;
|
|
1251
|
+
pipeline.push({ $sort: sort }, { $limit: 1 });
|
|
1252
|
+
}
|
|
1154
1253
|
return { $lookup: {
|
|
1155
1254
|
from: join.targetTable,
|
|
1156
1255
|
...varByPath.size > 0 ? { let: letVars } : {},
|
|
1157
|
-
pipeline
|
|
1256
|
+
pipeline,
|
|
1158
1257
|
as
|
|
1159
1258
|
} };
|
|
1160
1259
|
}
|
|
@@ -1204,12 +1303,11 @@ function viewMatch(node, pathOf) {
|
|
|
1204
1303
|
* `@db.view.having` as a `$match` after `$group`: refs name view fields —
|
|
1205
1304
|
* aggregates are top-level, dimensions under `_id`.
|
|
1206
1305
|
*/
|
|
1207
|
-
function havingMatch(node,
|
|
1208
|
-
const colMap = new Map(columns.map((c) => [c.viewPath, c]));
|
|
1306
|
+
function havingMatch(node, colMap) {
|
|
1209
1307
|
return viewMatch(node, (ref) => {
|
|
1210
1308
|
const col = ref.type ? void 0 : colMap.get(ref.field);
|
|
1211
1309
|
if (!col) return ref.field;
|
|
1212
|
-
return col.aggFn ? col.viewColumn : `_id.${col.viewColumn}`;
|
|
1310
|
+
return col.aggFn || col.expr !== void 0 ? col.viewColumn : `_id.${col.viewColumn}`;
|
|
1213
1311
|
});
|
|
1214
1312
|
}
|
|
1215
1313
|
//#endregion
|
|
@@ -1699,6 +1797,11 @@ function objectIdFromStorage(value) {
|
|
|
1699
1797
|
if (Array.isArray(value)) return value.map(objectIdFromStorage);
|
|
1700
1798
|
return value;
|
|
1701
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Documents per `_id $in` write when a mutation filter holds relational
|
|
1802
|
+
* predicates (see {@link MongoAdapter.supportsRelationFilters}).
|
|
1803
|
+
*/
|
|
1804
|
+
const REL_WRITE_BATCH = 1e3;
|
|
1702
1805
|
var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
1703
1806
|
db;
|
|
1704
1807
|
client;
|
|
@@ -1742,6 +1845,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1742
1845
|
return this.client ?? this.db;
|
|
1743
1846
|
}
|
|
1744
1847
|
/**
|
|
1848
|
+
* Same store = same client (or database handle) AND the same database: a
|
|
1849
|
+
* `$lookup` only reads collections of the pipeline's own database, and one
|
|
1850
|
+
* client may serve several (since 0.1.147).
|
|
1851
|
+
*/
|
|
1852
|
+
sharesStoreWith(other) {
|
|
1853
|
+
return other instanceof MongoAdapter && other._transactionOwner() === this._transactionOwner() && other.db.databaseName === this.db.databaseName;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1745
1856
|
* Per-client cache: whether transactions are unavailable (standalone MongoDB).
|
|
1746
1857
|
* Shared across all adapter instances for the same client so topology is probed once.
|
|
1747
1858
|
*/
|
|
@@ -1796,19 +1907,22 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1796
1907
|
if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
|
|
1797
1908
|
return this._collection;
|
|
1798
1909
|
}
|
|
1799
|
-
aggregatePipeline(pipeline) {
|
|
1800
|
-
return this.collection.aggregate(pipeline,
|
|
1910
|
+
aggregatePipeline(pipeline, options) {
|
|
1911
|
+
return this.collection.aggregate(pipeline, {
|
|
1912
|
+
...options,
|
|
1913
|
+
...this._getSessionOpts()
|
|
1914
|
+
});
|
|
1801
1915
|
}
|
|
1802
1916
|
async aggregate(query) {
|
|
1803
1917
|
const { buildAggregatePipeline, buildCountPipeline } = await import("./agg.mjs");
|
|
1804
1918
|
const searchStage = buildAggregateSearchStage(this, query.controls);
|
|
1805
1919
|
if (query.controls?.$count) {
|
|
1806
|
-
const pipeline = buildCountPipeline(query, searchStage);
|
|
1920
|
+
const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
|
|
1807
1921
|
this._log("aggregate (count)", pipeline);
|
|
1808
1922
|
const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
|
|
1809
1923
|
return result.length > 0 ? result : [{ count: 0 }];
|
|
1810
1924
|
}
|
|
1811
|
-
const pipeline = buildAggregatePipeline(query, searchStage);
|
|
1925
|
+
const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
|
|
1812
1926
|
this._log("aggregate", pipeline);
|
|
1813
1927
|
return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
|
|
1814
1928
|
}
|
|
@@ -1846,7 +1960,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1846
1960
|
supportsNativePatch() {
|
|
1847
1961
|
return true;
|
|
1848
1962
|
}
|
|
1849
|
-
/**
|
|
1963
|
+
/** Every unit, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
|
|
1850
1964
|
calendarBucketUnits() {
|
|
1851
1965
|
return ALL_BUCKET_UNITS;
|
|
1852
1966
|
}
|
|
@@ -1854,6 +1968,10 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1854
1968
|
aggregateFns() {
|
|
1855
1969
|
return ALL_AGGREGATE_FNS;
|
|
1856
1970
|
}
|
|
1971
|
+
/** Computed view columns and first-row joins. */
|
|
1972
|
+
viewCapabilities() {
|
|
1973
|
+
return ALL_VIEW_CAPABILITIES;
|
|
1974
|
+
}
|
|
1857
1975
|
/**
|
|
1858
1976
|
* See BaseDbAdapter.viewRenderRevision. 2 = 0.1.137 (null-guarded field filters, nested document paths).
|
|
1859
1977
|
* BUMP whenever `buildViewPipeline` output changes for an unchanged view definition.
|
|
@@ -1911,6 +2029,58 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1911
2029
|
supportsNativeRelations() {
|
|
1912
2030
|
return true;
|
|
1913
2031
|
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Relational predicates (`{ nav: { $some | $none: … } }`) render as
|
|
2034
|
+
* correlated `$lookup` stages, so a read whose filter holds one runs as an
|
|
2035
|
+
* aggregation pipeline (find / count / findManyWithCount / grouped
|
|
2036
|
+
* aggregate / search / geo); predicate-free reads keep their plain
|
|
2037
|
+
* `find` / `countDocuments` path.
|
|
2038
|
+
*
|
|
2039
|
+
* Writes (`updateMany` / `replaceMany` / `deleteMany` and the single-row
|
|
2040
|
+
* variants, whose scope may carry a predicate) first resolve the matching
|
|
2041
|
+
* `_id`s through that pipeline, then write by `_id` in batches of
|
|
2042
|
+
* {@link REL_WRITE_BATCH} — re-checking the predicate-free part of the
|
|
2043
|
+
* filter. Inside an active transaction both steps share its session and
|
|
2044
|
+
* are atomic; without one there is a window between resolving and writing
|
|
2045
|
+
* in which a related document can change, so a written document may no
|
|
2046
|
+
* longer satisfy the predicate (or a newly matching one is missed).
|
|
2047
|
+
*
|
|
2048
|
+
* Collation: such a pipeline runs without an operation-wide `collation`
|
|
2049
|
+
* (it would govern the join keys and every related field too); each
|
|
2050
|
+
* table's `'nocase'` fields are compared case-insensitively explicitly
|
|
2051
|
+
* ({@link TMongoFilterOptions.collation}) — on reads and on these writes.
|
|
2052
|
+
*
|
|
2053
|
+
* @since 0.1.147
|
|
2054
|
+
*/
|
|
2055
|
+
supportsRelationFilters(_mode) {
|
|
2056
|
+
return true;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* The `@db.column.collate` of a field (physical or logical path), or
|
|
2060
|
+
* `undefined` for a byte-wise one — how predicate pipelines render this
|
|
2061
|
+
* table's `'nocase'` comparisons ({@link TMongoFilterOptions.collation}).
|
|
2062
|
+
*
|
|
2063
|
+
* @since 0.1.147
|
|
2064
|
+
*/
|
|
2065
|
+
fieldCollation(field) {
|
|
2066
|
+
this._table.flatMap;
|
|
2067
|
+
return this._collateFields?.get(field);
|
|
2068
|
+
}
|
|
2069
|
+
/** Filter-rendering options of a pipeline with relational predicates on this table. */
|
|
2070
|
+
get _predicateFilterOpts() {
|
|
2071
|
+
return { collation: collationOfAdapter(this) };
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Operation options of a read: the request collation for a predicate-free
|
|
2075
|
+
* filter ({@link _getCollationOpts}); none with relational predicates —
|
|
2076
|
+
* those pipelines render collation per field ({@link _predicateFilterOpts}).
|
|
2077
|
+
*/
|
|
2078
|
+
_readOpts(query) {
|
|
2079
|
+
return containsRelationFilter(query.filter) ? this._getSessionOpts() : {
|
|
2080
|
+
...this._getCollationOpts(query),
|
|
2081
|
+
...this._getSessionOpts()
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
1914
2084
|
async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
|
|
1915
2085
|
return loadRelationsImpl(this, rows, withRelations, relations, foreignKeys, tableResolver);
|
|
1916
2086
|
}
|
|
@@ -1923,7 +2093,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1923
2093
|
};
|
|
1924
2094
|
}
|
|
1925
2095
|
async nativePatch(filter, patch, ops, expectedVersion) {
|
|
1926
|
-
const mongoFilter = this._buildCasFilter(filter, expectedVersion, "nativePatch");
|
|
2096
|
+
const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "nativePatch");
|
|
2097
|
+
if (!mongoFilter) return {
|
|
2098
|
+
matchedCount: 0,
|
|
2099
|
+
modifiedCount: 0
|
|
2100
|
+
};
|
|
1927
2101
|
const versionColumn = this._table.versionColumnPhysical;
|
|
1928
2102
|
const effectiveOps = versionColumn !== void 0 ? {
|
|
1929
2103
|
...ops,
|
|
@@ -1961,10 +2135,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1961
2135
|
}
|
|
1962
2136
|
onFieldScanned(field, _type, metadata) {
|
|
1963
2137
|
if (field === "_id") this._hasExplicitId = true;
|
|
1964
|
-
if (field !== "_id" && metadata.has("meta.id"))
|
|
1965
|
-
this._addMongoIndexField("unique", "__pk", field);
|
|
1966
|
-
this._pendingUniqueFields.push(field);
|
|
1967
|
-
}
|
|
2138
|
+
if (field !== "_id" && metadata.has("meta.id")) this._pendingUniqueFields.push(field);
|
|
1968
2139
|
for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
|
|
1969
2140
|
indexName: index.indexName,
|
|
1970
2141
|
field,
|
|
@@ -2031,6 +2202,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2031
2202
|
};
|
|
2032
2203
|
}
|
|
2033
2204
|
onAfterFlatten() {
|
|
2205
|
+
const meta = this._table.getMetadata();
|
|
2206
|
+
for (const field of this._pendingUniqueFields) this._addMongoIndexField("unique", "__pk", meta.documentPath(field));
|
|
2034
2207
|
for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
|
|
2035
2208
|
this._pendingSearchFields = [];
|
|
2036
2209
|
for (const [key, value] of this._vectorFilters.entries()) {
|
|
@@ -2120,25 +2293,12 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2120
2293
|
return geoSearchWithCountImpl(this, point, query, indexName);
|
|
2121
2294
|
}
|
|
2122
2295
|
async findManyWithCount(query) {
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
const dataStages = [];
|
|
2126
|
-
if (controls.$sort) dataStages.push({ $sort: controls.$sort });
|
|
2127
|
-
if (controls.$skip) dataStages.push({ $skip: controls.$skip });
|
|
2128
|
-
if (controls.$limit) dataStages.push({ $limit: controls.$limit });
|
|
2129
|
-
if (controls.$select) {
|
|
2130
|
-
const projection = controls.$select.asProjection;
|
|
2131
|
-
if (projection) dataStages.push({ $project: dedupeProjection(projection) });
|
|
2132
|
-
}
|
|
2133
|
-
const pipeline = [{ $match: filter }, { $facet: {
|
|
2134
|
-
data: dataStages,
|
|
2296
|
+
const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $facet: {
|
|
2297
|
+
data: pageStages(query.controls),
|
|
2135
2298
|
meta: [{ $count: "count" }]
|
|
2136
2299
|
} }];
|
|
2137
2300
|
this._log("aggregate (findManyWithCount)", pipeline);
|
|
2138
|
-
const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline,
|
|
2139
|
-
...this._getCollationOpts(query),
|
|
2140
|
-
...this._getSessionOpts()
|
|
2141
|
-
}).toArray());
|
|
2301
|
+
const result = await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
|
|
2142
2302
|
return {
|
|
2143
2303
|
data: result[0]?.data || [],
|
|
2144
2304
|
count: result[0]?.meta[0]?.count || 0
|
|
@@ -2168,14 +2328,94 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2168
2328
|
* is supplied for a non-versioned table — fail loud at the adapter boundary
|
|
2169
2329
|
* rather than silently dropping the CAS predicate.
|
|
2170
2330
|
*/
|
|
2171
|
-
_buildCasFilter(filter, expectedVersion, op) {
|
|
2331
|
+
async _buildCasFilter(filter, expectedVersion, op) {
|
|
2172
2332
|
const versionColumn = this._table.versionColumnPhysical;
|
|
2173
2333
|
if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error(`${op}: expectedVersion requires versionColumn`);
|
|
2174
|
-
const mongoFilter =
|
|
2334
|
+
const mongoFilter = await this._writeFilterOne(filter);
|
|
2335
|
+
if (!mongoFilter) return;
|
|
2175
2336
|
if (expectedVersion !== void 0 && versionColumn !== void 0) mongoFilter[versionColumn] = expectedVersion;
|
|
2176
2337
|
return mongoFilter;
|
|
2177
2338
|
}
|
|
2178
2339
|
/**
|
|
2340
|
+
* The id-resolving pipeline of a write filter holding relational
|
|
2341
|
+
* predicates: the matching `_id`s (in the active transaction's session,
|
|
2342
|
+
* if any), each table's `'nocase'` fields compared like on reads
|
|
2343
|
+
* ({@link _predicateFilterOpts}). `pre` is the predicate-free part, which
|
|
2344
|
+
* every `_id`-based write re-checks — so a document that stopped matching
|
|
2345
|
+
* it in between is not written.
|
|
2346
|
+
*/
|
|
2347
|
+
_predicateWritePlan(filter) {
|
|
2348
|
+
const plan = buildMongoQuery(filter, this._predicateFilterOpts);
|
|
2349
|
+
return {
|
|
2350
|
+
pipeline: planStages(plan),
|
|
2351
|
+
pre: plan.pre
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
/** A write filter's `_id` filter (re-checking `pre`). */
|
|
2355
|
+
static _byIds(ids, pre) {
|
|
2356
|
+
const byId = ids.length === 1 ? { _id: ids[0] } : { _id: { $in: ids } };
|
|
2357
|
+
return pre ? { $and: [byId, pre] } : byId;
|
|
2358
|
+
}
|
|
2359
|
+
/** Single-document write over a predicate filter: the first match's `_id` filter (`undefined`: none). */
|
|
2360
|
+
async _predicateWriteFilterOne(filter) {
|
|
2361
|
+
const { pipeline, pre } = this._predicateWritePlan(filter);
|
|
2362
|
+
pipeline.push({ $limit: 1 }, { $project: { _id: 1 } });
|
|
2363
|
+
this._log("aggregate (write id)", pipeline);
|
|
2364
|
+
const [doc] = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
|
|
2365
|
+
return doc ? MongoAdapter._byIds([doc._id], pre) : void 0;
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Multi-document write over `filter`: a predicate-free filter is written
|
|
2369
|
+
* as is; one with predicates calls `write` with one `_id`-based filter per
|
|
2370
|
+
* batch of {@link REL_WRITE_BATCH} matching ids, streamed from the cursor
|
|
2371
|
+
* (never all ids in memory at once).
|
|
2372
|
+
*
|
|
2373
|
+
* The ids come sorted by `_id`, so writes made while the cursor is open
|
|
2374
|
+
* can never feed back into it (otherwise a document an update moves within
|
|
2375
|
+
* the index the cursor scans could be returned — and written — twice):
|
|
2376
|
+
* after the `$lookup`s that `$sort` consumes every match before the first
|
|
2377
|
+
* batch returns, and an `_id` order read from the `_id` index is stable
|
|
2378
|
+
* (`_id` never changes).
|
|
2379
|
+
*/
|
|
2380
|
+
async _forEachMatching(filter, write) {
|
|
2381
|
+
if (!containsRelationFilter(filter)) return write(buildMongoFilter(filter));
|
|
2382
|
+
const { pipeline, pre } = this._predicateWritePlan(filter);
|
|
2383
|
+
pipeline.push({ $project: { _id: 1 } }, { $sort: { _id: 1 } });
|
|
2384
|
+
this._log("aggregate (write ids)", pipeline);
|
|
2385
|
+
const cursor = this.aggregatePipeline(pipeline, { allowDiskUse: true }).batchSize(REL_WRITE_BATCH);
|
|
2386
|
+
try {
|
|
2387
|
+
const next = () => wrapInvalidQuery(() => cursor.next());
|
|
2388
|
+
let ids = [];
|
|
2389
|
+
for (let doc = await next(); doc; doc = await next()) {
|
|
2390
|
+
ids.push(doc._id);
|
|
2391
|
+
if (ids.length === REL_WRITE_BATCH) {
|
|
2392
|
+
await write(MongoAdapter._byIds(ids, pre));
|
|
2393
|
+
ids = [];
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
if (ids.length > 0) await write(MongoAdapter._byIds(ids, pre));
|
|
2397
|
+
} finally {
|
|
2398
|
+
await cursor.close();
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
/** Runs an update-shaped write over `filter` ({@link _forEachMatching}), counts summed. */
|
|
2402
|
+
async _updateMatching(filter, write) {
|
|
2403
|
+
const total = {
|
|
2404
|
+
matchedCount: 0,
|
|
2405
|
+
modifiedCount: 0
|
|
2406
|
+
};
|
|
2407
|
+
await this._forEachMatching(filter, async (mongoFilter) => {
|
|
2408
|
+
const result = await write(mongoFilter);
|
|
2409
|
+
total.matchedCount += result.matchedCount;
|
|
2410
|
+
total.modifiedCount += result.modifiedCount;
|
|
2411
|
+
});
|
|
2412
|
+
return total;
|
|
2413
|
+
}
|
|
2414
|
+
/** The filter of a single-document write — its predicates resolved to one `_id`. */
|
|
2415
|
+
_writeFilterOne(filter) {
|
|
2416
|
+
return containsRelationFilter(filter) ? this._predicateWriteFilterOne(filter) : buildMongoFilter(filter);
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2179
2419
|
* Wraps an async operation to catch MongoDB duplicate key errors
|
|
2180
2420
|
* (code 11000) and rethrow as structured `DbError` — every write path
|
|
2181
2421
|
* (insert, replace, update, patch, their `*Many` variants) goes through it,
|
|
@@ -2232,6 +2472,10 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2232
2472
|
};
|
|
2233
2473
|
}
|
|
2234
2474
|
async findOne(query) {
|
|
2475
|
+
if (containsRelationFilter(query.filter)) {
|
|
2476
|
+
const [row] = await this._aggregateFind(query, "findOne", 1);
|
|
2477
|
+
return row ?? null;
|
|
2478
|
+
}
|
|
2235
2479
|
const filter = buildMongoFilter(query.filter);
|
|
2236
2480
|
const opts = this._buildFindOptions(query.controls);
|
|
2237
2481
|
this._log("findOne", filter, opts);
|
|
@@ -2242,6 +2486,7 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2242
2486
|
}));
|
|
2243
2487
|
}
|
|
2244
2488
|
async findMany(query) {
|
|
2489
|
+
if (containsRelationFilter(query.filter)) return this._aggregateFind(query, "findMany");
|
|
2245
2490
|
const filter = buildMongoFilter(query.filter);
|
|
2246
2491
|
const opts = this._buildFindOptions(query.controls);
|
|
2247
2492
|
this._log("findMany", filter, opts);
|
|
@@ -2252,6 +2497,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2252
2497
|
}).toArray());
|
|
2253
2498
|
}
|
|
2254
2499
|
async count(query) {
|
|
2500
|
+
if (containsRelationFilter(query.filter)) {
|
|
2501
|
+
const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), { $count: "count" }];
|
|
2502
|
+
this._log("aggregate (count)", pipeline);
|
|
2503
|
+
return (await wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray()))[0]?.count ?? 0;
|
|
2504
|
+
}
|
|
2255
2505
|
const filter = buildMongoFilter(query.filter);
|
|
2256
2506
|
this._log("countDocuments", filter);
|
|
2257
2507
|
return this.collection.countDocuments(filter, {
|
|
@@ -2260,13 +2510,21 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2260
2510
|
});
|
|
2261
2511
|
}
|
|
2262
2512
|
async updateOne(filter, data, ops, expectedVersion) {
|
|
2263
|
-
const mongoFilter = this._buildCasFilter(filter, expectedVersion, "updateOne");
|
|
2513
|
+
const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "updateOne");
|
|
2514
|
+
if (!mongoFilter) return {
|
|
2515
|
+
matchedCount: 0,
|
|
2516
|
+
modifiedCount: 0
|
|
2517
|
+
};
|
|
2264
2518
|
const updateDoc = buildMongoUpdateDoc(data, ops, this._table.versionColumnPhysical);
|
|
2265
2519
|
this._log("updateOne", mongoFilter, updateDoc);
|
|
2266
2520
|
return this._wrapUpdate(() => this.collection.updateOne(mongoFilter, updateDoc, this._getSessionOpts()));
|
|
2267
2521
|
}
|
|
2268
2522
|
async replaceOne(filter, data, expectedVersion) {
|
|
2269
|
-
const mongoFilter = this._buildCasFilter(filter, expectedVersion, "replaceOne");
|
|
2523
|
+
const mongoFilter = await this._buildCasFilter(filter, expectedVersion, "replaceOne");
|
|
2524
|
+
if (!mongoFilter) return {
|
|
2525
|
+
matchedCount: 0,
|
|
2526
|
+
modifiedCount: 0
|
|
2527
|
+
};
|
|
2270
2528
|
const versionColumn = this._table.versionColumnPhysical;
|
|
2271
2529
|
this._log("replaceOne", mongoFilter, data);
|
|
2272
2530
|
if (versionColumn !== void 0) {
|
|
@@ -2279,27 +2537,34 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2279
2537
|
return this._wrapUpdate(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
|
|
2280
2538
|
}
|
|
2281
2539
|
async deleteOne(filter) {
|
|
2282
|
-
const mongoFilter =
|
|
2540
|
+
const mongoFilter = await this._writeFilterOne(filter);
|
|
2541
|
+
if (!mongoFilter) return { deletedCount: 0 };
|
|
2283
2542
|
this._log("deleteOne", mongoFilter);
|
|
2284
2543
|
return { deletedCount: (await this.collection.deleteOne(mongoFilter, this._getSessionOpts())).deletedCount };
|
|
2285
2544
|
}
|
|
2286
2545
|
async updateMany(filter, data, ops) {
|
|
2287
2546
|
const versionColumn = this._table.versionColumnPhysical;
|
|
2288
|
-
const mongoFilter = buildMongoFilter(filter);
|
|
2289
2547
|
const updateDoc = buildMongoUpdateDoc(data, ops, versionColumn);
|
|
2290
|
-
this.
|
|
2291
|
-
|
|
2548
|
+
return this._updateMatching(filter, (mongoFilter) => {
|
|
2549
|
+
this._log("updateMany", mongoFilter, updateDoc);
|
|
2550
|
+
return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
|
|
2551
|
+
});
|
|
2292
2552
|
}
|
|
2293
2553
|
async replaceMany(filter, data) {
|
|
2294
|
-
const mongoFilter = buildMongoFilter(filter);
|
|
2295
2554
|
const updateDoc = buildMongoUpdateDoc(data, void 0, this._table.versionColumnPhysical);
|
|
2296
|
-
this.
|
|
2297
|
-
|
|
2555
|
+
return this._updateMatching(filter, (mongoFilter) => {
|
|
2556
|
+
this._log("replaceMany", mongoFilter, updateDoc);
|
|
2557
|
+
return this._wrapUpdate(() => this.collection.updateMany(mongoFilter, updateDoc, this._getSessionOpts()));
|
|
2558
|
+
});
|
|
2298
2559
|
}
|
|
2299
2560
|
async deleteMany(filter) {
|
|
2300
|
-
|
|
2301
|
-
this.
|
|
2302
|
-
|
|
2561
|
+
let deletedCount = 0;
|
|
2562
|
+
await this._forEachMatching(filter, async (mongoFilter) => {
|
|
2563
|
+
this._log("deleteMany", mongoFilter);
|
|
2564
|
+
const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
|
|
2565
|
+
deletedCount += result.deletedCount;
|
|
2566
|
+
});
|
|
2567
|
+
return { deletedCount };
|
|
2303
2568
|
}
|
|
2304
2569
|
clearCollectionCache() {
|
|
2305
2570
|
this._collection = void 0;
|
|
@@ -2426,6 +2691,15 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2426
2691
|
fieldCounters.set(physical, next + 1);
|
|
2427
2692
|
}
|
|
2428
2693
|
}
|
|
2694
|
+
/** A find whose filter holds relational predicates, as an aggregation pipeline. */
|
|
2695
|
+
async _aggregateFind(query, label, limit) {
|
|
2696
|
+
const pipeline = [...mongoFilterStages(query.filter, this._predicateFilterOpts), ...pageStages(limit ? {
|
|
2697
|
+
...query.controls,
|
|
2698
|
+
$limit: limit
|
|
2699
|
+
} : query.controls)];
|
|
2700
|
+
this._log(`aggregate (${label})`, pipeline);
|
|
2701
|
+
return wrapInvalidQuery(() => this.collection.aggregate(pipeline, this._readOpts(query)).toArray());
|
|
2702
|
+
}
|
|
2429
2703
|
_buildFindOptions(controls) {
|
|
2430
2704
|
const opts = {};
|
|
2431
2705
|
if (!controls) return opts;
|
|
@@ -2442,6 +2716,9 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2442
2716
|
* Returns MongoDB collation options if any filter field has a non-binary collation.
|
|
2443
2717
|
* Uses pre-computed insights when available, falls back to computing them on demand.
|
|
2444
2718
|
* Maps: nocase → strength 2 (case-insensitive), unicode → strength 1 (case+accent-insensitive).
|
|
2719
|
+
* Predicate-free filters only — a filter with relational predicates never gets an
|
|
2720
|
+
* operation-wide collation ({@link _readOpts}), so insights are never computed over
|
|
2721
|
+
* resolved predicate operands.
|
|
2445
2722
|
*/
|
|
2446
2723
|
_getCollationOpts(query) {
|
|
2447
2724
|
if (!this._collateFields) return;
|
|
@@ -2544,6 +2821,19 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2544
2821
|
return node.fields;
|
|
2545
2822
|
}
|
|
2546
2823
|
};
|
|
2824
|
+
/** `$sort` → `$skip` → `$limit` → `$project` stages of a read's controls. */
|
|
2825
|
+
function pageStages(controls) {
|
|
2826
|
+
const stages = [];
|
|
2827
|
+
if (!controls) return stages;
|
|
2828
|
+
if (controls.$sort) stages.push({ $sort: controls.$sort });
|
|
2829
|
+
if (controls.$skip) stages.push({ $skip: controls.$skip });
|
|
2830
|
+
if (controls.$limit) stages.push({ $limit: controls.$limit });
|
|
2831
|
+
if (controls.$select) {
|
|
2832
|
+
const projection = controls.$select.asProjection;
|
|
2833
|
+
if (projection) stages.push({ $project: dedupeProjection(projection) });
|
|
2834
|
+
}
|
|
2835
|
+
return stages;
|
|
2836
|
+
}
|
|
2547
2837
|
/**
|
|
2548
2838
|
* Normalizes a declared `fuzzy` arg (`0-2`) into the query-time metadata Atlas
|
|
2549
2839
|
* accepts. Atlas only honors an edit distance of `1` or `2`; `0`/undefined means
|
|
@@ -2606,4 +2896,4 @@ function createAdapter(connection, _options) {
|
|
|
2606
2896
|
return new DbSpace(() => new MongoAdapter(db, client));
|
|
2607
2897
|
}
|
|
2608
2898
|
//#endregion
|
|
2609
|
-
export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, buildMongoFilter, createAdapter, mongoIndexKey, validateMongoIdPlugin };
|
|
2899
|
+
export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
|