• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rogerpadilla / uql / 28907893047

08 Jul 2026 12:15AM UTC coverage: 94.906% (-0.002%) from 94.908%
28907893047

push

github

rogerpadilla
chore(package): update gitHead to latest commit hash

3228 of 3575 branches covered (90.29%)

Branch coverage included in aggregate %.

5603 of 5730 relevant lines covered (97.78%)

385.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.76
/packages/uql-orm/src/mongo/mongoDialect.ts
1
import { type Document, type Filter, ObjectId, type Sort } from 'mongodb';
2
import type { DialectOptions } from '../dialect/abstractDialect.js';
3
import { AbstractDialect } from '../dialect/abstractDialect.js';
4
import { getMeta } from '../entity/index.js';
5
import type { IndexType } from '../schema/types.js';
6
import type {
7
  DialectFeatures,
8
  EntityMeta,
9
  FieldValue,
10
  Query,
11
  QueryAggregate,
12
  QueryExclude,
13
  QueryOptions,
14
  QuerySelect,
15
  QuerySortMap,
16
  QueryVectorSearch,
17
  QueryWhere,
18
  RelationKey,
19
  Type,
20
} from '../type/index.js';
21
import {
22
  applyFilters,
23
  buildQueryWhereAsMap,
24
  buildSortMap,
25
  type CallbackKey,
26
  fillOnFields,
27
  filterFieldKeys,
28
  getKeys,
29
  getRelationRequestSummary,
30
  hasKeys,
31
  isVectorSearch,
32
  normalizeScalarFieldSelection,
33
  parseGroupMap,
34
  type RelationRequestSummary,
35
} from '../util/index.js';
36

37
export class MongoDialect extends AbstractDialect {
38
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoSchemaGenerator}. */
39
  static readonly defaultDialectFeatures: DialectFeatures = {
12✔
40
    explicitJsonCast: false,
41
    nativeArrays: false,
42
    supportsJsonb: false,
43
    returning: false,
44
    ifNotExists: false,
45
    indexIfNotExists: false,
46
    dropTableCascade: false,
47
    renameColumn: false,
48
    foreignKeyAlter: false,
49
    columnComment: false,
50
    vectorIndexStyle: 'create',
51
    vectorSupportsLength: false,
52
    supportsTimestamptz: false,
53
    defaultStringAsText: false,
54
  };
55

56
  readonly dialectName = 'mongodb';
69✔
57

58
  override readonly insertIdStrategy = 'last';
69✔
59

60
  constructor(options: DialectOptions = {}) {
69✔
61
    super(MongoDialect.defaultDialectFeatures, options);
69✔
62
  }
63

64
  private static readonly ID_KEY = '_id';
12✔
65
  private static readonly VECTOR_INDEX_TYPES = new Set<IndexType>(['vectorSearch', 'hnsw', 'ivfflat', 'vector']);
12✔
66

67
  private static readonly AGGREGATE_OP_MAP: Record<string, string> = {
12✔
68
    $count: '$sum',
69
    $sum: '$sum',
70
    $avg: '$avg',
71
    $min: '$min',
72
    $max: '$max',
73
  };
74

75
  public where<E extends Document>(entity: Type<E>, where: QueryWhere<E> = {}, opts: QueryOptions = {}): Filter<E> {
2,110✔
76
    const meta = getMeta(entity);
1,055✔
77
    // Apply this entity's filters once, at the scope entry point; recursion uses `renderFilter`.
78
    const whereMap = applyFilters(meta, buildQueryWhereAsMap(meta, where), opts);
1,055✔
79
    return this.renderFilter(entity, whereMap);
1,055✔
80
  }
81

82
  /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
83
  private renderFilter<E extends Document>(entity: Type<E>, where: QueryWhere<E> = {}): Filter<E> {
1,060✔
84
    const meta = getMeta(entity);
1,060✔
85
    const whereMap = buildQueryWhereAsMap(meta, where);
1,060✔
86

87
    const filter: Record<string, unknown> = {};
1,060✔
88
    for (const [rawKey, rawVal] of Object.entries(whereMap)) {
1,060✔
89
      let key = rawKey;
266✔
90
      let val: unknown = rawVal;
266✔
91
      if (key === '$and' || key === '$or') {
266✔
92
        filter[key] = (val as QueryWhere<E>[]).map((filterIt) => this.renderFilter(entity, filterIt));
5✔
93
      } else {
94
        const field = meta.fields[key];
263✔
95
        if (key === MongoDialect.ID_KEY || key === meta.id) {
263✔
96
          key = MongoDialect.ID_KEY;
57✔
97
          val = this.getIdValue(val as IdValue);
57✔
98
        } else if (field) {
206!
99
          key = this.resolveColumnName(key, field);
206✔
100
        }
101
        if (
263✔
102
          val &&
617✔
103
          typeof val === 'object' &&
104
          !Array.isArray(val) &&
105
          this.hasOperatorKeys(val as Record<string, unknown>)
106
        ) {
107
          val = this.transformOperators(val as Record<string, unknown>);
26✔
108
        } else if (Array.isArray(val)) {
237✔
109
          val = { $in: val };
34✔
110
        }
111
        filter[key] = val;
263✔
112
      }
113
    }
114
    return filter as Filter<E>;
1,060✔
115
  }
116

117
  /**
118
   * Check if an object has operator keys (keys starting with $).
119
   */
120
  private hasOperatorKeys(obj: Record<string, unknown>): boolean {
121
    return Object.keys(obj).some((key) => key.startsWith('$'));
89✔
122
  }
123

124
  protected mapTableNameRow(row: { table_name: string }): string {
125
    return row.table_name;
1✔
126
  }
127

128
  /** String operators → { pattern: (v) => regex, caseInsensitive } */
129
  private static readonly REGEX_OP_MAP: Record<string, { wrap: (v: unknown) => string; ci: boolean }> = {
12✔
130
    $startsWith: { wrap: (v) => `^${v}`, ci: false },
1✔
131
    $istartsWith: { wrap: (v) => `^${v}`, ci: true },
1✔
132
    $endsWith: { wrap: (v) => `${v}$`, ci: false },
1✔
133
    $iendsWith: { wrap: (v) => `${v}$`, ci: true },
1✔
134
    $includes: { wrap: (v) => String(v), ci: false },
2✔
135
    $iincludes: { wrap: (v) => String(v), ci: true },
2✔
136
    $like: { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false },
1✔
137
    $ilike: { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true },
1✔
138
  };
139

140
  /** MongoDB native operators - pass through as-is. */
141
  private static readonly NATIVE_OPS = new Set([
12✔
142
    '$all',
143
    '$size',
144
    '$elemMatch',
145
    '$eq',
146
    '$ne',
147
    '$lt',
148
    '$lte',
149
    '$gt',
150
    '$gte',
151
    '$in',
152
    '$nin',
153
    '$regex',
154
    '$not',
155
  ]);
156

157
  /**
158
   * Transform UQL operators to MongoDB operators.
159
   */
160
  private transformOperators(ops: Record<string, unknown>): Record<string, unknown> {
161
    const result: Record<string, unknown> = {};
28✔
162
    for (const [op, val] of Object.entries(ops)) {
28✔
163
      // Native MongoDB operators - pass through directly
164
      if (MongoDialect.NATIVE_OPS.has(op)) {
28✔
165
        result[op] = val;
11✔
166
        continue;
11✔
167
      }
168
      // String/pattern → regex operators (8 variants including $like/$ilike)
169
      const regexEntry = MongoDialect.REGEX_OP_MAP[op];
17✔
170
      if (regexEntry) {
17✔
171
        result['$regex'] = regexEntry.wrap(val);
10✔
172
        if (regexEntry.ci) result['$options'] = 'i';
10✔
173
        continue;
10✔
174
      }
175
      // Structural transforms
176
      switch (op) {
7✔
177
        case '$between': {
178
          const [min, max] = val as [unknown, unknown];
1✔
179
          result['$gte'] = min;
1✔
180
          result['$lte'] = max;
1✔
181
          break;
1✔
182
        }
183
        case '$isNull':
184
          result[val ? '$eq' : '$ne'] = null;
2✔
185
          break;
2✔
186
        case '$isNotNull':
187
          result[val ? '$ne' : '$eq'] = null;
2✔
188
          break;
2✔
189
        case '$text':
190
          result['$text'] = { $search: val };
1✔
191
          break;
1✔
192
        default:
193
          result[op] = val;
1✔
194
          break;
1✔
195
      }
196
    }
197
    return result;
28✔
198
  }
199

200
  public select<E extends Document>(
201
    entity: Type<E>,
202
    select?: QuerySelect<E>,
203
    exclude?: QueryExclude<E>,
204
  ): Record<string, 1> {
205
    const meta = getMeta(entity);
46✔
206
    if (!select && !exclude) {
46✔
207
      return {};
22✔
208
    }
209
    const selectedFields = normalizeScalarFieldSelection(meta, select, exclude);
24✔
210
    return selectedFields.reduce<Record<string, 1>>((acc, key) => {
24✔
211
      acc[key] = 1;
44✔
212
      return acc;
44✔
213
    }, {});
214
  }
215

216
  public sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>): Sort {
217
    const raw = buildSortMap(sort);
88✔
218
    const normalized: Record<string, 1 | -1> = {};
88✔
219
    for (const [key, dir] of Object.entries(raw)) {
88✔
220
      normalized[key] = dir === 'desc' || dir === -1 ? -1 : 1;
19✔
221
    }
222
    return normalized as Sort;
88✔
223
  }
224

225
  public aggregationPipeline<E extends Document>(
226
    entity: Type<E>,
227
    q: Query<E>,
228
    relationSummary?: RelationRequestSummary<E>,
229
    opts?: QueryOptions,
230
  ): MongoAggregationPipelineEntry<E>[] {
231
    const meta = getMeta(entity);
26✔
232
    const where = this.where(entity, q.$where, opts);
26✔
233
    const sort = this.sort(entity, q.$sort);
26✔
234
    const firstPipelineEntry: MongoAggregationPipelineEntry<E> = {};
26✔
235

236
    if (hasKeys(where)) {
26✔
237
      firstPipelineEntry.$match = where;
17✔
238
    }
239
    if (hasKeys(sort)) {
26✔
240
      firstPipelineEntry.$sort = sort;
3✔
241
    }
242

243
    const pipeline: MongoAggregationPipelineEntry<E>[] = [];
26✔
244

245
    if (hasKeys(firstPipelineEntry)) {
26✔
246
      pipeline.push(firstPipelineEntry);
18✔
247
    }
248

249
    const relKeys = (relationSummary ?? getRelationRequestSummary(meta, q.$populate)).joinableKeys;
26✔
250

251
    for (const relKey of relKeys) {
26✔
252
      const relOpts = meta.relations[relKey];
15✔
253
      if (!relOpts) continue;
15!
254

255
      if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
15!
256
        // '1m' and 'mm' should be resolved in a higher layer because they will need multiple queries
257
        continue;
×
258
      }
259

260
      const relEntity = relOpts.entity!();
15✔
261
      const relMeta = getMeta(relEntity);
15✔
262

263
      if (relOpts.cardinality === 'm1') {
15✔
264
        const localField = meta.fields[relOpts.references![0].local];
9✔
265
        pipeline.push({
9✔
266
          $lookup: {
267
            from: this.resolveTableName(relEntity, relMeta),
268
            localField: this.resolveColumnName(relOpts.references![0].local, localField!),
269
            foreignField: '_id',
270
            as: relKey,
271
          },
272
        });
273
      } else {
274
        const foreignField = relMeta.fields[relOpts.references![0].foreign];
6✔
275
        const foreignFieldName = this.resolveColumnName(relOpts.references![0].foreign, foreignField!);
6✔
276
        const referenceWhere = this.where(relEntity, where);
6✔
277
        const referenceSort = this.sort(relEntity, q.$sort);
6✔
278
        const _id = MongoDialect.ID_KEY;
6✔
279
        const referencePipelineEntry: MongoAggregationPipelineEntry<FieldValue<E>> = {
6✔
280
          $match: { [foreignFieldName]: referenceWhere[_id] },
281
        };
282
        if (hasKeys(referenceSort)) {
6✔
283
          referencePipelineEntry.$sort = referenceSort;
1✔
284
        }
285
        pipeline.push({
6✔
286
          $lookup: {
287
            from: this.resolveTableName(relEntity, relMeta),
288
            pipeline: [referencePipelineEntry],
289
            as: relKey,
290
          },
291
        });
292
      }
293

294
      pipeline.push({ $unwind: { path: `$${relKey}`, preserveNullAndEmptyArrays: true } });
15✔
295
    }
296

297
    return pipeline;
26✔
298
  }
299

300
  public normalizeIds<E extends Document>(meta: EntityMeta<E>, docs: E[] | undefined): E[] | undefined {
301
    return docs?.map((doc) => this.normalizeId(meta, doc)) as E[] | undefined;
165✔
302
  }
303

304
  public normalizeId<E extends Document>(meta: EntityMeta<E>, doc: E | undefined): E | undefined {
305
    if (!doc) {
177✔
306
      return doc;
1✔
307
    }
308

309
    const res = doc as Record<string, unknown>;
176✔
310
    const _id = MongoDialect.ID_KEY;
176✔
311

312
    if (res[_id]) {
176✔
313
      res[meta.id as string] = res[_id];
175✔
314
      if (meta.id !== _id) {
175!
315
        delete res[_id];
175✔
316
      }
317
    }
318

319
    for (const key of getKeys(meta.fields)) {
176✔
320
      const field = meta.fields[key];
1,424✔
321
      const dbName = this.resolveColumnName(key, field!);
1,424✔
322
      if (dbName !== key && res[dbName] !== undefined) {
1,424✔
323
        res[key] = res[dbName];
1✔
324
        delete res[dbName];
1✔
325
      }
326
    }
327

328
    const relKeys = getKeys(meta.relations).filter((key) => res[key]) as RelationKey<E>[];
627✔
329

330
    for (const relKey of relKeys) {
176✔
331
      const relOpts = meta.relations[relKey];
11✔
332
      if (!relOpts) continue;
11!
333
      const relMeta = getMeta(relOpts.entity!());
11✔
334
      res[relKey] = Array.isArray(res[relKey])
11✔
335
        ? this.normalizeIds(relMeta, res[relKey] as Document[])
336
        : this.normalizeId(relMeta, res[relKey] as Document);
337
    }
338

339
    return res as E;
176✔
340
  }
341

342
  public getIdValue<T extends IdValue>(value: T): T {
343
    if (value instanceof ObjectId) {
57✔
344
      return value;
34✔
345
    }
346
    try {
23✔
347
      return new ObjectId(value) as T;
23✔
348
    } catch (e) {
349
      return value;
1✔
350
    }
351
  }
352

353
  public getPersistable<E extends Document>(meta: EntityMeta<E>, payload: E, callbackKey: CallbackKey): Partial<E> {
354
    return this.getPersistables(meta, payload, callbackKey)[0];
15✔
355
  }
356

357
  public getPersistables<E extends Document>(
358
    meta: EntityMeta<E>,
359
    payload: E | E[],
360
    callbackKey: CallbackKey,
361
  ): Partial<E>[] {
362
    const payloads = fillOnFields(meta, payload, callbackKey);
87✔
363
    const persistableKeys = filterFieldKeys(meta, payloads[0], callbackKey);
87✔
364
    return payloads.map((it) =>
87✔
365
      persistableKeys.reduce<Partial<E>>(
118✔
366
        (acc, key) => {
367
          const field = meta.fields[key];
328✔
368
          (acc as Record<string, unknown>)[this.resolveColumnName(key, field!)] = it[key];
328✔
369
          return acc;
328✔
370
        },
371
        {} as Partial<E>,
372
      ),
373
    );
374
  }
375

376
  /**
377
   * Build MongoDB aggregation pipeline stages from a QueryAggregate.
378
   */
379
  public buildAggregateStages<E extends Document>(
380
    entity: Type<E>,
381
    q: QueryAggregate<E>,
382
    opts?: QueryOptions,
383
  ): Record<string, unknown>[] {
384
    const pipeline: Record<string, unknown>[] = [];
12✔
385

386
    // $match stage (WHERE equivalent - before grouping)
387
    if (q.$where) {
12✔
388
      const filter = this.where(entity, q.$where, opts);
3✔
389
      if (hasKeys(filter)) {
3!
390
        pipeline.push({ $match: filter });
3✔
391
      }
392
    }
393

394
    // $group stage
395
    const groupId: Record<string, string> = {};
12✔
396
    const groupAccumulators: Record<string, Record<string, unknown>> = {};
12✔
397

398
    for (const entry of parseGroupMap(q.$group)) {
12✔
399
      if (entry.kind === 'key') {
19✔
400
        groupId[entry.alias] = `$${entry.alias}`;
4✔
401
      } else {
402
        const mongoOp = MongoDialect.AGGREGATE_OP_MAP[entry.op];
15✔
403
        groupAccumulators[entry.alias] = entry.op === '$count' ? { [mongoOp]: 1 } : { [mongoOp]: `$${entry.fieldRef}` };
15✔
404
      }
405
    }
406

407
    pipeline.push({ $group: { _id: hasKeys(groupId) ? groupId : null, ...groupAccumulators } });
12✔
408

409
    // Project stage - rename _id fields back to their original names
410
    if (hasKeys(groupId)) {
12✔
411
      const project: Record<string, unknown> = { _id: 0 };
4✔
412
      for (const alias of Object.keys(groupId)) {
4✔
413
        project[alias] = `$_id.${alias}`;
4✔
414
      }
415
      for (const alias of Object.keys(groupAccumulators)) {
4✔
416
        project[alias] = 1;
7✔
417
      }
418
      pipeline.push({ $project: project });
4✔
419
    }
420

421
    // $match stage for HAVING (post-group filtering)
422
    if (q.$having) {
12✔
423
      const havingFilter = this.buildHavingFilter(q.$having);
4✔
424
      if (hasKeys(havingFilter)) {
4✔
425
        pipeline.push({ $match: havingFilter });
3✔
426
      }
427
    }
428

429
    // $sort stage
430
    if (q.$sort) {
12✔
431
      const sort = this.sort(entity, q.$sort);
4✔
432
      if (hasKeys(sort)) {
4!
433
        pipeline.push({ $sort: sort });
4✔
434
      }
435
    }
436

437
    // $skip and $limit stages
438
    if (q.$skip !== undefined) {
12✔
439
      pipeline.push({ $skip: q.$skip });
2✔
440
    }
441
    if (q.$limit !== undefined) {
12✔
442
      pipeline.push({ $limit: q.$limit });
2✔
443
    }
444

445
    return pipeline;
12✔
446
  }
447

448
  private buildHavingFilter(having: Record<string, unknown>): Record<string, unknown> {
449
    const filter: Record<string, unknown> = {};
4✔
450
    for (const [alias, condition] of Object.entries(having)) {
4✔
451
      if (condition === undefined) continue;
4✔
452
      if (typeof condition === 'number') {
3✔
453
        filter[alias] = condition;
1✔
454
      } else if (typeof condition === 'object' && condition !== null) {
2!
455
        filter[alias] = this.transformOperators(condition as Record<string, unknown>);
2✔
456
      }
457
    }
458
    return filter;
4✔
459
  }
460

461
  /**
462
   * Separate vector sort entries from regular sort entries.
463
   * Returns `undefined` if no vector sort is present.
464
   */
465
  extractVectorSort<E extends Document>(sort: QuerySortMap<E> | undefined): ExtractedVectorSort<E> | undefined {
466
    if (!sort) return undefined;
60✔
467
    const raw = buildSortMap(sort);
12✔
468
    let vectorKey: string | undefined;
469
    let vectorSearch: QueryVectorSearch | undefined;
470
    const regularSort = {} as QuerySortMap<E>;
12✔
471

472
    for (const [key, value] of Object.entries(raw)) {
12✔
473
      if (isVectorSearch(value)) {
16✔
474
        vectorKey = key;
10✔
475
        vectorSearch = value;
10✔
476
      } else {
477
        (regularSort as Record<string, unknown>)[key] = value;
6✔
478
      }
479
    }
480

481
    if (!vectorKey || !vectorSearch) return undefined;
12✔
482

483
    return { vectorKey, vectorSearch, regularSort };
10✔
484
  }
485

486
  /**
487
   * Build a `$vectorSearch` aggregation pipeline stage.
488
   * Merges `$where` into `$vectorSearch.filter` for optimal pre-filtering.
489
   */
490
  buildVectorSearchStage<E extends Document>(
491
    entity: Type<E>,
492
    meta: EntityMeta<E>,
493
    key: string,
494
    search: QueryVectorSearch,
495
    where: QueryWhere<E> | undefined,
496
    limit: number,
497
    opts?: QueryOptions,
498
  ): Record<string, unknown> {
499
    const field = meta.fields[key];
17✔
500
    if (!field) {
17!
501
      throw new TypeError(`Field '${key}' not found in entity '${meta.name}'`);
×
502
    }
503
    const colName = this.resolveColumnName(key, field);
17✔
504

505
    // Resolve index name from @Index metadata, or fall back to convention
506
    const indexMeta = meta.indexes?.find(
17✔
507
      (idx) => idx.columns.includes(key) && MongoDialect.VECTOR_INDEX_TYPES.has(idx.type!),
9✔
508
    );
509
    const indexName = indexMeta?.name ?? `${colName}_index`;
17✔
510

511
    const stage: Record<string, unknown> = {
17✔
512
      index: indexName,
513
      path: colName,
514
      queryVector: [...search.$vector],
515
      numCandidates: limit * 10,
516
      limit,
517
    };
518

519
    // Pre-filter: merge $where into $vectorSearch.filter
520
    if (where) {
17✔
521
      const filter = this.where(entity, where, opts);
4✔
522
      if (hasKeys(filter)) {
4✔
523
        stage['filter'] = filter;
3✔
524
      }
525
    }
526

527
    return { $vectorSearch: stage };
17✔
528
  }
529
}
530

531
export type MongoAggregationPipelineEntry<E extends Document> = {
532
  $lookup?: MongoAggregationLookup<E>;
533
  $match?: Filter<E> | Record<string, unknown>;
534
  $sort?: Sort;
535
  $unwind?: MongoAggregationUnwind;
536
  $group?: Record<string, unknown>;
537
  $project?: Record<string, unknown>;
538
  $skip?: number;
539
  $limit?: number;
540
};
541

542
type MongoAggregationLookup<E extends Document> = {
543
  readonly from?: string;
544
  readonly foreignField?: string;
545
  readonly localField?: string;
546
  readonly pipeline?: MongoAggregationPipelineEntry<FieldValue<E>>[];
547
  readonly as?: RelationKey<E>;
548
};
549

550
type MongoAggregationUnwind = {
551
  readonly path?: string;
552
  readonly preserveNullAndEmptyArrays?: boolean;
553
};
554

555
type IdValue = string | ObjectId;
556

557
export type ExtractedVectorSort<E> = {
558
  readonly vectorKey: string;
559
  readonly vectorSearch: QueryVectorSearch;
560
  readonly regularSort: QuerySortMap<E>;
561
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc