@travetto/model-sql 8.0.0-alpha.24 → 8.0.0-alpha.26

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