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

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