@atscript/db-mongo 0.1.38
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/LICENSE +21 -0
- package/dist/agg-CUX5Jb_A.mjs +75 -0
- package/dist/agg-FBVtOv9k.cjs +77 -0
- package/dist/agg.cjs +127 -0
- package/dist/agg.d.ts +25 -0
- package/dist/agg.mjs +102 -0
- package/dist/index.cjs +1710 -0
- package/dist/index.d.ts +336 -0
- package/dist/index.mjs +1703 -0
- package/dist/mongo-filter-C8w5by9H.cjs +65 -0
- package/dist/mongo-filter-CL69Yhcm.mjs +30 -0
- package/dist/plugin.cjs +188 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.mjs +162 -0
- package/package.json +92 -0
- package/scripts/setup-skills.js +78 -0
- package/skills/atscript-db-mongo/.gitkeep +0 -0
- package/skills/atscript-db-mongo/SKILL.md +45 -0
- package/skills/atscript-db-mongo/annotations.md +168 -0
- package/skills/atscript-db-mongo/collections.md +141 -0
- package/skills/atscript-db-mongo/core.md +83 -0
- package/skills/atscript-db-mongo/patches.md +205 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1703 @@
|
|
|
1
|
+
import { buildMongoFilter } from "./mongo-filter-CL69Yhcm.mjs";
|
|
2
|
+
import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace, computeInsights, getKeyProps } from "@atscript/db";
|
|
3
|
+
import { MongoClient, MongoServerError, ObjectId } from "mongodb";
|
|
4
|
+
|
|
5
|
+
//#region packages/db-mongo/src/lib/collection-patcher.ts
|
|
6
|
+
function _define_property$1(obj, key, value) {
|
|
7
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
8
|
+
value,
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true
|
|
12
|
+
});
|
|
13
|
+
else obj[key] = value;
|
|
14
|
+
return obj;
|
|
15
|
+
}
|
|
16
|
+
var CollectionPatcher = class {
|
|
17
|
+
/**
|
|
18
|
+
* Entry point – walk the payload, build `filter`, `update` and `options`.
|
|
19
|
+
*
|
|
20
|
+
* @returns Helper object exposing both individual parts and
|
|
21
|
+
* a `.toArgs()` convenience callback.
|
|
22
|
+
*/ preparePatch() {
|
|
23
|
+
this.filterObj = { _id: this.collection.prepareId(this.payload._id) };
|
|
24
|
+
this.flattenPayload(this.payload);
|
|
25
|
+
const updateFilter = this.updatePipeline;
|
|
26
|
+
return {
|
|
27
|
+
toArgs: () => [
|
|
28
|
+
this.filterObj,
|
|
29
|
+
updateFilter,
|
|
30
|
+
this.optionsObj
|
|
31
|
+
],
|
|
32
|
+
filter: this.filterObj,
|
|
33
|
+
updateFilter,
|
|
34
|
+
updateOptions: this.optionsObj
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Helper – lazily create `$set` section and assign *key* → *value*.
|
|
39
|
+
*
|
|
40
|
+
* @param key Fully‑qualified dotted path
|
|
41
|
+
* @param val Value to be written
|
|
42
|
+
* @private
|
|
43
|
+
*/ _set(key, val) {
|
|
44
|
+
if (this.currentSetStage && !(key in this.currentSetStage.$set)) {
|
|
45
|
+
this.currentSetStage.$set[key] = val;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.currentSetStage = { $set: { [key]: val } };
|
|
49
|
+
this.updatePipeline.push(this.currentSetStage);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Recursively walk through the patch *payload* and convert it into `$set`/…
|
|
53
|
+
* statements. Top‑level arrays are delegated to {@link parseArrayPatch}.
|
|
54
|
+
*
|
|
55
|
+
* @param payload Current payload chunk
|
|
56
|
+
* @param prefix Dotted path accumulated so far
|
|
57
|
+
* @private
|
|
58
|
+
*/ flattenPayload(payload, prefix = "") {
|
|
59
|
+
const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
|
|
60
|
+
for (const [_key, value] of Object.entries(payload)) {
|
|
61
|
+
const key = evalKey(_key);
|
|
62
|
+
const flatType = this.collection.flatMap.get(key);
|
|
63
|
+
const topLevelArray = flatType?.metadata?.get("db.__topLevelArray");
|
|
64
|
+
if (typeof value === "object" && !Array.isArray(value) && topLevelArray && !flatType?.metadata?.has("db.json")) this.parseArrayPatch(key, value, flatType);
|
|
65
|
+
else if (typeof value === "object" && flatType?.metadata?.get("db.patch.strategy") === "merge") this.flattenPayload(value, key);
|
|
66
|
+
else if (key !== "_id") this._set(key, value);
|
|
67
|
+
}
|
|
68
|
+
return this.updatePipeline;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Dispatch a *single* array patch. Exactly one of `$replace`, `$insert`,
|
|
72
|
+
* `$upsert`, `$update`, `$remove` must be present – otherwise we throw.
|
|
73
|
+
*
|
|
74
|
+
* @param key Dotted path to the array field
|
|
75
|
+
* @param value Payload slice for that field
|
|
76
|
+
* @private
|
|
77
|
+
*/ parseArrayPatch(key, value, flatType) {
|
|
78
|
+
const toRemove = value.$remove;
|
|
79
|
+
const toReplace = value.$replace;
|
|
80
|
+
const toInsert = value.$insert;
|
|
81
|
+
const toUpsert = value.$upsert;
|
|
82
|
+
const toUpdate = value.$update;
|
|
83
|
+
const keyProps = flatType.type.kind === "array" ? getKeyProps(flatType) : new Set();
|
|
84
|
+
const keys = keyProps.size > 0 ? [...keyProps] : [];
|
|
85
|
+
this._remove(key, toRemove, keys, flatType);
|
|
86
|
+
this._replace(key, toReplace);
|
|
87
|
+
this._insert(key, toInsert, keys, flatType);
|
|
88
|
+
this._upsert(key, toUpsert, keys, flatType);
|
|
89
|
+
this._update(key, toUpdate, keys, flatType);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build an *aggregation‐expression* that checks equality by **all** keys in
|
|
93
|
+
* `keys`. Example output for keys `["id", "lang"]` and bases `a`, `b`:
|
|
94
|
+
* ```json
|
|
95
|
+
* { "$and": [ { "$eq": ["$$a.id", "$$b.id"] }, { "$eq": ["$$a.lang", "$$b.lang"] } ] }
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @param keys Ordered list of key property names
|
|
99
|
+
* @param left Base token for *left* expression (e.g. `"$$el"`)
|
|
100
|
+
* @param right Base token for *right* expression (e.g. `"$$this"`)
|
|
101
|
+
*/ _keysEqual(keys, left, right) {
|
|
102
|
+
const eqs = keys.map((k) => ({ $eq: [`${left}.${k}`, `${right}.${k}`] }));
|
|
103
|
+
return eqs.length === 1 ? eqs[0] : { $and: eqs };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `$replace` – overwrite the entire array with `input`.
|
|
107
|
+
*
|
|
108
|
+
* @param key Dotted path to the array
|
|
109
|
+
* @param input New array value (may be `undefined`)
|
|
110
|
+
* @private
|
|
111
|
+
*/ _replace(key, input) {
|
|
112
|
+
if (input) this._set(key, input);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `$insert`
|
|
116
|
+
* - plain append → $concatArrays
|
|
117
|
+
* - unique / keyed → delegate to _upsert (insert-or-update)
|
|
118
|
+
*/ _insert(key, input, keys, flatType) {
|
|
119
|
+
if (!input?.length) return;
|
|
120
|
+
const uniqueItems = flatType.metadata?.has("expect.array.uniqueItems");
|
|
121
|
+
if (uniqueItems || keys.length > 0) this._upsert(key, input, keys, flatType);
|
|
122
|
+
else this._set(key, { $concatArrays: [{ $ifNull: [`$${key}`, []] }, input] });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `$upsert`
|
|
126
|
+
* - keyed → remove existing matching by key(s) then append candidate
|
|
127
|
+
* - unique → $setUnion (deep equality)
|
|
128
|
+
*/ _upsert(key, input, keys, flatType) {
|
|
129
|
+
if (!input?.length) return;
|
|
130
|
+
if (keys.length > 0) {
|
|
131
|
+
const mergeStrategy = flatType.metadata?.get("db.patch.strategy") === "merge";
|
|
132
|
+
const vars = {
|
|
133
|
+
acc: "$$value",
|
|
134
|
+
cand: "$$this"
|
|
135
|
+
};
|
|
136
|
+
let appendExpr = "$$cand";
|
|
137
|
+
if (mergeStrategy) {
|
|
138
|
+
vars.existing = { $arrayElemAt: [{ $filter: {
|
|
139
|
+
input: "$$value",
|
|
140
|
+
as: "el",
|
|
141
|
+
cond: this._keysEqual(keys, "$$el", "$$this")
|
|
142
|
+
} }, 0] };
|
|
143
|
+
appendExpr = { $cond: [
|
|
144
|
+
{ $ifNull: ["$$existing", false] },
|
|
145
|
+
{ $mergeObjects: ["$$existing", "$$cand"] },
|
|
146
|
+
"$$cand"
|
|
147
|
+
] };
|
|
148
|
+
}
|
|
149
|
+
this._set(key, { $reduce: {
|
|
150
|
+
input,
|
|
151
|
+
initialValue: { $ifNull: [`$${key}`, []] },
|
|
152
|
+
in: { $let: {
|
|
153
|
+
vars,
|
|
154
|
+
in: { $concatArrays: [{ $filter: {
|
|
155
|
+
input: "$$acc",
|
|
156
|
+
as: "el",
|
|
157
|
+
cond: { $not: this._keysEqual(keys, "$$el", "$$cand") }
|
|
158
|
+
} }, [appendExpr]] }
|
|
159
|
+
} }
|
|
160
|
+
} });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `$update`
|
|
167
|
+
* - keyed → map array and merge / replace matching element(s)
|
|
168
|
+
* - non-keyed → behave like `$addToSet` (insert only when not present)
|
|
169
|
+
*/ _update(key, input, keys, flatType) {
|
|
170
|
+
if (!input?.length) return;
|
|
171
|
+
if (keys.length > 0) {
|
|
172
|
+
const mergeStrategy = flatType.metadata?.get("db.patch.strategy") === "merge";
|
|
173
|
+
this._set(key, { $reduce: {
|
|
174
|
+
input,
|
|
175
|
+
initialValue: { $ifNull: [`$${key}`, []] },
|
|
176
|
+
in: { $map: {
|
|
177
|
+
input: "$$value",
|
|
178
|
+
as: "el",
|
|
179
|
+
in: { $cond: [
|
|
180
|
+
this._keysEqual(keys, "$$el", "$$this"),
|
|
181
|
+
mergeStrategy ? { $mergeObjects: ["$$el", "$$this"] } : "$$this",
|
|
182
|
+
"$$el"
|
|
183
|
+
] }
|
|
184
|
+
} }
|
|
185
|
+
} });
|
|
186
|
+
} else this._set(key, { $setUnion: [{ $ifNull: [`$${key}`, []] }, input] });
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* `$remove`
|
|
190
|
+
* - keyed → filter out any element whose key set matches a payload item
|
|
191
|
+
* - non-keyed → deep equality remove (`$setDifference`)
|
|
192
|
+
*/ _remove(key, input, keys, _flatType) {
|
|
193
|
+
if (!input?.length) return;
|
|
194
|
+
if (keys.length > 0) this._set(key, { $let: {
|
|
195
|
+
vars: { rem: input },
|
|
196
|
+
in: { $filter: {
|
|
197
|
+
input: { $ifNull: [`$${key}`, []] },
|
|
198
|
+
as: "el",
|
|
199
|
+
cond: { $not: { $anyElementTrue: { $map: {
|
|
200
|
+
input: "$$rem",
|
|
201
|
+
as: "r",
|
|
202
|
+
in: this._keysEqual(keys, "$$el", "$$r")
|
|
203
|
+
} } } }
|
|
204
|
+
} }
|
|
205
|
+
} });
|
|
206
|
+
else this._set(key, { $setDifference: [{ $ifNull: [`$${key}`, []] }, input] });
|
|
207
|
+
}
|
|
208
|
+
constructor(collection, payload) {
|
|
209
|
+
_define_property$1(this, "collection", void 0);
|
|
210
|
+
_define_property$1(this, "payload", void 0);
|
|
211
|
+
/**
|
|
212
|
+
* Internal accumulator: filter passed to `updateOne()`.
|
|
213
|
+
* Filled only with the `_id` field right now.
|
|
214
|
+
*/ _define_property$1(this, "filterObj", void 0);
|
|
215
|
+
/** MongoDB *update* document being built. */ _define_property$1(this, "updatePipeline", void 0);
|
|
216
|
+
/** Current `$set` stage being populated. */ _define_property$1(this, "currentSetStage", void 0);
|
|
217
|
+
/** Additional *options* (mainly `arrayFilters`). */ _define_property$1(this, "optionsObj", void 0);
|
|
218
|
+
this.collection = collection;
|
|
219
|
+
this.payload = payload;
|
|
220
|
+
this.filterObj = {};
|
|
221
|
+
this.updatePipeline = [];
|
|
222
|
+
this.currentSetStage = null;
|
|
223
|
+
this.optionsObj = {};
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
_define_property$1(CollectionPatcher, "getKeyProps", getKeyProps);
|
|
227
|
+
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region packages/db-mongo/src/lib/mongo-types.ts
|
|
230
|
+
const INDEX_PREFIX = "atscript__";
|
|
231
|
+
const DEFAULT_INDEX_NAME = "DEFAULT";
|
|
232
|
+
const JOINED_PREFIX = "__joined_";
|
|
233
|
+
function mongoIndexKey(type, name) {
|
|
234
|
+
const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
|
|
235
|
+
return `${INDEX_PREFIX}${type}__${cleanName}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region packages/db-mongo/src/lib/mongo-relations.ts
|
|
240
|
+
function buildPKKey(primaryKeys, doc) {
|
|
241
|
+
if (primaryKeys.length === 1) return String(doc[primaryKeys[0]] ?? "");
|
|
242
|
+
let key = "";
|
|
243
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
244
|
+
if (i > 0) key += "\0";
|
|
245
|
+
key += String(doc[primaryKeys[i]] ?? "");
|
|
246
|
+
}
|
|
247
|
+
return key;
|
|
248
|
+
}
|
|
249
|
+
async function loadRelationsImpl(host, rows, withRelations, relations, foreignKeys, tableResolver) {
|
|
250
|
+
if (rows.length === 0 || withRelations.length === 0) return;
|
|
251
|
+
const primaryKeys = host._table.primaryKeys;
|
|
252
|
+
const relMeta = [];
|
|
253
|
+
for (const withRel of withRelations) {
|
|
254
|
+
if (withRel.name.includes(".")) continue;
|
|
255
|
+
const relation = relations.get(withRel.name);
|
|
256
|
+
if (!relation) throw new Error(`Unknown relation "${withRel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
|
|
257
|
+
const lookupResult = buildRelationLookup(host, withRel, relation, foreignKeys, tableResolver);
|
|
258
|
+
if (!lookupResult) continue;
|
|
259
|
+
relMeta.push({
|
|
260
|
+
name: withRel.name,
|
|
261
|
+
isArray: lookupResult.isArray,
|
|
262
|
+
relation,
|
|
263
|
+
nestedWith: extractNestedWith(withRel),
|
|
264
|
+
stages: lookupResult.stages
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (relMeta.length === 0) return;
|
|
268
|
+
const pkMatchFilter = buildPKMatchFilter(rows, primaryKeys);
|
|
269
|
+
if (pkMatchFilter) {
|
|
270
|
+
const pipeline = [{ $match: pkMatchFilter }];
|
|
271
|
+
for (const meta of relMeta) pipeline.push(...meta.stages);
|
|
272
|
+
const results = await host.collection.aggregate(pipeline, host._getSessionOpts()).toArray();
|
|
273
|
+
mergeRelationResults(rows, results, primaryKeys, relMeta);
|
|
274
|
+
} else for (const row of rows) for (const meta of relMeta) row[meta.name] = meta.isArray ? [] : null;
|
|
275
|
+
await loadNestedRelations(rows, relMeta, tableResolver);
|
|
276
|
+
}
|
|
277
|
+
/** Builds a $match filter to re-select source rows by PK. */ function buildPKMatchFilter(rows, primaryKeys) {
|
|
278
|
+
if (primaryKeys.length === 1) {
|
|
279
|
+
const pk = primaryKeys[0];
|
|
280
|
+
const values = new Set();
|
|
281
|
+
for (const row of rows) {
|
|
282
|
+
const v = row[pk];
|
|
283
|
+
if (v !== null && v !== undefined) values.add(v);
|
|
284
|
+
}
|
|
285
|
+
if (values.size === 0) return undefined;
|
|
286
|
+
return { [pk]: { $in: [...values] } };
|
|
287
|
+
}
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
const orFilters = [];
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
const key = buildPKKey(primaryKeys, row);
|
|
292
|
+
if (seen.has(key)) continue;
|
|
293
|
+
seen.add(key);
|
|
294
|
+
const condition = {};
|
|
295
|
+
let valid = true;
|
|
296
|
+
for (const pk of primaryKeys) {
|
|
297
|
+
const val = row[pk];
|
|
298
|
+
if (val === null || val === undefined) {
|
|
299
|
+
valid = false;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
condition[pk] = val;
|
|
303
|
+
}
|
|
304
|
+
if (valid) orFilters.push(condition);
|
|
305
|
+
}
|
|
306
|
+
if (orFilters.length === 0) return undefined;
|
|
307
|
+
return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
|
|
308
|
+
}
|
|
309
|
+
/** Dispatches to the correct $lookup builder based on relation direction. */ function buildRelationLookup(host, withRel, relation, foreignKeys, tableResolver) {
|
|
310
|
+
switch (relation.direction) {
|
|
311
|
+
case "to": return buildToLookup(withRel, relation, foreignKeys);
|
|
312
|
+
case "from": return buildFromLookup(host, withRel, relation, tableResolver);
|
|
313
|
+
case "via": return buildViaLookup(host, withRel, relation, tableResolver);
|
|
314
|
+
default: return undefined;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Builds `let` variable bindings and the corresponding `$expr` match for `$lookup`. */ function buildLookupJoin(localFields, remoteFields, varPrefix) {
|
|
318
|
+
const letVars = {};
|
|
319
|
+
for (let i = 0; i < localFields.length; i++) letVars[`${varPrefix}${i}`] = `$${localFields[i]}`;
|
|
320
|
+
if (remoteFields.length === 1) return {
|
|
321
|
+
letVars,
|
|
322
|
+
exprMatch: { $eq: [`$${remoteFields[0]}`, `$$${varPrefix}0`] }
|
|
323
|
+
};
|
|
324
|
+
const andClauses = [];
|
|
325
|
+
for (let i = 0; i < remoteFields.length; i++) andClauses.push({ $eq: [`$${remoteFields[i]}`, `$$${varPrefix}${i}`] });
|
|
326
|
+
return {
|
|
327
|
+
letVars,
|
|
328
|
+
exprMatch: { $and: andClauses }
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** $lookup for TO relations (FK is on this table → target). Always single-valued. */ function buildToLookup(withRel, relation, foreignKeys) {
|
|
332
|
+
const fk = findFKForRelation(relation, foreignKeys);
|
|
333
|
+
if (!fk) return undefined;
|
|
334
|
+
const innerPipeline = buildLookupInnerPipeline(withRel, fk.targetFields);
|
|
335
|
+
const { letVars, exprMatch } = buildLookupJoin(fk.localFields, fk.targetFields, "fk_");
|
|
336
|
+
const stages = [{ $lookup: {
|
|
337
|
+
from: fk.targetTable,
|
|
338
|
+
let: letVars,
|
|
339
|
+
pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
|
|
340
|
+
as: withRel.name
|
|
341
|
+
} }, { $unwind: {
|
|
342
|
+
path: `$${withRel.name}`,
|
|
343
|
+
preserveNullAndEmptyArrays: true
|
|
344
|
+
} }];
|
|
345
|
+
return {
|
|
346
|
+
stages,
|
|
347
|
+
isArray: false
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** $lookup for FROM relations (FK is on target → this table). */ function buildFromLookup(host, withRel, relation, tableResolver) {
|
|
351
|
+
const targetType = relation.targetType();
|
|
352
|
+
if (!targetType || !tableResolver) return undefined;
|
|
353
|
+
const targetMeta = tableResolver(targetType);
|
|
354
|
+
if (!targetMeta) return undefined;
|
|
355
|
+
const remoteFK = findRemoteFK(targetMeta, host._table.tableName, relation.alias);
|
|
356
|
+
if (!remoteFK) return undefined;
|
|
357
|
+
const targetTableName = resolveRelTargetTableName(relation);
|
|
358
|
+
const innerPipeline = buildLookupInnerPipeline(withRel, remoteFK.fields);
|
|
359
|
+
const { letVars, exprMatch } = buildLookupJoin(remoteFK.targetFields, remoteFK.fields, "pk_");
|
|
360
|
+
const stages = [{ $lookup: {
|
|
361
|
+
from: targetTableName,
|
|
362
|
+
let: letVars,
|
|
363
|
+
pipeline: [{ $match: { $expr: exprMatch } }, ...innerPipeline],
|
|
364
|
+
as: withRel.name
|
|
365
|
+
} }];
|
|
366
|
+
if (!relation.isArray) stages.push({ $unwind: {
|
|
367
|
+
path: `$${withRel.name}`,
|
|
368
|
+
preserveNullAndEmptyArrays: true
|
|
369
|
+
} });
|
|
370
|
+
return {
|
|
371
|
+
stages,
|
|
372
|
+
isArray: relation.isArray
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** $lookup for VIA relations (M:N through junction table). Always array. */ function buildViaLookup(host, withRel, relation, tableResolver) {
|
|
376
|
+
if (!relation.viaType || !tableResolver) return undefined;
|
|
377
|
+
const junctionType = relation.viaType();
|
|
378
|
+
if (!junctionType) return undefined;
|
|
379
|
+
const junctionMeta = tableResolver(junctionType);
|
|
380
|
+
if (!junctionMeta) return undefined;
|
|
381
|
+
const junctionTableName = junctionType.metadata?.get("db.table") || junctionType.id || "";
|
|
382
|
+
const targetTableName = resolveRelTargetTableName(relation);
|
|
383
|
+
const fkToThis = findRemoteFK(junctionMeta, host._table.tableName);
|
|
384
|
+
if (!fkToThis) return undefined;
|
|
385
|
+
const fkToTarget = findRemoteFK(junctionMeta, targetTableName);
|
|
386
|
+
if (!fkToTarget) return undefined;
|
|
387
|
+
const innerPipeline = buildLookupInnerPipeline(withRel, fkToTarget.targetFields);
|
|
388
|
+
const { letVars, exprMatch } = buildLookupJoin(fkToThis.targetFields, fkToThis.fields, "pk_");
|
|
389
|
+
const stages = [{ $lookup: {
|
|
390
|
+
from: junctionTableName,
|
|
391
|
+
let: letVars,
|
|
392
|
+
pipeline: [
|
|
393
|
+
{ $match: { $expr: exprMatch } },
|
|
394
|
+
{ $lookup: {
|
|
395
|
+
from: targetTableName,
|
|
396
|
+
localField: fkToTarget.fields[0],
|
|
397
|
+
foreignField: fkToTarget.targetFields[0],
|
|
398
|
+
pipeline: innerPipeline,
|
|
399
|
+
as: "__target"
|
|
400
|
+
} },
|
|
401
|
+
{ $unwind: {
|
|
402
|
+
path: "$__target",
|
|
403
|
+
preserveNullAndEmptyArrays: false
|
|
404
|
+
} },
|
|
405
|
+
{ $replaceRoot: { newRoot: "$__target" } }
|
|
406
|
+
],
|
|
407
|
+
as: withRel.name
|
|
408
|
+
} }];
|
|
409
|
+
return {
|
|
410
|
+
stages,
|
|
411
|
+
isArray: true
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/** Builds inner pipeline stages for relation controls ($sort, $limit, $skip, $select, filter). */ function buildLookupInnerPipeline(withRel, requiredFields) {
|
|
415
|
+
const pipeline = [];
|
|
416
|
+
const flatRel = withRel;
|
|
417
|
+
const nested = withRel.controls || {};
|
|
418
|
+
const filter = withRel.filter;
|
|
419
|
+
const sort = nested.$sort || flatRel.$sort;
|
|
420
|
+
const limit = nested.$limit ?? flatRel.$limit;
|
|
421
|
+
const skip = nested.$skip ?? flatRel.$skip;
|
|
422
|
+
const select = nested.$select || flatRel.$select;
|
|
423
|
+
if (filter && Object.keys(filter).length > 0) pipeline.push({ $match: buildMongoFilter(filter) });
|
|
424
|
+
if (sort) pipeline.push({ $sort: sort });
|
|
425
|
+
if (skip) pipeline.push({ $skip: skip });
|
|
426
|
+
if (limit !== null && limit !== undefined) pipeline.push({ $limit: limit });
|
|
427
|
+
if (select) {
|
|
428
|
+
const projection = {};
|
|
429
|
+
for (const f of select) projection[f] = 1;
|
|
430
|
+
for (const f of requiredFields) projection[f] = 1;
|
|
431
|
+
if (!select.includes("_id") && !requiredFields.includes("_id")) projection["_id"] = 0;
|
|
432
|
+
pipeline.push({ $project: projection });
|
|
433
|
+
}
|
|
434
|
+
return pipeline;
|
|
435
|
+
}
|
|
436
|
+
/** Extracts nested $with from a WithRelation's controls. */ function extractNestedWith(withRel) {
|
|
437
|
+
const flatRel = withRel;
|
|
438
|
+
const nested = withRel.controls || {};
|
|
439
|
+
const nestedWith = nested.$with || flatRel.$with;
|
|
440
|
+
return nestedWith && nestedWith.length > 0 ? nestedWith : undefined;
|
|
441
|
+
}
|
|
442
|
+
/** Post-processes nested $with by delegating to the target table's own relation loading. */ async function loadNestedRelations(rows, relMeta, tableResolver) {
|
|
443
|
+
if (!tableResolver) return;
|
|
444
|
+
const tasks = [];
|
|
445
|
+
for (const meta of relMeta) {
|
|
446
|
+
if (!meta.nestedWith || meta.nestedWith.length === 0) continue;
|
|
447
|
+
const targetType = meta.relation.targetType();
|
|
448
|
+
if (!targetType) continue;
|
|
449
|
+
const targetTable = tableResolver(targetType);
|
|
450
|
+
if (!targetTable) continue;
|
|
451
|
+
const subRows = [];
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
const val = row[meta.name];
|
|
454
|
+
if (meta.isArray && Array.isArray(val)) for (const item of val) subRows.push(item);
|
|
455
|
+
else if (val && typeof val === "object") subRows.push(val);
|
|
456
|
+
}
|
|
457
|
+
if (subRows.length === 0) continue;
|
|
458
|
+
tasks.push(targetTable.loadRelations(subRows, meta.nestedWith));
|
|
459
|
+
}
|
|
460
|
+
await Promise.all(tasks);
|
|
461
|
+
}
|
|
462
|
+
/** Merges aggregation results back onto the original rows by PK. */ function mergeRelationResults(rows, results, primaryKeys, relMeta) {
|
|
463
|
+
const resultIndex = new Map();
|
|
464
|
+
for (const doc of results) resultIndex.set(buildPKKey(primaryKeys, doc), doc);
|
|
465
|
+
for (const row of rows) {
|
|
466
|
+
const enriched = resultIndex.get(buildPKKey(primaryKeys, row));
|
|
467
|
+
for (const meta of relMeta) if (enriched) {
|
|
468
|
+
const value = enriched[meta.name];
|
|
469
|
+
if (!meta.isArray && Array.isArray(value)) row[meta.name] = value[0] ?? null;
|
|
470
|
+
else row[meta.name] = value ?? (meta.isArray ? [] : null);
|
|
471
|
+
} else row[meta.name] = meta.isArray ? [] : null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** Finds FK entry for a TO relation from this table's foreignKeys map. */ function findFKForRelation(relation, foreignKeys) {
|
|
475
|
+
const targetTableName = resolveRelTargetTableName(relation);
|
|
476
|
+
for (const fk of foreignKeys.values()) if (relation.alias) {
|
|
477
|
+
if (fk.alias === relation.alias) return {
|
|
478
|
+
localFields: fk.fields,
|
|
479
|
+
targetFields: fk.targetFields,
|
|
480
|
+
targetTable: fk.targetTable
|
|
481
|
+
};
|
|
482
|
+
} else if (fk.targetTable === targetTableName) return {
|
|
483
|
+
localFields: fk.fields,
|
|
484
|
+
targetFields: fk.targetFields,
|
|
485
|
+
targetTable: fk.targetTable
|
|
486
|
+
};
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
/** Finds a FK on a remote table that points back to the given table name. */ function findRemoteFK(target, thisTableName, alias) {
|
|
490
|
+
for (const fk of target.foreignKeys.values()) {
|
|
491
|
+
if (alias && fk.alias === alias && fk.targetTable === thisTableName) return fk;
|
|
492
|
+
if (!alias && fk.targetTable === thisTableName) return fk;
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
/** Resolves the target table/collection name from a relation's target type. */ function resolveRelTargetTableName(relation) {
|
|
497
|
+
const targetType = relation.targetType();
|
|
498
|
+
return targetType?.metadata?.get("db.table") || targetType?.id || "";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region packages/db-mongo/src/lib/mongo-search.ts
|
|
503
|
+
function getSearchIndexesImpl(host) {
|
|
504
|
+
const result = [];
|
|
505
|
+
for (const [name, index] of host.getMongoSearchIndexes()) result.push({
|
|
506
|
+
name,
|
|
507
|
+
description: `${index.type} index`,
|
|
508
|
+
type: index.type === "vector" ? "vector" : "text"
|
|
509
|
+
});
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
function isVectorSearchableImpl(host) {
|
|
513
|
+
for (const index of host.getMongoSearchIndexes().values()) if (index.type === "vector") return true;
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
async function searchImpl(host, text, query, indexName) {
|
|
517
|
+
const stage = buildSearchStage(host, text, indexName);
|
|
518
|
+
if (!stage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
|
|
519
|
+
return runSearchPipeline(host, stage, query, "search");
|
|
520
|
+
}
|
|
521
|
+
async function searchWithCountImpl(host, text, query, indexName) {
|
|
522
|
+
const stage = buildSearchStage(host, text, indexName);
|
|
523
|
+
if (!stage) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
|
|
524
|
+
return runSearchWithCountPipeline(host, stage, query, "searchWithCount");
|
|
525
|
+
}
|
|
526
|
+
async function vectorSearchImpl(host, vector, query, indexName) {
|
|
527
|
+
const controls = query.controls || {};
|
|
528
|
+
const stage = buildVectorSearchStage(host, vector, indexName, controls.$limit);
|
|
529
|
+
const threshold = resolveThreshold(host, controls, indexName);
|
|
530
|
+
return runSearchPipeline(host, stage, query, "vectorSearch", threshold);
|
|
531
|
+
}
|
|
532
|
+
async function vectorSearchWithCountImpl(host, vector, query, indexName) {
|
|
533
|
+
const controls = query.controls || {};
|
|
534
|
+
const stage = buildVectorSearchStage(host, vector, indexName, controls.$limit);
|
|
535
|
+
const threshold = resolveThreshold(host, controls, indexName);
|
|
536
|
+
return runSearchWithCountPipeline(host, stage, query, "vectorSearchWithCount", threshold);
|
|
537
|
+
}
|
|
538
|
+
/** Resolves the effective threshold: query-time $threshold > schema-level @db.search.vector.threshold. */ function resolveThreshold(host, controls, indexName) {
|
|
539
|
+
const queryThreshold = controls.$threshold;
|
|
540
|
+
if (queryThreshold !== undefined) return queryThreshold;
|
|
541
|
+
return host.getVectorThreshold(indexName);
|
|
542
|
+
}
|
|
543
|
+
/** Builds a MongoDB $search pipeline stage for text search. */ function buildSearchStage(host, text, indexName) {
|
|
544
|
+
const index = host.getMongoSearchIndex(indexName);
|
|
545
|
+
if (!index) return undefined;
|
|
546
|
+
if (index.type === "vector") throw new Error("Vector indexes cannot be used with text search. Use vectorSearch() instead.");
|
|
547
|
+
return { $search: {
|
|
548
|
+
index: index.key,
|
|
549
|
+
text: {
|
|
550
|
+
query: text,
|
|
551
|
+
path: { wildcard: "*" }
|
|
552
|
+
}
|
|
553
|
+
} };
|
|
554
|
+
}
|
|
555
|
+
/** Builds a $vectorSearch aggregation stage from a pre-computed vector. */ function buildVectorSearchStage(host, vector, indexName, limit) {
|
|
556
|
+
let index;
|
|
557
|
+
if (indexName) {
|
|
558
|
+
const found = host.getMongoSearchIndex(indexName);
|
|
559
|
+
if (!found || found.type !== "vector") throw new Error(`Vector index "${indexName}" not found`);
|
|
560
|
+
index = found;
|
|
561
|
+
} else for (const idx of host.getMongoSearchIndexes().values()) if (idx.type === "vector") {
|
|
562
|
+
index = idx;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
if (!index) throw new Error("No vector index available");
|
|
566
|
+
let vectorField;
|
|
567
|
+
if (index.definition.fields) {
|
|
568
|
+
for (const f of index.definition.fields) if (f.type === "vector") {
|
|
569
|
+
vectorField = f;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!vectorField) throw new Error(`Vector index "${index.name}" has no vector field`);
|
|
574
|
+
return { $vectorSearch: {
|
|
575
|
+
index: index.key,
|
|
576
|
+
path: vectorField.path,
|
|
577
|
+
queryVector: vector,
|
|
578
|
+
numCandidates: Math.max((limit || 20) * 10, 100),
|
|
579
|
+
limit: limit || 20
|
|
580
|
+
} };
|
|
581
|
+
}
|
|
582
|
+
/** Runs a search/vector pipeline and returns results. Shared by search + vectorSearch. */ async function runSearchPipeline(host, stage, query, label, threshold) {
|
|
583
|
+
const filter = buildMongoFilter(query.filter);
|
|
584
|
+
const controls = query.controls || {};
|
|
585
|
+
const pipeline = [stage];
|
|
586
|
+
if (threshold !== undefined) {
|
|
587
|
+
pipeline.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
|
|
588
|
+
pipeline.push({ $match: { _score: { $gte: threshold } } });
|
|
589
|
+
}
|
|
590
|
+
pipeline.push({ $match: filter });
|
|
591
|
+
if (controls.$sort) pipeline.push({ $sort: controls.$sort });
|
|
592
|
+
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
593
|
+
if (controls.$limit) pipeline.push({ $limit: controls.$limit });
|
|
594
|
+
else pipeline.push({ $limit: 1e3 });
|
|
595
|
+
if (controls.$select) pipeline.push({ $project: controls.$select.asProjection });
|
|
596
|
+
host._log(`aggregate (${label})`, pipeline);
|
|
597
|
+
return host.collection.aggregate(pipeline, host._getSessionOpts()).toArray();
|
|
598
|
+
}
|
|
599
|
+
/** Runs a search/vector pipeline with $facet for count. Shared by searchWithCount + vectorSearchWithCount. */ async function runSearchWithCountPipeline(host, stage, query, label, threshold) {
|
|
600
|
+
const filter = buildMongoFilter(query.filter);
|
|
601
|
+
const controls = query.controls || {};
|
|
602
|
+
const preStages = [];
|
|
603
|
+
if (threshold !== undefined) {
|
|
604
|
+
preStages.push({ $addFields: { _score: { $meta: "vectorSearchScore" } } });
|
|
605
|
+
preStages.push({ $match: { _score: { $gte: threshold } } });
|
|
606
|
+
}
|
|
607
|
+
const dataStages = [];
|
|
608
|
+
if (controls.$sort) dataStages.push({ $sort: controls.$sort });
|
|
609
|
+
if (controls.$skip) dataStages.push({ $skip: controls.$skip });
|
|
610
|
+
if (controls.$limit) dataStages.push({ $limit: controls.$limit });
|
|
611
|
+
if (controls.$select) dataStages.push({ $project: controls.$select.asProjection });
|
|
612
|
+
const pipeline = [
|
|
613
|
+
stage,
|
|
614
|
+
...preStages,
|
|
615
|
+
{ $match: filter },
|
|
616
|
+
{ $facet: {
|
|
617
|
+
data: dataStages,
|
|
618
|
+
meta: [{ $count: "count" }]
|
|
619
|
+
} }
|
|
620
|
+
];
|
|
621
|
+
host._log(`aggregate (${label})`, pipeline);
|
|
622
|
+
const result = await host.collection.aggregate(pipeline, host._getSessionOpts()).toArray();
|
|
623
|
+
return {
|
|
624
|
+
data: result[0]?.data || [],
|
|
625
|
+
count: result[0]?.meta[0]?.count || 0
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region packages/db-mongo/src/lib/mongo-schema-sync.ts
|
|
631
|
+
const DESTRUCTIVE_OPTION_KEYS = new Set([
|
|
632
|
+
"capped",
|
|
633
|
+
"capped.size",
|
|
634
|
+
"capped.max"
|
|
635
|
+
]);
|
|
636
|
+
async function tableExistsImpl(host) {
|
|
637
|
+
return host.collectionExists();
|
|
638
|
+
}
|
|
639
|
+
function getDesiredTableOptionsImpl(cappedOptions) {
|
|
640
|
+
if (!cappedOptions) return [];
|
|
641
|
+
const opts = [{
|
|
642
|
+
key: "capped",
|
|
643
|
+
value: "true"
|
|
644
|
+
}, {
|
|
645
|
+
key: "capped.size",
|
|
646
|
+
value: String(cappedOptions.size)
|
|
647
|
+
}];
|
|
648
|
+
if (cappedOptions.max !== undefined) opts.push({
|
|
649
|
+
key: "capped.max",
|
|
650
|
+
value: String(cappedOptions.max)
|
|
651
|
+
});
|
|
652
|
+
return opts;
|
|
653
|
+
}
|
|
654
|
+
async function getExistingTableOptionsImpl(host) {
|
|
655
|
+
const cols = await host.db.listCollections({ name: host._table.tableName }, { nameOnly: false }).toArray();
|
|
656
|
+
if (cols.length === 0) return [];
|
|
657
|
+
const collOpts = cols[0].options;
|
|
658
|
+
if (!collOpts?.capped) return [];
|
|
659
|
+
const opts = [{
|
|
660
|
+
key: "capped",
|
|
661
|
+
value: "true"
|
|
662
|
+
}];
|
|
663
|
+
if (collOpts.size !== undefined) opts.push({
|
|
664
|
+
key: "capped.size",
|
|
665
|
+
value: String(collOpts.size)
|
|
666
|
+
});
|
|
667
|
+
if (collOpts.max !== undefined) opts.push({
|
|
668
|
+
key: "capped.max",
|
|
669
|
+
value: String(collOpts.max)
|
|
670
|
+
});
|
|
671
|
+
return opts;
|
|
672
|
+
}
|
|
673
|
+
async function ensureTableImpl(host, table) {
|
|
674
|
+
if (table instanceof AtscriptDbView && !table.isExternal) return ensureView(host, table);
|
|
675
|
+
return host.ensureCollectionExists();
|
|
676
|
+
}
|
|
677
|
+
/** Creates a MongoDB view from the AtscriptDbView's view plan. */ async function ensureView(host, view) {
|
|
678
|
+
const exists = await host.collectionExists();
|
|
679
|
+
if (exists) return;
|
|
680
|
+
const plan = view.viewPlan;
|
|
681
|
+
const columns = view.getViewColumnMappings();
|
|
682
|
+
const pipeline = [];
|
|
683
|
+
for (const join of plan.joins) {
|
|
684
|
+
const { localField, foreignField } = resolveJoinFields(join.condition, plan.entryTable, join.targetTable);
|
|
685
|
+
pipeline.push({ $lookup: {
|
|
686
|
+
from: join.targetTable,
|
|
687
|
+
localField,
|
|
688
|
+
foreignField,
|
|
689
|
+
as: `${JOINED_PREFIX}${join.targetTable}`
|
|
690
|
+
} });
|
|
691
|
+
pipeline.push({ $unwind: {
|
|
692
|
+
path: `$__joined_${join.targetTable}`,
|
|
693
|
+
preserveNullAndEmptyArrays: true
|
|
694
|
+
} });
|
|
695
|
+
}
|
|
696
|
+
if (plan.filter) {
|
|
697
|
+
const matchExpr = queryNodeToMatch(plan.filter, plan.entryTable);
|
|
698
|
+
pipeline.push({ $match: matchExpr });
|
|
699
|
+
}
|
|
700
|
+
const hasAggregates = columns.some((c) => c.aggFn);
|
|
701
|
+
/** Resolves a column to its MongoDB source field path. */ const colSourceField = (col) => col.sourceTable === plan.entryTable ? `$${col.sourceColumn}` : `$${JOINED_PREFIX}${col.sourceTable}.${col.sourceColumn}`;
|
|
702
|
+
if (hasAggregates) {
|
|
703
|
+
const group = { _id: {} };
|
|
704
|
+
const project = { _id: 0 };
|
|
705
|
+
for (const col of columns) if (col.aggFn) {
|
|
706
|
+
if (col.aggFn === "count" && col.aggField === "*") group[col.viewColumn] = { $sum: 1 };
|
|
707
|
+
else group[col.viewColumn] = { [`$${col.aggFn}`]: colSourceField(col) };
|
|
708
|
+
project[col.viewColumn] = `$${col.viewColumn}`;
|
|
709
|
+
} else {
|
|
710
|
+
group._id[col.viewColumn] = colSourceField(col);
|
|
711
|
+
project[col.viewColumn] = `$_id.${col.viewColumn}`;
|
|
712
|
+
}
|
|
713
|
+
pipeline.push({ $group: group });
|
|
714
|
+
if (plan.having) {
|
|
715
|
+
const havingMatch = queryNodeToHaving(plan.having, columns);
|
|
716
|
+
pipeline.push({ $match: havingMatch });
|
|
717
|
+
}
|
|
718
|
+
pipeline.push({ $project: project });
|
|
719
|
+
} else {
|
|
720
|
+
const project = { _id: 0 };
|
|
721
|
+
for (const col of columns) project[col.viewColumn] = colSourceField(col);
|
|
722
|
+
pipeline.push({ $project: project });
|
|
723
|
+
}
|
|
724
|
+
host._log("createView", host._table.tableName, plan.entryTable, pipeline);
|
|
725
|
+
await host.db.createCollection(host._table.tableName, {
|
|
726
|
+
viewOn: plan.entryTable,
|
|
727
|
+
pipeline
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/** Extracts localField/foreignField from a join condition. */ function resolveJoinFields(condition, entryTable, joinTable) {
|
|
731
|
+
const comp = "$and" in condition ? condition.$and[0] : condition;
|
|
732
|
+
const c = comp;
|
|
733
|
+
const leftTable = c.left.type ? c.left.type()?.metadata?.get("db.table") || "" : entryTable;
|
|
734
|
+
if (leftTable === joinTable) return {
|
|
735
|
+
localField: c.right.field,
|
|
736
|
+
foreignField: c.left.field
|
|
737
|
+
};
|
|
738
|
+
return {
|
|
739
|
+
localField: c.left.field,
|
|
740
|
+
foreignField: c.right.field
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/** Translates an AtscriptQueryNode to a MongoDB $match expression. */ function queryNodeToMatch(node, entryTable) {
|
|
744
|
+
if ("$and" in node) {
|
|
745
|
+
const items = node.$and;
|
|
746
|
+
const result = [];
|
|
747
|
+
for (const item of items) result.push(queryNodeToMatch(item, entryTable));
|
|
748
|
+
return { $and: result };
|
|
749
|
+
}
|
|
750
|
+
if ("$or" in node) {
|
|
751
|
+
const items = node.$or;
|
|
752
|
+
const result = [];
|
|
753
|
+
for (const item of items) result.push(queryNodeToMatch(item, entryTable));
|
|
754
|
+
return { $or: result };
|
|
755
|
+
}
|
|
756
|
+
if ("$not" in node) return { $not: queryNodeToMatch(node.$not, entryTable) };
|
|
757
|
+
const comp = node;
|
|
758
|
+
const fieldPath = resolveViewFieldPath(comp.left, entryTable);
|
|
759
|
+
if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
|
|
760
|
+
const rightPath = resolveViewFieldPath(comp.right, entryTable);
|
|
761
|
+
return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
|
|
762
|
+
}
|
|
763
|
+
if (comp.op === "$eq") return { [fieldPath]: comp.right };
|
|
764
|
+
if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
|
|
765
|
+
return { [fieldPath]: { [comp.op]: comp.right } };
|
|
766
|
+
}
|
|
767
|
+
/** Resolves a field ref to a MongoDB dot path for view pipeline expressions. */ function resolveViewFieldPath(ref, entryTable) {
|
|
768
|
+
if (!ref.type) return ref.field;
|
|
769
|
+
const table = ref.type()?.metadata?.get("db.table") || "";
|
|
770
|
+
if (table === entryTable) return ref.field;
|
|
771
|
+
return `${JOINED_PREFIX}${table}.${ref.field}`;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Translates an AtscriptQueryNode to a MongoDB $match for use after $group (HAVING).
|
|
775
|
+
* After $group, aggregate fields are top-level and dimension fields are under _id.
|
|
776
|
+
*/ function queryNodeToHaving(node, columns) {
|
|
777
|
+
const colMap = new Map(columns.map((c) => [c.viewColumn, c]));
|
|
778
|
+
const resolveHavingField = (ref) => {
|
|
779
|
+
if (!ref.type) {
|
|
780
|
+
const col = colMap.get(ref.field);
|
|
781
|
+
if (col?.aggFn) return ref.field;
|
|
782
|
+
if (col) return `_id.${ref.field}`;
|
|
783
|
+
}
|
|
784
|
+
return ref.field;
|
|
785
|
+
};
|
|
786
|
+
return queryNodeToHavingInner(node, resolveHavingField);
|
|
787
|
+
}
|
|
788
|
+
function queryNodeToHavingInner(node, resolveField) {
|
|
789
|
+
if ("$and" in node) return { $and: node.$and.map((n) => queryNodeToHavingInner(n, resolveField)) };
|
|
790
|
+
if ("$or" in node) return { $or: node.$or.map((n) => queryNodeToHavingInner(n, resolveField)) };
|
|
791
|
+
if ("$not" in node) return { $not: queryNodeToHavingInner(node.$not, resolveField) };
|
|
792
|
+
const comp = node;
|
|
793
|
+
const fieldPath = resolveField(comp.left);
|
|
794
|
+
if (comp.right && typeof comp.right === "object" && "field" in comp.right) {
|
|
795
|
+
const rightPath = resolveField(comp.right);
|
|
796
|
+
return { $expr: { [comp.op]: [`$${fieldPath}`, `$${rightPath}`] } };
|
|
797
|
+
}
|
|
798
|
+
if (comp.op === "$eq") return { [fieldPath]: comp.right };
|
|
799
|
+
if (comp.op === "$ne") return { [fieldPath]: { $ne: comp.right } };
|
|
800
|
+
return { [fieldPath]: { [comp.op]: comp.right } };
|
|
801
|
+
}
|
|
802
|
+
async function dropTableImpl(host) {
|
|
803
|
+
host._log("drop", host._table.tableName);
|
|
804
|
+
await host.collection.drop();
|
|
805
|
+
host.clearCollectionCache();
|
|
806
|
+
}
|
|
807
|
+
async function dropViewByNameImpl(host, viewName) {
|
|
808
|
+
host._log("dropView", viewName);
|
|
809
|
+
try {
|
|
810
|
+
await host.db.collection(viewName).drop();
|
|
811
|
+
} catch {}
|
|
812
|
+
}
|
|
813
|
+
async function dropTableByNameImpl(host, tableName) {
|
|
814
|
+
host._log("dropByName", tableName);
|
|
815
|
+
try {
|
|
816
|
+
await host.db.collection(tableName).drop();
|
|
817
|
+
} catch {}
|
|
818
|
+
}
|
|
819
|
+
async function recreateTableImpl(host) {
|
|
820
|
+
const tableName = host._table.tableName;
|
|
821
|
+
host._log("recreateTable", tableName);
|
|
822
|
+
const tempName = `${tableName}__tmp_${Date.now()}`;
|
|
823
|
+
const source = host.db.collection(tableName);
|
|
824
|
+
const count = await source.countDocuments();
|
|
825
|
+
if (count > 0) await source.aggregate([{ $out: tempName }]).toArray();
|
|
826
|
+
await host.collection.drop();
|
|
827
|
+
host.clearCollectionCache();
|
|
828
|
+
await host.ensureCollectionExists();
|
|
829
|
+
if (count > 0) {
|
|
830
|
+
const temp = host.db.collection(tempName);
|
|
831
|
+
await temp.aggregate([{ $merge: { into: tableName } }]).toArray();
|
|
832
|
+
await temp.drop();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
async function renameTableImpl(host, oldName) {
|
|
836
|
+
const newName = host.resolveTableName(false);
|
|
837
|
+
host._log("renameTable", oldName, "→", newName);
|
|
838
|
+
await host.db.renameCollection(oldName, newName);
|
|
839
|
+
host.clearCollectionCache();
|
|
840
|
+
}
|
|
841
|
+
async function syncColumnsImpl(host, diff) {
|
|
842
|
+
const renamed = [];
|
|
843
|
+
const added = [];
|
|
844
|
+
const update = {};
|
|
845
|
+
if (diff.renamed.length > 0) {
|
|
846
|
+
const renameSpec = {};
|
|
847
|
+
for (const r of diff.renamed) {
|
|
848
|
+
renameSpec[r.oldName] = r.field.physicalName;
|
|
849
|
+
renamed.push(r.field.physicalName);
|
|
850
|
+
}
|
|
851
|
+
update.$rename = renameSpec;
|
|
852
|
+
}
|
|
853
|
+
if (diff.added.length > 0) {
|
|
854
|
+
const setSpec = {};
|
|
855
|
+
for (const field of diff.added) {
|
|
856
|
+
const defaultVal = resolveSyncDefault(field);
|
|
857
|
+
if (defaultVal !== undefined) setSpec[field.physicalName] = defaultVal;
|
|
858
|
+
added.push(field.physicalName);
|
|
859
|
+
}
|
|
860
|
+
if (Object.keys(setSpec).length > 0) update.$set = setSpec;
|
|
861
|
+
}
|
|
862
|
+
if (Object.keys(update).length > 0) await host.collection.updateMany({}, update, host._getSessionOpts());
|
|
863
|
+
return {
|
|
864
|
+
added,
|
|
865
|
+
renamed
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
async function dropColumnsImpl(host, columns) {
|
|
869
|
+
if (columns.length === 0) return;
|
|
870
|
+
const unsetSpec = {};
|
|
871
|
+
for (const col of columns) unsetSpec[col] = "";
|
|
872
|
+
await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
|
|
873
|
+
}
|
|
874
|
+
/** Resolves a field's default value for bulk $set during column sync. */ function resolveSyncDefault(field) {
|
|
875
|
+
if (!field.defaultValue) return field.optional ? null : undefined;
|
|
876
|
+
if (field.defaultValue.kind === "value") return field.defaultValue.value;
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
async function syncIndexesImpl(host) {
|
|
880
|
+
await host.ensureCollectionExists();
|
|
881
|
+
const allIndexes = new Map();
|
|
882
|
+
for (const [key, index] of host._table.indexes.entries()) {
|
|
883
|
+
const fields = {};
|
|
884
|
+
const weights = {};
|
|
885
|
+
let mongoType;
|
|
886
|
+
if (index.type === "fulltext") {
|
|
887
|
+
mongoType = "text";
|
|
888
|
+
for (const f of index.fields) {
|
|
889
|
+
fields[f.name] = "text";
|
|
890
|
+
if (f.weight) weights[f.name] = f.weight;
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
mongoType = index.type;
|
|
894
|
+
for (const f of index.fields) fields[f.name] = 1;
|
|
895
|
+
}
|
|
896
|
+
allIndexes.set(key, {
|
|
897
|
+
key,
|
|
898
|
+
name: index.name,
|
|
899
|
+
type: mongoType,
|
|
900
|
+
fields,
|
|
901
|
+
weights
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
for (const [key, index] of host._mongoIndexes.entries()) if (index.type === "text") {
|
|
905
|
+
const existing = allIndexes.get(key);
|
|
906
|
+
if (existing && existing.type === "text") {
|
|
907
|
+
Object.assign(existing.fields, index.fields);
|
|
908
|
+
Object.assign(existing.weights, index.weights);
|
|
909
|
+
} else allIndexes.set(key, index);
|
|
910
|
+
} else allIndexes.set(key, index);
|
|
911
|
+
const existingIndexes = await host.collection.listIndexes().toArray();
|
|
912
|
+
const indexesToCreate = new Map(allIndexes);
|
|
913
|
+
for (const remote of existingIndexes) {
|
|
914
|
+
if (!remote.name.startsWith(INDEX_PREFIX)) continue;
|
|
915
|
+
if (indexesToCreate.has(remote.name)) {
|
|
916
|
+
const local = indexesToCreate.get(remote.name);
|
|
917
|
+
switch (local.type) {
|
|
918
|
+
case "plain":
|
|
919
|
+
case "unique":
|
|
920
|
+
case "text": {
|
|
921
|
+
if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
|
|
922
|
+
else {
|
|
923
|
+
host._log("dropIndex", remote.name);
|
|
924
|
+
await host.collection.dropIndex(remote.name);
|
|
925
|
+
}
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
default:
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
host._log("dropIndex", remote.name);
|
|
932
|
+
await host.collection.dropIndex(remote.name);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (const [key, value] of allIndexes.entries()) switch (value.type) {
|
|
936
|
+
case "plain": {
|
|
937
|
+
if (!indexesToCreate.has(key)) continue;
|
|
938
|
+
host._log("createIndex", key, value.fields);
|
|
939
|
+
await host.collection.createIndex(value.fields, { name: key });
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
case "unique": {
|
|
943
|
+
if (!indexesToCreate.has(key)) continue;
|
|
944
|
+
host._log("createIndex (unique)", key, value.fields);
|
|
945
|
+
await host.collection.createIndex(value.fields, {
|
|
946
|
+
name: key,
|
|
947
|
+
unique: true
|
|
948
|
+
});
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case "text": {
|
|
952
|
+
if (!indexesToCreate.has(key)) continue;
|
|
953
|
+
host._log("createIndex (text)", key, value.fields);
|
|
954
|
+
await host.collection.createIndex(value.fields, {
|
|
955
|
+
weights: value.weights,
|
|
956
|
+
name: key
|
|
957
|
+
});
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
default:
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
const toUpdate = new Set();
|
|
964
|
+
const existingSearchIndexes = await host.collection.listSearchIndexes().toArray();
|
|
965
|
+
for (const remote of existingSearchIndexes) {
|
|
966
|
+
if (!remote.name.startsWith(INDEX_PREFIX)) continue;
|
|
967
|
+
if (indexesToCreate.has(remote.name)) {
|
|
968
|
+
const local = indexesToCreate.get(remote.name);
|
|
969
|
+
const right = remote.latestDefinition;
|
|
970
|
+
switch (local.type) {
|
|
971
|
+
case "dynamic_text":
|
|
972
|
+
case "search_text": {
|
|
973
|
+
const left = local.definition;
|
|
974
|
+
if (left.analyzer === right.analyzer && fieldsMatch(left.mappings.fields || {}, right.mappings.fields || {})) indexesToCreate.delete(remote.name);
|
|
975
|
+
else toUpdate.add(remote.name);
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
case "vector": {
|
|
979
|
+
if (vectorFieldsMatch(local.definition.fields || [], right.fields || [])) indexesToCreate.delete(remote.name);
|
|
980
|
+
else toUpdate.add(remote.name);
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
default:
|
|
984
|
+
}
|
|
985
|
+
} else if (remote.status !== "DELETING") {
|
|
986
|
+
host._log("dropSearchIndex", remote.name);
|
|
987
|
+
await host.collection.dropSearchIndex(remote.name);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
for (const [key, value] of indexesToCreate.entries()) switch (value.type) {
|
|
991
|
+
case "dynamic_text":
|
|
992
|
+
case "search_text":
|
|
993
|
+
case "vector": {
|
|
994
|
+
if (toUpdate.has(key)) {
|
|
995
|
+
host._log("updateSearchIndex", key, value.definition);
|
|
996
|
+
await host.collection.updateSearchIndex(key, value.definition);
|
|
997
|
+
} else {
|
|
998
|
+
host._log("createSearchIndex", key, value.type);
|
|
999
|
+
await host.collection.createSearchIndex({
|
|
1000
|
+
name: key,
|
|
1001
|
+
type: value.type === "vector" ? "vectorSearch" : "search",
|
|
1002
|
+
definition: value.definition
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
break;
|
|
1006
|
+
}
|
|
1007
|
+
default:
|
|
1008
|
+
}
|
|
1009
|
+
} catch {}
|
|
1010
|
+
}
|
|
1011
|
+
function objMatch(o1, o2) {
|
|
1012
|
+
const keys1 = Object.keys(o1);
|
|
1013
|
+
const keys2 = Object.keys(o2);
|
|
1014
|
+
if (keys1.length !== keys2.length) return false;
|
|
1015
|
+
for (const key of keys1) if (o1[key] !== o2[key]) return false;
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
function fieldsMatch(left, right) {
|
|
1019
|
+
if (!left || !right) return left === right;
|
|
1020
|
+
const leftKeys = Object.keys(left);
|
|
1021
|
+
const rightKeys = Object.keys(right);
|
|
1022
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
1023
|
+
for (const key of leftKeys) {
|
|
1024
|
+
if (!(key in right)) return false;
|
|
1025
|
+
if (left[key].type !== right[key].type || left[key].analyzer !== right[key].analyzer) return false;
|
|
1026
|
+
}
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
function vectorFieldsMatch(left, right) {
|
|
1030
|
+
if (left.length !== (right || []).length) return false;
|
|
1031
|
+
const rightMap = new Map();
|
|
1032
|
+
for (const f of right || []) rightMap.set(f.path, f);
|
|
1033
|
+
for (const l of left) {
|
|
1034
|
+
const r = rightMap.get(l.path);
|
|
1035
|
+
if (!r) return false;
|
|
1036
|
+
if (l.type !== r.type || l.path !== r.path || l.similarity !== r.similarity || l.numDimensions !== r.numDimensions) return false;
|
|
1037
|
+
}
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region packages/db-mongo/src/lib/validate-plugins.ts
|
|
1043
|
+
const validateMongoIdPlugin = (ctx, def, value) => {
|
|
1044
|
+
if (def.type.tags?.has("objectId")) {
|
|
1045
|
+
if (ctx.path === "_id" && (value === undefined || value === null)) {
|
|
1046
|
+
const dbCtx = ctx.context;
|
|
1047
|
+
if (dbCtx && (dbCtx.mode === "insert" || dbCtx.mode === "replace")) return true;
|
|
1048
|
+
}
|
|
1049
|
+
return ctx.validateAnnotatedType(def, value instanceof ObjectId ? value.toString() : value);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region packages/db-mongo/src/lib/mongo-adapter.ts
|
|
1055
|
+
function _define_property(obj, key, value) {
|
|
1056
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
1057
|
+
value,
|
|
1058
|
+
enumerable: true,
|
|
1059
|
+
configurable: true,
|
|
1060
|
+
writable: true
|
|
1061
|
+
});
|
|
1062
|
+
else obj[key] = value;
|
|
1063
|
+
return obj;
|
|
1064
|
+
}
|
|
1065
|
+
var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
|
|
1066
|
+
get _client() {
|
|
1067
|
+
return this.client;
|
|
1068
|
+
}
|
|
1069
|
+
get _txDisabled() {
|
|
1070
|
+
return this.client ? MongoAdapter._txDisabledClients.has(this.client) : true;
|
|
1071
|
+
}
|
|
1072
|
+
set _txDisabled(value) {
|
|
1073
|
+
if (value && this.client) MongoAdapter._txDisabledClients.add(this.client);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Uses MongoDB's Convenient Transaction API (`session.withTransaction()`).
|
|
1077
|
+
* This handles txnNumber management and automatic retry for
|
|
1078
|
+
* `TransientTransactionError` / `UnknownTransactionCommitResult`.
|
|
1079
|
+
*/ async withTransaction(fn) {
|
|
1080
|
+
if (this._getTransactionState()) return fn();
|
|
1081
|
+
if (this._txDisabled || !this._client) return fn();
|
|
1082
|
+
try {
|
|
1083
|
+
const topology = this._client.topology;
|
|
1084
|
+
if (topology) {
|
|
1085
|
+
const desc = topology.description ?? topology.s?.description;
|
|
1086
|
+
const type = desc?.type;
|
|
1087
|
+
if (type === "Single" || type === "Unknown") {
|
|
1088
|
+
this._txDisabled = true;
|
|
1089
|
+
return fn();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch {
|
|
1093
|
+
this._txDisabled = true;
|
|
1094
|
+
return fn();
|
|
1095
|
+
}
|
|
1096
|
+
const session = this._client.startSession();
|
|
1097
|
+
try {
|
|
1098
|
+
let result;
|
|
1099
|
+
await session.withTransaction(async () => {
|
|
1100
|
+
result = await this._runInTransactionContext(session, fn);
|
|
1101
|
+
});
|
|
1102
|
+
return result;
|
|
1103
|
+
} finally {
|
|
1104
|
+
try {
|
|
1105
|
+
await session.endSession();
|
|
1106
|
+
} catch {}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/** Returns `{ session }` opts if inside a transaction, empty object otherwise. */ _getSessionOpts() {
|
|
1110
|
+
const session = this._getTransactionState();
|
|
1111
|
+
return session ? { session } : MongoAdapter._noSession;
|
|
1112
|
+
}
|
|
1113
|
+
get collection() {
|
|
1114
|
+
if (!this._collection) this._collection = this.db.collection(this.resolveTableName(false));
|
|
1115
|
+
return this._collection;
|
|
1116
|
+
}
|
|
1117
|
+
aggregatePipeline(pipeline) {
|
|
1118
|
+
return this.collection.aggregate(pipeline, this._getSessionOpts());
|
|
1119
|
+
}
|
|
1120
|
+
async aggregate(query) {
|
|
1121
|
+
const { buildAggregatePipeline, buildCountPipeline } = await import("./agg-CUX5Jb_A.mjs");
|
|
1122
|
+
if (query.controls?.$count) {
|
|
1123
|
+
const pipeline$1 = buildCountPipeline(query);
|
|
1124
|
+
this._log("aggregate (count)", pipeline$1);
|
|
1125
|
+
const result = await this.aggregatePipeline(pipeline$1).toArray();
|
|
1126
|
+
return result.length > 0 ? result : [{ count: 0 }];
|
|
1127
|
+
}
|
|
1128
|
+
const pipeline = buildAggregatePipeline(query);
|
|
1129
|
+
this._log("aggregate", pipeline);
|
|
1130
|
+
return this.aggregatePipeline(pipeline).toArray();
|
|
1131
|
+
}
|
|
1132
|
+
get idType() {
|
|
1133
|
+
const idProp = this._table.type.type.props.get("_id");
|
|
1134
|
+
const idTags = idProp?.type.tags;
|
|
1135
|
+
if (idTags?.has("objectId") && idTags?.has("mongo")) return "objectId";
|
|
1136
|
+
if (idProp?.type.kind === "") return idProp.type.designType;
|
|
1137
|
+
return "objectId";
|
|
1138
|
+
}
|
|
1139
|
+
prepareId(id, fieldType) {
|
|
1140
|
+
const tags = fieldType.type.tags;
|
|
1141
|
+
if (tags?.has("objectId") && tags?.has("mongo")) return id instanceof ObjectId ? id : new ObjectId(id);
|
|
1142
|
+
if (fieldType.type.kind === "") {
|
|
1143
|
+
const dt = fieldType.type.designType;
|
|
1144
|
+
if (dt === "number") return Number(id);
|
|
1145
|
+
}
|
|
1146
|
+
return String(id);
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Convenience method that uses `idType` to transform an ID value.
|
|
1150
|
+
* For use in controllers that don't have access to the field type.
|
|
1151
|
+
*/ prepareIdFromIdType(id) {
|
|
1152
|
+
switch (this.idType) {
|
|
1153
|
+
case "objectId": return id instanceof ObjectId ? id : new ObjectId(id);
|
|
1154
|
+
case "number": return Number(id);
|
|
1155
|
+
case "string": return String(id);
|
|
1156
|
+
default: throw new Error("Unknown \"_id\" type");
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
supportsNestedObjects() {
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
supportsNativePatch() {
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
getValidatorPlugins() {
|
|
1166
|
+
return [validateMongoIdPlugin];
|
|
1167
|
+
}
|
|
1168
|
+
getAdapterTableName(type) {
|
|
1169
|
+
return undefined;
|
|
1170
|
+
}
|
|
1171
|
+
supportsNativeRelations() {
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
async loadRelations(rows, withRelations, relations, foreignKeys, tableResolver) {
|
|
1175
|
+
return loadRelationsImpl(this, rows, withRelations, relations, foreignKeys, tableResolver);
|
|
1176
|
+
}
|
|
1177
|
+
/** Returns the context object used by CollectionPatcher. */ getPatcherContext() {
|
|
1178
|
+
return {
|
|
1179
|
+
flatMap: this._table.flatMap,
|
|
1180
|
+
prepareId: (id) => this.prepareIdFromIdType(id),
|
|
1181
|
+
createValidator: (opts) => this._table.createValidator(opts)
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
async nativePatch(filter, patch) {
|
|
1185
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1186
|
+
const patcher = new CollectionPatcher(this.getPatcherContext(), patch);
|
|
1187
|
+
const { updateFilter, updateOptions } = patcher.preparePatch();
|
|
1188
|
+
this._log("updateOne (patch)", mongoFilter, updateFilter);
|
|
1189
|
+
const result = await this.collection.updateOne(mongoFilter, updateFilter, {
|
|
1190
|
+
...updateOptions,
|
|
1191
|
+
...this._getSessionOpts()
|
|
1192
|
+
});
|
|
1193
|
+
return {
|
|
1194
|
+
matchedCount: result.matchedCount,
|
|
1195
|
+
modifiedCount: result.modifiedCount
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
onBeforeFlatten(type) {
|
|
1199
|
+
const typeMeta = type.metadata;
|
|
1200
|
+
const capped = typeMeta.get("db.mongo.capped");
|
|
1201
|
+
if (capped) this._cappedOptions = {
|
|
1202
|
+
size: capped.size,
|
|
1203
|
+
max: capped.max
|
|
1204
|
+
};
|
|
1205
|
+
const dynamicText = typeMeta.get("db.mongo.search.dynamic");
|
|
1206
|
+
if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
|
|
1207
|
+
mappings: { dynamic: true },
|
|
1208
|
+
analyzer: dynamicText.analyzer,
|
|
1209
|
+
text: { fuzzy: { maxEdits: dynamicText.fuzzy || 0 } }
|
|
1210
|
+
});
|
|
1211
|
+
for (const textSearch of typeMeta.get("db.mongo.search.static") || []) this._setSearchIndex("search_text", textSearch.indexName, {
|
|
1212
|
+
mappings: { fields: {} },
|
|
1213
|
+
analyzer: textSearch.analyzer,
|
|
1214
|
+
text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
onFieldScanned(field, type, metadata) {
|
|
1218
|
+
if (field === "_id") this._hasExplicitId = true;
|
|
1219
|
+
if (field !== "_id" && metadata.has("meta.id")) {
|
|
1220
|
+
this._addMongoIndexField("unique", "__pk", field);
|
|
1221
|
+
this._pendingUniqueFields.push(field);
|
|
1222
|
+
}
|
|
1223
|
+
if (metadata.has("db.default.increment")) {
|
|
1224
|
+
const physicalName = metadata.get("db.column") ?? field;
|
|
1225
|
+
const startValue = metadata.get("db.default.increment");
|
|
1226
|
+
this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : undefined);
|
|
1227
|
+
}
|
|
1228
|
+
for (const index of metadata.get("db.index.fulltext") || []) {
|
|
1229
|
+
const name = typeof index === "object" ? index.name || "" : "";
|
|
1230
|
+
const weight = typeof index === "object" ? index.weight || 1 : 1;
|
|
1231
|
+
this._addMongoIndexField("text", name, field, weight);
|
|
1232
|
+
}
|
|
1233
|
+
for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, index.analyzer);
|
|
1234
|
+
const vectorIndex = metadata.get("db.search.vector");
|
|
1235
|
+
if (vectorIndex) {
|
|
1236
|
+
const indexName = vectorIndex.indexName || field;
|
|
1237
|
+
this._setSearchIndex("vector", indexName, { fields: [{
|
|
1238
|
+
type: "vector",
|
|
1239
|
+
path: field,
|
|
1240
|
+
similarity: vectorIndex.similarity || "cosine",
|
|
1241
|
+
numDimensions: vectorIndex.dimensions
|
|
1242
|
+
}] });
|
|
1243
|
+
const threshold = metadata.get("db.search.vector.threshold");
|
|
1244
|
+
if (threshold !== undefined) this._vectorThresholds.set(mongoIndexKey("vector", indexName), threshold);
|
|
1245
|
+
}
|
|
1246
|
+
for (const indexName of metadata.get("db.search.filter") || []) this._vectorFilters.set(mongoIndexKey("vector", indexName), field);
|
|
1247
|
+
}
|
|
1248
|
+
getMetadataOverrides(meta) {
|
|
1249
|
+
const uniqueFields = this._pendingUniqueFields;
|
|
1250
|
+
if (this._hasExplicitId) return {
|
|
1251
|
+
addPrimaryKeys: ["_id"],
|
|
1252
|
+
removePrimaryKeys: meta.originalMetaIdFields.filter((f) => f !== "_id"),
|
|
1253
|
+
addUniqueFields: uniqueFields.length > 0 ? uniqueFields : undefined
|
|
1254
|
+
};
|
|
1255
|
+
uniqueFields.push("_id");
|
|
1256
|
+
return {
|
|
1257
|
+
injectFields: [{
|
|
1258
|
+
path: "_id",
|
|
1259
|
+
type: {
|
|
1260
|
+
__is_atscript_annotated_type: true,
|
|
1261
|
+
type: {
|
|
1262
|
+
kind: "",
|
|
1263
|
+
designType: "string",
|
|
1264
|
+
tags: new Set(["objectId", "mongo"])
|
|
1265
|
+
},
|
|
1266
|
+
metadata: new Map()
|
|
1267
|
+
}
|
|
1268
|
+
}],
|
|
1269
|
+
addUniqueFields: uniqueFields
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
onAfterFlatten() {
|
|
1273
|
+
for (const [key, value] of this._vectorFilters.entries()) {
|
|
1274
|
+
const index = this._mongoIndexes.get(key);
|
|
1275
|
+
if (index && index.type === "vector") index.definition.fields?.push({
|
|
1276
|
+
type: "filter",
|
|
1277
|
+
path: value
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
for (const fd of this._table.fieldDescriptors) if (fd.collate && fd.collate !== "binary") {
|
|
1281
|
+
if (!this._collateFields) this._collateFields = new Map();
|
|
1282
|
+
this._collateFields.set(fd.physicalName, fd.collate);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/** Returns MongoDB-specific search index map (internal). */ getMongoSearchIndexes() {
|
|
1286
|
+
if (!this._searchIndexesMap) {
|
|
1287
|
+
this._table.flatMap;
|
|
1288
|
+
this._searchIndexesMap = new Map();
|
|
1289
|
+
let defaultIndex;
|
|
1290
|
+
for (const index of this._table.indexes.values()) if (index.type === "fulltext" && !defaultIndex) defaultIndex = {
|
|
1291
|
+
key: index.key,
|
|
1292
|
+
name: index.name,
|
|
1293
|
+
type: "text",
|
|
1294
|
+
fields: Object.fromEntries(index.fields.map((f) => [f.name, "text"])),
|
|
1295
|
+
weights: Object.fromEntries(index.fields.filter((f) => f.weight).map((f) => [f.name, f.weight]))
|
|
1296
|
+
};
|
|
1297
|
+
for (const index of this._mongoIndexes.values()) switch (index.type) {
|
|
1298
|
+
case "text": {
|
|
1299
|
+
if (!defaultIndex) defaultIndex = index;
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
case "dynamic_text": {
|
|
1303
|
+
defaultIndex = index;
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
case "search_text": {
|
|
1307
|
+
if (!defaultIndex || defaultIndex.type === "text") defaultIndex = index;
|
|
1308
|
+
this._searchIndexesMap.set(index.name, index);
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
case "vector": {
|
|
1312
|
+
this._searchIndexesMap.set(index.name, index);
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
default:
|
|
1316
|
+
}
|
|
1317
|
+
if (defaultIndex && !this._searchIndexesMap.has(DEFAULT_INDEX_NAME)) this._searchIndexesMap.set(DEFAULT_INDEX_NAME, defaultIndex);
|
|
1318
|
+
}
|
|
1319
|
+
return this._searchIndexesMap;
|
|
1320
|
+
}
|
|
1321
|
+
/** Returns a specific MongoDB search index by name. */ getMongoSearchIndex(name = DEFAULT_INDEX_NAME) {
|
|
1322
|
+
return this.getMongoSearchIndexes().get(name);
|
|
1323
|
+
}
|
|
1324
|
+
/** Returns the default similarity threshold for a vector index (from @db.search.vector.threshold). */ getVectorThreshold(indexName) {
|
|
1325
|
+
const key = mongoIndexKey("vector", indexName || DEFAULT_INDEX_NAME);
|
|
1326
|
+
return this._vectorThresholds.get(key);
|
|
1327
|
+
}
|
|
1328
|
+
getSearchIndexes() {
|
|
1329
|
+
return getSearchIndexesImpl(this);
|
|
1330
|
+
}
|
|
1331
|
+
isVectorSearchable() {
|
|
1332
|
+
return isVectorSearchableImpl(this);
|
|
1333
|
+
}
|
|
1334
|
+
async search(text, query, indexName) {
|
|
1335
|
+
return searchImpl(this, text, query, indexName);
|
|
1336
|
+
}
|
|
1337
|
+
async searchWithCount(text, query, indexName) {
|
|
1338
|
+
return searchWithCountImpl(this, text, query, indexName);
|
|
1339
|
+
}
|
|
1340
|
+
async vectorSearch(vector, query, indexName) {
|
|
1341
|
+
return vectorSearchImpl(this, vector, query, indexName);
|
|
1342
|
+
}
|
|
1343
|
+
async vectorSearchWithCount(vector, query, indexName) {
|
|
1344
|
+
return vectorSearchWithCountImpl(this, vector, query, indexName);
|
|
1345
|
+
}
|
|
1346
|
+
async findManyWithCount(query) {
|
|
1347
|
+
const filter = buildMongoFilter(query.filter);
|
|
1348
|
+
const controls = query.controls || {};
|
|
1349
|
+
const dataStages = [];
|
|
1350
|
+
if (controls.$sort) dataStages.push({ $sort: controls.$sort });
|
|
1351
|
+
if (controls.$skip) dataStages.push({ $skip: controls.$skip });
|
|
1352
|
+
if (controls.$limit) dataStages.push({ $limit: controls.$limit });
|
|
1353
|
+
if (controls.$select) dataStages.push({ $project: controls.$select.asProjection });
|
|
1354
|
+
const pipeline = [{ $match: filter }, { $facet: {
|
|
1355
|
+
data: dataStages,
|
|
1356
|
+
meta: [{ $count: "count" }]
|
|
1357
|
+
} }];
|
|
1358
|
+
this._log("aggregate (findManyWithCount)", pipeline);
|
|
1359
|
+
const result = await this.collection.aggregate(pipeline, {
|
|
1360
|
+
...this._getCollationOpts(query),
|
|
1361
|
+
...this._getSessionOpts()
|
|
1362
|
+
}).toArray();
|
|
1363
|
+
return {
|
|
1364
|
+
data: result[0]?.data || [],
|
|
1365
|
+
count: result[0]?.meta[0]?.count || 0
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
async collectionExists() {
|
|
1369
|
+
const cols = await this.db.listCollections({ name: this._table.tableName }).toArray();
|
|
1370
|
+
return cols.length > 0;
|
|
1371
|
+
}
|
|
1372
|
+
async ensureCollectionExists() {
|
|
1373
|
+
const exists = await this.collectionExists();
|
|
1374
|
+
if (!exists) {
|
|
1375
|
+
this._log("createCollection", this._table.tableName);
|
|
1376
|
+
const opts = { comment: "Created by Atscript Mongo Adapter" };
|
|
1377
|
+
if (this._cappedOptions) {
|
|
1378
|
+
opts.capped = true;
|
|
1379
|
+
opts.size = this._cappedOptions.size;
|
|
1380
|
+
if (this._cappedOptions.max !== null && this._cappedOptions.max !== undefined) opts.max = this._cappedOptions.max;
|
|
1381
|
+
}
|
|
1382
|
+
await this.db.createCollection(this._table.tableName, opts);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Wraps an async operation to catch MongoDB duplicate key errors
|
|
1387
|
+
* (code 11000) and rethrow as structured `DbError`.
|
|
1388
|
+
*/ async _wrapDuplicateKeyError(fn) {
|
|
1389
|
+
try {
|
|
1390
|
+
return await fn();
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
if (error instanceof MongoServerError && error.code === 11e3) {
|
|
1393
|
+
const field = error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "";
|
|
1394
|
+
throw new DbError("CONFLICT", [{
|
|
1395
|
+
path: field,
|
|
1396
|
+
message: error.message
|
|
1397
|
+
}]);
|
|
1398
|
+
}
|
|
1399
|
+
throw error;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async insertOne(data) {
|
|
1403
|
+
if (this._incrementFields.size > 0) {
|
|
1404
|
+
const fields = this._fieldsNeedingIncrement(data);
|
|
1405
|
+
if (fields.length > 0) {
|
|
1406
|
+
const nextValues = await this._allocateIncrementValues(fields, 1);
|
|
1407
|
+
for (const physical of fields) data[physical] = nextValues.get(physical) ?? 1;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
this._log("insertOne", data);
|
|
1411
|
+
const result = await this._wrapDuplicateKeyError(() => this.collection.insertOne(data, this._getSessionOpts()));
|
|
1412
|
+
return { insertedId: this._resolveInsertedId(data, result.insertedId) };
|
|
1413
|
+
}
|
|
1414
|
+
async insertMany(data) {
|
|
1415
|
+
if (this._incrementFields.size > 0) {
|
|
1416
|
+
const allFields = new Set();
|
|
1417
|
+
for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
|
|
1418
|
+
if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
|
|
1419
|
+
}
|
|
1420
|
+
this._log("insertMany", `${data.length} docs`);
|
|
1421
|
+
const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
|
|
1422
|
+
return {
|
|
1423
|
+
insertedCount: result.insertedCount,
|
|
1424
|
+
insertedIds: data.map((item, i) => this._resolveInsertedId(item, result.insertedIds[i]))
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
async findOne(query) {
|
|
1428
|
+
const filter = buildMongoFilter(query.filter);
|
|
1429
|
+
const opts = this._buildFindOptions(query.controls);
|
|
1430
|
+
this._log("findOne", filter, opts);
|
|
1431
|
+
return this.collection.findOne(filter, {
|
|
1432
|
+
...opts,
|
|
1433
|
+
...this._getCollationOpts(query),
|
|
1434
|
+
...this._getSessionOpts()
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
async findMany(query) {
|
|
1438
|
+
const filter = buildMongoFilter(query.filter);
|
|
1439
|
+
const opts = this._buildFindOptions(query.controls);
|
|
1440
|
+
this._log("findMany", filter, opts);
|
|
1441
|
+
return this.collection.find(filter, {
|
|
1442
|
+
...opts,
|
|
1443
|
+
...this._getCollationOpts(query),
|
|
1444
|
+
...this._getSessionOpts()
|
|
1445
|
+
}).toArray();
|
|
1446
|
+
}
|
|
1447
|
+
async count(query) {
|
|
1448
|
+
const filter = buildMongoFilter(query.filter);
|
|
1449
|
+
this._log("countDocuments", filter);
|
|
1450
|
+
return this.collection.countDocuments(filter, {
|
|
1451
|
+
...this._getCollationOpts(query),
|
|
1452
|
+
...this._getSessionOpts()
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
async updateOne(filter, data) {
|
|
1456
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1457
|
+
this._log("updateOne", mongoFilter, { $set: data });
|
|
1458
|
+
const result = await this.collection.updateOne(mongoFilter, { $set: data }, this._getSessionOpts());
|
|
1459
|
+
return {
|
|
1460
|
+
matchedCount: result.matchedCount,
|
|
1461
|
+
modifiedCount: result.modifiedCount
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
async replaceOne(filter, data) {
|
|
1465
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1466
|
+
this._log("replaceOne", mongoFilter, data);
|
|
1467
|
+
const result = await this._wrapDuplicateKeyError(() => this.collection.replaceOne(mongoFilter, data, this._getSessionOpts()));
|
|
1468
|
+
return {
|
|
1469
|
+
matchedCount: result.matchedCount,
|
|
1470
|
+
modifiedCount: result.modifiedCount
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
async deleteOne(filter) {
|
|
1474
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1475
|
+
this._log("deleteOne", mongoFilter);
|
|
1476
|
+
const result = await this.collection.deleteOne(mongoFilter, this._getSessionOpts());
|
|
1477
|
+
return { deletedCount: result.deletedCount };
|
|
1478
|
+
}
|
|
1479
|
+
async updateMany(filter, data) {
|
|
1480
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1481
|
+
this._log("updateMany", mongoFilter, { $set: data });
|
|
1482
|
+
const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
|
|
1483
|
+
return {
|
|
1484
|
+
matchedCount: result.matchedCount,
|
|
1485
|
+
modifiedCount: result.modifiedCount
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
async replaceMany(filter, data) {
|
|
1489
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1490
|
+
this._log("replaceMany", mongoFilter, { $set: data });
|
|
1491
|
+
const result = await this.collection.updateMany(mongoFilter, { $set: data }, this._getSessionOpts());
|
|
1492
|
+
return {
|
|
1493
|
+
matchedCount: result.matchedCount,
|
|
1494
|
+
modifiedCount: result.modifiedCount
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
async deleteMany(filter) {
|
|
1498
|
+
const mongoFilter = buildMongoFilter(filter);
|
|
1499
|
+
this._log("deleteMany", mongoFilter);
|
|
1500
|
+
const result = await this.collection.deleteMany(mongoFilter, this._getSessionOpts());
|
|
1501
|
+
return { deletedCount: result.deletedCount };
|
|
1502
|
+
}
|
|
1503
|
+
clearCollectionCache() {
|
|
1504
|
+
this._collection = undefined;
|
|
1505
|
+
}
|
|
1506
|
+
async tableExists() {
|
|
1507
|
+
return tableExistsImpl(this);
|
|
1508
|
+
}
|
|
1509
|
+
async ensureTable() {
|
|
1510
|
+
return ensureTableImpl(this, this._table);
|
|
1511
|
+
}
|
|
1512
|
+
async syncIndexes() {
|
|
1513
|
+
return syncIndexesImpl(this);
|
|
1514
|
+
}
|
|
1515
|
+
async syncColumns(diff) {
|
|
1516
|
+
return syncColumnsImpl(this, diff);
|
|
1517
|
+
}
|
|
1518
|
+
async dropColumns(columns) {
|
|
1519
|
+
return dropColumnsImpl(this, columns);
|
|
1520
|
+
}
|
|
1521
|
+
async renameTable(oldName) {
|
|
1522
|
+
return renameTableImpl(this, oldName);
|
|
1523
|
+
}
|
|
1524
|
+
async recreateTable() {
|
|
1525
|
+
return recreateTableImpl(this);
|
|
1526
|
+
}
|
|
1527
|
+
async dropTable() {
|
|
1528
|
+
return dropTableImpl(this);
|
|
1529
|
+
}
|
|
1530
|
+
async dropViewByName(viewName) {
|
|
1531
|
+
return dropViewByNameImpl(this, viewName);
|
|
1532
|
+
}
|
|
1533
|
+
async dropTableByName(tableName) {
|
|
1534
|
+
return dropTableByNameImpl(this, tableName);
|
|
1535
|
+
}
|
|
1536
|
+
getDesiredTableOptions() {
|
|
1537
|
+
return getDesiredTableOptionsImpl(this._cappedOptions);
|
|
1538
|
+
}
|
|
1539
|
+
async getExistingTableOptions() {
|
|
1540
|
+
return getExistingTableOptionsImpl(this);
|
|
1541
|
+
}
|
|
1542
|
+
destructiveOptionKeys() {
|
|
1543
|
+
return DESTRUCTIVE_OPTION_KEYS;
|
|
1544
|
+
}
|
|
1545
|
+
/** Returns the counters collection used for atomic auto-increment. */ get _countersCollection() {
|
|
1546
|
+
return this.db.collection("__atscript_counters");
|
|
1547
|
+
}
|
|
1548
|
+
/** Returns physical field names of increment fields that are undefined in the data. */ _fieldsNeedingIncrement(data) {
|
|
1549
|
+
const result = [];
|
|
1550
|
+
for (const physical of this._incrementFields.keys()) if (data[physical] === undefined || data[physical] === null) result.push(physical);
|
|
1551
|
+
return result;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Atomically allocates `count` sequential values for each increment field
|
|
1555
|
+
* using a counter collection. Returns a map of field → first allocated value.
|
|
1556
|
+
*/ async _allocateIncrementValues(physicalFields, count) {
|
|
1557
|
+
const counters = this._countersCollection;
|
|
1558
|
+
const collectionName = this._table.tableName;
|
|
1559
|
+
const result = new Map();
|
|
1560
|
+
for (const field of physicalFields) {
|
|
1561
|
+
const counterId = `${collectionName}.${field}`;
|
|
1562
|
+
const startValue = this._incrementFields.get(field);
|
|
1563
|
+
const doc = await counters.findOneAndUpdate({ _id: counterId }, { $inc: { seq: count } }, {
|
|
1564
|
+
upsert: true,
|
|
1565
|
+
returnDocument: "after"
|
|
1566
|
+
});
|
|
1567
|
+
const seq = doc?.seq ?? count;
|
|
1568
|
+
if (seq === count) {
|
|
1569
|
+
const currentMax = await this._getCurrentFieldMax(field);
|
|
1570
|
+
const minStart = typeof startValue === "number" ? startValue : 1;
|
|
1571
|
+
const effectiveBase = Math.max(minStart, currentMax + 1);
|
|
1572
|
+
if (effectiveBase > seq) {
|
|
1573
|
+
const adjusted = effectiveBase + count - 1;
|
|
1574
|
+
await counters.updateOne({ _id: counterId }, { $max: { seq: adjusted } });
|
|
1575
|
+
result.set(field, effectiveBase);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
result.set(field, seq - count + 1);
|
|
1580
|
+
}
|
|
1581
|
+
return result;
|
|
1582
|
+
}
|
|
1583
|
+
/** Reads current max value for a single field via $group aggregation. */ async _getCurrentFieldMax(field) {
|
|
1584
|
+
const alias = `max__${field.replace(/\./g, "__")}`;
|
|
1585
|
+
const agg = await this.collection.aggregate([{ $group: {
|
|
1586
|
+
_id: null,
|
|
1587
|
+
[alias]: { $max: `$${field}` }
|
|
1588
|
+
} }]).toArray();
|
|
1589
|
+
if (agg.length > 0) {
|
|
1590
|
+
const val = agg[0][alias];
|
|
1591
|
+
if (typeof val === "number") return val;
|
|
1592
|
+
}
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
/** Allocates increment values for a batch of items, assigning in order. */ async _assignBatchIncrements(data, allFields) {
|
|
1596
|
+
const fieldCounts = new Map();
|
|
1597
|
+
for (const physical of allFields) {
|
|
1598
|
+
let count = 0;
|
|
1599
|
+
for (const item of data) if (item[physical] === undefined || item[physical] === null) count++;
|
|
1600
|
+
if (count > 0) fieldCounts.set(physical, count);
|
|
1601
|
+
}
|
|
1602
|
+
const fieldCounters = new Map();
|
|
1603
|
+
for (const [physical, count] of fieldCounts) {
|
|
1604
|
+
const allocated = await this._allocateIncrementValues([physical], count);
|
|
1605
|
+
fieldCounters.set(physical, allocated.get(physical) ?? 1);
|
|
1606
|
+
}
|
|
1607
|
+
for (const item of data) for (const physical of allFields) if (item[physical] === undefined || item[physical] === null) {
|
|
1608
|
+
const next = fieldCounters.get(physical) ?? 1;
|
|
1609
|
+
item[physical] = next;
|
|
1610
|
+
fieldCounters.set(physical, next + 1);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
_buildFindOptions(controls) {
|
|
1614
|
+
const opts = {};
|
|
1615
|
+
if (!controls) return opts;
|
|
1616
|
+
if (controls.$sort) opts.sort = controls.$sort;
|
|
1617
|
+
if (controls.$limit) opts.limit = controls.$limit;
|
|
1618
|
+
if (controls.$skip) opts.skip = controls.$skip;
|
|
1619
|
+
if (controls.$select) opts.projection = controls.$select.asProjection;
|
|
1620
|
+
return opts;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Returns MongoDB collation options if any filter field has a non-binary collation.
|
|
1624
|
+
* Uses pre-computed insights when available, falls back to computing them on demand.
|
|
1625
|
+
* Maps: nocase → strength 2 (case-insensitive), unicode → strength 1 (case+accent-insensitive).
|
|
1626
|
+
*/ _getCollationOpts(query) {
|
|
1627
|
+
if (!this._collateFields) return undefined;
|
|
1628
|
+
const insights = query.insights ?? computeInsights(query.filter);
|
|
1629
|
+
let strength;
|
|
1630
|
+
for (const field of insights.keys()) {
|
|
1631
|
+
const collation = this._collateFields.get(field);
|
|
1632
|
+
if (collation === "unicode") return { collation: {
|
|
1633
|
+
locale: "en",
|
|
1634
|
+
strength: 1
|
|
1635
|
+
} };
|
|
1636
|
+
if (collation === "nocase") strength = 2;
|
|
1637
|
+
}
|
|
1638
|
+
return strength ? { collation: {
|
|
1639
|
+
locale: "en",
|
|
1640
|
+
strength
|
|
1641
|
+
} } : undefined;
|
|
1642
|
+
}
|
|
1643
|
+
_addMongoIndexField(type, name, field, weight) {
|
|
1644
|
+
const key = mongoIndexKey(type, name);
|
|
1645
|
+
let index = this._mongoIndexes.get(key);
|
|
1646
|
+
const value = type === "text" ? "text" : 1;
|
|
1647
|
+
if (index) index.fields[field] = value;
|
|
1648
|
+
else {
|
|
1649
|
+
index = {
|
|
1650
|
+
key,
|
|
1651
|
+
name,
|
|
1652
|
+
type,
|
|
1653
|
+
fields: { [field]: value },
|
|
1654
|
+
weights: {}
|
|
1655
|
+
};
|
|
1656
|
+
this._mongoIndexes.set(key, index);
|
|
1657
|
+
}
|
|
1658
|
+
if (weight) index.weights[field] = weight;
|
|
1659
|
+
}
|
|
1660
|
+
_setSearchIndex(type, name, definition) {
|
|
1661
|
+
const key = mongoIndexKey(type, name || DEFAULT_INDEX_NAME);
|
|
1662
|
+
this._mongoIndexes.set(key, {
|
|
1663
|
+
key,
|
|
1664
|
+
name: name || DEFAULT_INDEX_NAME,
|
|
1665
|
+
type,
|
|
1666
|
+
definition
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
_addFieldToSearchIndex(type, _name, fieldName, analyzer) {
|
|
1670
|
+
const name = _name || DEFAULT_INDEX_NAME;
|
|
1671
|
+
let index = this._mongoIndexes.get(mongoIndexKey(type, name));
|
|
1672
|
+
if (!index && type === "search_text") {
|
|
1673
|
+
this._setSearchIndex(type, name, {
|
|
1674
|
+
mappings: { fields: {} },
|
|
1675
|
+
text: { fuzzy: { maxEdits: 0 } }
|
|
1676
|
+
});
|
|
1677
|
+
index = this._mongoIndexes.get(mongoIndexKey(type, name));
|
|
1678
|
+
}
|
|
1679
|
+
if (index) {
|
|
1680
|
+
index.definition.mappings.fields[fieldName] = { type: "string" };
|
|
1681
|
+
if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
constructor(db, client) {
|
|
1685
|
+
super(), _define_property(this, "db", void 0), _define_property(this, "client", void 0), _define_property(this, "_collection", void 0), _define_property(this, "_mongoIndexes", void 0), _define_property(this, "_vectorFilters", void 0), _define_property(this, "_vectorThresholds", void 0), _define_property(this, "_searchIndexesMap", void 0), _define_property(this, "_incrementFields", void 0), _define_property(this, "_collateFields", void 0), _define_property(this, "_cappedOptions", void 0), _define_property(this, "_hasExplicitId", void 0), _define_property(this, "_pendingUniqueFields", void 0), this.db = db, this.client = client, this._mongoIndexes = new Map(), this._vectorFilters = new Map(), this._vectorThresholds = new Map(), this._incrementFields = new Map(), this._hasExplicitId = false, this._pendingUniqueFields = [];
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
/**
|
|
1689
|
+
* Per-client cache: whether transactions are unavailable (standalone MongoDB).
|
|
1690
|
+
* Shared across all adapter instances for the same client so topology is probed once.
|
|
1691
|
+
*/ _define_property(MongoAdapter, "_txDisabledClients", new WeakSet());
|
|
1692
|
+
_define_property(MongoAdapter, "_noSession", Object.freeze({}));
|
|
1693
|
+
|
|
1694
|
+
//#endregion
|
|
1695
|
+
//#region packages/db-mongo/src/lib/index.ts
|
|
1696
|
+
function createAdapter(connection, _options) {
|
|
1697
|
+
const client = new MongoClient(connection);
|
|
1698
|
+
const db = client.db();
|
|
1699
|
+
return new DbSpace(() => new MongoAdapter(db, client));
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
//#endregion
|
|
1703
|
+
export { CollectionPatcher, MongoAdapter, buildMongoFilter, createAdapter, validateMongoIdPlugin };
|