@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-sql",
3
- "version": "8.0.0-alpha.29",
3
+ "version": "8.0.0-alpha.30",
4
4
  "type": "module",
5
5
  "description": "SQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
6
6
  "keywords": [
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.buildSqlPath(context, path, mode);
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
- return `${expression} ${isAscending ? 'ASC' : 'DESC'}`;
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
- return `${expression} ${value === -1 ? 'DESC' : 'ASC'}`;
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(context: TableContext, columns: string[], placeholders: string[], conflictTarget: string[], updates: string[]): string {
169
- return `INSERT INTO ${this.escapeIdentifier(context.tableName)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) ON CONFLICT (${conflictTarget.join(', ')}) DO UPDATE SET ${updates.join(', ')} RETURNING *;`;
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
- getDropIndexSQL(context: TableContext, indexName: string): string {
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
- compilePartialUpdate<T extends ModelType>(
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
+ }