uql-orm 0.41.1 → 0.42.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 (40) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +4 -4
  2. package/dist/browser/querier/httpQuerier.js +21 -6
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +4 -4
  5. package/dist/dialect/abstractSqlDialect.d.ts +1 -1
  6. package/dist/dialect/abstractSqlDialect.js +26 -21
  7. package/dist/dialect/mysqlLikeSqlDialect.d.ts +1 -1
  8. package/dist/dialect/mysqlLikeSqlDialect.js +2 -2
  9. package/dist/entity/decorator/members.d.ts +10 -2
  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 +18 -1
  13. package/dist/entity/metadata/definition.js +91 -36
  14. package/dist/http/handler.js +4 -4
  15. package/dist/maria/mariaDialect.d.ts +2 -2
  16. package/dist/maria/mariaDialect.js +2 -2
  17. package/dist/migrate/schemaGenerator.d.ts +2 -2
  18. package/dist/migrate/schemaGenerator.js +3 -3
  19. package/dist/mongo/mongoDialect.js +17 -6
  20. package/dist/mongo/mongodbQuerier.js +6 -3
  21. package/dist/querier/abstractQuerier.d.ts +16 -12
  22. package/dist/querier/abstractQuerier.js +77 -40
  23. package/dist/querier/abstractQuerierPool.d.ts +8 -8
  24. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  25. package/dist/querier/abstractSqlQuerier.js +12 -7
  26. package/dist/querier/relationCount.js +40 -31
  27. package/dist/schema/schemaASTBuilder.js +35 -22
  28. package/dist/type/entity.d.ts +57 -14
  29. package/dist/type/queryWhere.d.ts +7 -2
  30. package/dist/type/universalQuerier.d.ts +8 -8
  31. package/dist/util/dialect.util.js +15 -14
  32. package/dist/util/index.d.ts +1 -0
  33. package/dist/util/index.js +1 -0
  34. package/dist/util/object.util.d.ts +6 -0
  35. package/dist/util/object.util.js +10 -0
  36. package/dist/util/relationQuery.util.d.ts +38 -7
  37. package/dist/util/relationQuery.util.js +50 -9
  38. package/dist/util/rowKey.util.d.ts +9 -0
  39. package/dist/util/rowKey.util.js +24 -0
  40. package/package.json +1 -1
@@ -182,7 +182,7 @@ export type FieldValue<E> = E[FieldKey<E>];
182
182
  /**
183
183
  * Infers the name of the key identifier on an entity
184
184
  */
185
- export type IdKey<E> = E extends {
185
+ export type IdKey<E> = (E extends {
186
186
  [idKey]?: infer K;
187
187
  } ? K & FieldKey<E> : E extends {
188
188
  _id?: unknown;
@@ -190,15 +190,39 @@ export type IdKey<E> = E extends {
190
190
  id?: unknown;
191
191
  } ? 'id' & FieldKey<E> : E extends {
192
192
  uuid?: unknown;
193
- } ? 'uuid' & FieldKey<E> : FieldKey<E>;
193
+ } ? 'uuid' & FieldKey<E> : FieldKey<E>) & string;
194
194
  /**
195
195
  * Infers the value of the key identifier on an entity.
196
196
  *
197
+ * A composite key is addressed by an object carrying every key, which is also the `$where` map it
198
+ * reduces to - so both spellings are one type. Completeness is checked at run time by
199
+ * `assertIdValue`: TypeScript cannot accumulate `@Id` across properties into the class type, so it
200
+ * cannot know how many keys there are.
201
+ *
197
202
  * Nullable, because an entity declares its id optional - nothing has assigned one before the
198
203
  * insert. That puts `undefined` inside every by-id method's parameter, where it would mean "no
199
204
  * filter"; `assertIdValue` is what rejects it.
200
205
  */
201
206
  export type IdValue<E> = E[IdKey<E>];
207
+ /**
208
+ * How a row is addressed by its primary key: the value for a single key, an object carrying every
209
+ * key for a composite - which is also the `$where` map it reduces to, so both spellings are one type.
210
+ *
211
+ * Distinct from {@link IdValue}, the *column's* value, which stays a scalar: an id column holds a
212
+ * number, never an object. The two read alike on a single-key entity and are not the same thing -
213
+ * `findOneById` takes an `EntityId`, while the inserts return `IdValue | undefined`, which is why a
214
+ * composite insert reports no id rather than the map.
215
+ *
216
+ * The keys stay optional, and completeness is checked at run time by `assertIdValue`. Requiring them
217
+ * needs `IdKey` to be precise, which it is not: with no `id`/`_id`/`uuid` and no `idKey` brand it
218
+ * falls back to every field, so `IdKey<Membership>` accepts `'role'` and requiring the map would
219
+ * demand fields that are not keys. Making it conditional on the brand was tried and reverted - a
220
+ * conditional type does not reduce for an unresolved `E`, which left `QueryWhere<E>` opaque and broke
221
+ * assignability across the dialects.
222
+ */
223
+ export type EntityId<E> = IdValue<E> | {
224
+ [K in IdKey<E>]?: E[K];
225
+ };
202
226
  /**
203
227
  * Infers the values of the relations on an entity
204
228
  */
@@ -254,22 +278,34 @@ export type FieldType = StringConstructor | NumberConstructor | BooleanConstruct
254
278
  */
255
279
  export type TypeFor<V, T = NonNullable<V>> = IsJson<T> extends true ? JsonColumnType : IsJson<NonNullable<Unpacked<T>>> extends true ? JsonColumnType : T extends readonly number[] ? VectorColumnType : T extends string ? StringConstructor | StringColumnType : T extends number ? NumberConstructor | NumericColumnType : T extends bigint ? BigIntConstructor | NumericColumnType : T extends boolean ? BooleanConstructor | BooleanColumnType : T extends Date ? DateConstructor | DateColumnType : T extends Uint8Array ? BlobColumnType : FieldType;
256
280
  /**
257
- * Configurable options for a field, carrying `V`, the value the column holds: what a generator returns
258
- * and what a default is has to be that value, checked the same way the declared `type` is. `Scalar` by
259
- * by default, for the places that handle a field without knowing which one it is.
281
+ * A field as the registry holds it: what the user authored, plus what registration worked out.
282
+ *
283
+ * Separate from {@link FieldOptions} so neither of these can be written in a decorator. They used to
284
+ * live there behind an `@internal` tag and a "do not set this" note, which is a comment standing in
285
+ * for a type boundary.
260
286
  */
261
- export type FieldOptions<V = TsTypeOf<FieldType>> = {
262
- readonly name?: string;
263
- readonly isId?: true;
264
- readonly type?: FieldType;
287
+ export type FieldMeta<V = TsTypeOf<FieldType>> = FieldOptions<V> & {
265
288
  /**
266
289
  * Set by `defineField` when the field gave `references` but no `type`, so schema generation resolves
267
290
  * the column from the referenced primary key rather than from whatever ended up in `type`. That is
268
291
  * what keeps a `uuid` primary key from becoming TEXT on every foreign key pointing at it.
269
- * Internal bookkeeping - do not set this from a decorator.
270
- * @internal
271
292
  */
272
293
  readonly typeFromReference?: boolean;
294
+ /**
295
+ * Which key of the referenced entity this column points at, where that entity has more than one.
296
+ * Set by `fillOwningSide`; without it a composite target's columns would all take the first key's type.
297
+ */
298
+ readonly referencedKey?: string;
299
+ };
300
+ /**
301
+ * Configurable options for a field, carrying `V`, the value the column holds: what a generator returns
302
+ * and what a default is has to be that value, checked the same way the declared `type` is. `Scalar` by
303
+ * default, for the places that handle a field without knowing which one it is.
304
+ */
305
+ export type FieldOptions<V = TsTypeOf<FieldType>> = {
306
+ readonly name?: string;
307
+ readonly isId?: true;
308
+ readonly type?: FieldType;
273
309
  /**
274
310
  * Dimensions for vector fields. Used in schema generation.
275
311
  * @example `@Field({ type: 'vector', dimensions: 1536 })`
@@ -697,14 +733,21 @@ export type EntityMeta<E> = {
697
733
  name?: string;
698
734
  /** Set only when the entity named one; unset defers to the pool where it is used. See `AbstractDialect.resolveSchema`. */
699
735
  schema?: string;
700
- id: IdKey<E>;
736
+ /**
737
+ * Every key of the primary key, in declaration order. One unless the entity declares a composite.
738
+ *
739
+ * The only stored form: a single `id` beside it could only ever be right for a single-key entity,
740
+ * so every reader had to know whether it was safe. Asking whether *this* field is part of the key
741
+ * is `fields[key].isId`, which is O(1) and the source this list is derived from.
742
+ */
743
+ ids: readonly IdKey<E>[];
701
744
  softDelete?: FieldKey<E>;
702
745
  /** Named, default-on `$where` filters applied to every query unless bypassed. */
703
746
  filters?: Record<string, FilterOptions<E>>;
704
747
  fields: {
705
- [K in FieldKey<E>]?: FieldOptions;
748
+ [K in FieldKey<E>]?: FieldMeta;
706
749
  } & {
707
- [key: string]: FieldOptions | undefined;
750
+ [key: string]: FieldMeta | undefined;
708
751
  };
709
752
  relations: {
710
753
  [K in RelationKey<E>]?: RelationMeta;
@@ -1,4 +1,4 @@
1
- import type { FieldKey, IdValue, JsonFieldPaths, JsonFieldPathValue, RelationKey, RelationTarget } from './entity.js';
1
+ import type { EntityId, FieldKey, JsonFieldPaths, JsonFieldPathValue, RelationKey, RelationTarget } from './entity.js';
2
2
  import type { QueryRaw } from './queryRaw.js';
3
3
  import type { ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
4
4
  import type { QueryVectorQuery } from './vector.js';
@@ -316,5 +316,10 @@ export type QueryWhereArray<E> = (QueryWhereMap<E> | QueryRaw)[];
316
316
  /**
317
317
  * query filter.
318
318
  */
319
- export type QueryWhere<E> = IdValue<E> | IdValue<E>[] | QueryWhereMap<E> | QueryWhereArray<E> | QueryRaw;
319
+ /**
320
+ * `EntityId` rather than `IdValue`: a by-id method reduces to `$where: id`, and a composite key is
321
+ * addressed by an object carrying every key. That object is a where map naming those columns, so the
322
+ * two spellings meet here rather than needing a conversion.
323
+ */
324
+ export type QueryWhere<E> = EntityId<E> | EntityId<E>[] | QueryWhereMap<E> | QueryWhereArray<E> | QueryRaw;
320
325
  export {};
@@ -1,4 +1,4 @@
1
- import type { EntityData, FieldKey, IdValue, RelationKey, UpdatePayload } from './entity.js';
1
+ import type { EntityData, EntityId, FieldKey, IdValue, RelationKey, UpdatePayload } from './entity.js';
2
2
  import type { QueryConflictPaths, QueryFilter, QueryFindResult, QueryOneProjected, QueryOptions, QueryPage, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult } from './query.js';
3
3
  import type { QueryAggMap, QueryAggregate, QueryAggregateResult, QueryGroupMap } from './queryAggregate.js';
4
4
  import type { Type } from './utility.js';
@@ -22,7 +22,7 @@ export interface SharedQuerier<W extends QuerierTransport, O, DO = O> {
22
22
  * @param q the additional criteria options
23
23
  * @return the record
24
24
  */
25
- 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?: O): QuerierResult<W, QueryFindResult<E, S, V, X, P, C> | undefined>;
25
+ 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?: O): QuerierResult<W, QueryFindResult<E, S, V, X, P, C> | undefined>;
26
26
  /**
27
27
  * obtains the first record matching the given search parameters.
28
28
  * @param entity the target entity
@@ -68,7 +68,7 @@ export interface SharedQuerier<W extends QuerierTransport, O, DO = O> {
68
68
  * @param payload the data to be persisted
69
69
  * @return the number of affected records
70
70
  */
71
- updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: O): QuerierResult<W, number>;
71
+ updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdatePayload<E>, opts?: O): QuerierResult<W, number>;
72
72
  /**
73
73
  * updates many records partially.
74
74
  * @param entity the entity to persist on
@@ -83,7 +83,7 @@ export interface SharedQuerier<W extends QuerierTransport, O, DO = O> {
83
83
  * @param id the primary key of the record
84
84
  * @return the number of affected records
85
85
  */
86
- deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: DO): QuerierResult<W, number>;
86
+ deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: DO): QuerierResult<W, number>;
87
87
  /**
88
88
  * delete or SoftDelete records.
89
89
  * @param entity the entity to persist on
@@ -130,7 +130,7 @@ export interface UniversalQuerier extends SharedQuerier<'server', QueryOptions>
130
130
  * @param payload the data to be persisted
131
131
  * @return the IDs
132
132
  */
133
- insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
133
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
134
134
  /**
135
135
  * Insert or update a record based on the conflict paths.
136
136
  * @param entity the entity to persist on
@@ -153,19 +153,19 @@ export interface UniversalQuerier extends SharedQuerier<'server', QueryOptions>
153
153
  * @param payload the data to be persisted
154
154
  * @return the ID
155
155
  */
156
- saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
156
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
157
157
  /**
158
158
  * Insert or update records.
159
159
  * @param entity the entity to persist on
160
160
  * @param payload the data to be persisted
161
161
  * @return the IDs
162
162
  */
163
- saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
163
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<(IdValue<E> | undefined)[]>;
164
164
  /**
165
165
  * Restore soft-deleted records (sets the soft-delete field back to `null`). Throws if the
166
166
  * entity has no soft-delete field.
167
167
  */
168
- restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
168
+ restoreOneById<E extends object>(entity: Type<E>, id: EntityId<E>): Promise<number>;
169
169
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
170
170
  /**
171
171
  * runs an aggregate query (GROUP BY with aggregate functions).
@@ -1,7 +1,8 @@
1
1
  import { getContext, UqlSecurityError } from '../context/context.js';
2
+ import { soleIdOf } from '../entity/metadata/definition.js';
2
3
  import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index.js';
3
4
  import { VECTOR_INDEX_TYPES } from '../type/vector.js';
4
- import { entityName, getFieldKeys, getKeys, hasKeys, someKey } from './object.util.js';
5
+ import { entityName, getFieldKeys, getKeys, hasKeys, isScalarId, someKey } from './object.util.js';
5
6
  export function filterFieldKeys(meta, payload, callbackKey) {
6
7
  return getKeys(payload).filter((key) => {
7
8
  const fieldOpts = meta.fields[key];
@@ -112,7 +113,7 @@ export function isPagedQuery(q) {
112
113
  * compiler - so it is spelled once here rather than in each querier.
113
114
  */
114
115
  export function idOnlyQuery(meta, q) {
115
- return { ...q, $select: { [meta.id]: true } };
116
+ return { ...q, $select: Object.fromEntries(meta.ids.map((key) => [key, true])) };
116
117
  }
117
118
  /**
118
119
  * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
@@ -235,10 +236,18 @@ export function buildQueryWhereAsMap(meta, filter = {}) {
235
236
  if (filter instanceof QueryRaw) {
236
237
  return { $and: [filter] };
237
238
  }
238
- if (isIdValue(filter)) {
239
- return {
240
- [meta.id]: filter,
241
- };
239
+ if (Array.isArray(filter)) {
240
+ // A list of bare ids is an `IN` over the one key column; a list of anything else is a list of
241
+ // `$where`s, which is an OR - and that is how a composite's id objects name a settled set of rows.
242
+ return filter.every(isScalarId)
243
+ ? { [soleIdOf(meta, 'addressing by a bare id value')]: filter }
244
+ : { $or: filter };
245
+ }
246
+ if (isScalarId(filter)) {
247
+ // A scalar can only name one column, so on a composite it would address every row agreeing on
248
+ // that one. A composite is addressed by a map, which falls through below as the `$where` it
249
+ // already is - an id object and a where map are the same shape by design.
250
+ return { [soleIdOf(meta, 'addressing by a bare id value')]: filter };
242
251
  }
243
252
  return filter;
244
253
  }
@@ -311,14 +320,6 @@ export function applyFilters(meta, whereMap, opts) {
311
320
  }
312
321
  return result;
313
322
  }
314
- function isIdValue(filter) {
315
- const type = typeof filter;
316
- return (type === 'string' ||
317
- type === 'number' ||
318
- type === 'bigint' ||
319
- typeof filter.toHexString === 'function' ||
320
- Array.isArray(filter));
321
- }
322
323
  /**
323
324
  * The `$size` of a relation condition (`{ comments: { $size: { $gte: 2 } } }`), or `undefined` when the
324
325
  * condition constrains the target's fields instead. Shared so every dialect agrees on which of the two
@@ -6,6 +6,7 @@ export * from './ddlExpression.util.js';
6
6
  export * from './logger.js';
7
7
  export * from './object.util.js';
8
8
  export * from './raw.js';
9
+ export * from './rowKey.util.js';
9
10
  export * from './relationQuery.util.js';
10
11
  export * from './sql.util.js';
11
12
  export * from './string.util.js';
@@ -6,6 +6,7 @@ export * from './ddlExpression.util.js';
6
6
  export * from './logger.js';
7
7
  export * from './object.util.js';
8
8
  export * from './raw.js';
9
+ export * from './rowKey.util.js';
9
10
  export * from './relationQuery.util.js';
10
11
  export * from './sql.util.js';
11
12
  export * from './string.util.js';
@@ -29,3 +29,9 @@ export declare function entityName<E>(meta: EntityMeta<E>): string;
29
29
  export declare function getFieldKeys<E>(fields: {
30
30
  [K in FieldKey<E>]?: FieldOptions;
31
31
  }): FieldKey<E>[];
32
+ /**
33
+ * Whether `value` addresses a row by itself rather than naming columns: every primitive, and the
34
+ * object ids a driver deals in (`ObjectId`, `Date`, bytes). Only a plain object names columns, which
35
+ * is what a `$where` map and a composite key's id object both are; an array is a list of either.
36
+ */
37
+ export declare function isScalarId(value: unknown): boolean;
@@ -63,3 +63,13 @@ export function entityName(meta) {
63
63
  export function getFieldKeys(fields) {
64
64
  return getKeys(fields).filter((field) => fields[field].eager ?? true);
65
65
  }
66
+ /**
67
+ * Whether `value` addresses a row by itself rather than naming columns: every primitive, and the
68
+ * object ids a driver deals in (`ObjectId`, `Date`, bytes). Only a plain object names columns, which
69
+ * is what a `$where` map and a composite key's id object both are; an array is a list of either.
70
+ */
71
+ export function isScalarId(value) {
72
+ return (typeof value !== 'object' ||
73
+ value === null ||
74
+ (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype));
75
+ }
@@ -9,15 +9,46 @@ export type RelationRequestSummary<E> = {
9
9
  * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
10
10
  */
11
11
  export declare function isToManyRelation(relation: Pick<RelationMeta, 'cardinality'>): boolean;
12
+ /** One column of a parent's key, paired with the column matching it on the table being joined. */
13
+ export type ParentJoin = {
14
+ readonly parent: string;
15
+ readonly joined: string;
16
+ };
17
+ /**
18
+ * How a relation joins to its parent: `parent` is a column of the parent's own table, `joined` the
19
+ * column matching it on the table the relation reads - a junction's own column for a relation that
20
+ * goes through one, the child's foreign key otherwise.
21
+ *
22
+ * The two are spelled from opposite ends of `references` (`local` names a column of the table the
23
+ * relation is declared on, `foreign` a column of the other one), and getting that backwards reads a
24
+ * real column of the wrong table, so it is answered once here. One pair per key of the parent.
25
+ */
26
+ export declare function parentJoins(relOpts: Pick<RelationMeta, 'references' | 'through'>, parentKeyCount: number): ParentJoin[];
27
+ /**
28
+ * The junction columns holding the target's key, the other half of {@link parentJoins}.
29
+ *
30
+ * `parentKeyCount` is required: the target's columns start after the parent's, so guessing the
31
+ * boundary returned the parent's *second* column as the target's - a real column of the wrong side,
32
+ * which is the mistake this module exists to prevent.
33
+ */
34
+ export declare function targetKeyColumns(relOpts: Pick<RelationMeta, 'references'>, parentKeyCount: number): string[];
35
+ /**
36
+ * `{ joined column: every parent's value for it }`, the filter that fetches a whole page of parents'
37
+ * children in one statement.
38
+ *
39
+ * A composite over-selects, because the lists are independent and a pairing no parent has can still
40
+ * match. Regrouping the rows keys on every column, so those rows find no parent and are dropped -
41
+ * cheaper than the row-value comparison no engine spells the same way.
42
+ */
43
+ export declare function parentsIn(joins: readonly ParentJoin[], parents: readonly unknown[]): Record<string, unknown[]>;
12
44
  /**
13
- * The column holding the parent's id: a junction's own for a relation that goes through one, the
14
- * child's foreign key otherwise. The two are spelled from opposite ends - `local` names a column of
15
- * the table the relation is declared to write, `foreign` a column of the other one - and getting
16
- * that backwards reads a real column of the wrong table, so it is answered once here.
45
+ * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
46
+ * column a single key contributes, an OR of key maps for several.
47
+ *
48
+ * Exact, unlike {@link parentsIn}: a read absorbs over-selection by regrouping its rows, and a write
49
+ * has nothing to regroup - a pairing no parent has would delete a child of a parent that survives.
17
50
  */
18
- export declare function parentKeyColumn(relOpts: Pick<RelationMeta, 'references' | 'through'>): string;
19
- /** The column on a junction table holding the target's id, the other half of {@link parentKeyColumn}. */
20
- export declare function targetKeyColumn(relOpts: Pick<RelationMeta, 'references'>): string;
51
+ export declare function childrenOf(joins: readonly ParentJoin[], parentIds: readonly unknown[]): Record<string, unknown>;
21
52
  /**
22
53
  * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
23
54
  * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
@@ -8,17 +8,58 @@ export function isToManyRelation(relation) {
8
8
  return relation.cardinality === '1m' || relation.cardinality === 'mm';
9
9
  }
10
10
  /**
11
- * The column holding the parent's id: a junction's own for a relation that goes through one, the
12
- * child's foreign key otherwise. The two are spelled from opposite ends - `local` names a column of
13
- * the table the relation is declared to write, `foreign` a column of the other one - and getting
14
- * that backwards reads a real column of the wrong table, so it is answered once here.
11
+ * How a relation joins to its parent: `parent` is a column of the parent's own table, `joined` the
12
+ * column matching it on the table the relation reads - a junction's own column for a relation that
13
+ * goes through one, the child's foreign key otherwise.
14
+ *
15
+ * The two are spelled from opposite ends of `references` (`local` names a column of the table the
16
+ * relation is declared on, `foreign` a column of the other one), and getting that backwards reads a
17
+ * real column of the wrong table, so it is answered once here. One pair per key of the parent.
15
18
  */
16
- export function parentKeyColumn(relOpts) {
17
- return relOpts.through ? relOpts.references[0].local : relOpts.references[0].foreign;
19
+ export function parentJoins(relOpts, parentKeyCount) {
20
+ if (!relOpts.through) {
21
+ return relOpts.references.map(({ local, foreign }) => ({ parent: local, joined: foreign }));
22
+ }
23
+ // A junction's pairs are the parent's followed by the target's, and how many the parent has is
24
+ // something its caller already knows - so the boundary is passed rather than stored on a relation.
25
+ return relOpts.references.slice(0, parentKeyCount).map(({ local, foreign }) => ({ parent: foreign, joined: local }));
26
+ }
27
+ /**
28
+ * The junction columns holding the target's key, the other half of {@link parentJoins}.
29
+ *
30
+ * `parentKeyCount` is required: the target's columns start after the parent's, so guessing the
31
+ * boundary returned the parent's *second* column as the target's - a real column of the wrong side,
32
+ * which is the mistake this module exists to prevent.
33
+ */
34
+ export function targetKeyColumns(relOpts, parentKeyCount) {
35
+ return relOpts.references.slice(parentKeyCount).map(({ local }) => local);
36
+ }
37
+ /**
38
+ * `{ joined column: every parent's value for it }`, the filter that fetches a whole page of parents'
39
+ * children in one statement.
40
+ *
41
+ * A composite over-selects, because the lists are independent and a pairing no parent has can still
42
+ * match. Regrouping the rows keys on every column, so those rows find no parent and are dropped -
43
+ * cheaper than the row-value comparison no engine spells the same way.
44
+ */
45
+ export function parentsIn(joins, parents) {
46
+ return Object.fromEntries(joins.map(({ parent, joined }) => [joined, parents.map((it) => it[parent])]));
18
47
  }
19
- /** The column on a junction table holding the target's id, the other half of {@link parentKeyColumn}. */
20
- export function targetKeyColumn(relOpts) {
21
- return relOpts.references[1].local;
48
+ /**
49
+ * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
50
+ * column a single key contributes, an OR of key maps for several.
51
+ *
52
+ * Exact, unlike {@link parentsIn}: a read absorbs over-selection by regrouping its rows, and a write
53
+ * has nothing to regroup - a pairing no parent has would delete a child of a parent that survives.
54
+ */
55
+ export function childrenOf(joins, parentIds) {
56
+ const [first] = joins;
57
+ if (joins.length === 1) {
58
+ return { [first.joined]: parentIds };
59
+ }
60
+ return {
61
+ $or: parentIds.map((id) => Object.fromEntries(joins.map(({ parent, joined }) => [joined, id[parent]]))),
62
+ };
22
63
  }
23
64
  /**
24
65
  * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A row's key as a string, for matching rows to each other in a `Map`.
3
+ *
4
+ * Values are normalized before joining, not stringified: `String(date)` is locale- and
5
+ * timezone-dependent, so two equal dates could key apart, and a `Uint8Array` stringifies to its
6
+ * bytes with commas. Every part is included, so two rows agreeing on one column of a composite key
7
+ * are not treated as one row.
8
+ */
9
+ export declare function rowKey(values: readonly unknown[]): string;
@@ -0,0 +1,24 @@
1
+ /** Separates the parts of a composite key: a unit separator, which no column value carries. */
2
+ const KEY_SEPARATOR = '\u001f';
3
+ /**
4
+ * A row's key as a string, for matching rows to each other in a `Map`.
5
+ *
6
+ * Values are normalized before joining, not stringified: `String(date)` is locale- and
7
+ * timezone-dependent, so two equal dates could key apart, and a `Uint8Array` stringifies to its
8
+ * bytes with commas. Every part is included, so two rows agreeing on one column of a composite key
9
+ * are not treated as one row.
10
+ */
11
+ export function rowKey(values) {
12
+ return values.map(keyPart).join(KEY_SEPARATOR);
13
+ }
14
+ function keyPart(value) {
15
+ if (value instanceof Date) {
16
+ return value.toISOString();
17
+ }
18
+ // Hex by hand rather than through `Buffer`, which is undefined on the browser and edge runtimes
19
+ // this module reaches through `AbstractQuerier`.
20
+ if (value instanceof Uint8Array) {
21
+ return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('');
22
+ }
23
+ return String(value);
24
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.41.1",
6
+ "version": "0.42.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"