• 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

95.99
/packages/uql-orm/src/dialect/abstractSqlDialect.ts
1
import { getMeta } from '../entity/index.js';
2
import { resolveVectorCast, type VectorCast } from '../schema/canonicalType.js';
3
import {
4
  type DialectFeatures,
5
  type EntityMeta,
6
  type FieldKey,
7
  type FieldOptions,
8
  type IdKey,
9
  type IsolationLevel,
10
  type JsonUpdateOp,
11
  type Key,
12
  type Query,
13
  type QueryAggregate,
14
  type QueryComparisonOptions,
15
  type QueryConflictPaths,
16
  type QueryContext,
17
  type QueryDialect,
18
  type QueryExclude,
19
  type QueryHavingMap,
20
  type QueryOptions,
21
  type QueryPager,
22
  type QueryPopulate,
23
  QueryRaw,
24
  type QueryRawFnOptions,
25
  type QuerySearch,
26
  type QuerySelect,
27
  type QuerySelectOptions,
28
  type QuerySizeComparisonOps,
29
  type QuerySortDirection,
30
  type QuerySortMap,
31
  type QueryTextSearchOptions,
32
  type QueryVectorSearch,
33
  type QueryWhere,
34
  type QueryWhereArray,
35
  type QueryWhereFieldOperatorMap,
36
  type QueryWhereMap,
37
  type QueryWhereOptions,
38
  RAW_ALIAS,
39
  RAW_VALUE,
40
  type RelationOptions,
41
  type SqlDialectName,
42
  type SqlQueryDialect,
43
  type Type,
44
  type UpdatePayload,
45
  type VectorDistance,
46
} from '../type/index.js';
47

48
import {
49
  buildQueryWhereAsMap,
50
  buildSortMap,
51
  type CallbackKey,
52
  escapeSqlId,
53
  fillOnFields,
54
  filterFieldKeys,
55
  flatObject,
56
  getFieldCallbackValue,
57
  getFieldKeys,
58
  getKeys,
59
  getRelationRequestSummary,
60
  hasKeys,
61
  isJsonType,
62
  isPopulatingRelations,
63
  isVectorSearch,
64
  normalizeScalarFieldSelection,
65
  parseGroupMap,
66
  parseRelationAtKey,
67
  type RelationQuery,
68
  raw,
69
} from '../util/index.js';
70

71
import { AbstractDialect, type DialectOptions } from './abstractDialect.js';
72
import { SqlQueryContext } from './queryContext.js';
73

74
export abstract class AbstractSqlDialect extends AbstractDialect implements QueryDialect, SqlQueryDialect {
75
  // Narrow dialect type from Dialect to SqlDialect
76
  abstract override readonly dialectName: SqlDialectName;
77

78
  abstract readonly quoteChar: '"' | '`';
79
  abstract readonly serialPrimaryKey: string;
80
  abstract readonly tableOptions: string;
81
  abstract readonly beginTransactionCommand: string;
82
  abstract readonly commitTransactionCommand: string;
83
  abstract readonly rollbackTransactionCommand: string;
84

85
  constructor(engineDefaults: DialectFeatures, options: DialectOptions = {}) {
402✔
86
    super(engineDefaults, options);
402✔
87
  }
88

89
  readonly isolationLevelStrategy: 'inline' | 'set-before' | 'none' = 'inline';
402✔
90

91
  readonly alterColumnStrategy: 'separate-clauses' | 'single-statement' = 'single-statement';
402✔
92

93
  readonly alterColumnSyntax: 'ALTER COLUMN' | 'MODIFY COLUMN' | 'none' = 'ALTER COLUMN';
402✔
94

95
  readonly dropForeignKeySyntax: 'DROP CONSTRAINT' | 'DROP FOREIGN KEY' = 'DROP CONSTRAINT';
402✔
96

97
  readonly dropIndexSyntax: 'on-table' | 'standalone' = 'standalone';
402✔
98

99
  readonly renameTableSyntax: 'rename-table' | 'alter-table' = 'alter-table';
402✔
100

101
  readonly booleanLiteral: 'native' | 'integer' = 'native';
402✔
102

103
  readonly vectorOpsClass: Readonly<Record<VectorDistance, string>> | undefined = undefined;
402✔
104

105
  readonly vectorExtension: string | undefined = undefined;
402✔
106

107
  get escapeIdChar() {
108
    return this.quoteChar;
23,380✔
109
  }
110

111
  getBeginTransactionStatements(isolationLevel?: IsolationLevel): string[] {
112
    const level = isolationLevel?.toUpperCase();
106✔
113
    const strategy = this.isolationLevelStrategy;
106✔
114
    if (!level || strategy === 'none') {
106✔
115
      return [this.beginTransactionCommand];
91✔
116
    }
117
    if (strategy === 'inline') {
15✔
118
      return [`${this.beginTransactionCommand} ISOLATION LEVEL ${level}`];
7✔
119
    }
120
    // 'set-before' — MySQL/MariaDB pattern
121
    return [`SET TRANSACTION ISOLATION LEVEL ${level}`, this.beginTransactionCommand];
8✔
122
  }
123

124
  createContext(): QueryContext {
125
    return new SqlQueryContext(this);
5,991✔
126
  }
127

128
  addValue(values: unknown[], value: unknown): string {
129
    values.push(this.normalizeValue(value));
3,549✔
130
    return this.placeholder(values.length);
3,549✔
131
  }
132

133
  /**
134
   * Normalizes a parameter value for the database driver.
135
   * Handles bigint, boolean, and serializes plain objects/arrays to JSON strings.
136
   * Date values are preserved so SQL drivers can apply native date/time binding.
137
   * Postgres overrides to pass objects through to its native JSONB driver.
138
   */
139
  normalizeValue(value: unknown): unknown {
140
    if (value == null || value instanceof Date || value instanceof Uint8Array || value instanceof QueryRaw) {
7,251✔
141
      return value;
30✔
142
    }
143
    if (typeof value === 'bigint') {
7,221!
144
      return Number(value);
×
145
    }
146
    if (typeof value === 'boolean') {
7,221✔
147
      return this.booleanLiteral === 'native' ? value : value ? 1 : 0;
7✔
148
    }
149
    return value;
7,214✔
150
  }
151

152
  /**
153
   * Normalizes a list of parameter values.
154
   */
155
  normalizeValues(values: unknown[] | undefined): unknown[] | undefined {
156
    return values?.map((v) => this.normalizeValue(v));
8,102✔
157
  }
158

159
  placeholder(_index: number): string {
160
    return '?';
2,836✔
161
  }
162

163
  returningId<E>(entity: Type<E>): string {
164
    const meta = getMeta(entity);
180✔
165
    const idKey = (meta.id ?? 'id') as IdKey<E>;
180!
166
    const idName = this.resolveColumnName(idKey, meta.fields[idKey]);
180✔
167
    return `RETURNING ${this.escapeId(idName)} ${this.escapeId('id')}`;
180✔
168
  }
169

170
  search<E>(ctx: QueryContext, entity: Type<E>, q: Query<E> = {}, opts: QueryOptions = {}): void {
10,804✔
171
    const meta = getMeta(entity);
5,402✔
172
    const tableName = this.resolveTableName(entity, meta);
5,402✔
173
    const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, q.$select, q.$populate);
5,402✔
174
    opts = { ...opts, prefix };
5,402✔
175
    this.where<E>(ctx, entity, q.$where, opts);
5,402✔
176
    this.sort<E>(ctx, entity, q.$sort, opts);
5,402✔
177
    this.pager(ctx, q);
5,402✔
178
  }
179

180
  selectFields<E>(
181
    ctx: QueryContext,
182
    entity: Type<E>,
183
    select: QuerySelect<E> | QueryRaw[] | undefined,
184
    opts: QuerySelectOptions = {},
4,083✔
185
    exclude?: QueryExclude<E>,
186
  ): void {
187
    const meta = getMeta(entity);
4,083✔
188
    const prefix = opts.prefix ? opts.prefix + '.' : '';
4,083✔
189
    const escapedPrefix = this.escapeId(opts.prefix as string, true, true);
4,083✔
190

191
    let selectArr: (FieldKey<E> | QueryRaw)[];
192

193
    if (select) {
4,083✔
194
      if (Array.isArray(select)) {
3,941✔
195
        // Internal-only path: raw SQL expressions passed as QueryRaw[]
196
        selectArr = select;
160✔
197
      } else {
198
        selectArr = normalizeScalarFieldSelection(meta, select, exclude);
3,781✔
199
      }
200

201
      const id = meta.id;
3,941✔
202
      if (id && opts.prefix && !selectArr.includes(id)) {
3,941✔
203
        selectArr = [id, ...selectArr];
49✔
204
      }
205
    } else {
206
      selectArr = normalizeScalarFieldSelection(meta, undefined, exclude);
142✔
207
    }
208

209
    if (!selectArr.length) {
4,083✔
210
      ctx.append(escapedPrefix + '*');
1✔
211
      return;
1✔
212
    }
213

214
    selectArr.forEach((key, index) => {
4,082✔
215
      if (index > 0) ctx.append(', ');
5,282✔
216
      if (key instanceof QueryRaw) {
5,282✔
217
        this.getRawValue(ctx, {
165✔
218
          value: key,
219
          prefix: opts.prefix,
220
          escapedPrefix,
221
          autoPrefixAlias: opts.autoPrefixAlias,
222
        });
223
      } else {
224
        const field = meta.fields[key];
5,117✔
225
        if (!field) return;
5,117!
226
        const columnName = this.resolveColumnName(key, field);
5,117✔
227
        if (field.virtual) {
5,117✔
228
          this.getRawValue(ctx, {
9✔
229
            value: raw(field.virtual[RAW_VALUE], key),
230
            prefix: opts.prefix,
231
            escapedPrefix,
232
            autoPrefixAlias: opts.autoPrefixAlias,
233
          });
234
        } else {
235
          ctx.append(escapedPrefix + this.escapeId(columnName));
5,108✔
236
        }
237
        if (!field.virtual && (columnName !== key || opts.autoPrefixAlias)) {
5,117✔
238
          const aliasStr = prefix + key;
318✔
239
          ctx.append(' ' + this.escapeId(aliasStr, true));
318✔
240
        }
241
      }
242
    });
243
  }
244

245
  select<E>(
246
    ctx: QueryContext,
247
    entity: Type<E>,
248
    select: QuerySelect<E> | QueryRaw[] | undefined,
249
    exclude?: QueryExclude<E>,
250
    populate?: QueryPopulate<E>,
251
    opts: QueryOptions = {},
3,990✔
252
    distinct?: boolean,
253
    sort?: QuerySortMap<E>,
254
  ): void {
255
    const meta = getMeta(entity);
3,990✔
256
    const tableName = this.resolveTableName(entity, meta);
3,990✔
257
    const mapSelect = Array.isArray(select) ? undefined : select;
3,990✔
258
    const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, mapSelect, populate);
3,990✔
259

260
    ctx.append(distinct ? 'SELECT DISTINCT ' : 'SELECT ');
3,990✔
261
    this.selectFields(ctx, entity, select, { prefix }, exclude);
3,990✔
262
    // Add related fields BEFORE FROM clause
263
    this.selectRelationFields(ctx, entity, mapSelect, populate, { prefix });
3,990✔
264
    // Inject vector distance projections when $project is set
265
    if (sort) {
3,990✔
266
      const sortMap = buildSortMap(sort);
107✔
267
      for (const [key, val] of Object.entries(sortMap)) {
107✔
268
        if (isVectorSearch(val) && val.$project) {
182✔
269
          ctx.append(', ');
5✔
270
          this.appendVectorProjection(ctx, meta, key, val);
5✔
271
        }
272
      }
273
    }
274
    ctx.append(` FROM ${this.escapeId(tableName)}`);
3,990✔
275
    // Add JOINs AFTER FROM clause
276
    this.selectRelationJoins(ctx, entity, mapSelect, populate, { prefix });
3,990✔
277
  }
278

279
  private resolveRelationAwarePrefix<E>(
280
    tableName: string,
281
    meta: EntityMeta<E>,
282
    opts: QueryOptions,
283
    select?: QuerySelect<E>,
284
    populate?: QueryPopulate<E>,
285
  ): string | undefined {
286
    return (opts.prefix ?? (opts.autoPrefix || isPopulatingRelations(meta, populate))) ? tableName : undefined;
9,392✔
287
  }
288

289
  protected selectRelationFields<E>(
290
    ctx: QueryContext,
291
    entity: Type<E>,
292
    select: QuerySelect<E> | undefined,
293
    populate: QueryPopulate<E> | undefined,
294
    opts: { prefix?: string } = {},
4,076✔
295
  ): void {
296
    this.forEachJoinableRelation(entity, select, populate, opts, (relEntity, relQuery, joinRelAlias) => {
4,076✔
297
      ctx.append(', ');
86✔
298
      this.selectFields(
86✔
299
        ctx,
300
        relEntity,
301
        relQuery.$select,
302
        { prefix: joinRelAlias, autoPrefixAlias: true },
303
        relQuery.$exclude,
304
      );
305
      this.selectRelationFields(ctx, relEntity, relQuery.$select, relQuery.$populate, { prefix: joinRelAlias });
86✔
306
    });
307
  }
308

309
  protected selectRelationJoins<E>(
310
    ctx: QueryContext,
311
    entity: Type<E>,
312
    select: QuerySelect<E> | undefined,
313
    populate: QueryPopulate<E> | undefined,
314
    opts: { prefix?: string } = {},
4,076✔
315
  ): void {
316
    this.forEachJoinableRelation(
4,076✔
317
      entity,
318
      select,
319
      populate,
320
      opts,
321
      (relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required) => {
322
        const relMeta = getMeta(relEntity);
86✔
323
        const relTableName = this.resolveTableName(relEntity, relMeta);
86✔
324
        const relEntityName = this.escapeId(relTableName);
86✔
325
        const relPath = opts.prefix ? this.escapeId(opts.prefix, true) : this.escapeId(tableName);
86!
326
        const joinType = required ? 'INNER' : 'LEFT';
86✔
327
        const joinAlias = this.escapeId(joinRelAlias, true);
86✔
328

329
        ctx.append(` ${joinType} JOIN ${relEntityName} ${joinAlias} ON `);
86✔
330
        let refAppended = false;
86✔
331
        for (const it of relOpts.references ?? []) {
86!
332
          if (refAppended) ctx.append(' AND ');
86!
333
          const relField = relMeta.fields[it.foreign];
86✔
334
          const field = meta.fields[it.local];
86✔
335
          const foreignColumnName = this.resolveColumnName(it.foreign, relField);
86✔
336
          const localColumnName = this.resolveColumnName(it.local, field);
86✔
337
          ctx.append(`${joinAlias}.${this.escapeId(foreignColumnName)} = ${relPath}.${this.escapeId(localColumnName)}`);
86✔
338
          refAppended = true;
86✔
339
        }
340

341
        if (relQuery.$where) {
86✔
342
          ctx.append(' AND ');
4✔
343
          this.where(ctx, relEntity, relQuery.$where, { prefix: joinRelAlias, clause: false });
4✔
344
        }
345

346
        this.selectRelationJoins(ctx, relEntity, relQuery.$select, relQuery.$populate, { prefix: joinRelAlias });
86✔
347
      },
348
    );
349
  }
350

351
  /**
352
   * Iterates over joinable (11/m1) relations for a given select, resolving shared metadata.
353
   * Used by both `selectRelationFields` and `selectRelationJoins` to avoid duplicated iteration logic.
354
   */
355
  private forEachJoinableRelation<E>(
356
    entity: Type<E>,
357
    select: QuerySelect<E> | undefined,
358
    populate: QueryPopulate<E> | undefined,
359
    opts: { prefix?: string },
360
    callback: (
361
      relEntity: Type<object>,
362
      relQuery: RelationQuery,
363
      joinRelAlias: string,
364
      relOpts: RelationOptions,
365
      meta: EntityMeta<E>,
366
      tableName: string,
367
      required: boolean,
368
    ) => void,
369
  ): void {
370
    if (!select && !populate) return;
8,152✔
371
    const meta = getMeta(entity);
7,644✔
372
    const tableName = this.resolveTableName(entity, meta);
7,644✔
373
    const relKeys = getRelationRequestSummary(meta, populate).joinableKeys;
7,644✔
374
    const prefix = opts.prefix;
7,644✔
375

376
    for (const relKey of relKeys) {
7,644✔
377
      const relOpts = meta.relations[relKey];
172✔
378
      if (!relOpts?.entity) continue;
172!
379

380
      const isFirstLevel = prefix === tableName;
172✔
381
      const joinRelAlias = isFirstLevel ? relKey : prefix ? `${prefix}.${relKey}` : relKey;
172!
382
      const relEntity = relOpts.entity();
172✔
383
      const { query: relQuery, required } = parseRelationAtKey<E>(relKey, populate);
172✔
384

385
      callback(relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required);
172✔
386
    }
387
  }
388

389
  where<E>(ctx: QueryContext, entity: Type<E>, where: QueryWhere<E> = {}, opts: QueryWhereOptions = {}): void {
11,260✔
390
    const meta = getMeta(entity);
5,630✔
391
    const { usePrecedence, clause = 'WHERE', softDelete } = opts;
5,630✔
392

393
    where = buildQueryWhereAsMap(meta, where);
5,630✔
394

395
    if (
5,630✔
396
      meta.softDelete &&
7,366✔
397
      (softDelete || softDelete === undefined) &&
398
      !(where as Record<string, unknown>)[meta.softDelete]
399
    ) {
400
      (where as Record<string, unknown>)[meta.softDelete] = null;
571✔
401
    }
402

403
    const entries = Object.entries(where);
5,630✔
404

405
    if (!entries.length) {
5,630✔
406
      return;
3,889✔
407
    }
408

409
    if (clause) {
1,741✔
410
      ctx.append(` ${clause} `);
1,636✔
411
    }
412

413
    if (usePrecedence) {
1,741✔
414
      ctx.append('(');
6✔
415
    }
416

417
    const whereKeys = getKeys(where) as (keyof QueryWhereMap<E>)[];
1,741✔
418
    const hasMultipleKeys = whereKeys.length > 1;
1,741✔
419
    let appended = false;
1,741✔
420
    whereKeys.forEach((key) => {
1,741✔
421
      const val = (where as Record<string, unknown>)[key];
1,918✔
422
      if (val === undefined) return;
1,918!
423
      if (appended) {
1,918✔
424
        ctx.append(' AND ');
177✔
425
      }
426
      this.compare(ctx, entity, key, val as QueryWhereMap<E>[keyof QueryWhereMap<E>], {
1,918✔
427
        ...opts,
428
        usePrecedence: hasMultipleKeys,
429
      });
430
      appended = true;
1,918✔
431
    });
432

433
    if (usePrecedence) {
1,741✔
434
      ctx.append(')');
6✔
435
    }
436
  }
437

438
  compare<E>(ctx: QueryContext, entity: Type<E>, key: string, val: unknown, opts: QueryComparisonOptions = {}): void {
1,929✔
439
    const meta = getMeta(entity);
1,929✔
440

441
    if (val instanceof QueryRaw) {
1,929✔
442
      if (key === '$exists' || key === '$nexists') {
23✔
443
        ctx.append(key === '$exists' ? 'EXISTS (' : 'NOT EXISTS (');
5✔
444
        const tableName = this.resolveTableName(entity, meta);
5✔
445
        this.getRawValue(ctx, {
5✔
446
          value: val,
447
          prefix: tableName,
448
          escapedPrefix: this.escapeId(tableName, false, true),
449
        });
450
        ctx.append(')');
5✔
451
        return;
5✔
452
      }
453
      this.getComparisonKey(ctx, entity, key as FieldKey<E>, opts);
18✔
454
      ctx.append(' = ');
18✔
455
      this.getRawValue(ctx, { value: val });
18✔
456
      return;
18✔
457
    }
458

459
    if (key === '$text') {
1,906✔
460
      const search = val as QueryTextSearchOptions<E>;
4✔
461
      const searchFields = search.$fields ?? (getFieldKeys(meta.fields) as FieldKey<E>[]);
4!
462
      const fields = searchFields.map((fKey) => {
4✔
463
        const field = meta.fields[fKey];
6✔
464
        const columnName = this.resolveColumnName(fKey, field);
6✔
465
        return this.escapeId(columnName);
6✔
466
      });
467
      ctx.append(`MATCH(${fields.join(', ')}) AGAINST(`);
4✔
468
      ctx.addValue(search.$value);
4✔
469
      ctx.append(')');
4✔
470
      return;
4✔
471
    }
472

473
    if (key === '$and' || key === '$or' || key === '$not' || key === '$nor') {
1,902✔
474
      this.compareLogicalOperator(
78✔
475
        ctx,
476
        entity,
477
        key as '$and' | '$or' | '$not' | '$nor',
478
        val as QueryWhereArray<E>,
479
        opts,
480
      );
481
      return;
78✔
482
    }
483

484
    // Detect JSONB dot-notation: 'column.path' where column is a registered JSON/JSONB field
485
    const jsonDot = this.resolveJsonDotPath(meta, key, opts.prefix);
1,824✔
486
    if (jsonDot) {
1,824✔
487
      this.compareJsonPath(ctx, jsonDot, val);
40✔
488
      return;
40✔
489
    }
490

491
    if (key.includes('.')) {
1,784!
492
      throw new TypeError(`path ${key} does not exist in ${meta.name}`);
×
493
    }
494

495
    // Detect relation filtering
496
    const rel = meta.relations[key];
1,784✔
497
    if (rel) {
1,784✔
498
      // Check if this is a $size query on a relation (count filtering)
499
      const valObj = val as Record<string, unknown> | undefined;
27✔
500
      if (valObj && typeof valObj === 'object' && '$size' in valObj && Object.keys(valObj).length === 1) {
27✔
501
        this.compareRelationSize(ctx, entity, key, valObj['$size'] as number | QuerySizeComparisonOps, rel, opts);
12✔
502
        return;
12✔
503
      }
504
      this.compareRelation(ctx, entity, key, val as QueryWhereMap<unknown>, rel, opts);
15✔
505
      return;
15✔
506
    }
507

508
    const value = this.normalizeWhereValue(val);
1,757✔
509
    const operators = getKeys(value) as (keyof QueryWhereFieldOperatorMap<E>)[];
1,757✔
510

511
    if (operators.length > 1) {
1,757✔
512
      ctx.append('(');
46✔
513
    }
514

515
    operators.forEach((op, index) => {
1,757✔
516
      if (index > 0) {
1,803✔
517
        ctx.append(' AND ');
46✔
518
      }
519
      this.compareFieldOperator(
1,803✔
520
        ctx,
521
        entity,
522
        key as FieldKey<E>,
523
        op,
524
        (value as QueryWhereFieldOperatorMap<E>)[op],
525
        opts,
526
      );
527
    });
528

529
    if (operators.length > 1) {
1,757✔
530
      ctx.append(')');
46✔
531
    }
532
  }
533

534
  protected compareLogicalOperator<E>(
535
    ctx: QueryContext,
536
    entity: Type<E>,
537
    key: '$and' | '$or' | '$not' | '$nor',
538
    val: QueryWhereArray<E>,
539
    opts: QueryComparisonOptions,
540
  ): void {
541
    const op = (AbstractSqlDialect.NEGATE_OP_MAP as Record<string, '$and' | '$or'>)[key] ?? (key as '$and' | '$or');
78✔
542
    const negate = key in AbstractSqlDialect.NEGATE_OP_MAP ? 'NOT' : '';
78✔
543

544
    const valArr = val ?? [];
78!
545
    const hasManyItems = valArr.length > 1;
78✔
546

547
    if ((opts.usePrecedence || negate) && hasManyItems) {
78✔
548
      ctx.append((negate ? negate + ' ' : '') + '(');
15✔
549
    } else if (negate) {
63✔
550
      ctx.append(negate + ' ');
12✔
551
    }
552

553
    valArr.forEach((whereEntry, index) => {
78✔
554
      if (index > 0) {
125✔
555
        ctx.append(op === '$or' ? ' OR ' : ' AND ');
47✔
556
      }
557
      if (whereEntry instanceof QueryRaw) {
125✔
558
        this.getRawValue(ctx, {
24✔
559
          value: whereEntry,
560
        });
561
      } else if (whereEntry) {
101!
562
        this.where(ctx, entity, whereEntry, {
101✔
563
          prefix: opts.prefix,
564
          usePrecedence: hasManyItems && !Array.isArray(whereEntry) && Object.keys(whereEntry as object).length > 1,
249✔
565
          clause: false,
566
        });
567
      }
568
    });
569

570
    if ((opts.usePrecedence || negate) && hasManyItems) {
78✔
571
      ctx.append(')');
15✔
572
    }
573
  }
574

575
  /** Simple comparison operators: `getComparisonKey → op → addValue`. */
576
  private static readonly NEGATE_OP_MAP = { $not: '$and', $nor: '$or' } as const;
60✔
577

578
  private static readonly COMPARE_OP_MAP: Record<string, string> = {
60✔
579
    $gt: ' > ',
580
    $gte: ' >= ',
581
    $lt: ' < ',
582
    $lte: ' <= ',
583
  };
584

585
  private static readonly LIKE_OP_MAP: Record<string, (v: string) => string> = {
60✔
586
    $startsWith: (v) => `${v}%`,
16✔
587
    $istartsWith: (v) => `${v.toLowerCase()}%`,
11✔
588
    $endsWith: (v) => `%${v}`,
9✔
589
    $iendsWith: (v) => `%${v.toLowerCase()}`,
8✔
590
    $includes: (v) => `%${v}%`,
6✔
591
    $iincludes: (v) => `%${v.toLowerCase()}%`,
8✔
592
    $like: (v) => v,
14✔
593
    $ilike: (v) => v.toLowerCase(),
8✔
594
  };
595

596
  protected resolveColumnWithPrefix(entity: Type<any>, key: string, { prefix }: QueryOptions = {}): string {
1,800✔
597
    const meta = getMeta(entity);
1,800✔
598
    const field = meta.fields[key as string];
1,800✔
599
    const columnName = this.resolveColumnName(key, field);
1,800✔
600
    const escapedPrefix = this.escapeId(prefix as string, true, true);
1,800✔
601
    return escapedPrefix + this.escapeId(columnName);
1,800✔
602
  }
603

604
  /**
605
   * Resolves the SQL operand for a field comparison.
606
   * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
607
   */
608
  protected resolveOperandField(
609
    ctx: QueryContext,
610
    entity: Type<any>,
611
    key: string,
612
    opts: QueryOptions,
613
  ): string | undefined {
614
    const col = getMeta(entity).fields[key];
1,802✔
615
    if (col?.virtual) {
1,802✔
616
      if (col.virtual instanceof QueryRaw) {
4!
617
        this.getComparisonKey(ctx, entity, key as FieldKey<any>, opts);
4✔
618
        return undefined;
4✔
619
      }
620
      return `(${col.virtual})`;
×
621
    }
622
    return this.resolveColumnWithPrefix(entity, key, opts);
1,798✔
623
  }
624

625
  private appendFieldSql(ctx: QueryContext, field: string | undefined, sql: string): void {
626
    ctx.append(field ? `${field}${sql}` : sql);
1,655✔
627
  }
628

629
  compareFieldOperator<E, K extends keyof QueryWhereFieldOperatorMap<E>>(
630
    ctx: QueryContext,
631
    entity: Type<E>,
632
    key: FieldKey<E>,
633
    op: K,
634
    val: QueryWhereFieldOperatorMap<E>[K],
635
    opts: QueryOptions = {},
1,802✔
636
  ): void {
637
    const field = this.resolveOperandField(ctx, entity, key as string, opts);
1,802✔
638

639
    const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP[op as string];
1,802✔
640
    if (simpleOp) {
1,802✔
641
      this.appendFieldSql(ctx, field, `${simpleOp}${this.addValue(ctx.values, val)}`);
28✔
642
      return;
28✔
643
    }
644

645
    const likeWrap = AbstractSqlDialect.LIKE_OP_MAP[op as string];
1,774✔
646
    if (likeWrap) {
1,774✔
647
      this.appendLikeOp(ctx, field, op as string, likeWrap(val as string));
80✔
648
      return;
80✔
649
    }
650

651
    switch (op) {
1,694✔
652
      case '$eq':
653
      case '$ne':
654
        this.appendEqNe(ctx, field, op as string, val);
1,207✔
655
        break;
1,207✔
656
      case '$regex':
657
        this.appendFieldSql(ctx, field, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
4✔
658
        break;
4✔
659
      case '$not':
660
        ctx.append('NOT (');
15✔
661
        this.compare(ctx, entity, key as keyof QueryWhereMap<E>, val as QueryWhereMap<E>[keyof QueryWhereMap<E>], opts);
15✔
662
        ctx.append(')');
15✔
663
        break;
15✔
664
      case '$in':
665
      case '$nin':
666
        this.appendInNin(ctx, field, op as string, val);
421✔
667
        break;
421✔
668
      case '$between': {
669
        const col = this.resolveColumnWithPrefix(entity, key, opts);
2✔
670
        const [min, max] = val as [unknown, unknown];
2✔
671
        ctx.append(`${col} BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
2✔
672
        break;
2✔
673
      }
674
      case '$isNull':
675
        this.appendFieldSql(ctx, field, val ? ' IS NULL' : ' IS NOT NULL');
3✔
676
        break;
3✔
677
      case '$isNotNull':
678
        this.appendFieldSql(ctx, field, val ? ' IS NOT NULL' : ' IS NULL');
3✔
679
        break;
3✔
680
      case '$all':
681
        ctx.append(this.jsonAll(ctx, field ?? '', val));
4!
682
        break;
4✔
683
      case '$size':
684
        ctx.append(this.jsonSize(ctx, field ?? '', val as number | QuerySizeComparisonOps));
13!
685
        break;
13✔
686
      case '$elemMatch':
687
        ctx.append(this.jsonElemMatch(ctx, field ?? '', val as Record<string, unknown>));
18!
688
        break;
18✔
689
      default:
690
        throw TypeError(`unknown operator: ${op}`);
4✔
691
    }
692
  }
693

694
  private appendLikeOp(ctx: QueryContext, field: string | undefined, op: string, wrappedVal: string): void {
695
    const isIlike = op.startsWith('$i') || op === '$ilike';
80✔
696
    const ph = this.addValue(ctx.values, wrappedVal);
80✔
697
    if (isIlike && field) {
80✔
698
      ctx.append(this.ilikeExpr(field, ph));
41✔
699
    } else {
700
      this.appendFieldSql(ctx, field, ` ${this.likeFn} ${ph}`);
39✔
701
    }
702
  }
703

704
  private appendEqNe(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
705
    if (val === null) {
1,207✔
706
      this.appendFieldSql(ctx, field, op === '$eq' ? ' IS NULL' : ' IS NOT NULL');
599✔
707
      return;
599✔
708
    }
709
    const ph = this.addValue(ctx.values, val);
608✔
710
    if (op === '$eq') {
608✔
711
      this.appendFieldSql(ctx, field, ` = ${ph}`);
558✔
712
      return;
558✔
713
    }
714
    if (field) {
50!
715
      ctx.append(this.neExpr(field, ph));
50✔
716
    } else {
717
      this.appendFieldSql(ctx, field, ` ${this.neOp} ${ph}`);
×
718
    }
719
  }
720

721
  private appendInNin(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
722
    this.appendFieldSql(ctx, field, this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin'));
421!
723
  }
724

725
  protected addValues(ctx: QueryContext, vals: unknown[]): void {
726
    vals.forEach((val, index) => {
×
727
      if (index > 0) {
×
728
        ctx.append(', ');
×
729
      }
730
      ctx.addValue(val);
×
731
    });
732
  }
733

734
  /**
735
   * Build a comparison condition for a JSON field.
736
   * Used by both `$elemMatch` and dot-notation paths.
737
   * All dialect-specific behavior comes from overridable methods on `this`.
738
   */
739
  protected buildJsonFieldCondition(
740
    ctx: QueryContext,
741
    fieldAccessor: (f: string) => string,
742
    jsonPath: string,
743
    op: string,
744
    value: unknown,
745
  ): string {
746
    const jsonField = fieldAccessor(jsonPath);
111✔
747
    switch (op) {
111✔
748
      case '$eq':
749
        return value === null ? `${jsonField} IS NULL` : `${jsonField} = ${this.addValue(ctx.values, value)}`;
18✔
750
      case '$ne':
751
        if (value === null) return `${jsonField} IS NOT NULL`;
10✔
752
        return this.neExpr(jsonField, this.addValue(ctx.values, value));
9✔
753
      case '$gt':
754
        return `${this.numericCast(jsonField)} > ${this.addValue(ctx.values, value)}`;
8✔
755
      case '$gte':
756
        return `${this.numericCast(jsonField)} >= ${this.addValue(ctx.values, value)}`;
5✔
757
      case '$lt':
758
        return `${this.numericCast(jsonField)} < ${this.addValue(ctx.values, value)}`;
5✔
759
      case '$lte':
760
        return `${this.numericCast(jsonField)} <= ${this.addValue(ctx.values, value)}`;
5✔
761
      case '$like':
762
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, value)}`;
7✔
763
      case '$ilike':
764
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, (value as string).toLowerCase()));
7✔
765
      case '$startsWith':
766
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `${value}%`)}`;
6✔
767
      case '$istartsWith':
768
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `${(value as string).toLowerCase()}%`));
4✔
769
      case '$endsWith':
770
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}`)}`;
5✔
771
      case '$iendsWith':
772
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${(value as string).toLowerCase()}`));
4✔
773
      case '$includes':
774
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}%`)}`;
5✔
775
      case '$iincludes':
776
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${(value as string).toLowerCase()}%`));
4✔
777
      case '$regex':
778
        return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
5✔
779
      case '$in':
780
      case '$nin':
781
        return this.jsonInNin(ctx, jsonField, op, value);
9✔
782
      case '$all':
783
        return this.jsonAll(ctx, jsonField, value);
1✔
784
      case '$size':
785
        return this.jsonSize(ctx, jsonField, value as number | QuerySizeComparisonOps);
1✔
786
      case '$elemMatch':
787
        return this.jsonElemMatch(ctx, jsonField, value as Record<string, unknown>);
1✔
788
      default:
789
        throw TypeError(`unknown operator: ${op}`);
1✔
790
    }
791
  }
792

793
  private jsonInNin(ctx: QueryContext, jsonField: string, op: string, value: unknown): string {
794
    return `${jsonField}${this.formatIn(ctx, Array.isArray(value) ? value : [], op === '$nin')}`;
9!
795
  }
796

797
  protected jsonAll(ctx: QueryContext, jsonField: string, value: unknown): string {
798
    throw TypeError(`$all is not supported in the base SQL dialect - override in dialect subclass`);
1✔
799
  }
800

801
  protected jsonSize(ctx: QueryContext, jsonField: string, value: number | QuerySizeComparisonOps): string {
802
    throw TypeError(`$size is not supported in the base SQL dialect - override in dialect subclass`);
1✔
803
  }
804

805
  protected jsonElemMatch(ctx: QueryContext, jsonField: string, value: Record<string, unknown>): string {
806
    throw TypeError(`$elemMatch is not supported in the base SQL dialect - override in dialect subclass`);
1✔
807
  }
808

809
  protected isJsonbOp(op: string): boolean {
810
    return op === '$all' || op === '$size' || op === '$elemMatch';
42✔
811
  }
812

813
  getComparisonKey<E>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, { prefix }: QueryOptions = {}): void {
33✔
814
    const meta = getMeta(entity);
33✔
815
    const escapedPrefix = this.escapeId(prefix as string, true, true);
33✔
816
    const field = meta.fields[key];
33✔
817

818
    if (field?.virtual) {
33✔
819
      this.getRawValue(ctx, {
4✔
820
        value: field.virtual,
821
        prefix,
822
        escapedPrefix,
823
      });
824
      return;
4✔
825
    }
826

827
    const columnName = this.resolveColumnName(key, field);
29✔
828
    ctx.append(escapedPrefix + this.escapeId(columnName));
29✔
829
  }
830

831
  sort<E>(ctx: QueryContext, entity: Type<E>, sort: QuerySortMap<E> | undefined, { prefix }: QueryOptions): void {
832
    const sortMap = buildSortMap(sort);
5,398✔
833
    if (!hasKeys(sortMap)) {
5,398✔
834
      return;
5,291✔
835
    }
836
    const meta = getMeta(entity);
107✔
837

838
    // Separate vector search entries from direction entries before flattening,
839
    // because flatObject recursively destructures objects — it would break QueryVectorSearch.
840
    const vectorEntries: [string, QueryVectorSearch][] = [];
107✔
841
    const directionEntries: Record<string, unknown> = {};
107✔
842
    for (const [key, val] of Object.entries(sortMap)) {
107✔
843
      if (isVectorSearch(val)) {
182✔
844
        vectorEntries.push([key, val]);
26✔
845
      } else {
846
        directionEntries[key] = val;
156✔
847
      }
848
    }
849

850
    const flattenedSort = flatObject(directionEntries, prefix);
107✔
851

852
    // Merge: vector entries first (primary ordering), then flattened direction entries.
853
    const allEntries: [string, unknown][] = [...vectorEntries, ...Object.entries(flattenedSort)];
107✔
854

855
    if (!allEntries.length) return;
107!
856

857
    ctx.append(' ORDER BY ');
107✔
858

859
    allEntries.forEach(([key, sort], index) => {
107✔
860
      if (index > 0) {
182✔
861
        ctx.append(', ');
75✔
862
      }
863

864
      if (isVectorSearch(sort)) {
182✔
865
        if (sort.$project) {
26✔
866
          // Distance already projected in SELECT — reference the alias to avoid recomputation
867
          ctx.append(this.escapeId(sort.$project));
5✔
868
        } else {
869
          this.appendVectorSort(ctx, meta, key, sort);
21✔
870
        }
871
        return;
23✔
872
      }
873

874
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[sort as QuerySortDirection];
156✔
875

876
      // Detect JSONB dot-notation: 'column.path'
877
      const jsonDot = this.resolveJsonDotPath(meta, key);
156✔
878
      if (jsonDot) {
156✔
879
        ctx.append(jsonDot.accessor() + direction);
8✔
880
        return;
8✔
881
      }
882

883
      const field = meta.fields[key as Key<E>];
148✔
884
      const name = this.resolveColumnName(key, field);
148✔
885
      ctx.append(this.escapeId(name) + direction);
148✔
886
    });
887
  }
888

889
  /**
890
   * Resolve common parameters for a vector similarity ORDER BY expression.
891
   * Shared by all dialect overrides of `appendVectorSort`.
892
   */
893
  protected resolveVectorSortParams<E>(
894
    meta: EntityMeta<E>,
895
    key: string,
896
    search: QueryVectorSearch,
897
  ): { colName: string; distance: VectorDistance; field: FieldOptions | undefined; vectorCast: VectorCast } {
898
    const field = meta.fields[key as FieldKey<E>];
25✔
899
    const colName = this.resolveColumnName(key, field);
25✔
900
    const distance = search.$distance ?? field?.distance ?? 'cosine';
25✔
901
    const vectorCast = resolveVectorCast(field);
25✔
902
    return { colName, distance, field, vectorCast };
25✔
903
  }
904

905
  /**
906
   * Mapping of UQL vector distance metrics to native SQL functions.
907
   * Override in dialects that use function-call syntax (e.g. SQLite, MariaDB).
908
   * Dialects with operator-based syntax (e.g. Postgres) leave this empty and override `appendVectorSort` directly.
909
   */
910
  protected readonly vectorDistanceFns: Partial<Record<VectorDistance, string>> = {};
60✔
911

912
  /**
913
   * Append a vector similarity function call: `fn(col, ?)`.
914
   * Used by dialects that express vector distance via SQL functions (SQLite, MariaDB).
915
   */
916
  protected appendFunctionVectorSort<E>(
917
    ctx: QueryContext,
918
    meta: EntityMeta<E>,
919
    key: string,
920
    search: QueryVectorSearch,
921
    dialectName: string,
922
  ): void {
923
    const { colName, distance, vectorCast } = this.resolveVectorSortParams(meta, key, search);
12✔
924
    const fn = this.vectorDistanceFns[distance];
12✔
925

926
    if (!fn) {
12✔
927
      throw Error(`${dialectName} does not support vector distance metric: ${distance}`);
2✔
928
    }
929

930
    ctx.append(`${fn}(${this.escapeId(colName)}, `);
10✔
931
    ctx.addValue(`[${search.$vector.join(',')}]`);
10✔
932
    if (vectorCast && dialectName === 'PostgreSQL') {
10!
933
      ctx.append(`::${vectorCast}`);
×
934
    }
935
    ctx.append(')');
10✔
936
  }
937

938
  /**
939
   * Append a vector distance projection.
940
   */
941
  protected appendVectorProjection<E>(
942
    ctx: QueryContext,
943
    meta: EntityMeta<E>,
944
    key: string,
945
    search: QueryVectorSearch,
946
  ): void {
947
    this.appendVectorSort(ctx, meta, key, search);
5✔
948
    ctx.append(` AS ${this.escapeId(search.$project as string)}`);
5✔
949
  }
950

951
  /**
952
   * Append a vector similarity ORDER BY expression.
953
   * Default: auto-delegates to `appendFunctionVectorSort` when `vectorDistanceFns` has entries.
954
   * Override for operator-based syntax (e.g. PostgreSQL `<=>`, `<->` operators).
955
   */
956
  protected appendVectorSort<E>(ctx: QueryContext, meta: EntityMeta<E>, key: string, search: QueryVectorSearch): void {
957
    if (hasKeys(this.vectorDistanceFns)) {
13✔
958
      this.appendFunctionVectorSort(ctx, meta, key, search, this.dialectName);
12✔
959
      return;
12✔
960
    }
961
    throw new TypeError('Vector similarity sort is not supported by this dialect. Use raw() for vector queries.');
1✔
962
  }
963

964
  pager(ctx: QueryContext, opts: QueryPager): void {
965
    if (opts.$limit) {
5,452✔
966
      ctx.append(` LIMIT ${Number(opts.$limit)}`);
285✔
967
    }
968
    if (opts.$skip !== undefined) {
5,452✔
969
      ctx.append(` OFFSET ${Number(opts.$skip)}`);
71✔
970
    }
971
  }
972

973
  count<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void {
974
    const search: Query<E> = { ...q };
144✔
975
    delete search.$sort;
144✔
976
    this.select<E>(ctx, entity, [raw('COUNT(*)', 'count')]);
144✔
977
    this.search(ctx, entity, search, opts);
144✔
978
  }
979

980
  aggregate<E>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E>, opts: QueryOptions = {}): void {
57✔
981
    const meta = getMeta(entity);
57✔
982
    const tableName = this.resolveTableName(entity, meta);
57✔
983
    const groupKeys: string[] = [];
57✔
984
    const selectParts: string[] = [];
57✔
985
    const aggregateExpressions: Record<string, string> = {};
57✔
986

987
    for (const entry of parseGroupMap(q.$group)) {
57✔
988
      if (entry.kind === 'key') {
127✔
989
        const field = meta.fields[entry.alias as FieldKey<E>];
49✔
990
        const columnName = this.resolveColumnName(entry.alias, field);
49✔
991
        const escaped = this.escapeId(columnName);
49✔
992
        groupKeys.push(escaped);
49✔
993
        selectParts.push(columnName !== entry.alias ? `${escaped} ${this.escapeId(entry.alias)}` : escaped);
49!
994
      } else {
995
        const sqlFn = entry.op.slice(1).toUpperCase();
78✔
996
        const sqlArg =
997
          entry.fieldRef === '*'
78✔
998
            ? '*'
999
            : this.escapeId(this.resolveColumnName(entry.fieldRef, meta.fields[entry.fieldRef as FieldKey<E>]));
1000
        const expr = `${sqlFn}(${sqlArg})`;
78✔
1001
        aggregateExpressions[entry.alias] = expr;
78✔
1002
        selectParts.push(`${expr} ${this.escapeId(entry.alias)}`);
78✔
1003
      }
1004
    }
1005

1006
    ctx.append(`SELECT ${selectParts.join(', ')} FROM ${this.escapeId(tableName)}`);
57✔
1007
    this.where<E>(ctx, entity, q.$where, opts);
57✔
1008

1009
    if (groupKeys.length) {
57✔
1010
      ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
49✔
1011
    }
1012

1013
    if (q.$having) {
57✔
1014
      this.having(ctx, q.$having, aggregateExpressions);
28✔
1015
    }
1016

1017
    this.aggregateSort(ctx, q.$sort, aggregateExpressions);
57✔
1018
    this.pager(ctx, q);
57✔
1019
  }
1020

1021
  /**
1022
   * ORDER BY for aggregate queries — handles both entity-field and alias references.
1023
   */
1024
  private aggregateSort(
1025
    ctx: QueryContext,
1026
    sort: QuerySortMap<object> | undefined,
1027
    aggregateExpressions: Record<string, string>,
1028
  ): void {
1029
    const sortMap = buildSortMap(sort);
57✔
1030
    if (!hasKeys(sortMap)) return;
57✔
1031

1032
    ctx.append(' ORDER BY ');
16✔
1033
    Object.entries(sortMap).forEach(([key, dir], index) => {
16✔
1034
      if (index > 0) ctx.append(', ');
25✔
1035
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[dir as QuerySortDirection];
25✔
1036
      const ref = aggregateExpressions[key] ?? this.escapeId(key);
25✔
1037
      ctx.append(ref + direction);
25✔
1038
    });
1039
  }
1040

1041
  protected having(ctx: QueryContext, having: QueryHavingMap, aggregateExpressions: Record<string, string>): void {
1042
    const entries = Object.entries(having).filter(([, v]) => v !== undefined);
31✔
1043
    if (!entries.length) return;
28!
1044

1045
    ctx.append(' HAVING ');
28✔
1046
    entries.forEach(([alias, condition], index) => {
28✔
1047
      if (index > 0) ctx.append(' AND ');
31✔
1048
      const expr = aggregateExpressions[alias] ?? this.escapeId(alias);
31!
1049
      this.havingCondition(ctx, expr, condition!);
31✔
1050
    });
1051
  }
1052

1053
  private static readonly SORT_DIRECTION_MAP: Record<string | number, string> = Object.assign(
60✔
1054
    { 1: '', asc: '', desc: ' DESC', '-1': ' DESC' },
1055
    { [-1]: ' DESC' },
1056
  );
1057

1058
  private static readonly havingOpMap: Record<string, string> = {
60✔
1059
    $eq: '=',
1060
    $ne: '<>',
1061
    $gt: '>',
1062
    $gte: '>=',
1063
    $lt: '<',
1064
    $lte: '<=',
1065
  };
1066

1067
  protected havingCondition(ctx: QueryContext, expr: string, condition: QueryHavingMap[string]): void {
1068
    if (typeof condition !== 'object' || condition === null) {
31✔
1069
      ctx.append(`${expr} = `);
3✔
1070
      ctx.addValue(condition);
3✔
1071
      return;
3✔
1072
    }
1073
    const ops = condition as QueryWhereFieldOperatorMap<number>;
28✔
1074
    const keys = getKeys(ops);
28✔
1075
    keys.forEach((op, i) => {
28✔
1076
      if (i > 0) ctx.append(' AND ');
28!
1077
      const val = ops[op];
28✔
1078
      if (op === '$between') {
28✔
1079
        const [min, max] = val as [number, number];
3✔
1080
        ctx.append(`${expr} BETWEEN `);
3✔
1081
        ctx.addValue(min);
3✔
1082
        ctx.append(' AND ');
3✔
1083
        ctx.addValue(max);
3✔
1084
      } else if (op === '$in' || op === '$nin') {
25✔
1085
        ctx.append(`${expr}${this.formatIn(ctx, Array.isArray(val) ? (val as unknown[]) : [], op === '$nin')}`);
9!
1086
      } else if (op === '$isNull') {
16✔
1087
        ctx.append(`${expr}${val ? ' IS NULL' : ' IS NOT NULL'}`);
3!
1088
      } else if (op === '$isNotNull') {
13✔
1089
        ctx.append(`${expr}${val ? ' IS NOT NULL' : ' IS NULL'}`);
3!
1090
      } else if (op === '$ne') {
10!
1091
        ctx.append(this.neExpr(expr, this.addValue(ctx.values, val)));
×
1092
      } else {
1093
        const sqlOp = AbstractSqlDialect.havingOpMap[op];
10✔
1094
        if (!sqlOp) throw TypeError(`unsupported HAVING operator: ${op}`);
10!
1095
        ctx.append(`${expr} ${sqlOp} `);
10✔
1096
        ctx.addValue(val);
10✔
1097
      }
1098
    });
1099
  }
1100

1101
  find<E>(ctx: QueryContext, entity: Type<E>, q: Query<E> = {}, opts?: QueryOptions): void {
3,846✔
1102
    this.select(ctx, entity, q.$select, q.$exclude, q.$populate, opts, q.$distinct, q.$sort);
3,846✔
1103
    this.search(ctx, entity, q, opts);
3,846✔
1104
  }
1105

1106
  insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void {
1107
    const meta = getMeta(entity);
397✔
1108
    const payloads = fillOnFields(meta, payload, 'onInsert');
397✔
1109
    const keys = filterFieldKeys(meta, payloads[0], 'onInsert');
397✔
1110

1111
    const columns = keys.map((key) => {
397✔
1112
      const field = meta.fields[key];
1,103✔
1113
      return this.escapeId(this.resolveColumnName(key, field));
1,103✔
1114
    });
1115
    const tableName = this.resolveTableName(entity, meta);
397✔
1116
    ctx.append(`INSERT INTO ${this.escapeId(tableName)} (${columns.join(', ')}) VALUES (`);
397✔
1117

1118
    payloads.forEach((it, recordIndex) => {
397✔
1119
      if (recordIndex > 0) {
566✔
1120
        ctx.append('), (');
169✔
1121
      }
1122
      keys.forEach((key, keyIndex) => {
566✔
1123
        if (keyIndex > 0) {
1,581✔
1124
          ctx.append(', ');
1,015✔
1125
        }
1126
        const field = meta.fields[key];
1,581✔
1127
        this.formatPersistableValue(ctx, field, it[key]);
1,581✔
1128
      });
1129
    });
1130
    ctx.append(')');
397✔
1131
  }
1132

1133
  update<E>(
1134
    ctx: QueryContext,
1135
    entity: Type<E>,
1136
    q: QuerySearch<E>,
1137
    payload: UpdatePayload<E>,
1138
    opts?: QueryOptions,
1139
  ): void {
1140
    const meta = getMeta(entity);
121✔
1141
    const [filledPayload] = fillOnFields(meta, payload as E, 'onUpdate');
121✔
1142
    const keys = filterFieldKeys(meta, filledPayload, 'onUpdate');
121✔
1143

1144
    const tableName = this.resolveTableName(entity, meta);
121✔
1145
    ctx.append(`UPDATE ${this.escapeId(tableName)} SET `);
121✔
1146
    keys.forEach((key, index) => {
121✔
1147
      if (index > 0) {
231✔
1148
        ctx.append(', ');
110✔
1149
      }
1150
      const field = meta.fields[key];
231✔
1151
      const columnName = this.resolveColumnName(key, field);
231✔
1152
      const escapedCol = this.escapeId(columnName);
231✔
1153
      const value = filledPayload[key];
231✔
1154

1155
      if (this.isJsonUpdateOp(value)) {
231✔
1156
        this.formatJsonUpdate<E>(ctx, escapedCol, value);
50✔
1157
      } else {
1158
        ctx.append(`${escapedCol} = `);
181✔
1159
        this.formatPersistableValue(ctx, field, value);
181✔
1160
      }
1161
    });
1162

1163
    this.search(ctx, entity, q, opts);
121✔
1164
  }
1165

1166
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void {
1167
    const meta = getMeta(entity);
7✔
1168
    const updateCtx = this.createContext();
7✔
1169
    const update = this.getUpsertUpdateAssignments(
7✔
1170
      updateCtx,
1171
      meta,
1172
      conflictPaths,
1173
      payload,
1174
      (name) => `VALUES(${name})`,
8✔
1175
    );
1176

1177
    if (update) {
7✔
1178
      this.insert(ctx, entity, payload);
6✔
1179
      ctx.append(` ON DUPLICATE KEY UPDATE ${update}`);
6✔
1180
      ctx.pushValue(...updateCtx.values);
6✔
1181
    } else {
1182
      const insertCtx = this.createContext();
1✔
1183
      this.insert(insertCtx, entity, payload);
1✔
1184
      ctx.append(insertCtx.sql.replace(/^INSERT/, 'INSERT IGNORE'));
1✔
1185
      ctx.pushValue(...insertCtx.values);
1✔
1186
    }
1187
  }
1188

1189
  protected getUpsertUpdateAssignments<E>(
1190
    ctx: QueryContext,
1191
    meta: EntityMeta<E>,
1192
    conflictPaths: QueryConflictPaths<E>,
1193
    payload: E | E[],
1194
    callback?: (columnName: string) => string,
1195
  ): string {
1196
    const sample = Array.isArray(payload) ? payload[0] : payload;
38✔
1197
    const cloned = { ...sample };
38✔
1198
    const [filledPayload] = fillOnFields(meta, cloned, 'onUpdate');
38✔
1199
    const fields = filterFieldKeys(meta, filledPayload, 'onUpdate');
38✔
1200
    return fields
38✔
1201
      .filter((col) => !conflictPaths[col])
105✔
1202
      .map((col) => {
1203
        const field = meta.fields[col];
72✔
1204
        const columnName = this.resolveColumnName(col, field);
72✔
1205
        if (callback && Object.hasOwn(sample as object, col)) {
72✔
1206
          return `${this.escapeId(columnName)} = ${callback(this.escapeId(columnName))}`;
40✔
1207
        }
1208
        const valCtx = this.createContext();
32✔
1209
        this.formatPersistableValue(valCtx, field, filledPayload[col]);
32✔
1210
        ctx.pushValue(...valCtx.values);
32✔
1211
        return `${this.escapeId(columnName)} = ${valCtx.sql}`;
32✔
1212
      })
1213
      .join(', ');
1214
  }
1215

1216
  /**
1217
   * Shared ON CONFLICT ... DO UPDATE / DO NOTHING logic for positional-placeholder dialects (SQLite).
1218
   * Uses a deferred context for update params so they follow INSERT params.
1219
   * PG uses its own implementation since `$N` numbered placeholders handle param ordering natively.
1220
   */
1221
  protected onConflictUpsert<E>(
1222
    ctx: QueryContext,
1223
    entity: Type<E>,
1224
    conflictPaths: QueryConflictPaths<E>,
1225
    payload: E | E[],
1226
    insertFn: (ctx: QueryContext, entity: Type<E>, payload: E | E[]) => void,
1227
  ): void {
1228
    const meta = getMeta(entity);
9✔
1229
    const updateCtx = this.createContext();
9✔
1230
    const update = this.getUpsertUpdateAssignments(
9✔
1231
      updateCtx,
1232
      meta,
1233
      conflictPaths,
1234
      payload,
1235
      (name) => `EXCLUDED.${name}`,
10✔
1236
    );
1237
    const keysStr = this.getUpsertConflictPathsStr(meta, conflictPaths);
9✔
1238
    const onConflict = update ? `DO UPDATE SET ${update}` : 'DO NOTHING';
9✔
1239
    insertFn(ctx, entity, payload);
9✔
1240
    ctx.append(` ON CONFLICT (${keysStr}) ${onConflict}`);
9✔
1241
    ctx.pushValue(...updateCtx.values);
9✔
1242
  }
1243

1244
  protected getUpsertConflictPathsStr<E>(meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>): string {
1245
    return (getKeys(conflictPaths) as Key<E>[])
23✔
1246
      .map((key) => {
1247
        const field = meta.fields[key];
25✔
1248
        const columnName = this.resolveColumnName(key, field);
25✔
1249
        return this.escapeId(columnName);
25✔
1250
      })
1251
      .join(', ');
1252
  }
1253

1254
  delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts: QueryOptions = {}): void {
1,294✔
1255
    const meta = getMeta(entity);
1,294✔
1256
    const tableName = this.resolveTableName(entity, meta);
1,294✔
1257

1258
    if (opts.softDelete || opts.softDelete === undefined) {
1,294✔
1259
      if (meta.softDelete) {
1,288✔
1260
        const field = meta.fields[meta.softDelete];
150✔
1261
        if (!field?.onDelete) return;
150!
1262
        const value = getFieldCallbackValue(field.onDelete);
150✔
1263
        const columnName = this.resolveColumnName(meta.softDelete, field);
150✔
1264
        ctx.append(`UPDATE ${this.escapeId(tableName)} SET ${this.escapeId(columnName)} = `);
150✔
1265
        ctx.addValue(value);
150✔
1266
        this.search(ctx, entity, q, opts);
150✔
1267
        return;
150✔
1268
      }
1269
      if (opts.softDelete) {
1,138✔
1270
        throw TypeError(`'${tableName}' has not enabled 'softDelete'`);
3✔
1271
      }
1272
    }
1273

1274
    ctx.append(`DELETE FROM ${this.escapeId(tableName)}`);
1,141✔
1275
    this.search(ctx, entity, q, opts);
1,141✔
1276
  }
1277

1278
  escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string {
1279
    return escapeSqlId(val, this.escapeIdChar, forbidQualified, addDot);
23,253✔
1280
  }
1281

1282
  protected getPersistables<E>(
1283
    ctx: QueryContext,
1284
    meta: EntityMeta<E>,
1285
    payload: E | E[],
1286
    callbackKey: CallbackKey,
1287
  ): Record<string, unknown>[] {
1288
    const payloads = fillOnFields(meta, payload, callbackKey);
1✔
1289
    return payloads.map((it) => this.getPersistable(ctx, meta, it, callbackKey));
1✔
1290
  }
1291

1292
  protected getPersistable<E>(
1293
    ctx: QueryContext,
1294
    meta: EntityMeta<E>,
1295
    payload: E,
1296
    callbackKey: CallbackKey,
1297
  ): Record<string, unknown> {
1298
    const filledPayload = fillOnFields(meta, payload, callbackKey)[0];
1✔
1299
    const keys = filterFieldKeys(meta, filledPayload, callbackKey);
1✔
1300
    return keys.reduce(
1✔
1301
      (acc, key) => {
1302
        const field = meta.fields[key];
2✔
1303
        const valCtx = this.createContext();
2✔
1304
        this.formatPersistableValue(valCtx, field, filledPayload[key]);
2✔
1305
        ctx.pushValue(...valCtx.values);
2✔
1306
        acc[key] = valCtx.sql;
2✔
1307
        return acc;
2✔
1308
      },
1309
      {} as Record<string, unknown>,
1310
    );
1311
  }
1312

1313
  protected formatPersistableValue<E>(ctx: QueryContext, field: FieldOptions | undefined, value: unknown): void {
1314
    if (value instanceof QueryRaw) {
1,777✔
1315
      this.getRawValue(ctx, { value });
4✔
1316
      return;
4✔
1317
    }
1318
    if (isJsonType(field?.type)) {
1,773✔
1319
      ctx.addValue(value == null ? null : JSON.stringify(value));
12✔
1320
      return;
12✔
1321
    }
1322
    if (field?.type === 'vector' && Array.isArray(value)) {
1,761✔
1323
      ctx.addValue(`[${value.join(',')}]`);
1✔
1324
      return;
1✔
1325
    }
1326
    ctx.addValue(value);
1,760✔
1327
  }
1328

1329
  /**
1330
   * Generate SQL for a JSONB merge and/or unset operation.
1331
   * Called from `update()` when a field value has `$merge`, `$unset`, and/or `$push` operators.
1332
   * Generates the full `"col" = <expression>` assignment.
1333
   *
1334
   * Base implementation uses MySQL-compatible syntax with *shallow* merge semantics
1335
   * (RHS top-level keys replace LHS top-level keys, matching PostgreSQL's `jsonb || jsonb`).
1336
   * Override in dialect subclasses when a dialect needs different JSON function semantics.
1337
   */
1338
  protected getJsonCastExpr(): string {
1339
    return 'CAST(? AS JSON)';
12✔
1340
  }
1341

1342
  protected formatJsonUpdate<E>(ctx: QueryContext, escapedCol: string, value: JsonUpdateOp<E>): void {
1343
    let expr = escapedCol;
18✔
1344
    if (hasKeys(value.$merge)) {
18✔
1345
      const merge = value.$merge as Record<string, unknown>;
10✔
1346
      expr = `JSON_SET(COALESCE(${escapedCol}, '{}')`;
10✔
1347
      for (const [key, v] of Object.entries(merge)) {
10✔
1348
        expr += `, '$.${this.escapeJsonKey(key)}', ${this.getJsonCastExpr()}`;
10✔
1349
        ctx.pushValue(JSON.stringify(v));
10✔
1350
      }
1351
      expr += ')';
10✔
1352
    }
1353
    if (hasKeys(value.$push)) {
18✔
1354
      const push = value.$push as Record<string, unknown>;
6✔
1355
      expr = `JSON_ARRAY_APPEND(${expr}`;
6✔
1356
      for (const [key, v] of Object.entries(push)) {
6✔
1357
        expr += `, '$.${this.escapeJsonKey(key)}', ${this.getJsonCastExpr()}`;
6✔
1358
        ctx.pushValue(JSON.stringify(v));
6✔
1359
      }
1360
      expr += ')';
6✔
1361
    }
1362
    if (value.$unset?.length) {
18✔
1363
      for (const key of value.$unset) {
6✔
1364
        expr = `JSON_REMOVE(${expr}, '$.${this.escapeJsonKey(key)}')`;
7✔
1365
      }
1366
    }
1367
    ctx.append(`${escapedCol} = ${expr}`);
18✔
1368
  }
1369

1370
  protected isJsonUpdateOp(value: unknown): value is JsonUpdateOp {
1371
    return typeof value === 'object' && value !== null && ('$merge' in value || '$unset' in value || '$push' in value);
231✔
1372
  }
1373

1374
  /** Escapes a JSON key for safe interpolation into SQL string literals. */
1375
  protected escapeJsonKey(key: string): string {
1376
    return key.replace(/'/g, "''");
195✔
1377
  }
1378

1379
  getRawValue(ctx: QueryContext, opts: QueryRawFnOptions & { value: QueryRaw; autoPrefixAlias?: boolean }) {
1380
    const { value, prefix = '', escapedPrefix, autoPrefixAlias } = opts;
232✔
1381
    const rawValue = value[RAW_VALUE];
232✔
1382
    if (typeof rawValue === 'function') {
232✔
1383
      const res = rawValue({
49✔
1384
        ...opts,
1385
        ctx,
1386
        dialect: this,
1387
        prefix,
1388
        escapedPrefix: escapedPrefix ?? this.escapeId(prefix, true, true),
71✔
1389
      });
1390
      if (typeof res === 'string' || (typeof res === 'number' && !Number.isNaN(res))) {
49✔
1391
        ctx.append(String(res));
10✔
1392
      }
1393
    } else {
1394
      ctx.append(prefix + String(rawValue));
183✔
1395
    }
1396
    const alias = value[RAW_ALIAS];
232✔
1397
    if (alias) {
232✔
1398
      const fullAlias = autoPrefixAlias && prefix ? `${prefix}.${alias}` : alias;
171✔
1399
      ctx.append(' ' + this.escapeId(fullAlias, true));
171✔
1400
    }
1401
  }
1402

1403
  /**
1404
   * Resolves a dot-notation key to its JSON field metadata.
1405
   * Shared by `where()` and `sort()` to detect 'column.path' keys where 'column' is a JSON/JSONB field.
1406
   *
1407
   * @returns resolved metadata or `undefined` if the key is not a JSON dot-notation path
1408
   */
1409
  protected resolveJsonDotPath<E>(
1410
    meta: EntityMeta<E>,
1411
    key: string,
1412
    prefix?: string,
1413
  ):
1414
    | {
1415
        root: string;
1416
        jsonPath: string;
1417
        accessor: (asJsonb?: boolean) => string;
1418
      }
1419
    | undefined {
1420
    const dotIndex = key.indexOf('.');
1,980✔
1421
    if (dotIndex <= 0) {
1,980✔
1422
      return undefined;
1,923✔
1423
    }
1424
    const root = key.slice(0, dotIndex);
57✔
1425
    const field = meta.fields[root as FieldKey<E>];
57✔
1426
    if (!field || !isJsonType(field.type)) {
57✔
1427
      return undefined;
9✔
1428
    }
1429
    const jsonPath = key.slice(dotIndex + 1);
48✔
1430
    const colName = this.resolveColumnName(root, field);
48✔
1431
    const escapedCol = (prefix ? this.escapeId(prefix, true, true) : '') + this.escapeId(colName);
48!
1432
    return {
1,980✔
1433
      root,
1434
      jsonPath,
1435
      accessor: (asJsonb?: boolean) =>
1436
        asJsonb ? this.getJsonPathJsonbExpr(escapedCol, jsonPath) : this.getJsonPathScalarExpr(escapedCol, jsonPath),
50✔
1437
    };
1438
  }
1439

1440
  /**
1441
   * Compare a JSONB dot-notation path, e.g. `'settings.isArchived': { $ne: true }`.
1442
   * Receives a pre-resolved `resolveJsonDotPath` result to avoid redundant computation.
1443
   */
1444
  protected compareJsonPath(
1445
    ctx: QueryContext,
1446
    resolved: {
1447
      jsonPath: string;
1448
      accessor: (asJsonb?: boolean) => string;
1449
    },
1450
    val: unknown,
1451
  ): void {
1452
    const { jsonPath, accessor } = resolved;
40✔
1453
    const value = this.normalizeWhereValue(val);
40✔
1454
    const operators = getKeys(value);
40✔
1455

1456
    if (operators.length > 1) {
40✔
1457
      ctx.append('(');
2✔
1458
    }
1459

1460
    operators.forEach((op, index) => {
40✔
1461
      if (index > 0) ctx.append(' AND ');
42✔
1462
      const sql = this.buildJsonFieldCondition(ctx, (f) => accessor(this.isJsonbOp(op)), jsonPath, op, value[op]);
42✔
1463
      if (sql) {
42✔
1464
        ctx.append(sql);
41✔
1465
      }
1466
    });
1467

1468
    if (operators.length > 1) {
40✔
1469
      ctx.append(')');
2✔
1470
    }
1471
  }
1472

1473
  /**
1474
   * Returns SQL that extracts a scalar value from a JSON path.
1475
   * Dialects can override this to customize path access syntax while preserving
1476
   * the shared comparison/operator pipeline.
1477
   */
1478
  protected getJsonPathScalarExpr(escapedColumn: string, jsonPath: string): string {
1479
    const segments = jsonPath.split('.');
37✔
1480
    let expr = escapedColumn;
37✔
1481
    for (let i = 0; i < segments.length; i++) {
37✔
1482
      const op = i === segments.length - 1 ? '->>' : '->';
42✔
1483
      expr = `(${expr}${op}'${this.escapeJsonKey(segments[i])}')`;
42✔
1484
    }
1485
    return expr;
37✔
1486
  }
1487

1488
  protected getJsonPathJsonbExpr(escapedColumn: string, jsonPath: string): string {
1489
    const segments = jsonPath.split('.');
3✔
1490
    let expr = escapedColumn;
3✔
1491
    for (const segment of segments) {
3✔
1492
      expr = `(${expr}->'${this.escapeJsonKey(segment)}')`;
3✔
1493
    }
1494
    return expr;
3✔
1495
  }
1496

1497
  /**
1498
   * Normalizes a raw WHERE value into an operator map.
1499
   * Arrays become `$in`, scalars/null become `$eq`, objects pass through.
1500
   */
1501
  private normalizeWhereValue(val: unknown): Record<string, unknown> {
1502
    if (Array.isArray(val)) return { $in: val };
1,797✔
1503
    if (typeof val === 'object' && val !== null) return val as Record<string, unknown>;
1,390✔
1504
    return { $eq: val };
1,151✔
1505
  }
1506

1507
  /**
1508
   * Filter by relation using an EXISTS subquery.
1509
   * Supports all cardinalities: mm (via junction), 1m, m1, and 11.
1510
   */
1511
  protected compareRelation<E>(
1512
    ctx: QueryContext,
1513
    entity: Type<E>,
1514
    key: string,
1515
    val: QueryWhereMap<unknown>,
1516
    rel: RelationOptions,
1517
    opts: QueryComparisonOptions,
1518
  ): void {
1519
    const meta = getMeta(entity);
15✔
1520
    const parentTable = this.resolveTableName(entity, meta);
15✔
1521
    const parentId = meta.id!;
15✔
1522
    const escapedParentId =
15✔
1523
      (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
15!
1524
      this.escapeId(parentId);
1525

1526
    if (!rel.references?.length) {
15✔
1527
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1528
    }
1529

1530
    const relatedEntity = rel.entity!();
14✔
1531
    const relatedMeta = getMeta(relatedEntity);
14✔
1532
    const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
14✔
1533

1534
    ctx.append('EXISTS (SELECT 1 FROM ');
14✔
1535

1536
    if (rel.cardinality === 'mm' && rel.through) {
14✔
1537
      // ManyToMany: EXISTS (SELECT 1 FROM JunctionTable WHERE junction.localFk = parent.id AND junction.foreignFk IN (SELECT related.id FROM Related WHERE ...))
1538
      const throughEntity = rel.through();
7✔
1539
      const throughMeta = getMeta(throughEntity);
7✔
1540
      const throughTable = this.resolveTableName(throughEntity, throughMeta);
7✔
1541
      const localFk = rel.references[0].local;
7✔
1542
      const foreignFk = rel.references[1].local;
7✔
1543
      const relatedId = relatedMeta.id!;
7✔
1544

1545
      ctx.append(this.escapeId(throughTable));
7✔
1546
      ctx.append(` WHERE ${this.escapeId(throughTable, false, true)}${this.escapeId(localFk)} = ${escapedParentId}`);
7✔
1547
      ctx.append(` AND ${this.escapeId(throughTable, false, true)}${this.escapeId(foreignFk)} IN (`);
7✔
1548
      ctx.append(
7✔
1549
        `SELECT ${this.escapeId(relatedTable, false, true)}${this.escapeId(relatedId)} FROM ${this.escapeId(relatedTable)}`,
1550
      );
1551
      this.where(ctx, relatedEntity, val as QueryWhere<typeof relatedEntity>, {
7✔
1552
        prefix: relatedTable,
1553
        clause: 'WHERE',
1554
        softDelete: false,
1555
      });
1556
      ctx.append(')');
7✔
1557
    } else {
1558
      // 1m / m1 / 11: EXISTS (SELECT 1 FROM Related WHERE related.fk_or_pk = parent.pk_or_fk AND ...)
1559
      // Left side is always relatedTable.references[0].foreign
1560
      // Right side is the parent's PK (1m) or the parent's FK (m1/11)
1561
      const joinLeft = `${this.escapeId(relatedTable, false, true)}${this.escapeId(rel.references[0].foreign)}`;
7✔
1562
      const joinRight =
1563
        rel.cardinality === '1m'
7✔
1564
          ? escapedParentId
1565
          : (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
3!
1566
            this.escapeId(rel.references[0].local);
1567

1568
      ctx.append(this.escapeId(relatedTable));
7✔
1569
      ctx.append(` WHERE ${joinLeft} = ${joinRight}`);
7✔
1570
      this.where(ctx, relatedEntity, val as QueryWhere<typeof relatedEntity>, {
7✔
1571
        prefix: relatedTable,
1572
        clause: 'AND',
1573
        softDelete: false,
1574
      });
1575
    }
1576

1577
    ctx.append(')');
14✔
1578
  }
1579

1580
  /**
1581
   * Filter by relation size using a `COUNT(*)` subquery.
1582
   * Supports all cardinalities: mm (via junction), 1m.
1583
   */
1584
  protected compareRelationSize<E>(
1585
    ctx: QueryContext,
1586
    entity: Type<E>,
1587
    key: string,
1588
    sizeVal: number | QuerySizeComparisonOps,
1589
    rel: RelationOptions,
1590
    opts: QueryComparisonOptions,
1591
  ): void {
1592
    const meta = getMeta(entity);
12✔
1593
    const parentTable = this.resolveTableName(entity, meta);
12✔
1594
    const parentId = meta.id!;
12✔
1595
    const escapedParentId =
12✔
1596
      (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
12!
1597
      this.escapeId(parentId);
1598

1599
    if (!rel.references?.length) {
12✔
1600
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1601
    }
1602

1603
    const appendSubquery = () => {
11✔
1604
      ctx.append('(SELECT COUNT(*) FROM ');
11✔
1605

1606
      if (rel.cardinality === 'mm' && rel.through) {
11✔
1607
        const throughEntity = rel.through();
5✔
1608
        const throughMeta = getMeta(throughEntity);
5✔
1609
        const throughTable = this.resolveTableName(throughEntity, throughMeta);
5✔
1610
        const localFk = rel.references![0].local;
5✔
1611

1612
        ctx.append(this.escapeId(throughTable));
5✔
1613
        ctx.append(` WHERE ${this.escapeId(throughTable, false, true)}${this.escapeId(localFk)} = ${escapedParentId}`);
5✔
1614
      } else {
1615
        const relatedEntity = rel.entity!();
6✔
1616
        const relatedMeta = getMeta(relatedEntity);
6✔
1617
        const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
6✔
1618
        const joinLeft = `${this.escapeId(relatedTable, false, true)}${this.escapeId(rel.references![0].foreign)}`;
6✔
1619

1620
        ctx.append(this.escapeId(relatedTable));
6✔
1621
        ctx.append(` WHERE ${joinLeft} = ${escapedParentId}`);
6✔
1622
      }
1623

1624
      ctx.append(')');
11✔
1625
    };
1626

1627
    this.buildSizeComparison(ctx, appendSubquery, sizeVal);
11✔
1628
  }
1629

1630
  /**
1631
   * Build a complete `$size` comparison expression.
1632
   * Handles both single and multiple comparison operators by repeating the size expression.
1633
   * @param sizeExprFn - function that appends the size expression to ctx (e.g. `jsonb_array_length("col")`)
1634
   */
1635
  protected buildSizeComparison(
1636
    ctx: QueryContext,
1637
    sizeExprFn: () => void,
1638
    sizeVal: number | QuerySizeComparisonOps,
1639
  ): void {
1640
    if (typeof sizeVal === 'number') {
28✔
1641
      sizeExprFn();
7✔
1642
      ctx.append(' = ');
7✔
1643
      ctx.addValue(sizeVal);
7✔
1644
      return;
7✔
1645
    }
1646

1647
    const entries = Object.entries(sizeVal).filter(([, v]) => v !== undefined);
25✔
1648

1649
    if (entries.length > 1) {
21✔
1650
      ctx.append('(');
4✔
1651
    }
1652

1653
    entries.forEach(([op, val], index) => {
21✔
1654
      if (index > 0) {
25✔
1655
        ctx.append(' AND ');
4✔
1656
      }
1657
      sizeExprFn();
25✔
1658
      this.appendSizeOp(ctx, op, val);
25✔
1659
    });
1660

1661
    if (entries.length > 1) {
21✔
1662
      ctx.append(')');
4✔
1663
    }
1664
  }
1665

1666
  /**
1667
   * Append a single size comparison operator and value to the context.
1668
   */
1669
  private appendSizeOp(ctx: QueryContext, op: string, val: unknown): void {
1670
    switch (op) {
25✔
1671
      case '$eq':
1672
        ctx.append(' = ');
1✔
1673
        ctx.addValue(val);
1✔
1674
        break;
1✔
1675
      case '$ne':
1676
        ctx.append(' <> ');
1✔
1677
        ctx.addValue(val);
1✔
1678
        break;
1✔
1679
      case '$gt':
1680
        ctx.append(' > ');
5✔
1681
        ctx.addValue(val);
5✔
1682
        break;
5✔
1683
      case '$gte':
1684
        ctx.append(' >= ');
6✔
1685
        ctx.addValue(val);
6✔
1686
        break;
6✔
1687
      case '$lt':
1688
        ctx.append(' < ');
1✔
1689
        ctx.addValue(val);
1✔
1690
        break;
1✔
1691
      case '$lte':
1692
        ctx.append(' <= ');
5✔
1693
        ctx.addValue(val);
5✔
1694
        break;
5✔
1695
      case '$between': {
1696
        const [min, max] = val as [number, number];
5✔
1697
        ctx.append(' BETWEEN ');
5✔
1698
        ctx.addValue(min);
5✔
1699
        ctx.append(' AND ');
5✔
1700
        ctx.addValue(max);
5✔
1701
        break;
5✔
1702
      }
1703
      default:
1704
        throw TypeError(`unsupported $size comparison operator: ${op}`);
1✔
1705
    }
1706
  }
1707

1708
  abstract escape(value: unknown): string;
1709

1710
  protected get regexpOp(): string {
1711
    return 'REGEXP';
7✔
1712
  }
1713

1714
  protected get likeFn(): string {
1715
    return 'LIKE';
62✔
1716
  }
1717

1718
  /**
1719
   * Not-equal operator token for non-null comparisons.
1720
   * Postgres uses `IS DISTINCT FROM`; MySQL/Maria uses custom `neExpr`.
1721
   */
1722
  protected get neOp(): string {
1723
    return '<>';
3✔
1724
  }
1725

1726
  protected neExpr(field: string, ph: string): string {
1727
    return `${field} ${this.neOp} ${ph}`;
27✔
1728
  }
1729

1730
  protected ilikeExpr(f: string, ph: string): string {
1731
    return `LOWER(${f}) LIKE ${ph}`;
1✔
1732
  }
1733

1734
  /**
1735
   * Formats an IN/NOT IN expression, binding each value individually.
1736
   * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.
1737
   */
1738
  protected formatIn(ctx: QueryContext, values: unknown[], negate: boolean): string {
1739
    if (values.length === 0) return negate ? ' NOT IN (NULL)' : ' IN (NULL)';
335✔
1740
    const phs = values.map((v) => this.addValue(ctx.values, v)).join(', ');
563✔
1741
    return ` ${negate ? 'NOT IN' : 'IN'} (${phs})`;
330✔
1742
  }
1743

1744
  protected numericCast(expr: string): string {
1745
    return expr;
×
1746
  }
1747

1748
  override toString(): string {
1749
    return this.dialectName;
7✔
1750
  }
1751
}
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