uql-orm 0.29.0 → 0.30.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.
@@ -1,6 +1,20 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, } from '../util/index.js';
2
+ import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
+ /**
5
+ * Rejects a nullish primary key before it reaches a statement. The by-id methods reduce to
6
+ * `{ $where: id }`, and a nullish `$where` is *no filter*, so an unchecked one addresses the whole
7
+ * table. An entity declares its id optional, which puts `undefined` inside `IdValue<E>`, and the
8
+ * HTTP layer reaches these methods with parsed JSON regardless, so the guard belongs at runtime.
9
+ *
10
+ * Its callers are all `async` so this surfaces as a rejection on every one of them: a guard that
11
+ * threw synchronously from some and rejected from others would escape a caller's `.catch()`.
12
+ */
13
+ function assertIdValue(entity, id) {
14
+ if (id === undefined || id === null) {
15
+ throw new TypeError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
16
+ }
17
+ }
4
18
  /**
5
19
  * Base class for all database queriers.
6
20
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -65,6 +79,7 @@ export class AbstractQuerier {
65
79
  return [$entity, query, maybeQueryOrOpts];
66
80
  }
67
81
  async findOneById(entity, id, q = {}, opts) {
82
+ assertIdValue(entity, id);
68
83
  return this.findOne(entity, { ...q, $where: augmentWhere(getMeta(entity), q.$where, id) }, opts);
69
84
  }
70
85
  async findOne(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
@@ -115,7 +130,8 @@ export class AbstractQuerier {
115
130
  await this.emitHook(entity, 'afterInsert', payload);
116
131
  return ids;
117
132
  }
118
- updateOneById(entity, id, payload, opts) {
133
+ async updateOneById(entity, id, payload, opts) {
134
+ assertIdValue(entity, id);
119
135
  return this.updateMany(entity, { $where: id }, payload, opts);
120
136
  }
121
137
  async updateMany(entity, q, payload, opts) {
@@ -124,7 +140,8 @@ export class AbstractQuerier {
124
140
  await this.emitHook(entity, 'afterUpdate', [payload]);
125
141
  return changes;
126
142
  }
127
- restoreOneById(entity, id) {
143
+ async restoreOneById(entity, id) {
144
+ assertIdValue(entity, id);
128
145
  return this.restoreMany(entity, { $where: id });
129
146
  }
130
147
  async restoreMany(entity, q) {
@@ -137,16 +154,41 @@ export class AbstractQuerier {
137
154
  filters: { softDelete: false },
138
155
  });
139
156
  }
140
- deleteOneById(entity, id, opts) {
157
+ async deleteOneById(entity, id, opts) {
158
+ assertIdValue(entity, id);
141
159
  return this.deleteMany(entity, { $where: id }, opts);
142
160
  }
143
161
  async deleteMany(entityOrQuery, qOrOpts, maybeOpts) {
144
162
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, qOrOpts, maybeOpts);
145
- await this.emitHook(entity, 'beforeDelete', []);
146
- const changes = await this.internalDeleteMany(entity, q, opts);
147
- await this.emitHook(entity, 'afterDelete', []);
163
+ const doomed = await this.findDoomed(entity, q, opts);
164
+ if (doomed?.length === 0) {
165
+ return 0;
166
+ }
167
+ // Where a snapshot was taken, the statement names those rows rather than resolving `q` a second
168
+ // time. Two reads of one `$limit` with no total order are free to disagree, which would fire the
169
+ // hooks for one row and delete another; naming them also spares the second read.
170
+ const target = doomed ? { $where: doomed.map((it) => it[getMeta(entity).id]) } : q;
171
+ await this.emitHook(entity, 'beforeDelete', doomed ?? []);
172
+ const changes = await this.internalDeleteMany(entity, target, opts);
173
+ await this.emitHook(entity, 'afterDelete', doomed ?? []);
148
174
  return changes;
149
175
  }
176
+ /**
177
+ * The rows a delete is about to take, loaded only when a hook or listener is there to receive
178
+ * them: the round trip is pure overhead for the (common) delete nobody is watching, and
179
+ * `internalDeleteMany` has its own fast path that never reads the rows at all.
180
+ *
181
+ * `undefined` means nobody was watching, which is not the same as the empty array meaning nothing
182
+ * matched - the caller deletes by `q` for the first and skips the statement entirely for the second.
183
+ */
184
+ async findDoomed(entity, q, opts) {
185
+ if (!this.hasHook(entity, 'beforeDelete') && !this.hasHook(entity, 'afterDelete')) {
186
+ return undefined;
187
+ }
188
+ // A hard delete takes already-soft-deleted rows too, so reading them back has to see them.
189
+ const findOpts = opts?.hardDelete ? { ...opts, filters: withoutSoftDeleteFilter(opts.filters) } : opts;
190
+ return this.internalFindMany(entity, q, findOpts);
191
+ }
150
192
  async saveOne(entity, payload) {
151
193
  const [id] = await this.saveMany(entity, [payload]);
152
194
  return id;
@@ -400,32 +442,26 @@ export class AbstractQuerier {
400
442
  throw err;
401
443
  }
402
444
  }
445
+ /** Whether anything at all - a global listener or the entity itself - handles `event`. */
446
+ hasHook(entity, event) {
447
+ return (this.extra?.listeners?.some((listener) => listener[event]) || (getMeta(entity).hooks?.[event]?.length ?? 0) > 0);
448
+ }
403
449
  /**
404
450
  * Emit a lifecycle hook event for the given entity.
405
451
  * Fires global listeners first, then entity-level hooks.
406
452
  */
407
453
  async emitHook(entity, event, payloads) {
408
- const listeners = this.extra?.listeners;
409
- const meta = getMeta(entity);
410
- const registrations = meta.hooks?.[event];
411
- // Fast bail-out: skip if no listeners and no entity hooks
412
- if (!listeners?.length && !registrations?.length)
454
+ if (!this.hasHook(entity, event))
413
455
  return;
414
- // Fire global listeners first
415
- if (listeners?.length) {
416
- for (const listener of listeners) {
417
- const fn = listener[event];
418
- if (fn) {
419
- const result = fn({ entity, querier: this, payloads, event });
420
- if (result instanceof Promise)
421
- await result;
422
- }
456
+ for (const listener of this.extra?.listeners ?? []) {
457
+ const fn = listener[event];
458
+ if (fn) {
459
+ const result = fn({ entity, querier: this, payloads, event });
460
+ if (result instanceof Promise)
461
+ await result;
423
462
  }
424
463
  }
425
- // Fire entity-level hooks
426
- if (registrations?.length) {
427
- await runHooks(entity, event, payloads, { querier: this });
428
- }
464
+ await runHooks(entity, event, payloads, { querier: this });
429
465
  }
430
466
  /**
431
467
  * Runs `task` after everything already queued on this querier, one at a time.
@@ -1,5 +1,5 @@
1
1
  import type { AbstractDialect } from '../dialect/index.js';
2
- import type { ExtraOptions, IdValue, PoolRunOptions, Querier, QuerierPool, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, ExtraOptions, IdValue, PoolRunOptions, Querier, QuerierPool, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult, 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
  */
@@ -35,14 +35,14 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
35
35
  findManyAndCount<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
36
36
  count<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
37
37
  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>[]>;
38
- insertOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E> | undefined>;
39
- insertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
38
+ insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
39
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
40
40
  updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
41
41
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
42
- upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E): Promise<QueryUpdateResult>;
43
- upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E[]): Promise<QueryUpdateResult>;
44
- saveOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E>>;
45
- saveMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
42
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
43
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
44
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
45
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
46
46
  deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
47
47
  deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
48
48
  restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  import type { AbstractSqlDialect } from '../dialect/index.js';
2
- import type { ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
3
  import type { BuildUpdateResultPayload } from '../util/sql.util.js';
4
4
  import { AbstractQuerier } from './abstractQuerier.js';
5
5
  export declare abstract class AbstractSqlQuerier extends AbstractQuerier implements SqlQuerier {
@@ -64,10 +64,12 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
64
64
  private hydrateFields;
65
65
  protected internalCount<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
66
66
  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>[]>;
67
- internalInsertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
67
+ internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
68
68
  internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
69
- upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E): Promise<QueryUpdateResult>;
70
- upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E[]): Promise<QueryUpdateResult>;
69
+ /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
70
+ private settleIds;
71
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
72
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
71
73
  protected internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
72
74
  get hasOpenTransaction(): boolean;
73
75
  beginTransaction(opts?: TransactionOptions): Promise<void>;
@@ -3,6 +3,14 @@ import { getMeta } from '../entity/index.js';
3
3
  import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, isAutoIncrement, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { AbstractQuerier } from './abstractQuerier.js';
5
5
  import { enrichError } from './queryError.js';
6
+ /**
7
+ * Whether `q` picks a specific slice of the matching rows rather than all of them. A write that
8
+ * does has to settle those rows and name them: `ORDER BY`/`LIMIT` on an UPDATE or DELETE is MySQL's
9
+ * alone.
10
+ */
11
+ function isPaged(q) {
12
+ return q.$sort !== undefined || q.$limit !== undefined || q.$skip !== undefined;
13
+ }
6
14
  export class AbstractSqlQuerier extends AbstractQuerier {
7
15
  dialect;
8
16
  extra;
@@ -219,12 +227,30 @@ export class AbstractSqlQuerier extends AbstractQuerier {
219
227
  }
220
228
  async internalUpdateMany(entity, q, payload, opts) {
221
229
  payload = clone(payload);
230
+ // Settled first for the reason `internalDeleteMany` settles: `ORDER BY`/`LIMIT` on an UPDATE is
231
+ // MySQL's alone, so a paged update has to name the rows it picked.
232
+ let target = q;
233
+ if (isPaged(q)) {
234
+ const ids = await this.settleIds(entity, q, opts);
235
+ if (!ids.length) {
236
+ return 0;
237
+ }
238
+ target = { $where: ids };
239
+ }
222
240
  const ctx = this.dialect.createContext();
223
- this.dialect.update(ctx, entity, q, payload, opts);
241
+ this.dialect.update(ctx, entity, target, payload, opts);
224
242
  const { changes = 0 } = await this.run(ctx.sql, ctx.values);
225
- await this.updateRelations(entity, q, payload, opts);
243
+ await this.updateRelations(entity, target, payload, opts);
226
244
  return changes;
227
245
  }
246
+ /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
247
+ async settleIds(entity, q, opts) {
248
+ const meta = getMeta(entity);
249
+ const ctx = this.dialect.createContext();
250
+ this.dialect.find(ctx, entity, { ...q, $select: { [meta.id]: true } }, opts);
251
+ const founds = await this.all(ctx.sql, ctx.values);
252
+ return founds.map((it) => it[meta.id]);
253
+ }
228
254
  async upsertOne(entity, conflictPaths, payload) {
229
255
  return this.upsertMany(entity, conflictPaths, [payload]);
230
256
  }
@@ -252,8 +278,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
252
278
  // parents' ids to find their children, and no engine but MySQL accepts `ORDER BY`/`LIMIT` on a
253
279
  // DELETE, so a paged delete has to name the rows it settled on. A plain predicate needs neither,
254
280
  // and there the round trip buys nothing: the statement can say what the caller already said.
255
- const hasPagination = q.$sort !== undefined || q.$limit !== undefined || q.$skip !== undefined;
256
- if (!hasPagination && !cascadesOnDelete(meta)) {
281
+ if (!isPaged(q) && !cascadesOnDelete(meta)) {
257
282
  const ctx = this.dialect.createContext();
258
283
  this.dialect.delete(ctx, entity, q, opts);
259
284
  const { changes = 0 } = await this.run(ctx.sql, ctx.values);
@@ -261,13 +286,10 @@ export class AbstractSqlQuerier extends AbstractQuerier {
261
286
  }
262
287
  // A hard delete also targets already-soft-deleted rows, so drop the soft-delete filter when finding ids.
263
288
  const findOpts = opts?.hardDelete ? { ...opts, filters: withoutSoftDeleteFilter(opts.filters) } : opts;
264
- const findCtx = this.dialect.createContext();
265
- this.dialect.find(findCtx, entity, { ...q, $select: { [meta.id]: true } }, findOpts);
266
- const founds = await this.all(findCtx.sql, findCtx.values);
267
- if (!founds.length) {
289
+ const ids = await this.settleIds(entity, q, findOpts);
290
+ if (!ids.length) {
268
291
  return 0;
269
292
  }
270
- const ids = founds.map((it) => it[meta.id]);
271
293
  // Children first: they hold the foreign key, so deleting the parent ahead of them is rejected
272
294
  // outright by any schema that declares the constraint without `ON DELETE CASCADE`.
273
295
  await this.deleteRelations(entity, ids, opts);
@@ -16,16 +16,20 @@ export type Key<E> = keyof E & string;
16
16
  * Includes scalar fields, JSON fields, and scalar arrays (e.g. vector `number[]`).
17
17
  * The `-?` modifier strips optionality so the indexed access yields clean key unions
18
18
  * (without it, optional properties leak `undefined` into the union).
19
+ *
20
+ * The check is bracketed so `any` resolves once rather than matching both this and
21
+ * {@link RelationKey}: an unbracketed `any extends X` satisfies either branch.
19
22
  */
20
23
  export type FieldKey<E> = {
21
- readonly [K in keyof E]-?: NonNullable<E[K]> extends Scalar | Scalar[] | Json ? K : never;
24
+ readonly [K in keyof E]-?: [NonNullable<E[K]>] extends [Scalar | Scalar[] | Json] ? K : never;
22
25
  }[Key<E>];
23
26
  /**
24
- * Infers the relation names of an entity
27
+ * Infers the relation names of an entity: whatever is left once its fields and its methods are
28
+ * taken out. Stated as the complement rather than as {@link FieldKey}'s test negated, so the two
29
+ * cannot drift; methods are subtracted because one is not a `Scalar` and would otherwise read as a
30
+ * relation.
25
31
  */
26
- export type RelationKey<E> = {
27
- readonly [K in keyof E]-?: NonNullable<E[K]> extends Scalar | Scalar[] | Json ? never : K;
28
- }[Key<E>];
32
+ export type RelationKey<E> = Exclude<Key<E>, FieldKey<E> | MethodKey<E>>;
29
33
  /**
30
34
  * Whether `T` carries the `Json` brand. Checks for the `__json` marker key explicitly:
31
35
  * a bare `extends Json<infer T>` is not discriminating in check position (primitives match it,
@@ -117,14 +121,29 @@ export type JsonUpdateOp<T = unknown> = {
117
121
  */
118
122
  type JsonUpdateOpFor<V, T = UnwrapJson<NonNullable<V>>> = [T] extends [never] ? never : T extends readonly unknown[] ? never : JsonUpdateOp<T>;
119
123
  /**
120
- * Accepted value for a single field in an update payload: the value itself, `QueryRaw` for a raw SQL
121
- * expression (e.g. `raw('NOW()')`), and - for JSON object fields - the JSON operators.
124
+ * Accepted value for a single field in an update payload: the value itself, `null` where the column
125
+ * is nullable, `QueryRaw` for a raw SQL expression (e.g. `raw('NOW()')`), and - for JSON object
126
+ * fields - the JSON operators.
127
+ *
128
+ * An optional property is a nullable column, and clearing one is what an update is for, so `null`
129
+ * belongs in the declared type rather than behind a cast.
122
130
  */
123
- type UpdateFieldValue<V> = V | QueryRaw | JsonUpdateOpFor<V>;
131
+ type UpdateFieldValue<V> = V | (undefined extends V ? null : never) | QueryRaw | JsonUpdateOpFor<V>;
124
132
  /**
125
- * Payload type for update operations.
126
- * Widens each field to additionally accept `QueryRaw` or `JsonUpdateOp` (for JSON fields),
127
- * providing IDE autocomplete for `$set`/`$push`/`$pull` keys via `Json<infer T>`.
133
+ * An entity's fields and relations, each keeping its declared optionality: what the whole-record
134
+ * writes (`insertOne`, `saveOne`, `upsertOne`, and their `*Many`) persist.
135
+ *
136
+ * Not `E`: that *demands* back every method the class declares, so on an entity carrying a
137
+ * lifecycle hook - `@BeforeInsert() generateSlug()` - a plain `{ title: 'Hello' }` was rejected as
138
+ * "missing the following properties". Method-free entities were unaffected, which is why the other
139
+ * examples worked. No runtime filter can help; the call never gets that far. `Pick` because it
140
+ * stays indexable by `IdKey<E>`, which the write path needs.
141
+ */
142
+ export type EntityData<E> = Pick<E, FieldKey<E> | RelationKey<E>>;
143
+ /**
144
+ * Payload type for update operations: {@link EntityData} made partial, and widened per field to
145
+ * accept `QueryRaw` or `JsonUpdateOp` (for JSON fields), which gives IDE autocomplete for
146
+ * `$set`/`$push`/`$pull` keys via `Json<infer T>`.
128
147
  */
129
148
  export type UpdatePayload<E> = {
130
149
  [K in FieldKey<E>]?: UpdateFieldValue<E[K]>;
@@ -149,6 +168,10 @@ export type IdKey<E> = E extends {
149
168
  } ? 'uuid' & FieldKey<E> : FieldKey<E>;
150
169
  /**
151
170
  * Infers the value of the key identifier on an entity.
171
+ *
172
+ * Nullable, because an entity declares its id optional - nothing has assigned one before the
173
+ * insert. That puts `undefined` inside every by-id method's parameter, where it would mean "no
174
+ * filter"; `assertIdValue` is what rejects it.
152
175
  */
153
176
  export type IdValue<E> = E[IdKey<E>];
154
177
  /**
@@ -4,7 +4,7 @@ import type { SqlDialectName } from './dialect.js';
4
4
  import type { HookEvent } from './entity.js';
5
5
  import type { LoggingOptions } from './logger.js';
6
6
  import type { NamingStrategy } from './namingStrategy.js';
7
- import type { Query, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult } from './query.js';
7
+ import type { Query, QueryFilter, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult } from './query.js';
8
8
  import type { UniversalQuerier } from './universalQuerier.js';
9
9
  import type { Type } from './utility.js';
10
10
  /**
@@ -25,51 +25,57 @@ export type TransactionOptions = {
25
25
  readonly isolationLevel?: IsolationLevel;
26
26
  };
27
27
  export type DialectName = SqlDialectName | 'mongodb';
28
+ /**
29
+ * The read and delete methods below take the entity as an argument or as the query's `$entity` key.
30
+ * In each pair the `$entity` overload comes **first** on purpose: when no overload matches,
31
+ * TypeScript reports the error from the *last* one, so keeping the entity-argument form last is
32
+ * what makes a typo'd query key report as itself rather than as a missing `$entity`.
33
+ */
28
34
  export interface Querier extends UniversalQuerier {
29
35
  /**
30
36
  * Find one record. Supports both entity-as-argument and entity-as-field patterns.
31
37
  */
32
- findOne<E extends object>(entity: Type<E>, q: QueryOne<E>, opts?: QueryOptions): Promise<E | undefined>;
33
38
  findOne<E extends object>(q: QueryOne<E> & {
34
39
  $entity: Type<E>;
35
40
  }, opts?: QueryOptions): Promise<E | undefined>;
41
+ findOne<E extends object>(entity: Type<E>, q: QueryOne<E>, opts?: QueryOptions): Promise<E | undefined>;
36
42
  /**
37
43
  * Find many records. Supports both entity-as-argument and entity-as-field patterns.
38
44
  */
39
- findMany<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
40
45
  findMany<E extends object>(q: Query<E> & {
41
46
  $entity: Type<E>;
42
47
  }, opts?: QueryOptions): Promise<E[]>;
48
+ findMany<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
43
49
  /**
44
50
  * Stream records as an async iterable. Supports both patterns.
45
51
  * Does not fill relations or fire lifecycle hooks.
46
52
  */
47
- findManyStream<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncIterable<E>;
48
53
  findManyStream<E extends object>(q: Query<E> & {
49
54
  $entity: Type<E>;
50
55
  }, opts?: QueryOptions): AsyncIterable<E>;
56
+ findManyStream<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncIterable<E>;
51
57
  /**
52
58
  * Find many records and count. Supports both patterns.
53
59
  */
54
- findManyAndCount<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
55
60
  findManyAndCount<E extends object>(q: Query<E> & {
56
61
  $entity: Type<E>;
57
62
  }, opts?: QueryOptions): Promise<[E[], number]>;
63
+ findManyAndCount<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
58
64
  /**
59
65
  * Count records. Supports both patterns.
60
66
  */
61
- count<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
62
- count<E extends object>(q: QuerySearch<E> & {
67
+ count<E extends object>(q: QueryFilter<E> & {
63
68
  $entity: Type<E>;
64
69
  }, opts?: QueryOptions): Promise<number>;
70
+ count<E extends object>(entity: Type<E>, q?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
65
71
  /**
66
72
  * Delete many records (soft-deletes when the entity has a soft-delete field, else removes them).
67
73
  * Supports both entity-as-argument and entity-as-field patterns.
68
74
  */
69
- deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
70
75
  deleteMany<E extends object>(q: QuerySearch<E> & {
71
76
  $entity: Type<E>;
72
77
  }, opts?: QueryOptions): Promise<number>;
78
+ deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
73
79
  /**
74
80
  * whether this querier is in a transaction or not.
75
81
  */
@@ -142,21 +142,29 @@ export type QueryPager = {
142
142
  $limit?: number;
143
143
  };
144
144
  /**
145
- * search options.
145
+ * Which rows a statement addresses. `count` takes exactly this: how many rows match is all a count
146
+ * can answer, so an ordering or a page on it is a clause it could only drop or choke on.
146
147
  */
147
- export type QuerySearch<E> = {
148
+ export type QueryFilter<E> = {
148
149
  /**
149
150
  * filtering options.
150
151
  */
151
152
  $where?: QueryWhere<E>;
153
+ };
154
+ /**
155
+ * A filter plus the ordering and page `updateMany`/`deleteMany` take. Both settle the rows they
156
+ * picked before writing, so the page is portable rather than MySQL-only.
157
+ *
158
+ * `$sort` excludes vector search here for the reason `$lock` is declared on {@link Query} instead:
159
+ * a vector search ranks rows into a projected distance column, and only a SELECT has a projection
160
+ * list to hold one. Passing `QuerySortMap` its `Vector = false` is what keeps it off these.
161
+ */
162
+ export type QuerySearch<E> = QueryFilter<E> & {
152
163
  /**
153
164
  * sorting options.
154
165
  */
155
- $sort?: QuerySortMap<E>;
166
+ $sort?: QuerySortMap<E, false>;
156
167
  } & QueryPager;
157
- /**
158
- * criteria one options.
159
- */
160
168
  /**
161
169
  * query options.
162
170
  */
@@ -177,6 +185,11 @@ export type Query<E> = {
177
185
  * regardless, since subtracting them would leave the relation unfilled.
178
186
  */
179
187
  $exclude?: QueryExclude<E>;
188
+ /**
189
+ * sorting options, vector similarity search included: a SELECT is the one statement with a
190
+ * projection list to hold the distance such a search computes.
191
+ */
192
+ $sort?: QuerySortMap<E>;
180
193
  /**
181
194
  * whether to return only distinct rows.
182
195
  */
@@ -187,11 +200,11 @@ export type Query<E> = {
187
200
  * the rows, so it is rejected rather than emitted. Locks only the queried entity, never anything
188
201
  * reached through `$populate`. SQL only; MongoDB and the SQLite family reject it.
189
202
  *
190
- * Deliberately declared here rather than on `QuerySearch`, which `count`/`update`/`delete` take:
191
- * that placement is what keeps the clause off those statements at the type level.
203
+ * Declared here rather than on {@link QuerySearch}, which `update`/`delete` take: that placement
204
+ * is what keeps the clause off those statements at the type level.
192
205
  */
193
206
  $lock?: QueryLock;
194
- } & QuerySearch<E>;
207
+ } & QueryFilter<E> & QueryPager;
195
208
  /**
196
209
  * options to get a single record.
197
210
  */
@@ -1,5 +1,5 @@
1
1
  import type { FieldKey } from './entity.js';
2
- import type { QueryPager, QuerySortDirection, QuerySortMap } from './query.js';
2
+ import type { QueryPager, QuerySortDirection } from './query.js';
3
3
  import type { QueryWhere, QueryWhereFieldValue } from './queryWhere.js';
4
4
  /**
5
5
  * Maps the offending keys to `never`, turning an excess key into a compile error; resolves to
@@ -10,6 +10,22 @@ import type { QueryWhere, QueryWhereFieldValue } from './queryWhere.js';
10
10
  * @internal
11
11
  */
12
12
  type Reject<K> = [K] extends [never] ? unknown : Record<K & string, never>;
13
+ /**
14
+ * The columns `$group` actually names: keys whose value is literally `true`, not `keyof G`.
15
+ * Wherever `G` cannot be inferred - `$group` omitted, hoisted, or annotated - it *is* its own
16
+ * constraint, whose every value is `true | undefined`, and keying off values yields `never` there
17
+ * rather than every field of the entity.
18
+ * @internal
19
+ */
20
+ type GroupedKeys<G> = {
21
+ [K in keyof G]: G[K] extends true ? K : never;
22
+ }[keyof G];
23
+ /**
24
+ * The keys `T` declares by name, or `never` when `T` is only an index signature - which is what an
25
+ * uninferred `$agg` is, and what would otherwise make every key look like a declared alias.
26
+ * @internal
27
+ */
28
+ type NamedKeys<T> = string extends keyof T ? never : keyof T;
13
29
  declare const QUERY_AGGREGATE_OPS: readonly ['$count', '$sum', '$avg', '$min', '$max'];
14
30
  /**
15
31
  * Supported aggregate operations.
@@ -44,10 +60,29 @@ export declare function resolveAggregateOp(key: string): {
44
60
  };
45
61
  /** The argument of an aggregate function: a field, or `'*'` (only meaningful for `COUNT(*)`). */
46
62
  export type QueryAggregateArg<E> = FieldKey<E> | '*';
47
- /** Ops whose argument must be a plain field: every op except `$count` (which also accepts `'*'`). */
48
- type QueryAggregateFieldOp = Exclude<QueryAggregateOp, '$count'> | QueryAggregateDistinctOp;
49
- /** Every aggregate op mapped to its accepted argument: `$count` takes a field or `'*'`, the rest a field. */
50
- type QueryAggregateArgMap<E> = Record<'$count', QueryAggregateArg<E>> & Record<QueryAggregateFieldOp, FieldKey<E>>;
63
+ /**
64
+ * Fields `SUM`/`AVG` can total. Restricted to numeric columns because the result is declared
65
+ * `number`: totalling a text or date column is either an engine error or a coercion, and neither
66
+ * produces the value the signature promises.
67
+ */
68
+ type NumericFieldKey<E> = {
69
+ readonly [K in FieldKey<E>]: [NonNullable<E[K]>] extends [number | bigint] ? K : never;
70
+ }[FieldKey<E>];
71
+ /** Every aggregate op, plain and DISTINCT-qualified. */
72
+ type AggregateOp = QueryAggregateOp | QueryAggregateDistinctOp;
73
+ /**
74
+ * Names a subset of {@link AggregateOp}. The constraint is the point, and why this is not `Extract`:
75
+ * renaming an op stops these literals satisfying it and breaks the subsets below at compile time,
76
+ * where `Extract` would quietly drop the renamed member and leave the subset wrong but valid.
77
+ */
78
+ type OpsOf<K extends AggregateOp> = K;
79
+ /** Ops that total a column, so their argument has to be numeric. */
80
+ type TotallingOp = OpsOf<'$sum' | '$avg' | '$sumDistinct' | '$avgDistinct'>;
81
+ /**
82
+ * Every aggregate op mapped to the argument it accepts: `$count` a field or `'*'` (`COUNT(*)`),
83
+ * the totalling ops a numeric field, `$min`/`$max`/`$countDistinct` any field.
84
+ */
85
+ type QueryAggregateArgMap<E> = Record<'$count', QueryAggregateArg<E>> & Record<TotallingOp, NumericFieldKey<E>> & Record<Exclude<AggregateOp, '$count' | TotallingOp>, FieldKey<E>>;
51
86
  /** Exactly one key of `T`: the chosen op with its value; every other op key is forbidden (`never`). */
52
87
  type ExactlyOne<T> = {
53
88
  [K in keyof T]: Readonly<Record<K, T[K]>> & Partial<Readonly<Record<Exclude<keyof T, K>, never>>>;
@@ -64,17 +99,14 @@ type ExactlyOne<T> = {
64
99
  * @example { $avg: 'age' } → AVG("age")
65
100
  */
66
101
  export type QueryAggregateFn<E> = ExactlyOne<QueryAggregateArgMap<E>>;
67
- /**
68
- * Aggregate ops whose grouped column always resolves to `number`, regardless of the aggregated
69
- * field's own type: the DISTINCT variants plus the base ops they map to (`$count`/`$sum`/`$avg`).
70
- */
71
- type QueryAggregateNumericOp = QueryAggregateDistinctOp | (typeof QUERY_AGGREGATE_DISTINCT_OP_BASE)[QueryAggregateDistinctOp];
72
- /** A single-key `{ [op]: unknown }` shape for each {@link QueryAggregateNumericOp}, matched to infer a `number` result. */
73
- type QueryAggregateNumericFn = {
74
- [K in QueryAggregateNumericOp]: {
102
+ /** A single-key `{ [op]: unknown }` shape for each op in `Ops`, matched to infer that op's result. */
103
+ type FnWithOp<Ops extends string> = {
104
+ [K in Ops]: {
75
105
  readonly [P in K]: unknown;
76
106
  };
77
- }[QueryAggregateNumericOp];
107
+ }[Ops];
108
+ /** Ops that count rows. Alone among the ops they answer `0`, never NULL, over an empty group. */
109
+ type CountingOp = OpsOf<'$count' | '$countDistinct'>;
78
110
  /**
79
111
  * Group-by columns: an object mapping entity field keys to `true`, exactly like {@link QuerySelect}.
80
112
  * Typed against the entity, so a typo'd column is a compile error. Compute aggregate columns with
@@ -105,18 +137,22 @@ export type QueryAggMap<E> = {
105
137
  /** The entity type of an aggregated field reference `F`, or `unknown` if it is not a known field. */
106
138
  type FieldValueType<E, F> = F extends keyof E ? E[F] : unknown;
107
139
  /**
108
- * Resolves a single computed column's type from its aggregate function: `$count`/`$sum`/`$avg` are
109
- * always `number`; `$min`/`$max` keep the aggregated field's own type.
140
+ * Resolves a single computed column's type from its aggregate function: `$count` is always
141
+ * `number`; `$sum`/`$avg` total to a `number` or to `null`; `$min`/`$max` keep the aggregated
142
+ * field's own type, likewise or `null`.
143
+ *
144
+ * Everything but `$count` is nullable: an aggregate over zero rows is NULL, and an ungrouped one
145
+ * still returns a row, so a `$where` matching nothing hands back a row of NULLs.
110
146
  *
111
147
  * `$sum`/`$avg` are exact to 2^53: Postgres widens a sum over BIGINT to NUMERIC, and decoding that
112
148
  * text to satisfy this `number` drops the digits past that bound. Use `raw()` for a wider total.
113
149
  * @internal
114
150
  */
115
- type QueryAggregateFnResult<E, Fn> = Fn extends QueryAggregateNumericFn ? number : Fn extends {
151
+ type QueryAggregateFnResult<E, Fn> = Fn extends FnWithOp<CountingOp> ? number : Fn extends FnWithOp<TotallingOp> ? number | null : Fn extends {
116
152
  readonly $min: infer F;
117
- } ? FieldValueType<E, F> : Fn extends {
153
+ } | {
118
154
  readonly $max: infer F;
119
- } ? FieldValueType<E, F> : unknown;
155
+ } ? FieldValueType<E, F> | null : unknown;
120
156
  /**
121
157
  * Flattens an intersection into a single object literal for readable editor hovers.
122
158
  * @internal
@@ -127,9 +163,12 @@ type Simplify<T> = {
127
163
  /**
128
164
  * Infers the aggregated result row: grouped columns (`G`) keep their entity type; computed columns
129
165
  * (`A`) resolve from their aggregate function via {@link QueryAggregateFnResult}.
166
+ *
167
+ * Grouped columns come from {@link GroupedKeys}, not `keyof G`, so a `$group` the compiler could
168
+ * not read contributes none rather than all of them.
130
169
  */
131
170
  export type QueryAggregateResult<E, G, A> = Simplify<{
132
- -readonly [K in keyof G & FieldKey<E>]: E[K];
171
+ -readonly [K in GroupedKeys<G> & FieldKey<E>]: E[K];
133
172
  } & {
134
173
  -readonly [K in keyof A]: QueryAggregateFnResult<E, A[K]>;
135
174
  }>;
@@ -171,8 +210,11 @@ export type QueryAggregate<E, G extends QueryGroupMap<E> = QueryGroupMap<E>, A e
171
210
  readonly $group?: G & Reject<Exclude<keyof G, FieldKey<E>>>;
172
211
  /**
173
212
  * Computed aggregate columns - `{ count: { $count: '*' }, avgAge: { $avg: 'age' } }`.
213
+ *
214
+ * An alias repeating a `$group` column is rejected: both would be emitted under that one name,
215
+ * leaving the driver to keep whichever it read last.
174
216
  */
175
- readonly $agg?: A;
217
+ readonly $agg?: A & Reject<NamedKeys<A> & GroupedKeys<G>>;
176
218
  /**
177
219
  * Post-aggregation filtering, applied after grouping (SQL `HAVING`, MongoDB post-group `$match`).
178
220
  * Keyed by the result columns (grouped columns + computed aliases), and each value is typed to that
@@ -184,10 +226,11 @@ export type QueryAggregate<E, G extends QueryGroupMap<E> = QueryGroupMap<E>, A e
184
226
  readonly [K in keyof QueryAggregateResult<E, G, A>]?: QueryWhereFieldValue<QueryAggregateResult<E, G, A>[K]>;
185
227
  };
186
228
  /**
187
- * Sort the aggregated results by a grouped column, a computed alias, or an entity field.
229
+ * Sort the aggregated results by a grouped column or a computed alias - an aggregate's rows are
230
+ * its groups, so any other entity field names a value the statement never produced.
188
231
  */
189
- readonly $sort?: QuerySortMap<E> & {
190
- readonly [K in (keyof G & string) | (keyof A & string)]?: QuerySortDirection;
232
+ readonly $sort?: {
233
+ readonly [K in keyof QueryAggregateResult<E, G, A>]?: QuerySortDirection;
191
234
  };
192
235
  } & QueryPager;
193
236
  export {};