mongoose-killer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/cjs/CastIdsConflictError.d.ts +11 -0
- package/dist/cjs/CastIdsConflictError.d.ts.map +1 -0
- package/dist/cjs/CastIdsConflictError.js +27 -0
- package/dist/cjs/Model.d.ts +51 -0
- package/dist/cjs/Model.d.ts.map +1 -0
- package/dist/cjs/Model.js +205 -0
- package/dist/cjs/Query.d.ts +85 -0
- package/dist/cjs/Query.d.ts.map +1 -0
- package/dist/cjs/Query.js +439 -0
- package/dist/cjs/castFilter.d.ts +25 -0
- package/dist/cjs/castFilter.d.ts.map +1 -0
- package/dist/cjs/castFilter.js +100 -0
- package/dist/cjs/createGetModel.d.ts +3 -0
- package/dist/cjs/createGetModel.d.ts.map +1 -0
- package/dist/cjs/createGetModel.js +47 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +12 -0
- package/dist/cjs/populate.d.ts +23 -0
- package/dist/cjs/populate.d.ts.map +1 -0
- package/dist/cjs/populate.js +185 -0
- package/dist/cjs/types.d.ts +14 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +2 -0
- package/dist/esm/CastIdsConflictError.js +23 -0
- package/dist/esm/Model.js +201 -0
- package/dist/esm/Query.js +434 -0
- package/dist/esm/castFilter.js +96 -0
- package/dist/esm/createGetModel.js +43 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/populate.js +180 -0
- package/dist/esm/types.js +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { runPopulate, normalizePopulateArg } from "./populate.js";
|
|
2
|
+
import { castFilter } from "./castFilter.js";
|
|
3
|
+
import { CastIdsConflictError } from "./CastIdsConflictError.js";
|
|
4
|
+
export class Query {
|
|
5
|
+
model;
|
|
6
|
+
op;
|
|
7
|
+
filter;
|
|
8
|
+
update;
|
|
9
|
+
pipeline;
|
|
10
|
+
distinctField;
|
|
11
|
+
// Chained options. We accumulate them and push to the driver at exec.
|
|
12
|
+
_sort;
|
|
13
|
+
_limit;
|
|
14
|
+
_skip;
|
|
15
|
+
_select;
|
|
16
|
+
_hint;
|
|
17
|
+
_comment;
|
|
18
|
+
_session;
|
|
19
|
+
_readPref;
|
|
20
|
+
_populates = [];
|
|
21
|
+
_lean = false;
|
|
22
|
+
// Ordered log of cast-id overrides on this chain.
|
|
23
|
+
// "on" — user called .castIds()
|
|
24
|
+
// "off" — user called .skipCastIds()
|
|
25
|
+
// Empty = neither was called; use model's autoCastIds default.
|
|
26
|
+
// Both present at exec time = conflict, resolve per policy.
|
|
27
|
+
_castIdsOps = [];
|
|
28
|
+
// Options bag set by entry verb (e.g. { new, upsert, returnDocument }).
|
|
29
|
+
// Stays mostly opaque — we forward what the driver understands.
|
|
30
|
+
opOptions;
|
|
31
|
+
// Memoize result so a Query (like mongoose) can be awaited / .then'd
|
|
32
|
+
// exactly once and re-runs throw the mongoose error string.
|
|
33
|
+
_executed = false;
|
|
34
|
+
_execPromise = null;
|
|
35
|
+
constructor(init) {
|
|
36
|
+
this.model = init.model;
|
|
37
|
+
this.op = init.op;
|
|
38
|
+
this.filter = init.filter;
|
|
39
|
+
this.update = init.update;
|
|
40
|
+
this.pipeline = init.pipeline ?? [];
|
|
41
|
+
this.distinctField = init.distinctField;
|
|
42
|
+
this.opOptions = init.options ?? {};
|
|
43
|
+
}
|
|
44
|
+
// ---- chain methods ------------------------------------------------
|
|
45
|
+
lean(on = true) {
|
|
46
|
+
this._lean = on;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
sort(spec) {
|
|
50
|
+
this._sort = spec;
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
limit(n) {
|
|
54
|
+
this._limit = n;
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
skip(n) {
|
|
58
|
+
this._skip = n;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
select(spec) {
|
|
62
|
+
// Mongoose accepts strings ("name -_id") and objects ({ name: 1 }).
|
|
63
|
+
// The driver only takes objects, so normalize.
|
|
64
|
+
this._select = typeof spec === "string" ? parseSelectString(spec) : spec;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
hint(h) {
|
|
68
|
+
this._hint = h;
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
comment(c) {
|
|
72
|
+
this._comment = c;
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
session(s) {
|
|
76
|
+
this._session = s ?? undefined;
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
read(pref) {
|
|
80
|
+
this._readPref = pref;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
// Force ObjectId auto-cast ON for this query, regardless of the
|
|
84
|
+
// constructor's `autoCastIds` setting. See README + SECURITY.md.
|
|
85
|
+
castIds() {
|
|
86
|
+
this._castIdsOps.push("on");
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
// Force ObjectId auto-cast OFF for this query, regardless of the
|
|
90
|
+
// constructor's `autoCastIds` setting.
|
|
91
|
+
skipCastIds() {
|
|
92
|
+
this._castIdsOps.push("off");
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
populate(arg) {
|
|
96
|
+
// Mongoose semantics: bare `.populate()` populates every ref in the
|
|
97
|
+
// schema. We mirror that by populating every path in the model's
|
|
98
|
+
// populates map.
|
|
99
|
+
if (arg === undefined) {
|
|
100
|
+
const allPaths = Object.keys(this.model.populates);
|
|
101
|
+
this._populates.push(...allPaths.map((path) => ({ path })));
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
const specs = normalizePopulateArg(arg);
|
|
105
|
+
this._populates.push(...specs);
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
where(field, value) {
|
|
109
|
+
// Mongoose-style .where('foo').equals(bar) / .where('foo', bar).
|
|
110
|
+
// Inventory shows minimal usage; just support the two-arg form
|
|
111
|
+
// and merge into filter.
|
|
112
|
+
this.filter = this.filter ?? {};
|
|
113
|
+
if (arguments.length === 2) {
|
|
114
|
+
this.filter[field] = value;
|
|
115
|
+
}
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
// Mongoose-style filter combinators. Merge into the existing filter
|
|
119
|
+
// exactly the way mongoose does — push onto the existing $or/$and
|
|
120
|
+
// array if one exists, else create it.
|
|
121
|
+
or(conditions) {
|
|
122
|
+
this.filter = this.filter ?? {};
|
|
123
|
+
this.filter.$or = [...(this.filter.$or ?? []), ...conditions];
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
and(conditions) {
|
|
127
|
+
this.filter = this.filter ?? {};
|
|
128
|
+
this.filter.$and = [...(this.filter.$and ?? []), ...conditions];
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
nor(conditions) {
|
|
132
|
+
this.filter = this.filter ?? {};
|
|
133
|
+
this.filter.$nor = [...(this.filter.$nor ?? []), ...conditions];
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
// Aggregate-only: matches mongoose's Query.option (loose passthrough).
|
|
137
|
+
option(o) {
|
|
138
|
+
this.opOptions = { ...this.opOptions, ...o };
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
// ---- thenable terminus ---------------------------------------------
|
|
142
|
+
exec() {
|
|
143
|
+
if (this._executed) {
|
|
144
|
+
// Match mongoose's actual behavior on re-execution.
|
|
145
|
+
return Promise.reject(new Error("Query was already executed: re-run not supported"));
|
|
146
|
+
}
|
|
147
|
+
this._executed = true;
|
|
148
|
+
this._execPromise = this.run();
|
|
149
|
+
return this._execPromise;
|
|
150
|
+
}
|
|
151
|
+
then(onResolve, onReject) {
|
|
152
|
+
return this.exec().then(onResolve, onReject);
|
|
153
|
+
}
|
|
154
|
+
catch(onReject) {
|
|
155
|
+
return this.exec().catch(onReject);
|
|
156
|
+
}
|
|
157
|
+
finally(onFinally) {
|
|
158
|
+
return this.exec().finally(onFinally);
|
|
159
|
+
}
|
|
160
|
+
// ---- dispatch ------------------------------------------------------
|
|
161
|
+
async run() {
|
|
162
|
+
switch (this.op) {
|
|
163
|
+
case "find":
|
|
164
|
+
return this.runFind();
|
|
165
|
+
case "findOne":
|
|
166
|
+
return this.runFindOne();
|
|
167
|
+
case "findOneAndUpdate":
|
|
168
|
+
return this.runFindOneAndUpdate();
|
|
169
|
+
case "findOneAndDelete":
|
|
170
|
+
return this.runFindOneAndDelete();
|
|
171
|
+
case "updateOne":
|
|
172
|
+
return this.runUpdate("updateOne");
|
|
173
|
+
case "updateMany":
|
|
174
|
+
return this.runUpdate("updateMany");
|
|
175
|
+
case "deleteOne":
|
|
176
|
+
return this.runDelete("deleteOne");
|
|
177
|
+
case "deleteMany":
|
|
178
|
+
return this.runDelete("deleteMany");
|
|
179
|
+
case "countDocuments":
|
|
180
|
+
return this.runCount();
|
|
181
|
+
case "distinct":
|
|
182
|
+
return this.runDistinct();
|
|
183
|
+
case "exists":
|
|
184
|
+
return this.runExists();
|
|
185
|
+
case "aggregate":
|
|
186
|
+
return this.runAggregate();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Apply 24-hex-string → ObjectId coercion, IF the resolved
|
|
190
|
+
// per-query setting says we should. Done once per exec, at the
|
|
191
|
+
// boundary just before handing the filter to the driver.
|
|
192
|
+
//
|
|
193
|
+
// The cast is on by default for Mongoose parity (Mongoose does the
|
|
194
|
+
// same silently from the schema). Opt out globally with
|
|
195
|
+
// `autoCastIds: false`, or per-query with `.skipCastIds()` when the
|
|
196
|
+
// filter value happens to look like a hex id but isn't.
|
|
197
|
+
castedFilter() {
|
|
198
|
+
const filter = this.filter ?? {};
|
|
199
|
+
return this.resolveCastIds() ? castFilter(filter) : filter;
|
|
200
|
+
}
|
|
201
|
+
// Resolve the per-query cast-id setting:
|
|
202
|
+
// 0 calls of either → constructor default
|
|
203
|
+
// only .castIds() was called → true
|
|
204
|
+
// only .skipCastIds() was called → false
|
|
205
|
+
// both were called → consult conflict policy
|
|
206
|
+
resolveCastIds() {
|
|
207
|
+
const ops = this._castIdsOps;
|
|
208
|
+
const hasOn = ops.includes("on");
|
|
209
|
+
const hasOff = ops.includes("off");
|
|
210
|
+
if (!hasOn && !hasOff)
|
|
211
|
+
return this.model.autoCastIds;
|
|
212
|
+
if (hasOn && !hasOff)
|
|
213
|
+
return true;
|
|
214
|
+
if (!hasOn && hasOff)
|
|
215
|
+
return false;
|
|
216
|
+
// Conflict: both were called at least once.
|
|
217
|
+
switch (this.model.castIdsConflictPolicy) {
|
|
218
|
+
case "throw":
|
|
219
|
+
throw new CastIdsConflictError({
|
|
220
|
+
castIdsCallCount: ops.filter((o) => o === "on").length,
|
|
221
|
+
skipCastIdsCallCount: ops.filter((o) => o === "off").length,
|
|
222
|
+
});
|
|
223
|
+
case "firstWins":
|
|
224
|
+
return ops[0] === "on";
|
|
225
|
+
case "lastWins":
|
|
226
|
+
return ops[ops.length - 1] === "on";
|
|
227
|
+
case "defaultWins":
|
|
228
|
+
return this.model.autoCastIds;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
findOptions() {
|
|
232
|
+
const o = {};
|
|
233
|
+
if (this._sort)
|
|
234
|
+
o.sort = this._sort;
|
|
235
|
+
if (this._limit !== undefined)
|
|
236
|
+
o.limit = this._limit;
|
|
237
|
+
if (this._skip !== undefined)
|
|
238
|
+
o.skip = this._skip;
|
|
239
|
+
if (this._select)
|
|
240
|
+
o.projection = this._select;
|
|
241
|
+
if (this._hint)
|
|
242
|
+
o.hint = this._hint;
|
|
243
|
+
if (this._comment)
|
|
244
|
+
o.comment = this._comment;
|
|
245
|
+
if (this._session)
|
|
246
|
+
o.session = this._session;
|
|
247
|
+
if (this._readPref)
|
|
248
|
+
o.readPreference = this._readPref;
|
|
249
|
+
return o;
|
|
250
|
+
}
|
|
251
|
+
writeOptions() {
|
|
252
|
+
const o = { ...this.opOptions };
|
|
253
|
+
if (this._hint)
|
|
254
|
+
o.hint = this._hint;
|
|
255
|
+
if (this._comment)
|
|
256
|
+
o.comment = this._comment;
|
|
257
|
+
if (this._session)
|
|
258
|
+
o.session = this._session;
|
|
259
|
+
return o;
|
|
260
|
+
}
|
|
261
|
+
async runFind() {
|
|
262
|
+
const cursor = this.model.collection.find(this.castedFilter(), this.findOptions());
|
|
263
|
+
let docs = await cursor.toArray();
|
|
264
|
+
if (this._populates.length) {
|
|
265
|
+
docs = await runPopulate(docs, this._populates, {
|
|
266
|
+
ownerModel: this.model,
|
|
267
|
+
session: this._session,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (!this._lean)
|
|
271
|
+
docs.forEach(addIdVirtual);
|
|
272
|
+
return docs;
|
|
273
|
+
}
|
|
274
|
+
async runFindOne() {
|
|
275
|
+
const doc = await this.model.collection.findOne(this.castedFilter(), this.findOptions());
|
|
276
|
+
if (!doc)
|
|
277
|
+
return null;
|
|
278
|
+
if (this._populates.length) {
|
|
279
|
+
const [populated] = await runPopulate([doc], this._populates, {
|
|
280
|
+
ownerModel: this.model,
|
|
281
|
+
session: this._session,
|
|
282
|
+
});
|
|
283
|
+
if (!this._lean)
|
|
284
|
+
addIdVirtual(populated);
|
|
285
|
+
return populated;
|
|
286
|
+
}
|
|
287
|
+
if (!this._lean)
|
|
288
|
+
addIdVirtual(doc);
|
|
289
|
+
return doc;
|
|
290
|
+
}
|
|
291
|
+
async runFindOneAndUpdate() {
|
|
292
|
+
// Mongoose default: { new: false, upsert: false }, returns the doc
|
|
293
|
+
// (pre-update unless new: true). Driver default in modern driver is
|
|
294
|
+
// returnDocument: 'before' which matches mongoose. We only translate
|
|
295
|
+
// { new: true } → returnDocument: 'after'.
|
|
296
|
+
const o = this.writeOptions();
|
|
297
|
+
if (o.new === true)
|
|
298
|
+
o.returnDocument = "after";
|
|
299
|
+
delete o.new;
|
|
300
|
+
if (this._select)
|
|
301
|
+
o.projection = this._select;
|
|
302
|
+
if (this._sort)
|
|
303
|
+
o.sort = this._sort;
|
|
304
|
+
const res = await this.model.collection.findOneAndUpdate(this.castedFilter(), wrapInSetIfPlain(this.update), o);
|
|
305
|
+
// Driver returns the document directly (not { value }) in modern
|
|
306
|
+
// versions. Mongoose unwraps to the doc too.
|
|
307
|
+
const doc = res && typeof res === "object" && "value" in res
|
|
308
|
+
? res.value
|
|
309
|
+
: res;
|
|
310
|
+
if (!doc)
|
|
311
|
+
return null;
|
|
312
|
+
if (this._populates.length) {
|
|
313
|
+
const [populated] = await runPopulate([doc], this._populates, {
|
|
314
|
+
ownerModel: this.model,
|
|
315
|
+
session: this._session,
|
|
316
|
+
});
|
|
317
|
+
if (!this._lean)
|
|
318
|
+
addIdVirtual(populated);
|
|
319
|
+
return populated;
|
|
320
|
+
}
|
|
321
|
+
if (!this._lean)
|
|
322
|
+
addIdVirtual(doc);
|
|
323
|
+
return doc;
|
|
324
|
+
}
|
|
325
|
+
async runFindOneAndDelete() {
|
|
326
|
+
const o = this.writeOptions();
|
|
327
|
+
if (this._select)
|
|
328
|
+
o.projection = this._select;
|
|
329
|
+
if (this._sort)
|
|
330
|
+
o.sort = this._sort;
|
|
331
|
+
const res = await this.model.collection.findOneAndDelete(this.castedFilter(), o);
|
|
332
|
+
const doc = res && typeof res === "object" && "value" in res
|
|
333
|
+
? res.value
|
|
334
|
+
: res;
|
|
335
|
+
if (doc && !this._lean)
|
|
336
|
+
addIdVirtual(doc);
|
|
337
|
+
return doc;
|
|
338
|
+
}
|
|
339
|
+
async runUpdate(kind) {
|
|
340
|
+
return this.model.collection[kind](this.castedFilter(), wrapInSetIfPlain(this.update), this.writeOptions());
|
|
341
|
+
}
|
|
342
|
+
async runDelete(kind) {
|
|
343
|
+
return this.model.collection[kind](this.castedFilter(), this.writeOptions());
|
|
344
|
+
}
|
|
345
|
+
async runCount() {
|
|
346
|
+
return this.model.collection.countDocuments(this.castedFilter(), this.findOptions());
|
|
347
|
+
}
|
|
348
|
+
async runDistinct() {
|
|
349
|
+
return this.model.collection.distinct(this.distinctField, this.castedFilter(), this.findOptions());
|
|
350
|
+
}
|
|
351
|
+
async runExists() {
|
|
352
|
+
const doc = await this.model.collection.findOne(this.castedFilter(), {
|
|
353
|
+
projection: { _id: 1 },
|
|
354
|
+
session: this._session,
|
|
355
|
+
});
|
|
356
|
+
return doc ? { _id: doc._id } : null;
|
|
357
|
+
}
|
|
358
|
+
async runAggregate() {
|
|
359
|
+
const opts = {};
|
|
360
|
+
if (this._session)
|
|
361
|
+
opts.session = this._session;
|
|
362
|
+
if (this._hint)
|
|
363
|
+
opts.hint = this._hint;
|
|
364
|
+
if (this._comment)
|
|
365
|
+
opts.comment = this._comment;
|
|
366
|
+
if (this.opOptions.allowDiskUse)
|
|
367
|
+
opts.allowDiskUse = this.opOptions.allowDiskUse;
|
|
368
|
+
// Cast hex strings in $match stages — but only if auto-cast is
|
|
369
|
+
// resolved-on for this query, same gating as the top-level
|
|
370
|
+
// filter. Other stages may contain expressions, but only $match
|
|
371
|
+
// is a true "filter" position.
|
|
372
|
+
const castOn = this.resolveCastIds();
|
|
373
|
+
const pipeline = this.pipeline.map((stage) => {
|
|
374
|
+
if (castOn && stage && typeof stage === "object" && "$match" in stage) {
|
|
375
|
+
return { ...stage, $match: castFilter(stage.$match) };
|
|
376
|
+
}
|
|
377
|
+
return stage;
|
|
378
|
+
});
|
|
379
|
+
return this.model.collection.aggregate(pipeline, opts).toArray();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Mongoose silently wraps a plain object update in `$set`. The native
|
|
383
|
+
// driver requires atomic operators or a pipeline. Pipeline updates are
|
|
384
|
+
// Mongoose hydrated docs expose `.id` as a getter that returns
|
|
385
|
+
// `this._id.toString()`. Lean docs don't (they're plain objects with
|
|
386
|
+
// just _id). We mirror that: every non-lean return path runs each
|
|
387
|
+
// doc through addIdVirtual to attach a non-enumerable `id` getter.
|
|
388
|
+
//
|
|
389
|
+
// Non-enumerable so JSON.stringify / spread / Object.keys see the
|
|
390
|
+
// same shape as a lean doc. Idempotent: skip if `id` is already an
|
|
391
|
+
// own property (already set, already populated, doc came from
|
|
392
|
+
// somewhere else).
|
|
393
|
+
export const addIdVirtual = (doc) => {
|
|
394
|
+
if (!doc || typeof doc !== "object")
|
|
395
|
+
return;
|
|
396
|
+
if (Object.prototype.hasOwnProperty.call(doc, "id"))
|
|
397
|
+
return;
|
|
398
|
+
Object.defineProperty(doc, "id", {
|
|
399
|
+
get() {
|
|
400
|
+
// Mongoose's `.id` returns `null` when `_id` is missing (rather
|
|
401
|
+
// than `undefined`). Matching that exactly — drop-in compat.
|
|
402
|
+
return this._id == null ? null : this._id.toString();
|
|
403
|
+
},
|
|
404
|
+
enumerable: false,
|
|
405
|
+
configurable: true,
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
// arrays — pass through. Updates that already have at least one
|
|
409
|
+
// top-level `$`-key go through. Anything else gets wrapped.
|
|
410
|
+
const wrapInSetIfPlain = (update) => {
|
|
411
|
+
if (update == null)
|
|
412
|
+
return update;
|
|
413
|
+
if (Array.isArray(update))
|
|
414
|
+
return update;
|
|
415
|
+
if (typeof update !== "object")
|
|
416
|
+
return update;
|
|
417
|
+
const keys = Object.keys(update);
|
|
418
|
+
if (keys.length === 0)
|
|
419
|
+
return update;
|
|
420
|
+
const hasOperator = keys.some((k) => k.startsWith("$"));
|
|
421
|
+
if (hasOperator)
|
|
422
|
+
return update;
|
|
423
|
+
return { $set: update };
|
|
424
|
+
};
|
|
425
|
+
const parseSelectString = (s) => {
|
|
426
|
+
const out = {};
|
|
427
|
+
for (const tok of s.split(/\s+/).filter(Boolean)) {
|
|
428
|
+
if (tok.startsWith("-"))
|
|
429
|
+
out[tok.slice(1)] = 0;
|
|
430
|
+
else
|
|
431
|
+
out[tok] = 1;
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-cast filter values that look like ObjectIds.
|
|
3
|
+
*
|
|
4
|
+
* Mongoose does this via schema knowledge ("this field is an ObjectId
|
|
5
|
+
* ref, so cast the string"). We don't have schemas, so we use the
|
|
6
|
+
* structurally atomic rule:
|
|
7
|
+
*
|
|
8
|
+
* A 24-character hex string in a filter position is an ObjectId.
|
|
9
|
+
*
|
|
10
|
+
* This is the boundary where call sites stop having to remember to
|
|
11
|
+
* wrap with `new ObjectId(...)`. Without it, queries like
|
|
12
|
+
* `{ authorID: user.id }` silently match nothing because the stored
|
|
13
|
+
* value is an ObjectId but the filter is a string.
|
|
14
|
+
*
|
|
15
|
+
* Scope:
|
|
16
|
+
* - Only the filter argument. Updates ($set/$push/$inc payloads) are
|
|
17
|
+
* left alone — the user controls what gets written.
|
|
18
|
+
* - Recursive into nested objects so $and/$or/$nor work, as do
|
|
19
|
+
* positional operators like `{ items.0.ownerID: "..." }`.
|
|
20
|
+
* - $in arrays: each element is cast individually.
|
|
21
|
+
* - $regex / $text / $expr / $where / $jsonSchema / $where are
|
|
22
|
+
* skipped — those operators take strings, not ObjectIds.
|
|
23
|
+
*/
|
|
24
|
+
import { ObjectId } from "bson";
|
|
25
|
+
const HEX_24 = /^[a-f0-9]{24}$/i;
|
|
26
|
+
// Operators whose values are intentional strings/expressions; never
|
|
27
|
+
// cast their contents.
|
|
28
|
+
const STRING_OPERATORS = new Set([
|
|
29
|
+
"$regex",
|
|
30
|
+
"$options",
|
|
31
|
+
"$text",
|
|
32
|
+
"$where",
|
|
33
|
+
"$expr",
|
|
34
|
+
"$jsonSchema",
|
|
35
|
+
"$comment",
|
|
36
|
+
"$search",
|
|
37
|
+
"$language",
|
|
38
|
+
"$caseSensitive",
|
|
39
|
+
"$diacriticSensitive",
|
|
40
|
+
]);
|
|
41
|
+
const isHex24 = (v) => typeof v === "string" && HEX_24.test(v);
|
|
42
|
+
const toOidIfHex = (v) => (isHex24(v) ? new ObjectId(v) : v);
|
|
43
|
+
export const castFilter = (filter) => {
|
|
44
|
+
if (filter == null)
|
|
45
|
+
return filter;
|
|
46
|
+
if (Array.isArray(filter))
|
|
47
|
+
return filter.map(castFilter);
|
|
48
|
+
if (typeof filter !== "object")
|
|
49
|
+
return filter;
|
|
50
|
+
// Leave ObjectId / Date / RegExp / etc. untouched.
|
|
51
|
+
if (isBsonLike(filter))
|
|
52
|
+
return filter;
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
55
|
+
if (STRING_OPERATORS.has(key)) {
|
|
56
|
+
out[key] = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out[key] = castValue(value);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
const castValue = (value) => {
|
|
64
|
+
if (isHex24(value))
|
|
65
|
+
return new ObjectId(value);
|
|
66
|
+
if (value == null)
|
|
67
|
+
return value;
|
|
68
|
+
if (Array.isArray(value))
|
|
69
|
+
return value.map(castValue);
|
|
70
|
+
if (typeof value !== "object")
|
|
71
|
+
return value;
|
|
72
|
+
if (isBsonLike(value))
|
|
73
|
+
return value;
|
|
74
|
+
// Object — could be an operator spec ({ $in: [...] }) or a nested
|
|
75
|
+
// subdocument. Either way, recurse with the same rules.
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of Object.entries(value)) {
|
|
78
|
+
if (STRING_OPERATORS.has(k)) {
|
|
79
|
+
out[k] = v;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out[k] = castValue(v);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
};
|
|
86
|
+
// True for values we should pass through untouched (ObjectId, Date,
|
|
87
|
+
// Decimal128, Buffer, RegExp, etc.). Heuristic: non-plain objects.
|
|
88
|
+
const isBsonLike = (v) => {
|
|
89
|
+
if (v instanceof Date)
|
|
90
|
+
return true;
|
|
91
|
+
if (v instanceof RegExp)
|
|
92
|
+
return true;
|
|
93
|
+
// bson types and similar — anything whose prototype isn't plain Object
|
|
94
|
+
const proto = Object.getPrototypeOf(v);
|
|
95
|
+
return proto !== Object.prototype && proto !== null;
|
|
96
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Model } from "./Model.js";
|
|
2
|
+
// Build a registry of Model instances keyed by name. The returned
|
|
3
|
+
// getModel is the only thing call sites see; they never touch Model
|
|
4
|
+
// directly.
|
|
5
|
+
export const createGetModel = (opts) => {
|
|
6
|
+
const registry = new Map();
|
|
7
|
+
// collection name -> model name, so populate can resolve refs by
|
|
8
|
+
// collection (matches how the test fixtures express them).
|
|
9
|
+
const collectionToModelName = new Map();
|
|
10
|
+
for (const [name, collection] of Object.entries(opts.models)) {
|
|
11
|
+
collectionToModelName.set(collection, name);
|
|
12
|
+
}
|
|
13
|
+
const getModelByName = (name) => {
|
|
14
|
+
const m = registry.get(name);
|
|
15
|
+
if (!m)
|
|
16
|
+
throw new Error(`Unknown model: ${name}`);
|
|
17
|
+
return m;
|
|
18
|
+
};
|
|
19
|
+
// Resolve defaults once, here, so each Model gets a frozen copy.
|
|
20
|
+
// autoCastIds defaults to ON for Mongoose-parity — Mongoose silently
|
|
21
|
+
// coerces 24-char hex strings to ObjectId, and viper is a drop-in
|
|
22
|
+
// replacement. Set autoCastIds: false to opt out.
|
|
23
|
+
const autoCastIds = opts.autoCastIds !== false;
|
|
24
|
+
// Conflict resolution defaults to "throw" so .castIds() and
|
|
25
|
+
// .skipCastIds() colliding on the same query surfaces the bug
|
|
26
|
+
// instead of silently picking one.
|
|
27
|
+
const castIdsConflictPolicy = opts.castIdsConflictPolicy ?? "throw";
|
|
28
|
+
for (const [name, collection] of Object.entries(opts.models)) {
|
|
29
|
+
const populates = opts.populates?.[name];
|
|
30
|
+
const model = new Model({
|
|
31
|
+
name,
|
|
32
|
+
collection: opts.db.collection(collection),
|
|
33
|
+
db: opts.db,
|
|
34
|
+
populates: populates ?? {},
|
|
35
|
+
collectionToModelName,
|
|
36
|
+
getModelByName,
|
|
37
|
+
autoCastIds,
|
|
38
|
+
castIdsConflictPolicy,
|
|
39
|
+
});
|
|
40
|
+
registry.set(name, model);
|
|
41
|
+
}
|
|
42
|
+
return (name) => getModelByName(name);
|
|
43
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public surface. Just createGetModel — no Schema, no Types, no
|
|
2
|
+
// connection management, no Model export. The caller wires up the
|
|
3
|
+
// MongoClient + Db themselves and hands us the Db.
|
|
4
|
+
//
|
|
5
|
+
// See README.md for the full design rationale.
|
|
6
|
+
export { createGetModel } from "./createGetModel.js";
|
|
7
|
+
export { CastIdsConflictError } from "./CastIdsConflictError.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|