uql-orm 0.47.0 → 0.48.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.
Files changed (36) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +2 -2
  2. package/dist/browser/type/clientQuerier.d.ts +3 -3
  3. package/dist/browser/uql-browser.min.js.map +2 -2
  4. package/dist/dialect/abstractDialect.d.ts +35 -1
  5. package/dist/dialect/abstractDialect.js +29 -0
  6. package/dist/dialect/abstractSqlDialect.d.ts +13 -5
  7. package/dist/dialect/abstractSqlDialect.js +45 -49
  8. package/dist/entity/decorator/members.d.ts +2 -0
  9. package/dist/entity/decorator/members.js +2 -0
  10. package/dist/entity/index.d.ts +1 -1
  11. package/dist/entity/index.js +1 -1
  12. package/dist/entity/metadata/definition.d.ts +11 -2
  13. package/dist/entity/metadata/definition.js +11 -0
  14. package/dist/mongo/mongoDialect.d.ts +36 -6
  15. package/dist/mongo/mongoDialect.js +116 -31
  16. package/dist/mongo/mongodbQuerier.d.ts +8 -3
  17. package/dist/mongo/mongodbQuerier.js +30 -11
  18. package/dist/querier/abstractQuerier.d.ts +33 -4
  19. package/dist/querier/abstractQuerier.js +92 -43
  20. package/dist/querier/abstractQuerierPool.d.ts +2 -2
  21. package/dist/querier/abstractSqlQuerier.d.ts +3 -2
  22. package/dist/querier/abstractSqlQuerier.js +132 -31
  23. package/dist/querier/relationCount.js +15 -9
  24. package/dist/type/entity.d.ts +1 -1
  25. package/dist/type/queryRaw.d.ts +1 -1
  26. package/dist/type/queryWhere.d.ts +11 -1
  27. package/dist/type/universalQuerier.d.ts +2 -2
  28. package/dist/util/dialect.util.d.ts +7 -0
  29. package/dist/util/dialect.util.js +14 -0
  30. package/dist/util/fieldOption.util.d.ts +1 -1
  31. package/dist/util/fieldOption.util.js +1 -1
  32. package/dist/util/relationQuery.util.d.ts +9 -6
  33. package/dist/util/relationQuery.util.js +11 -11
  34. package/dist/util/rowKey.util.d.ts +18 -4
  35. package/dist/util/rowKey.util.js +29 -5
  36. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { type Document, type Filter, ObjectId, type Sort, type UpdateFilter } from 'mongodb';
1
+ import { type Document, type Filter, type Sort, type UpdateFilter } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import type { DialectFeatures, EntityData, EntityMeta, FieldValue, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPopulate, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
4
  import { type CallbackKey } from '../util/index.js';
@@ -43,11 +43,21 @@ export declare class MongoDialect extends AbstractDialect {
43
43
  /** Whether a `$where` constrains any relation, and so needs the aggregation path rather than a cursor. */
44
44
  constrainsRelations<E extends Document>(entity: Type<E>, where: QueryWhere<E> | undefined): boolean;
45
45
  /**
46
- * Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or`
46
+ * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
47
47
  * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
48
48
  * given - a plain `find`/`updateMany` filter has nowhere to put them.
49
49
  */
50
50
  private renderFilter;
51
+ /**
52
+ * Renders `$and`/`$or`/`$not`/`$nor` into `filter`. MongoDB has no root-level `$not`, so both
53
+ * negating operators become its `$nor`, which is exactly `NOT (a OR b)` - and by De Morgan that
54
+ * makes a `$nor` list its clauses directly while a `$not` wraps them in one `$and` first.
55
+ *
56
+ * Clauses that render to nothing are dropped and an empty operator emits no key at all: MongoDB
57
+ * rejects an empty `$and`/`$or`/`$nor` outright, where the SQL dialects contribute no term.
58
+ * Negations accumulate into the one `$nor`, since `NOT a AND NOT b` is `$nor: [a, b]`.
59
+ */
60
+ private appendLogicalOperator;
51
61
  /**
52
62
  * Emits the correlated `$lookup` for one relation condition and returns the condition that tests its
53
63
  * result: presence of a row for a plain relation filter, a comparison against the row count for
@@ -177,9 +187,20 @@ export declare class MongoDialect extends AbstractDialect {
177
187
  private joinKeys;
178
188
  /** `[column, key]` for the fields whose stored name differs from their property name, memoized per entity. */
179
189
  private renamedColumns;
180
- normalizeIds<E extends Document>(meta: EntityMeta<E>, docs: E[] | undefined): E[] | undefined;
181
- normalizeId<E extends Document>(meta: EntityMeta<E>, doc: E | undefined): E | undefined;
182
- getIdValue<T extends IdValue>(value: T): T;
190
+ private referenceKeys;
191
+ normalizeIds<E extends Document>(meta: EntityMeta<E>, docs: Document[] | undefined): E[] | undefined;
192
+ /** `doc` is the wire shape - `_id`, stored names, `ObjectId`s - and what comes back is the code's. */
193
+ normalizeId<E extends Document>(meta: EntityMeta<E>, doc: Document | undefined): E | undefined;
194
+ /**
195
+ * The seam into the driver: a key, or a reference to one, as MongoDB stores it. A 24-hex string
196
+ * becomes an `ObjectId`, so a write agrees with the filter that will later look for it; anything
197
+ * else - a UUID, a number, an `ObjectId` already - is stored as given, which is how those keys keep
198
+ * their value. Strictly 24-hex: the driver also accepts any 12-byte string, and coercing one of
199
+ * those turned an ordinary short key into a foreign `ObjectId`. Arrays convert element-wise.
200
+ */
201
+ toWireId(value: unknown): unknown;
202
+ /** The seam out of the driver: an `ObjectId` becomes its hex string, the type the code declares. */
203
+ fromWireId(value: unknown): unknown;
183
204
  getPersistable<E extends Document>(meta: EntityMeta<E>, payload: EntityData<E>, callbackKey: CallbackKey): Partial<E>;
184
205
  /** One MongoDB update document's operators, grouped by kind and keyed by dotted path. */
185
206
  private groupUpdateOperators;
@@ -201,6 +222,16 @@ export declare class MongoDialect extends AbstractDialect {
201
222
  * `$literal` so a string starting with `$` stays data rather than becoming a field reference.
202
223
  */
203
224
  private getUpdatePipeline;
225
+ /**
226
+ * Refuses a key the caller left to MongoDB that MongoDB cannot mint one of.
227
+ *
228
+ * The only key a server generates is an `ObjectId`, which {@link fromWireId} hands back as its hex
229
+ * string - so a key declared `String` is satisfiable and one declared `Number` is not. Answering a
230
+ * numeric declaration with a string is the lie this exists to refuse: the field says `number`, the
231
+ * value is not one, and every consumer that indexes or compares by it is quietly wrong. Prisma
232
+ * refuses the same shape at its schema, and this is the first moment uql can.
233
+ */
234
+ private assertMintableKey;
204
235
  getPersistables<E extends Document>(meta: EntityMeta<E>, payload: EntityData<E> | EntityData<E>[], callbackKey: CallbackKey): Partial<E>[];
205
236
  /**
206
237
  * Build MongoDB aggregation pipeline stages from a QueryAggregate.
@@ -253,7 +284,6 @@ type MongoAggregationUnwind = {
253
284
  readonly path?: string;
254
285
  readonly preserveNullAndEmptyArrays?: boolean;
255
286
  };
256
- type IdValue = string | ObjectId;
257
287
  export type ExtractedVectorSort<E> = {
258
288
  readonly vectorKey: string;
259
289
  readonly vectorSearch: QueryVectorSearch;
@@ -4,7 +4,7 @@ import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '..
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, getMeta, soleIdOf } from '../entity/index.js';
6
6
  import { QueryRaw } from '../type/queryRaw.js';
7
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
7
+ import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, columnFamily, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
8
8
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
9
9
  export const mongoDialectFeatures = {
10
10
  explicitJsonCast: false,
@@ -24,6 +24,12 @@ export const mongoDialectFeatures = {
24
24
  supportsTimestamptz: false,
25
25
  defaultStringAsText: false,
26
26
  };
27
+ /** What `toWireId` converts: the hex spelling of an `ObjectId`, and nothing looser. */
28
+ const HEX_24 = /^[0-9a-f]{24}$/i;
29
+ /** How a declared column type reads in a message: `Number`, `uuid` - not its whole source. */
30
+ function declaredTypeName(type) {
31
+ return typeof type === 'function' ? type.name : String(type);
32
+ }
27
33
  export class MongoDialect extends AbstractDialect {
28
34
  featureDefaults = mongoDialectFeatures;
29
35
  dialectName = 'mongodb';
@@ -83,12 +89,12 @@ export class MongoDialect extends AbstractDialect {
83
89
  }
84
90
  const meta = getMeta(entity);
85
91
  const whereMap = buildQueryWhereAsMap(meta, where);
86
- return someKey(whereMap, (key) => key === '$and' || key === '$or'
87
- ? whereMap[key].some((it) => this.constrainsRelations(entity, it))
92
+ return someKey(whereMap, (key) => MongoDialect.isGroupOp(key)
93
+ ? (whereMap[key] ?? []).some((it) => this.constrainsRelations(entity, it))
88
94
  : Boolean(meta.relations[key]));
89
95
  }
90
96
  /**
91
- * Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or`
97
+ * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
92
98
  * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
93
99
  * given - a plain `find`/`updateMany` filter has nowhere to put them.
94
100
  */
@@ -99,13 +105,8 @@ export class MongoDialect extends AbstractDialect {
99
105
  for (const [rawKey, rawVal] of Object.entries(whereMap)) {
100
106
  let key = rawKey;
101
107
  let val = rawVal;
102
- if (key === '$and' || key === '$or') {
103
- filter[key] = val.map((filterIt) => {
104
- // A `QueryRaw` here would recurse forever: `buildQueryWhereAsMap` re-wraps it as
105
- // `{ $and: [raw] }`, which lands back on this branch.
106
- this.assertNoRaw(filterIt);
107
- return this.renderFilter(entity, filterIt, opts, lookups);
108
- });
108
+ if (MongoDialect.isGroupOp(key)) {
109
+ this.appendLogicalOperator(filter, entity, key, val, opts, lookups);
109
110
  }
110
111
  else if (key === '$text') {
111
112
  // MongoDB's text index declares which fields it covers, so `$fields` cannot narrow the search
@@ -122,9 +123,10 @@ export class MongoDialect extends AbstractDialect {
122
123
  else {
123
124
  this.assertNoRaw(val);
124
125
  this.assertKnownPathRoot(meta, key);
126
+ const isReference = !!meta.fields[key]?.references;
125
127
  key = this.pathOf(meta, key);
126
- if (key === MongoDialect.ID_KEY) {
127
- val = this.getIdValue(val);
128
+ if ((key === MongoDialect.ID_KEY || isReference) && !isOperatorObject(val)) {
129
+ val = this.toWireId(val);
128
130
  }
129
131
  if (isOperatorObject(val)) {
130
132
  val = this.transformOperators(val);
@@ -137,6 +139,35 @@ export class MongoDialect extends AbstractDialect {
137
139
  }
138
140
  return filter;
139
141
  }
142
+ /**
143
+ * Renders `$and`/`$or`/`$not`/`$nor` into `filter`. MongoDB has no root-level `$not`, so both
144
+ * negating operators become its `$nor`, which is exactly `NOT (a OR b)` - and by De Morgan that
145
+ * makes a `$nor` list its clauses directly while a `$not` wraps them in one `$and` first.
146
+ *
147
+ * Clauses that render to nothing are dropped and an empty operator emits no key at all: MongoDB
148
+ * rejects an empty `$and`/`$or`/`$nor` outright, where the SQL dialects contribute no term.
149
+ * Negations accumulate into the one `$nor`, since `NOT a AND NOT b` is `$nor: [a, b]`.
150
+ */
151
+ appendLogicalOperator(filter, entity, key, val, opts, lookups) {
152
+ const { join, negate } = MongoDialect.GROUP_OPS[key];
153
+ const parts = MongoDialect.groupClauses(key, val)
154
+ .map((filterIt) => {
155
+ // A `QueryRaw` here would recurse forever: `buildQueryWhereAsMap` re-wraps it as
156
+ // `{ $and: [raw] }`, which lands back on this branch.
157
+ this.assertNoRaw(filterIt);
158
+ return this.renderFilter(entity, filterIt, opts, lookups);
159
+ })
160
+ .filter((part) => Object.keys(part).length > 0);
161
+ if (!parts.length) {
162
+ return;
163
+ }
164
+ if (!negate) {
165
+ filter[key] = parts;
166
+ return;
167
+ }
168
+ const negated = join === '$and' && parts.length > 1 ? [{ $and: parts }] : parts;
169
+ filter['$nor'] = [...(filter['$nor'] ?? []), ...negated];
170
+ }
140
171
  /**
141
172
  * Emits the correlated `$lookup` for one relation condition and returns the condition that tests its
142
173
  * result: presence of a row for a plain relation filter, a comparison against the row count for
@@ -689,20 +720,31 @@ export class MongoDialect extends AbstractDialect {
689
720
  }
690
721
  return renamed;
691
722
  }
723
+ referenceKeys(meta) {
724
+ let keys = this.#referenceKeys.get(meta);
725
+ if (!keys) {
726
+ keys = getKeys(meta.fields).filter((key) => meta.fields[key]?.references);
727
+ this.#referenceKeys.set(meta, keys);
728
+ }
729
+ return keys;
730
+ }
692
731
  // Keyed by the meta object itself; entity metadata is immutable once defined.
693
732
  #renamedColumns = new WeakMap();
733
+ #referenceKeys = new WeakMap();
694
734
  normalizeIds(meta, docs) {
695
735
  return docs?.map((doc) => this.normalizeId(meta, doc));
696
736
  }
737
+ /** `doc` is the wire shape - `_id`, stored names, `ObjectId`s - and what comes back is the code's. */
697
738
  normalizeId(meta, doc) {
698
739
  if (!doc) {
699
740
  return doc;
700
741
  }
701
742
  const res = doc;
702
743
  const _id = MongoDialect.ID_KEY;
703
- if (res[_id]) {
744
+ // `!== undefined`, not truthiness: `0` is a key MongoDB accepts and a truthy test dropped it.
745
+ if (res[_id] !== undefined) {
704
746
  const idKey = soleIdOf(meta, 'MongoDB');
705
- res[idKey] = res[_id];
747
+ res[idKey] = this.fromWireId(res[_id]);
706
748
  if (idKey !== _id) {
707
749
  delete res[_id];
708
750
  }
@@ -715,6 +757,12 @@ export class MongoDialect extends AbstractDialect {
715
757
  delete res[column];
716
758
  }
717
759
  }
760
+ // After the rename, so a renamed reference is converted under the name the code reads.
761
+ for (const key of this.referenceKeys(meta)) {
762
+ if (res[key] !== undefined) {
763
+ res[key] = this.fromWireId(res[key]);
764
+ }
765
+ }
718
766
  const relKeys = getKeys(meta.relations).filter((key) => res[key]);
719
767
  for (const relKey of relKeys) {
720
768
  const relOpts = meta.relations[relKey];
@@ -727,16 +775,22 @@ export class MongoDialect extends AbstractDialect {
727
775
  }
728
776
  return res;
729
777
  }
730
- getIdValue(value) {
731
- if (value instanceof ObjectId) {
732
- return value;
733
- }
734
- try {
735
- return new ObjectId(value);
736
- }
737
- catch (e) {
738
- return value;
778
+ /**
779
+ * The seam into the driver: a key, or a reference to one, as MongoDB stores it. A 24-hex string
780
+ * becomes an `ObjectId`, so a write agrees with the filter that will later look for it; anything
781
+ * else - a UUID, a number, an `ObjectId` already - is stored as given, which is how those keys keep
782
+ * their value. Strictly 24-hex: the driver also accepts any 12-byte string, and coercing one of
783
+ * those turned an ordinary short key into a foreign `ObjectId`. Arrays convert element-wise.
784
+ */
785
+ toWireId(value) {
786
+ if (Array.isArray(value)) {
787
+ return value.map((it) => this.toWireId(it));
739
788
  }
789
+ return typeof value === 'string' && HEX_24.test(value) ? new ObjectId(value) : value;
790
+ }
791
+ /** The seam out of the driver: an `ObjectId` becomes its hex string, the type the code declares. */
792
+ fromWireId(value) {
793
+ return value instanceof ObjectId ? value.toHexString() : value;
740
794
  }
741
795
  getPersistable(meta, payload, callbackKey) {
742
796
  return this.getPersistables(meta, payload, callbackKey)[0];
@@ -819,17 +873,48 @@ export class MongoDialect extends AbstractDialect {
819
873
  }
820
874
  return [{ $set: assignments }, ...(unset.size > 0 ? [{ $unset: [...unset] }] : [])];
821
875
  }
876
+ /**
877
+ * Refuses a key the caller left to MongoDB that MongoDB cannot mint one of.
878
+ *
879
+ * The only key a server generates is an `ObjectId`, which {@link fromWireId} hands back as its hex
880
+ * string - so a key declared `String` is satisfiable and one declared `Number` is not. Answering a
881
+ * numeric declaration with a string is the lie this exists to refuse: the field says `number`, the
882
+ * value is not one, and every consumer that indexes or compares by it is quietly wrong. Prisma
883
+ * refuses the same shape at its schema, and this is the first moment uql can.
884
+ */
885
+ assertMintableKey(meta, field) {
886
+ if (columnFamily(field.type) === 'string') {
887
+ return;
888
+ }
889
+ throw new TypeError(`'${entityName(meta)}.${meta.ids[0]}' is declared '${declaredTypeName(field.type)}' and left to the ` +
890
+ 'database, which MongoDB cannot do: the only key it generates is an ObjectId, read back as a string. ' +
891
+ "Declare the key as a string, or give it an 'onInsert' generator.");
892
+ }
822
893
  getPersistables(meta, payload, callbackKey) {
823
- // Not `columnOf`, which maps the primary key to `_id`: an update may not touch `_id` at all, and
824
- // an insert leaves it to the driver. What that mapping refuses, a write has to refuse too.
894
+ // What `columnOf` refuses, a write has to refuse too.
825
895
  assertSoleId(meta, 'MongoDB');
896
+ const [idKey] = meta.ids;
826
897
  const payloads = fillOnFields(meta, payload, callbackKey);
827
898
  // Keys are resolved per document so heterogeneous payloads keep every provided field.
828
- return payloads.map((it) => filterFieldKeys(meta, it, callbackKey).reduce((acc, key) => {
829
- const field = meta.fields[key];
830
- acc[this.resolveColumnName(key, field)] = it[key];
831
- return acc;
832
- }, {}));
899
+ const inserting = callbackKey === 'onInsert';
900
+ return payloads.map((it) => {
901
+ // The key is `_id` on an insert and immutable on an update, so it is left out of one. It used
902
+ // to land under its own name beside the `_id` the driver minted: a supplied id, or one an
903
+ // `onInsert` generated, was written and unreachable by the value the caller held.
904
+ const named = inserting && it[idKey] != null;
905
+ if (inserting && !named) {
906
+ // Nothing named the key, so the database is being asked to mint one.
907
+ this.assertMintableKey(meta, meta.fields[idKey]);
908
+ }
909
+ const doc = named ? { [MongoDialect.ID_KEY]: this.toWireId(it[idKey]) } : {};
910
+ for (const key of filterFieldKeys(meta, it, callbackKey)) {
911
+ if (key === idKey)
912
+ continue;
913
+ const field = meta.fields[key];
914
+ doc[this.resolveColumnName(key, field)] = field.references ? this.toWireId(it[key]) : it[key];
915
+ }
916
+ return doc;
917
+ });
833
918
  }
834
919
  /**
835
920
  * Build MongoDB aggregation pipeline stages from a QueryAggregate.
@@ -58,13 +58,18 @@ export declare class MongodbQuerier extends AbstractQuerier {
58
58
  private settleIds;
59
59
  internalInsertMany<E extends Document>(entity: Type<E>, payloads: EntityData<E>[]): Promise<IdValue<E>[]>;
60
60
  internalUpdateMany<E extends Document>(entity: Type<E>, qm: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
61
+ /**
62
+ * `_id` is immutable, so a key the payload names can only be written on the insert branch of an
63
+ * upsert; in `$set` it would refuse every matched document. Everything else updates either way.
64
+ */
65
+ private upsertUpdate;
61
66
  private buildConflictFilter;
62
- upsertOne<E extends Document>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<{
63
- firstId: string;
67
+ protected internalUpsertOne<E extends Document>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<{
68
+ firstId: PrimaryKey | undefined;
64
69
  changes: number;
65
70
  created: boolean;
66
71
  }>;
67
- upsertMany<E extends Document>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<{
72
+ protected internalUpsertMany<E extends Document>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<{
68
73
  changes: number;
69
74
  ids?: undefined;
70
75
  firstId?: undefined;
@@ -81,7 +81,11 @@ export class MongodbQuerier extends AbstractQuerier {
81
81
  */
82
82
  async internalFindManyPerParent(entity, q, { joins, parents }) {
83
83
  const queries = parents.map((parent) => queryChildrenOf(q, joins, parent));
84
- // A vector sort needs a pipeline of its own shape, which only `internalFindMany` builds.
84
+ // A vector sort is not a degraded fallback here, it is the only expressible form: `$vectorSearch`
85
+ // has to be the first stage of a pipeline, so it cannot be one of N `$unionWith` branches. Read a
86
+ // parent at a time it stays correct, because `buildVectorSearchStage` passes the query's `$where`
87
+ // - which carries this parent's key - into the search as its filter, so each parent gets its own
88
+ // nearest rather than a share of the collection's.
85
89
  if (this.dialect.extractVectorSort(q.$sort)) {
86
90
  return this.readEachInTurn(entity, queries);
87
91
  }
@@ -264,7 +268,7 @@ export class MongodbQuerier extends AbstractQuerier {
264
268
  const meta = getMeta(entity);
265
269
  const persistables = this.dialect.getPersistables(meta, payloads, 'onInsert');
266
270
  const { insertedIds } = await this.execute((session) => this.collection(entity).insertMany(persistables, { session }));
267
- const ids = Object.values(insertedIds);
271
+ const ids = Object.values(insertedIds).map((id) => this.dialect.fromWireId(id));
268
272
  const idKey = soleIdOf(meta, 'insert');
269
273
  for (const [index, it] of payloads.entries()) {
270
274
  it[idKey] = ids[index];
@@ -282,7 +286,7 @@ export class MongodbQuerier extends AbstractQuerier {
282
286
  // relation condition has nowhere to go, and MongoDB takes no page on a write, so a paged one
283
287
  // has to name the rows it picked rather than touching every match.
284
288
  const where = this.dialect.constrainsRelations(entity, qm.$where) || isPagedQuery(qm)
285
- ? { _id: { $in: await this.settleIds(entity, qm, opts) } }
289
+ ? { _id: { $in: this.dialect.toWireId(await this.settleIds(entity, qm, opts)) } }
286
290
  : this.dialect.where(entity, qm.$where, opts);
287
291
  // Maps JSON operators ($set/$unset/$push/$pull) onto their native MongoDB equivalents.
288
292
  const update = this.dialect.getUpdateFilter(persistable);
@@ -293,6 +297,21 @@ export class MongodbQuerier extends AbstractQuerier {
293
297
  return matchedCount;
294
298
  });
295
299
  }
300
+ /**
301
+ * `_id` is immutable, so a key the payload names can only be written on the insert branch of an
302
+ * upsert; in `$set` it would refuse every matched document. Everything else updates either way.
303
+ */
304
+ upsertUpdate(persistable) {
305
+ const { _id, ...rest } = persistable;
306
+ const update = {};
307
+ if (hasKeys(rest)) {
308
+ update['$set'] = rest;
309
+ }
310
+ if (_id !== undefined) {
311
+ update['$setOnInsert'] = { _id };
312
+ }
313
+ return update;
314
+ }
296
315
  buildConflictFilter(entity, conflictPaths, item) {
297
316
  const where = getKeys(conflictPaths).reduce((acc, key) => {
298
317
  acc[key] = item[key];
@@ -300,26 +319,26 @@ export class MongodbQuerier extends AbstractQuerier {
300
319
  }, {});
301
320
  return this.dialect.where(entity, where);
302
321
  }
303
- async upsertOne(entity, conflictPaths, payload) {
322
+ async internalUpsertOne(entity, conflictPaths, payload) {
304
323
  return this.timed('upsertOne', undefined, async () => {
305
324
  payload = clone(payload);
306
325
  const meta = getMeta(entity);
307
326
  const persistable = this.dialect.getPersistable(meta, payload, 'onInsert');
308
327
  const filter = this.buildConflictFilter(entity, conflictPaths, payload);
309
- const update = { $set: persistable };
328
+ const update = this.upsertUpdate(persistable);
310
329
  const res = await this.execute((session) => this.collection(entity).findOneAndUpdate(filter, update, {
311
330
  upsert: true,
312
331
  returnDocument: 'after',
313
332
  includeResultMetadata: true,
314
333
  session,
315
334
  }));
316
- const firstId = res?.value?._id;
335
+ const firstId = this.dialect.fromWireId(res?.value?._id);
317
336
  // `updatedExisting` is false when a new document was inserted (upserted).
318
337
  const created = res?.lastErrorObject?.['updatedExisting'] === false;
319
338
  return { firstId, changes: firstId ? 1 : 0, created };
320
339
  });
321
340
  }
322
- async upsertMany(entity, conflictPaths, payload) {
341
+ async internalUpsertMany(entity, conflictPaths, payload) {
323
342
  return this.timed('upsertMany', undefined, async () => {
324
343
  if (!payload?.length) {
325
344
  return { changes: 0 };
@@ -329,7 +348,7 @@ export class MongodbQuerier extends AbstractQuerier {
329
348
  const operations = payload.map((item) => {
330
349
  const persistable = this.dialect.getPersistable(meta, item, 'onInsert');
331
350
  const filter = this.buildConflictFilter(entity, conflictPaths, item);
332
- const update = { $set: persistable };
351
+ const update = this.upsertUpdate(persistable);
333
352
  return {
334
353
  updateOne: {
335
354
  filter,
@@ -344,7 +363,7 @@ export class MongodbQuerier extends AbstractQuerier {
344
363
  // updated document's `_id` isn't in the response, so it's simply not represented here - same
345
364
  // "exact where knowable, absent otherwise" convention `RETURNING`-based SQL dialects use for
346
365
  // rows that hit `DO NOTHING`.
347
- const ids = Object.values(res.upsertedIds);
366
+ const ids = Object.values(res.upsertedIds).map((id) => this.dialect.fromWireId(id));
348
367
  return { changes, ids, firstId: ids[0] };
349
368
  });
350
369
  }
@@ -366,13 +385,13 @@ export class MongodbQuerier extends AbstractQuerier {
366
385
  // Stamp the mapped column: reads filter on it, so a `@Field({ name })` mismatch here would
367
386
  // report a successful delete and leave the row visible.
368
387
  const softDeleteColumn = this.dialect.resolveColumnName(meta.softDelete, field);
369
- const updateResult = await this.execute((session) => this.collection(entity).updateMany({ _id: { $in: ids } }, { $set: { [softDeleteColumn]: getSoftDeleteValue(field) } }, {
388
+ const updateResult = await this.execute((session) => this.collection(entity).updateMany({ _id: { $in: this.dialect.toWireId(ids) } }, { $set: { [softDeleteColumn]: getSoftDeleteValue(field) } }, {
370
389
  session,
371
390
  }));
372
391
  changes = updateResult.matchedCount;
373
392
  }
374
393
  else {
375
- const deleteResult = await this.execute((session) => this.collection(entity).deleteMany({ _id: { $in: ids } }, { session }));
394
+ const deleteResult = await this.execute((session) => this.collection(entity).deleteMany({ _id: { $in: this.dialect.toWireId(ids) } }, { session }));
376
395
  changes = deleteResult.deletedCount;
377
396
  }
378
397
  await this.deleteRelations(entity, ids, opts);
@@ -119,8 +119,16 @@ export declare abstract class AbstractQuerier implements Querier {
119
119
  protected abstract internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
120
120
  restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
121
121
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
122
- abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
123
- abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
122
+ /**
123
+ * `beforeUpsert`/`afterUpsert` rather than the insert's or the update's pair: the database decides
124
+ * which branch each row takes as the statement runs, so neither of those could be fired honestly -
125
+ * but the upsert itself is a fact known before and after, and a row written with no hook at all
126
+ * was how an `@Id({ onInsert })` or an audit trail silently skipped this path.
127
+ */
128
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
129
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
130
+ protected abstract internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
131
+ protected abstract internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
124
132
  deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
125
133
  /**
126
134
  * Delete records matching the query. Soft-deletes when the entity has a soft-delete field (unless
@@ -140,8 +148,24 @@ export declare abstract class AbstractQuerier implements Querier {
140
148
  */
141
149
  private findDoomed;
142
150
  protected abstract internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
143
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
144
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
151
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<EntityId<E> | undefined>;
152
+ /**
153
+ * Insert or update, as the name has always promised - and now as one statement per kind rather
154
+ * than a guess.
155
+ *
156
+ * Whether a row names its key decides which statement it takes, never whether the row exists: an
157
+ * id the caller invented is not proof of anything, and a stale one used to issue an `UPDATE` that
158
+ * matched nothing and reported success. A named row upserts on its own key, so it is written
159
+ * either way and no read can go stale between deciding and writing. An unnamed one inserts, and
160
+ * the database assigns the key.
161
+ *
162
+ * A composite key is always supplied by the caller, so it always takes the upsert branch - which
163
+ * is why nothing here special-cases one, and why this is the method that stopped refusing them.
164
+ *
165
+ * The hooks follow the statement: a named row fires `beforeUpsert`/`afterUpsert`, never the
166
+ * update pair, because the database picks the branch as the statement runs.
167
+ */
168
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(EntityId<E> | undefined)[]>;
145
169
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
146
170
  private fillToManyThroughRelation;
147
171
  private fillToManyOneToMany;
@@ -196,6 +220,11 @@ export declare abstract class AbstractQuerier implements Querier {
196
220
  * Emit a lifecycle hook event for the given entity.
197
221
  * Fires global listeners first, then entity-level hooks.
198
222
  */
223
+ /**
224
+ * Runs `write` between the event's `before`/`after` pair. Every hooked write is this shape, and
225
+ * each one spelled out was a place the pair could drift - `upsert` had none at all for a release.
226
+ */
227
+ private hooked;
199
228
  private emitHook;
200
229
  /**
201
230
  * Runs `task` after everything already queued on this querier, one at a time.