@atscript/db-mongo 0.1.107 → 0.1.109
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/index.cjs +165 -39
- package/dist/index.d.cts +31 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.mjs +165 -39
- package/package.json +7 -7
package/dist/index.cjs
CHANGED
|
@@ -3,6 +3,13 @@ const require_mongo_filter = require("./mongo-filter-z_hLPMyv.cjs");
|
|
|
3
3
|
let _atscript_db = require("@atscript/db");
|
|
4
4
|
let mongodb = require("mongodb");
|
|
5
5
|
//#region src/lib/path-utils.ts
|
|
6
|
+
function joinPath(prefix, segment) {
|
|
7
|
+
return prefix ? `${prefix}.${segment}` : segment;
|
|
8
|
+
}
|
|
9
|
+
function isArrayPath(flatMap, path) {
|
|
10
|
+
const type = flatMap.get(path);
|
|
11
|
+
return type !== void 0 && (0, _atscript_db.resolveDesignType)(type) === "array";
|
|
12
|
+
}
|
|
6
13
|
function hasAncestorIn(path, set) {
|
|
7
14
|
let dot = path.indexOf(".");
|
|
8
15
|
while (dot !== -1) {
|
|
@@ -157,7 +164,7 @@ var CollectionPatcher = class {
|
|
|
157
164
|
* @private
|
|
158
165
|
*/
|
|
159
166
|
flattenPayload(payload, prefix = "") {
|
|
160
|
-
const evalKey = (k) => prefix
|
|
167
|
+
const evalKey = (k) => joinPath(prefix, k);
|
|
161
168
|
for (const [_key, value] of Object.entries(payload)) {
|
|
162
169
|
const key = evalKey(_key);
|
|
163
170
|
const flatType = this.collection.flatMap.get(key);
|
|
@@ -770,27 +777,44 @@ function buildSearchStage(host, text, indexName, controls) {
|
|
|
770
777
|
const searchIndex = index;
|
|
771
778
|
const fuzzy = resolveSearchFuzzy(searchIndex, controls);
|
|
772
779
|
const strategy = searchIndex.strategy ?? "compound";
|
|
773
|
-
const
|
|
774
|
-
const
|
|
780
|
+
const paths = collectSearchPathsCached(searchIndex);
|
|
781
|
+
const fuzzyOpt = fuzzy ? { fuzzy } : {};
|
|
782
|
+
const textOp = (path) => ({ text: {
|
|
775
783
|
query: text,
|
|
776
|
-
path
|
|
777
|
-
...
|
|
784
|
+
path,
|
|
785
|
+
...fuzzyOpt
|
|
778
786
|
} });
|
|
779
|
-
const
|
|
787
|
+
const autocompleteOp = (path) => ({ autocomplete: {
|
|
780
788
|
query: text,
|
|
781
789
|
path,
|
|
782
|
-
...
|
|
790
|
+
...fuzzyOpt
|
|
783
791
|
} });
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
minimumShouldMatch: 1
|
|
792
|
+
const textClause = () => textOp({ wildcard: "*" });
|
|
793
|
+
const wrapEmbedded = (chain, operator) => {
|
|
794
|
+
let wrapped = operator;
|
|
795
|
+
for (let i = chain.length - 1; i >= 0; i--) wrapped = { embeddedDocument: {
|
|
796
|
+
path: chain[i],
|
|
797
|
+
operator: wrapped
|
|
791
798
|
} };
|
|
792
|
-
|
|
793
|
-
|
|
799
|
+
return wrapped;
|
|
800
|
+
};
|
|
801
|
+
const embeddedTextClause = (chain, leaves) => wrapEmbedded(chain, textOp(leaves.length === 1 ? leaves[0] : leaves));
|
|
802
|
+
const embeddedAutocompleteClause = (chain, leaf) => wrapEmbedded(chain, autocompleteOp(leaf));
|
|
803
|
+
const wantWord = strategy === "text" || strategy === "compound";
|
|
804
|
+
const wantAutocomplete = strategy === "autocomplete" || strategy === "compound";
|
|
805
|
+
const clauses = [];
|
|
806
|
+
if (wantWord) {
|
|
807
|
+
clauses.push(textClause());
|
|
808
|
+
for (const group of paths.arrayGroups) if (group.textLeaves.length > 0) clauses.push(embeddedTextClause(group.chain, group.textLeaves));
|
|
809
|
+
}
|
|
810
|
+
if (wantAutocomplete) {
|
|
811
|
+
for (const path of paths.autocompleteOutside) clauses.push(autocompleteOp(path));
|
|
812
|
+
for (const group of paths.arrayGroups) for (const leaf of group.autocompleteLeaves) clauses.push(embeddedAutocompleteClause(group.chain, leaf));
|
|
813
|
+
}
|
|
814
|
+
let body;
|
|
815
|
+
if (clauses.length <= 1) body = clauses[0] ?? textClause();
|
|
816
|
+
else body = { compound: {
|
|
817
|
+
should: clauses,
|
|
794
818
|
minimumShouldMatch: 1
|
|
795
819
|
} };
|
|
796
820
|
return {
|
|
@@ -811,13 +835,65 @@ function resolveSearchFuzzy(index, controls) {
|
|
|
811
835
|
const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
|
|
812
836
|
return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
|
|
813
837
|
}
|
|
814
|
-
/**
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
838
|
+
/**
|
|
839
|
+
* An index's mapping tree is immutable once schema sync builds it, and the index
|
|
840
|
+
* object is cached for the adapter's lifetime — so the walk runs once per index,
|
|
841
|
+
* not once per query (`buildSearchStage` is on the search hot path).
|
|
842
|
+
*/
|
|
843
|
+
const searchPathsCache = /* @__PURE__ */ new WeakMap();
|
|
844
|
+
function collectSearchPathsCached(index) {
|
|
845
|
+
let cached = searchPathsCache.get(index);
|
|
846
|
+
if (!cached) {
|
|
847
|
+
cached = collectSearchPaths(index.definition.mappings?.fields);
|
|
848
|
+
searchPathsCache.set(index, cached);
|
|
849
|
+
}
|
|
850
|
+
return cached;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Walks an index's nested mapping tree, classifying each searchable leaf by how
|
|
854
|
+
* it must be queried: a plain dotted path (top-level / `document`-nested) vs.
|
|
855
|
+
* scoped under an `embeddedDocuments` array root (queried via `embeddedDocument`).
|
|
856
|
+
* Word-matching for non-array fields rides the wildcard `text` clause, so only
|
|
857
|
+
* autocomplete leaves are tracked outside arrays.
|
|
858
|
+
*/
|
|
859
|
+
function collectSearchPaths(fields) {
|
|
860
|
+
const out = {
|
|
861
|
+
autocompleteOutside: [],
|
|
862
|
+
arrayGroups: []
|
|
863
|
+
};
|
|
864
|
+
const byChain = /* @__PURE__ */ new Map();
|
|
865
|
+
const walk = (map, prefix, chain) => {
|
|
866
|
+
if (!map) return;
|
|
867
|
+
for (const [key, mapping] of Object.entries(map)) {
|
|
868
|
+
const path = joinPath(prefix, key);
|
|
869
|
+
const list = Array.isArray(mapping) ? mapping : [mapping];
|
|
870
|
+
const embedded = list.find((m) => m.type === "embeddedDocuments");
|
|
871
|
+
const document = list.find((m) => m.type === "document");
|
|
872
|
+
if (embedded?.fields) walk(embedded.fields, path, [...chain, path]);
|
|
873
|
+
else if (document?.fields) walk(document.fields, path, chain);
|
|
874
|
+
else {
|
|
875
|
+
const isAutocomplete = list.some((m) => m.type === "autocomplete");
|
|
876
|
+
const isText = list.some((m) => m.type === "string");
|
|
877
|
+
if (chain.length > 0) {
|
|
878
|
+
const chainKey = chain.join(">");
|
|
879
|
+
let group = byChain.get(chainKey);
|
|
880
|
+
if (!group) {
|
|
881
|
+
group = {
|
|
882
|
+
chain,
|
|
883
|
+
textLeaves: [],
|
|
884
|
+
autocompleteLeaves: []
|
|
885
|
+
};
|
|
886
|
+
byChain.set(chainKey, group);
|
|
887
|
+
out.arrayGroups.push(group);
|
|
888
|
+
}
|
|
889
|
+
if (isText) group.textLeaves.push(path);
|
|
890
|
+
if (isAutocomplete) group.autocompleteLeaves.push(path);
|
|
891
|
+
} else if (isAutocomplete) out.autocompleteOutside.push(path);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
walk(fields, "", []);
|
|
896
|
+
return out;
|
|
821
897
|
}
|
|
822
898
|
/** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
|
|
823
899
|
function buildVectorSearchStage(host, vector, indexName, limit) {
|
|
@@ -1176,11 +1252,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
|
|
|
1176
1252
|
for (let i = 0; i < logicalSegments.length; i++) {
|
|
1177
1253
|
const isLeaf = i === logicalSegments.length - 1;
|
|
1178
1254
|
out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
|
|
1179
|
-
prefix = prefix
|
|
1180
|
-
if (!isLeaf)
|
|
1181
|
-
const type = host._table.flatMap.get(prefix);
|
|
1182
|
-
if (type && (0, _atscript_db.resolveDesignType)(type) === "array") out.push("$[]");
|
|
1183
|
-
}
|
|
1255
|
+
prefix = joinPath(prefix, logicalSegments[i]);
|
|
1256
|
+
if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
|
|
1184
1257
|
}
|
|
1185
1258
|
return out.join(".");
|
|
1186
1259
|
}
|
|
@@ -1190,9 +1263,8 @@ function pathCrossesArray(host, logicalPath) {
|
|
|
1190
1263
|
if (segments.length < 2) return false;
|
|
1191
1264
|
let prefix = "";
|
|
1192
1265
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
1193
|
-
prefix = prefix
|
|
1194
|
-
|
|
1195
|
-
if (type && (0, _atscript_db.resolveDesignType)(type) === "array") return true;
|
|
1266
|
+
prefix = joinPath(prefix, segments[i]);
|
|
1267
|
+
if (isArrayPath(host._table.flatMap, prefix)) return true;
|
|
1196
1268
|
}
|
|
1197
1269
|
return false;
|
|
1198
1270
|
}
|
|
@@ -1440,6 +1512,7 @@ function fieldMappingEqual(a, b) {
|
|
|
1440
1512
|
for (const [type, av] of am) {
|
|
1441
1513
|
const bv = bm.get(type);
|
|
1442
1514
|
if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
|
|
1515
|
+
if (!fieldsMatch(av.fields, bv.fields)) return false;
|
|
1443
1516
|
}
|
|
1444
1517
|
return true;
|
|
1445
1518
|
}
|
|
@@ -1484,6 +1557,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1484
1557
|
_vectorThresholds = /* @__PURE__ */ new Map();
|
|
1485
1558
|
/** Cached search index lookup. */
|
|
1486
1559
|
_searchIndexesMap;
|
|
1560
|
+
/**
|
|
1561
|
+
* Search-field mappings recorded during `onFieldScanned`, applied in
|
|
1562
|
+
* `onAfterFlatten`. Deferred because resolving a nested field's container
|
|
1563
|
+
* shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
|
|
1564
|
+
* is only safe to read once the metadata build is marked complete — i.e. in
|
|
1565
|
+
* `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
|
|
1566
|
+
*/
|
|
1567
|
+
_pendingSearchFields = [];
|
|
1487
1568
|
/** Physical field names with @db.default.increment → optional start value. */
|
|
1488
1569
|
_incrementFields = /* @__PURE__ */ new Map();
|
|
1489
1570
|
/** Physical field names that have a non-binary collation (nocase/unicode). */
|
|
@@ -1708,10 +1789,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1708
1789
|
const startValue = metadata.get("db.default.increment");
|
|
1709
1790
|
this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
|
|
1710
1791
|
}
|
|
1711
|
-
for (const index of metadata.get("db.mongo.search.text") || []) this.
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1792
|
+
for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
|
|
1793
|
+
indexName: index.indexName,
|
|
1794
|
+
field,
|
|
1795
|
+
mappings: [index.analyzer ? {
|
|
1796
|
+
type: "string",
|
|
1797
|
+
analyzer: index.analyzer
|
|
1798
|
+
} : { type: "string" }]
|
|
1799
|
+
});
|
|
1715
1800
|
for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
|
|
1716
1801
|
const autocomplete = {
|
|
1717
1802
|
type: "autocomplete",
|
|
@@ -1724,7 +1809,11 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1724
1809
|
type: "string",
|
|
1725
1810
|
analyzer: ac.analyzer
|
|
1726
1811
|
} : { type: "string" };
|
|
1727
|
-
this.
|
|
1812
|
+
this._pendingSearchFields.push({
|
|
1813
|
+
indexName: ac.indexName,
|
|
1814
|
+
field,
|
|
1815
|
+
mappings: [autocomplete, companion]
|
|
1816
|
+
});
|
|
1728
1817
|
}
|
|
1729
1818
|
const vectorIndex = metadata.get("db.search.vector");
|
|
1730
1819
|
if (vectorIndex) {
|
|
@@ -1766,6 +1855,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1766
1855
|
};
|
|
1767
1856
|
}
|
|
1768
1857
|
onAfterFlatten() {
|
|
1858
|
+
for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
|
|
1859
|
+
this._pendingSearchFields = [];
|
|
1769
1860
|
for (const [key, value] of this._vectorFilters.entries()) {
|
|
1770
1861
|
const index = this._mongoIndexes.get(key);
|
|
1771
1862
|
if (index && index.type === "vector") index.definition.fields?.push({
|
|
@@ -2219,10 +2310,45 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
2219
2310
|
const name = _name || "DEFAULT";
|
|
2220
2311
|
let index = this._mongoIndexes.get(mongoIndexKey(type, name));
|
|
2221
2312
|
if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
|
|
2222
|
-
if (index)
|
|
2223
|
-
|
|
2224
|
-
|
|
2313
|
+
if (!index) return;
|
|
2314
|
+
const rootFields = index.definition.mappings.fields;
|
|
2315
|
+
const segments = fieldName.split(".");
|
|
2316
|
+
const topKey = this._table.columnMap.get(segments[0]) ?? segments[0];
|
|
2317
|
+
if (segments.length === 1) {
|
|
2318
|
+
rootFields[topKey] = mergeFieldMappings(rootFields[topKey], mappings);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
let cursor = rootFields;
|
|
2322
|
+
let prefix = "";
|
|
2323
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
2324
|
+
const segment = segments[i];
|
|
2325
|
+
prefix = joinPath(prefix, segment);
|
|
2326
|
+
const nodeType = isArrayPath(this._table.flatMap, prefix) ? "embeddedDocuments" : "document";
|
|
2327
|
+
cursor = this._descendSearchNode(cursor, i === 0 ? topKey : segment, nodeType, fieldName);
|
|
2225
2328
|
}
|
|
2329
|
+
const leaf = segments[segments.length - 1];
|
|
2330
|
+
cursor[leaf] = mergeFieldMappings(cursor[leaf], mappings);
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Returns (creating if needed) the nested `fields` map of the container node
|
|
2334
|
+
* at `segment`, reusing a same-typed sibling node so multiple searchable
|
|
2335
|
+
* fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
|
|
2336
|
+
* instead of clobbering.
|
|
2337
|
+
*/
|
|
2338
|
+
_descendSearchNode(fields, segment, nodeType, fullPath) {
|
|
2339
|
+
const existing = fields[segment];
|
|
2340
|
+
if (existing && !Array.isArray(existing) && (existing.type === "document" || existing.type === "embeddedDocuments")) {
|
|
2341
|
+
if (existing.type !== nodeType) this._log(`search index: conflicting container type for "${fullPath}" (${existing.type} vs ${nodeType}); keeping ${existing.type}`);
|
|
2342
|
+
existing.fields ??= {};
|
|
2343
|
+
return existing.fields;
|
|
2344
|
+
}
|
|
2345
|
+
if (existing) this._log(`search index: field "${segment}" used as both value and parent in "${fullPath}"`);
|
|
2346
|
+
const node = {
|
|
2347
|
+
type: nodeType,
|
|
2348
|
+
fields: {}
|
|
2349
|
+
};
|
|
2350
|
+
fields[segment] = node;
|
|
2351
|
+
return node.fields;
|
|
2226
2352
|
}
|
|
2227
2353
|
};
|
|
2228
2354
|
/**
|
package/dist/index.d.cts
CHANGED
|
@@ -191,6 +191,16 @@ type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
|
|
|
191
191
|
* `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
|
|
192
192
|
* A field may carry several mappings at once (an array of these) — e.g. an
|
|
193
193
|
* autocomplete field double-mapped as `string` so exact-word hits still rank.
|
|
194
|
+
*
|
|
195
|
+
* Container nodes carry `fields` instead of leaf attributes:
|
|
196
|
+
* - `type: "document"` — a single embedded object (`identity: Identity`). Atlas
|
|
197
|
+
* does NOT accept a dotted mapping key, so each parent object on a nested path
|
|
198
|
+
* must be its own `document` node. Nested fields are reachable by the dotted
|
|
199
|
+
* query path and by a `{ wildcard: "*" }` `text` operator.
|
|
200
|
+
* - `type: "embeddedDocuments"` — an array of objects (`items: Item[]`). Atlas
|
|
201
|
+
* cannot index array-of-object fields under a `document` node; they MUST use
|
|
202
|
+
* `embeddedDocuments` and be queried via the `embeddedDocument` operator
|
|
203
|
+
* (the wildcard `text` operator does not reach them).
|
|
194
204
|
*/
|
|
195
205
|
interface TSearchFieldMapping {
|
|
196
206
|
type: string;
|
|
@@ -199,6 +209,8 @@ interface TSearchFieldMapping {
|
|
|
199
209
|
minGrams?: number;
|
|
200
210
|
maxGrams?: number;
|
|
201
211
|
foldDiacritics?: boolean;
|
|
212
|
+
/** Nested mappings for `document` / `embeddedDocuments` container nodes. */
|
|
213
|
+
fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
|
|
202
214
|
}
|
|
203
215
|
interface TMongoSearchIndexDefinition {
|
|
204
216
|
mappings?: {
|
|
@@ -227,6 +239,18 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
227
239
|
protected _vectorThresholds: Map<string, number>;
|
|
228
240
|
/** Cached search index lookup. */
|
|
229
241
|
protected _searchIndexesMap?: Map<string, TMongoIndex>;
|
|
242
|
+
/**
|
|
243
|
+
* Search-field mappings recorded during `onFieldScanned`, applied in
|
|
244
|
+
* `onAfterFlatten`. Deferred because resolving a nested field's container
|
|
245
|
+
* shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
|
|
246
|
+
* is only safe to read once the metadata build is marked complete — i.e. in
|
|
247
|
+
* `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
|
|
248
|
+
*/
|
|
249
|
+
protected _pendingSearchFields: Array<{
|
|
250
|
+
indexName: string | undefined;
|
|
251
|
+
field: string;
|
|
252
|
+
mappings: TSearchFieldMapping[];
|
|
253
|
+
}>;
|
|
230
254
|
/** Physical field names with @db.default.increment → optional start value. */
|
|
231
255
|
protected _incrementFields: Map<string, number | undefined>;
|
|
232
256
|
/** Physical field names that have a non-binary collation (nocase/unicode). */
|
|
@@ -408,6 +432,13 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
408
432
|
* instead of overwriting one another.
|
|
409
433
|
*/
|
|
410
434
|
protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
|
|
435
|
+
/**
|
|
436
|
+
* Returns (creating if needed) the nested `fields` map of the container node
|
|
437
|
+
* at `segment`, reusing a same-typed sibling node so multiple searchable
|
|
438
|
+
* fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
|
|
439
|
+
* instead of clobbering.
|
|
440
|
+
*/
|
|
441
|
+
private _descendSearchNode;
|
|
411
442
|
}
|
|
412
443
|
//#endregion
|
|
413
444
|
//#region src/lib/mongo-filter.d.ts
|
package/dist/index.d.mts
CHANGED
|
@@ -191,6 +191,16 @@ type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
|
|
|
191
191
|
* `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
|
|
192
192
|
* A field may carry several mappings at once (an array of these) — e.g. an
|
|
193
193
|
* autocomplete field double-mapped as `string` so exact-word hits still rank.
|
|
194
|
+
*
|
|
195
|
+
* Container nodes carry `fields` instead of leaf attributes:
|
|
196
|
+
* - `type: "document"` — a single embedded object (`identity: Identity`). Atlas
|
|
197
|
+
* does NOT accept a dotted mapping key, so each parent object on a nested path
|
|
198
|
+
* must be its own `document` node. Nested fields are reachable by the dotted
|
|
199
|
+
* query path and by a `{ wildcard: "*" }` `text` operator.
|
|
200
|
+
* - `type: "embeddedDocuments"` — an array of objects (`items: Item[]`). Atlas
|
|
201
|
+
* cannot index array-of-object fields under a `document` node; they MUST use
|
|
202
|
+
* `embeddedDocuments` and be queried via the `embeddedDocument` operator
|
|
203
|
+
* (the wildcard `text` operator does not reach them).
|
|
194
204
|
*/
|
|
195
205
|
interface TSearchFieldMapping {
|
|
196
206
|
type: string;
|
|
@@ -199,6 +209,8 @@ interface TSearchFieldMapping {
|
|
|
199
209
|
minGrams?: number;
|
|
200
210
|
maxGrams?: number;
|
|
201
211
|
foldDiacritics?: boolean;
|
|
212
|
+
/** Nested mappings for `document` / `embeddedDocuments` container nodes. */
|
|
213
|
+
fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
|
|
202
214
|
}
|
|
203
215
|
interface TMongoSearchIndexDefinition {
|
|
204
216
|
mappings?: {
|
|
@@ -227,6 +239,18 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
227
239
|
protected _vectorThresholds: Map<string, number>;
|
|
228
240
|
/** Cached search index lookup. */
|
|
229
241
|
protected _searchIndexesMap?: Map<string, TMongoIndex>;
|
|
242
|
+
/**
|
|
243
|
+
* Search-field mappings recorded during `onFieldScanned`, applied in
|
|
244
|
+
* `onAfterFlatten`. Deferred because resolving a nested field's container
|
|
245
|
+
* shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
|
|
246
|
+
* is only safe to read once the metadata build is marked complete — i.e. in
|
|
247
|
+
* `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
|
|
248
|
+
*/
|
|
249
|
+
protected _pendingSearchFields: Array<{
|
|
250
|
+
indexName: string | undefined;
|
|
251
|
+
field: string;
|
|
252
|
+
mappings: TSearchFieldMapping[];
|
|
253
|
+
}>;
|
|
230
254
|
/** Physical field names with @db.default.increment → optional start value. */
|
|
231
255
|
protected _incrementFields: Map<string, number | undefined>;
|
|
232
256
|
/** Physical field names that have a non-binary collation (nocase/unicode). */
|
|
@@ -408,6 +432,13 @@ declare class MongoAdapter extends BaseDbAdapter {
|
|
|
408
432
|
* instead of overwriting one another.
|
|
409
433
|
*/
|
|
410
434
|
protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
|
|
435
|
+
/**
|
|
436
|
+
* Returns (creating if needed) the nested `fields` map of the container node
|
|
437
|
+
* at `segment`, reusing a same-typed sibling node so multiple searchable
|
|
438
|
+
* fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
|
|
439
|
+
* instead of clobbering.
|
|
440
|
+
*/
|
|
441
|
+
private _descendSearchNode;
|
|
411
442
|
}
|
|
412
443
|
//#endregion
|
|
413
444
|
//#region src/lib/mongo-filter.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -2,6 +2,13 @@ import { t as buildMongoFilter } from "./mongo-filter-DceAGI-S.mjs";
|
|
|
2
2
|
import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace, computeInsights, getDbFieldOp, getKeyProps, resolveDesignType } from "@atscript/db";
|
|
3
3
|
import { MongoClient, MongoServerError, ObjectId } from "mongodb";
|
|
4
4
|
//#region src/lib/path-utils.ts
|
|
5
|
+
function joinPath(prefix, segment) {
|
|
6
|
+
return prefix ? `${prefix}.${segment}` : segment;
|
|
7
|
+
}
|
|
8
|
+
function isArrayPath(flatMap, path) {
|
|
9
|
+
const type = flatMap.get(path);
|
|
10
|
+
return type !== void 0 && resolveDesignType(type) === "array";
|
|
11
|
+
}
|
|
5
12
|
function hasAncestorIn(path, set) {
|
|
6
13
|
let dot = path.indexOf(".");
|
|
7
14
|
while (dot !== -1) {
|
|
@@ -156,7 +163,7 @@ var CollectionPatcher = class {
|
|
|
156
163
|
* @private
|
|
157
164
|
*/
|
|
158
165
|
flattenPayload(payload, prefix = "") {
|
|
159
|
-
const evalKey = (k) => prefix
|
|
166
|
+
const evalKey = (k) => joinPath(prefix, k);
|
|
160
167
|
for (const [_key, value] of Object.entries(payload)) {
|
|
161
168
|
const key = evalKey(_key);
|
|
162
169
|
const flatType = this.collection.flatMap.get(key);
|
|
@@ -769,27 +776,44 @@ function buildSearchStage(host, text, indexName, controls) {
|
|
|
769
776
|
const searchIndex = index;
|
|
770
777
|
const fuzzy = resolveSearchFuzzy(searchIndex, controls);
|
|
771
778
|
const strategy = searchIndex.strategy ?? "compound";
|
|
772
|
-
const
|
|
773
|
-
const
|
|
779
|
+
const paths = collectSearchPathsCached(searchIndex);
|
|
780
|
+
const fuzzyOpt = fuzzy ? { fuzzy } : {};
|
|
781
|
+
const textOp = (path) => ({ text: {
|
|
774
782
|
query: text,
|
|
775
|
-
path
|
|
776
|
-
...
|
|
783
|
+
path,
|
|
784
|
+
...fuzzyOpt
|
|
777
785
|
} });
|
|
778
|
-
const
|
|
786
|
+
const autocompleteOp = (path) => ({ autocomplete: {
|
|
779
787
|
query: text,
|
|
780
788
|
path,
|
|
781
|
-
...
|
|
789
|
+
...fuzzyOpt
|
|
782
790
|
} });
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
minimumShouldMatch: 1
|
|
791
|
+
const textClause = () => textOp({ wildcard: "*" });
|
|
792
|
+
const wrapEmbedded = (chain, operator) => {
|
|
793
|
+
let wrapped = operator;
|
|
794
|
+
for (let i = chain.length - 1; i >= 0; i--) wrapped = { embeddedDocument: {
|
|
795
|
+
path: chain[i],
|
|
796
|
+
operator: wrapped
|
|
790
797
|
} };
|
|
791
|
-
|
|
792
|
-
|
|
798
|
+
return wrapped;
|
|
799
|
+
};
|
|
800
|
+
const embeddedTextClause = (chain, leaves) => wrapEmbedded(chain, textOp(leaves.length === 1 ? leaves[0] : leaves));
|
|
801
|
+
const embeddedAutocompleteClause = (chain, leaf) => wrapEmbedded(chain, autocompleteOp(leaf));
|
|
802
|
+
const wantWord = strategy === "text" || strategy === "compound";
|
|
803
|
+
const wantAutocomplete = strategy === "autocomplete" || strategy === "compound";
|
|
804
|
+
const clauses = [];
|
|
805
|
+
if (wantWord) {
|
|
806
|
+
clauses.push(textClause());
|
|
807
|
+
for (const group of paths.arrayGroups) if (group.textLeaves.length > 0) clauses.push(embeddedTextClause(group.chain, group.textLeaves));
|
|
808
|
+
}
|
|
809
|
+
if (wantAutocomplete) {
|
|
810
|
+
for (const path of paths.autocompleteOutside) clauses.push(autocompleteOp(path));
|
|
811
|
+
for (const group of paths.arrayGroups) for (const leaf of group.autocompleteLeaves) clauses.push(embeddedAutocompleteClause(group.chain, leaf));
|
|
812
|
+
}
|
|
813
|
+
let body;
|
|
814
|
+
if (clauses.length <= 1) body = clauses[0] ?? textClause();
|
|
815
|
+
else body = { compound: {
|
|
816
|
+
should: clauses,
|
|
793
817
|
minimumShouldMatch: 1
|
|
794
818
|
} };
|
|
795
819
|
return {
|
|
@@ -810,13 +834,65 @@ function resolveSearchFuzzy(index, controls) {
|
|
|
810
834
|
const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
|
|
811
835
|
return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
|
|
812
836
|
}
|
|
813
|
-
/**
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
837
|
+
/**
|
|
838
|
+
* An index's mapping tree is immutable once schema sync builds it, and the index
|
|
839
|
+
* object is cached for the adapter's lifetime — so the walk runs once per index,
|
|
840
|
+
* not once per query (`buildSearchStage` is on the search hot path).
|
|
841
|
+
*/
|
|
842
|
+
const searchPathsCache = /* @__PURE__ */ new WeakMap();
|
|
843
|
+
function collectSearchPathsCached(index) {
|
|
844
|
+
let cached = searchPathsCache.get(index);
|
|
845
|
+
if (!cached) {
|
|
846
|
+
cached = collectSearchPaths(index.definition.mappings?.fields);
|
|
847
|
+
searchPathsCache.set(index, cached);
|
|
848
|
+
}
|
|
849
|
+
return cached;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Walks an index's nested mapping tree, classifying each searchable leaf by how
|
|
853
|
+
* it must be queried: a plain dotted path (top-level / `document`-nested) vs.
|
|
854
|
+
* scoped under an `embeddedDocuments` array root (queried via `embeddedDocument`).
|
|
855
|
+
* Word-matching for non-array fields rides the wildcard `text` clause, so only
|
|
856
|
+
* autocomplete leaves are tracked outside arrays.
|
|
857
|
+
*/
|
|
858
|
+
function collectSearchPaths(fields) {
|
|
859
|
+
const out = {
|
|
860
|
+
autocompleteOutside: [],
|
|
861
|
+
arrayGroups: []
|
|
862
|
+
};
|
|
863
|
+
const byChain = /* @__PURE__ */ new Map();
|
|
864
|
+
const walk = (map, prefix, chain) => {
|
|
865
|
+
if (!map) return;
|
|
866
|
+
for (const [key, mapping] of Object.entries(map)) {
|
|
867
|
+
const path = joinPath(prefix, key);
|
|
868
|
+
const list = Array.isArray(mapping) ? mapping : [mapping];
|
|
869
|
+
const embedded = list.find((m) => m.type === "embeddedDocuments");
|
|
870
|
+
const document = list.find((m) => m.type === "document");
|
|
871
|
+
if (embedded?.fields) walk(embedded.fields, path, [...chain, path]);
|
|
872
|
+
else if (document?.fields) walk(document.fields, path, chain);
|
|
873
|
+
else {
|
|
874
|
+
const isAutocomplete = list.some((m) => m.type === "autocomplete");
|
|
875
|
+
const isText = list.some((m) => m.type === "string");
|
|
876
|
+
if (chain.length > 0) {
|
|
877
|
+
const chainKey = chain.join(">");
|
|
878
|
+
let group = byChain.get(chainKey);
|
|
879
|
+
if (!group) {
|
|
880
|
+
group = {
|
|
881
|
+
chain,
|
|
882
|
+
textLeaves: [],
|
|
883
|
+
autocompleteLeaves: []
|
|
884
|
+
};
|
|
885
|
+
byChain.set(chainKey, group);
|
|
886
|
+
out.arrayGroups.push(group);
|
|
887
|
+
}
|
|
888
|
+
if (isText) group.textLeaves.push(path);
|
|
889
|
+
if (isAutocomplete) group.autocompleteLeaves.push(path);
|
|
890
|
+
} else if (isAutocomplete) out.autocompleteOutside.push(path);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
walk(fields, "", []);
|
|
895
|
+
return out;
|
|
820
896
|
}
|
|
821
897
|
/** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
|
|
822
898
|
function buildVectorSearchStage(host, vector, indexName, limit) {
|
|
@@ -1175,11 +1251,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
|
|
|
1175
1251
|
for (let i = 0; i < logicalSegments.length; i++) {
|
|
1176
1252
|
const isLeaf = i === logicalSegments.length - 1;
|
|
1177
1253
|
out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
|
|
1178
|
-
prefix = prefix
|
|
1179
|
-
if (!isLeaf)
|
|
1180
|
-
const type = host._table.flatMap.get(prefix);
|
|
1181
|
-
if (type && resolveDesignType(type) === "array") out.push("$[]");
|
|
1182
|
-
}
|
|
1254
|
+
prefix = joinPath(prefix, logicalSegments[i]);
|
|
1255
|
+
if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
|
|
1183
1256
|
}
|
|
1184
1257
|
return out.join(".");
|
|
1185
1258
|
}
|
|
@@ -1189,9 +1262,8 @@ function pathCrossesArray(host, logicalPath) {
|
|
|
1189
1262
|
if (segments.length < 2) return false;
|
|
1190
1263
|
let prefix = "";
|
|
1191
1264
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
1192
|
-
prefix = prefix
|
|
1193
|
-
|
|
1194
|
-
if (type && resolveDesignType(type) === "array") return true;
|
|
1265
|
+
prefix = joinPath(prefix, segments[i]);
|
|
1266
|
+
if (isArrayPath(host._table.flatMap, prefix)) return true;
|
|
1195
1267
|
}
|
|
1196
1268
|
return false;
|
|
1197
1269
|
}
|
|
@@ -1439,6 +1511,7 @@ function fieldMappingEqual(a, b) {
|
|
|
1439
1511
|
for (const [type, av] of am) {
|
|
1440
1512
|
const bv = bm.get(type);
|
|
1441
1513
|
if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
|
|
1514
|
+
if (!fieldsMatch(av.fields, bv.fields)) return false;
|
|
1442
1515
|
}
|
|
1443
1516
|
return true;
|
|
1444
1517
|
}
|
|
@@ -1483,6 +1556,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1483
1556
|
_vectorThresholds = /* @__PURE__ */ new Map();
|
|
1484
1557
|
/** Cached search index lookup. */
|
|
1485
1558
|
_searchIndexesMap;
|
|
1559
|
+
/**
|
|
1560
|
+
* Search-field mappings recorded during `onFieldScanned`, applied in
|
|
1561
|
+
* `onAfterFlatten`. Deferred because resolving a nested field's container
|
|
1562
|
+
* shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
|
|
1563
|
+
* is only safe to read once the metadata build is marked complete — i.e. in
|
|
1564
|
+
* `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
|
|
1565
|
+
*/
|
|
1566
|
+
_pendingSearchFields = [];
|
|
1486
1567
|
/** Physical field names with @db.default.increment → optional start value. */
|
|
1487
1568
|
_incrementFields = /* @__PURE__ */ new Map();
|
|
1488
1569
|
/** Physical field names that have a non-binary collation (nocase/unicode). */
|
|
@@ -1707,10 +1788,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1707
1788
|
const startValue = metadata.get("db.default.increment");
|
|
1708
1789
|
this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
|
|
1709
1790
|
}
|
|
1710
|
-
for (const index of metadata.get("db.mongo.search.text") || []) this.
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1791
|
+
for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
|
|
1792
|
+
indexName: index.indexName,
|
|
1793
|
+
field,
|
|
1794
|
+
mappings: [index.analyzer ? {
|
|
1795
|
+
type: "string",
|
|
1796
|
+
analyzer: index.analyzer
|
|
1797
|
+
} : { type: "string" }]
|
|
1798
|
+
});
|
|
1714
1799
|
for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
|
|
1715
1800
|
const autocomplete = {
|
|
1716
1801
|
type: "autocomplete",
|
|
@@ -1723,7 +1808,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1723
1808
|
type: "string",
|
|
1724
1809
|
analyzer: ac.analyzer
|
|
1725
1810
|
} : { type: "string" };
|
|
1726
|
-
this.
|
|
1811
|
+
this._pendingSearchFields.push({
|
|
1812
|
+
indexName: ac.indexName,
|
|
1813
|
+
field,
|
|
1814
|
+
mappings: [autocomplete, companion]
|
|
1815
|
+
});
|
|
1727
1816
|
}
|
|
1728
1817
|
const vectorIndex = metadata.get("db.search.vector");
|
|
1729
1818
|
if (vectorIndex) {
|
|
@@ -1765,6 +1854,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
1765
1854
|
};
|
|
1766
1855
|
}
|
|
1767
1856
|
onAfterFlatten() {
|
|
1857
|
+
for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
|
|
1858
|
+
this._pendingSearchFields = [];
|
|
1768
1859
|
for (const [key, value] of this._vectorFilters.entries()) {
|
|
1769
1860
|
const index = this._mongoIndexes.get(key);
|
|
1770
1861
|
if (index && index.type === "vector") index.definition.fields?.push({
|
|
@@ -2218,10 +2309,45 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
|
2218
2309
|
const name = _name || "DEFAULT";
|
|
2219
2310
|
let index = this._mongoIndexes.get(mongoIndexKey(type, name));
|
|
2220
2311
|
if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
|
|
2221
|
-
if (index)
|
|
2222
|
-
|
|
2223
|
-
|
|
2312
|
+
if (!index) return;
|
|
2313
|
+
const rootFields = index.definition.mappings.fields;
|
|
2314
|
+
const segments = fieldName.split(".");
|
|
2315
|
+
const topKey = this._table.columnMap.get(segments[0]) ?? segments[0];
|
|
2316
|
+
if (segments.length === 1) {
|
|
2317
|
+
rootFields[topKey] = mergeFieldMappings(rootFields[topKey], mappings);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
let cursor = rootFields;
|
|
2321
|
+
let prefix = "";
|
|
2322
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
2323
|
+
const segment = segments[i];
|
|
2324
|
+
prefix = joinPath(prefix, segment);
|
|
2325
|
+
const nodeType = isArrayPath(this._table.flatMap, prefix) ? "embeddedDocuments" : "document";
|
|
2326
|
+
cursor = this._descendSearchNode(cursor, i === 0 ? topKey : segment, nodeType, fieldName);
|
|
2224
2327
|
}
|
|
2328
|
+
const leaf = segments[segments.length - 1];
|
|
2329
|
+
cursor[leaf] = mergeFieldMappings(cursor[leaf], mappings);
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Returns (creating if needed) the nested `fields` map of the container node
|
|
2333
|
+
* at `segment`, reusing a same-typed sibling node so multiple searchable
|
|
2334
|
+
* fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
|
|
2335
|
+
* instead of clobbering.
|
|
2336
|
+
*/
|
|
2337
|
+
_descendSearchNode(fields, segment, nodeType, fullPath) {
|
|
2338
|
+
const existing = fields[segment];
|
|
2339
|
+
if (existing && !Array.isArray(existing) && (existing.type === "document" || existing.type === "embeddedDocuments")) {
|
|
2340
|
+
if (existing.type !== nodeType) this._log(`search index: conflicting container type for "${fullPath}" (${existing.type} vs ${nodeType}); keeping ${existing.type}`);
|
|
2341
|
+
existing.fields ??= {};
|
|
2342
|
+
return existing.fields;
|
|
2343
|
+
}
|
|
2344
|
+
if (existing) this._log(`search index: field "${segment}" used as both value and parent in "${fullPath}"`);
|
|
2345
|
+
const node = {
|
|
2346
|
+
type: nodeType,
|
|
2347
|
+
fields: {}
|
|
2348
|
+
};
|
|
2349
|
+
fields[segment] = node;
|
|
2350
|
+
return node.fields;
|
|
2225
2351
|
}
|
|
2226
2352
|
};
|
|
2227
2353
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-mongo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.109",
|
|
4
4
|
"description": "Mongodb plugin for atscript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -46,17 +46,17 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@atscript/core": "^0.1.
|
|
50
|
-
"@atscript/typescript": "^0.1.
|
|
49
|
+
"@atscript/core": "^0.1.78",
|
|
50
|
+
"@atscript/typescript": "^0.1.78",
|
|
51
51
|
"mongodb": "^6.17.0",
|
|
52
52
|
"mongodb-memory-server-core": "^10.0.0",
|
|
53
|
-
"unplugin-atscript": "^0.1.
|
|
53
|
+
"unplugin-atscript": "^0.1.78"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@atscript/core": "^0.1.
|
|
57
|
-
"@atscript/typescript": "^0.1.
|
|
56
|
+
"@atscript/core": "^0.1.78",
|
|
57
|
+
"@atscript/typescript": "^0.1.78",
|
|
58
58
|
"mongodb": "^6.17.0",
|
|
59
|
-
"@atscript/db": "^0.1.
|
|
59
|
+
"@atscript/db": "^0.1.109"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"postinstall": "asc -f dts",
|