@travetto/model-sql 8.0.0-alpha.25 → 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.
@@ -1,1182 +0,0 @@
1
- import { type BulkResponse, type IndexConfig, IndexNotSupported, type ModelType } from '@travetto/model';
2
- import { isModelIndexedIndex } from '@travetto/model-indexed';
3
- import {
4
- isModelQueryIndex,
5
- ModelQueryUtil,
6
- type Query,
7
- type RetainQueryPrimitiveFields,
8
- type SelectClause,
9
- type SortClause,
10
- type WhereClause
11
- } from '@travetto/model-query';
12
- import { type Class, castKey, castTo, JSONUtil, RuntimeError, TimeUtil, TypedObject, toConcrete } from '@travetto/runtime';
13
- import { DataUtil, type Point, type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
14
-
15
- import type { Connection } from '../connection/base.ts';
16
- import type { DeleteWrapper, DialectState, InsertWrapper } from '../internal/types.ts';
17
- import type { VisitStack } from '../types.ts';
18
- import { SQLModelUtil } from '../util.ts';
19
-
20
- const PointConcrete = toConcrete<Point>();
21
-
22
- interface Alias {
23
- alias: string;
24
- path: VisitStack[];
25
- }
26
-
27
- export type SQLTableDescription = {
28
- columns: { name: string; type: string; is_not_null: boolean }[];
29
- foreignKeys: { name: string; from_column: string; to_column: string; to_table: string }[];
30
- indices: { name: string; columns: { name: string; desc: boolean }[]; is_unique: boolean }[];
31
- };
32
-
33
- function makeField(name: string, type: Class, required: boolean, extra: Partial<SchemaFieldConfig>): SchemaFieldConfig {
34
- return {
35
- name,
36
- class: null!,
37
- type,
38
- array: false,
39
- ...(required ? { required: { active: true } } : {}),
40
- ...extra
41
- };
42
- }
43
-
44
- /**
45
- * Base sql dialect
46
- */
47
- export abstract class SQLDialect implements DialectState {
48
- /**
49
- * Default length of unique ids
50
- */
51
- ID_LENGTH = 32;
52
-
53
- /**
54
- * Hash Length
55
- */
56
- HASH_LENGTH = 64;
57
-
58
- /**
59
- * Default length for varchar
60
- */
61
- DEFAULT_STRING_LENGTH = 1024;
62
-
63
- /**
64
- * Mapping between query operators and SQL operations
65
- */
66
- SQL_OPS = {
67
- $and: 'AND',
68
- $or: 'OR',
69
- $not: 'NOT',
70
- $all: '=ALL',
71
- $regex: undefined,
72
- $iregex: undefined,
73
- $in: 'IN',
74
- $nin: 'NOT IN',
75
- $eq: '=',
76
- $ne: '<>',
77
- $gte: '>=',
78
- $like: 'LIKE',
79
- $ilike: 'ILIKE',
80
- $lte: '<=',
81
- $gt: '>',
82
- $lt: '<',
83
- $is: 'IS',
84
- $isNot: 'IS NOT'
85
- };
86
-
87
- /**
88
- * Column type mapping
89
- */
90
- COLUMN_TYPES = {
91
- JSON: '',
92
- POINT: 'POINT',
93
- BOOLEAN: 'BOOLEAN',
94
- TINYINT: 'TINYINT',
95
- SMALLINT: 'SMALLINT',
96
- MEDIUMINT: 'MEDIUMINT',
97
- INT: 'INT',
98
- BIGINT: 'BIGINT',
99
- TIMESTAMP: 'TIMESTAMP',
100
- TEXT: 'TEXT'
101
- };
102
-
103
- /**
104
- * Column types with inputs
105
- */
106
- PARAMETERIZED_COLUMN_TYPES: Record<'VARCHAR' | 'DECIMAL', (...values: number[]) => string> = {
107
- VARCHAR: count => `VARCHAR(${count})`,
108
- DECIMAL: (digits, precision) => `DECIMAL(${digits},${precision})`
109
- };
110
-
111
- ID_AFFIX = '`';
112
-
113
- /**
114
- * Generate an id field
115
- */
116
- idField = makeField('id', String, true, {
117
- maxlength: { limit: this.ID_LENGTH },
118
- minlength: { limit: this.ID_LENGTH }
119
- });
120
-
121
- /**
122
- * Generate an idx field
123
- */
124
- idxField = makeField('__idx', Number, true, {});
125
-
126
- /**
127
- * Parent path reference
128
- */
129
- parentPathField = makeField('__parent_path', String, true, {
130
- maxlength: { limit: this.HASH_LENGTH },
131
- minlength: { limit: this.HASH_LENGTH },
132
- required: { active: true }
133
- });
134
-
135
- /**
136
- * Path reference
137
- */
138
- pathField = makeField('__path', String, true, {
139
- maxlength: { limit: this.HASH_LENGTH },
140
- minlength: { limit: this.HASH_LENGTH },
141
- required: { active: true }
142
- });
143
-
144
- regexWordBoundary = '\\b';
145
-
146
- rootAlias = '_ROOT';
147
-
148
- aliasCache = new Map<Class, Map<string, Alias>>();
149
- namespacePrefix: string;
150
-
151
- constructor(namespacePrefix: string) {
152
- this.namespace = this.namespace.bind(this);
153
- this.table = this.table.bind(this);
154
- this.identifier = this.identifier.bind(this);
155
- this.namespacePrefix = namespacePrefix ? `${namespacePrefix}_` : namespacePrefix;
156
- }
157
-
158
- /**
159
- * Get connection
160
- */
161
- abstract get connection(): Connection<unknown>;
162
-
163
- /**
164
- * Hash a value
165
- */
166
- abstract hash(input: string): string;
167
-
168
- /**
169
- * Describe a table structure
170
- */
171
- abstract describeTable(table: string): Promise<SQLTableDescription | undefined>;
172
-
173
- executeSQL<T>(sql: string): Promise<{ records: T[]; count: number }> {
174
- return this.connection.execute<T>(this.connection.active, sql);
175
- }
176
-
177
- /**
178
- * Identify a name or field (escape it)
179
- */
180
- identifier(field: SchemaFieldConfig | string): string {
181
- if (field === '*') {
182
- return field;
183
- } else {
184
- const name = typeof field === 'string' ? field : field.name;
185
- return `${this.ID_AFFIX}${name}${this.ID_AFFIX}`;
186
- }
187
- }
188
-
189
- quote(text: string): string {
190
- return `'${text.replace(/[']/g, "''")}'`;
191
- }
192
-
193
- /**
194
- * Resolve date value
195
- * @param value
196
- * @returns
197
- */
198
- resolveDateValue(value: Date): string {
199
- const [day, time] = value.toISOString().split(/[TZ]/);
200
- return this.quote(`${day} ${time}`);
201
- }
202
-
203
- /**
204
- * Convert value to SQL valid representation
205
- */
206
- resolveValue(config: SchemaFieldConfig, value: unknown): string {
207
- if (value === undefined || value === null) {
208
- return 'NULL';
209
- } else if (config.type === String) {
210
- if (value instanceof RegExp) {
211
- const regexSource = DataUtil.toRegex(value).source.replace(/\\b/g, this.regexWordBoundary);
212
- return this.quote(regexSource);
213
- } else {
214
- return this.quote(castTo(value));
215
- }
216
- } else if (config.type === Boolean) {
217
- return `${value ? 'TRUE' : 'FALSE'}`;
218
- } else if (config.type === castTo(BigInt)) {
219
- return value.toString();
220
- } else if (config.type === Number) {
221
- return `${value}`;
222
- } else if (config.type === Date) {
223
- if (typeof value === 'string' && TimeUtil.isTimeSpan(value)) {
224
- return this.resolveDateValue(TimeUtil.fromNow(value));
225
- } else {
226
- return this.resolveDateValue(DataUtil.coerceType(value, Date, true));
227
- }
228
- } else if (config.type === PointConcrete && Array.isArray(value)) {
229
- return `point(${value[0]},${value[1]})`;
230
- } else if (config.type === Object) {
231
- return this.quote(JSONUtil.toUTF8(value).replaceAll("'", "''"));
232
- }
233
- throw new RuntimeError(`Unknown value type for field ${config.name}, ${value}`, { category: 'data' });
234
- }
235
-
236
- /**
237
- * Get column type from field config
238
- */
239
- getColumnType(config: SchemaFieldConfig): string {
240
- let type: string = '';
241
-
242
- if (config.type === castTo(BigInt)) {
243
- type = this.COLUMN_TYPES.BIGINT;
244
- } else if (config.type === Number) {
245
- type = this.COLUMN_TYPES.INT;
246
- if (config.precision) {
247
- const [digits, decimals] = config.precision;
248
- if (decimals) {
249
- type = this.PARAMETERIZED_COLUMN_TYPES.DECIMAL(digits, decimals);
250
- } else if (digits) {
251
- if (digits < 3) {
252
- type = this.COLUMN_TYPES.TINYINT;
253
- } else if (digits < 5) {
254
- type = this.COLUMN_TYPES.SMALLINT;
255
- } else if (digits < 7) {
256
- type = this.COLUMN_TYPES.MEDIUMINT;
257
- } else if (digits < 10) {
258
- type = this.COLUMN_TYPES.INT;
259
- } else {
260
- type = this.COLUMN_TYPES.BIGINT;
261
- }
262
- }
263
- } else {
264
- type = this.COLUMN_TYPES.INT;
265
- }
266
- } else if (config.type === Date) {
267
- type = this.COLUMN_TYPES.TIMESTAMP;
268
- } else if (config.type === Boolean) {
269
- type = this.COLUMN_TYPES.BOOLEAN;
270
- } else if (config.type === String) {
271
- if (config.specifiers?.includes('text')) {
272
- type = this.COLUMN_TYPES.TEXT;
273
- } else {
274
- type = this.PARAMETERIZED_COLUMN_TYPES.VARCHAR(config.maxlength?.limit ?? this.DEFAULT_STRING_LENGTH);
275
- }
276
- } else if (config.type === PointConcrete) {
277
- type = this.COLUMN_TYPES.POINT;
278
- } else if (config.type === Object) {
279
- type = this.COLUMN_TYPES.JSON;
280
- }
281
-
282
- return type;
283
- }
284
-
285
- /**
286
- * FieldConfig to Column definition
287
- */
288
- getColumnDefinition(config: SchemaFieldConfig, overrideRequired?: boolean): string | undefined {
289
- const type = this.getColumnType(config);
290
- if (!type) {
291
- return;
292
- }
293
- const required = overrideRequired ? true : (config.required?.active ?? false);
294
- return `${this.identifier(config)} ${type} ${required ? 'NOT NULL' : ''}`;
295
- }
296
-
297
- /**
298
- * Delete query and return count removed
299
- */
300
- async deleteAndGetCount<T extends ModelType>(cls: Class<T>, query: Query<T>): Promise<number> {
301
- const { count } = await this.executeSQL<T>(this.getDeleteSQL(SQLModelUtil.classToStack(cls), query.where));
302
- return DataUtil.coerceType(count, Number);
303
- }
304
-
305
- /**
306
- * Get the count for a given query
307
- */
308
- async getCountForQuery<T extends ModelType>(cls: Class<T>, query: Query<T>): Promise<number> {
309
- const { records } = await this.executeSQL<{ total: number }>(
310
- this.getQueryCountSQL(cls, ModelQueryUtil.getWhereClause(cls, query.where))
311
- );
312
- return DataUtil.coerceType(records[0].total, Number);
313
- }
314
-
315
- /**
316
- * Remove a sql column
317
- */
318
- getDropColumnSQL(stack: VisitStack[]): string {
319
- const field = stack.at(-1)!;
320
- return `ALTER TABLE ${this.parentTable(stack)} DROP COLUMN ${this.identifier(field.name)};`;
321
- }
322
-
323
- /**
324
- * Add a sql column
325
- */
326
- getAddColumnSQL(stack: VisitStack[]): string {
327
- const field: SchemaFieldConfig = castTo(stack.at(-1));
328
- return `ALTER TABLE ${this.parentTable(stack)} ADD COLUMN ${this.getColumnDefinition(field)};`;
329
- }
330
-
331
- /**
332
- * Modify a sql column
333
- */
334
- abstract getModifyColumnSQL(stack: VisitStack[]): string;
335
-
336
- /**
337
- * Determine table/field namespace for a given stack location
338
- */
339
- namespace(stack: VisitStack[]): string {
340
- return `${this.namespacePrefix}${SQLModelUtil.buildTable(stack)}`;
341
- }
342
-
343
- /**
344
- * Determine namespace for a given stack location - 1
345
- */
346
- namespaceParent(stack: VisitStack[]): string {
347
- return this.namespace(stack.slice(0, stack.length - 1));
348
- }
349
-
350
- /**
351
- * Determine table name for a given stack location
352
- */
353
- table(stack: VisitStack[]): string {
354
- return this.identifier(this.namespace(stack));
355
- }
356
-
357
- /**
358
- * Determine parent table name for a given stack location
359
- */
360
- parentTable(stack: VisitStack[]): string {
361
- return this.table(stack.slice(0, stack.length - 1));
362
- }
363
-
364
- /**
365
- * Get lookup key for cls and name
366
- */
367
- getKey(cls: Class, name: string): string {
368
- return `${cls.name}:${name}`;
369
- }
370
-
371
- /**
372
- * Alias a field for usage
373
- */
374
- alias(field: string | SchemaFieldConfig, alias: string = this.rootAlias): string {
375
- return `${alias}.${this.identifier(field)}`;
376
- }
377
-
378
- /**
379
- * Get alias cache for the stack
380
- */
381
- getAliasCache(stack: VisitStack[], resolve: (path: VisitStack[]) => string): Map<string, Alias> {
382
- const cls = stack[0].type;
383
-
384
- if (this.aliasCache.has(cls)) {
385
- return this.aliasCache.get(cls)!;
386
- }
387
-
388
- const clauses = new Map<string, Alias>();
389
- let idx = 0;
390
-
391
- SQLModelUtil.visitSchemaSync(SchemaRegistryIndex.getConfig(cls), {
392
- onRoot: ({ descend, path }) => {
393
- const table = resolve(path);
394
- clauses.set(table, { alias: this.rootAlias, path });
395
- return descend();
396
- },
397
- onSub: ({ descend, config, path }) => {
398
- const table = resolve(path);
399
- clauses.set(table, { alias: `${config.name.charAt(0)}${idx++}`, path });
400
- return descend();
401
- },
402
- onSimple: ({ config, path }) => {
403
- const table = resolve(path);
404
- clauses.set(table, { alias: `${config.name.charAt(0)}${idx++}`, path });
405
- }
406
- });
407
-
408
- this.aliasCache.set(cls, clauses);
409
-
410
- return clauses;
411
- }
412
-
413
- /**
414
- * Resolve field name for given location in stack
415
- */
416
- resolveName(stack: VisitStack[]): string {
417
- const path = this.namespaceParent(stack);
418
- const name = stack.at(-1)!.name;
419
- const cache = this.getAliasCache(stack, this.namespace);
420
- const base = cache.get(path)!;
421
- return this.alias(name, base.alias);
422
- }
423
-
424
- /**
425
- * Generate WHERE field clause
426
- */
427
- getWhereFieldSQL(stack: VisitStack[], input: Record<string, unknown>): string {
428
- const items = [];
429
- const { foreignMap, localMap } = SQLModelUtil.getFieldsByLocation(stack);
430
- const SQL_OPS = this.SQL_OPS;
431
-
432
- for (const key of Object.keys(input)) {
433
- const top = input[key];
434
- const field = localMap[key] ?? foreignMap[key];
435
- if (!field) {
436
- throw new Error(`Unknown field: ${key}`);
437
- }
438
- const sStack = [...stack, field];
439
- if (key in foreignMap && field.array && !SchemaRegistryIndex.has(field.type)) {
440
- // If dealing with simple external
441
- sStack.push({
442
- name: field.name,
443
- class: null!,
444
- type: field.type
445
- });
446
- }
447
- const sPath = this.resolveName(sStack);
448
-
449
- if (DataUtil.isPlainObject(top)) {
450
- const subKey = Object.keys(top)[0];
451
- if (!subKey.startsWith('$')) {
452
- const inner = this.getWhereFieldSQL(sStack, top);
453
- items.push(inner);
454
- } else {
455
- const value = top[subKey];
456
- const resolve = this.resolveValue.bind(this, field);
457
-
458
- switch (subKey) {
459
- case '$nin':
460
- case '$in': {
461
- const arr = (Array.isArray(value) ? value : [value]).map(resolve);
462
- items.push(`${sPath} ${SQL_OPS[subKey]} (${arr.join(',')})`);
463
- break;
464
- }
465
- case '$all': {
466
- const set = new Set();
467
- const arr = [value]
468
- .flat()
469
- .filter(item => !set.has(item) && !!set.add(item))
470
- .map(resolve);
471
- const valueTable = this.parentTable(sStack);
472
- const alias = `_all_${sStack.length}`;
473
- const pPath = this.identifier(this.parentPathField.name);
474
- const rpPath = this.resolveName([...sStack, field, this.parentPathField]);
475
-
476
- items.push(`${arr.length} = (
477
- SELECT COUNT(DISTINCT ${alias}.${this.identifier(field.name)})
478
- FROM ${valueTable} ${alias}
479
- WHERE ${alias}.${pPath} = ${rpPath}
480
- AND ${alias}.${this.identifier(field.name)} IN (${arr.join(',')})
481
- )`);
482
- break;
483
- }
484
- case '$regex': {
485
- const regex = DataUtil.toRegex(castTo(value));
486
- const regexSource = regex.source;
487
- const ins = regex.flags?.includes('i');
488
-
489
- if (/^[\^]\S+[.][*][$]?$/.test(regexSource)) {
490
- const inner = regexSource.substring(1, regexSource.length - 2);
491
- if (!ins || SQL_OPS.$ilike) {
492
- items.push(`${sPath} ${ins ? SQL_OPS.$ilike : SQL_OPS.$like} ${resolve(`${inner}%`)}`);
493
- } else {
494
- items.push(`LOWER(${sPath}) ${SQL_OPS.$like} LOWER(${resolve(`${inner}%`)})`);
495
- }
496
- } else {
497
- if (!ins || SQL_OPS.$iregex) {
498
- const result = resolve(value);
499
- items.push(`${sPath} ${SQL_OPS[!ins ? subKey : '$iregex']} ${result}`);
500
- } else {
501
- const result = resolve(new RegExp(regexSource.toLowerCase(), regex.flags));
502
- items.push(`LOWER(${sPath}) ${SQL_OPS[subKey]} ${result}`);
503
- }
504
- }
505
- break;
506
- }
507
- case '$exists': {
508
- if (field.array) {
509
- const valueTable = this.parentTable(sStack);
510
- const alias = `_all_${sStack.length}`;
511
- const pPath = this.identifier(this.parentPathField.name);
512
- const rpPath = this.resolveName([...sStack, field, this.parentPathField]);
513
-
514
- items.push(`0 ${!value ? '=' : '<>'} (
515
- SELECT COUNT(${alias}.${this.identifier(field.name)})
516
- FROM ${valueTable} ${alias}
517
- WHERE ${alias}.${pPath} = ${rpPath}
518
- )`);
519
- } else {
520
- items.push(`${sPath} ${value ? SQL_OPS.$isNot : SQL_OPS.$is} NULL`);
521
- }
522
- break;
523
- }
524
- case '$ne':
525
- case '$eq': {
526
- if (value === null || value === undefined) {
527
- items.push(`${sPath} ${subKey === '$ne' ? SQL_OPS.$isNot : SQL_OPS.$is} NULL`);
528
- } else {
529
- const base = `${sPath} ${SQL_OPS[subKey]} ${resolve(value)}`;
530
- items.push(subKey === '$ne' ? `(${base} OR ${sPath} ${SQL_OPS.$is} NULL)` : base);
531
- }
532
- break;
533
- }
534
- case '$lt':
535
- case '$gt':
536
- case '$gte':
537
- case '$lte': {
538
- const subItems = TypedObject.keys(castTo<typeof SQL_OPS>(top)).map(
539
- subSubKey => `${sPath} ${SQL_OPS[subSubKey]} ${resolve(top[subSubKey])}`
540
- );
541
- items.push(subItems.length > 1 ? `(${subItems.join(` ${SQL_OPS.$and} `)})` : subItems[0]);
542
- break;
543
- }
544
- case '$near':
545
- case '$unit':
546
- case '$maxDistance':
547
- case '$geoWithin':
548
- throw new Error('Geo-spatial queries are not currently supported in SQL');
549
- }
550
- }
551
- // Handle operations
552
- } else {
553
- items.push(`${sPath} ${SQL_OPS.$eq} ${this.resolveValue(field, top)}`);
554
- }
555
- }
556
- if (items.length === 0) {
557
- return 'TRUE';
558
- } else if (items.length === 1) {
559
- return items[0];
560
- } else {
561
- return `(${items.join(` ${SQL_OPS.$and} `)})`;
562
- }
563
- }
564
-
565
- /**
566
- * Grouping of where clauses
567
- */
568
- getWhereGroupingSQL<T>(cls: Class<T>, clause: WhereClause<T>): string {
569
- const SQL_OPS = this.SQL_OPS;
570
-
571
- if (ModelQueryUtil.has$And(clause)) {
572
- return `(${clause.$and.map(item => this.getWhereGroupingSQL<T>(cls, item)).join(` ${SQL_OPS.$and} `)})`;
573
- } else if (ModelQueryUtil.has$Or(clause)) {
574
- return `(${clause.$or.map(item => this.getWhereGroupingSQL<T>(cls, item)).join(` ${SQL_OPS.$or} `)})`;
575
- } else if (ModelQueryUtil.has$Not(clause)) {
576
- return `${SQL_OPS.$not} (${this.getWhereGroupingSQL<T>(cls, clause.$not)})`;
577
- } else {
578
- return this.getWhereFieldSQL(SQLModelUtil.classToStack(cls), clause);
579
- }
580
- }
581
-
582
- /**
583
- * Generate WHERE clause
584
- */
585
- getWhereSQL<T>(cls: Class<T>, where?: WhereClause<T>): string {
586
- return !where || !Object.keys(where).length ? '' : `WHERE ${this.getWhereGroupingSQL(cls, castTo(where))}`;
587
- }
588
-
589
- /**
590
- * Generate ORDER BY clause
591
- */
592
- getOrderBySQL<T>(cls: Class<T>, sortBy?: SortClause<T>[]): string {
593
- return !sortBy
594
- ? ''
595
- : `ORDER BY ${SQLModelUtil.orderBy(cls, sortBy)
596
- .map(item => `${this.resolveName(item.stack)} ${item.asc ? 'ASC' : 'DESC'}`)
597
- .join(', ')}`;
598
- }
599
-
600
- /**
601
- * Generate SELECT clause
602
- */
603
- getSelectSQL<T>(cls: Class<T>, select?: SelectClause<T>): string {
604
- const stack = SQLModelUtil.classToStack(cls);
605
- const columns = select && SQLModelUtil.select(cls, select).map(sel => this.resolveName([...stack, sel]));
606
- if (columns) {
607
- columns.unshift(this.alias(this.pathField));
608
- }
609
- return !columns ? `SELECT ${this.rootAlias}.* ` : `SELECT ${columns.join(', ')}`;
610
- }
611
-
612
- /**
613
- * Generate FROM clause
614
- */
615
- getFromSQL<T>(cls: Class<T>): string {
616
- const stack = SQLModelUtil.classToStack(cls);
617
- const aliases = this.getAliasCache(stack, this.namespace);
618
- const tables = [...aliases.keys()].toSorted((a, b) => a.length - b.length); // Shortest first
619
- return `FROM ${tables
620
- .map(table => {
621
- const { alias, path } = aliases.get(table)!;
622
- let from = `${this.identifier(table)} ${alias}`;
623
- if (path.length > 1) {
624
- const key = this.namespaceParent(path);
625
- const { alias: parentAlias } = aliases.get(key)!;
626
- from = `
627
- LEFT OUTER JOIN ${from} ON
628
- ${this.alias(this.parentPathField, alias)} = ${this.alias(this.pathField, parentAlias)}
629
- `;
630
- }
631
- return from;
632
- })
633
- .join('\n')}`;
634
- }
635
-
636
- /**
637
- * Generate LIMIT clause
638
- */
639
- getLimitSQL<T>(cls: Class<T>, query?: Query<T>): string {
640
- return !query || (!query.limit && !query.offset) ? '' : `LIMIT ${query.limit ?? 200} OFFSET ${query.offset ?? 0}`;
641
- }
642
-
643
- /**
644
- * Generate GROUP BY clause
645
- */
646
- getGroupBySQL<T>(cls: Class<T>, query: Query<T>): string {
647
- const sortFields = !query.sort
648
- ? ''
649
- : SQLModelUtil.orderBy(cls, query.sort)
650
- .map(item => this.resolveName(item.stack))
651
- .join(', ');
652
-
653
- return `GROUP BY ${this.alias(this.idField)}${sortFields ? `, ${sortFields}` : ''}`;
654
- }
655
-
656
- /**
657
- * Generate full query
658
- */
659
- getQuerySQL<T>(cls: Class<T>, query: Query<T>, where?: WhereClause<T>): string {
660
- return `
661
- ${this.getSelectSQL(cls, query.select)}
662
- ${this.getFromSQL(cls)}
663
- ${this.getWhereSQL(cls, where)}
664
- ${this.getGroupBySQL(cls, query)}
665
- ${this.getOrderBySQL(cls, query.sort)}
666
- ${this.getLimitSQL(cls, query)}`;
667
- }
668
-
669
- getCreateTableSQL(stack: VisitStack[]): string {
670
- const config = stack.at(-1)!;
671
- const parent = stack.length > 1;
672
- const array = parent && config.array;
673
- const fields = SchemaRegistryIndex.has(config.type)
674
- ? [...SQLModelUtil.getFieldsByLocation(stack).local]
675
- : array
676
- ? [castTo<SchemaFieldConfig>(config)]
677
- : [];
678
-
679
- if (!parent) {
680
- const idField = fields.find(field => field.name === this.idField.name);
681
- if (!idField) {
682
- fields.push(this.idField);
683
- }
684
- }
685
-
686
- const fieldSql = fields
687
- .map(field => this.getColumnDefinition(field, field.name === this.idField.name && !parent) || '')
688
- .filter(line => !!line.trim())
689
- .join(',\n ');
690
-
691
- const out = `
692
- CREATE TABLE IF NOT EXISTS ${this.table(stack)} (
693
- ${fieldSql}${fieldSql.length ? ',' : ''}
694
- ${this.getColumnDefinition(this.pathField)} UNIQUE,
695
- ${
696
- !parent
697
- ? `PRIMARY KEY (${this.identifier(this.idField)})`
698
- : `${this.getColumnDefinition(this.parentPathField)},
699
- ${array ? `${this.getColumnDefinition(this.idxField)},` : ''}
700
- PRIMARY KEY (${this.identifier(this.pathField)}),
701
- FOREIGN KEY (${this.identifier(this.parentPathField)}) REFERENCES ${this.parentTable(stack)}(${this.identifier(this.pathField)}) ON DELETE CASCADE`
702
- }
703
- );`;
704
- return out;
705
- }
706
-
707
- /**
708
- * Generate drop SQL
709
- */
710
- getDropTableSQL(stack: VisitStack[]): string {
711
- return `DROP TABLE IF EXISTS ${this.table(stack)}; `;
712
- }
713
-
714
- /**
715
- * Generate truncate SQL
716
- */
717
- getTruncateTableSQL(stack: VisitStack[]): string {
718
- return `TRUNCATE ${this.table(stack)}; `;
719
- }
720
-
721
- /**
722
- * Get all table create queries for a class
723
- */
724
- getCreateAllTablesSQL(cls: Class): string[] {
725
- const out: string[] = [];
726
- SQLModelUtil.visitSchemaSync(SchemaRegistryIndex.getConfig(cls), {
727
- onRoot: ({ path, descend }) => {
728
- out.push(this.getCreateTableSQL(path));
729
- descend();
730
- },
731
- onSub: ({ path, descend }) => {
732
- out.push(this.getCreateTableSQL(path));
733
- descend();
734
- },
735
- onSimple: ({ path }) => out.push(this.getCreateTableSQL(path))
736
- });
737
- return out;
738
- }
739
-
740
- /**
741
- * Get all create indices need for a given class
742
- */
743
- getCreateAllIndicesSQL<T extends ModelType>(cls: Class<T>, indices: IndexConfig[]): string[] {
744
- return indices.map(idx => this.getCreateIndexSQL(cls, idx)).filter((sql): sql is string => !!sql);
745
- }
746
-
747
- /**
748
- * Get index name
749
- */
750
- getIndexName<T extends ModelType>(cls: Class<T>, idx: IndexConfig): string {
751
- const table = this.namespace(SQLModelUtil.classToStack(cls));
752
- return ['idx', table, idx.name.toLowerCase().replaceAll('-', '_')].join('_');
753
- }
754
-
755
- /**
756
- * Get CREATE INDEX sql
757
- */
758
- getCreateIndexSQL<T extends ModelType>(cls: Class<T>, idx: IndexConfig): string | undefined {
759
- const constraint = this.getIndexName(cls, idx);
760
- const table = this.namespace(SQLModelUtil.classToStack(cls));
761
-
762
- if (isModelQueryIndex(idx)) {
763
- const fields: [string, boolean][] = idx.fields.map(field => {
764
- const key = TypedObject.keys(field)[0];
765
- const value = field[key];
766
- if (DataUtil.isPlainObject(value)) {
767
- throw new IndexNotSupported(cls, idx, 'Only indexed and query indices are supported in SQL');
768
- }
769
- return [castTo(key), typeof value === 'number' ? value === 1 : !!value];
770
- });
771
- return `CREATE ${idx.unique ? 'UNIQUE ' : ''}INDEX ${constraint} ON ${this.identifier(table)} (${fields
772
- .map(([name, sel]) => `${this.identifier(name)} ${sel ? 'ASC' : 'DESC'}`)
773
- .join(', ')});`;
774
- } else if (isModelIndexedIndex(idx)) {
775
- const all = [...idx.keyTemplate, ...idx.sortTemplate];
776
- if (all.find(field => field.path.length > 1)) {
777
- console.debug('Nested fields are not supported in ModelIndexed indices SQL', { index: idx.name });
778
- return;
779
- }
780
- const fields = all.map(({ path, value }) => `${this.identifier(path.join('_'))} ${value === -1 ? 'DESC' : 'ASC'}`).join(', ');
781
- switch (idx.type) {
782
- case 'indexed:keyed':
783
- return `CREATE ${idx.unique ? 'UNIQUE ' : ''}INDEX ${constraint} ON ${this.identifier(table)} (${fields});`;
784
- case 'indexed:sorted':
785
- return `CREATE INDEX ${constraint} ON ${this.identifier(table)} (${fields});`;
786
- }
787
- } else {
788
- throw new IndexNotSupported(cls, idx, 'Only indexed and query indices are supported in SQL');
789
- }
790
- }
791
-
792
- /**
793
- * Get DROP INDEX sql
794
- */
795
- getDropIndexSQL<T extends ModelType>(cls: Class<T>, idx: IndexConfig | string): string {
796
- const constraint = typeof idx === 'string' ? idx : this.getIndexName(cls, idx);
797
- return `DROP INDEX ${this.identifier(constraint)} ;`;
798
- }
799
-
800
- /**
801
- * Drop all tables for a given class
802
- */
803
- getDropAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
804
- const out: string[] = [];
805
- SQLModelUtil.visitSchemaSync(SchemaRegistryIndex.getConfig(cls), {
806
- onRoot: ({ path, descend }) => {
807
- descend();
808
- out.push(this.getDropTableSQL(path));
809
- },
810
- onSub: ({ path, descend }) => {
811
- descend();
812
- out.push(this.getDropTableSQL(path));
813
- },
814
- onSimple: ({ path }) => out.push(this.getDropTableSQL(path))
815
- });
816
- return out;
817
- }
818
-
819
- /**
820
- * Truncate all tables for a given class
821
- */
822
- getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
823
- const out: string[] = [];
824
- SQLModelUtil.visitSchemaSync(SchemaRegistryIndex.getConfig(cls), {
825
- onRoot: ({ path, descend }) => {
826
- descend();
827
- out.push(this.getTruncateTableSQL(path));
828
- },
829
- onSub: ({ path, descend }) => {
830
- descend();
831
- out.push(this.getTruncateTableSQL(path));
832
- },
833
- onSimple: ({ path }) => out.push(this.getTruncateTableSQL(path))
834
- });
835
- return out;
836
- }
837
-
838
- /**
839
- * Get INSERT sql for a given instance and a specific stack location
840
- */
841
- getInsertSQL(stack: VisitStack[], instances: InsertWrapper['records']): string | undefined {
842
- const config = stack.at(-1)!;
843
- const columns = SQLModelUtil.getFieldsByLocation(stack)
844
- .local.filter(field => !SchemaRegistryIndex.has(field.type))
845
- .toSorted((a, b) => a.name.localeCompare(b.name));
846
- const columnNames = columns.map(column => column.name);
847
-
848
- const hasParent = stack.length > 1;
849
- const isArray = !!config.array;
850
-
851
- if (isArray) {
852
- const newInstances: typeof instances = [];
853
- for (const instance of instances) {
854
- if (instance.value === null || instance.value === undefined) {
855
- // Continue
856
- } else if (Array.isArray(instance.value)) {
857
- const name = instance.stack.at(-1)!.name;
858
- for (const sel of instance.value) {
859
- newInstances.push({
860
- stack: instance.stack,
861
- value: {
862
- [name]: sel
863
- }
864
- });
865
- }
866
- } else {
867
- newInstances.push(instance);
868
- }
869
- }
870
- instances = newInstances;
871
- }
872
-
873
- if (!instances.length) {
874
- return;
875
- }
876
-
877
- const matrix = instances.map(inst =>
878
- columns.map(column => this.resolveValue(column, castTo<Record<string, unknown>>(inst.value)[column.name]))
879
- );
880
-
881
- columnNames.push(this.pathField.name);
882
- if (hasParent) {
883
- columnNames.push(this.parentPathField.name);
884
- if (isArray) {
885
- columnNames.push(this.idxField.name);
886
- }
887
- }
888
-
889
- const idx = config.index ?? 0;
890
-
891
- for (let i = 0; i < matrix.length; i++) {
892
- const { stack: elStack } = instances[i];
893
- if (hasParent) {
894
- matrix[i].push(this.hash(`${SQLModelUtil.buildPath(elStack)}${isArray ? `[${i + idx}]` : ''}`));
895
- matrix[i].push(this.hash(SQLModelUtil.buildPath(elStack.slice(0, elStack.length - 1))));
896
- if (isArray) {
897
- matrix[i].push(this.resolveValue(this.idxField, i + idx));
898
- }
899
- } else {
900
- matrix[i].push(this.hash(SQLModelUtil.buildPath(elStack)));
901
- }
902
- }
903
-
904
- return `
905
- INSERT INTO ${this.table(stack)} (${columnNames.map(this.identifier).join(', ')})
906
- VALUES
907
- ${matrix.map(row => `(${row.join(', ')})`).join(',\n')};`;
908
- }
909
-
910
- /**
911
- * Get ALL Insert queries as needed
912
- */
913
- getAllInsertSQL<T extends ModelType>(cls: Class<T>, instance: T): string[] {
914
- const out: string[] = [];
915
- const add = (text?: string): void => {
916
- text && out.push(text);
917
- };
918
- SQLModelUtil.visitSchemaInstance(cls, instance, {
919
- onRoot: ({ value, path }) => add(this.getInsertSQL(path, [{ stack: path, value }])),
920
- onSub: ({ value, path }) => add(this.getInsertSQL(path, [{ stack: path, value }])),
921
- onSimple: ({ value, path }) => add(this.getInsertSQL(path, [{ stack: path, value }]))
922
- });
923
- return out;
924
- }
925
-
926
- /**
927
- * Simple data base updates
928
- */
929
- getUpdateSQL(stack: VisitStack[], data: Record<string, unknown>, where?: WhereClause<unknown>): string {
930
- const { type } = stack.at(-1)!;
931
- const { localMap } = SQLModelUtil.getFieldsByLocation(stack);
932
- return `
933
- UPDATE ${this.table(stack)} ${this.rootAlias}
934
- SET
935
- ${Object.entries(data)
936
- .filter(([key]) => key in localMap)
937
- .map(([key, value]) => `${this.identifier(key)}=${this.resolveValue(localMap[key], value)}`)
938
- .join(', ')}
939
- ${this.getWhereSQL(type, where)};`;
940
- }
941
-
942
- getDeleteSQL(stack: VisitStack[], where?: WhereClause<unknown>): string {
943
- const { type } = stack.at(-1)!;
944
- return `
945
- DELETE
946
- FROM ${this.table(stack)} ${this.rootAlias}
947
- ${this.getWhereSQL(type, where)};`;
948
- }
949
-
950
- /**
951
- * Get elements by ids
952
- */
953
- getSelectRowsByIdsSQL(stack: VisitStack[], ids: string[], select: SchemaFieldConfig[] = []): string {
954
- const config = stack.at(-1)!;
955
- const orderBy = !config.array ? '' : `ORDER BY ${this.rootAlias}.${this.idxField.name} ASC`;
956
-
957
- const idField = stack.length > 1 ? this.parentPathField : this.idField;
958
-
959
- return `
960
- SELECT ${select.length ? select.map(field => this.alias(field)).join(',') : '*'}
961
- FROM ${this.table(stack)} ${this.rootAlias}
962
- WHERE ${this.alias(idField)} IN (${ids.map(id => this.resolveValue(idField, id)).join(', ')})
963
- ${orderBy};`;
964
- }
965
-
966
- /**
967
- * Get COUNT(1) query
968
- */
969
- getQueryCountSQL<T>(cls: Class<T>, where?: WhereClause<T>): string {
970
- return `
971
- SELECT COUNT(DISTINCT ${this.rootAlias}.id) as total
972
- ${this.getFromSQL(cls)}
973
- ${this.getWhereSQL(cls, where!)}`;
974
- }
975
-
976
- async fetchDependents<T>(cls: Class<T>, items: T[], select?: SelectClause<T>): Promise<T[]> {
977
- const stack: Record<string, unknown>[] = [];
978
- const selectStack: (SelectClause<T> | undefined)[] = [];
979
-
980
- const buildSet = (children: unknown[], field?: SchemaFieldConfig): Record<string, unknown> =>
981
- SQLModelUtil.collectDependents(this, stack.at(-1)!, children, field);
982
-
983
- await SQLModelUtil.visitSchema(SchemaRegistryIndex.getConfig(cls), {
984
- onRoot: async config => {
985
- const fieldSet = buildSet(items); // Already filtered by initial select query
986
- selectStack.push(select);
987
- stack.push(fieldSet);
988
- await config.descend();
989
- },
990
- onSub: async ({ config, descend, fields, path }) => {
991
- const top = stack.at(-1)!;
992
- const ids = Object.keys(top);
993
- const selectTop = selectStack.at(-1)!;
994
- const fieldKey = castKey<RetainQueryPrimitiveFields<T>>(config.name);
995
- const subSelectTop: SelectClause<T> | undefined = castTo(selectTop?.[fieldKey]);
996
-
997
- // See if a selection exists at all
998
- const selected: SchemaFieldConfig[] = subSelectTop
999
- ? fields.filter(field => typeof subSelectTop === 'object' && subSelectTop[castTo<typeof fieldKey>(field.name)] === 1)
1000
- : [];
1001
-
1002
- if (selected.length) {
1003
- selected.push(this.pathField, this.parentPathField);
1004
- if (config.array) {
1005
- selected.push(this.idxField);
1006
- }
1007
- }
1008
-
1009
- // If children and selection exists
1010
- if (ids.length && (!subSelectTop || selected)) {
1011
- const { records: children } = await this.executeSQL<unknown[]>(this.getSelectRowsByIdsSQL(path, ids, selected));
1012
-
1013
- const fieldSet = buildSet(children, config);
1014
- try {
1015
- stack.push(fieldSet);
1016
- selectStack.push(subSelectTop);
1017
- await descend();
1018
- } finally {
1019
- selectStack.pop();
1020
- stack.pop();
1021
- }
1022
- }
1023
- },
1024
- onSimple: async ({ config, path }): Promise<void> => {
1025
- const top = stack.at(-1)!;
1026
- const ids = Object.keys(top);
1027
- if (ids.length) {
1028
- const { records: matching } = await this.executeSQL(this.getSelectRowsByIdsSQL(path, ids));
1029
- buildSet(matching, config);
1030
- }
1031
- }
1032
- });
1033
-
1034
- return items;
1035
- }
1036
-
1037
- /**
1038
- * Delete all ids
1039
- */
1040
- async deleteByIds(stack: VisitStack[], ids: string[]): Promise<number> {
1041
- return this.deleteAndGetCount<ModelType>(stack.at(-1)!.type, {
1042
- where: {
1043
- [stack.length === 1 ? this.idField.name : this.pathField.name]: {
1044
- $in: ids
1045
- }
1046
- }
1047
- });
1048
- }
1049
-
1050
- /**
1051
- * Do bulk process
1052
- */
1053
- async bulkProcess(
1054
- deletes: DeleteWrapper[],
1055
- inserts: InsertWrapper[],
1056
- upserts: InsertWrapper[],
1057
- updates: InsertWrapper[]
1058
- ): Promise<BulkResponse> {
1059
- const out = {
1060
- counts: {
1061
- delete: deletes.reduce((count, item) => count + item.ids.length, 0),
1062
- error: 0,
1063
- insert: inserts.filter(item => item.stack.length === 1).reduce((count, item) => count + item.records.length, 0),
1064
- update: updates.filter(item => item.stack.length === 1).reduce((count, item) => count + item.records.length, 0),
1065
- upsert: upserts.filter(item => item.stack.length === 1).reduce((count, item) => count + item.records.length, 0)
1066
- },
1067
- errors: [],
1068
- insertedIds: new Map()
1069
- };
1070
-
1071
- // Full removals
1072
- await Promise.all(deletes.map(item => this.deleteByIds(item.stack, item.ids)));
1073
-
1074
- // Adding deletes
1075
- if (upserts.length || updates.length) {
1076
- const idx = this.idField.name;
1077
-
1078
- await Promise.all([
1079
- ...upserts
1080
- .filter(item => item.stack.length === 1)
1081
- .map(item =>
1082
- this.deleteByIds(
1083
- item.stack,
1084
- item.records.map(value => castTo<Record<string, string>>(value.value)[idx])
1085
- )
1086
- ),
1087
- ...updates
1088
- .filter(item => item.stack.length === 1)
1089
- .map(item =>
1090
- this.deleteByIds(
1091
- item.stack,
1092
- item.records.map(value => castTo<Record<string, string>>(value.value)[idx])
1093
- )
1094
- )
1095
- ]);
1096
- }
1097
-
1098
- // Adding
1099
- for (const items of [inserts, upserts, updates]) {
1100
- if (!items.length) {
1101
- continue;
1102
- }
1103
- let level = 1; // Add by level
1104
- for (;;) {
1105
- // Loop until done
1106
- const leveled = items.filter(insertWrapper => insertWrapper.stack.length === level);
1107
- if (!leveled.length) {
1108
- break;
1109
- }
1110
- await Promise.all(
1111
- leveled
1112
- .map(inserted => this.getInsertSQL(inserted.stack, inserted.records))
1113
- .filter(sql => !!sql)
1114
- .map(sql => this.executeSQL(sql!))
1115
- );
1116
- level += 1;
1117
- }
1118
- }
1119
-
1120
- return out;
1121
- }
1122
-
1123
- /**
1124
- * Determine if a column has changed
1125
- */
1126
- isColumnChanged(requested: SchemaFieldConfig, existing: SQLTableDescription['columns'][number]): boolean {
1127
- const requestedColumnType = this.getColumnType(requested);
1128
- const result =
1129
- (requested.name !== this.idField.name && !!requested.required?.active !== !!existing.is_not_null) ||
1130
- requestedColumnType.toUpperCase() !== existing.type.toUpperCase();
1131
-
1132
- return result;
1133
- }
1134
-
1135
- /**
1136
- * Determine if an index has changed
1137
- */
1138
- isIndexChanged(requested: IndexConfig, existing: SQLTableDescription['indices'][number]): boolean {
1139
- if (isModelQueryIndex(requested)) {
1140
- const uniqueChanged = existing.is_unique && !requested.unique;
1141
- const columnSizeChanged = requested.fields.length !== existing.columns.length;
1142
- let result = uniqueChanged || columnSizeChanged;
1143
- for (let i = 0; i < requested.fields.length && !result; i++) {
1144
- const [[key, value]] = Object.entries(requested.fields[i]);
1145
- const desc = value === -1;
1146
- result ||= key !== existing.columns[i].name && desc !== existing.columns[i].desc;
1147
- }
1148
-
1149
- return result;
1150
- } else if (isModelIndexedIndex(requested)) {
1151
- const keys = Object.entries(requested.key);
1152
- const sort = Object.entries(requested.sort);
1153
- const all = [...keys, ...sort];
1154
-
1155
- const uniqueChanged = requested.type === 'indexed:keyed' && existing.is_unique && !requested.unique;
1156
- const columnSizeChanged = all.length !== existing.columns.length;
1157
- let result = uniqueChanged || columnSizeChanged;
1158
-
1159
- for (let i = 0; i < all.length && !result; i++) {
1160
- const [key, value] = all[i];
1161
- const desc = value === -1;
1162
- result ||= key !== existing.columns[i].name && desc !== existing.columns[i].desc;
1163
- }
1164
-
1165
- return result;
1166
- } else {
1167
- throw new IndexNotSupported(requested.class, requested, 'Only indexed and query indices are supported in SQL');
1168
- }
1169
- }
1170
-
1171
- /**
1172
- * Enforce the dialect specific id length
1173
- */
1174
- enforceIdLength(cls: Class<ModelType>): void {
1175
- const config = SchemaRegistryIndex.getConfig(cls);
1176
- const idField = config.fields[this.idField.name];
1177
- if (idField) {
1178
- idField.maxlength = { limit: this.ID_LENGTH };
1179
- idField.minlength = { limit: this.ID_LENGTH };
1180
- }
1181
- }
1182
- }