@travetto/model-sql 8.0.0-alpha.3 → 8.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dialect.ts ADDED
@@ -0,0 +1,825 @@
1
+ import type { IndexConfig, ModelType } from '@travetto/model';
2
+ import { ModelRegistryIndex } from '@travetto/model';
3
+ import { isModelIndexedIndex } from '@travetto/model-indexed';
4
+ import { isModelQueryIndex, ModelQueryUtil, type SortClause, type WhereClause } from '@travetto/model-query';
5
+ import { type Class, castTo, JSONUtil, RuntimeError } from '@travetto/runtime';
6
+ import { DataUtil, type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
7
+
8
+ import type { JSONSqlPathMode, ResolvedPathContext, SchemaContext, TableContext } from './types.ts';
9
+
10
+ export interface TransactionStatements {
11
+ begin: string;
12
+ beginNested: string;
13
+ isolate: string;
14
+ rollback: string;
15
+ rollbackNested: string;
16
+ commit: string;
17
+ commitNested: string;
18
+ }
19
+
20
+ interface QueryClause {
21
+ sql?: string;
22
+ parameters?: Record<string, unknown>;
23
+ }
24
+
25
+ type IdentificationPath = string;
26
+
27
+ /**
28
+ * Abstract ANSI SQL-99 Dialect base implementation.
29
+ * Pure SQL text generator and query builder (does not execute queries or hold connection state).
30
+ */
31
+ export abstract class AbstractANSI99Dialect {
32
+ static TRANSACTION_STATEMENTS: TransactionStatements = {
33
+ begin: 'BEGIN;',
34
+ beginNested: 'SAVEPOINT $1;',
35
+ isolate: 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED;',
36
+ rollback: 'ROLLBACK;',
37
+ rollbackNested: 'ROLLBACK TO $1;',
38
+ commit: 'COMMIT;',
39
+ commitNested: 'RELEASE SAVEPOINT $1;'
40
+ };
41
+
42
+ static SCHEMA_CACHE = new Map<Class, SchemaContext<unknown>>();
43
+
44
+ returningSupport = false;
45
+ suggestLikeOperator = 'LIKE';
46
+ transactionStatements: TransactionStatements = AbstractANSI99Dialect.TRANSACTION_STATEMENTS;
47
+ abstract getComplexColumnType(field: SchemaFieldConfig): string;
48
+
49
+ getComplexColumnValue(field: SchemaFieldConfig, value: unknown): unknown {
50
+ return value === null || value === undefined ? null : JSONUtil.toUTF8(value);
51
+ }
52
+
53
+ escapeIdentifier(name: string): string {
54
+ return `"${name.replaceAll('"', '""')}"`;
55
+ }
56
+
57
+ escapeLiteral(value: string): string {
58
+ return value.replaceAll("'", "''");
59
+ }
60
+
61
+ getPlaceholder(index: number): string {
62
+ return '?';
63
+ }
64
+
65
+ abstract getColumnType(fieldConfiguration: SchemaFieldConfig): string;
66
+ abstract compileJsonIndexPath(columnName: string, jsonPath: string[], mode: JSONSqlPathMode): string;
67
+ abstract compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown };
68
+ abstract compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown };
69
+ abstract compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown };
70
+ abstract compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string };
71
+ abstract compileArrayRegex(context: ResolvedPathContext, identifier: string, value: RegExp | string): { sql: string; formatted: unknown };
72
+
73
+ abstract getRegexOperator(caseInsensitive: boolean): string;
74
+ abstract formatRegex(source: string, caseInsensitive: boolean): string;
75
+ abstract castColumn(sqlPath: string, type: Class): string;
76
+
77
+ compileJsonEquality?(sqlPath: string, identifier: string): string;
78
+ shiftPlaceholders?(whereSQL: string, offset: number): string;
79
+
80
+ buildSqlPath<T extends ModelType>(tableContext: TableContext<T>, path: string[], mode: JSONSqlPathMode): string {
81
+ const firstSegment = path[0];
82
+ const escapedFirst = this.escapeIdentifier(firstSegment);
83
+ if (tableContext.simpleFields.has(firstSegment)) {
84
+ if (path.length > 1) {
85
+ throw new RuntimeError(
86
+ `Cannot traverse nested properties under simple column "${firstSegment}" in table "${tableContext.tableName}"`,
87
+ {
88
+ category: 'data'
89
+ }
90
+ );
91
+ }
92
+ return escapedFirst;
93
+ } else {
94
+ const nestedSegments = path.slice(1);
95
+ if (nestedSegments.length === 0) {
96
+ return escapedFirst;
97
+ }
98
+ return this.compileJsonIndexPath(escapedFirst, nestedSegments, mode);
99
+ }
100
+ }
101
+
102
+ formatJsonPath(jsonPath: string[]): string {
103
+ return jsonPath.map(segment => (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(segment) ? segment : `"${segment.replaceAll('"', '\\"')}"`)).join('.');
104
+ }
105
+
106
+ compileIndexPath(context: TableContext, path: string[], mode: JSONSqlPathMode): string {
107
+ return this.resolvePath(context, path, mode).sqlPath;
108
+ }
109
+
110
+ getCreateIndexSQL(context: TableContext, indexConfig: IndexConfig): string {
111
+ const { tableName, cls: modelClass } = context;
112
+ const indexName = ['idx', tableName, indexConfig.name.toLowerCase().replaceAll('-', '_')].join('_');
113
+
114
+ if (isModelQueryIndex(indexConfig)) {
115
+ const indexFields = indexConfig.fields.map(field => {
116
+ const fieldKey = Object.keys(field)[0];
117
+ const sortDirection = castTo<Record<string, unknown>>(field)[fieldKey];
118
+ const isAscending = typeof sortDirection === 'number' ? sortDirection === 1 : !sortDirection;
119
+
120
+ const path = fieldKey.split('.');
121
+ const expression = this.compileIndexPath(context, path, 'createIndex');
122
+ const formattedExpression = path.length > 1 ? `(${expression})` : expression;
123
+ return `${formattedExpression} ${isAscending ? 'ASC' : 'DESC'}`;
124
+ });
125
+
126
+ return `CREATE ${indexConfig.unique ? 'UNIQUE ' : ''}INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)} (${indexFields.join(', ')});`;
127
+ } else if (isModelIndexedIndex(indexConfig)) {
128
+ const allFields = [...indexConfig.keyTemplate, ...indexConfig.sortTemplate];
129
+ const indexFields = allFields.map(({ path, value }) => {
130
+ const expression = this.compileIndexPath(context, path, 'createIndex');
131
+ const formattedExpression = path.length > 1 ? `(${expression})` : expression;
132
+ return `${formattedExpression} ${value === -1 ? 'DESC' : 'ASC'}`;
133
+ });
134
+
135
+ const isUnique = 'unique' in indexConfig && indexConfig.unique;
136
+ return `CREATE ${isUnique ? 'UNIQUE ' : ''}INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)} (${indexFields.join(', ')});`;
137
+ }
138
+
139
+ throw new RuntimeError(`Unsupported index configuration for class ${modelClass.name}`);
140
+ }
141
+
142
+ getCreateTableSQL(context: TableContext): string {
143
+ const idType = this.getColumnType(castTo({ name: 'id', type: String }));
144
+ const columnDefinitions: string[] = [`${this.escapeIdentifier('id')} ${idType} PRIMARY KEY`];
145
+
146
+ for (const field of context.simpleFields.values()) {
147
+ if (field.name === 'id') {
148
+ continue;
149
+ }
150
+ const columnType = this.getColumnType(field);
151
+ columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${columnType}`);
152
+ }
153
+
154
+ for (const field of context.complexFields.values()) {
155
+ columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${this.getComplexColumnType(field)}`);
156
+ }
157
+
158
+ return `
159
+ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
160
+ ${columnDefinitions.join(',\n ')}
161
+ );
162
+ `.trim();
163
+ }
164
+
165
+ getCreateTableIndexSQLs(context: TableContext): string[] {
166
+ const indexes = ModelRegistryIndex.getIndices(context.cls) || [];
167
+ return indexes.map(indexConfig => this.getCreateIndexSQL(context, indexConfig));
168
+ }
169
+
170
+ getAddColumnSQL(context: TableContext, columnName: string, columnType: string): string {
171
+ return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ADD COLUMN ${this.escapeIdentifier(columnName)} ${columnType};`;
172
+ }
173
+
174
+ abstract getUpsertSQL(
175
+ context: TableContext,
176
+ columns: string[],
177
+ placeholders: string[],
178
+ conflictTarget: string[],
179
+ updates: string[]
180
+ ): string;
181
+
182
+ normalizeIndexDefinition(sql: string): string {
183
+ return sql
184
+ .toLowerCase()
185
+ .replaceAll('"', '')
186
+ .replaceAll("'", '')
187
+ .replaceAll('`', '')
188
+ .replaceAll(' ', '')
189
+ .replaceAll('asc', '')
190
+ .replaceAll('desc', '')
191
+ .replaceAll('btree', '')
192
+ .replaceAll('public.', '')
193
+ .replaceAll('::text', '')
194
+ .replaceAll('(', '')
195
+ .replaceAll(')', '');
196
+ }
197
+
198
+ abstract getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] };
199
+ abstract parseTableExistsResult(records: unknown[]): boolean;
200
+ abstract getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] };
201
+ abstract parseExistingColumns(records: unknown[]): Map<string, string>;
202
+ abstract getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] };
203
+ abstract parseExistingIndexes(records: unknown[]): Map<string, string>;
204
+ abstract getDropIndexSQL(context: TableContext, indexName: string): string;
205
+ abstract getTruncateTableSQL(context: TableContext): string;
206
+
207
+ getDropTableSQL(context: TableContext): string {
208
+ return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)};`;
209
+ }
210
+
211
+ getAlterColumnTypeSQL?(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined;
212
+
213
+ // Query Compilation
214
+ static #combineResults(results: QueryClause[], operator: string): QueryClause {
215
+ const filtered = results.filter(result => !!result.sql);
216
+
217
+ if (filtered.length === 0) {
218
+ return {};
219
+ } else if (filtered.length === 1) {
220
+ return filtered[0];
221
+ } else {
222
+ const fullOperator = ` ${operator} `;
223
+ return {
224
+ sql: `(${filtered.map(result => result.sql).join(fullOperator)})`,
225
+ parameters: Object.assign({}, ...results.map(result => result.parameters))
226
+ };
227
+ }
228
+ }
229
+
230
+ compileWhere<T extends ModelType>(
231
+ tableContext: TableContext<T>,
232
+ where?: WhereClause<T>,
233
+ checkExpiry = true
234
+ ): {
235
+ whereSQL?: string;
236
+ parameters?: unknown[];
237
+ } {
238
+ const resolvedWhere = ModelQueryUtil.getWhereClause(tableContext.cls, where, checkExpiry);
239
+ const compiled = this.#compileClause(tableContext, resolvedWhere);
240
+ if (Object.entries(compiled.parameters ?? {}).length) {
241
+ const parameters: unknown[] = [];
242
+ const seen = new Map<string, string>();
243
+ const sql = compiled
244
+ .sql!.replace(/%%([^%]{0,200})%%/g, key => {
245
+ if (!seen.has(key)) {
246
+ parameters.push(compiled.parameters![key]);
247
+ seen.set(key, this.getPlaceholder(parameters.length));
248
+ }
249
+ return seen.get(key)!;
250
+ })
251
+ .trim();
252
+ return { whereSQL: sql, parameters };
253
+ } else {
254
+ return { whereSQL: compiled.sql?.trim() };
255
+ }
256
+ }
257
+
258
+ compileSort<T extends ModelType>(tableContext: TableContext<T>, sort?: SortClause<T>[]): string {
259
+ if (!sort || sort.length === 0) {
260
+ return '';
261
+ }
262
+ const sortClauses = sort.map(sortClause => {
263
+ const key = Object.keys(sortClause)[0];
264
+ const direction = castTo<Record<string, 1 | -1>>(sortClause)[key];
265
+ const path = key.split('.');
266
+ const { sqlPath } = this.resolvePath(tableContext, path, 'orderBy');
267
+ return `${sqlPath} ${direction === -1 ? 'DESC' : 'ASC'}`;
268
+ });
269
+ return sortClauses.length ? `ORDER BY ${sortClauses.join(', ')}` : '';
270
+ }
271
+
272
+ #resolveSchemaPath<T extends ModelType>(
273
+ tableContext: TableContext<T>,
274
+ path: string[]
275
+ ): {
276
+ leafField?: SchemaFieldConfig;
277
+ arrayField?: SchemaFieldConfig;
278
+ arraySegmentIndex?: number;
279
+ } {
280
+ const firstSegment = path[0];
281
+
282
+ if (tableContext.simpleFields.has(firstSegment)) {
283
+ if (path.length > 1) {
284
+ throw new RuntimeError(
285
+ `Cannot traverse nested properties under simple column "${firstSegment}" in table "${tableContext.tableName}"`,
286
+ {
287
+ category: 'data'
288
+ }
289
+ );
290
+ }
291
+ return { leafField: tableContext.simpleFields.get(firstSegment) };
292
+ }
293
+
294
+ let currentField: SchemaFieldConfig | undefined = tableContext.complexFields.get(firstSegment);
295
+ let arrayField: SchemaFieldConfig | undefined = currentField?.array ? currentField : undefined;
296
+ let arraySegmentIndex: number | undefined = currentField?.array ? 0 : undefined;
297
+ let currentClass = currentField?.type;
298
+
299
+ for (let pathIndex = 1; pathIndex < path.length; pathIndex += 1) {
300
+ const segment = path[pathIndex];
301
+ const subclassConfiguration = SchemaRegistryIndex.getOptional(currentClass!)?.get();
302
+ currentField = subclassConfiguration?.fields[segment];
303
+ if (currentField?.array && !arrayField) {
304
+ arrayField = currentField;
305
+ arraySegmentIndex = pathIndex;
306
+ }
307
+ currentClass = currentField?.type;
308
+ }
309
+
310
+ return {
311
+ leafField: currentField,
312
+ arrayField,
313
+ arraySegmentIndex
314
+ };
315
+ }
316
+
317
+ getSchemaSubPathMetadata(
318
+ initialClass: Class | undefined,
319
+ subPath: string[]
320
+ ): Array<{ segment: string; fieldConfig?: SchemaFieldConfig; isArray: boolean; fieldClass?: Class }> {
321
+ let currentClass = initialClass;
322
+ return subPath.map(segment => {
323
+ let fieldConfig: SchemaFieldConfig | undefined;
324
+ let isArray = false;
325
+ if (currentClass) {
326
+ const classConfiguration = SchemaRegistryIndex.getOptional(currentClass)?.get();
327
+ fieldConfig = classConfiguration?.fields[segment];
328
+ if (fieldConfig) {
329
+ isArray = !!fieldConfig.array;
330
+ currentClass = fieldConfig.type;
331
+ } else {
332
+ currentClass = undefined;
333
+ }
334
+ }
335
+ return { segment, fieldConfig, isArray, fieldClass: currentClass };
336
+ });
337
+ }
338
+
339
+ resolvePath<T extends ModelType>(tableContext: TableContext<T>, path: string[], mode: JSONSqlPathMode): ResolvedPathContext {
340
+ const firstSegment = path[0];
341
+
342
+ if (tableContext.simpleFields.has(firstSegment)) {
343
+ const { leafField } = this.#resolveSchemaPath(tableContext, path);
344
+ return { sqlPath: this.buildSqlPath(tableContext, path, mode), leafField };
345
+ }
346
+
347
+ const { leafField, arrayField, arraySegmentIndex } = this.#resolveSchemaPath(tableContext, path);
348
+ const sqlPath = this.buildSqlPath(tableContext, path, mode);
349
+
350
+ const finalSqlPath = leafField && !leafField.array ? this.castColumn(sqlPath, leafField.type) : sqlPath;
351
+
352
+ const arrayPath = arraySegmentIndex !== undefined ? path.slice(0, arraySegmentIndex + 1) : undefined;
353
+ const subPath = arraySegmentIndex !== undefined ? path.slice(arraySegmentIndex + 1) : undefined;
354
+
355
+ return {
356
+ sqlPath: finalSqlPath,
357
+ leafField,
358
+ arrayField,
359
+ arrayPath,
360
+ subPath
361
+ };
362
+ }
363
+
364
+ #compileClause<T extends ModelType>(
365
+ tableContext: TableContext<T>,
366
+ clause: WhereClause<T>,
367
+ identificationPath: IdentificationPath = ''
368
+ ): QueryClause {
369
+ if (!clause) {
370
+ return {};
371
+ }
372
+ if (ModelQueryUtil.has$And(clause)) {
373
+ const compiled = clause.$and
374
+ .map((item, index) => this.#compileClause(tableContext, item, `${identificationPath}_${index}`))
375
+ .filter(Boolean);
376
+ return AbstractANSI99Dialect.#combineResults(compiled, 'AND');
377
+ } else if (ModelQueryUtil.has$Or(clause)) {
378
+ const compiled = clause.$or
379
+ .map((item, index) => this.#compileClause(tableContext, item, `${identificationPath}_${index}`))
380
+ .filter(Boolean);
381
+ return AbstractANSI99Dialect.#combineResults(compiled, 'OR');
382
+ } else if (ModelQueryUtil.has$Not(clause)) {
383
+ const compiled = this.#compileClause(tableContext, clause.$not, identificationPath);
384
+ return compiled
385
+ ? {
386
+ sql: `NOT (${compiled.sql})`,
387
+ parameters: compiled.parameters
388
+ }
389
+ : {};
390
+ } else {
391
+ return this.#compileSimple(tableContext, clause, [], identificationPath);
392
+ }
393
+ }
394
+
395
+ #compileSimple<T extends ModelType>(
396
+ tableContext: TableContext<T>,
397
+ item: Record<string, unknown>,
398
+ parentPath: string[] = [],
399
+ identificationPath: IdentificationPath = ''
400
+ ): QueryClause {
401
+ if (!item) {
402
+ return {};
403
+ }
404
+ const clauses: QueryClause[] = [];
405
+
406
+ let index = 0;
407
+ for (const [key, value] of Object.entries(item)) {
408
+ index += 1;
409
+ const currentPath = [...parentPath, key];
410
+ const isPlainObject = DataUtil.isPlainObject(value);
411
+ const firstKey = isPlainObject ? Object.keys(value)[0] : '';
412
+
413
+ const nextIdentificationPath = `${identificationPath}__${index}`;
414
+
415
+ if (isPlainObject) {
416
+ if (firstKey.startsWith('$')) {
417
+ clauses.push(this.#compileOperator(tableContext, currentPath, value as Record<string, unknown>, nextIdentificationPath));
418
+ } else {
419
+ clauses.push(this.#compileSimple(tableContext, value as Record<string, unknown>, currentPath, nextIdentificationPath));
420
+ }
421
+ } else {
422
+ clauses.push(this.#compileOperator(tableContext, currentPath, { $eq: value }, nextIdentificationPath));
423
+ }
424
+ }
425
+
426
+ return AbstractANSI99Dialect.#combineResults(clauses, 'AND');
427
+ }
428
+
429
+ #compileOperator<T extends ModelType>(
430
+ tableContext: TableContext<T>,
431
+ path: string[],
432
+ operation: Record<string, unknown>,
433
+ identificationPath: IdentificationPath = ''
434
+ ): QueryClause {
435
+ const resolvedContext = this.resolvePath(tableContext, path, 'read');
436
+ const { sqlPath, leafField, arrayField } = resolvedContext;
437
+ const effectiveArrayField = leafField?.array ? leafField : arrayField;
438
+ const clauses: QueryClause[] = [];
439
+
440
+ let index = 0;
441
+ for (let [operator, value] of Object.entries(operation)) {
442
+ index += 1;
443
+
444
+ if (Array.isArray(value)) {
445
+ value = value.map(valueItem => ModelQueryUtil.resolveComparator(valueItem));
446
+ } else {
447
+ value = ModelQueryUtil.resolveComparator(value);
448
+ }
449
+
450
+ const nestedIdentificationPath = `${identificationPath}_${index}`;
451
+ const identifier = `%%${nestedIdentificationPath}%%`;
452
+
453
+ let clause: QueryClause;
454
+
455
+ if (effectiveArrayField) {
456
+ if (operator === '$eq' || operator === '$ne') {
457
+ const { sql, formatted } = this.compileArrayEquals(resolvedContext, identifier, value);
458
+ const finalSql = operator === '$ne' ? `NOT(${sql})` : sql;
459
+ clause = { parameters: { [identifier]: formatted }, sql: finalSql };
460
+ } else if (operator === '$in' || operator === '$nin') {
461
+ if (!Array.isArray(value) || value.length === 0) {
462
+ clause = operator === '$in' ? { sql: '1=0' } : {};
463
+ } else {
464
+ const { sql, formatted } = this.compileArrayAny(resolvedContext, identifier, value);
465
+ const finalSql = operator === '$nin' ? `NOT(${sql})` : sql;
466
+ clause = { sql: finalSql, parameters: { [identifier]: formatted } };
467
+ }
468
+ } else if (operator === '$all') {
469
+ if (!Array.isArray(value) || value.length === 0) {
470
+ clause = { sql: '1=0' };
471
+ } else {
472
+ const { sql, formatted } = this.compileArrayAll(resolvedContext, identifier, value);
473
+ clause = { sql, parameters: { [identifier]: formatted } };
474
+ }
475
+ } else if (operator === '$exists') {
476
+ const { sql } = this.compileArrayExists(resolvedContext, identifier);
477
+ const finalSql = !value ? `NOT(${sql})` : sql;
478
+ clause = { sql: finalSql };
479
+ } else if (operator === '$regex') {
480
+ const { sql, formatted } = this.compileArrayRegex(resolvedContext, identifier, value as RegExp | string);
481
+ clause = { sql, parameters: { [identifier]: formatted } };
482
+ } else {
483
+ throw new RuntimeError(`Operator "${operator}" is not supported for arrays`, { category: 'data' });
484
+ }
485
+ } else {
486
+ if (operator === '$eq') {
487
+ if (value === null || value === undefined) {
488
+ clause = { sql: `${sqlPath} IS NULL` };
489
+ } else {
490
+ clause = {
491
+ sql: `${sqlPath} = ${identifier}`,
492
+ parameters: { [identifier]: value }
493
+ };
494
+ }
495
+ } else if (operator === '$ne') {
496
+ if (value === null || value === undefined) {
497
+ clause = { sql: `${sqlPath} IS NOT NULL` };
498
+ } else {
499
+ clause = {
500
+ sql: `${sqlPath} <> ${identifier}`,
501
+ parameters: { [identifier]: value }
502
+ };
503
+ }
504
+ } else if (operator === '$gt' || operator === '$gte' || operator === '$lt' || operator === '$lte') {
505
+ const sqlOperator = operator === '$gt' ? '>' : operator === '$gte' ? '>=' : operator === '$lt' ? '<' : '<=';
506
+ clause = {
507
+ sql: `${sqlPath} ${sqlOperator} ${identifier}`,
508
+ parameters: { [identifier]: value }
509
+ };
510
+ } else if (operator === '$in') {
511
+ if (!Array.isArray(value) || value.length === 0) {
512
+ clause = { sql: '1=0' };
513
+ } else {
514
+ const choices = value.map((valueItem, itemIndex) => {
515
+ const innerIdentifier = `%%${nestedIdentificationPath}_${itemIndex}%%`;
516
+ return [innerIdentifier, valueItem];
517
+ });
518
+ clause = {
519
+ sql: `${sqlPath} IN (${choices.map(choice => choice[0]).join(', ')})`,
520
+ parameters: Object.fromEntries(choices)
521
+ };
522
+ }
523
+ } else if (operator === '$nin') {
524
+ if (!Array.isArray(value) || value.length === 0) {
525
+ clause = {};
526
+ } else {
527
+ const choices = value.map((valueItem, itemIndex) => {
528
+ const innerIdentifier = `%%${nestedIdentificationPath}_${itemIndex}%%`;
529
+ return [innerIdentifier, valueItem];
530
+ });
531
+ clause = {
532
+ sql: `${sqlPath} NOT IN (${choices.map(choice => choice[0]).join(', ')})`,
533
+ parameters: Object.fromEntries(choices)
534
+ };
535
+ }
536
+ } else if (operator === '$exists') {
537
+ clause = { sql: value ? `${sqlPath} IS NOT NULL` : `${sqlPath} IS NULL` };
538
+ } else if (operator === '$regex') {
539
+ const regex = value instanceof RegExp ? value : new RegExp(String(value));
540
+ const caseInsensitive = regex.flags.includes('i');
541
+ const regexOp = this.getRegexOperator(caseInsensitive);
542
+ const regexSource = this.formatRegex(regex.source, caseInsensitive);
543
+ clause = {
544
+ parameters: { [identifier]: regexSource },
545
+ sql: `${sqlPath} ${regexOp} ${identifier}`
546
+ };
547
+ } else {
548
+ throw new RuntimeError(`Operator "${operator}" is not supported for scalar columns`, { category: 'data' });
549
+ }
550
+ }
551
+
552
+ if (clause) {
553
+ clauses.push(clause);
554
+ }
555
+ }
556
+ return AbstractANSI99Dialect.#combineResults(clauses, 'AND');
557
+ }
558
+
559
+ // Statement Builders
560
+ buildInsert<T extends ModelType>(tableContext: TableContext<T>, rawItem: Record<string, unknown>): { sql: string; values: unknown[] } {
561
+ return this.buildInsertAll(tableContext, [rawItem]);
562
+ }
563
+
564
+ buildInsertAll<T extends ModelType>(
565
+ tableContext: TableContext<T>,
566
+ rawItems: Record<string, unknown>[]
567
+ ): { sql: string; values: unknown[] } {
568
+ if (rawItems.length === 0) {
569
+ return { sql: '', values: [] };
570
+ }
571
+
572
+ const columns: string[] = [];
573
+ for (const field of tableContext.simpleFields.values()) {
574
+ columns.push(this.escapeIdentifier(field.name));
575
+ }
576
+ for (const field of tableContext.complexFields.values()) {
577
+ columns.push(this.escapeIdentifier(field.name));
578
+ }
579
+
580
+ const values: unknown[] = [];
581
+ const valueTuples: string[] = [];
582
+
583
+ for (const rawItem of rawItems) {
584
+ const tuplePlaceholders: string[] = [];
585
+ for (const field of tableContext.simpleFields.values()) {
586
+ tuplePlaceholders.push(this.getPlaceholder(values.length + 1));
587
+ const value = rawItem[field.name];
588
+ values.push(value === undefined || value === null ? null : value);
589
+ }
590
+
591
+ for (const field of tableContext.complexFields.values()) {
592
+ tuplePlaceholders.push(this.getPlaceholder(values.length + 1));
593
+ const value = rawItem[field.name];
594
+ values.push(this.getComplexColumnValue(field, value));
595
+ }
596
+
597
+ valueTuples.push(`(${tuplePlaceholders.join(', ')})`);
598
+ }
599
+
600
+ const sql = `INSERT INTO ${this.escapeIdentifier(tableContext.tableName)} (${columns.join(', ')}) VALUES ${valueTuples.join(', ')};`;
601
+
602
+ return { sql, values };
603
+ }
604
+
605
+ buildUpdateAll<T extends ModelType>(
606
+ tableContext: TableContext<T>,
607
+ rawItems: Record<string, unknown>[]
608
+ ): { sql: string; values: unknown[] } {
609
+ if (rawItems.length === 0) {
610
+ return { sql: '', values: [] };
611
+ }
612
+
613
+ if (rawItems.length === 1) {
614
+ const { whereSQL, parameters = [] } = this.compileWhere(tableContext, castTo({ id: rawItems[0].id }));
615
+ return this.buildUpdate(tableContext, rawItems[0], whereSQL, parameters);
616
+ }
617
+
618
+ const simpleFieldsToUpdate = [...tableContext.simpleFields.values()].filter(field => field.name !== 'id');
619
+ const complexFieldsToUpdate = [...tableContext.complexFields.values()];
620
+
621
+ const setClauses: string[] = [];
622
+ const values: unknown[] = [];
623
+
624
+ const ids = rawItems.map(item => item.id);
625
+
626
+ for (const field of simpleFieldsToUpdate) {
627
+ const cases: string[] = [];
628
+ for (const rawItem of rawItems) {
629
+ const idPlaceholder = this.getPlaceholder(values.length + 1);
630
+ values.push(rawItem.id);
631
+ const valPlaceholder = this.getPlaceholder(values.length + 1);
632
+ const val = rawItem[field.name];
633
+ values.push(val === undefined || val === null ? null : val);
634
+ cases.push(`WHEN ${idPlaceholder} THEN ${valPlaceholder}`);
635
+ }
636
+ setClauses.push(`${this.escapeIdentifier(field.name)} = CASE ${this.escapeIdentifier('id')} ${cases.join(' ')} END`);
637
+ }
638
+
639
+ for (const field of complexFieldsToUpdate) {
640
+ const cases: string[] = [];
641
+ for (const rawItem of rawItems) {
642
+ const idPlaceholder = this.getPlaceholder(values.length + 1);
643
+ values.push(rawItem.id);
644
+ const valPlaceholder = this.getPlaceholder(values.length + 1);
645
+ const val = rawItem[field.name];
646
+ values.push(this.getComplexColumnValue(field, val));
647
+ cases.push(`WHEN ${idPlaceholder} THEN ${valPlaceholder}`);
648
+ }
649
+ setClauses.push(`${this.escapeIdentifier(field.name)} = CASE ${this.escapeIdentifier('id')} ${cases.join(' ')} END`);
650
+ }
651
+
652
+ if (setClauses.length === 0) {
653
+ setClauses.push(`${this.escapeIdentifier('id')} = ${this.escapeIdentifier('id')}`);
654
+ }
655
+
656
+ const whereIdPlaceholders = ids.map(idVal => {
657
+ values.push(idVal);
658
+ return this.getPlaceholder(values.length);
659
+ });
660
+
661
+ const tableName = this.escapeIdentifier(tableContext.tableName);
662
+ const sql = `UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE ${this.escapeIdentifier('id')} IN (${whereIdPlaceholders.join(', ')});`;
663
+
664
+ return { sql, values };
665
+ }
666
+
667
+ buildUpdate<T extends ModelType>(
668
+ tableContext: TableContext<T>,
669
+ rawItem: Record<string, unknown>,
670
+ whereSQL?: string,
671
+ whereParameters: unknown[] = []
672
+ ): { sql: string; values: unknown[] } {
673
+ const sets: string[] = [];
674
+ const values: unknown[] = [];
675
+
676
+ for (const field of tableContext.simpleFields.values()) {
677
+ if (field.name === 'id') {
678
+ continue;
679
+ }
680
+ sets.push(`${this.escapeIdentifier(field.name)} = ${this.getPlaceholder(values.length + 1)}`);
681
+ const value = rawItem[field.name];
682
+ values.push(value === undefined || value === null ? null : value);
683
+ }
684
+
685
+ for (const field of tableContext.complexFields.values()) {
686
+ sets.push(`${this.escapeIdentifier(field.name)} = ${this.getPlaceholder(values.length + 1)}`);
687
+ const value = rawItem[field.name];
688
+ values.push(this.getComplexColumnValue(field, value));
689
+ }
690
+
691
+ const shiftedWhereSQL = whereSQL && this.shiftPlaceholders ? this.shiftPlaceholders(whereSQL, values.length) : whereSQL;
692
+ if (whereSQL) {
693
+ values.push(...whereParameters);
694
+ }
695
+
696
+ const sql = `UPDATE ${this.escapeIdentifier(tableContext.tableName)} SET ${sets.join(', ')}${shiftedWhereSQL ? ` WHERE ${shiftedWhereSQL}` : ''};`;
697
+ return { sql, values };
698
+ }
699
+
700
+ #buildUpdateSets<T extends ModelType>(tableContext: TableContext<T>, preparedData: Partial<T>): { sets: string[]; values: unknown[] } {
701
+ const sets: string[] = [];
702
+ const values: unknown[] = [];
703
+
704
+ for (const [fieldName, value] of Object.entries(preparedData)) {
705
+ const simpleField = tableContext.simpleFields.get(fieldName);
706
+ if (simpleField) {
707
+ sets.push(`${this.escapeIdentifier(fieldName)} = ${this.getPlaceholder(values.length + 1)}`);
708
+ values.push(value === undefined || value === null ? null : value);
709
+ continue;
710
+ }
711
+
712
+ const complexField = tableContext.complexFields.get(fieldName);
713
+ if (complexField) {
714
+ sets.push(`${this.escapeIdentifier(fieldName)} = ${this.getPlaceholder(values.length + 1)}`);
715
+ values.push(this.getComplexColumnValue(complexField, value));
716
+ }
717
+ }
718
+
719
+ return { sets, values };
720
+ }
721
+
722
+ compilePartialUpdate<T extends ModelType>(
723
+ tableContext: TableContext<T>,
724
+ preparedData: Partial<T>
725
+ ): { sets: string[]; values: unknown[] } {
726
+ return this.#buildUpdateSets(tableContext, preparedData);
727
+ }
728
+
729
+ buildPartialUpdate<T extends ModelType>(
730
+ tableContext: TableContext<T>,
731
+ preparedData: Partial<T>,
732
+ whereSQL?: string,
733
+ whereParameters: unknown[] = [],
734
+ returning = false
735
+ ): { sql: string; values: unknown[] } {
736
+ const { sets, values } = this.#buildUpdateSets(tableContext, preparedData);
737
+
738
+ const shiftedWhereSQL = whereSQL && this.shiftPlaceholders ? this.shiftPlaceholders(whereSQL, values.length) : whereSQL;
739
+ if (whereSQL) {
740
+ values.push(...whereParameters);
741
+ }
742
+
743
+ const returningClause = returning && this.returningSupport ? ' RETURNING *' : '';
744
+ const sql = `UPDATE ${this.escapeIdentifier(tableContext.tableName)} SET ${sets.join(', ')}${shiftedWhereSQL ? ` WHERE ${shiftedWhereSQL}` : ''}${returningClause};`;
745
+
746
+ return { sql, values };
747
+ }
748
+
749
+ buildUpsert<T extends ModelType>(
750
+ tableContext: TableContext<T>,
751
+ rawItem: Record<string, unknown>,
752
+ conflictTarget: string[]
753
+ ): { sql: string; values: unknown[] } {
754
+ const columns: string[] = [];
755
+ const values: unknown[] = [];
756
+ const updates: string[] = [];
757
+
758
+ for (const field of tableContext.simpleFields.values()) {
759
+ columns.push(this.escapeIdentifier(field.name));
760
+ const value = rawItem[field.name];
761
+ values.push(value === undefined || value === null ? null : value);
762
+ if (field.name !== 'id') {
763
+ updates.push(`${this.escapeIdentifier(field.name)} = EXCLUDED.${this.escapeIdentifier(field.name)}`);
764
+ }
765
+ }
766
+
767
+ for (const field of tableContext.complexFields.values()) {
768
+ columns.push(this.escapeIdentifier(field.name));
769
+ const value = rawItem[field.name];
770
+ values.push(this.getComplexColumnValue(field, value));
771
+ updates.push(`${this.escapeIdentifier(field.name)} = EXCLUDED.${this.escapeIdentifier(field.name)}`);
772
+ }
773
+
774
+ const placeholders = columns.map((_, index) => this.getPlaceholder(index + 1));
775
+ const sql = this.getUpsertSQL(tableContext, columns, placeholders, conflictTarget, updates);
776
+
777
+ return { sql, values };
778
+ }
779
+
780
+ buildSelect<T extends ModelType>(
781
+ tableContext: TableContext<T>,
782
+ options?: {
783
+ whereSQL?: string;
784
+ sortSQL?: string;
785
+ limit?: number;
786
+ offset?: number | string;
787
+ columns?: string[];
788
+ }
789
+ ): string {
790
+ const selectedColumns = options?.columns && options.columns.length > 0 ? options.columns.join(', ') : '*';
791
+ const where = options?.whereSQL ? ` WHERE ${options.whereSQL}` : '';
792
+ const sort = options?.sortSQL ? ` ${options.sortSQL}` : '';
793
+ const limit = options?.limit !== undefined ? ` LIMIT ${options.limit}` : '';
794
+ const offset = options?.offset !== undefined ? ` OFFSET ${options.offset}` : '';
795
+
796
+ return `SELECT ${selectedColumns} FROM ${this.escapeIdentifier(tableContext.tableName)}${where}${sort}${limit}${offset};`;
797
+ }
798
+
799
+ buildDelete<T extends ModelType>(tableContext: TableContext<T>, whereSQL?: string): string {
800
+ return `DELETE FROM ${this.escapeIdentifier(tableContext.tableName)}${whereSQL ? ` WHERE ${whereSQL}` : ''};`;
801
+ }
802
+
803
+ buildCount<T extends ModelType>(tableContext: TableContext<T>, whereSQL?: string): string {
804
+ return `SELECT COUNT(*) as ${this.escapeIdentifier('total')} FROM ${this.escapeIdentifier(tableContext.tableName)}${whereSQL ? ` WHERE ${whereSQL}` : ''};`;
805
+ }
806
+
807
+ buildIndexSort<T extends ModelType>(
808
+ tableContext: TableContext<T>,
809
+ indexConfig: { sortTemplate: { path: string[]; value: number }[] }
810
+ ): string {
811
+ const sortClauses = indexConfig.sortTemplate.map(({ path, value }) => {
812
+ const expression = this.compileIndexPath(tableContext, path, 'orderBy');
813
+ return `${expression} ${value === -1 ? 'DESC' : 'ASC'}`;
814
+ });
815
+ return sortClauses.length ? `ORDER BY ${sortClauses.join(', ')}` : '';
816
+ }
817
+
818
+ buildFacet<T extends ModelType>(tableContext: TableContext<T>, sqlPath: string, whereSQL?: string): string {
819
+ const keySql = this.castColumn?.(sqlPath, String) ?? sqlPath;
820
+ const countSql = this.castColumn?.('COUNT(*)', Number) ?? 'COUNT(*)';
821
+ const where = whereSQL ? ` AND ${whereSQL}` : '';
822
+
823
+ return `SELECT ${keySql} AS ${this.escapeIdentifier('key')}, ${countSql} AS ${this.escapeIdentifier('count')} FROM ${this.escapeIdentifier(tableContext.tableName)} WHERE ${sqlPath} IS NOT NULL${where} GROUP BY ${sqlPath} ORDER BY ${this.escapeIdentifier('count')} DESC;`;
824
+ }
825
+ }