@travetto/model-sql 8.0.0-alpha.29 → 8.0.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/dialect.ts +61 -31
- package/support/test/dialect.ts +157 -0
package/package.json
CHANGED
package/src/dialect.ts
CHANGED
|
@@ -99,8 +99,12 @@ export abstract class AbstractANSI99Dialect {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
formatJsonPath(jsonPath: string[]): string {
|
|
103
|
+
return jsonPath.map(segment => (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(segment) ? segment : `"${segment.replaceAll('"', '\\"')}"`)).join('.');
|
|
104
|
+
}
|
|
105
|
+
|
|
102
106
|
compileIndexPath(context: TableContext, path: string[], mode: JSONSqlPathMode): string {
|
|
103
|
-
return this.
|
|
107
|
+
return this.resolvePath(context, path, mode).sqlPath;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
getCreateIndexSQL(context: TableContext, indexConfig: IndexConfig): string {
|
|
@@ -115,7 +119,8 @@ export abstract class AbstractANSI99Dialect {
|
|
|
115
119
|
|
|
116
120
|
const path = fieldKey.split('.');
|
|
117
121
|
const expression = this.compileIndexPath(context, path, 'createIndex');
|
|
118
|
-
|
|
122
|
+
const formattedExpression = path.length > 1 ? `(${expression})` : expression;
|
|
123
|
+
return `${formattedExpression} ${isAscending ? 'ASC' : 'DESC'}`;
|
|
119
124
|
});
|
|
120
125
|
|
|
121
126
|
return `CREATE ${indexConfig.unique ? 'UNIQUE ' : ''}INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)} (${indexFields.join(', ')});`;
|
|
@@ -123,7 +128,8 @@ export abstract class AbstractANSI99Dialect {
|
|
|
123
128
|
const allFields = [...indexConfig.keyTemplate, ...indexConfig.sortTemplate];
|
|
124
129
|
const indexFields = allFields.map(({ path, value }) => {
|
|
125
130
|
const expression = this.compileIndexPath(context, path, 'createIndex');
|
|
126
|
-
|
|
131
|
+
const formattedExpression = path.length > 1 ? `(${expression})` : expression;
|
|
132
|
+
return `${formattedExpression} ${value === -1 ? 'DESC' : 'ASC'}`;
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
const isUnique = 'unique' in indexConfig && indexConfig.unique;
|
|
@@ -165,15 +171,20 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
|
|
|
165
171
|
return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ADD COLUMN ${this.escapeIdentifier(columnName)} ${columnType};`;
|
|
166
172
|
}
|
|
167
173
|
|
|
168
|
-
getUpsertSQL(
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
abstract getUpsertSQL(
|
|
175
|
+
context: TableContext,
|
|
176
|
+
columns: string[],
|
|
177
|
+
placeholders: string[],
|
|
178
|
+
conflictTarget: string[],
|
|
179
|
+
updates: string[]
|
|
180
|
+
): string;
|
|
171
181
|
|
|
172
182
|
normalizeIndexDefinition(sql: string): string {
|
|
173
183
|
return sql
|
|
174
184
|
.toLowerCase()
|
|
175
185
|
.replaceAll('"', '')
|
|
176
186
|
.replaceAll("'", '')
|
|
187
|
+
.replaceAll('`', '')
|
|
177
188
|
.replaceAll(' ', '')
|
|
178
189
|
.replaceAll('asc', '')
|
|
179
190
|
.replaceAll('desc', '')
|
|
@@ -190,19 +201,13 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
|
|
|
190
201
|
abstract parseExistingColumns(records: unknown[]): Map<string, string>;
|
|
191
202
|
abstract getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] };
|
|
192
203
|
abstract parseExistingIndexes(records: unknown[]): Map<string, string>;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return `DROP INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)};`;
|
|
196
|
-
}
|
|
204
|
+
abstract getDropIndexSQL(context: TableContext, indexName: string): string;
|
|
205
|
+
abstract getTruncateTableSQL(context: TableContext): string;
|
|
197
206
|
|
|
198
207
|
getDropTableSQL(context: TableContext): string {
|
|
199
208
|
return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)};`;
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
getTruncateTableSQL(context: TableContext): string {
|
|
203
|
-
return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)};`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
211
|
getAlterColumnTypeSQL?(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined;
|
|
207
212
|
|
|
208
213
|
// Query Compilation
|
|
@@ -309,6 +314,28 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
|
|
|
309
314
|
};
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
getSchemaSubPathMetadata(
|
|
318
|
+
initialClass: Class | undefined,
|
|
319
|
+
subPath: string[]
|
|
320
|
+
): Array<{ segment: string; fieldConfig?: SchemaFieldConfig; isArray: boolean; fieldClass?: Class }> {
|
|
321
|
+
let currentClass = initialClass;
|
|
322
|
+
return subPath.map(segment => {
|
|
323
|
+
let fieldConfig: SchemaFieldConfig | undefined;
|
|
324
|
+
let isArray = false;
|
|
325
|
+
if (currentClass) {
|
|
326
|
+
const classConfiguration = SchemaRegistryIndex.getOptional(currentClass)?.get();
|
|
327
|
+
fieldConfig = classConfiguration?.fields[segment];
|
|
328
|
+
if (fieldConfig) {
|
|
329
|
+
isArray = !!fieldConfig.array;
|
|
330
|
+
currentClass = fieldConfig.type;
|
|
331
|
+
} else {
|
|
332
|
+
currentClass = undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { segment, fieldConfig, isArray, fieldClass: currentClass };
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
312
339
|
resolvePath<T extends ModelType>(tableContext: TableContext<T>, path: string[], mode: JSONSqlPathMode): ResolvedPathContext {
|
|
313
340
|
const firstSegment = path[0];
|
|
314
341
|
|
|
@@ -670,23 +697,7 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
|
|
|
670
697
|
return { sql, values };
|
|
671
698
|
}
|
|
672
699
|
|
|
673
|
-
|
|
674
|
-
tableContext: TableContext<T>,
|
|
675
|
-
preparedData: Partial<T>
|
|
676
|
-
): { sets: string[]; values: unknown[] } {
|
|
677
|
-
const { sql, values } = this.buildPartialUpdate(tableContext, preparedData);
|
|
678
|
-
const setMatch = sql.match(/SET (.*?)(\s+WHERE|$)/);
|
|
679
|
-
const sets = setMatch ? setMatch[1].split(', ') : [];
|
|
680
|
-
return { sets, values };
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
buildPartialUpdate<T extends ModelType>(
|
|
684
|
-
tableContext: TableContext<T>,
|
|
685
|
-
preparedData: Partial<T>,
|
|
686
|
-
whereSQL?: string,
|
|
687
|
-
whereParameters: unknown[] = [],
|
|
688
|
-
returning = false
|
|
689
|
-
): { sql: string; values: unknown[] } {
|
|
700
|
+
#buildUpdateSets<T extends ModelType>(tableContext: TableContext<T>, preparedData: Partial<T>): { sets: string[]; values: unknown[] } {
|
|
690
701
|
const sets: string[] = [];
|
|
691
702
|
const values: unknown[] = [];
|
|
692
703
|
|
|
@@ -705,6 +716,25 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
|
|
|
705
716
|
}
|
|
706
717
|
}
|
|
707
718
|
|
|
719
|
+
return { sets, values };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
compilePartialUpdate<T extends ModelType>(
|
|
723
|
+
tableContext: TableContext<T>,
|
|
724
|
+
preparedData: Partial<T>
|
|
725
|
+
): { sets: string[]; values: unknown[] } {
|
|
726
|
+
return this.#buildUpdateSets(tableContext, preparedData);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
buildPartialUpdate<T extends ModelType>(
|
|
730
|
+
tableContext: TableContext<T>,
|
|
731
|
+
preparedData: Partial<T>,
|
|
732
|
+
whereSQL?: string,
|
|
733
|
+
whereParameters: unknown[] = [],
|
|
734
|
+
returning = false
|
|
735
|
+
): { sql: string; values: unknown[] } {
|
|
736
|
+
const { sets, values } = this.#buildUpdateSets(tableContext, preparedData);
|
|
737
|
+
|
|
708
738
|
const shiftedWhereSQL = whereSQL && this.shiftPlaceholders ? this.shiftPlaceholders(whereSQL, values.length) : whereSQL;
|
|
709
739
|
if (whereSQL) {
|
|
710
740
|
values.push(...whereParameters);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
|
|
3
|
+
import { Model, type ModelType } from '@travetto/model';
|
|
4
|
+
import { Registry } from '@travetto/registry';
|
|
5
|
+
import type { Class } from '@travetto/runtime';
|
|
6
|
+
import { Schema } from '@travetto/schema';
|
|
7
|
+
import { BeforeAll, Suite, Test } from '@travetto/test';
|
|
8
|
+
|
|
9
|
+
import { MysqlDialect } from '../../../model-mysql/src/dialect.ts';
|
|
10
|
+
import { PostgresDialect } from '../../../model-postgres/src/dialect.ts';
|
|
11
|
+
import { SqliteDialect } from '../../../model-sqlite/src/dialect.ts';
|
|
12
|
+
import { SQLModelSchemaUtil } from '../../src/schema.ts';
|
|
13
|
+
import type { TableContext } from '../../src/types.ts';
|
|
14
|
+
|
|
15
|
+
@Schema()
|
|
16
|
+
class ChildItem {
|
|
17
|
+
name: string;
|
|
18
|
+
age: number;
|
|
19
|
+
active: boolean;
|
|
20
|
+
createdDate: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Model()
|
|
24
|
+
class ParentModel {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
child: ChildItem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getTableContext<T extends ModelType>(modelClass: Class<T>): TableContext<T> {
|
|
31
|
+
return {
|
|
32
|
+
tableName: modelClass.name.toLowerCase(),
|
|
33
|
+
...SQLModelSchemaUtil.getSchemaContext(modelClass)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Suite()
|
|
38
|
+
export class SQLDialectGapsTest {
|
|
39
|
+
@BeforeAll()
|
|
40
|
+
async setup() {
|
|
41
|
+
await Registry.init();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test()
|
|
45
|
+
async testIndexAndQueryExpressionParity() {
|
|
46
|
+
const tableContext = getTableContext(ParentModel);
|
|
47
|
+
const mysqlDialect = new MysqlDialect();
|
|
48
|
+
const postgresDialect = new PostgresDialect();
|
|
49
|
+
const sqliteDialect = new SqliteDialect();
|
|
50
|
+
|
|
51
|
+
// Verify MySQL path resolution matches index creation
|
|
52
|
+
const mysqlAgeResolved = mysqlDialect.resolvePath(tableContext, ['child', 'age'], 'read');
|
|
53
|
+
assert(mysqlAgeResolved.sqlPath === "CAST(`child`->>'$.age' AS DECIMAL)");
|
|
54
|
+
|
|
55
|
+
const mysqlNameResolved = mysqlDialect.resolvePath(tableContext, ['child', 'name'], 'read');
|
|
56
|
+
assert(mysqlNameResolved.sqlPath === "(CAST(`child`->>'$.name' AS CHAR(255)) COLLATE utf8mb4_bin)");
|
|
57
|
+
|
|
58
|
+
// Verify Postgres path resolution matches index creation
|
|
59
|
+
const postgresAgeResolved = postgresDialect.resolvePath(tableContext, ['child', 'age'], 'read');
|
|
60
|
+
assert(postgresAgeResolved.sqlPath === '((("child"->>\'age\')))::NUMERIC');
|
|
61
|
+
|
|
62
|
+
// Verify SQLite path resolution matches index creation
|
|
63
|
+
const sqliteAgeResolved = sqliteDialect.resolvePath(tableContext, ['child', 'age'], 'read');
|
|
64
|
+
assert(sqliteAgeResolved.sqlPath === 'CAST(json_extract("child", \'$.age\') AS NUMERIC)');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test()
|
|
68
|
+
async testSqliteArraySubObjectPatch() {
|
|
69
|
+
const tableContext = getTableContext(ParentModel);
|
|
70
|
+
const sqliteDialect = new SqliteDialect();
|
|
71
|
+
|
|
72
|
+
const resolvedContext = sqliteDialect.resolvePath(tableContext, ['child', 'name'], 'read');
|
|
73
|
+
const { sql } = sqliteDialect.compileArrayEquals(resolvedContext, '$$1', { name: 'bob' });
|
|
74
|
+
|
|
75
|
+
assert(sql.includes('json_patch('));
|
|
76
|
+
assert(sql.includes('= elem.value'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@Test()
|
|
80
|
+
async testCreateIndexes() {
|
|
81
|
+
const tableContext = getTableContext(ParentModel);
|
|
82
|
+
const mysqlDialect = new MysqlDialect();
|
|
83
|
+
const postgresDialect = new PostgresDialect();
|
|
84
|
+
|
|
85
|
+
// Verify create index SQL contains the resolved expressions
|
|
86
|
+
const mysqlCreateIndexSql = mysqlDialect.getCreateIndexSQL(tableContext, {
|
|
87
|
+
type: 'query',
|
|
88
|
+
name: 'child_age',
|
|
89
|
+
fields: [{ 'child.age': 1 }]
|
|
90
|
+
});
|
|
91
|
+
assert(mysqlCreateIndexSql.includes("(CAST(`child`->>'$.age' AS DECIMAL))"));
|
|
92
|
+
|
|
93
|
+
const postgresCreateIndexSql = postgresDialect.getCreateIndexSQL(tableContext, {
|
|
94
|
+
type: 'query',
|
|
95
|
+
name: 'child_age',
|
|
96
|
+
fields: [{ 'child.age': 1 }]
|
|
97
|
+
});
|
|
98
|
+
assert(postgresCreateIndexSql.includes('((("child"->>\'age\')))::NUMERIC)'));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test()
|
|
102
|
+
async testMysqlExistingIndexesParsing() {
|
|
103
|
+
const mysqlDialect = new MysqlDialect();
|
|
104
|
+
const existingIndexRecords = [
|
|
105
|
+
{
|
|
106
|
+
name: 'idx_parentmodel_child_age',
|
|
107
|
+
tableName: 'parentmodel',
|
|
108
|
+
nonUnique: 1,
|
|
109
|
+
indexColumns: "(CAST(`child`->>'$.age' AS DECIMAL))"
|
|
110
|
+
}
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const parsedIndexes = mysqlDialect.parseExistingIndexes(existingIndexRecords);
|
|
114
|
+
assert(parsedIndexes.size === 1);
|
|
115
|
+
assert(parsedIndexes.has('idx_parentmodel_child_age'));
|
|
116
|
+
|
|
117
|
+
const indexDefinition = parsedIndexes.get('idx_parentmodel_child_age')!;
|
|
118
|
+
assert(indexDefinition.includes('CREATE INDEX `idx_parentmodel_child_age` ON `parentmodel`'));
|
|
119
|
+
assert(indexDefinition.includes("(CAST(`child`->>'$.age' AS DECIMAL))"));
|
|
120
|
+
|
|
121
|
+
const normalizedDefinition = mysqlDialect.normalizeIndexDefinition(indexDefinition);
|
|
122
|
+
assert(normalizedDefinition.length > 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test()
|
|
126
|
+
async testMysqlAlterColumnType() {
|
|
127
|
+
const tableContext = getTableContext(ParentModel);
|
|
128
|
+
const mysqlDialect = new MysqlDialect();
|
|
129
|
+
|
|
130
|
+
const alterColumnSql = mysqlDialect.getAlterColumnTypeSQL(tableContext, 'title', 'VARCHAR(255)', 'INT');
|
|
131
|
+
assert(alterColumnSql === 'ALTER TABLE `parentmodel` MODIFY COLUMN `title` VARCHAR(255);');
|
|
132
|
+
|
|
133
|
+
const noopAlterColumnSql = mysqlDialect.getAlterColumnTypeSQL(tableContext, 'title', 'VARCHAR(255)', 'VARCHAR(255)');
|
|
134
|
+
assert(noopAlterColumnSql === undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@Test()
|
|
138
|
+
async testFormatJsonPathEscaping() {
|
|
139
|
+
const mysqlDialect = new MysqlDialect();
|
|
140
|
+
|
|
141
|
+
const simpleJsonPath = mysqlDialect.formatJsonPath(['child', 'age']);
|
|
142
|
+
assert(simpleJsonPath === 'child.age');
|
|
143
|
+
|
|
144
|
+
const complexJsonPath = mysqlDialect.formatJsonPath(['child', 'first name']);
|
|
145
|
+
assert(complexJsonPath === 'child."first name"');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test()
|
|
149
|
+
async testPartialUpdateSetsWithoutRegex() {
|
|
150
|
+
const tableContext = getTableContext(ParentModel);
|
|
151
|
+
const sqliteDialect = new SqliteDialect();
|
|
152
|
+
|
|
153
|
+
const { sets, values } = sqliteDialect.compilePartialUpdate(tableContext, { title: 'Updated Title' });
|
|
154
|
+
assert.deepStrictEqual(sets, ['"title" = ?']);
|
|
155
|
+
assert.deepStrictEqual(values, ['Updated Title']);
|
|
156
|
+
}
|
|
157
|
+
}
|