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

rogerpadilla / uql / 24317085364

12 Apr 2026 09:42PM UTC coverage: 94.786% (-0.09%) from 94.874%
24317085364

push

github

rogerpadilla
chore: remove changelog and update gitHead in package.json

3088 of 3431 branches covered (90.0%)

Branch coverage included in aggregate %.

5420 of 5545 relevant lines covered (97.75%)

353.61 hits per line

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

96.8
/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
  buildQueryWhereAsMap,
23
  buildSortMap,
24
  type CallbackKey,
25
  fillOnFields,
26
  filterFieldKeys,
27
  getKeys,
28
  getRelationRequestSummary,
29
  hasKeys,
30
  isVectorSearch,
31
  normalizeScalarFieldSelection,
32
  parseGroupMap,
33
  type RelationRequestSummary,
34
} from '../util/index.js';
35

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

55
  readonly dialectName = 'mongodb';
69✔
56

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

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

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

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

74
  public where<E extends Document>(
75
    entity: Type<E>,
76
    where: QueryWhere<E> = {},
1,001✔
77
    { softDelete }: QueryOptions = {},
1,001✔
78
  ): Filter<E> {
79
    const meta = getMeta(entity);
1,001✔
80
    const whereMap = buildQueryWhereAsMap(meta, where);
1,001✔
81

82
    if (meta.softDelete && (softDelete || softDelete === undefined) && !whereMap[meta.softDelete]) {
1,001✔
83
      const field = meta.fields[meta.softDelete];
107✔
84
      (whereMap as Record<string, unknown>)[this.resolveColumnName(meta.softDelete, field!)] = null;
107✔
85
    }
86

87
    const filter: Record<string, unknown> = {};
1,001✔
88
    for (const [rawKey, rawVal] of Object.entries(whereMap)) {
1,001✔
89
      let key = rawKey;
243✔
90
      let val: unknown = rawVal;
243✔
91
      if (key === '$and' || key === '$or') {
243✔
92
        filter[key] = (val as QueryWhere<E>[]).map((filterIt) => this.where(entity, filterIt));
5✔
93
      } else {
94
        const field = meta.fields[key];
240✔
95
        if (key === MongoDialect.ID_KEY || key === meta.id) {
240✔
96
          key = MongoDialect.ID_KEY;
48✔
97
          val = this.getIdValue(val as IdValue);
48✔
98
        } else if (field) {
192!
99
          key = this.resolveColumnName(key, field);
192✔
100
        }
101
        if (
240✔
102
          val &&
559✔
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>);
24✔
108
        } else if (Array.isArray(val)) {
216✔
109
          val = { $in: val };
33✔
110
        }
111
        filter[key] = val;
240✔
112
      }
113
    }
114
    return filter as Filter<E>;
1,001✔
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('$'));
78✔
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> = {};
26✔
162
    for (const [op, val] of Object.entries(ops)) {
26✔
163
      // Native MongoDB operators — pass through directly
164
      if (MongoDialect.NATIVE_OPS.has(op)) {
26✔
165
        result[op] = val;
9✔
166
        continue;
9✔
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;
26✔
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);
40✔
206
    if (!select && !exclude) {
40✔
207
      return {};
16✔
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);
82✔
218
    const normalized: Record<string, 1 | -1> = {};
82✔
219
    for (const [key, dir] of Object.entries(raw)) {
82✔
220
      normalized[key] = dir === 'desc' || dir === -1 ? -1 : 1;
19✔
221
    }
222
    return normalized as Sort;
82✔
223
  }
224

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

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

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

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

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

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

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

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

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

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

296
    return pipeline;
26✔
297
  }
298

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

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

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

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

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

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

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

338
    return res as unknown as E;
160✔
339
  }
340

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

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

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

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

381
    // $match stage (WHERE equivalent — before grouping)
382
    if (q.$where) {
12✔
383
      const filter = this.where(entity, q.$where);
3✔
384
      if (hasKeys(filter)) {
3!
385
        pipeline.push({ $match: filter });
3✔
386
      }
387
    }
388

389
    // $group stage
390
    const groupId: Record<string, string> = {};
12✔
391
    const groupAccumulators: Record<string, Record<string, unknown>> = {};
12✔
392

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

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

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

416
    // $match stage for HAVING (post-group filtering)
417
    if (q.$having) {
12✔
418
      const havingFilter = this.buildHavingFilter(q.$having);
4✔
419
      if (hasKeys(havingFilter)) {
4✔
420
        pipeline.push({ $match: havingFilter });
3✔
421
      }
422
    }
423

424
    // $sort stage
425
    if (q.$sort) {
12✔
426
      const sort = this.sort(entity, q.$sort);
4✔
427
      if (hasKeys(sort)) {
4!
428
        pipeline.push({ $sort: sort });
4✔
429
      }
430
    }
431

432
    // $skip and $limit stages
433
    if (q.$skip !== undefined) {
12✔
434
      pipeline.push({ $skip: q.$skip });
2✔
435
    }
436
    if (q.$limit !== undefined) {
12✔
437
      pipeline.push({ $limit: q.$limit });
2✔
438
    }
439

440
    return pipeline;
12✔
441
  }
442

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

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

467
    for (const [key, value] of Object.entries(raw)) {
12✔
468
      if (isVectorSearch(value)) {
16✔
469
        vectorKey = key;
10✔
470
        vectorSearch = value;
10✔
471
      } else {
472
        (regularSort as Record<string, unknown>)[key] = value;
6✔
473
      }
474
    }
475

476
    if (!vectorKey || !vectorSearch) return undefined;
12✔
477

478
    return { vectorKey, vectorSearch, regularSort };
10✔
479
  }
480

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

499
    // Resolve index name from @Index metadata, or fall back to convention
500
    const indexMeta = meta.indexes?.find(
17✔
501
      (idx) => idx.columns.includes(key) && MongoDialect.VECTOR_INDEX_TYPES.has(idx.type!),
9✔
502
    );
503
    const indexName = indexMeta?.name ?? `${colName}_index`;
17✔
504

505
    const stage: Record<string, unknown> = {
17✔
506
      index: indexName,
507
      path: colName,
508
      queryVector: [...search.$vector],
509
      numCandidates: limit * 10,
510
      limit,
511
    };
512

513
    // Pre-filter: merge $where into $vectorSearch.filter
514
    if (where) {
17✔
515
      const filter = this.where(entity, where);
4✔
516
      if (hasKeys(filter)) {
4✔
517
        stage['filter'] = filter;
3✔
518
      }
519
    }
520

521
    return { $vectorSearch: stage };
17✔
522
  }
523
}
524

525
export type MongoAggregationPipelineEntry<E extends Document> = {
526
  $lookup?: MongoAggregationLookup<E>;
527
  $match?: Filter<E> | Record<string, unknown>;
528
  $sort?: Sort;
529
  $unwind?: MongoAggregationUnwind;
530
  $group?: Record<string, unknown>;
531
  $project?: Record<string, unknown>;
532
  $skip?: number;
533
  $limit?: number;
534
};
535

536
type MongoAggregationLookup<E extends Document> = {
537
  readonly from?: string;
538
  readonly foreignField?: string;
539
  readonly localField?: string;
540
  readonly pipeline?: MongoAggregationPipelineEntry<FieldValue<E>>[];
541
  readonly as?: RelationKey<E>;
542
};
543

544
type MongoAggregationUnwind = {
545
  readonly path?: string;
546
  readonly preserveNullAndEmptyArrays?: boolean;
547
};
548

549
type IdValue = string | ObjectId;
550

551
export type ExtractedVectorSort<E> = {
552
  readonly vectorKey: string;
553
  readonly vectorSearch: QueryVectorSearch;
554
  readonly regularSort: QuerySortMap<E>;
555
};
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