• 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.03
/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
  applyFilters,
50
  buildQueryWhereAsMap,
51
  buildSortMap,
52
  type CallbackKey,
53
  escapeSqlId,
54
  fillOnFields,
55
  filterFieldKeys,
56
  flatObject,
57
  getFieldKeys,
58
  getKeys,
59
  getRelationRequestSummary,
60
  getSoftDeleteValue,
61
  hasKeys,
62
  isJsonType,
63
  isPopulatingRelations,
64
  isVectorSearch,
65
  normalizeScalarFieldSelection,
66
  parseGroupMap,
67
  parseRelationAtKey,
68
  type RelationQuery,
69
  raw,
70
  withoutSoftDeleteFilter,
71
} from '../util/index.js';
72

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

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

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

87
  constructor(engineDefaults: DialectFeatures, options: DialectOptions = {}) {
424✔
88
    super(engineDefaults, options);
424✔
89
  }
90

91
  readonly isolationLevelStrategy: 'inline' | 'set-before' | 'none' = 'inline';
424✔
92

93
  readonly alterColumnStrategy: 'separate-clauses' | 'single-statement' = 'single-statement';
424✔
94

95
  readonly alterColumnSyntax: 'ALTER COLUMN' | 'MODIFY COLUMN' | 'none' = 'ALTER COLUMN';
424✔
96

97
  readonly dropForeignKeySyntax: 'DROP CONSTRAINT' | 'DROP FOREIGN KEY' = 'DROP CONSTRAINT';
424✔
98

99
  readonly dropIndexSyntax: 'on-table' | 'standalone' = 'standalone';
424✔
100

101
  readonly renameTableSyntax: 'rename-table' | 'alter-table' = 'alter-table';
424✔
102

103
  readonly booleanLiteral: 'native' | 'integer' = 'native';
424✔
104

105
  readonly vectorOpsClass: Readonly<Record<VectorDistance, string>> | undefined = undefined;
424✔
106

107
  readonly vectorExtension: string | undefined = undefined;
424✔
108

109
  get escapeIdChar() {
110
    return this.quoteChar;
24,874✔
111
  }
112

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

126
  createContext(): QueryContext {
127
    return new SqlQueryContext(this);
6,294✔
128
  }
129

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

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

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

161
  placeholder(_index: number): string {
162
    return '?';
2,950✔
163
  }
164

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

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

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

193
    let selectArr: (FieldKey<E> | QueryRaw)[];
194

195
    if (select) {
4,343✔
196
      if (Array.isArray(select)) {
4,161✔
197
        // Internal-only path: raw SQL expressions passed as QueryRaw[]
198
        selectArr = select;
160✔
199
      } else {
200
        selectArr = normalizeScalarFieldSelection(meta, select, exclude);
4,001✔
201
      }
202

203
      const id = meta.id;
4,161✔
204
      if (id && opts.prefix && !selectArr.includes(id)) {
4,161✔
205
        selectArr = [id, ...selectArr];
49✔
206
      }
207
    } else {
208
      selectArr = normalizeScalarFieldSelection(meta, undefined, exclude);
182✔
209
    }
210

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

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

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

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

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

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

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

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

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

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

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

378
    for (const relKey of relKeys) {
8,084✔
379
      const relOpts = meta.relations[relKey];
172✔
380
      if (!relOpts?.entity) continue;
172!
381

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

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

391
  where<E>(ctx: QueryContext, entity: Type<E>, where: QueryWhere<E> = {}, opts: QueryWhereOptions = {}): void {
11,646✔
392
    const meta = getMeta(entity);
5,823✔
393
    // Apply this entity's filters once, at the scope entry point; recursion uses `renderWhere`.
394
    const whereMap = applyFilters(meta, buildQueryWhereAsMap(meta, where), opts);
5,823✔
395
    this.renderWhere(ctx, entity, whereMap, opts);
5,823✔
396
  }
397

398
  /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
399
  protected renderWhere<E>(
400
    ctx: QueryContext,
401
    entity: Type<E>,
402
    where: QueryWhere<E> = {},
5,926✔
403
    opts: QueryWhereOptions = {},
5,926✔
404
  ): void {
405
    const meta = getMeta(entity);
5,926✔
406
    const { usePrecedence, clause = 'WHERE' } = opts;
5,926✔
407

408
    where = buildQueryWhereAsMap(meta, where);
5,926✔
409

410
    const entries = Object.entries(where);
5,926✔
411

412
    if (!entries.length) {
5,926✔
413
      return;
4,070✔
414
    }
415

416
    if (clause) {
1,856✔
417
      ctx.append(` ${clause} `);
1,749✔
418
    }
419

420
    if (usePrecedence) {
1,856✔
421
      ctx.append('(');
6✔
422
    }
423

424
    const whereKeys = getKeys(where) as (keyof QueryWhereMap<E>)[];
1,856✔
425
    const hasMultipleKeys = whereKeys.length > 1;
1,856✔
426
    let appended = false;
1,856✔
427
    whereKeys.forEach((key) => {
1,856✔
428
      const val = (where as Record<string, unknown>)[key];
2,079✔
429
      if (val === undefined) return;
2,079✔
430
      if (appended) {
2,078✔
431
        ctx.append(' AND ');
223✔
432
      }
433
      this.compare(ctx, entity, key, val as QueryWhereMap<E>[keyof QueryWhereMap<E>], {
2,078✔
434
        ...opts,
435
        usePrecedence: hasMultipleKeys,
436
      });
437
      appended = true;
2,078✔
438
    });
439

440
    if (usePrecedence) {
1,856✔
441
      ctx.append(')');
6✔
442
    }
443
  }
444

445
  compare<E>(ctx: QueryContext, entity: Type<E>, key: string, val: unknown, opts: QueryComparisonOptions = {}): void {
2,089✔
446
    const meta = getMeta(entity);
2,089✔
447

448
    if (val instanceof QueryRaw) {
2,089✔
449
      if (key === '$exists' || key === '$nexists') {
23✔
450
        ctx.append(key === '$exists' ? 'EXISTS (' : 'NOT EXISTS (');
5✔
451
        const tableName = this.resolveTableName(entity, meta);
5✔
452
        this.getRawValue(ctx, {
5✔
453
          value: val,
454
          prefix: tableName,
455
          escapedPrefix: this.escapeId(tableName, false, true),
456
        });
457
        ctx.append(')');
5✔
458
        return;
5✔
459
      }
460
      this.getComparisonKey(ctx, entity, key as FieldKey<E>, opts);
18✔
461
      ctx.append(' = ');
18✔
462
      this.getRawValue(ctx, { value: val });
18✔
463
      return;
18✔
464
    }
465

466
    if (key === '$text') {
2,066✔
467
      const search = val as QueryTextSearchOptions<E>;
4✔
468
      const searchFields = search.$fields ?? (getFieldKeys(meta.fields) as FieldKey<E>[]);
4!
469
      const fields = searchFields.map((fKey) => {
4✔
470
        const field = meta.fields[fKey];
6✔
471
        const columnName = this.resolveColumnName(fKey, field);
6✔
472
        return this.escapeId(columnName);
6✔
473
      });
474
      ctx.append(`MATCH(${fields.join(', ')}) AGAINST(`);
4✔
475
      ctx.addValue(search.$value);
4✔
476
      ctx.append(')');
4✔
477
      return;
4✔
478
    }
479

480
    if (key === '$and' || key === '$or' || key === '$not' || key === '$nor') {
2,062✔
481
      this.compareLogicalOperator(
79✔
482
        ctx,
483
        entity,
484
        key as '$and' | '$or' | '$not' | '$nor',
485
        val as QueryWhereArray<E>,
486
        opts,
487
      );
488
      return;
79✔
489
    }
490

491
    // Detect JSONB dot-notation: 'column.path' where column is a registered JSON/JSONB field
492
    const jsonDot = this.resolveJsonDotPath(meta, key, opts.prefix);
1,983✔
493
    if (jsonDot) {
1,983✔
494
      this.compareJsonPath(ctx, jsonDot, val);
40✔
495
      return;
40✔
496
    }
497

498
    if (key.includes('.')) {
1,943!
499
      throw new TypeError(`path ${key} does not exist in ${meta.name}`);
×
500
    }
501

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

515
    const value = this.normalizeWhereValue(val);
1,916✔
516
    const operators = getKeys(value) as (keyof QueryWhereFieldOperatorMap<E>)[];
1,916✔
517

518
    if (operators.length > 1) {
1,916✔
519
      ctx.append('(');
46✔
520
    }
521

522
    operators.forEach((op, index) => {
1,916✔
523
      if (index > 0) {
1,962✔
524
        ctx.append(' AND ');
46✔
525
      }
526
      this.compareFieldOperator(
1,962✔
527
        ctx,
528
        entity,
529
        key as FieldKey<E>,
530
        op,
531
        (value as QueryWhereFieldOperatorMap<E>)[op],
532
        opts,
533
      );
534
    });
535

536
    if (operators.length > 1) {
1,916✔
537
      ctx.append(')');
46✔
538
    }
539
  }
540

541
  protected compareLogicalOperator<E>(
542
    ctx: QueryContext,
543
    entity: Type<E>,
544
    key: '$and' | '$or' | '$not' | '$nor',
545
    val: QueryWhereArray<E>,
546
    opts: QueryComparisonOptions,
547
  ): void {
548
    const op = (AbstractSqlDialect.NEGATE_OP_MAP as Record<string, '$and' | '$or'>)[key] ?? (key as '$and' | '$or');
79✔
549
    const negate = key in AbstractSqlDialect.NEGATE_OP_MAP ? 'NOT' : '';
79✔
550

551
    const valArr = val ?? [];
79!
552
    const hasManyItems = valArr.length > 1;
79✔
553

554
    if ((opts.usePrecedence || negate) && hasManyItems) {
79✔
555
      ctx.append((negate ? negate + ' ' : '') + '(');
15✔
556
    } else if (negate) {
64✔
557
      ctx.append(negate + ' ');
12✔
558
    }
559

560
    valArr.forEach((whereEntry, index) => {
79✔
561
      if (index > 0) {
127✔
562
        ctx.append(op === '$or' ? ' OR ' : ' AND ');
48✔
563
      }
564
      if (whereEntry instanceof QueryRaw) {
127✔
565
        this.getRawValue(ctx, {
24✔
566
          value: whereEntry,
567
        });
568
      } else if (whereEntry) {
103!
569
        this.renderWhere(ctx, entity, whereEntry, {
103✔
570
          prefix: opts.prefix,
571
          usePrecedence: hasManyItems && !Array.isArray(whereEntry) && Object.keys(whereEntry as object).length > 1,
255✔
572
          clause: false,
573
        });
574
      }
575
    });
576

577
    if ((opts.usePrecedence || negate) && hasManyItems) {
79✔
578
      ctx.append(')');
15✔
579
    }
580
  }
581

582
  /** Simple comparison operators: `getComparisonKey → op → addValue`. */
583
  private static readonly NEGATE_OP_MAP = { $not: '$and', $nor: '$or' } as const;
61✔
584

585
  private static readonly COMPARE_OP_MAP: Record<string, string> = {
61✔
586
    $gt: ' > ',
587
    $gte: ' >= ',
588
    $lt: ' < ',
589
    $lte: ' <= ',
590
  };
591

592
  private static readonly LIKE_OP_MAP: Record<string, (v: string) => string> = {
61✔
593
    $startsWith: (v) => `${v}%`,
16✔
594
    $istartsWith: (v) => `${v.toLowerCase()}%`,
11✔
595
    $endsWith: (v) => `%${v}`,
9✔
596
    $iendsWith: (v) => `%${v.toLowerCase()}`,
8✔
597
    $includes: (v) => `%${v}%`,
6✔
598
    $iincludes: (v) => `%${v.toLowerCase()}%`,
8✔
599
    $like: (v) => v,
14✔
600
    $ilike: (v) => v.toLowerCase(),
8✔
601
  };
602

603
  protected resolveColumnWithPrefix(entity: Type<unknown>, key: string, { prefix }: QueryOptions = {}): string {
1,959✔
604
    const meta = getMeta(entity);
1,959✔
605
    const field = meta.fields[key];
1,959✔
606
    const columnName = this.resolveColumnName(key, field);
1,959✔
607
    const escapedPrefix = this.escapeId(prefix as string, true, true);
1,959✔
608
    return escapedPrefix + this.escapeId(columnName);
1,959✔
609
  }
610

611
  /**
612
   * Resolves the SQL operand for a field comparison.
613
   * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
614
   */
615
  protected resolveOperandField<E>(
616
    ctx: QueryContext,
617
    entity: Type<E>,
618
    key: string,
619
    opts: QueryOptions,
620
  ): string | undefined {
621
    const col = getMeta(entity).fields[key];
1,961✔
622
    if (col?.virtual) {
1,961✔
623
      if (col.virtual instanceof QueryRaw) {
4!
624
        this.getComparisonKey(ctx, entity, key as FieldKey<E>, opts);
4✔
625
        return undefined;
4✔
626
      }
627
      return `(${col.virtual})`;
×
628
    }
629
    return this.resolveColumnWithPrefix(entity, key, opts);
1,957✔
630
  }
631

632
  private appendFieldSql(ctx: QueryContext, field: string | undefined, sql: string): void {
633
    ctx.append(field ? `${field}${sql}` : sql);
1,814✔
634
  }
635

636
  compareFieldOperator<E, K extends keyof QueryWhereFieldOperatorMap<E>>(
637
    ctx: QueryContext,
638
    entity: Type<E>,
639
    key: FieldKey<E>,
640
    op: K,
641
    val: QueryWhereFieldOperatorMap<E>[K],
642
    opts: QueryOptions = {},
1,961✔
643
  ): void {
644
    const field = this.resolveOperandField(ctx, entity, key as string, opts);
1,961✔
645

646
    const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP[op as string];
1,961✔
647
    if (simpleOp) {
1,961✔
648
      this.appendFieldSql(ctx, field, `${simpleOp}${this.addValue(ctx.values, val)}`);
28✔
649
      return;
28✔
650
    }
651

652
    const likeWrap = AbstractSqlDialect.LIKE_OP_MAP[op as string];
1,933✔
653
    if (likeWrap) {
1,933✔
654
      this.appendLikeOp(ctx, field, op as string, likeWrap(val as string));
80✔
655
      return;
80✔
656
    }
657

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

701
  private appendLikeOp(ctx: QueryContext, field: string | undefined, op: string, wrappedVal: string): void {
702
    const isIlike = op.startsWith('$i') || op === '$ilike';
80✔
703
    const ph = this.addValue(ctx.values, wrappedVal);
80✔
704
    if (isIlike && field) {
80✔
705
      ctx.append(this.ilikeExpr(field, ph));
41✔
706
    } else {
707
      this.appendFieldSql(ctx, field, ` ${this.likeFn} ${ph}`);
39✔
708
    }
709
  }
710

711
  private appendEqNe(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
712
    if (val === null) {
1,337✔
713
      this.appendFieldSql(ctx, field, op === '$eq' ? ' IS NULL' : ' IS NOT NULL');
678✔
714
      return;
678✔
715
    }
716
    const ph = this.addValue(ctx.values, val);
659✔
717
    if (op === '$eq') {
659✔
718
      this.appendFieldSql(ctx, field, ` = ${ph}`);
609✔
719
      return;
609✔
720
    }
721
    if (field) {
50!
722
      ctx.append(this.neExpr(field, ph));
50✔
723
    } else {
724
      this.appendFieldSql(ctx, field, ` ${this.neOp} ${ph}`);
×
725
    }
726
  }
727

728
  private appendInNin(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
729
    this.appendFieldSql(ctx, field, this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin'));
450!
730
  }
731

732
  protected addValues(ctx: QueryContext, vals: unknown[]): void {
733
    vals.forEach((val, index) => {
×
734
      if (index > 0) {
×
735
        ctx.append(', ');
×
736
      }
737
      ctx.addValue(val);
×
738
    });
739
  }
740

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

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

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

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

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

816
  protected isJsonbOp(op: string): boolean {
817
    return op === '$all' || op === '$size' || op === '$elemMatch';
42✔
818
  }
819

820
  getComparisonKey<E>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, { prefix }: QueryOptions = {}): void {
33✔
821
    const meta = getMeta(entity);
33✔
822
    const escapedPrefix = this.escapeId(prefix as string, true, true);
33✔
823
    const field = meta.fields[key];
33✔
824

825
    if (field?.virtual) {
33✔
826
      this.getRawValue(ctx, {
4✔
827
        value: field.virtual,
828
        prefix,
829
        escapedPrefix,
830
      });
831
      return;
4✔
832
    }
833

834
    const columnName = this.resolveColumnName(key, field);
29✔
835
    ctx.append(escapedPrefix + this.escapeId(columnName));
29✔
836
  }
837

838
  sort<E>(ctx: QueryContext, entity: Type<E>, sort: QuerySortMap<E> | undefined, { prefix }: QueryOptions): void {
839
    const sortMap = buildSortMap(sort);
5,692✔
840
    if (!hasKeys(sortMap)) {
5,692✔
841
      return;
5,585✔
842
    }
843
    const meta = getMeta(entity);
107✔
844

845
    // Separate vector search entries from direction entries before flattening,
846
    // because flatObject recursively destructures objects - it would break QueryVectorSearch.
847
    const vectorEntries: [string, QueryVectorSearch][] = [];
107✔
848
    const directionEntries: Record<string, unknown> = {};
107✔
849
    for (const [key, val] of Object.entries(sortMap)) {
107✔
850
      if (isVectorSearch(val)) {
182✔
851
        vectorEntries.push([key, val]);
26✔
852
      } else {
853
        directionEntries[key] = val;
156✔
854
      }
855
    }
856

857
    const flattenedSort = flatObject(directionEntries, prefix);
107✔
858

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

862
    if (!allEntries.length) return;
107!
863

864
    ctx.append(' ORDER BY ');
107✔
865

866
    allEntries.forEach(([key, sort], index) => {
107✔
867
      if (index > 0) {
182✔
868
        ctx.append(', ');
75✔
869
      }
870

871
      if (isVectorSearch(sort)) {
182✔
872
        if (sort.$project) {
26✔
873
          // Distance already projected in SELECT - reference the alias to avoid recomputation
874
          ctx.append(this.escapeId(sort.$project));
5✔
875
        } else {
876
          this.appendVectorSort(ctx, meta, key, sort);
21✔
877
        }
878
        return;
23✔
879
      }
880

881
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[sort as QuerySortDirection];
156✔
882

883
      // Detect JSONB dot-notation: 'column.path'
884
      const jsonDot = this.resolveJsonDotPath(meta, key);
156✔
885
      if (jsonDot) {
156✔
886
        ctx.append(jsonDot.accessor() + direction);
8✔
887
        return;
8✔
888
      }
889

890
      const field = meta.fields[key as Key<E>];
148✔
891
      const name = this.resolveColumnName(key, field);
148✔
892
      ctx.append(this.escapeId(name) + direction);
148✔
893
    });
894
  }
895

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

912
  /**
913
   * Mapping of UQL vector distance metrics to native SQL functions.
914
   * Override in dialects that use function-call syntax (e.g. SQLite, MariaDB).
915
   * Dialects with operator-based syntax (e.g. Postgres) leave this empty and override `appendVectorSort` directly.
916
   */
917
  protected readonly vectorDistanceFns: Partial<Record<VectorDistance, string>> = {};
61✔
918

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

933
    if (!fn) {
12✔
934
      throw Error(`${dialectName} does not support vector distance metric: ${distance}`);
2✔
935
    }
936

937
    ctx.append(`${fn}(${this.escapeId(colName)}, `);
10✔
938
    ctx.addValue(`[${search.$vector.join(',')}]`);
10✔
939
    if (vectorCast && dialectName === 'PostgreSQL') {
10!
940
      ctx.append(`::${vectorCast}`);
×
941
    }
942
    ctx.append(')');
10✔
943
  }
944

945
  /**
946
   * Append a vector distance projection.
947
   */
948
  protected appendVectorProjection<E>(
949
    ctx: QueryContext,
950
    meta: EntityMeta<E>,
951
    key: string,
952
    search: QueryVectorSearch,
953
  ): void {
954
    this.appendVectorSort(ctx, meta, key, search);
5✔
955
    ctx.append(` AS ${this.escapeId(search.$project as string)}`);
5✔
956
  }
957

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

971
  pager(ctx: QueryContext, opts: QueryPager): void {
972
    if (opts.$limit) {
5,746✔
973
      ctx.append(` LIMIT ${Number(opts.$limit)}`);
305✔
974
    }
975
    if (opts.$skip !== undefined) {
5,746✔
976
      ctx.append(` OFFSET ${Number(opts.$skip)}`);
71✔
977
    }
978
  }
979

980
  count<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void {
981
    const search: Query<E> = { ...q };
144✔
982
    delete search.$sort;
144✔
983
    this.select<E>(ctx, entity, [raw('COUNT(*)', 'count')]);
144✔
984
    this.search(ctx, entity, search, opts);
144✔
985
  }
986

987
  aggregate<E>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E>, opts: QueryOptions = {}): void {
57✔
988
    const meta = getMeta(entity);
57✔
989
    const tableName = this.resolveTableName(entity, meta);
57✔
990
    const groupKeys: string[] = [];
57✔
991
    const selectParts: string[] = [];
57✔
992
    const aggregateExpressions: Record<string, string> = {};
57✔
993

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

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

1016
    if (groupKeys.length) {
57✔
1017
      ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
49✔
1018
    }
1019

1020
    if (q.$having) {
57✔
1021
      this.having(ctx, q.$having, aggregateExpressions);
28✔
1022
    }
1023

1024
    this.aggregateSort(ctx, q.$sort, aggregateExpressions);
57✔
1025
    this.pager(ctx, q);
57✔
1026
  }
1027

1028
  /**
1029
   * ORDER BY for aggregate queries - handles both entity-field and alias references.
1030
   */
1031
  private aggregateSort(
1032
    ctx: QueryContext,
1033
    sort: QuerySortMap<object> | undefined,
1034
    aggregateExpressions: Record<string, string>,
1035
  ): void {
1036
    const sortMap = buildSortMap(sort);
57✔
1037
    if (!hasKeys(sortMap)) return;
57✔
1038

1039
    ctx.append(' ORDER BY ');
16✔
1040
    Object.entries(sortMap).forEach(([key, dir], index) => {
16✔
1041
      if (index > 0) ctx.append(', ');
25✔
1042
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[dir as QuerySortDirection];
25✔
1043
      const ref = aggregateExpressions[key] ?? this.escapeId(key);
25✔
1044
      ctx.append(ref + direction);
25✔
1045
    });
1046
  }
1047

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

1052
    ctx.append(' HAVING ');
28✔
1053
    entries.forEach(([alias, condition], index) => {
28✔
1054
      if (index > 0) ctx.append(' AND ');
31✔
1055
      const expr = aggregateExpressions[alias] ?? this.escapeId(alias);
31!
1056
      this.havingCondition(ctx, expr, condition);
31✔
1057
    });
1058
  }
1059

1060
  private static readonly SORT_DIRECTION_MAP: Record<string | number, string> = Object.assign(
61✔
1061
    { 1: '', asc: '', desc: ' DESC', '-1': ' DESC' },
1062
    { [-1]: ' DESC' },
1063
  );
1064

1065
  private static readonly havingOpMap: Record<string, string> = {
61✔
1066
    $eq: '=',
1067
    $ne: '<>',
1068
    $gt: '>',
1069
    $gte: '>=',
1070
    $lt: '<',
1071
    $lte: '<=',
1072
  };
1073

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

1108
  find<E>(ctx: QueryContext, entity: Type<E>, q: Query<E> = {}, opts?: QueryOptions): void {
4,106✔
1109
    this.select(ctx, entity, q.$select, q.$exclude, q.$populate, opts, q.$distinct, q.$sort);
4,106✔
1110
    this.search(ctx, entity, q, opts);
4,106✔
1111
  }
1112

1113
  insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void {
1114
    const meta = getMeta(entity);
409✔
1115
    const payloads = fillOnFields(meta, payload, 'onInsert');
409✔
1116
    const keys = filterFieldKeys(meta, payloads[0], 'onInsert');
409✔
1117

1118
    const columns = keys.map((key) => {
409✔
1119
      const field = meta.fields[key];
1,127✔
1120
      return this.escapeId(this.resolveColumnName(key, field));
1,127✔
1121
    });
1122
    const tableName = this.resolveTableName(entity, meta);
409✔
1123
    ctx.append(`INSERT INTO ${this.escapeId(tableName)} (${columns.join(', ')}) VALUES (`);
409✔
1124

1125
    payloads.forEach((it, recordIndex) => {
409✔
1126
      if (recordIndex > 0) {
582✔
1127
        ctx.append('), (');
173✔
1128
      }
1129
      keys.forEach((key, keyIndex) => {
582✔
1130
        if (keyIndex > 0) {
1,613✔
1131
          ctx.append(', ');
1,031✔
1132
        }
1133
        const field = meta.fields[key];
1,613✔
1134
        this.formatPersistableValue(ctx, field, it[key]);
1,613✔
1135
      });
1136
    });
1137
    ctx.append(')');
409✔
1138
  }
1139

1140
  update<E>(
1141
    ctx: QueryContext,
1142
    entity: Type<E>,
1143
    q: QuerySearch<E>,
1144
    payload: UpdatePayload<E>,
1145
    opts?: QueryOptions,
1146
  ): void {
1147
    const meta = getMeta(entity);
128✔
1148
    const [filledPayload] = fillOnFields(meta, payload as E, 'onUpdate');
128✔
1149
    const keys = filterFieldKeys(meta, filledPayload, 'onUpdate');
128✔
1150

1151
    const tableName = this.resolveTableName(entity, meta);
128✔
1152
    ctx.append(`UPDATE ${this.escapeId(tableName)} SET `);
128✔
1153
    keys.forEach((key, index) => {
128✔
1154
      if (index > 0) {
245✔
1155
        ctx.append(', ');
117✔
1156
      }
1157
      const field = meta.fields[key];
245✔
1158
      const columnName = this.resolveColumnName(key, field);
245✔
1159
      const escapedCol = this.escapeId(columnName);
245✔
1160
      const value = filledPayload[key];
245✔
1161

1162
      if (this.isJsonUpdateOp(value)) {
245✔
1163
        this.formatJsonUpdate<E>(ctx, escapedCol, value);
50✔
1164
      } else {
1165
        ctx.append(`${escapedCol} = `);
195✔
1166
        this.formatPersistableValue(ctx, field, value);
195✔
1167
      }
1168
    });
1169

1170
    this.search(ctx, entity, q, opts);
128✔
1171
  }
1172

1173
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void {
1174
    const meta = getMeta(entity);
7✔
1175
    const updateCtx = this.createContext();
7✔
1176
    const update = this.getUpsertUpdateAssignments(
7✔
1177
      updateCtx,
1178
      meta,
1179
      conflictPaths,
1180
      payload,
1181
      (name) => `VALUES(${name})`,
8✔
1182
    );
1183

1184
    if (update) {
7✔
1185
      this.insert(ctx, entity, payload);
6✔
1186
      ctx.append(` ON DUPLICATE KEY UPDATE ${update}`);
6✔
1187
      ctx.pushValue(...updateCtx.values);
6✔
1188
    } else {
1189
      const insertCtx = this.createContext();
1✔
1190
      this.insert(insertCtx, entity, payload);
1✔
1191
      ctx.append(insertCtx.sql.replace(/^INSERT/, 'INSERT IGNORE'));
1✔
1192
      ctx.pushValue(...insertCtx.values);
1✔
1193
    }
1194
  }
1195

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

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

1251
  protected getUpsertConflictPathsStr<E>(meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>): string {
1252
    return (getKeys(conflictPaths) as Key<E>[])
23✔
1253
      .map((key) => {
1254
        const field = meta.fields[key];
25✔
1255
        const columnName = this.resolveColumnName(key, field);
25✔
1256
        return this.escapeId(columnName);
25✔
1257
      })
1258
      .join(', ');
1259
  }
1260

1261
  delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts: QueryOptions = {}): void {
1,318✔
1262
    const meta = getMeta(entity);
1,318✔
1263
    const tableName = this.resolveTableName(entity, meta);
1,318✔
1264

1265
    // Soft-delete (stamp only live rows) unless `hardDelete` is requested or the entity has no
1266
    // soft-delete field (e.g. a cascade onto a non-soft-deletable child).
1267
    if (!opts.hardDelete && meta.softDelete) {
1,318✔
1268
      const field = meta.fields[meta.softDelete];
169✔
1269
      if (field) {
169!
1270
        const columnName = this.resolveColumnName(meta.softDelete, field);
169✔
1271
        ctx.append(`UPDATE ${this.escapeId(tableName)} SET ${this.escapeId(columnName)} = `);
169✔
1272
        this.formatPersistableValue(ctx, field, getSoftDeleteValue(field));
169✔
1273
        this.search(ctx, entity, q, opts);
169✔
1274
        return;
169✔
1275
      }
1276
    }
1277

1278
    // Hard delete removes matching rows regardless of soft-delete state (keeps other filters, e.g. tenant).
1279
    ctx.append(`DELETE FROM ${this.escapeId(tableName)}`);
1,149✔
1280
    this.search(ctx, entity, q, { ...opts, filters: withoutSoftDeleteFilter(opts.filters) });
1,149✔
1281
  }
1282

1283
  escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string {
1284
    return escapeSqlId(val, this.escapeIdChar, forbidQualified, addDot);
24,747✔
1285
  }
1286

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

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

1318
  protected formatPersistableValue<E>(ctx: QueryContext, field: FieldOptions | undefined, value: unknown): void {
1319
    if (value instanceof QueryRaw) {
1,992✔
1320
      this.getRawValue(ctx, { value });
7✔
1321
      return;
7✔
1322
    }
1323
    if (isJsonType(field?.type)) {
1,985✔
1324
      ctx.addValue(value == null ? null : JSON.stringify(value));
12✔
1325
      return;
12✔
1326
    }
1327
    if (field?.type === 'vector' && Array.isArray(value)) {
1,973✔
1328
      ctx.addValue(`[${value.join(',')}]`);
1✔
1329
      return;
1✔
1330
    }
1331
    ctx.addValue(value);
1,972✔
1332
  }
1333

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

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

1375
  protected isJsonUpdateOp(value: unknown): value is JsonUpdateOp {
1376
    return typeof value === 'object' && value !== null && ('$merge' in value || '$unset' in value || '$push' in value);
245✔
1377
  }
1378

1379
  /** Escapes a JSON key for safe interpolation into SQL string literals. */
1380
  protected escapeJsonKey(key: string): string {
1381
    return key.replace(/'/g, "''");
195✔
1382
  }
1383

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

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

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

1461
    if (operators.length > 1) {
40✔
1462
      ctx.append('(');
2✔
1463
    }
1464

1465
    operators.forEach((op, index) => {
40✔
1466
      if (index > 0) ctx.append(' AND ');
42✔
1467
      const sql = this.buildJsonFieldCondition(ctx, (f) => accessor(this.isJsonbOp(op)), jsonPath, op, value[op]);
42✔
1468
      if (sql) {
42✔
1469
        ctx.append(sql);
41✔
1470
      }
1471
    });
1472

1473
    if (operators.length > 1) {
40✔
1474
      ctx.append(')');
2✔
1475
    }
1476
  }
1477

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

1493
  protected getJsonPathJsonbExpr(escapedColumn: string, jsonPath: string): string {
1494
    const segments = jsonPath.split('.');
3✔
1495
    let expr = escapedColumn;
3✔
1496
    for (const segment of segments) {
3✔
1497
      expr = `(${expr}->'${this.escapeJsonKey(segment)}')`;
3✔
1498
    }
1499
    return expr;
3✔
1500
  }
1501

1502
  /**
1503
   * Normalizes a raw WHERE value into an operator map.
1504
   * Arrays become `$in`, scalars/null become `$eq`, objects pass through.
1505
   */
1506
  private normalizeWhereValue(val: unknown): Record<string, unknown> {
1507
    if (Array.isArray(val)) return { $in: val };
1,956✔
1508
    if (typeof val === 'object' && val !== null) return val as Record<string, unknown>;
1,521✔
1509
    return { $eq: val };
1,269✔
1510
  }
1511

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

1531
    if (!rel.references?.length) {
15✔
1532
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1533
    }
1534

1535
    const relatedEntity = rel.entity!();
14✔
1536
    const relatedMeta = getMeta(relatedEntity);
14✔
1537
    const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
14✔
1538

1539
    ctx.append('EXISTS (SELECT 1 FROM ');
14✔
1540

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

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

1573
      ctx.append(this.escapeId(relatedTable));
7✔
1574
      ctx.append(` WHERE ${joinLeft} = ${joinRight}`);
7✔
1575
      this.where(ctx, relatedEntity, val as QueryWhere<typeof relatedEntity>, {
7✔
1576
        prefix: relatedTable,
1577
        clause: 'AND',
1578
        filters: { softDelete: false },
1579
      });
1580
    }
1581

1582
    ctx.append(')');
14✔
1583
  }
1584

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

1604
    if (!rel.references?.length) {
12✔
1605
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1606
    }
1607

1608
    const appendSubquery = () => {
11✔
1609
      ctx.append('(SELECT COUNT(*) FROM ');
11✔
1610

1611
      if (rel.cardinality === 'mm' && rel.through) {
11✔
1612
        const throughEntity = rel.through();
5✔
1613
        const throughMeta = getMeta(throughEntity);
5✔
1614
        const throughTable = this.resolveTableName(throughEntity, throughMeta);
5✔
1615
        const localFk = rel.references![0].local;
5✔
1616

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

1625
        ctx.append(this.escapeId(relatedTable));
6✔
1626
        ctx.append(` WHERE ${joinLeft} = ${escapedParentId}`);
6✔
1627
      }
1628

1629
      ctx.append(')');
11✔
1630
    };
1631

1632
    this.buildSizeComparison(ctx, appendSubquery, sizeVal);
11✔
1633
  }
1634

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

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

1654
    if (entries.length > 1) {
21✔
1655
      ctx.append('(');
4✔
1656
    }
1657

1658
    entries.forEach(([op, val], index) => {
21✔
1659
      if (index > 0) {
25✔
1660
        ctx.append(' AND ');
4✔
1661
      }
1662
      sizeExprFn();
25✔
1663
      this.appendSizeOp(ctx, op, val);
25✔
1664
    });
1665

1666
    if (entries.length > 1) {
21✔
1667
      ctx.append(')');
4✔
1668
    }
1669
  }
1670

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

1713
  abstract escape(value: unknown): string;
1714

1715
  protected get regexpOp(): string {
1716
    return 'REGEXP';
7✔
1717
  }
1718

1719
  protected get likeFn(): string {
1720
    return 'LIKE';
62✔
1721
  }
1722

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

1731
  protected neExpr(field: string, ph: string): string {
1732
    return `${field} ${this.neOp} ${ph}`;
27✔
1733
  }
1734

1735
  protected ilikeExpr(f: string, ph: string): string {
1736
    return `LOWER(${f}) LIKE ${ph}`;
1✔
1737
  }
1738

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

1749
  protected numericCast(expr: string): string {
1750
    return expr;
×
1751
  }
1752

1753
  override toString(): string {
1754
    return this.dialectName;
7✔
1755
  }
1756
}
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