@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 +1 -0
- package/package.json +7 -7
- package/src/dialect.ts +29 -7
- package/src/schema.ts +17 -2
- package/support/test/dialect.ts +67 -99
package/__index__.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-sql",
|
|
3
|
-
"version": "8.0.0-alpha.
|
|
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.
|
|
31
|
+
"@travetto/config": "^8.0.0-alpha.25",
|
|
32
32
|
"@travetto/context": "^8.0.0-alpha.22",
|
|
33
|
-
"@travetto/model": "^8.0.0-alpha.
|
|
34
|
-
"@travetto/model-indexed": "^8.0.0-alpha.
|
|
35
|
-
"@travetto/model-query": "^8.0.0-alpha.
|
|
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.
|
|
39
|
-
"@travetto/test": "^8.0.0-alpha.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
}
|
package/support/test/dialect.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const
|
|
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
|
-
|
|
52
|
-
const
|
|
53
|
-
assert(mysqlAgeResolved.sqlPath === "CAST(`child`->>'$.age' AS DECIMAL)");
|
|
78
|
+
const simpleContext = getTableContext(SimpleModel);
|
|
79
|
+
const simpleSQL = dialect.getCreateTableSQL(simpleContext);
|
|
54
80
|
|
|
55
|
-
const
|
|
56
|
-
assert(
|
|
81
|
+
const requiredLine = simpleSQL.split('\n').find(line => line.includes(`${quote}requiredField${quote}`));
|
|
82
|
+
assert(requiredLine?.includes('NOT NULL'));
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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 =
|
|
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
|
|
118
|
+
const quote = this.dialect.escapeIdentifier('').substring(0, 1) || '"';
|
|
119
|
+
const placeholder = this.dialect.getPlaceholder(1);
|
|
152
120
|
|
|
153
|
-
const { sets, values } =
|
|
154
|
-
assert.deepStrictEqual(sets, [
|
|
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
|
}
|