@travetto/model-sql 8.0.0-alpha.30 → 8.0.0-alpha.32

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/__index__.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './src/connection.ts';
2
2
  export * from './src/dialect.ts';
3
+ export * from './src/schema.ts';
3
4
  export * from './src/service.ts';
4
5
  export * from './src/types.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-sql",
3
- "version": "8.0.0-alpha.30",
3
+ "version": "8.0.0-alpha.32",
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": [
@@ -28,15 +28,15 @@
28
28
  "directory": "module/model-sql"
29
29
  },
30
30
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.24",
31
+ "@travetto/config": "^8.0.0-alpha.25",
32
32
  "@travetto/context": "^8.0.0-alpha.22",
33
- "@travetto/model": "^8.0.0-alpha.25",
34
- "@travetto/model-indexed": "^8.0.0-alpha.27",
35
- "@travetto/model-query": "^8.0.0-alpha.27"
33
+ "@travetto/model": "^8.0.0-alpha.26",
34
+ "@travetto/model-indexed": "^8.0.0-alpha.28",
35
+ "@travetto/model-query": "^8.0.0-alpha.29"
36
36
  },
37
37
  "peerDependencies": {
38
- "@travetto/cli": "^8.0.0-alpha.30",
39
- "@travetto/test": "^8.0.0-alpha.23"
38
+ "@travetto/cli": "^8.0.0-alpha.31",
39
+ "@travetto/test": "^8.0.0-alpha.24"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@travetto/cli": {
package/src/dialect.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { IndexConfig, ModelType } from '@travetto/model';
2
2
  import { ModelRegistryIndex } from '@travetto/model';
3
3
  import { isModelIndexedIndex } from '@travetto/model-indexed';
4
- import { isModelQueryIndex, ModelQueryUtil, type SortClause, type WhereClause } from '@travetto/model-query';
4
+ import { isModelQueryIndex, ModelQueryUtil, type QueryIndexConfig, type SortClause, type WhereClause } from '@travetto/model-query';
5
5
  import { type Class, castTo, JSONUtil, RuntimeError } from '@travetto/runtime';
6
6
  import { DataUtil, type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
7
7
 
8
+ import { SQLModelSchemaUtil } from './schema.ts';
8
9
  import type { JSONSqlPathMode, ResolvedPathContext, SchemaContext, TableContext } from './types.ts';
9
10
 
10
11
  export interface TransactionStatements {
@@ -24,6 +25,26 @@ interface QueryClause {
24
25
 
25
26
  type IdentificationPath = string;
26
27
 
28
+ function extractQueryIndexPathAndDirection(indexField: Record<string, unknown>): { path: string[]; sortDirection: 1 | -1 | true } {
29
+ const path: string[] = [];
30
+ let current: unknown = indexField;
31
+ while (typeof current === 'object' && current !== null) {
32
+ const keys = Object.keys(current);
33
+ if (keys.length === 0) {
34
+ break;
35
+ }
36
+ const key = keys[0];
37
+ if (key.includes('.')) {
38
+ path.push(...key.split('.'));
39
+ } else {
40
+ path.push(key);
41
+ }
42
+ current = castTo<Record<string, unknown>>(current)[key];
43
+ }
44
+ const sortDirection = castTo<1 | -1 | true>(current ?? 1);
45
+ return { path, sortDirection };
46
+ }
47
+
27
48
  /**
28
49
  * Abstract ANSI SQL-99 Dialect base implementation.
29
50
  * Pure SQL text generator and query builder (does not execute queries or hold connection state).
@@ -107,17 +128,15 @@ export abstract class AbstractANSI99Dialect {
107
128
  return this.resolvePath(context, path, mode).sqlPath;
108
129
  }
109
130
 
110
- getCreateIndexSQL(context: TableContext, indexConfig: IndexConfig): string {
131
+ getCreateIndexSQL<T extends ModelType>(context: TableContext, indexConfig: IndexConfig<string, T> | QueryIndexConfig<T>): string {
111
132
  const { tableName, cls: modelClass } = context;
112
133
  const indexName = ['idx', tableName, indexConfig.name.toLowerCase().replaceAll('-', '_')].join('_');
113
134
 
114
135
  if (isModelQueryIndex(indexConfig)) {
115
136
  const indexFields = indexConfig.fields.map(field => {
116
- const fieldKey = Object.keys(field)[0];
117
- const sortDirection = castTo<Record<string, unknown>>(field)[fieldKey];
137
+ const { path, sortDirection } = extractQueryIndexPathAndDirection(castTo(field));
118
138
  const isAscending = typeof sortDirection === 'number' ? sortDirection === 1 : !sortDirection;
119
139
 
120
- const path = fieldKey.split('.');
121
140
  const expression = this.compileIndexPath(context, path, 'createIndex');
122
141
  const formattedExpression = path.length > 1 ? `(${expression})` : expression;
123
142
  return `${formattedExpression} ${isAscending ? 'ASC' : 'DESC'}`;
@@ -148,11 +167,14 @@ export abstract class AbstractANSI99Dialect {
148
167
  continue;
149
168
  }
150
169
  const columnType = this.getColumnType(field);
151
- columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${columnType}`);
170
+ const isNotNullClause = SQLModelSchemaUtil.isColumnNotNull(context, field.name) ? ' NOT NULL' : '';
171
+ columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${columnType}${isNotNullClause}`);
152
172
  }
153
173
 
154
174
  for (const field of context.complexFields.values()) {
155
- columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${this.getComplexColumnType(field)}`);
175
+ const columnType = this.getComplexColumnType(field);
176
+ const isNotNullClause = SQLModelSchemaUtil.isColumnNotNull(context, field.name) ? ' NOT NULL' : '';
177
+ columnDefinitions.push(`${this.escapeIdentifier(field.name)} ${columnType}${isNotNullClause}`);
156
178
  }
157
179
 
158
180
  return `
package/src/schema.ts CHANGED
@@ -19,7 +19,7 @@ export class SQLModelSchemaUtil {
19
19
  throw new RuntimeError('Cannot store unregistered models', { category: 'data' });
20
20
  }
21
21
 
22
- const fields = Object.values(registryConfig.fields).map(field => ({ ...field }));
22
+ const fields = Object.values(registryConfig.fields);
23
23
 
24
24
  const hasModel = ModelRegistryIndex.has(modelClass);
25
25
  if (hasModel && registryConfig.discriminatedBase) {
@@ -29,7 +29,7 @@ export class SQLModelSchemaUtil {
29
29
  for (const field of TypedObject.values(subclassConfig.fields)) {
30
30
  if (!fieldMap.has(field.name)) {
31
31
  fieldMap.add(field.name);
32
- fields.push({ ...field, required: { active: false } });
32
+ fields.push(field);
33
33
  }
34
34
  }
35
35
  }
@@ -43,4 +43,19 @@ export class SQLModelSchemaUtil {
43
43
  this.SCHEMA_CACHE.set(modelClass, context);
44
44
  return context;
45
45
  }
46
+
47
+ static isColumnNotNull<T>(context: SchemaContext<T>, fieldName: string): boolean {
48
+ const schemaConfig = SchemaRegistryIndex.getOptional(context.cls)?.get();
49
+ const fieldConfig = schemaConfig?.fields[fieldName];
50
+ if (!fieldConfig || fieldConfig.required?.active === false || fieldConfig.accessor) {
51
+ return false;
52
+ }
53
+ if (ModelRegistryIndex.has(context.cls)) {
54
+ const modelConfig = ModelRegistryIndex.getConfig(context.cls);
55
+ if (modelConfig.transientFields?.includes(fieldName)) {
56
+ return false;
57
+ }
58
+ }
59
+ return true;
60
+ }
46
61
  }
@@ -1,14 +1,12 @@
1
1
  import assert from 'node:assert';
2
2
 
3
- import { Model, type ModelType } from '@travetto/model';
3
+ import { Model, type ModelType, TransientField } from '@travetto/model';
4
4
  import { Registry } from '@travetto/registry';
5
5
  import type { Class } from '@travetto/runtime';
6
- import { Schema } from '@travetto/schema';
6
+ import { DiscriminatorField, Required, Schema } from '@travetto/schema';
7
7
  import { BeforeAll, Suite, Test } from '@travetto/test';
8
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';
9
+ import type { AbstractANSI99Dialect } from '../../src/dialect.ts';
12
10
  import { SQLModelSchemaUtil } from '../../src/schema.ts';
13
11
  import type { TableContext } from '../../src/types.ts';
14
12
 
@@ -20,13 +18,41 @@ class ChildItem {
20
18
  createdDate: Date;
21
19
  }
22
20
 
23
- @Model()
21
+ @Model('dialect_gap_parent')
24
22
  class ParentModel {
25
23
  id: string;
26
24
  title: string;
27
25
  child: ChildItem;
28
26
  }
29
27
 
28
+ @Schema()
29
+ @Model('dialect_gap_simple')
30
+ class SimpleModel {
31
+ id: string;
32
+ @Required()
33
+ requiredField: string;
34
+ optionalField?: string;
35
+ @TransientField()
36
+ transientField: string;
37
+ }
38
+
39
+ @Schema()
40
+ @Model('dialect_gap_base_poly')
41
+ abstract class BasePolymorphic {
42
+ id: string;
43
+ @DiscriminatorField()
44
+ type: string;
45
+ @Required()
46
+ sharedRequired: string;
47
+ }
48
+
49
+ @Schema()
50
+ @Model('dialect_gap_sub_a')
51
+ class SubTypeA extends BasePolymorphic {
52
+ @Required()
53
+ subTypeAOnlyRequired: string;
54
+ }
55
+
30
56
  function getTableContext<T extends ModelType>(modelClass: Class<T>): TableContext<T> {
31
57
  return {
32
58
  tableName: modelClass.name.toLowerCase(),
@@ -34,124 +60,66 @@ function getTableContext<T extends ModelType>(modelClass: Class<T>): TableContex
34
60
  };
35
61
  }
36
62
 
37
- @Suite()
38
- export class SQLDialectGapsTest {
63
+ @Suite({ skip: true })
64
+ export abstract class BaseSQLDialectSuite {
65
+ abstract dialect: AbstractANSI99Dialect;
66
+
39
67
  @BeforeAll()
40
68
  async setup() {
41
69
  await Registry.init();
70
+ SQLModelSchemaUtil.SCHEMA_CACHE.clear();
42
71
  }
43
72
 
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();
73
+ @Test('Verify DDL column nullability')
74
+ async testDDLNullability() {
75
+ const dialect = this.dialect;
76
+ const quote = dialect.escapeIdentifier('').substring(0, 1) || '"';
50
77
 
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)");
78
+ const simpleContext = getTableContext(SimpleModel);
79
+ const simpleSQL = dialect.getCreateTableSQL(simpleContext);
54
80
 
55
- const mysqlNameResolved = mysqlDialect.resolvePath(tableContext, ['child', 'name'], 'read');
56
- assert(mysqlNameResolved.sqlPath === "(CAST(`child`->>'$.name' AS CHAR(255)) COLLATE utf8mb4_bin)");
81
+ const requiredLine = simpleSQL.split('\n').find(line => line.includes(`${quote}requiredField${quote}`));
82
+ assert(requiredLine?.includes('NOT NULL'));
57
83
 
58
- // Verify Postgres path resolution matches index creation
59
- const postgresAgeResolved = postgresDialect.resolvePath(tableContext, ['child', 'age'], 'read');
60
- assert(postgresAgeResolved.sqlPath === '((("child"->>\'age\')))::NUMERIC');
84
+ const optionalLine = simpleSQL.split('\n').find(line => line.includes(`${quote}optionalField${quote}`));
85
+ if (optionalLine) {
86
+ assert(!optionalLine.includes('NOT NULL'));
87
+ }
61
88
 
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
- }
89
+ const transientLine = simpleSQL.split('\n').find(line => line.includes(`${quote}transientField${quote}`));
90
+ if (transientLine) {
91
+ assert(!transientLine.includes('NOT NULL'));
92
+ }
66
93
 
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
- }
94
+ const polyContext = getTableContext(BasePolymorphic);
95
+ const polySQL = dialect.getCreateTableSQL(polyContext);
78
96
 
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
- }
97
+ const sharedRequiredLine = polySQL.split('\n').find(line => line.includes(`${quote}sharedRequired${quote}`));
98
+ assert(sharedRequiredLine?.includes('NOT NULL'));
100
99
 
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);
100
+ const subTypeLine = polySQL.split('\n').find(line => line.includes(`${quote}subTypeAOnlyRequired${quote}`));
101
+ if (subTypeLine) {
102
+ assert(!subTypeLine.includes('NOT NULL'));
103
+ }
135
104
  }
136
105
 
137
106
  @Test()
138
107
  async testFormatJsonPathEscaping() {
139
- const mysqlDialect = new MysqlDialect();
140
-
141
- const simpleJsonPath = mysqlDialect.formatJsonPath(['child', 'age']);
108
+ const simpleJsonPath = this.dialect.formatJsonPath(['child', 'age']);
142
109
  assert(simpleJsonPath === 'child.age');
143
110
 
144
- const complexJsonPath = mysqlDialect.formatJsonPath(['child', 'first name']);
111
+ const complexJsonPath = this.dialect.formatJsonPath(['child', 'first name']);
145
112
  assert(complexJsonPath === 'child."first name"');
146
113
  }
147
114
 
148
115
  @Test()
149
116
  async testPartialUpdateSetsWithoutRegex() {
150
117
  const tableContext = getTableContext(ParentModel);
151
- const sqliteDialect = new SqliteDialect();
118
+ const quote = this.dialect.escapeIdentifier('').substring(0, 1) || '"';
119
+ const placeholder = this.dialect.getPlaceholder(1);
152
120
 
153
- const { sets, values } = sqliteDialect.compilePartialUpdate(tableContext, { title: 'Updated Title' });
154
- assert.deepStrictEqual(sets, ['"title" = ?']);
121
+ const { sets, values } = this.dialect.compilePartialUpdate(tableContext, { title: 'Updated Title' });
122
+ assert.deepStrictEqual(sets, [`${quote}title${quote} = ${placeholder}`]);
155
123
  assert.deepStrictEqual(values, ['Updated Title']);
156
124
  }
157
125
  }