uql-orm 0.24.0 → 0.24.2

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
@@ -50,8 +50,8 @@ from the browser to the server. The same object runs on every supported database
50
50
 
51
51
  ## Why UQL?
52
52
 
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.3× faster than the runner-up on average, close to 4M ops/s on simple SELECTs.
54
- - **Light.** Zero dependencies, under 1 MB installed, every dialect included.
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 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.
@@ -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,114 +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
- fillThroughRelations(relOpts.through());
204
+ const junction = getMeta(relOpts.through());
205
+ for (const { local } of relOpts.references) {
206
+ if (junction.fields[local])
207
+ continue;
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'.");
210
+ }
231
211
  }
232
212
  }
213
+ fillForeignKeyRelations(meta);
233
214
  return meta;
234
215
  }
235
- 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) {
236
247
  const relEntity = relOpts.entity();
237
248
  const relMeta = getMeta(relEntity);
238
- const mappedBy = getMappedByRelationKey(relOpts);
249
+ const mappedBy = getMappedByKey(relOpts);
239
250
  relOpts.mappedBy = mappedBy;
251
+ if (relOpts.references)
252
+ return;
240
253
  if (relMeta.fields[mappedBy]) {
241
254
  relOpts.references = [{ local: relMeta.id, foreign: mappedBy }];
242
255
  return;
243
256
  }
244
- const mappedByRelation = relMeta.relations[mappedBy];
245
- if (!mappedByRelation)
246
- return;
247
- if (relOpts.cardinality === 'm1' || relOpts.cardinality === 'mm') {
248
- relOpts.references = (mappedByRelation.references ?? []).slice().reverse();
249
- relOpts.through = mappedByRelation.through;
250
- 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}'.`);
251
262
  }
252
- relOpts.references = (mappedByRelation.references ?? []).map(({ local, foreign }) => ({
253
- local: foreign,
254
- foreign: local,
255
- }));
256
- }
257
- function fillThroughRelations(entity) {
258
- const meta = ensureMeta(entity);
259
- meta.relations = getKeys(meta.fields).reduce((relations, key) => {
260
- const field = meta.fields[key];
261
- if (!field)
262
- return relations;
263
- if (field.references) {
264
- const relEntity = field.references();
265
- const relMeta = ensureMeta(relEntity);
266
- const relIdKey = relMeta.id;
267
- const relKey = key.slice(0, -relIdKey.length);
268
- const relOpts = {
269
- entity: field.references,
270
- cardinality: 'm1',
271
- references: [{ local: key, foreign: relIdKey }],
272
- };
273
- relations[relKey] = relOpts;
274
- }
275
- return relations;
276
- }, {});
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;
277
273
  }
278
- function getMappedByRelationKey(relOpts) {
279
- if (typeof relOpts.mappedBy === 'function') {
280
- const relEntity = relOpts.entity();
281
- const relMeta = ensureMeta(relEntity);
282
- const keyMap = getRelationKeyMap(relMeta);
283
- 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
+ };
284
301
  }
285
- return relOpts.mappedBy;
286
302
  }
287
- function getRelationKeyMap(meta) {
288
- const keys = [...getKeys(meta.fields), ...getKeys(meta.relations)];
289
- return keys.reduce((acc, key) => {
290
- acc[key] = key;
291
- return acc;
292
- }, {});
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;
293
313
  }
294
314
  function getIdKey(meta) {
295
315
  const id = getKeys(meta.fields).find((key) => meta.fields[key]?.isId);
@@ -297,12 +317,10 @@ function getIdKey(meta) {
297
317
  }
298
318
  function extendMeta(target, source) {
299
319
  const sourceFields = { ...source.fields };
300
- const targetId = getIdKey(target);
301
- if (targetId) {
302
- const sourceId = getIdKey(source);
303
- if (sourceId) {
304
- delete sourceFields[sourceId];
305
- }
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];
306
324
  }
307
325
  target.fields = { ...sourceFields, ...target.fields };
308
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;
@@ -331,12 +331,12 @@ export type RelationTarget<V> = NonNullable<Unpacked<NonNullable<V>>>;
331
331
  * {@link RelationOptions} for a relation field declared as `V`, with `entity` required and pinned to
332
332
  * `V`'s own type, and the cardinality restricted to the ones that field shape can hold. Together those
333
333
  * reject `@ManyToOne({ entity: () => Other })` on a `Company` field, and any to-many cardinality on a
334
- * field that is not an array.
334
+ * field that is not an array. An array field additionally needs a {@link RelationJoin}.
335
335
  */
336
336
  export type RelationOptionsFor<V> = Omit<RelationOptions<RelationTarget<V>>, 'entity' | 'cardinality'> & {
337
337
  readonly entity: EntityGetter<RelationTarget<V>>;
338
338
  readonly cardinality: NonNullable<V> extends readonly unknown[] ? '1m' | 'mm' : '11' | 'm1';
339
- };
339
+ } & (NonNullable<V> extends readonly unknown[] ? RelationJoin<RelationTarget<V>> : unknown);
340
340
  /**
341
341
  * The method names of an entity, so hook registrations name a method that exists.
342
342
  */
@@ -350,16 +350,47 @@ export type RelationOptions<E = any> = {
350
350
  cardinality: RelationCardinality;
351
351
  readonly cascade?: boolean | CascadeType;
352
352
  mappedBy?: RelationMappedBy<E>;
353
- through?: EntityGetter<RelationValue<E>>;
353
+ /**
354
+ * The pivot entity of a many-to-many. Unconstrained by `E`: a pivot holds foreign keys to both
355
+ * sides and is not a relation value of the target, so nothing about it is derivable from `E`.
356
+ */
357
+ through?: EntityGetter;
354
358
  references?: RelationReferences;
355
359
  };
360
+ /**
361
+ * A relation once `getMeta` has resolved it: `entity` and `references` are settled and `mappedBy` is
362
+ * the key its callback named. Consumers read this shape rather than {@link RelationOptions}, so they
363
+ * need no assertions - `fillRelations` establishes the invariant once, and throws where it cannot.
364
+ */
365
+ export type RelationMeta<E = any> = Omit<RelationOptions<E>, 'entity' | 'mappedBy' | 'references'> & {
366
+ entity: EntityGetter<E>;
367
+ mappedBy?: Key<E>;
368
+ references: RelationReferences;
369
+ };
370
+ /** How a to-many owner reaches its children: a junction entity, or the join columns by name. */
371
+ type RelationOwnerJoin<E> = Required<Pick<RelationOptions<E>, 'through'>> | Required<Pick<RelationOptions<E>, 'references'>>;
372
+ /**
373
+ * Every way a to-many can say where its rows are. Required because nothing about the field implies it:
374
+ * without one of the three, resolution has no columns to join on and throws.
375
+ */
376
+ type RelationJoin<E> = RelationOwnerJoin<E> | Required<Pick<RelationOptions<E>, 'mappedBy'>>;
356
377
  type RelationOptionsOwner<E> = Pick<RelationOptions<E>, 'entity' | 'references' | 'cascade'>;
357
378
  type RelationOptionsInverseSide<E> = Required<Pick<RelationOptions<E>, 'entity' | 'mappedBy'>> & Pick<RelationOptions<E>, 'cascade'>;
358
- type RelationOptionsThroughOwner<E> = Required<Pick<RelationOptions<E>, 'entity'>> & Pick<RelationOptions<E>, 'through' | 'references' | 'cascade'>;
379
+ type RelationOptionsThroughOwner<E> = Required<Pick<RelationOptions<E>, 'entity'>> & Pick<RelationOptions<E>, 'cascade'> & RelationOwnerJoin<E>;
380
+ /**
381
+ * The key names of `E` as values, so `mappedBy` can be written as `(user) => user.company` instead of
382
+ * a string literal and survive a rename.
383
+ *
384
+ * Mapping over `Key<E>` rather than `keyof E` is what makes the callback usable: a homomorphic
385
+ * `[K in keyof E]` inherits the entity's optional modifiers, so `user.company` is
386
+ * `'company' | undefined` and {@link RelationKeyMapper} rejects it - every callback needed a `!`.
387
+ *
388
+ * At runtime a callback only ever reads one property off the map, so a single `Proxy` returning its
389
+ * own key stands in for every entity's: see `RELATION_KEY_MAP`. A key that names neither a field nor
390
+ * a relation of the target is rejected when the entity resolves.
391
+ */
359
392
  export type RelationKeyMap<E> = {
360
- readonly [K in keyof E]: K;
361
- } & {
362
- readonly [key: string]: string;
393
+ readonly [K in Key<E>]: K;
363
394
  };
364
395
  export type RelationKeyMapper<E> = (keyMap: RelationKeyMap<E>) => Key<E>;
365
396
  export type RelationReferences = {
@@ -481,9 +512,9 @@ export type EntityMeta<E> = {
481
512
  [key: string]: FieldOptions | undefined;
482
513
  };
483
514
  relations: {
484
- [K in RelationKey<E>]?: RelationOptions;
515
+ [K in RelationKey<E>]?: RelationMeta;
485
516
  } & {
486
- [key: string]: RelationOptions | undefined;
517
+ [key: string]: RelationMeta | undefined;
487
518
  };
488
519
  /** Composite indexes defined via @Index decorator */
489
520
  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
  */
@@ -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.0",
6
+ "version": "0.24.2",
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": "d9499a4b8c5ee866950b397fb87f55643d86fc39"
201
+ "gitHead": "419ba75dc2e856f6b8030fa0a6fed3e5c7940de1"
202
202
  }