uql-orm 0.24.1 → 0.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ from the browser to the server. The same object runs on every supported database
51
51
  ## Why UQL?
52
52
 
53
53
  - **The fastest.** Wins [all 8 categories](https://uql-orm.dev/benchmark) of our [open benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), beating even query builders like Knex and Kysely: ~2.4× faster than the runner-up on average, over 4.6M ops/s on simple SELECTs.
54
- - **Light.** Zero dependencies, under 1 MB installed, every dialect included.
54
+ - **Light.** Zero runtime dependencies, 288 kB on the wire, every dialect included.
55
55
  - **Queries are data, not method chains.** Plain JSON in, typed rows out. There's no DSL to learn and nothing to compile.
56
56
  - **Type-safe to the leaf.** Operators are gated per field type, and JSON/JSONB dot-paths resolve each path's value type, so `{ age: { $like: 'x' } }` or a typo'd path is a compile error instead of a runtime surprise.
57
57
  - **No codegen.** Entities are TypeScript classes, so your code *is* the schema. No `.prisma` file to regenerate, no generated client to keep in sync.
@@ -1,4 +1,4 @@
1
- import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, type QueryPopulate, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelect, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationOptions, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
1
+ import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, type QueryPopulate, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelect, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
2
2
  import { IndexSqlDialect } from './indexSqlDialect.js';
3
3
  /** How a column's values are bound: see {@link AbstractSqlDialect.persistKind}. */
4
4
  type PersistKind = 'plain' | 'json' | 'vector';
@@ -340,9 +340,9 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
340
340
  */
341
341
  private appendRelationSubquery;
342
342
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
343
- protected compareRelation<E>(ctx: QueryContext, entity: Type<E>, key: string, val: QueryWhereMap<unknown>, rel: RelationOptions, opts: QueryComparisonOptions): void;
343
+ protected compareRelation<E>(ctx: QueryContext, entity: Type<E>, val: QueryWhereMap<unknown>, rel: RelationMeta, opts: QueryComparisonOptions): void;
344
344
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
345
- protected compareRelationSize<E>(ctx: QueryContext, entity: Type<E>, key: string, sizeVal: number | QuerySizeComparisonOps, rel: RelationOptions, opts: QueryComparisonOptions): void;
345
+ protected compareRelationSize<E>(ctx: QueryContext, entity: Type<E>, sizeVal: number | QuerySizeComparisonOps, rel: RelationMeta, opts: QueryComparisonOptions): void;
346
346
  /**
347
347
  * Build a complete `$size` comparison expression.
348
348
  * Handles both single and multiple comparison operators by repeating the size expression.
@@ -215,7 +215,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
215
215
  const joinAlias = this.escapeId(joinRelAlias, true);
216
216
  ctx.append(` ${joinType} JOIN ${relEntityName} ${joinAlias} ON `);
217
217
  let refAppended = false;
218
- for (const it of relOpts.references ?? []) {
218
+ for (const it of relOpts.references) {
219
219
  if (refAppended)
220
220
  ctx.append(' AND ');
221
221
  const relField = relMeta.fields[it.foreign];
@@ -246,7 +246,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
246
246
  const prefix = opts.prefix;
247
247
  for (const relKey of relKeys) {
248
248
  const relOpts = meta.relations[relKey];
249
- if (!relOpts?.entity)
249
+ if (!relOpts)
250
250
  continue;
251
251
  const isFirstLevel = prefix === tableName;
252
252
  const joinRelAlias = isFirstLevel ? relKey : prefix ? `${prefix}.${relKey}` : relKey;
@@ -334,10 +334,10 @@ export class AbstractSqlDialect extends IndexSqlDialect {
334
334
  if (rel) {
335
335
  const sizeVal = parseRelationSize(val);
336
336
  if (sizeVal !== undefined) {
337
- this.compareRelationSize(ctx, entity, key, sizeVal, rel, opts);
337
+ this.compareRelationSize(ctx, entity, sizeVal, rel, opts);
338
338
  return;
339
339
  }
340
- this.compareRelation(ctx, entity, key, val, rel, opts);
340
+ this.compareRelation(ctx, entity, val, rel, opts);
341
341
  return;
342
342
  }
343
343
  const value = this.normalizeWhereValue(val);
@@ -1304,12 +1304,9 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1304
1304
  * invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
1305
1305
  * not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
1306
1306
  */
1307
- appendRelationSubquery(ctx, entity, key, rel, opts, projection, val) {
1307
+ appendRelationSubquery(ctx, entity, rel, opts, projection, val) {
1308
1308
  const meta = getMeta(entity);
1309
1309
  const parentTable = this.resolveTableName(entity, meta);
1310
- if (!rel.references?.length) {
1311
- throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1312
- }
1313
1310
  const references = rel.references;
1314
1311
  const escapedParentId = this.escapedParentColumn(parentTable, meta, opts, meta.id);
1315
1312
  const relatedEntity = rel.entity();
@@ -1345,13 +1342,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1345
1342
  ctx.append(')');
1346
1343
  }
1347
1344
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
1348
- compareRelation(ctx, entity, key, val, rel, opts) {
1345
+ compareRelation(ctx, entity, val, rel, opts) {
1349
1346
  ctx.append('EXISTS ');
1350
- this.appendRelationSubquery(ctx, entity, key, rel, opts, '1', val);
1347
+ this.appendRelationSubquery(ctx, entity, rel, opts, '1', val);
1351
1348
  }
1352
1349
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
1353
- compareRelationSize(ctx, entity, key, sizeVal, rel, opts) {
1354
- this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, entity, key, rel, opts, 'COUNT(*)', {}), sizeVal);
1350
+ compareRelationSize(ctx, entity, sizeVal, rel, opts) {
1351
+ this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, entity, rel, opts, 'COUNT(*)', {}), sizeVal);
1355
1352
  }
1356
1353
  /**
1357
1354
  * Build a complete `$size` comparison expression.
@@ -1,6 +1,17 @@
1
- import type { EntityGetter, FieldOptions, FieldType, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, TsTypeOf } from '../../type/index.js';
1
+ import type { EntityGetter, FieldOptions, FieldType, IdValue, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, TsTypeOf } from '../../type/index.js';
2
2
  /** A member decorator that also constrains the property it may be applied to. */
3
3
  type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext<unknown, V>) => void;
4
+ /**
5
+ * The property type a set of field options describes, in the same order schema generation resolves the
6
+ * column: a declared `type` wins, and otherwise the column - and so the property - is the referenced
7
+ * primary key's own type. Which is what makes `@Field({ references: () => User })` on a `number`, where
8
+ * `User.id` is a `uuid`, a compile error rather than a column that disagrees with its property.
9
+ */
10
+ type DeclaredValue<O> = O extends {
11
+ readonly type: infer T extends FieldType;
12
+ } ? TsTypeOf<T> : O extends {
13
+ readonly references: EntityGetter<infer E>;
14
+ } ? IdValue<E> : never;
4
15
  /**
5
16
  * Declares a persisted field.
6
17
  *
@@ -8,27 +19,22 @@ type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext
8
19
  * which is what makes the now-mandatory `type` worth stating.
9
20
  *
10
21
  * @example `@Field({ type: String }) name?: string;`
11
- * @example `@Field({ references: () => Company }) companyId?: string;`
12
- */
13
- export declare function Field<T extends FieldType>(opts: FieldOptions & {
14
- readonly type: T;
15
- }): MemberDecorator<TsTypeOf<T> | undefined>;
16
- /**
17
- * A foreign key may omit `type`: schema generation resolves it from the referenced primary key, so the
18
- * column picks up that key's `columnType`, length and chained references rather than a guess.
22
+ * @example `@Field({ references: () => User }) userId?: string;` (where `User.id` is a `uuid`)
19
23
  */
20
- export declare function Field(opts: FieldOptions & {
21
- readonly references: EntityGetter;
22
- }): MemberDecorator<unknown>;
24
+ export declare function Field<O extends FieldOptions<DeclaredValue<O>> & ({
25
+ type: FieldType;
26
+ } | {
27
+ references: EntityGetter;
28
+ })>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
23
29
  /**
24
30
  * Declares the primary key, checked the same way as `@Field`.
25
31
  *
26
32
  * @example `@Id({ type: Number }) id?: number;`
27
33
  * @example `@Id({ type: 'uuid', onInsert: uuidv7 }) id?: string;`
28
34
  */
29
- export declare function Id<T extends FieldType>(opts: FieldOptions & {
30
- readonly type: T;
31
- }): MemberDecorator<TsTypeOf<T> | undefined>;
35
+ export declare function Id<O extends FieldOptions<DeclaredValue<O>> & {
36
+ type: FieldType;
37
+ }>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
32
38
  /**
33
39
  * `E` comes from the mandatory `entity` getter, so the context can insist the property really holds that
34
40
  * entity: `@ManyToOne({ entity: () => Other })` on a `Company` field stops compiling, and a to-many
@@ -1,4 +1,13 @@
1
1
  import { memberRegistrations } from './bag.js';
2
+ /**
3
+ * Declares a persisted field.
4
+ *
5
+ * `@Field({ type: String })` on a `number` property is a compile error rather than a silent TEXT column,
6
+ * which is what makes the now-mandatory `type` worth stating.
7
+ *
8
+ * @example `@Field({ type: String }) name?: string;`
9
+ * @example `@Field({ references: () => User }) userId?: string;` (where `User.id` is a `uuid`)
10
+ */
2
11
  export function Field(opts) {
3
12
  return (_value, context) => {
4
13
  memberRegistrations(context.metadata).fields[String(context.name)] = opts;
@@ -38,8 +38,11 @@ export function defineRelation(entity, key, opts) {
38
38
  throw new TypeError(`'${entity.name}.${key}' needs an 'entity' getter, e.g. '@ManyToOne({ entity: () => Company })'.`);
39
39
  }
40
40
  const meta = ensureMeta(entity);
41
- const relKey = key;
42
- meta.relations[relKey] = { ...meta.relations[relKey], ...opts };
41
+ // Registration writes the authored shape into a map declared as resolved: `getMeta` runs
42
+ // `fillRelations`, which settles `entity`, `references` and `mappedBy` or throws. Bridging the two
43
+ // shapes here is what lets every consumer read `RelationMeta` without asserting.
44
+ const relations = meta.relations;
45
+ relations[key] = { ...relations[key], ...opts };
43
46
  return meta;
44
47
  }
45
48
  export function defineHook(entity, methodName, event) {
@@ -182,122 +185,131 @@ export function getMeta(entity) {
182
185
  }
183
186
  function fillRelations(meta) {
184
187
  for (const relKey in meta.relations) {
188
+ // The authored view: `mappedBy` may still be the callback and `references` unset until this settles them.
185
189
  const relOpts = meta.relations[relKey];
186
190
  if (!relOpts)
187
191
  continue;
188
- if (relOpts.references) {
189
- // references were manually specified
190
- continue;
191
- }
192
+ const at = `'${meta.entity.name}.${relKey}'`;
192
193
  if (relOpts.mappedBy) {
193
- fillInverseSideRelations(relOpts);
194
- continue;
194
+ fillInverseSide(at, relOpts);
195
195
  }
196
- const relEntity = relOpts.entity();
197
- const relMeta = ensureMeta(relEntity);
198
- if (relOpts.cardinality === 'mm') {
199
- const idKey = meta.id;
200
- const relIdKey = relMeta.id;
201
- const idName = meta.fields[idKey]?.name ?? idKey;
202
- const relIdName = relMeta.fields[relIdKey]?.name ?? relIdKey;
203
- const source = lowerFirst(meta.name ?? '') + upperFirst(idName);
204
- const target = lowerFirst(relMeta.name ?? '') + upperFirst(relIdName);
205
- relOpts.references = [
206
- { local: source, foreign: idKey },
207
- { local: target, foreign: relIdKey },
208
- ];
196
+ else if (!relOpts.references) {
197
+ fillOwningSide(at, meta, relKey, relOpts);
209
198
  }
210
- else {
211
- const relIdKey = relMeta.id;
212
- const fkKey = `${relKey}Id`;
213
- relOpts.references = [{ local: fkKey, foreign: relIdKey }];
214
- // Auto-create the FK column when only the relation is declared (no explicit `@Field`).
215
- // Mirror an explicit `@Field({ references })` column: carry `references` and mark the type
216
- // as inferred so schema generation resolves the exact referenced primary-key type
217
- // (columnType, length, chained keys) via `resolveColumnCanonicalType`.
218
- if (!meta.fields[fkKey]) {
219
- const relatedIdField = relMeta.fields[relIdKey];
220
- meta.fields[fkKey] = {
221
- ...meta.fields[fkKey],
222
- name: fkKey,
223
- type: relatedIdField?.type ?? Number,
224
- references: relOpts.entity,
225
- typeFromReference: true,
226
- };
227
- }
199
+ if (!relOpts.references?.length) {
200
+ throw new TypeError(`${at} has no columns to join on.`);
228
201
  }
202
+ // Hand-written `references` land here too: naming the columns says which they are, not that they exist.
229
203
  if (relOpts.through) {
230
- const throughEntity = relOpts.through();
231
- const throughMeta = fillThroughRelations(throughEntity);
204
+ const junction = getMeta(relOpts.through());
232
205
  for (const { local } of relOpts.references) {
233
- if (throughMeta.fields[local])
206
+ if (junction.fields[local])
234
207
  continue;
235
- throw new TypeError(`'${meta.entity.name}.${relKey}' joins through '${throughEntity.name}', which has no '${local}' ` +
236
- "field. Declare it, or name the join columns yourself with 'references'.");
208
+ throw new TypeError(`${at} joins through '${junction.entity.name}', which has no '${local}' field. Declare it, or name ` +
209
+ "the join columns with 'references'.");
237
210
  }
238
211
  }
239
212
  }
213
+ fillForeignKeyRelations(meta);
240
214
  return meta;
241
215
  }
242
- function fillInverseSideRelations(relOpts) {
216
+ function fillOwningSide(at, meta, relKey, relOpts) {
217
+ const relMeta = ensureMeta(relOpts.entity());
218
+ const relIdKey = relMeta.id;
219
+ if (relOpts.through) {
220
+ // Both columns live on the junction, whatever the cardinality: `fillToManyThroughRelation`,
221
+ // `deleteRelations` and every dialect read them as junction columns.
222
+ relOpts.references = [
223
+ { local: junctionColumn(meta, meta.id), foreign: meta.id },
224
+ { local: junctionColumn(relMeta, relIdKey), foreign: relIdKey },
225
+ ];
226
+ return;
227
+ }
228
+ if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
229
+ throw new TypeError(`${at} is a to-many relation with no way to join: it needs 'mappedBy' (the field on the other side), ` +
230
+ "'through' (a junction entity), or 'references' (the columns).");
231
+ }
232
+ const fkKey = `${relKey}Id`;
233
+ relOpts.references = [{ local: fkKey, foreign: relIdKey }];
234
+ // `typeFromReference` so schema generation resolves the referenced primary key's exact type
235
+ // (columnType, length, chained keys) rather than trusting the fallback, as it does for an
236
+ // explicit `@Field({ references })`.
237
+ if (!meta.fields[fkKey]) {
238
+ meta.fields[fkKey] = {
239
+ name: fkKey,
240
+ type: relMeta.fields[relIdKey]?.type ?? Number,
241
+ references: relOpts.entity,
242
+ typeFromReference: true,
243
+ };
244
+ }
245
+ }
246
+ function fillInverseSide(at, relOpts) {
243
247
  const relEntity = relOpts.entity();
244
248
  const relMeta = getMeta(relEntity);
245
- const mappedBy = getMappedByRelationKey(relOpts);
249
+ const mappedBy = getMappedByKey(relOpts);
246
250
  relOpts.mappedBy = mappedBy;
251
+ if (relOpts.references)
252
+ return;
247
253
  if (relMeta.fields[mappedBy]) {
248
254
  relOpts.references = [{ local: relMeta.id, foreign: mappedBy }];
249
255
  return;
250
256
  }
251
- const mappedByRelation = relMeta.relations[mappedBy];
252
- if (!mappedByRelation)
253
- return;
254
- if (relOpts.cardinality === 'm1' || relOpts.cardinality === 'mm') {
255
- relOpts.references = (mappedByRelation.references ?? []).slice().reverse();
256
- relOpts.through = mappedByRelation.through;
257
- return;
257
+ // Authored view again: with each side mapped by the other, the target is still mid-resolution here and
258
+ // its own `references` are unset, which is what the second throw reports.
259
+ const owner = relMeta.relations[mappedBy];
260
+ if (!owner) {
261
+ throw new TypeError(`${at} is mapped by '${mappedBy}', which is neither a field nor a relation of '${relEntity.name}'.`);
258
262
  }
259
- relOpts.references = (mappedByRelation.references ?? []).map(({ local, foreign }) => ({
260
- local: foreign,
261
- foreign: local,
262
- }));
263
- }
264
- // `getMeta` rather than `ensureMeta`: a pivot that declares its sides as relations rather than as
265
- // `@Field({ references })` columns gets those foreign-key columns auto-created there, and the caller
266
- // checks its own derived join columns against them.
267
- function fillThroughRelations(entity) {
268
- const meta = getMeta(entity);
269
- meta.relations = getKeys(meta.fields).reduce((relations, key) => {
270
- const field = meta.fields[key];
271
- if (!field)
272
- return relations;
273
- if (field.references) {
274
- const relEntity = field.references();
275
- const relMeta = ensureMeta(relEntity);
276
- const relIdKey = relMeta.id;
277
- const relKey = key.slice(0, -relIdKey.length);
278
- const relOpts = {
279
- entity: field.references,
280
- cardinality: 'm1',
281
- references: [{ local: key, foreign: relIdKey }],
282
- };
283
- relations[relKey] = relOpts;
284
- }
285
- return relations;
286
- }, {});
287
- return meta;
263
+ if (!owner.references?.length) {
264
+ throw new TypeError(`${at} is mapped by '${relEntity.name}.${mappedBy}', an inverse side too, so neither owns the foreign key.`);
265
+ }
266
+ // Two different flips: a junction pair is `[thisSide, otherSide]`, so the array reverses; a plain
267
+ // foreign key is one pair whose ends swap.
268
+ relOpts.references =
269
+ relOpts.cardinality === 'm1' || relOpts.cardinality === 'mm'
270
+ ? owner.references.toReversed()
271
+ : owner.references.map(({ local, foreign }) => ({ local: foreign, foreign: local }));
272
+ relOpts.through = owner.through;
288
273
  }
289
- function getMappedByRelationKey(relOpts) {
290
- if (typeof relOpts.mappedBy === 'function') {
291
- const relEntity = relOpts.entity();
292
- const relMeta = ensureMeta(relEntity);
293
- const keyMap = getRelationKeyMap(relMeta);
294
- return relOpts.mappedBy(keyMap);
274
+ /**
275
+ * A field carrying `references` is a foreign key, and a foreign key is a many-to-one whether or not
276
+ * anyone declared the relation. Deriving it everywhere is what lets a junction be written as two plain
277
+ * columns: it needs the relations for `$populate` and for its DDL constraints, and it used to get them
278
+ * only because some *other* entity pointed `through` at it. Gaps only, so a declared relation keeps its
279
+ * own cardinality and `cascade`.
280
+ */
281
+ function fillForeignKeyRelations(meta) {
282
+ const joined = new Set(getKeys(meta.relations).flatMap((key) => meta.relations[key]?.references.map(({ local }) => local) ?? []));
283
+ for (const fieldKey of getKeys(meta.fields)) {
284
+ const references = meta.fields[fieldKey]?.references;
285
+ if (!references || joined.has(fieldKey))
286
+ continue;
287
+ const foreign = ensureMeta(references()).id;
288
+ // The relation takes the column's name minus the key it points at (`itemId` -> `item`); a column
289
+ // named anything else has no name to take, so it stays a plain foreign key.
290
+ const suffix = upperFirst(foreign);
291
+ if (!fieldKey.endsWith(suffix))
292
+ continue;
293
+ const relKey = fieldKey.slice(0, -suffix.length);
294
+ if (!relKey || meta.fields[relKey] || meta.relations[relKey])
295
+ continue;
296
+ meta.relations[relKey] = {
297
+ entity: references,
298
+ cardinality: 'm1',
299
+ references: [{ local: fieldKey, foreign }],
300
+ };
295
301
  }
296
- return relOpts.mappedBy;
297
302
  }
298
- function getRelationKeyMap(meta) {
299
- const keys = [...getKeys(meta.fields), ...getKeys(meta.relations)];
300
- return Object.fromEntries(keys.map((key) => [key, key]));
303
+ /** `<entityName><IdColumn>`, not the `<relationKey>Id` an owning to-one derives: a junction row has no relation key to borrow from. */
304
+ function junctionColumn(meta, idKey) {
305
+ return lowerFirst(meta.name ?? '') + upperFirst(meta.fields[idKey]?.name ?? idKey);
306
+ }
307
+ /** A callback only reads one property off the key map, and that property is the key, so one serves every entity. */
308
+ const RELATION_KEY_MAP = new Proxy({}, { get: (_, key) => key });
309
+ function getMappedByKey(relOpts) {
310
+ return typeof relOpts.mappedBy === 'function'
311
+ ? relOpts.mappedBy(RELATION_KEY_MAP)
312
+ : relOpts.mappedBy;
301
313
  }
302
314
  function getIdKey(meta) {
303
315
  const id = getKeys(meta.fields).find((key) => meta.fields[key]?.isId);
@@ -305,12 +317,10 @@ function getIdKey(meta) {
305
317
  }
306
318
  function extendMeta(target, source) {
307
319
  const sourceFields = { ...source.fields };
308
- const targetId = getIdKey(target);
309
- if (targetId) {
310
- const sourceId = getIdKey(source);
311
- if (sourceId) {
312
- delete sourceFields[sourceId];
313
- }
320
+ const sourceId = getIdKey(source);
321
+ // A subclass that declares its own primary key drops the parent's, so exactly one stays marked.
322
+ if (sourceId && getIdKey(target)) {
323
+ delete sourceFields[sourceId];
314
324
  }
315
325
  target.fields = { ...sourceFields, ...target.fields };
316
326
  target.relations = { ...source.relations, ...target.relations };
@@ -40,7 +40,7 @@ export class AbstractQuerier {
40
40
  }
41
41
  forEachRequestedRelation(meta, q.$populate, (relKey, relValue) => {
42
42
  const relOpts = meta.relations[relKey];
43
- if (!relOpts?.entity)
43
+ if (!relOpts)
44
44
  return;
45
45
  const relEntity = relOpts.entity();
46
46
  const parsed = parseRelationQueryValue(relValue);
@@ -205,7 +205,7 @@ export class AbstractQuerier {
205
205
  const localField = relOpts.references[0].local;
206
206
  const throughEntity = relOpts.through();
207
207
  const throughMeta = getMeta(throughEntity);
208
- const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key].references.some(({ local }) => local === relOpts.references[1].local));
208
+ const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === relOpts.references[1].local));
209
209
  const ids = payload.map((it) => it[meta.id]);
210
210
  const throughFounds = await this.findMany(throughEntity, {
211
211
  ...relationQuery,
@@ -119,10 +119,9 @@ export class AbstractSqlQuerier extends AbstractQuerier {
119
119
  }
120
120
  for (const key in meta.relations) {
121
121
  const rel = meta.relations[key];
122
- const relEntity = rel?.entity?.();
123
- if (!relEntity) {
122
+ if (!rel)
124
123
  continue;
125
- }
124
+ const relEntity = rel.entity();
126
125
  const value = row[key];
127
126
  if (Array.isArray(value)) {
128
127
  for (const it of value) {
@@ -152,7 +152,7 @@ export class SchemaASTBuilder {
152
152
  const relations = meta.relations;
153
153
  for (const key of Object.keys(relations)) {
154
154
  const relation = relations[key];
155
- if (!relation?.entity)
155
+ if (!relation)
156
156
  continue;
157
157
  const relatedEntity = relation.entity();
158
158
  const relatedMeta = getMeta(relatedEntity);
@@ -164,11 +164,10 @@ export class SchemaASTBuilder {
164
164
  // `references` describe how to join back (its own primary key against the owner's FK column) -
165
165
  // reading those as a foreign key emitted a reversed constraint (`User(id) REFERENCES
166
166
  // user_profile(creatorId)`), which SQLite rejects outright as a foreign key mismatch.
167
- const ownsForeignKey = relation.cardinality === 'm1' || (relation.cardinality === '11' && !!relation.references && !relation.mappedBy);
167
+ const ownsForeignKey = relation.cardinality === 'm1' || (relation.cardinality === '11' && !relation.mappedBy);
168
168
  if (ownsForeignKey) {
169
- const references = relation.references ?? [{ local: `${key}Id`, foreign: relatedMeta.id }];
170
- const localPropName = references[0].local;
171
- const foreignPropName = references[0].foreign;
169
+ const localPropName = relation.references[0].local;
170
+ const foreignPropName = relation.references[0].foreign;
172
171
  const localField = meta.fields[localPropName];
173
172
  if (!localField)
174
173
  continue;
@@ -206,9 +206,11 @@ export type FieldType = StringConstructor | NumberConstructor | BooleanConstruct
206
206
  */
207
207
  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;
208
208
  /**
209
- * Configurable options for a field
209
+ * Configurable options for a field, carrying `V`, the value the column holds: what a generator returns
210
+ * and what a default is has to be that value, checked the same way the declared `type` is. `Scalar` by
211
+ * by default, for the places that handle a field without knowing which one it is.
210
212
  */
211
- export type FieldOptions = {
213
+ export type FieldOptions<V = TsTypeOf<FieldType>> = {
212
214
  readonly name?: string;
213
215
  readonly isId?: true;
214
216
  readonly type?: FieldType;
@@ -238,8 +240,8 @@ export type FieldOptions = {
238
240
  readonly virtual?: QueryRaw;
239
241
  readonly updatable?: boolean;
240
242
  readonly eager?: boolean;
241
- readonly onInsert?: OnFieldCallback;
242
- readonly onUpdate?: OnFieldCallback;
243
+ readonly onInsert?: OnFieldCallback<V>;
244
+ readonly onUpdate?: OnFieldCallback<V>;
243
245
  /**
244
246
  * Marks this field as the soft-delete field. Its presence makes the entity "soft deletable":
245
247
  * a `delete` becomes an `UPDATE` that stamps this field instead of removing the row, and reads
@@ -251,7 +253,7 @@ export type FieldOptions = {
251
253
  * @example `@Field({ softDelete: true }) deletedAt?: Date;`
252
254
  * @example `@Field({ softDelete: () => Date.now() }) deletedAt?: number;`
253
255
  */
254
- readonly softDelete?: OnFieldCallback;
256
+ readonly softDelete?: true | OnFieldCallback<V>;
255
257
  /**
256
258
  * SQL column type for migrations. If not specified, inferred from TypeScript type.
257
259
  */
@@ -279,7 +281,7 @@ export type FieldOptions = {
279
281
  /**
280
282
  * Default value for the column
281
283
  */
282
- readonly defaultValue?: Scalar | Record<string, unknown>;
284
+ readonly defaultValue?: V | Record<string, unknown>;
283
285
  /**
284
286
  * Whether the column is auto-incrementing (for integer IDs).
285
287
  */
@@ -293,7 +295,7 @@ export type FieldOptions = {
293
295
  */
294
296
  readonly comment?: string;
295
297
  };
296
- export type OnFieldCallback = Scalar | QueryRaw | (() => Scalar | QueryRaw);
298
+ export type OnFieldCallback<V = TsTypeOf<FieldType>> = V | QueryRaw | (() => V | QueryRaw);
297
299
  /**
298
300
  * The TypeScript types a field may be declared as, given the `type` it registers: the inverse of
299
301
  * {@link TypeFor}.
@@ -316,9 +318,9 @@ export type TsTypeOf<T> = T extends StringConstructor ? string : T extends Numbe
316
318
  * `@Field({ references: () => Company })` would silently downgrade a `uuid` key to TEXT on every
317
319
  * column pointing at it.
318
320
  */
319
- export type FieldOptionsFor<V> = (FieldOptions & {
321
+ export type FieldOptionsFor<V> = (FieldOptions<NonNullable<V>> & {
320
322
  readonly type: TypeFor<V>;
321
- }) | (FieldOptions & {
323
+ }) | (FieldOptions<NonNullable<V>> & {
322
324
  readonly references: EntityGetter;
323
325
  readonly type?: TypeFor<V>;
324
326
  });
@@ -331,12 +333,12 @@ export type RelationTarget<V> = NonNullable<Unpacked<NonNullable<V>>>;
331
333
  * {@link RelationOptions} for a relation field declared as `V`, with `entity` required and pinned to
332
334
  * `V`'s own type, and the cardinality restricted to the ones that field shape can hold. Together those
333
335
  * reject `@ManyToOne({ entity: () => Other })` on a `Company` field, and any to-many cardinality on a
334
- * field that is not an array.
336
+ * field that is not an array. An array field additionally needs a {@link RelationJoin}.
335
337
  */
336
338
  export type RelationOptionsFor<V> = Omit<RelationOptions<RelationTarget<V>>, 'entity' | 'cardinality'> & {
337
339
  readonly entity: EntityGetter<RelationTarget<V>>;
338
340
  readonly cardinality: NonNullable<V> extends readonly unknown[] ? '1m' | 'mm' : '11' | 'm1';
339
- };
341
+ } & (NonNullable<V> extends readonly unknown[] ? RelationJoin<RelationTarget<V>> : unknown);
340
342
  /**
341
343
  * The method names of an entity, so hook registrations name a method that exists.
342
344
  */
@@ -357,18 +359,37 @@ export type RelationOptions<E = any> = {
357
359
  through?: EntityGetter;
358
360
  references?: RelationReferences;
359
361
  };
362
+ /**
363
+ * A relation once `getMeta` has resolved it: `entity` and `references` are settled and `mappedBy` is
364
+ * the key its callback named. Consumers read this shape rather than {@link RelationOptions}, so they
365
+ * need no assertions - `fillRelations` establishes the invariant once, and throws where it cannot.
366
+ */
367
+ export type RelationMeta<E = any> = Omit<RelationOptions<E>, 'entity' | 'mappedBy' | 'references'> & {
368
+ entity: EntityGetter<E>;
369
+ mappedBy?: Key<E>;
370
+ references: RelationReferences;
371
+ };
372
+ /** How a to-many owner reaches its children: a junction entity, or the join columns by name. */
373
+ type RelationOwnerJoin<E> = Required<Pick<RelationOptions<E>, 'through'>> | Required<Pick<RelationOptions<E>, 'references'>>;
374
+ /**
375
+ * Every way a to-many can say where its rows are. Required because nothing about the field implies it:
376
+ * without one of the three, resolution has no columns to join on and throws.
377
+ */
378
+ type RelationJoin<E> = RelationOwnerJoin<E> | Required<Pick<RelationOptions<E>, 'mappedBy'>>;
360
379
  type RelationOptionsOwner<E> = Pick<RelationOptions<E>, 'entity' | 'references' | 'cascade'>;
361
380
  type RelationOptionsInverseSide<E> = Required<Pick<RelationOptions<E>, 'entity' | 'mappedBy'>> & Pick<RelationOptions<E>, 'cascade'>;
362
- type RelationOptionsThroughOwner<E> = Required<Pick<RelationOptions<E>, 'entity'>> & Pick<RelationOptions<E>, 'through' | 'references' | 'cascade'>;
381
+ type RelationOptionsThroughOwner<E> = Required<Pick<RelationOptions<E>, 'entity'>> & Pick<RelationOptions<E>, 'cascade'> & RelationOwnerJoin<E>;
363
382
  /**
364
383
  * The key names of `E` as values, so `mappedBy` can be written as `(user) => user.company` instead of
365
384
  * a string literal and survive a rename.
366
385
  *
367
386
  * Mapping over `Key<E>` rather than `keyof E` is what makes the callback usable: a homomorphic
368
387
  * `[K in keyof E]` inherits the entity's optional modifiers, so `user.company` is
369
- * `'company' | undefined` and {@link RelationKeyMapper} rejects it - every callback needed a `!`. The
370
- * map is built from the target's metadata (see `getRelationKeyMap`), where every key is present and
371
- * every key is a string.
388
+ * `'company' | undefined` and {@link RelationKeyMapper} rejects it - every callback needed a `!`.
389
+ *
390
+ * At runtime a callback only ever reads one property off the map, so a single `Proxy` returning its
391
+ * own key stands in for every entity's: see `RELATION_KEY_MAP`. A key that names neither a field nor
392
+ * a relation of the target is rejected when the entity resolves.
372
393
  */
373
394
  export type RelationKeyMap<E> = {
374
395
  readonly [K in Key<E>]: K;
@@ -493,9 +514,9 @@ export type EntityMeta<E> = {
493
514
  [key: string]: FieldOptions | undefined;
494
515
  };
495
516
  relations: {
496
- [K in RelationKey<E>]?: RelationOptions;
517
+ [K in RelationKey<E>]?: RelationMeta;
497
518
  } & {
498
- [key: string]: RelationOptions | undefined;
519
+ [key: string]: RelationMeta | undefined;
499
520
  };
500
521
  /** Composite indexes defined via @Index decorator */
501
522
  indexes?: EntityIndexMeta[];
@@ -1,7 +1,8 @@
1
1
  import type { QueryContext, QueryDialect } from './dialect.js';
2
2
  import type { Scalar } from './utility.js';
3
3
  /**
4
- * options for the `raw` function.
4
+ * What may be passed towards a `raw` callback. Every key is optional here because the callers along the
5
+ * way fill them in progressively; what reaches the callback is the complete set - see {@link QueryRawFn}.
5
6
  */
6
7
  export type QueryRawFnOptions = {
7
8
  /**
@@ -22,9 +23,15 @@ export type QueryRawFnOptions = {
22
23
  ctx?: QueryContext;
23
24
  };
24
25
  /**
25
- * a `raw` function
26
+ * A `raw` callback: write into `ctx`, or return a string or number to have it appended. Anything else
27
+ * it returns is ignored, which is why the return type is `unknown` rather than `void | Scalar` - the
28
+ * latter rejected `({ ctx }) => ctx.append(...)`, the form every virtual field is written in, because
29
+ * TypeScript's "returning a value where void is expected" allowance does not apply to a union.
30
+ *
31
+ * `Required`, and the parameter not optional, because the one place that calls it (`getRawValue`)
32
+ * passes all four every time.
26
33
  */
27
- export type QueryRawFn = (opts?: QueryRawFnOptions) => void | Scalar;
34
+ export type QueryRawFn = (opts: Required<QueryRawFnOptions>) => unknown;
28
35
  export declare const RAW_VALUE: unique symbol;
29
36
  export declare const RAW_ALIAS: unique symbol;
30
37
  export declare class QueryRaw {
@@ -6,8 +6,14 @@ export type MongoId = {
6
6
  /**
7
7
  * Every value type storable in an entity column. Superset of {@link QueryComparableScalar}
8
8
  * and {@link PrimaryKey}.
9
+ *
10
+ * `Uint8Array` rather than `Buffer`, which every `Buffer` still satisfies: naming an ambient Node
11
+ * global here made the whole key-checking layer depend on `@types/node` being in scope. Without it
12
+ * `Buffer` resolves to nothing, this union collapses to `any`, and `FieldKey` - the basis of
13
+ * `$select`, `$where`, `$sort`, `@Index` and `defineEntity({ fields })` - silently stops checking
14
+ * anything. A browser or edge project would have got no type safety and no error saying so.
9
15
  */
10
- export type Scalar = string | number | boolean | bigint | Date | RegExp | Buffer | MongoId;
16
+ export type Scalar = string | number | boolean | bigint | Date | RegExp | Uint8Array | MongoId;
11
17
  /**
12
18
  * Scalar types with a meaningful ordering, accepted by `$lt`/`$lte`/`$gt`/`$gte`/`$between`.
13
19
  */
@@ -14,12 +14,20 @@ export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: E, call
14
14
  * chunk-size estimate inspects the raw payload).
15
15
  */
16
16
  export declare function getInsertFieldKeys<E>(meta: EntityMeta<E>, payloads: E[]): FieldKey<E>[];
17
- export declare function getFieldCallbackValue(val: OnFieldCallback): QueryRaw | import("../type/utility.js").Scalar;
17
+ export declare function getFieldCallbackValue(val: OnFieldCallback): string | number | bigint | boolean | Date | QueryRaw | readonly {
18
+ readonly __json?: never;
19
+ }[] | readonly number[] | Uint8Array<ArrayBufferLike> | {
20
+ readonly __json?: never;
21
+ };
18
22
  /**
19
23
  * Resolves the value stamped on the soft-delete field when deleting a row.
20
24
  * `true` stamps the current timestamp (`new Date()`); any other marker is an {@link OnFieldCallback}.
21
25
  */
22
- export declare function getSoftDeleteValue(field: FieldOptions): QueryRaw | import("../type/utility.js").Scalar;
26
+ export declare function getSoftDeleteValue(field: FieldOptions): string | number | bigint | boolean | Date | QueryRaw | readonly {
27
+ readonly __json?: never;
28
+ }[] | readonly number[] | Uint8Array<ArrayBufferLike> | {
29
+ readonly __json?: never;
30
+ };
23
31
  export declare function fillOnFields<E>(meta: EntityMeta<E>, payload: E | E[], callbackKey: CallbackKey): E[];
24
32
  /**
25
33
  * The relation keys present in `payload` whose cascade configuration allows `action`. Only
@@ -10,8 +10,6 @@ export declare function isPopulatingRelations<E>(meta: EntityMeta<E>, populate?:
10
10
  export type RelationQuery<E extends object = object> = Query<E> & {
11
11
  $required?: boolean;
12
12
  };
13
- export declare function getRelationQueryValue<E>(relKey: RelationKey<E>, populate?: QueryPopulate<E>): unknown;
14
- export declare function isRelationQueryObject<E extends object = object>(value: unknown): value is RelationQuery<E>;
15
13
  export type ParsedRelationQuery<E extends object = object> = {
16
14
  query: RelationQuery<E>;
17
15
  required: boolean;
@@ -37,13 +37,8 @@ const RELATION_QUERY_ALLOWED_KEYS = new Set([
37
37
  ...RELATION_QUERY_NUMBER_KEYS,
38
38
  '$where',
39
39
  ]);
40
- export function getRelationQueryValue(relKey, populate) {
41
- return populate?.[relKey];
42
- }
43
- export function isRelationQueryObject(value) {
44
- if (!isRecord(value))
45
- return false;
46
- return isValidRelationQueryShape(value);
40
+ function isRelationQueryObject(value) {
41
+ return isRecord(value) && isValidRelationQueryShape(value);
47
42
  }
48
43
  export function parseRelationQueryValue(value) {
49
44
  if (isRelationQueryObject(value)) {
@@ -63,11 +58,11 @@ export function parseRelationQueryValue(value) {
63
58
  }
64
59
  /** Parses the relation payload for `relKey` */
65
60
  export function parseRelationAtKey(relKey, populate) {
66
- return parseRelationQueryValue(getRelationQueryValue(relKey, populate));
61
+ return parseRelationQueryValue(populate?.[relKey]);
67
62
  }
68
63
  export function forEachRequestedRelation(meta, populate, fn) {
69
64
  for (const relKey of getRelationRequestSummary(meta, populate).requestedKeys) {
70
- fn(relKey, getRelationQueryValue(relKey, populate));
65
+ fn(relKey, populate?.[relKey]);
71
66
  }
72
67
  }
73
68
  function isRecord(value) {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "Extremely fast, type-safe TypeScript ORM - one API for every database",
5
5
  "license": "MIT",
6
- "version": "0.24.1",
6
+ "version": "0.24.3",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -198,5 +198,5 @@
198
198
  "publishConfig": {
199
199
  "access": "public"
200
200
  },
201
- "gitHead": "7f11b72301d647853c7a2778daaa09af8f4d5695"
201
+ "gitHead": "4301613bcab77a626513197f1e815f972aa21bba"
202
202
  }