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

rogerpadilla / uql / 23572402971

26 Mar 2026 01:08AM UTC coverage: 94.888% (-0.2%) from 95.086%
23572402971

push

github

rogerpadilla
chore: update version to 0.7.1 in CHANGELOG.md to reflect the addition of Bun SQL support and related enhancements

2890 of 3210 branches covered (90.03%)

Branch coverage included in aggregate %.

5129 of 5241 relevant lines covered (97.86%)

348.18 hits per line

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

96.19
/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 EntityMeta,
5
  type FieldKey,
6
  type FieldOptions,
7
  type IdKey,
8
  type IsolationLevel,
9
  type JsonUpdateOp,
10
  type Key,
11
  type Query,
12
  type QueryAggregate,
13
  type QueryComparisonOptions,
14
  type QueryConflictPaths,
15
  type QueryContext,
16
  type QueryDialect,
17
  type QueryHavingMap,
18
  type QueryOptions,
19
  type QueryPager,
20
  QueryRaw,
21
  type QueryRawFnOptions,
22
  type QuerySearch,
23
  type QuerySelect,
24
  type QuerySelectOptions,
25
  type QuerySizeComparisonOps,
26
  type QuerySortDirection,
27
  type QuerySortMap,
28
  type QueryTextSearchOptions,
29
  type QueryVectorSearch,
30
  type QueryWhere,
31
  type QueryWhereArray,
32
  type QueryWhereFieldOperatorMap,
33
  type QueryWhereMap,
34
  type QueryWhereOptions,
35
  RAW_ALIAS,
36
  RAW_VALUE,
37
  type RelationOptions,
38
  type SqlDialect,
39
  type SqlQueryDialect,
40
  type Type,
41
  type UpdatePayload,
42
  type VectorDistance,
43
} from '../type/index.js';
44

45
import {
46
  buildQueryWhereAsMap,
47
  buildSortMap,
48
  type CallbackKey,
49
  escapeSqlId,
50
  fillOnFields,
51
  filterFieldKeys,
52
  filterRelationKeys,
53
  flatObject,
54
  getFieldCallbackValue,
55
  getFieldKeys,
56
  getKeys,
57
  hasKeys,
58
  isJsonType,
59
  isSelectingRelations,
60
  isVectorSearch,
61
  parseGroupMap,
62
  raw,
63
} from '../util/index.js';
64

65
import { AbstractDialect } from './abstractDialect.js';
66
import { SqlQueryContext } from './queryContext.js';
67

68
export abstract class AbstractSqlDialect extends AbstractDialect implements QueryDialect, SqlQueryDialect {
69
  // Narrow dialect type from Dialect to SqlDialect
70
  declare readonly dialect: SqlDialect;
71

72
  get escapeIdChar() {
73
    return this.config.quoteChar;
22,255✔
74
  }
75

76
  get beginTransactionCommand() {
77
    return this.config.beginTransactionCommand;
8✔
78
  }
79

80
  getBeginTransactionStatements(isolationLevel?: IsolationLevel): string[] {
81
    const level = isolationLevel?.toUpperCase();
106✔
82
    const strategy = this.config.isolationLevelStrategy;
106✔
83
    if (!level || strategy === 'none') {
106✔
84
      return [this.config.beginTransactionCommand];
91✔
85
    }
86
    if (strategy === 'inline') {
15✔
87
      return [`${this.config.beginTransactionCommand} ISOLATION LEVEL ${level}`];
7✔
88
    }
89
    // 'set-before' — MySQL/MariaDB pattern
90
    return [`SET TRANSACTION ISOLATION LEVEL ${level}`, this.config.beginTransactionCommand];
8✔
91
  }
92

93
  get commitTransactionCommand() {
94
    return this.config.commitTransactionCommand;
67✔
95
  }
96

97
  get rollbackTransactionCommand() {
98
    return this.config.rollbackTransactionCommand;
21✔
99
  }
100

101
  createContext(): QueryContext {
102
    return new SqlQueryContext(this);
5,482✔
103
  }
104

105
  addValue(values: unknown[], value: unknown): string {
106
    values.push(this.normalizeValue(value));
3,355✔
107
    return this.placeholder(values.length);
3,355✔
108
  }
109

110
  /**
111
   * Normalizes a parameter value for the database driver.
112
   * Handles bigint, boolean, and serializes plain objects/arrays to JSON strings.
113
   * Date values are preserved so SQL drivers can apply native date/time binding.
114
   * Postgres overrides to pass objects through to its native JSONB driver.
115
   */
116
  normalizeValue(value: unknown): unknown {
117
    if (typeof value === 'bigint') return Number(value);
7,204!
118
    if (typeof value === 'boolean') return value ? 1 : 0;
7,204✔
119
    if (value instanceof Date) return value;
7,198✔
120
    if (value !== null && typeof value === 'object' && !(value instanceof Uint8Array) && !(value instanceof QueryRaw)) {
7,189!
121
      return JSON.stringify(value);
×
122
    }
123
    return value;
7,189✔
124
  }
125

126
  /**
127
   * Normalizes a list of parameter values.
128
   */
129
  normalizeValues(values: unknown[] | undefined): unknown[] | undefined {
130
    return values?.map((v) => this.normalizeValue(v));
7,804✔
131
  }
132

133
  placeholder(_index: number): string {
134
    return '?';
2,727✔
135
  }
136

137
  returningId<E>(entity: Type<E>): string {
138
    const meta = getMeta(entity);
173✔
139
    const idKey = (meta.id ?? 'id') as IdKey<E>;
173!
140
    const idName = this.resolveColumnName(idKey, meta.fields[idKey]);
173✔
141
    return `RETURNING ${this.escapeId(idName)} ${this.escapeId('id')}`;
173✔
142
  }
143

144
  search<E>(ctx: QueryContext, entity: Type<E>, q: Query<E> = {}, opts: QueryOptions = {}): void {
9,846✔
145
    const meta = getMeta(entity);
4,923✔
146
    const tableName = this.resolveTableName(entity, meta);
4,923✔
147
    const prefix = (opts.prefix ?? (opts.autoPrefix || isSelectingRelations(meta, q.$select))) ? tableName : undefined;
4,923✔
148
    opts = { ...opts, prefix };
4,923✔
149
    this.where<E>(ctx, entity, q.$where, opts);
4,923✔
150
    this.sort<E>(ctx, entity, q.$sort, opts);
4,923✔
151
    this.pager(ctx, q);
4,923✔
152
  }
153

154
  selectFields<E>(
155
    ctx: QueryContext,
156
    entity: Type<E>,
157
    select: QuerySelect<E> | QueryRaw[] | undefined,
158
    opts: QuerySelectOptions = {},
3,765✔
159
  ): void {
160
    const meta = getMeta(entity);
3,765✔
161
    const prefix = opts.prefix ? opts.prefix + '.' : '';
3,765✔
162
    const escapedPrefix = this.escapeId(opts.prefix as string, true, true);
3,765✔
163

164
    let selectArr: (FieldKey<E> | QueryRaw)[];
165

166
    if (select) {
3,765✔
167
      if (Array.isArray(select)) {
3,673✔
168
        // Internal-only path: raw SQL expressions passed as QueryRaw[]
169
        selectArr = select;
173✔
170
      } else {
171
        const positiveFields: FieldKey<E>[] = [];
3,500✔
172
        const negativeFields: FieldKey<E>[] = [];
3,500✔
173

174
        for (const prop in select) {
3,500✔
175
          if (!(prop in meta.fields)) {
3,777✔
176
            continue;
131✔
177
          }
178
          const val = select[prop as FieldKey<E>];
3,646✔
179
          if (val) {
3,646✔
180
            positiveFields.push(prop as FieldKey<E>);
3,628✔
181
          } else {
182
            negativeFields.push(prop as FieldKey<E>);
18✔
183
          }
184
        }
185

186
        selectArr = positiveFields.length
3,500✔
187
          ? positiveFields
188
          : (getFieldKeys(meta.fields).filter((it) => !negativeFields.includes(it)) as FieldKey<E>[]);
298✔
189
      }
190

191
      const id = meta.id;
3,673✔
192
      if (id && opts.prefix && !selectArr.includes(id)) {
3,673✔
193
        selectArr = [id, ...selectArr];
45✔
194
      }
195
    } else {
196
      selectArr = getFieldKeys(meta.fields) as FieldKey<E>[];
92✔
197
    }
198

199
    if (!selectArr.length) {
3,765✔
200
      ctx.append(escapedPrefix + '*');
1✔
201
      return;
1✔
202
    }
203

204
    selectArr.forEach((key, index) => {
3,764✔
205
      if (index > 0) ctx.append(', ');
4,866✔
206
      if (key instanceof QueryRaw) {
4,866✔
207
        this.getRawValue(ctx, {
165✔
208
          value: key,
209
          prefix: opts.prefix,
210
          escapedPrefix,
211
          autoPrefixAlias: opts.autoPrefixAlias,
212
        });
213
      } else {
214
        const field = meta.fields[key];
4,701✔
215
        if (!field) return;
4,701!
216
        const columnName = this.resolveColumnName(key, field);
4,701✔
217
        if (field.virtual) {
4,701✔
218
          this.getRawValue(ctx, {
9✔
219
            value: raw(field.virtual[RAW_VALUE], key),
220
            prefix: opts.prefix,
221
            escapedPrefix,
222
            autoPrefixAlias: opts.autoPrefixAlias,
223
          });
224
        } else {
225
          ctx.append(escapedPrefix + this.escapeId(columnName));
4,692✔
226
        }
227
        if (!field.virtual && (columnName !== key || opts.autoPrefixAlias)) {
4,701✔
228
          const aliasStr = prefix + key;
304✔
229
          ctx.append(' ' + this.escapeId(aliasStr, true));
304✔
230
        }
231
      }
232
    });
233
  }
234

235
  select<E>(
236
    ctx: QueryContext,
237
    entity: Type<E>,
238
    select: QuerySelect<E> | QueryRaw[] | undefined,
239
    opts: QueryOptions = {},
3,677✔
240
    distinct?: boolean,
241
    sort?: QuerySortMap<E>,
242
  ): void {
243
    const meta = getMeta(entity);
3,677✔
244
    const tableName = this.resolveTableName(entity, meta);
3,677✔
245
    const mapSelect = Array.isArray(select) ? undefined : select;
3,677✔
246
    const prefix = (opts.prefix ?? (opts.autoPrefix || isSelectingRelations(meta, mapSelect))) ? tableName : undefined;
3,677✔
247

248
    ctx.append(distinct ? 'SELECT DISTINCT ' : 'SELECT ');
3,677✔
249
    this.selectFields(ctx, entity, select, { prefix });
3,677✔
250
    // Add related fields BEFORE FROM clause
251
    this.selectRelationFields(ctx, entity, mapSelect, { prefix });
3,677✔
252
    // Inject vector distance projections when $project is set
253
    if (sort) {
3,677✔
254
      const sortMap = buildSortMap(sort);
107✔
255
      for (const [key, val] of Object.entries(sortMap)) {
107✔
256
        if (isVectorSearch(val) && val.$project) {
182✔
257
          ctx.append(', ');
5✔
258
          this.appendVectorProjection(ctx, meta, key, val);
5✔
259
        }
260
      }
261
    }
262
    ctx.append(` FROM ${this.escapeId(tableName)}`);
3,677✔
263
    // Add JOINs AFTER FROM clause
264
    this.selectRelationJoins(ctx, entity, mapSelect, { prefix });
3,677✔
265
  }
266

267
  protected selectRelationFields<E>(
268
    ctx: QueryContext,
269
    entity: Type<E>,
270
    select: QuerySelect<E> | undefined,
271
    opts: { prefix?: string } = {},
3,758✔
272
  ): void {
273
    this.forEachJoinableRelation(entity, select, opts, (relEntity, relQuery, joinRelAlias) => {
3,758✔
274
      ctx.append(', ');
81✔
275
      this.selectFields(ctx, relEntity, relQuery.$select, { prefix: joinRelAlias, autoPrefixAlias: true });
81✔
276
      this.selectRelationFields(ctx, relEntity, relQuery.$select, { prefix: joinRelAlias });
81✔
277
    });
278
  }
279

280
  protected selectRelationJoins<E>(
281
    ctx: QueryContext,
282
    entity: Type<E>,
283
    select: QuerySelect<E> | undefined,
284
    opts: { prefix?: string } = {},
3,758✔
285
  ): void {
286
    this.forEachJoinableRelation(
3,758✔
287
      entity,
288
      select,
289
      opts,
290
      (relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required) => {
291
        const relMeta = getMeta(relEntity);
81✔
292
        const relTableName = this.resolveTableName(relEntity, relMeta);
81✔
293
        const relEntityName = this.escapeId(relTableName);
81✔
294
        const relPath = opts.prefix ? this.escapeId(opts.prefix, true) : this.escapeId(tableName);
81!
295
        const joinType = required ? 'INNER' : 'LEFT';
81✔
296
        const joinAlias = this.escapeId(joinRelAlias, true);
81✔
297

298
        ctx.append(` ${joinType} JOIN ${relEntityName} ${joinAlias} ON `);
81✔
299
        let refAppended = false;
81✔
300
        for (const it of relOpts.references ?? []) {
81!
301
          if (refAppended) ctx.append(' AND ');
81!
302
          const relField = relMeta.fields[it.foreign];
81✔
303
          const field = meta.fields[it.local];
81✔
304
          const foreignColumnName = this.resolveColumnName(it.foreign, relField);
81✔
305
          const localColumnName = this.resolveColumnName(it.local, field);
81✔
306
          ctx.append(`${joinAlias}.${this.escapeId(foreignColumnName)} = ${relPath}.${this.escapeId(localColumnName)}`);
81✔
307
          refAppended = true;
81✔
308
        }
309

310
        if (relQuery.$where) {
81✔
311
          ctx.append(' AND ');
4✔
312
          this.where(ctx, relEntity, relQuery.$where, { prefix: joinRelAlias, clause: false });
4✔
313
        }
314

315
        this.selectRelationJoins(ctx, relEntity, relQuery.$select, { prefix: joinRelAlias });
81✔
316
      },
317
    );
318
  }
319

320
  /**
321
   * Iterates over joinable (11/m1) relations for a given select, resolving shared metadata.
322
   * Used by both `selectRelationFields` and `selectRelationJoins` to avoid duplicated iteration logic.
323
   */
324
  private forEachJoinableRelation<E>(
325
    entity: Type<E>,
326
    select: QuerySelect<E> | undefined,
327
    opts: { prefix?: string },
328
    callback: (
329
      relEntity: Type<unknown>,
330
      relQuery: Query<unknown>,
331
      joinRelAlias: string,
332
      relOpts: RelationOptions,
333
      meta: EntityMeta<E>,
334
      tableName: string,
335
      required: boolean,
336
    ) => void,
337
  ): void {
338
    if (!select) return;
7,516✔
339
    const meta = getMeta(entity);
7,026✔
340
    const tableName = this.resolveTableName(entity, meta);
7,026✔
341
    const relKeys = filterRelationKeys(meta, select);
7,026✔
342
    const prefix = opts.prefix;
7,026✔
343

344
    for (const relKey of relKeys) {
7,026✔
345
      const relOpts = meta.relations[relKey];
250✔
346
      if (!relOpts || relOpts.cardinality === '1m' || relOpts.cardinality === 'mm' || !relOpts.entity) continue;
250✔
347

348
      const isFirstLevel = prefix === tableName;
162✔
349
      const joinRelAlias = isFirstLevel ? relKey : prefix ? `${prefix}.${relKey}` : relKey;
162!
350
      const relEntity = relOpts.entity();
250✔
351
      const relSelect = select?.[relKey];
250✔
352

353
      let relQuery: Query<unknown>;
354
      let required = false;
250✔
355

356
      if (isRelationSelectQuery(relSelect)) {
250✔
357
        relQuery = relSelect;
92✔
358
        required = relSelect.$required === true;
92✔
359
      } else if (Array.isArray(relSelect)) {
70✔
360
        relQuery = { $select: relSelect };
26✔
361
      } else {
362
        relQuery = {};
44✔
363
      }
364

365
      callback(relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required);
162✔
366
    }
367
  }
368

369
  where<E>(ctx: QueryContext, entity: Type<E>, where: QueryWhere<E> = {}, opts: QueryWhereOptions = {}): void {
10,294✔
370
    const meta = getMeta(entity);
5,147✔
371
    const { usePrecedence, clause = 'WHERE', softDelete } = opts;
5,147✔
372

373
    where = buildQueryWhereAsMap(meta, where);
5,147✔
374

375
    if (
5,147✔
376
      meta.softDelete &&
6,733✔
377
      (softDelete || softDelete === undefined) &&
378
      !(where as Record<string, unknown>)[meta.softDelete]
379
    ) {
380
      (where as Record<string, unknown>)[meta.softDelete] = null;
521✔
381
    }
382

383
    const entries = Object.entries(where);
5,147✔
384

385
    if (!entries.length) {
5,147✔
386
      return;
3,532✔
387
    }
388

389
    if (clause) {
1,615✔
390
      ctx.append(` ${clause} `);
1,510✔
391
    }
392

393
    if (usePrecedence) {
1,615✔
394
      ctx.append('(');
6✔
395
    }
396

397
    const whereKeys = getKeys(where) as (keyof QueryWhereMap<E>)[];
1,615✔
398
    const hasMultipleKeys = whereKeys.length > 1;
1,615✔
399
    let appended = false;
1,615✔
400
    whereKeys.forEach((key) => {
1,615✔
401
      const val = (where as Record<string, unknown>)[key];
1,792✔
402
      if (val === undefined) return;
1,792!
403
      if (appended) {
1,792✔
404
        ctx.append(' AND ');
177✔
405
      }
406
      this.compare(ctx, entity, key, val as QueryWhereMap<E>[keyof QueryWhereMap<E>], {
1,792✔
407
        ...opts,
408
        usePrecedence: hasMultipleKeys,
409
      });
410
      appended = true;
1,792✔
411
    });
412

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

418
  compare<E>(ctx: QueryContext, entity: Type<E>, key: string, val: unknown, opts: QueryComparisonOptions = {}): void {
1,803✔
419
    const meta = getMeta(entity);
1,803✔
420

421
    if (val instanceof QueryRaw) {
1,803✔
422
      if (key === '$exists' || key === '$nexists') {
23✔
423
        ctx.append(key === '$exists' ? 'EXISTS (' : 'NOT EXISTS (');
5✔
424
        const tableName = this.resolveTableName(entity, meta);
5✔
425
        this.getRawValue(ctx, {
5✔
426
          value: val,
427
          prefix: tableName,
428
          escapedPrefix: this.escapeId(tableName, false, true),
429
        });
430
        ctx.append(')');
5✔
431
        return;
5✔
432
      }
433
      this.getComparisonKey(ctx, entity, key as FieldKey<E>, opts);
18✔
434
      ctx.append(' = ');
18✔
435
      this.getRawValue(ctx, { value: val });
18✔
436
      return;
18✔
437
    }
438

439
    if (key === '$text') {
1,780✔
440
      const search = val as QueryTextSearchOptions<E>;
4✔
441
      const searchFields = search.$fields ?? (getFieldKeys(meta.fields) as FieldKey<E>[]);
4!
442
      const fields = searchFields.map((fKey) => {
4✔
443
        const field = meta.fields[fKey];
6✔
444
        const columnName = this.resolveColumnName(fKey, field);
6✔
445
        return this.escapeId(columnName);
6✔
446
      });
447
      ctx.append(`MATCH(${fields.join(', ')}) AGAINST(`);
4✔
448
      ctx.addValue(search.$value);
4✔
449
      ctx.append(')');
4✔
450
      return;
4✔
451
    }
452

453
    if (key === '$and' || key === '$or' || key === '$not' || key === '$nor') {
1,776✔
454
      this.compareLogicalOperator(
78✔
455
        ctx,
456
        entity,
457
        key as '$and' | '$or' | '$not' | '$nor',
458
        val as QueryWhereArray<E>,
459
        opts,
460
      );
461
      return;
78✔
462
    }
463

464
    // Detect JSONB dot-notation: 'column.path' where column is a registered JSON/JSONB field
465
    const keyStr = key as string;
1,698✔
466
    const jsonDot = this.resolveJsonDotPath(meta, keyStr, opts.prefix);
1,698✔
467
    if (jsonDot) {
1,698✔
468
      this.compareJsonPath(ctx, jsonDot, val);
36✔
469
      return;
36✔
470
    }
471

472
    // Detect relation filtering
473
    const rel = meta.relations[keyStr];
1,662✔
474
    if (rel) {
1,662✔
475
      // Check if this is a $size query on a relation (count filtering)
476
      const valObj = val as Record<string, unknown> | undefined;
27✔
477
      if (valObj && typeof valObj === 'object' && '$size' in valObj && Object.keys(valObj).length === 1) {
27✔
478
        this.compareRelationSize(ctx, entity, keyStr, valObj['$size'] as number | QuerySizeComparisonOps, rel, opts);
12✔
479
        return;
12✔
480
      }
481
      this.compareRelation(ctx, entity, keyStr, val as QueryWhereMap<unknown>, rel, opts);
15✔
482
      return;
15✔
483
    }
484

485
    const value = this.normalizeWhereValue(val);
1,635✔
486
    const operators = getKeys(value) as (keyof QueryWhereFieldOperatorMap<E>)[];
1,635✔
487

488
    if (operators.length > 1) {
1,635✔
489
      ctx.append('(');
46✔
490
    }
491

492
    operators.forEach((op, index) => {
1,635✔
493
      if (index > 0) {
1,681✔
494
        ctx.append(' AND ');
46✔
495
      }
496
      this.compareFieldOperator(
1,681✔
497
        ctx,
498
        entity,
499
        key as FieldKey<E>,
500
        op,
501
        (value as QueryWhereFieldOperatorMap<E>)[op],
502
        opts,
503
      );
504
    });
505

506
    if (operators.length > 1) {
1,635✔
507
      ctx.append(')');
46✔
508
    }
509
  }
510

511
  protected compareLogicalOperator<E>(
512
    ctx: QueryContext,
513
    entity: Type<E>,
514
    key: '$and' | '$or' | '$not' | '$nor',
515
    val: QueryWhereArray<E>,
516
    opts: QueryComparisonOptions,
517
  ): void {
518
    const op = (AbstractSqlDialect.NEGATE_OP_MAP as Record<string, '$and' | '$or'>)[key] ?? (key as '$and' | '$or');
78✔
519
    const negate = key in AbstractSqlDialect.NEGATE_OP_MAP ? 'NOT' : '';
78✔
520

521
    const valArr = val ?? [];
78!
522
    const hasManyItems = valArr.length > 1;
78✔
523

524
    if ((opts.usePrecedence || negate) && hasManyItems) {
78✔
525
      ctx.append((negate ? negate + ' ' : '') + '(');
15✔
526
    } else if (negate) {
63✔
527
      ctx.append(negate + ' ');
12✔
528
    }
529

530
    valArr.forEach((whereEntry, index) => {
78✔
531
      if (index > 0) {
125✔
532
        ctx.append(op === '$or' ? ' OR ' : ' AND ');
47✔
533
      }
534
      if (whereEntry instanceof QueryRaw) {
125✔
535
        this.getRawValue(ctx, {
24✔
536
          value: whereEntry,
537
        });
538
      } else if (whereEntry) {
101!
539
        this.where(ctx, entity, whereEntry, {
101✔
540
          prefix: opts.prefix,
541
          usePrecedence: hasManyItems && !Array.isArray(whereEntry) && Object.keys(whereEntry as object).length > 1,
249✔
542
          clause: false,
543
        });
544
      }
545
    });
546

547
    if ((opts.usePrecedence || negate) && hasManyItems) {
78✔
548
      ctx.append(')');
15✔
549
    }
550
  }
551

552
  /** Simple comparison operators: `getComparisonKey → op → addValue`. */
553
  private static readonly NEGATE_OP_MAP = { $not: '$and', $nor: '$or' } as const;
55✔
554

555
  private static readonly COMPARE_OP_MAP: Record<string, string> = {
55✔
556
    $gt: ' > ',
557
    $gte: ' >= ',
558
    $lt: ' < ',
559
    $lte: ' <= ',
560
  };
561

562
  private static readonly LIKE_OP_MAP: Record<string, (v: string) => string> = {
55✔
563
    $startsWith: (v) => `${v}%`,
16✔
564
    $istartsWith: (v) => `${v.toLowerCase()}%`,
11✔
565
    $endsWith: (v) => `%${v}`,
9✔
566
    $iendsWith: (v) => `%${v.toLowerCase()}`,
8✔
567
    $includes: (v) => `%${v}%`,
6✔
568
    $iincludes: (v) => `%${v.toLowerCase()}%`,
8✔
569
    $like: (v) => v,
14✔
570
    $ilike: (v) => v.toLowerCase(),
8✔
571
  };
572

573
  protected resolveColumnWithPrefix(entity: Type<any>, key: string, { prefix }: QueryOptions = {}): string {
1,646✔
574
    const meta = getMeta(entity);
1,646✔
575
    const field = meta.fields[key as string];
1,646✔
576
    const columnName = this.resolveColumnName(key, field);
1,646✔
577
    const escapedPrefix = this.escapeId(prefix as string, true, true);
1,646✔
578
    return escapedPrefix + this.escapeId(columnName);
1,646✔
579
  }
580

581
  /**
582
   * Resolves the SQL operand for a field comparison.
583
   * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
584
   */
585
  private resolveOperandField(
586
    ctx: QueryContext,
587
    entity: Type<any>,
588
    key: string,
589
    opts: QueryOptions,
590
  ): string | undefined {
591
    const col = getMeta(entity).fields[key];
1,648✔
592
    if (col?.virtual) {
1,648✔
593
      if (col.virtual instanceof QueryRaw) {
4!
594
        this.getComparisonKey(ctx, entity, key as FieldKey<any>, opts);
4✔
595
        return undefined;
4✔
596
      }
597
      return `(${col.virtual})`;
×
598
    }
599
    return this.resolveColumnWithPrefix(entity, key, opts);
1,644✔
600
  }
601

602
  private appendFieldSql(ctx: QueryContext, field: string | undefined, sql: string): void {
603
    ctx.append(field ? `${field}${sql}` : sql);
1,533✔
604
  }
605

606
  compareFieldOperator<E, K extends keyof QueryWhereFieldOperatorMap<E>>(
607
    ctx: QueryContext,
608
    entity: Type<E>,
609
    key: FieldKey<E>,
610
    op: K,
611
    val: QueryWhereFieldOperatorMap<E>[K],
612
    opts: QueryOptions = {},
1,648✔
613
  ): void {
614
    const field = this.resolveOperandField(ctx, entity, key as string, opts);
1,648✔
615

616
    const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP[op as string];
1,648✔
617
    if (simpleOp) {
1,648✔
618
      this.appendFieldSql(ctx, field, `${simpleOp}${this.addValue(ctx.values, val)}`);
24✔
619
      return;
24✔
620
    }
621

622
    const likeWrap = AbstractSqlDialect.LIKE_OP_MAP[op as string];
1,624✔
623
    if (likeWrap) {
1,624✔
624
      this.appendLikeOp(ctx, field, op as string, likeWrap(val as string));
80✔
625
      return;
80✔
626
    }
627

628
    switch (op) {
1,544✔
629
      case '$eq':
630
      case '$ne':
631
        this.appendEqNe(ctx, field, op as string, val);
1,101✔
632
        break;
1,101✔
633
      case '$regex':
634
        this.appendFieldSql(ctx, field, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
4✔
635
        break;
4✔
636
      case '$not':
637
        ctx.append('NOT (');
15✔
638
        this.compare(ctx, entity, key as keyof QueryWhereMap<E>, val as QueryWhereMap<E>[keyof QueryWhereMap<E>], opts);
15✔
639
        ctx.append(')');
15✔
640
        break;
15✔
641
      case '$in':
642
      case '$nin':
643
        this.appendInNin(ctx, field, op as string, val);
409✔
644
        break;
409✔
645
      case '$between': {
646
        const col = this.resolveColumnWithPrefix(entity, key, opts);
2✔
647
        const [min, max] = val as [unknown, unknown];
2✔
648
        ctx.append(`${col} BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
2✔
649
        break;
2✔
650
      }
651
      case '$isNull':
652
        this.appendFieldSql(ctx, field, val ? ' IS NULL' : ' IS NOT NULL');
3✔
653
        break;
3✔
654
      case '$isNotNull':
655
        this.appendFieldSql(ctx, field, val ? ' IS NOT NULL' : ' IS NULL');
3✔
656
        break;
3✔
657
      case '$all':
658
      case '$size':
659
      case '$elemMatch':
660
        throw TypeError(`${op} is not supported in the base SQL dialect - override in dialect subclass`);
3✔
661
      default:
662
        throw TypeError(`unknown operator: ${op}`);
4✔
663
    }
664
  }
665

666
  private appendLikeOp(ctx: QueryContext, field: string | undefined, op: string, wrappedVal: string): void {
667
    const isIlike = op.startsWith('$i') || op === '$ilike';
80✔
668
    const ph = this.addValue(ctx.values, wrappedVal);
80✔
669
    if (isIlike && field) {
80✔
670
      ctx.append(this.ilikeExpr(field, ph));
41✔
671
    } else {
672
      this.appendFieldSql(ctx, field, ` ${this.likeFn} ${ph}`);
39✔
673
    }
674
  }
675

676
  private appendEqNe(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
677
    if (val === null) {
1,101✔
678
      this.appendFieldSql(ctx, field, op === '$eq' ? ' IS NULL' : ' IS NOT NULL');
549✔
679
      return;
549✔
680
    }
681
    const ph = this.addValue(ctx.values, val);
552✔
682
    if (op === '$eq') {
552✔
683
      this.appendFieldSql(ctx, field, ` = ${ph}`);
502✔
684
      return;
502✔
685
    }
686
    if (field) {
50!
687
      ctx.append(this.neExpr(field, ph));
50✔
688
    } else {
689
      this.appendFieldSql(ctx, field, ` ${this.neOp} ${ph}`);
×
690
    }
691
  }
692

693
  private appendInNin(ctx: QueryContext, field: string | undefined, op: string, val: unknown): void {
694
    this.appendFieldSql(ctx, field, this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin'));
409!
695
  }
696

697
  protected addValues(ctx: QueryContext, vals: unknown[]): void {
698
    vals.forEach((val, index) => {
×
699
      if (index > 0) {
×
700
        ctx.append(', ');
×
701
      }
702
      ctx.addValue(val);
×
703
    });
704
  }
705

706
  /**
707
   * Build a comparison condition for a JSON field.
708
   * Used by both `$elemMatch` and dot-notation paths.
709
   * All dialect-specific behavior comes from overridable methods on `this`.
710
   */
711
  protected buildJsonFieldCondition(
712
    ctx: QueryContext,
713
    fieldAccessor: (f: string) => string,
714
    jsonPath: string,
715
    op: string,
716
    value: unknown,
717
  ): string {
718
    const jsonField = fieldAccessor(jsonPath);
106✔
719
    switch (op) {
106✔
720
      case '$eq':
721
        return value === null ? `${jsonField} IS NULL` : `${jsonField} = ${this.addValue(ctx.values, value)}`;
18✔
722
      case '$ne':
723
        if (value === null) return `${jsonField} IS NOT NULL`;
10✔
724
        return this.neExpr(jsonField, this.addValue(ctx.values, value));
9✔
725
      case '$gt':
726
        return `${this.numericCast(jsonField)} > ${this.addValue(ctx.values, value)}`;
7✔
727
      case '$gte':
728
        return `${this.numericCast(jsonField)} >= ${this.addValue(ctx.values, value)}`;
5✔
729
      case '$lt':
730
        return `${this.numericCast(jsonField)} < ${this.addValue(ctx.values, value)}`;
5✔
731
      case '$lte':
732
        return `${this.numericCast(jsonField)} <= ${this.addValue(ctx.values, value)}`;
5✔
733
      case '$like':
734
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, value)}`;
7✔
735
      case '$ilike':
736
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, (value as string).toLowerCase()));
7✔
737
      case '$startsWith':
738
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `${value}%`)}`;
5✔
739
      case '$istartsWith':
740
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `${(value as string).toLowerCase()}%`));
4✔
741
      case '$endsWith':
742
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}`)}`;
5✔
743
      case '$iendsWith':
744
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${(value as string).toLowerCase()}`));
4✔
745
      case '$includes':
746
        return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}%`)}`;
5✔
747
      case '$iincludes':
748
        return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${(value as string).toLowerCase()}%`));
4✔
749
      case '$regex':
750
        return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
5✔
751
      case '$in':
752
      case '$nin':
753
        return this.jsonInNin(ctx, jsonField, op, value);
9✔
754
      default:
755
        throw TypeError(`JSON field condition does not support operator: ${op}`);
1✔
756
    }
757
  }
758

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

763
  getComparisonKey<E>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, { prefix }: QueryOptions = {}): void {
68✔
764
    const meta = getMeta(entity);
68✔
765
    const escapedPrefix = this.escapeId(prefix as string, true, true);
68✔
766
    const field = meta.fields[key];
68✔
767

768
    if (field?.virtual) {
68✔
769
      this.getRawValue(ctx, {
4✔
770
        value: field.virtual,
771
        prefix,
772
        escapedPrefix,
773
      });
774
      return;
4✔
775
    }
776

777
    const columnName = this.resolveColumnName(key, field);
64✔
778
    ctx.append(escapedPrefix + this.escapeId(columnName));
64✔
779
  }
780

781
  sort<E>(ctx: QueryContext, entity: Type<E>, sort: QuerySortMap<E> | undefined, { prefix }: QueryOptions): void {
782
    const sortMap = buildSortMap(sort);
4,919✔
783
    if (!hasKeys(sortMap)) {
4,919✔
784
      return;
4,812✔
785
    }
786
    const meta = getMeta(entity);
107✔
787

788
    // Separate vector search entries from direction entries before flattening,
789
    // because flatObject recursively destructures objects — it would break QueryVectorSearch.
790
    const vectorEntries: [string, QueryVectorSearch][] = [];
107✔
791
    const directionEntries: Record<string, unknown> = {};
107✔
792
    for (const [key, val] of Object.entries(sortMap)) {
107✔
793
      if (isVectorSearch(val)) {
182✔
794
        vectorEntries.push([key, val]);
26✔
795
      } else {
796
        directionEntries[key] = val;
156✔
797
      }
798
    }
799

800
    const flattenedSort = flatObject(directionEntries, prefix);
107✔
801

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

805
    if (!allEntries.length) return;
107!
806

807
    ctx.append(' ORDER BY ');
107✔
808

809
    allEntries.forEach(([key, sort], index) => {
107✔
810
      if (index > 0) {
182✔
811
        ctx.append(', ');
75✔
812
      }
813

814
      if (isVectorSearch(sort)) {
182✔
815
        if (sort.$project) {
26✔
816
          // Distance already projected in SELECT — reference the alias to avoid recomputation
817
          ctx.append(this.escapeId(sort.$project));
5✔
818
        } else {
819
          this.appendVectorSort(ctx, meta, key, sort);
21✔
820
        }
821
        return;
23✔
822
      }
823

824
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[sort as QuerySortDirection];
156✔
825

826
      // Detect JSONB dot-notation: 'column.path'
827
      const jsonDot = this.resolveJsonDotPath(meta, key);
156✔
828
      if (jsonDot) {
156✔
829
        ctx.append(jsonDot.fieldAccessor(jsonDot.jsonPath) + direction);
8✔
830
        return;
8✔
831
      }
832

833
      const field = meta.fields[key as Key<E>];
148✔
834
      const name = this.resolveColumnName(key, field);
148✔
835
      ctx.append(this.escapeId(name) + direction);
148✔
836
    });
837
  }
838

839
  /**
840
   * Resolve common parameters for a vector similarity ORDER BY expression.
841
   * Shared by all dialect overrides of `appendVectorSort`.
842
   */
843
  protected resolveVectorSortParams<E>(
844
    meta: EntityMeta<E>,
845
    key: string,
846
    search: QueryVectorSearch,
847
  ): { colName: string; distance: VectorDistance; field: FieldOptions | undefined; vectorCast: VectorCast } {
848
    const field = meta.fields[key as FieldKey<E>];
25✔
849
    const colName = this.resolveColumnName(key, field);
25✔
850
    const distance = search.$distance ?? field?.distance ?? 'cosine';
25✔
851
    const vectorCast = resolveVectorCast(field);
25✔
852
    return { colName, distance, field, vectorCast };
25✔
853
  }
854

855
  /**
856
   * Mapping of UQL vector distance metrics to native SQL functions.
857
   * Override in dialects that use function-call syntax (e.g. SQLite, MariaDB).
858
   * Dialects with operator-based syntax (e.g. Postgres) leave this empty and override `appendVectorSort` directly.
859
   */
860
  protected readonly vectorDistanceFns: Partial<Record<VectorDistance, string>> = {};
105✔
861

862
  /**
863
   * Append a vector similarity function call: `fn(col, ?)`.
864
   * Used by dialects that express vector distance via SQL functions (SQLite, MariaDB).
865
   */
866
  protected appendFunctionVectorSort<E>(
867
    ctx: QueryContext,
868
    meta: EntityMeta<E>,
869
    key: string,
870
    search: QueryVectorSearch,
871
    dialectName: string,
872
  ): void {
873
    const { colName, distance, vectorCast } = this.resolveVectorSortParams(meta, key, search);
12✔
874
    const fn = this.vectorDistanceFns[distance];
12✔
875

876
    if (!fn) {
12✔
877
      throw Error(`${dialectName} does not support vector distance metric: ${distance}`);
2✔
878
    }
879

880
    ctx.append(`${fn}(${this.escapeId(colName)}, `);
10✔
881
    ctx.addValue(`[${search.$vector.join(',')}]`);
10✔
882
    if (vectorCast && dialectName === 'PostgreSQL') {
10!
883
      ctx.append(`::${vectorCast}`);
×
884
    }
885
    ctx.append(')');
10✔
886
  }
887

888
  /**
889
   * Append a vector distance projection.
890
   */
891
  protected appendVectorProjection<E>(
892
    ctx: QueryContext,
893
    meta: EntityMeta<E>,
894
    key: string,
895
    search: QueryVectorSearch,
896
  ): void {
897
    this.appendVectorSort(ctx, meta, key, search);
5✔
898
    ctx.append(` AS ${this.escapeId(search.$project as string)}`);
5✔
899
  }
900

901
  /**
902
   * Append a vector similarity ORDER BY expression.
903
   * Default: auto-delegates to `appendFunctionVectorSort` when `vectorDistanceFns` has entries.
904
   * Override for operator-based syntax (e.g. PostgreSQL `<=>`, `<->` operators).
905
   */
906
  protected appendVectorSort<E>(ctx: QueryContext, meta: EntityMeta<E>, key: string, search: QueryVectorSearch): void {
907
    if (hasKeys(this.vectorDistanceFns)) {
13✔
908
      this.appendFunctionVectorSort(ctx, meta, key, search, this.dialect);
12✔
909
      return;
12✔
910
    }
911
    throw new TypeError('Vector similarity sort is not supported by this dialect. Use raw() for vector queries.');
1✔
912
  }
913

914
  pager(ctx: QueryContext, opts: QueryPager): void {
915
    if (opts.$limit) {
4,969✔
916
      ctx.append(` LIMIT ${Number(opts.$limit)}`);
259✔
917
    }
918
    if (opts.$skip !== undefined) {
4,969✔
919
      ctx.append(` OFFSET ${Number(opts.$skip)}`);
71✔
920
    }
921
  }
922

923
  count<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void {
924
    const search: Query<E> = { ...q };
144✔
925
    delete search.$sort;
144✔
926
    this.select<E>(ctx, entity, [raw('COUNT(*)', 'count')]);
144✔
927
    this.search(ctx, entity, search, opts);
144✔
928
  }
929

930
  aggregate<E>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E>, opts: QueryOptions = {}): void {
53✔
931
    const meta = getMeta(entity);
53✔
932
    const tableName = this.resolveTableName(entity, meta);
53✔
933
    const groupKeys: string[] = [];
53✔
934
    const selectParts: string[] = [];
53✔
935
    const aggregateExpressions: Record<string, string> = {};
53✔
936

937
    for (const entry of parseGroupMap(q.$group)) {
53✔
938
      if (entry.kind === 'key') {
123✔
939
        const field = meta.fields[entry.alias as FieldKey<E>];
49✔
940
        const columnName = this.resolveColumnName(entry.alias, field);
49✔
941
        const escaped = this.escapeId(columnName);
49✔
942
        groupKeys.push(escaped);
49✔
943
        selectParts.push(columnName !== entry.alias ? `${escaped} ${this.escapeId(entry.alias)}` : escaped);
49!
944
      } else {
945
        const sqlFn = entry.op.slice(1).toUpperCase();
74✔
946
        const sqlArg =
947
          entry.fieldRef === '*'
74✔
948
            ? '*'
949
            : this.escapeId(this.resolveColumnName(entry.fieldRef, meta.fields[entry.fieldRef as FieldKey<E>]));
950
        const expr = `${sqlFn}(${sqlArg})`;
74✔
951
        aggregateExpressions[entry.alias] = expr;
74✔
952
        selectParts.push(`${expr} ${this.escapeId(entry.alias)}`);
74✔
953
      }
954
    }
955

956
    ctx.append(`SELECT ${selectParts.join(', ')} FROM ${this.escapeId(tableName)}`);
53✔
957
    this.where<E>(ctx, entity, q.$where, opts);
53✔
958

959
    if (groupKeys.length) {
53✔
960
      ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
49✔
961
    }
962

963
    if (q.$having) {
53✔
964
      this.having(ctx, q.$having, aggregateExpressions);
28✔
965
    }
966

967
    this.aggregateSort(ctx, q.$sort, aggregateExpressions);
53✔
968
    this.pager(ctx, q);
53✔
969
  }
970

971
  /**
972
   * ORDER BY for aggregate queries — handles both entity-field and alias references.
973
   */
974
  private aggregateSort(
975
    ctx: QueryContext,
976
    sort: QuerySortMap<object> | undefined,
977
    aggregateExpressions: Record<string, string>,
978
  ): void {
979
    const sortMap = buildSortMap(sort);
53✔
980
    if (!hasKeys(sortMap)) return;
53✔
981

982
    ctx.append(' ORDER BY ');
16✔
983
    Object.entries(sortMap).forEach(([key, dir], index) => {
16✔
984
      if (index > 0) ctx.append(', ');
25✔
985
      const direction = AbstractSqlDialect.SORT_DIRECTION_MAP[dir as QuerySortDirection];
25✔
986
      const ref = aggregateExpressions[key] ?? this.escapeId(key);
25✔
987
      ctx.append(ref + direction);
25✔
988
    });
989
  }
990

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

995
    ctx.append(' HAVING ');
28✔
996
    entries.forEach(([alias, condition], index) => {
28✔
997
      if (index > 0) ctx.append(' AND ');
31✔
998
      const expr = aggregateExpressions[alias] ?? this.escapeId(alias);
31!
999
      this.havingCondition(ctx, expr, condition!);
31✔
1000
    });
1001
  }
1002

1003
  private static readonly SORT_DIRECTION_MAP: Record<string | number, string> = Object.assign(
55✔
1004
    { 1: '', asc: '', desc: ' DESC', '-1': ' DESC' },
1005
    { [-1]: ' DESC' },
1006
  );
1007

1008
  private static readonly havingOpMap: Record<string, string> = {
55✔
1009
    $eq: '=',
1010
    $ne: '<>',
1011
    $gt: '>',
1012
    $gte: '>=',
1013
    $lt: '<',
1014
    $lte: '<=',
1015
  };
1016

1017
  protected havingCondition(ctx: QueryContext, expr: string, condition: QueryHavingMap[string]): void {
1018
    if (typeof condition !== 'object' || condition === null) {
31✔
1019
      ctx.append(`${expr} = `);
3✔
1020
      ctx.addValue(condition);
3✔
1021
      return;
3✔
1022
    }
1023
    const ops = condition as QueryWhereFieldOperatorMap<number>;
28✔
1024
    const keys = getKeys(ops);
28✔
1025
    keys.forEach((op, i) => {
28✔
1026
      if (i > 0) ctx.append(' AND ');
28!
1027
      const val = ops[op];
28✔
1028
      if (op === '$between') {
28✔
1029
        const [min, max] = val as [number, number];
3✔
1030
        ctx.append(`${expr} BETWEEN `);
3✔
1031
        ctx.addValue(min);
3✔
1032
        ctx.append(' AND ');
3✔
1033
        ctx.addValue(max);
3✔
1034
      } else if (op === '$in' || op === '$nin') {
25✔
1035
        ctx.append(`${expr}${this.formatIn(ctx, Array.isArray(val) ? (val as unknown[]) : [], op === '$nin')}`);
9!
1036
      } else if (op === '$isNull') {
16✔
1037
        ctx.append(`${expr}${val ? ' IS NULL' : ' IS NOT NULL'}`);
3!
1038
      } else if (op === '$isNotNull') {
13✔
1039
        ctx.append(`${expr}${val ? ' IS NOT NULL' : ' IS NULL'}`);
3!
1040
      } else if (op === '$ne') {
10!
1041
        ctx.append(this.neExpr(expr, this.addValue(ctx.values, val)));
×
1042
      } else {
1043
        const sqlOp = AbstractSqlDialect.havingOpMap[op];
10✔
1044
        if (!sqlOp) throw TypeError(`unsupported HAVING operator: ${op}`);
10!
1045
        ctx.append(`${expr} ${sqlOp} `);
10✔
1046
        ctx.addValue(val);
10✔
1047
      }
1048
    });
1049
  }
1050

1051
  find<E>(ctx: QueryContext, entity: Type<E>, q: Query<E> = {}, opts?: QueryOptions): void {
3,533✔
1052
    this.select(ctx, entity, q.$select, opts, q.$distinct, q.$sort);
3,533✔
1053
    this.search(ctx, entity, q, opts);
3,533✔
1054
  }
1055

1056
  insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void {
1057
    const meta = getMeta(entity);
384✔
1058
    const payloads = fillOnFields(meta, payload, 'onInsert');
384✔
1059
    const keys = filterFieldKeys(meta, payloads[0], 'onInsert');
384✔
1060

1061
    const columns = keys.map((key) => {
384✔
1062
      const field = meta.fields[key];
1,068✔
1063
      return this.escapeId(this.resolveColumnName(key, field));
1,068✔
1064
    });
1065
    const tableName = this.resolveTableName(entity, meta);
384✔
1066
    ctx.append(`INSERT INTO ${this.escapeId(tableName)} (${columns.join(', ')}) VALUES (`);
384✔
1067

1068
    payloads.forEach((it, recordIndex) => {
384✔
1069
      if (recordIndex > 0) {
545✔
1070
        ctx.append('), (');
161✔
1071
      }
1072
      keys.forEach((key, keyIndex) => {
545✔
1073
        if (keyIndex > 0) {
1,530✔
1074
          ctx.append(', ');
985✔
1075
        }
1076
        const field = meta.fields[key];
1,530✔
1077
        this.formatPersistableValue(ctx, field, it[key]);
1,530✔
1078
      });
1079
    });
1080
    ctx.append(')');
384✔
1081
  }
1082

1083
  update<E>(
1084
    ctx: QueryContext,
1085
    entity: Type<E>,
1086
    q: QuerySearch<E>,
1087
    payload: UpdatePayload<E>,
1088
    opts?: QueryOptions,
1089
  ): void {
1090
    const meta = getMeta(entity);
92✔
1091
    const [filledPayload] = fillOnFields(meta, payload as E, 'onUpdate');
92✔
1092
    const keys = filterFieldKeys(meta, filledPayload, 'onUpdate');
92✔
1093

1094
    const tableName = this.resolveTableName(entity, meta);
92✔
1095
    ctx.append(`UPDATE ${this.escapeId(tableName)} SET `);
92✔
1096
    keys.forEach((key, index) => {
92✔
1097
      if (index > 0) {
173✔
1098
        ctx.append(', ');
81✔
1099
      }
1100
      const field = meta.fields[key];
173✔
1101
      const columnName = this.resolveColumnName(key, field);
173✔
1102
      const escapedCol = this.escapeId(columnName);
173✔
1103
      const value = filledPayload[key];
173✔
1104

1105
      if (this.isJsonUpdateOp(value)) {
173✔
1106
        this.formatJsonUpdate<E>(ctx, escapedCol, value);
22✔
1107
      } else {
1108
        ctx.append(`${escapedCol} = `);
151✔
1109
        this.formatPersistableValue(ctx, field, value);
151✔
1110
      }
1111
    });
1112

1113
    this.search(ctx, entity, q, opts);
92✔
1114
  }
1115

1116
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void {
1117
    const meta = getMeta(entity);
7✔
1118
    const updateCtx = this.createContext();
7✔
1119
    const update = this.getUpsertUpdateAssignments(
7✔
1120
      updateCtx,
1121
      meta,
1122
      conflictPaths,
1123
      payload,
1124
      (name) => `VALUES(${name})`,
8✔
1125
    );
1126

1127
    if (update) {
7✔
1128
      this.insert(ctx, entity, payload);
6✔
1129
      ctx.append(` ON DUPLICATE KEY UPDATE ${update}`);
6✔
1130
      ctx.pushValue(...updateCtx.values);
6✔
1131
    } else {
1132
      const insertCtx = this.createContext();
1✔
1133
      this.insert(insertCtx, entity, payload);
1✔
1134
      ctx.append(insertCtx.sql.replace(/^INSERT/, 'INSERT IGNORE'));
1✔
1135
      ctx.pushValue(...insertCtx.values);
1✔
1136
    }
1137
  }
1138

1139
  protected getUpsertUpdateAssignments<E>(
1140
    ctx: QueryContext,
1141
    meta: EntityMeta<E>,
1142
    conflictPaths: QueryConflictPaths<E>,
1143
    payload: E | E[],
1144
    callback?: (columnName: string) => string,
1145
  ): string {
1146
    const sample = Array.isArray(payload) ? payload[0] : payload;
38✔
1147
    const cloned = { ...sample };
38✔
1148
    const [filledPayload] = fillOnFields(meta, cloned, 'onUpdate');
38✔
1149
    const fields = filterFieldKeys(meta, filledPayload, 'onUpdate');
38✔
1150
    return fields
38✔
1151
      .filter((col) => !conflictPaths[col])
105✔
1152
      .map((col) => {
1153
        const field = meta.fields[col];
72✔
1154
        const columnName = this.resolveColumnName(col, field);
72✔
1155
        if (callback && Object.hasOwn(sample as object, col)) {
72✔
1156
          return `${this.escapeId(columnName)} = ${callback(this.escapeId(columnName))}`;
40✔
1157
        }
1158
        const valCtx = this.createContext();
32✔
1159
        this.formatPersistableValue(valCtx, field, filledPayload[col]);
32✔
1160
        ctx.pushValue(...valCtx.values);
32✔
1161
        return `${this.escapeId(columnName)} = ${valCtx.sql}`;
32✔
1162
      })
1163
      .join(', ');
1164
  }
1165

1166
  /**
1167
   * Shared ON CONFLICT ... DO UPDATE / DO NOTHING logic for positional-placeholder dialects (SQLite).
1168
   * Uses a deferred context for update params so they follow INSERT params.
1169
   * PG uses its own implementation since `$N` numbered placeholders handle param ordering natively.
1170
   */
1171
  protected onConflictUpsert<E>(
1172
    ctx: QueryContext,
1173
    entity: Type<E>,
1174
    conflictPaths: QueryConflictPaths<E>,
1175
    payload: E | E[],
1176
    insertFn: (ctx: QueryContext, entity: Type<E>, payload: E | E[]) => void,
1177
  ): void {
1178
    const meta = getMeta(entity);
9✔
1179
    const updateCtx = this.createContext();
9✔
1180
    const update = this.getUpsertUpdateAssignments(
9✔
1181
      updateCtx,
1182
      meta,
1183
      conflictPaths,
1184
      payload,
1185
      (name) => `EXCLUDED.${name}`,
10✔
1186
    );
1187
    const keysStr = this.getUpsertConflictPathsStr(meta, conflictPaths);
9✔
1188
    const onConflict = update ? `DO UPDATE SET ${update}` : 'DO NOTHING';
9✔
1189
    insertFn(ctx, entity, payload);
9✔
1190
    ctx.append(` ON CONFLICT (${keysStr}) ${onConflict}`);
9✔
1191
    ctx.pushValue(...updateCtx.values);
9✔
1192
  }
1193

1194
  protected getUpsertConflictPathsStr<E>(meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>): string {
1195
    return (getKeys(conflictPaths) as Key<E>[])
23✔
1196
      .map((key) => {
1197
        const field = meta.fields[key];
25✔
1198
        const columnName = this.resolveColumnName(key, field);
25✔
1199
        return this.escapeId(columnName);
25✔
1200
      })
1201
      .join(', ');
1202
  }
1203

1204
  delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts: QueryOptions = {}): void {
1,157✔
1205
    const meta = getMeta(entity);
1,157✔
1206
    const tableName = this.resolveTableName(entity, meta);
1,157✔
1207

1208
    if (opts.softDelete || opts.softDelete === undefined) {
1,157✔
1209
      if (meta.softDelete) {
1,151✔
1210
        const field = meta.fields[meta.softDelete];
134✔
1211
        if (!field?.onDelete) return;
134!
1212
        const value = getFieldCallbackValue(field.onDelete);
134✔
1213
        const columnName = this.resolveColumnName(meta.softDelete, field);
134✔
1214
        ctx.append(`UPDATE ${this.escapeId(tableName)} SET ${this.escapeId(columnName)} = `);
134✔
1215
        ctx.addValue(value);
134✔
1216
        this.search(ctx, entity, q, opts);
134✔
1217
        return;
134✔
1218
      }
1219
      if (opts.softDelete) {
1,017✔
1220
        throw TypeError(`'${tableName}' has not enabled 'softDelete'`);
3✔
1221
      }
1222
    }
1223

1224
    ctx.append(`DELETE FROM ${this.escapeId(tableName)}`);
1,020✔
1225
    this.search(ctx, entity, q, opts);
1,020✔
1226
  }
1227

1228
  escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string {
1229
    return escapeSqlId(val, this.escapeIdChar, forbidQualified, addDot);
21,586✔
1230
  }
1231

1232
  protected getPersistables<E>(
1233
    ctx: QueryContext,
1234
    meta: EntityMeta<E>,
1235
    payload: E | E[],
1236
    callbackKey: CallbackKey,
1237
  ): Record<string, unknown>[] {
1238
    const payloads = fillOnFields(meta, payload, callbackKey);
1✔
1239
    return payloads.map((it) => this.getPersistable(ctx, meta, it, callbackKey));
1✔
1240
  }
1241

1242
  protected getPersistable<E>(
1243
    ctx: QueryContext,
1244
    meta: EntityMeta<E>,
1245
    payload: E,
1246
    callbackKey: CallbackKey,
1247
  ): Record<string, unknown> {
1248
    const filledPayload = fillOnFields(meta, payload, callbackKey)[0];
1✔
1249
    const keys = filterFieldKeys(meta, filledPayload, callbackKey);
1✔
1250
    return keys.reduce(
1✔
1251
      (acc, key) => {
1252
        const field = meta.fields[key];
2✔
1253
        const valCtx = this.createContext();
2✔
1254
        this.formatPersistableValue(valCtx, field, filledPayload[key]);
2✔
1255
        ctx.pushValue(...valCtx.values);
2✔
1256
        acc[key] = valCtx.sql;
2✔
1257
        return acc;
2✔
1258
      },
1259
      {} as Record<string, unknown>,
1260
    );
1261
  }
1262

1263
  protected formatPersistableValue<E>(ctx: QueryContext, field: FieldOptions | undefined, value: unknown): void {
1264
    if (value instanceof QueryRaw) {
1,700✔
1265
      this.getRawValue(ctx, { value });
4✔
1266
      return;
4✔
1267
    }
1268
    if (isJsonType(field?.type)) {
1,696✔
1269
      ctx.addValue(value ? JSON.stringify(value) : null);
6✔
1270
      return;
6✔
1271
    }
1272
    if (field?.type === 'vector' && Array.isArray(value)) {
1,690✔
1273
      ctx.addValue(`[${value.join(',')}]`);
1✔
1274
      return;
1✔
1275
    }
1276
    ctx.addValue(value);
1,689✔
1277
  }
1278

1279
  /**
1280
   * Generate SQL for a JSONB merge and/or unset operation.
1281
   * Called from `update()` when a field value has `$merge`, `$unset`, and/or `$push` operators.
1282
   * Generates the full `"col" = <expression>` assignment.
1283
   *
1284
   * Base implementation uses MySQL-compatible syntax with *shallow* merge semantics
1285
   * (RHS top-level keys replace LHS top-level keys, matching PostgreSQL's `jsonb || jsonb`).
1286
   * Override in dialect subclasses when a dialect needs different JSON function semantics.
1287
   */
1288
  protected formatJsonUpdate<E>(ctx: QueryContext, escapedCol: string, value: JsonUpdateOp<E>): void {
1289
    let expr = escapedCol;
8✔
1290
    if (hasKeys(value.$merge)) {
8✔
1291
      const merge = value.$merge as Record<string, unknown>;
4✔
1292
      expr = `JSON_SET(COALESCE(${escapedCol}, '{}')`;
4✔
1293
      for (const [key, v] of Object.entries(merge)) {
4✔
1294
        expr += `, '$.${this.escapeJsonKey(key)}', CAST(? AS JSON)`;
4✔
1295
        ctx.pushValue(JSON.stringify(v));
4✔
1296
      }
1297
      expr += ')';
4✔
1298
    }
1299
    if (hasKeys(value.$push)) {
8✔
1300
      const push = value.$push as Record<string, unknown>;
4✔
1301
      expr = `JSON_ARRAY_APPEND(${expr}`;
4✔
1302
      for (const [key, v] of Object.entries(push)) {
4✔
1303
        expr += `, '$.${this.escapeJsonKey(key)}', CAST(? AS JSON)`;
4✔
1304
        ctx.pushValue(JSON.stringify(v));
4✔
1305
      }
1306
      expr += ')';
4✔
1307
    }
1308
    if (value.$unset?.length) {
8✔
1309
      for (const key of value.$unset) {
4✔
1310
        expr = `JSON_REMOVE(${expr}, '$.${this.escapeJsonKey(key)}')`;
5✔
1311
      }
1312
    }
1313
    ctx.append(`${escapedCol} = ${expr}`);
8✔
1314
  }
1315

1316
  protected isJsonUpdateOp(value: unknown): value is JsonUpdateOp {
1317
    return typeof value === 'object' && value !== null && ('$merge' in value || '$unset' in value || '$push' in value);
173✔
1318
  }
1319

1320
  /** Escapes a JSON key for safe interpolation into SQL string literals. */
1321
  protected escapeJsonKey(key: string): string {
1322
    return key.replace(/'/g, "''");
160✔
1323
  }
1324

1325
  getRawValue(ctx: QueryContext, opts: QueryRawFnOptions & { value: QueryRaw; autoPrefixAlias?: boolean }) {
1326
    const { value, prefix = '', escapedPrefix, autoPrefixAlias } = opts;
232✔
1327
    const rawValue = value[RAW_VALUE];
232✔
1328
    if (typeof rawValue === 'function') {
232✔
1329
      const res = rawValue({
49✔
1330
        ...opts,
1331
        ctx,
1332
        dialect: this,
1333
        prefix,
1334
        escapedPrefix: escapedPrefix ?? this.escapeId(prefix, true, true),
71✔
1335
      });
1336
      if (typeof res === 'string' || (typeof res === 'number' && !Number.isNaN(res))) {
49✔
1337
        ctx.append(String(res));
10✔
1338
      }
1339
    } else {
1340
      ctx.append(prefix + String(rawValue));
183✔
1341
    }
1342
    const alias = value[RAW_ALIAS];
232✔
1343
    if (alias) {
232✔
1344
      const fullAlias = autoPrefixAlias && prefix ? `${prefix}.${alias}` : alias;
171✔
1345
      ctx.append(' ' + this.escapeId(fullAlias, true));
171✔
1346
    }
1347
  }
1348

1349
  /**
1350
   * Resolves a dot-notation key to its JSON field metadata.
1351
   * Shared by `where()` and `sort()` to detect 'column.path' keys where 'column' is a JSON/JSONB field.
1352
   *
1353
   * @returns resolved metadata or `undefined` if the key is not a JSON dot-notation path
1354
   */
1355
  protected resolveJsonDotPath<E>(
1356
    meta: EntityMeta<E>,
1357
    key: string,
1358
    prefix?: string,
1359
  ): { root: string; jsonPath: string; fieldAccessor: (f: string) => string } | undefined {
1360
    const dotIndex = key.indexOf('.');
1,854✔
1361
    if (dotIndex <= 0) {
1,854✔
1362
      return undefined;
1,801✔
1363
    }
1364
    const root = key.slice(0, dotIndex);
53✔
1365
    const field = meta.fields[root as FieldKey<E>];
53✔
1366
    if (!field || !isJsonType(field.type)) {
53✔
1367
      return undefined;
9✔
1368
    }
1369
    const jsonPath = key.slice(dotIndex + 1);
44✔
1370
    const colName = this.resolveColumnName(root, field);
44✔
1371
    const escapedCol = (prefix ? this.escapeId(prefix, true, true) : '') + this.escapeId(colName);
44!
1372
    return { root, jsonPath, fieldAccessor: () => this.getJsonPathScalarExpr(escapedCol, jsonPath) };
1,854✔
1373
  }
1374

1375
  /**
1376
   * Compare a JSONB dot-notation path, e.g. `'settings.isArchived': { $ne: true }`.
1377
   * Receives a pre-resolved `resolveJsonDotPath` result to avoid redundant computation.
1378
   */
1379
  protected compareJsonPath(
1380
    ctx: QueryContext,
1381
    resolved: { jsonPath: string; fieldAccessor: (f: string) => string },
1382
    val: unknown,
1383
  ): void {
1384
    const { jsonPath, fieldAccessor } = resolved;
36✔
1385
    const value = this.normalizeWhereValue(val);
36✔
1386
    const operators = getKeys(value);
36✔
1387

1388
    if (operators.length > 1) {
36✔
1389
      ctx.append('(');
2✔
1390
    }
1391

1392
    operators.forEach((op, index) => {
36✔
1393
      if (index > 0) ctx.append(' AND ');
38✔
1394
      ctx.append(this.buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value[op]));
38✔
1395
    });
1396

1397
    if (operators.length > 1) {
36✔
1398
      ctx.append(')');
2✔
1399
    }
1400
  }
1401

1402
  /**
1403
   * Returns SQL that extracts a scalar value from a JSON path.
1404
   * Dialects can override this to customize path access syntax while preserving
1405
   * the shared comparison/operator pipeline.
1406
   */
1407
  protected getJsonPathScalarExpr(escapedColumn: string, jsonPath: string): string {
1408
    const segments = jsonPath.split('.');
36✔
1409
    let expr = escapedColumn;
36✔
1410
    for (let i = 0; i < segments.length; i++) {
36✔
1411
      const op = i === segments.length - 1 ? '->>' : '->';
40✔
1412
      expr = `(${expr}${op}'${this.escapeJsonKey(segments[i])}')`;
40✔
1413
    }
1414
    return expr;
36✔
1415
  }
1416

1417
  /**
1418
   * Normalizes a raw WHERE value into an operator map.
1419
   * Arrays become `$in`, scalars/null become `$eq`, objects pass through.
1420
   */
1421
  private normalizeWhereValue(val: unknown): Record<string, unknown> {
1422
    if (Array.isArray(val)) return { $in: val };
1,671✔
1423
    if (typeof val === 'object' && val !== null) return val as Record<string, unknown>;
1,273✔
1424
    return { $eq: val };
1,045✔
1425
  }
1426

1427
  /**
1428
   * Filter by relation using an EXISTS subquery.
1429
   * Supports all cardinalities: mm (via junction), 1m, m1, and 11.
1430
   */
1431
  protected compareRelation<E>(
1432
    ctx: QueryContext,
1433
    entity: Type<E>,
1434
    key: string,
1435
    val: QueryWhereMap<unknown>,
1436
    rel: RelationOptions,
1437
    opts: QueryComparisonOptions,
1438
  ): void {
1439
    const meta = getMeta(entity);
15✔
1440
    const parentTable = this.resolveTableName(entity, meta);
15✔
1441
    const parentId = meta.id!;
15✔
1442
    const escapedParentId =
15✔
1443
      (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
15!
1444
      this.escapeId(parentId);
1445

1446
    if (!rel.references?.length) {
15✔
1447
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1448
    }
1449

1450
    const relatedEntity = rel.entity!();
14✔
1451
    const relatedMeta = getMeta(relatedEntity);
14✔
1452
    const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
14✔
1453

1454
    ctx.append('EXISTS (SELECT 1 FROM ');
14✔
1455

1456
    if (rel.cardinality === 'mm' && rel.through) {
14✔
1457
      // ManyToMany: EXISTS (SELECT 1 FROM JunctionTable WHERE junction.localFk = parent.id AND junction.foreignFk IN (SELECT related.id FROM Related WHERE ...))
1458
      const throughEntity = rel.through();
7✔
1459
      const throughMeta = getMeta(throughEntity);
7✔
1460
      const throughTable = this.resolveTableName(throughEntity, throughMeta);
7✔
1461
      const localFk = rel.references[0].local;
7✔
1462
      const foreignFk = rel.references[1].local;
7✔
1463
      const relatedId = relatedMeta.id!;
7✔
1464

1465
      ctx.append(this.escapeId(throughTable));
7✔
1466
      ctx.append(` WHERE ${this.escapeId(throughTable, false, true)}${this.escapeId(localFk)} = ${escapedParentId}`);
7✔
1467
      ctx.append(` AND ${this.escapeId(throughTable, false, true)}${this.escapeId(foreignFk)} IN (`);
7✔
1468
      ctx.append(
7✔
1469
        `SELECT ${this.escapeId(relatedTable, false, true)}${this.escapeId(relatedId)} FROM ${this.escapeId(relatedTable)}`,
1470
      );
1471
      this.where(ctx, relatedEntity, val as QueryWhere<typeof relatedEntity>, {
7✔
1472
        prefix: relatedTable,
1473
        clause: 'WHERE',
1474
        softDelete: false,
1475
      });
1476
      ctx.append(')');
7✔
1477
    } else {
1478
      // 1m / m1 / 11: EXISTS (SELECT 1 FROM Related WHERE related.fk_or_pk = parent.pk_or_fk AND ...)
1479
      // Left side is always relatedTable.references[0].foreign
1480
      // Right side is the parent's PK (1m) or the parent's FK (m1/11)
1481
      const joinLeft = `${this.escapeId(relatedTable, false, true)}${this.escapeId(rel.references[0].foreign)}`;
7✔
1482
      const joinRight =
1483
        rel.cardinality === '1m'
7✔
1484
          ? escapedParentId
1485
          : (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
3!
1486
            this.escapeId(rel.references[0].local);
1487

1488
      ctx.append(this.escapeId(relatedTable));
7✔
1489
      ctx.append(` WHERE ${joinLeft} = ${joinRight}`);
7✔
1490
      this.where(ctx, relatedEntity, val as QueryWhere<typeof relatedEntity>, {
7✔
1491
        prefix: relatedTable,
1492
        clause: 'AND',
1493
        softDelete: false,
1494
      });
1495
    }
1496

1497
    ctx.append(')');
14✔
1498
  }
1499

1500
  /**
1501
   * Filter by relation size using a `COUNT(*)` subquery.
1502
   * Supports all cardinalities: mm (via junction), 1m.
1503
   */
1504
  protected compareRelationSize<E>(
1505
    ctx: QueryContext,
1506
    entity: Type<E>,
1507
    key: string,
1508
    sizeVal: number | QuerySizeComparisonOps,
1509
    rel: RelationOptions,
1510
    opts: QueryComparisonOptions,
1511
  ): void {
1512
    const meta = getMeta(entity);
12✔
1513
    const parentTable = this.resolveTableName(entity, meta);
12✔
1514
    const parentId = meta.id!;
12✔
1515
    const escapedParentId =
12✔
1516
      (opts.prefix ? this.escapeId(opts.prefix, true, true) : this.escapeId(parentTable, false, true)) +
12!
1517
      this.escapeId(parentId);
1518

1519
    if (!rel.references?.length) {
12✔
1520
      throw new TypeError(`Relation '${key}' on '${parentTable}' has no references defined`);
1✔
1521
    }
1522

1523
    const appendSubquery = () => {
11✔
1524
      ctx.append('(SELECT COUNT(*) FROM ');
11✔
1525

1526
      if (rel.cardinality === 'mm' && rel.through) {
11✔
1527
        const throughEntity = rel.through();
5✔
1528
        const throughMeta = getMeta(throughEntity);
5✔
1529
        const throughTable = this.resolveTableName(throughEntity, throughMeta);
5✔
1530
        const localFk = rel.references![0].local;
5✔
1531

1532
        ctx.append(this.escapeId(throughTable));
5✔
1533
        ctx.append(` WHERE ${this.escapeId(throughTable, false, true)}${this.escapeId(localFk)} = ${escapedParentId}`);
5✔
1534
      } else {
1535
        const relatedEntity = rel.entity!();
6✔
1536
        const relatedMeta = getMeta(relatedEntity);
6✔
1537
        const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
6✔
1538
        const joinLeft = `${this.escapeId(relatedTable, false, true)}${this.escapeId(rel.references![0].foreign)}`;
6✔
1539

1540
        ctx.append(this.escapeId(relatedTable));
6✔
1541
        ctx.append(` WHERE ${joinLeft} = ${escapedParentId}`);
6✔
1542
      }
1543

1544
      ctx.append(')');
11✔
1545
    };
1546

1547
    this.buildSizeComparison(ctx, appendSubquery, sizeVal);
11✔
1548
  }
1549

1550
  /**
1551
   * Build a complete `$size` comparison expression.
1552
   * Handles both single and multiple comparison operators by repeating the size expression.
1553
   * @param sizeExprFn - function that appends the size expression to ctx (e.g. `jsonb_array_length("col")`)
1554
   */
1555
  protected buildSizeComparison(
1556
    ctx: QueryContext,
1557
    sizeExprFn: () => void,
1558
    sizeVal: number | QuerySizeComparisonOps,
1559
  ): void {
1560
    if (typeof sizeVal === 'number') {
27✔
1561
      sizeExprFn();
6✔
1562
      ctx.append(' = ');
6✔
1563
      ctx.addValue(sizeVal);
6✔
1564
      return;
6✔
1565
    }
1566

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

1569
    if (entries.length > 1) {
21✔
1570
      ctx.append('(');
4✔
1571
    }
1572

1573
    entries.forEach(([op, val], index) => {
21✔
1574
      if (index > 0) {
25✔
1575
        ctx.append(' AND ');
4✔
1576
      }
1577
      sizeExprFn();
25✔
1578
      this.appendSizeOp(ctx, op, val);
25✔
1579
    });
1580

1581
    if (entries.length > 1) {
21✔
1582
      ctx.append(')');
4✔
1583
    }
1584
  }
1585

1586
  /**
1587
   * Append a single size comparison operator and value to the context.
1588
   */
1589
  private appendSizeOp(ctx: QueryContext, op: string, val: unknown): void {
1590
    switch (op) {
25✔
1591
      case '$eq':
1592
        ctx.append(' = ');
1✔
1593
        ctx.addValue(val);
1✔
1594
        break;
1✔
1595
      case '$ne':
1596
        ctx.append(' <> ');
1✔
1597
        ctx.addValue(val);
1✔
1598
        break;
1✔
1599
      case '$gt':
1600
        ctx.append(' > ');
5✔
1601
        ctx.addValue(val);
5✔
1602
        break;
5✔
1603
      case '$gte':
1604
        ctx.append(' >= ');
6✔
1605
        ctx.addValue(val);
6✔
1606
        break;
6✔
1607
      case '$lt':
1608
        ctx.append(' < ');
1✔
1609
        ctx.addValue(val);
1✔
1610
        break;
1✔
1611
      case '$lte':
1612
        ctx.append(' <= ');
5✔
1613
        ctx.addValue(val);
5✔
1614
        break;
5✔
1615
      case '$between': {
1616
        const [min, max] = val as [number, number];
5✔
1617
        ctx.append(' BETWEEN ');
5✔
1618
        ctx.addValue(min);
5✔
1619
        ctx.append(' AND ');
5✔
1620
        ctx.addValue(max);
5✔
1621
        break;
5✔
1622
      }
1623
      default:
1624
        throw TypeError(`unsupported $size comparison operator: ${op}`);
1✔
1625
    }
1626
  }
1627

1628
  abstract escape(value: unknown): string;
1629

1630
  protected get regexpOp(): string {
1631
    return 'REGEXP';
7✔
1632
  }
1633

1634
  protected get likeFn(): string {
1635
    return 'LIKE';
61✔
1636
  }
1637

1638
  /**
1639
   * Not-equal operator token for non-null comparisons.
1640
   * Postgres uses `IS DISTINCT FROM`; MySQL/Maria uses custom `neExpr`.
1641
   */
1642
  protected get neOp(): string {
1643
    return '<>';
3✔
1644
  }
1645

1646
  protected neExpr(field: string, ph: string): string {
1647
    return `${field} ${this.neOp} ${ph}`;
27✔
1648
  }
1649

1650
  protected ilikeExpr(f: string, ph: string): string {
1651
    return `LOWER(${f}) LIKE ${ph}`;
1✔
1652
  }
1653

1654
  /**
1655
   * Formats an IN/NOT IN expression, binding each value individually.
1656
   * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.
1657
   */
1658
  protected formatIn(ctx: QueryContext, values: unknown[], negate: boolean): string {
1659
    if (values.length === 0) return negate ? ' NOT IN (NULL)' : ' IN (NULL)';
329✔
1660
    const phs = values.map((v) => this.addValue(ctx.values, v)).join(', ');
557✔
1661
    return ` ${negate ? 'NOT IN' : 'IN'} (${phs})`;
324✔
1662
  }
1663

1664
  protected numericCast(expr: string): string {
1665
    return expr;
×
1666
  }
1667
}
1668

1669
/**
1670
 * Type guard: narrows a relation select value to a query object (with optional `$required`).
1671
 */
1672
function isRelationSelectQuery(val: unknown): val is Query<unknown> & { $required?: boolean } {
1673
  return val !== null && typeof val === 'object' && !Array.isArray(val);
162✔
1674
}
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