uql-orm 0.41.1 → 0.42.1

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 (69) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +7 -7
  2. package/dist/browser/querier/httpQuerier.js +21 -6
  3. package/dist/browser/type/clientQuerier.d.ts +3 -3
  4. package/dist/browser/uql-browser.min.js +2 -2
  5. package/dist/browser/uql-browser.min.js.map +4 -4
  6. package/dist/dialect/abstractSqlDialect.d.ts +32 -3
  7. package/dist/dialect/abstractSqlDialect.js +58 -22
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -2
  9. package/dist/dialect/mysqlLikeSqlDialect.js +5 -3
  10. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  11. package/dist/dialect/pgLikeSqlDialect.js +2 -1
  12. package/dist/entity/decorator/entity.d.ts +1 -1
  13. package/dist/entity/decorator/entity.js +1 -1
  14. package/dist/entity/decorator/members.d.ts +10 -2
  15. package/dist/entity/index.d.ts +1 -1
  16. package/dist/entity/index.js +1 -1
  17. package/dist/entity/metadata/definition.d.ts +18 -1
  18. package/dist/entity/metadata/definition.js +91 -36
  19. package/dist/http/handler.js +4 -4
  20. package/dist/maria/mariaDialect.d.ts +2 -2
  21. package/dist/maria/mariaDialect.js +3 -2
  22. package/dist/migrate/builder/tableBuilder.js +5 -4
  23. package/dist/migrate/drift/driftDetector.js +16 -0
  24. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +7 -0
  25. package/dist/migrate/generator/mongoSchemaGenerator.js +24 -28
  26. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +9 -1
  27. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +11 -2
  28. package/dist/migrate/introspection/baseSqlIntrospector.js +6 -3
  29. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  30. package/dist/migrate/introspection/sqliteIntrospector.js +7 -3
  31. package/dist/migrate/migrator.js +6 -0
  32. package/dist/migrate/schemaGenerator.d.ts +44 -34
  33. package/dist/migrate/schemaGenerator.js +158 -141
  34. package/dist/mongo/mongoDialect.js +39 -14
  35. package/dist/mongo/mongodbQuerier.js +6 -3
  36. package/dist/postgres/postgresDialect.js +1 -1
  37. package/dist/querier/abstractQuerier.d.ts +16 -12
  38. package/dist/querier/abstractQuerier.js +88 -40
  39. package/dist/querier/abstractQuerierPool.d.ts +8 -8
  40. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  41. package/dist/querier/abstractSqlQuerier.js +12 -7
  42. package/dist/querier/relationCount.js +44 -33
  43. package/dist/schema/indexDifferences.d.ts +28 -0
  44. package/dist/schema/indexDifferences.js +46 -0
  45. package/dist/schema/schemaASTBuilder.js +38 -31
  46. package/dist/schema/schemaASTDiffer.d.ts +27 -1
  47. package/dist/schema/schemaASTDiffer.js +54 -18
  48. package/dist/schema/types.d.ts +46 -7
  49. package/dist/sqlite/sqliteDialect.d.ts +2 -1
  50. package/dist/sqlite/sqliteDialect.js +4 -1
  51. package/dist/type/dialect.d.ts +6 -0
  52. package/dist/type/entity.d.ts +57 -14
  53. package/dist/type/migration.d.ts +19 -0
  54. package/dist/type/queryWhere.d.ts +7 -2
  55. package/dist/type/universalQuerier.d.ts +8 -8
  56. package/dist/util/dialect.util.js +15 -14
  57. package/dist/util/field.util.d.ts +11 -1
  58. package/dist/util/field.util.js +12 -0
  59. package/dist/util/index.d.ts +1 -0
  60. package/dist/util/index.js +1 -0
  61. package/dist/util/object.util.d.ts +6 -0
  62. package/dist/util/object.util.js +18 -0
  63. package/dist/util/relationQuery.util.d.ts +48 -7
  64. package/dist/util/relationQuery.util.js +70 -9
  65. package/dist/util/rowKey.util.d.ts +9 -0
  66. package/dist/util/rowKey.util.js +24 -0
  67. package/dist/util/sql.util.d.ts +24 -7
  68. package/dist/util/sql.util.js +75 -10
  69. package/package.json +1 -1
@@ -2,9 +2,9 @@ import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
- import { getMeta } from '../entity/index.js';
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, parseGroupMap, parseRelationSize, parseSortByCount, someKey, } from '../util/index.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';
8
8
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
9
9
  export const mongoDialectFeatures = {
10
10
  explicitJsonCast: false,
@@ -16,6 +16,7 @@ export const mongoDialectFeatures = {
16
16
  dropTableCascade: false,
17
17
  renameColumn: false,
18
18
  foreignKeyAlter: false,
19
+ primaryKeyAlter: false,
19
20
  columnComment: false,
20
21
  vectorIndexRequiresNotNull: false,
21
22
  vectorSupportsLength: false,
@@ -44,7 +45,14 @@ export class MongoDialect extends AbstractDialect {
44
45
  * the document does not use.
45
46
  */
46
47
  columnOf(meta, key) {
47
- if (key === MongoDialect.ID_KEY || key === meta.id) {
48
+ if (key === MongoDialect.ID_KEY) {
49
+ return MongoDialect.ID_KEY;
50
+ }
51
+ if (meta.fields[key]?.isId) {
52
+ // A document has one `_id`. A composite key is a different document shape - a sub-document,
53
+ // whose field order decides equality - rather than a translation, so it is refused here, which
54
+ // every read reaches, and in `getPersistables`, which every write reaches.
55
+ assertSoleId(meta, 'MongoDB');
48
56
  return MongoDialect.ID_KEY;
49
57
  }
50
58
  return super.columnOf(meta, key);
@@ -161,7 +169,7 @@ export class MongoDialect extends AbstractDialect {
161
169
  */
162
170
  relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
163
171
  return relOpts.cardinality === 'mm' && relOpts.through
164
- ? this.junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts)
172
+ ? this.junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts)
165
173
  : {
166
174
  $lookup: {
167
175
  from: this.resolveTableName(relMeta),
@@ -175,22 +183,29 @@ export class MongoDialect extends AbstractDialect {
175
183
  * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
176
184
  * lookup - the junction's filters apply too, since a soft-deleted link is not a link.
177
185
  */
178
- junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
186
+ junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts) {
179
187
  const throughEntity = relOpts.through();
180
188
  const throughMeta = getMeta(throughEntity);
181
189
  const junctionScope = this.renderFilter(throughEntity, this.scopedWhereMap(throughMeta, {}), opts);
182
190
  const nested = REL_NESTED_KEY;
191
+ // Both ends are one column here - each `$lookup` matches one field against `_id` - so both sides
192
+ // must be sole-keyed. Sliced rather than indexed positionally: `references[1]` is the parent's
193
+ // *second* column on a composite, a real column of the wrong side.
194
+ assertSoleId(meta, 'MongoDB');
195
+ assertSoleId(relMeta, 'MongoDB');
196
+ const [parentJoin] = parentJoins(relOpts, meta.ids.length);
197
+ const [targetColumn] = targetKeyColumns(relOpts, meta.ids.length);
183
198
  return {
184
199
  $lookup: {
185
200
  from: this.resolveTableName(throughMeta),
186
201
  localField: MongoDialect.ID_KEY,
187
- foreignField: this.columnOf(throughMeta, relOpts.references[0].local),
202
+ foreignField: this.columnOf(throughMeta, parentJoin.joined),
188
203
  pipeline: [
189
204
  ...(hasKeys(junctionScope) ? [{ $match: junctionScope }] : []),
190
205
  {
191
206
  $lookup: {
192
207
  from: this.resolveTableName(relMeta),
193
- localField: this.columnOf(throughMeta, relOpts.references[1].local),
208
+ localField: this.columnOf(throughMeta, targetColumn),
194
209
  foreignField: MongoDialect.ID_KEY,
195
210
  pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), { $limit: 1 }],
196
211
  as: nested,
@@ -250,7 +265,7 @@ export class MongoDialect extends AbstractDialect {
250
265
  */
251
266
  assertKnownPathRoot(meta, key) {
252
267
  const root = key.includes('.') ? key.slice(0, key.indexOf('.')) : key;
253
- if (root === MongoDialect.ID_KEY || root === meta.id || meta.fields[root]) {
268
+ if (root === MongoDialect.ID_KEY || meta.fields[root]) {
254
269
  return;
255
270
  }
256
271
  throw new TypeError(`path ${key} does not exist in ${entityName(meta)}`);
@@ -372,7 +387,7 @@ export class MongoDialect extends AbstractDialect {
372
387
  // MongoDB returns `_id` unless it is explicitly excluded, so subtracting the primary key needs
373
388
  // `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
374
389
  // have no effect at all.
375
- if (this.subtractsKey(meta.id, selectMap, exclude)) {
390
+ if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), selectMap, exclude)) {
376
391
  projection[MongoDialect.ID_KEY] = 0;
377
392
  }
378
393
  return projection;
@@ -652,9 +667,15 @@ export class MongoDialect extends AbstractDialect {
652
667
  * direction joins the parent's `_id` to the target's FK.
653
668
  */
654
669
  joinKeys(meta, relMeta, relOpts) {
655
- return relOpts.cardinality === 'm1'
656
- ? { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: MongoDialect.ID_KEY }
657
- : { localField: MongoDialect.ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
670
+ if (relOpts.cardinality === 'm1') {
671
+ // The target's side, not this entity's: a lookup matches one `localField` against one
672
+ // `foreignField`, so a composite target would join on its first column alone and gather the
673
+ // rows of every key that agrees on it. The other branch is refused by `columnOf` below, whose
674
+ // key *is* an id column; this one names a plain foreign key, so it has to say so itself.
675
+ assertSoleId(relMeta, 'MongoDB');
676
+ return { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: MongoDialect.ID_KEY };
677
+ }
678
+ return { localField: MongoDialect.ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
658
679
  }
659
680
  /** `[column, key]` for the fields whose stored name differs from their property name, memoized per entity. */
660
681
  renamedColumns(meta) {
@@ -679,8 +700,9 @@ export class MongoDialect extends AbstractDialect {
679
700
  const res = doc;
680
701
  const _id = MongoDialect.ID_KEY;
681
702
  if (res[_id]) {
682
- res[meta.id] = res[_id];
683
- if (meta.id !== _id) {
703
+ const idKey = soleIdOf(meta, 'MongoDB');
704
+ res[idKey] = res[_id];
705
+ if (idKey !== _id) {
684
706
  delete res[_id];
685
707
  }
686
708
  }
@@ -797,6 +819,9 @@ export class MongoDialect extends AbstractDialect {
797
819
  return [{ $set: assignments }, ...(unset.size > 0 ? [{ $unset: [...unset] }] : [])];
798
820
  }
799
821
  getPersistables(meta, payload, callbackKey) {
822
+ // Not `columnOf`, which maps the primary key to `_id`: an update may not touch `_id` at all, and
823
+ // an insert leaves it to the driver. What that mapping refuses, a write has to refuse too.
824
+ assertSoleId(meta, 'MongoDB');
800
825
  const payloads = fillOnFields(meta, payload, callbackKey);
801
826
  // Keys are resolved per document so heterogeneous payloads keep every provided field.
802
827
  return payloads.map((it) => filterFieldKeys(meta, it, callbackKey).reduce((acc, key) => {
@@ -1,6 +1,6 @@
1
1
  import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
- import { getMeta } from '../entity/index.js';
3
+ import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
5
  import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
@@ -202,7 +202,9 @@ export class MongodbQuerier extends AbstractQuerier {
202
202
  const meta = getMeta(entity);
203
203
  const pipeline = this.dialect.aggregationPipeline(entity, idOnlyQuery(meta, q), opts);
204
204
  const founds = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
205
- return (this.dialect.normalizeIds(meta, founds) || []).map((found) => found[meta.id]);
205
+ // `normalizeIds` has already spread a compound `_id` back into its columns, so the settled rows
206
+ // are named the same way every other driver names them.
207
+ return (this.dialect.normalizeIds(meta, founds) || []).map((found) => idOf(meta, found));
206
208
  }
207
209
  async internalInsertMany(entity, payloads) {
208
210
  return this.timed('internalInsertMany', undefined, async () => {
@@ -214,8 +216,9 @@ export class MongodbQuerier extends AbstractQuerier {
214
216
  const persistables = this.dialect.getPersistables(meta, payloads, 'onInsert');
215
217
  const { insertedIds } = await this.execute((session) => this.collection(entity).insertMany(persistables, { session }));
216
218
  const ids = Object.values(insertedIds);
219
+ const idKey = soleIdOf(meta, 'insert');
217
220
  for (const [index, it] of payloads.entries()) {
218
- it[meta.id] = ids[index];
221
+ it[idKey] = ids[index];
219
222
  }
220
223
  await this.insertRelations(entity, payloads);
221
224
  return ids;
@@ -15,7 +15,7 @@ export class PostgresDialect extends PgLikeSqlDialect {
15
15
  hasNarrowVectorTypes = true;
16
16
  upsert(ctx, entity, conflictPaths, payload) {
17
17
  // The xmax system column is 0 for a newly inserted row and non-zero for an updated one (MVCC).
18
- super.upsert(ctx, entity, conflictPaths, payload, `, (xmax = 0) AS ${this.escapeId('_created')}`);
18
+ super.upsert(ctx, entity, conflictPaths, payload, `(xmax = 0) AS ${this.escapeId('_created')}`);
19
19
  }
20
20
  /**
21
21
  * `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
@@ -1,5 +1,5 @@
1
- import type { EntityData, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
- import { LoggerWrapper } from '../util/index.js';
1
+ import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import { LoggerWrapper, type ParentJoin } from '../util/index.js';
3
3
  /**
4
4
  * Base class for all database queriers.
5
5
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -30,7 +30,7 @@ export declare abstract class AbstractQuerier implements Querier {
30
30
  protected resolveEntityQuery<E extends object, Q extends object>(entityOrQuery: Type<E> | (Q & {
31
31
  $entity: Type<E>;
32
32
  }), maybeQueryOrOpts?: Q | QueryOptions, maybeOpts?: QueryOptions): [Type<E>, Q, QueryOptions | undefined];
33
- findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: IdValue<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
33
+ findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: EntityId<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
34
34
  /**
35
35
  * Find a single record matching the query.
36
36
  * Supports both entity-as-argument and entity-as-field patterns.
@@ -104,16 +104,16 @@ export declare abstract class AbstractQuerier implements Querier {
104
104
  /** Abstract outright: nothing is shared to do around it. See {@link UniversalQuerier.estimatedCount}. */
105
105
  abstract estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
106
106
  insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
107
- insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
108
- protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
109
- updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
107
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
108
+ protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
109
+ updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
110
110
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
111
111
  protected abstract internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
112
- restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
112
+ restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
113
113
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
114
114
  abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
115
115
  abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
116
- deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
116
+ deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
117
117
  /**
118
118
  * Delete records matching the query. Soft-deletes when the entity has a soft-delete field (unless
119
119
  * `opts.hardDelete`), otherwise removes the rows. Supports both entity-as-argument and entity-as-field patterns.
@@ -132,15 +132,19 @@ export declare abstract class AbstractQuerier implements Querier {
132
132
  */
133
133
  private findDoomed;
134
134
  protected abstract internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
135
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
136
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
135
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
136
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
137
137
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
138
138
  private fillToManyThroughRelation;
139
139
  private fillToManyOneToMany;
140
- protected putChildrenInParents<E>(parents: E[], children: RawRow[], parentIdKey: keyof E & string, referenceKey: string, relKey: keyof E & string): void;
140
+ protected putChildrenInParents<E>(parents: E[], children: RawRow[], joins: readonly ParentJoin[], relKey: keyof E & string): void;
141
141
  protected insertRelations<E extends object>(entity: Type<E>, payload: E[]): Promise<void>;
142
142
  protected updateRelations<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<void>;
143
- protected deleteRelations<E extends object>(entity: Type<E>, ids: IdValue<E>[], opts?: QueryOptions): Promise<void>;
143
+ /**
144
+ * `EntityId` because a settled set names composite rows as objects, which is also what the parent's
145
+ * own delete takes - and what {@link childrenOf} reads each child's foreign key columns out of.
146
+ */
147
+ protected deleteRelations<E extends object>(entity: Type<E>, ids: EntityId<E>[], opts?: QueryOptions): Promise<void>;
144
148
  /**
145
149
  * Persists `relValue` against every id in `ids`, which an update hands the whole page of rows it
146
150
  * settled: the value is the same for all of them, so the statements are per relation rather than per
@@ -1,5 +1,5 @@
1
- import { getMeta } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, LoggerWrapper, parentKeyColumn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumn, withoutSoftDeleteFilter, } from '../util/index.js';
1
+ import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, parentJoins, parentRowKey, parentsIn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  import { fillRelationCounts, withIdForCounts } from './relationCount.js';
5
5
  /**
@@ -15,6 +15,29 @@ function assertIdValue(entity, id) {
15
15
  if (id === undefined || id === null) {
16
16
  throw new TypeError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
17
17
  }
18
+ if (isScalarId(id)) {
19
+ // One value names one column, which `buildQueryWhereAsMap` refuses on a composite.
20
+ return;
21
+ }
22
+ // Every key, or the `$where` names only some of the columns and addresses each row that agrees on
23
+ // those - which for an update or a delete is silently far more than the one row asked for. `{}`
24
+ // reaches here too, and would otherwise be an unfiltered statement over the whole table.
25
+ const given = id;
26
+ const { ids } = getMeta(entity);
27
+ const missing = ids.filter((key) => given[key] == null);
28
+ if (missing.length) {
29
+ throw new TypeError(`'${entity.name}' is addressed by an object carrying every key of its primary key (${ids.join(', ')}); missing ${missing.join(', ')}.`);
30
+ }
31
+ }
32
+ /**
33
+ * The one column a write path matches a parent's key against.
34
+ *
35
+ * Every caller reaches its parents through a sole id - `soleIdOf` refused a composite before any of
36
+ * them - so the parent contributes exactly one column here, whether the relation goes through a
37
+ * junction or straight to the child.
38
+ */
39
+ function soleParentColumn(relOpts) {
40
+ return parentJoins(relOpts, 1)[0].joined;
18
41
  }
19
42
  /**
20
43
  * Base class for all database queriers.
@@ -187,7 +210,11 @@ export class AbstractQuerier {
187
210
  // Where a snapshot was taken, the statement names those rows rather than resolving `q` a second
188
211
  // time. Two reads of one `$limit` with no total order are free to disagree, which would fire the
189
212
  // hooks for one row and delete another; naming them also spares the second read.
190
- const target = doomed ? { $where: doomed.map((it) => it[getMeta(entity).id]) } : q;
213
+ let target = q;
214
+ if (doomed) {
215
+ const meta = getMeta(entity);
216
+ target = { $where: doomed.map((it) => idOf(meta, it)) };
217
+ }
191
218
  await this.emitHook(entity, 'beforeDelete', doomed ?? []);
192
219
  const changes = await this.internalDeleteMany(entity, target, opts);
193
220
  await this.emitHook(entity, 'afterDelete', doomed ?? []);
@@ -218,12 +245,16 @@ export class AbstractQuerier {
218
245
  const toInsert = [];
219
246
  const toUpdate = [];
220
247
  const existingIds = [];
248
+ // Save reads an id as proof the row exists, and inserts the rest. A composite key is supplied by
249
+ // the caller on every row, insert included, so that proof does not exist for one: telling the two
250
+ // apart takes a read, which is `upsertMany`'s job and not this one's.
251
+ const idKey = soleIdOf(meta, 'saving a row');
221
252
  for (const it of payload) {
222
- const id = it[meta.id];
253
+ const id = it[idKey];
223
254
  if (!id) {
224
255
  toInsert.push(it);
225
256
  }
226
- else if (!someKey(it, (key) => key !== meta.id)) {
257
+ else if (!someKey(it, (key) => key !== idKey)) {
227
258
  existingIds.push(id);
228
259
  }
229
260
  else {
@@ -233,9 +264,9 @@ export class AbstractQuerier {
233
264
  const [insertedIds, updatedIds] = await Promise.all([
234
265
  toInsert.length ? this.insertMany(entity, toInsert) : [],
235
266
  Promise.all(toUpdate.map(async (it) => {
236
- const id = it[meta.id];
267
+ const id = it[idKey];
237
268
  const data = { ...it };
238
- delete data[meta.id];
269
+ delete data[idKey];
239
270
  await this.updateOneById(entity, id, data);
240
271
  return id;
241
272
  })),
@@ -263,11 +294,11 @@ export class AbstractQuerier {
263
294
  }
264
295
  }
265
296
  async fillToManyThroughRelation(payload, meta, relKey, relOpts, relationQuery) {
266
- const localField = parentKeyColumn(relOpts);
297
+ const joins = parentJoins(relOpts, meta.ids.length);
298
+ const [targetColumn] = targetKeyColumns(relOpts, meta.ids.length);
267
299
  const throughEntity = relOpts.through();
268
300
  const throughMeta = getMeta(throughEntity);
269
- const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetKeyColumn(relOpts)));
270
- const ids = payload.map((it) => it[meta.id]);
301
+ const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetColumn));
271
302
  // A relation query names the target's columns, not the join table's, so the projection and the
272
303
  // filter belong on the populate below - resolved there against the entity that has them. Spread
273
304
  // onto the through query they asked `ItemTag` for `Tag`'s columns: `$where`/`$sort` failed with
@@ -275,53 +306,52 @@ export class AbstractQuerier {
275
306
  const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
276
307
  const throughFounds = await this.findMany(throughEntity, {
277
308
  ...throughQuery,
278
- $select: {
279
- [localField]: true,
280
- },
309
+ $select: joinedColumns(joins),
281
310
  $populate: {
282
311
  [targetRelKey]: {
283
312
  ...relationQuery,
284
313
  $required: true,
285
314
  },
286
315
  },
287
- $where: {
288
- [localField]: ids,
289
- },
316
+ $where: parentsIn(joins, payload),
290
317
  });
318
+ // The junction's own columns carried onto the target's row, which is where `putChildrenInParents`
319
+ // reads them back from - a junction row holds the parent's key under `joined`, not under `parent`.
291
320
  const founds = throughFounds.map((it) => ({
292
321
  ...it[targetRelKey],
293
- [localField]: it[localField],
322
+ ...Object.fromEntries(joins.map(({ joined }) => [joined, it[joined]])),
294
323
  }));
295
- this.putChildrenInParents(payload, founds, meta.id, localField, relKey);
324
+ this.putChildrenInParents(payload, founds, joins, relKey);
296
325
  }
297
326
  async fillToManyOneToMany(payload, meta, relKey, relOpts, relationQuery, relEntity) {
298
- const foreignField = parentKeyColumn(relOpts);
327
+ const joins = parentJoins(relOpts, meta.ids.length);
299
328
  // The FK is what putChildrenInParents groups on, so it outlives the relation's projection
300
329
  // either way: added to a whitelisting `$select` (the raw-array form has nothing to augment),
301
330
  // dropped from a subtractive `$exclude`. `relationQuery` is already a clone.
302
331
  const select = asSelectMap(relationQuery.$select);
303
- if (select && !select[foreignField]) {
304
- select[foreignField] = true;
332
+ const exclude = relationQuery.$exclude;
333
+ for (const { joined } of joins) {
334
+ if (select && !select[joined]) {
335
+ select[joined] = true;
336
+ }
337
+ delete exclude?.[joined];
305
338
  }
306
- delete relationQuery.$exclude?.[foreignField];
307
- const ids = payload.map((it) => it[meta.id]);
308
- relationQuery.$where = { ...relationQuery.$where, [foreignField]: ids };
339
+ relationQuery.$where = { ...relationQuery.$where, ...parentsIn(joins, payload) };
309
340
  const founds = await this.findMany(relEntity, relationQuery);
310
- this.putChildrenInParents(payload, founds, meta.id, foreignField, relKey);
341
+ this.putChildrenInParents(payload, founds, joins, relKey);
311
342
  }
312
- putChildrenInParents(parents, children, parentIdKey, referenceKey, relKey) {
343
+ putChildrenInParents(parents, children, joins, relKey) {
313
344
  const childrenByParentId = {};
314
345
  for (const child of children) {
315
- const parentId = String(child[referenceKey]);
316
- if (!childrenByParentId[parentId])
317
- childrenByParentId[parentId] = [];
318
- childrenByParentId[parentId].push(child);
346
+ // Every joined column, so two children agreeing on one column of a composite key are not
347
+ // gathered under the same parent.
348
+ (childrenByParentId[joinedRowKey(joins, child)] ??= []).push(child);
319
349
  }
320
350
  for (const parent of parents) {
321
351
  // `[]` rather than nothing for a parent with no children: a populated to-many is a list the
322
352
  // caller asked for, so it maps and counts without a guard, and its type can say so. An
323
353
  // unpopulated one stays absent, which is what tells the two apart.
324
- parent[relKey] = (childrenByParentId[String(parent[parentIdKey])] ?? []);
354
+ parent[relKey] = (childrenByParentId[parentRowKey(joins, parent)] ?? []);
325
355
  }
326
356
  }
327
357
  async insertRelations(entity, payload) {
@@ -334,7 +364,8 @@ export class AbstractQuerier {
334
364
  }, []);
335
365
  if (!entries.length)
336
366
  return;
337
- await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, [it[meta.id]], it[relKey], relKey)))));
367
+ const idKey = soleIdOf(meta, 'saving a relation');
368
+ await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, [it[idKey]], it[relKey], relKey)))));
338
369
  }
339
370
  async updateRelations(entity, q, payload, opts) {
340
371
  const meta = getMeta(entity);
@@ -342,8 +373,9 @@ export class AbstractQuerier {
342
373
  if (!relKeys.length) {
343
374
  return;
344
375
  }
376
+ const idKey = soleIdOf(meta, 'saving a relation');
345
377
  const founds = await this.findMany(entity, idOnlyQuery(meta, q), opts);
346
- const ids = founds.map((found) => found[meta.id]);
378
+ const ids = founds.map((found) => found[idKey]);
347
379
  if (!ids.length) {
348
380
  return;
349
381
  }
@@ -351,6 +383,10 @@ export class AbstractQuerier {
351
383
  await this.saveRelation(entity, ids, payload[relKey], relKey, true);
352
384
  }
353
385
  }
386
+ /**
387
+ * `EntityId` because a settled set names composite rows as objects, which is also what the parent's
388
+ * own delete takes - and what {@link childrenOf} reads each child's foreign key columns out of.
389
+ */
354
390
  async deleteRelations(entity, ids, opts) {
355
391
  const meta = getMeta(entity);
356
392
  const relKeys = filterPersistableRelationKeys(meta, meta.relations, 'delete');
@@ -360,9 +396,9 @@ export class AbstractQuerier {
360
396
  if (!relOpts)
361
397
  continue;
362
398
  const relEntity = relOpts.entity();
363
- const parentKey = parentKeyColumn(relOpts);
364
399
  const target = relOpts.through ? relOpts.through() : relEntity;
365
- await this.deleteMany(target, { $where: { [parentKey]: ids } }, opts);
400
+ const where = childrenOf(parentJoins(relOpts, meta.ids.length), ids);
401
+ await this.deleteMany(target, { $where: where }, opts);
366
402
  }
367
403
  }
368
404
  /**
@@ -375,6 +411,11 @@ export class AbstractQuerier {
375
411
  const relOpts = meta.relations[relKey];
376
412
  if (!relOpts)
377
413
  return;
414
+ // Here rather than only in the callers below: writing the parent's key into a child is one column
415
+ // per key, so a composite takes a statement per parent. `soleParentColumn` and the sole
416
+ // `targetKeyColumns` under it read the *first* pair, which is a real column of a wrong pairing
417
+ // unless this has run - and this method is `protected`, so a caller can arrive without them.
418
+ assertSoleId(meta, 'saving a relation');
378
419
  const relEntity = relOpts.entity();
379
420
  const relPayload = relValue;
380
421
  switch (relOpts.cardinality) {
@@ -391,7 +432,8 @@ export class AbstractQuerier {
391
432
  async saveToMany(relOpts, relEntity, ids, relPayload, isUpdate) {
392
433
  const { through } = relOpts;
393
434
  if (through) {
394
- const localField = parentKeyColumn(relOpts);
435
+ const localField = soleParentColumn(relOpts);
436
+ const [targetColumn] = targetKeyColumns(relOpts, 1);
395
437
  const throughEntity = through();
396
438
  if (isUpdate) {
397
439
  await this.deleteMany(throughEntity, { $where: { [localField]: ids } });
@@ -401,12 +443,18 @@ export class AbstractQuerier {
401
443
  // would link every parent to a single shared row instead.
402
444
  for (const id of ids) {
403
445
  const savedIds = await this.saveMany(relEntity, relPayload);
404
- await this.insertMany(throughEntity, savedIds.map((relId) => ({ [localField]: id, [targetKeyColumn(relOpts)]: relId })));
446
+ // A link needs the target's id, and a driver that cannot report one (a MySQL batch mixing
447
+ // supplied and generated keys) would otherwise write a row pointing at `undefined`.
448
+ if (savedIds.some((relId) => relId === undefined)) {
449
+ throw new TypeError(`'${relEntity.name}' rows saved through '${throughEntity.name}' reported no id, so they cannot be linked. ` +
450
+ 'Insert them with their own ids, or save the relation in its own statement.');
451
+ }
452
+ await this.insertMany(throughEntity, savedIds.map((relId) => ({ [localField]: id, [targetColumn]: relId })));
405
453
  }
406
454
  }
407
455
  return;
408
456
  }
409
- const foreignField = parentKeyColumn(relOpts);
457
+ const foreignField = soleParentColumn(relOpts);
410
458
  if (isUpdate) {
411
459
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
412
460
  }
@@ -415,7 +463,7 @@ export class AbstractQuerier {
415
463
  }
416
464
  }
417
465
  async saveOneToOne(relEntity, relOpts, ids, relPayload) {
418
- const foreignField = parentKeyColumn(relOpts);
466
+ const foreignField = soleParentColumn(relOpts);
419
467
  if (relPayload === null) {
420
468
  await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
421
469
  return;
@@ -423,7 +471,7 @@ export class AbstractQuerier {
423
471
  await this.saveMany(relEntity, ids.map((id) => ({ ...relPayload, [foreignField]: id })));
424
472
  }
425
473
  async saveManyToOne(entity, relEntity, relOpts, ids, relPayload) {
426
- // Not `parentKeyColumn`: a many-to-one points the other way, so this is the *parent's* own column
474
+ // Not `soleParentColumn`: a many-to-one points the other way, so this is the *parent's* own column
427
475
  // holding the child's id - the one place `references[0].local` does not name a key of the parent.
428
476
  const localField = relOpts.references[0].local;
429
477
  // Per parent: each gets its own reference row, so each `SET` carries a different value.
@@ -1,5 +1,5 @@
1
1
  import type { AbstractDialect } from '../dialect/index.js';
2
- import type { EntityData, ExtraOptions, FieldKey, IdValue, PoolRunOptions, Querier, QuerierPool, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, PoolRunOptions, Querier, QuerierPool, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
3
  /**
4
4
  * Base pool: dialect id and behavior come only from the `dialect` instance (see {@link QuerierPool}).
5
5
  */
@@ -24,7 +24,7 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
24
24
  withQuerier<T>(callback: (querier: Q) => Promise<T>, opts?: PoolRunOptions): Promise<T>;
25
25
  /** Run `fn` under `context` (an enclosing {@link withContext}) when provided, else run it as-is. */
26
26
  private runScoped;
27
- findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: IdValue<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
27
+ findOneById<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, id: EntityId<E>, q?: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
28
28
  findOne<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, q: QueryOneProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C> | undefined>;
29
29
  findMany<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never, const C extends RelationKey<E> = never>(entity: Type<E>, q: QueryProjected<E, S, V, X, P, C>, opts?: QueryOptions): Promise<QueryFindResult<E, S, V, X, P, C>[]>;
30
30
  /**
@@ -38,16 +38,16 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
38
38
  aggregate<E extends object, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
39
39
  estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
40
40
  insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
41
- insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
42
- updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
41
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
42
+ updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
43
43
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
44
44
  upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
45
45
  upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
46
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
47
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
48
- deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
46
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
47
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
48
+ deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
49
49
  deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
50
- restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
50
+ restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
51
51
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
52
52
  /**
53
53
  * end the pool.
@@ -105,7 +105,7 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
105
105
  protected internalCount<E extends object>(entity: Type<E>, q?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
106
106
  estimatedCount<E extends object>(entity: Type<E>): Promise<number>;
107
107
  protected internalAggregate<E extends object, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
108
- internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
108
+ internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
109
109
  internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
110
110
  /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
111
111
  private settleIds;
@@ -1,6 +1,6 @@
1
1
  import { COUNT_ALIAS, TOTAL_ALIAS } from '../dialect/aliases.js';
2
2
  import { decodeColumn } from '../dialect/hydrateColumn.js';
3
- import { getMeta } from '../entity/index.js';
3
+ import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, hasKeys, idOnlyQuery, isAutoIncrement, isPagedQuery, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { AbstractQuerier } from './abstractQuerier.js';
6
6
  import { enrichError } from './queryError.js';
@@ -283,14 +283,19 @@ export class AbstractSqlQuerier extends AbstractQuerier {
283
283
  }
284
284
  payload = clone(payload);
285
285
  const meta = getMeta(entity);
286
- const idKey = meta.id;
287
- const idField = meta.fields[idKey];
286
+ // A composite key is supplied whole by the caller, so nothing is generated and nothing comes
287
+ // back: the rows insert, and their ids are `undefined` for the reason MySQL's are below - there
288
+ // is no id to report. `idOf` names such a row for the caller that wants one.
289
+ const [idKey] = meta.ids;
290
+ const sole = meta.ids.length === 1;
291
+ const idField = sole ? meta.fields[idKey] : undefined;
288
292
  // RETURNING-based IDs are exact per row. Header-derived IDs (LAST_INSERT_ID /
289
293
  // lastInsertRowid arithmetic) are only sound when the primary key is database-generated
290
294
  // and no record supplies an explicit ID (a mixed batch shifts the positional mapping and
291
295
  // MySQL stops guaranteeing consecutive values); otherwise generated IDs stay `undefined`.
292
- const idsReliable = this.dialect.insertIdSource === 'returning' ||
293
- (!!idField && isAutoIncrement(idField, true) && payload.every((it) => it[idKey] === undefined));
296
+ const idsReliable = sole &&
297
+ (this.dialect.insertIdSource === 'returning' ||
298
+ (!!idField && isAutoIncrement(idField, true) && payload.every((it) => it[idKey] === undefined)));
294
299
  // Inferring multiple ids from the single header id (MySQL) assumes a known stride; a clustered
295
300
  // server may set `auto_increment_increment` > 1, so probe it (once, cached) before inferring.
296
301
  if (idsReliable && payload.length > 1 && this.dialect.insertIdSource === 'firstId') {
@@ -309,7 +314,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
309
314
  if (idsReliable) {
310
315
  it[idKey] ??= ids[index];
311
316
  }
312
- payloadIds.push(it[idKey]);
317
+ payloadIds.push(sole ? it[idKey] : undefined);
313
318
  });
314
319
  }
315
320
  await this.insertRelations(entity, payload);
@@ -339,7 +344,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
339
344
  const ctx = this.dialect.createContext();
340
345
  this.dialect.find(ctx, entity, idOnlyQuery(meta, q), opts);
341
346
  const founds = await this.all(ctx.sql, ctx.values);
342
- return founds.map((it) => it[meta.id]);
347
+ return founds.map((found) => idOf(meta, found));
343
348
  }
344
349
  async upsertOne(entity, conflictPaths, payload) {
345
350
  return this.upsertMany(entity, conflictPaths, [payload]);