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