uql-orm 0.24.5 → 0.24.6

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.
@@ -79,7 +79,6 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
79
79
  protected renderWhere<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
80
80
  compare<E>(ctx: QueryContext, entity: Type<E>, key: string, val: unknown, opts?: QueryComparisonOptions): void;
81
81
  protected compareLogicalOperator<E>(ctx: QueryContext, entity: Type<E>, key: '$and' | '$or' | '$not' | '$nor', val: QueryWhereArray<E>, opts: QueryComparisonOptions): void;
82
- /** Simple comparison operators: `getComparisonKey → op → addValue`. */
83
82
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
84
83
  private readonly escapedColumns;
85
84
  private static readonly NEGATE_OP_MAP;
@@ -100,23 +100,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
100
100
  const meta = getMeta(entity);
101
101
  const prefix = opts.prefix ? opts.prefix + '.' : '';
102
102
  const escapedPrefix = this.escapeId(opts.prefix, true, true);
103
- let selectArr;
104
- if (select) {
105
- if (Array.isArray(select)) {
106
- // Raw SQL projections passed as QueryRaw[]
107
- selectArr = select;
108
- }
109
- else {
110
- selectArr = normalizeScalarFieldSelection(meta, asSelectMap(select), exclude);
111
- }
112
- const id = meta.id;
113
- if (id && opts.prefix && !selectArr.includes(id)) {
114
- selectArr = [id, ...selectArr];
115
- }
116
- }
117
- else {
118
- selectArr = normalizeScalarFieldSelection(meta, undefined, exclude);
119
- }
103
+ const scalars = Array.isArray(select)
104
+ ? select // raw SQL projections passed as QueryRaw[]
105
+ : normalizeScalarFieldSelection(meta, asSelectMap(select), exclude);
106
+ // A prefix means relations are in play: rows arrive keyed by the id and `fillToManyRelations`
107
+ // groups children by it, so it outlives any subtraction - `$exclude` or falsy `$select` alike.
108
+ const id = meta.id;
109
+ const selectArr = id && opts.prefix && !scalars.includes(id) ? [id, ...scalars] : scalars;
120
110
  if (!selectArr.length) {
121
111
  ctx.append(escapedPrefix + '*');
122
112
  return;
@@ -387,7 +377,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
387
377
  ctx.append(')');
388
378
  }
389
379
  }
390
- /** Simple comparison operators: `getComparisonKey → op → addValue`. */
391
380
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
392
381
  escapedColumns = new WeakMap();
393
382
  static NEGATE_OP_MAP = new Map([
@@ -103,6 +103,13 @@ export declare class MongoDialect extends AbstractDialect {
103
103
  */
104
104
  private pathOf;
105
105
  aggregationPipeline<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
106
+ /**
107
+ * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
108
+ * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
109
+ * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
110
+ * empty, and is why the pipeline emitted no projection at all and returned every column.
111
+ */
112
+ pipelineProjection<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>): Record<string, 0 | 1> | undefined;
106
113
  /**
107
114
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
108
115
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
@@ -414,8 +414,33 @@ export class MongoDialect extends AbstractDialect {
414
414
  // does after an INNER JOIN. Otherwise paging first is equivalent and spares the lookups.
415
415
  const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
416
416
  pipeline.push(...(dropsParents ? [...relStages, ...pager] : [...pager, ...relStages]));
417
+ const projection = this.pipelineProjection(entity, q, relationSummary);
418
+ if (projection) {
419
+ pipeline.push({ $project: projection });
420
+ }
417
421
  return pipeline;
418
422
  }
423
+ /**
424
+ * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
425
+ * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
426
+ * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
427
+ * empty, and is why the pipeline emitted no projection at all and returned every column.
428
+ */
429
+ pipelineProjection(entity, q, relationSummary) {
430
+ if (!q.$select && !q.$exclude) {
431
+ return undefined;
432
+ }
433
+ const projection = this.select(entity, q.$select, q.$exclude);
434
+ const summary = relationSummary ?? getRelationRequestSummary(getMeta(entity), q.$populate);
435
+ for (const relKey of summary.joinableKeys) {
436
+ projection[relKey] = 1;
437
+ }
438
+ // Only ever undoes an exclusion: a relation cannot be filled onto a parent with no key.
439
+ if (summary.requestedKeys.length && projection[MongoDialect.ID_KEY] === 0) {
440
+ delete projection[MongoDialect.ID_KEY];
441
+ }
442
+ return projection;
443
+ }
419
444
  /**
420
445
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
421
446
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
@@ -439,11 +464,22 @@ export class MongoDialect extends AbstractDialect {
439
464
  // filters (in particular `security: true` ones) must apply even to a bare
440
465
  // `$populate: { rel: true }`, exactly like the SQL dialects' JOIN ON-clause filters.
441
466
  const relationFilter = this.where(relEntity, relQuery.$where ?? {}, opts);
467
+ // The relation's own projection runs inside the lookup, where its keys resolve against the
468
+ // related entity. Left out, `$populate: { rel: { $select } }` returned all of `rel`'s columns.
469
+ const relationProjection = this.pipelineProjection(relEntity, relQuery);
470
+ // MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
471
+ // how a joined document keeps its own id - as it does on the SQL dialects, and as a nested
472
+ // to-many fill needs.
473
+ delete relationProjection?.[MongoDialect.ID_KEY];
474
+ const lookupPipeline = [
475
+ ...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
476
+ ...(relationProjection ? [{ $project: relationProjection }] : []),
477
+ ];
442
478
  pipeline.push({
443
479
  $lookup: {
444
480
  from: this.resolveTableName(relEntity, relMeta),
445
481
  ...this.joinKeys(meta, relMeta, relOpts),
446
- ...(hasKeys(relationFilter) ? { pipeline: [{ $match: relationFilter }] } : {}),
482
+ ...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
447
483
  as: relKey,
448
484
  },
449
485
  });
@@ -109,14 +109,19 @@ export class MongodbQuerier extends AbstractQuerier {
109
109
  const meta = getMeta(entity);
110
110
  const relationSummary = getRelationRequestSummary(meta, q.$populate);
111
111
  const scoreAlias = vectorSort.vectorSearch.$project;
112
- // With relations, the score is captured with `$addFields` before the lookups and no scalar
113
- // `$project` is emitted: projecting here would drop both the join keys and the joined documents,
114
- // which is why `$populate` used to come back empty under a vector sort.
112
+ // With relations the score is captured with `$addFields` before the lookups, and the scalar
113
+ // projection waits until after them: projecting any earlier drops the join keys and the joined
114
+ // documents, which is why `$populate` used to come back empty under a vector sort.
115
115
  if (relationSummary.requestedKeys.length) {
116
116
  if (scoreAlias) {
117
117
  pipeline.push({ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } });
118
118
  }
119
119
  pipeline.push(...this.dialect.relationStages(entity, q, relationSummary));
120
+ const projection = this.dialect.pipelineProjection(entity, q, relationSummary);
121
+ if (projection) {
122
+ // `$addFields` already made the score a real field, so it projects like any other.
123
+ pipeline.push({ $project: scoreAlias ? { ...projection, [scoreAlias]: 1 } : projection });
124
+ }
120
125
  }
121
126
  else if (scoreAlias) {
122
127
  const select = q.$select || q.$exclude ? this.buildScalarProjection(entity, q) : {};
@@ -6,9 +6,6 @@ import { LoggerWrapper } from '../util/index.js';
6
6
  */
7
7
  export declare abstract class AbstractQuerier implements Querier {
8
8
  readonly extra?: ExtraOptions | undefined;
9
- private static readonly emittedWarnings;
10
- /** Clears process-wide warning deduplication. For tests only. */
11
- static clearEmittedWarningsForTests(): void;
12
9
  /**
13
10
  * Internal promise used to queue database operations.
14
11
  * This ensures that each operation is executed serially, preventing race conditions
@@ -7,11 +7,6 @@ import { enrichError } from './queryError.js';
7
7
  */
8
8
  export class AbstractQuerier {
9
9
  extra;
10
- static emittedWarnings = new Set();
11
- /** Clears process-wide warning deduplication. For tests only. */
12
- static clearEmittedWarningsForTests() {
13
- AbstractQuerier.emittedWarnings.clear();
14
- }
15
10
  /**
16
11
  * Internal promise used to queue database operations.
17
12
  * This ensures that each operation is executed serially, preventing race conditions
@@ -207,8 +202,13 @@ export class AbstractQuerier {
207
202
  const throughMeta = getMeta(throughEntity);
208
203
  const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === relOpts.references[1].local));
209
204
  const ids = payload.map((it) => it[meta.id]);
205
+ // A relation query names the target's columns, not the join table's, so the projection and the
206
+ // filter belong on the populate below - resolved there against the entity that has them. Spread
207
+ // onto the through query they asked `ItemTag` for `Tag`'s columns: `$where`/`$sort` failed with
208
+ // "no such column", and `$exclude` collided with the `$select` this builds.
209
+ const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
210
210
  const throughFounds = await this.findMany(throughEntity, {
211
- ...relationQuery,
211
+ ...throughQuery,
212
212
  $select: {
213
213
  [localField]: true,
214
214
  },
@@ -219,7 +219,6 @@ export class AbstractQuerier {
219
219
  },
220
220
  },
221
221
  $where: {
222
- ...relationQuery.$where,
223
222
  [localField]: ids,
224
223
  },
225
224
  });
@@ -231,12 +230,14 @@ export class AbstractQuerier {
231
230
  }
232
231
  async fillToManyOneToMany(payload, meta, relKey, relOpts, relationQuery, relEntity) {
233
232
  const foreignField = relOpts.references[0].foreign;
234
- // Ensure the FK column is selected so putChildrenInParents can group by it; skips the raw-array
235
- // `$select` form (nothing to augment). Mutates the same object asSelectMap returns.
233
+ // The FK is what putChildrenInParents groups on, so it outlives the relation's projection
234
+ // either way: added to a whitelisting `$select` (the raw-array form has nothing to augment),
235
+ // dropped from a subtractive `$exclude`. `relationQuery` is already a clone.
236
236
  const select = asSelectMap(relationQuery.$select);
237
237
  if (select && !select[foreignField]) {
238
238
  select[foreignField] = true;
239
239
  }
240
+ delete relationQuery.$exclude?.[foreignField];
240
241
  const ids = payload.map((it) => it[meta.id]);
241
242
  relationQuery.$where = { ...relationQuery.$where, [foreignField]: ids };
242
243
  const founds = await this.findMany(relEntity, relationQuery);
@@ -172,6 +172,8 @@ export type Query<E> = {
172
172
  $populate?: QueryPopulate<E>;
173
173
  /**
174
174
  * field exclusion - `{ name: true }` blacklists fields. Mutually exclusive with positive `$select`.
175
+ * Keys a relation is assembled from (a joined row's primary key, a to-many's foreign key) are kept
176
+ * regardless, since subtracting them would leave the relation unfilled.
175
177
  */
176
178
  $exclude?: QueryExclude<E>;
177
179
  /**
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.5",
6
+ "version": "0.24.6",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -134,9 +134,9 @@
134
134
  "@tursodatabase/serverless": "^1.4.0",
135
135
  "@types/better-sqlite3": "^9.6.0",
136
136
  "@types/express": "^5.0.6",
137
- "@types/pg": "^8.20.3",
137
+ "@types/pg": "^8.20.4",
138
138
  "@types/ws": "^8.18.1",
139
- "better-sqlite3": "^13.0.2",
139
+ "better-sqlite3": "^13.0.3",
140
140
  "express": "^5.2.1",
141
141
  "mariadb": "^3.5.3",
142
142
  "mongodb": "^7.5.0",
@@ -145,7 +145,7 @@
145
145
  "pg-query-stream": "^4.16.0",
146
146
  "rxjs": "^7.8.2",
147
147
  "sqlite-vec": "^0.1.9",
148
- "ws": "^8.21.1"
148
+ "ws": "^8.21.2"
149
149
  },
150
150
  "author": "Roger Padilla",
151
151
  "repository": {
@@ -198,5 +198,5 @@
198
198
  "publishConfig": {
199
199
  "access": "public"
200
200
  },
201
- "gitHead": "1c763ccfdb4431afa49716b99512d9df4e0ad4b7"
201
+ "gitHead": "2610226954cdf152705dc47ca130f6908a91825d"
202
202
  }