@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.
package/src/types.ts CHANGED
@@ -1,11 +1,25 @@
1
- import { type Class } from '@travetto/runtime';
1
+ import type { ModelType } from '@travetto/model';
2
+ import type { Class } from '@travetto/runtime';
3
+ import type { SchemaFieldConfig } from '@travetto/schema';
2
4
 
3
- export const TableSymbol = Symbol.for('@travetto/model-sql:table');
5
+ export type JSONSqlPathMode = 'orderBy' | 'createIndex' | 'read';
4
6
 
5
- export type VisitStack = {
6
- [TableSymbol]?: string;
7
- array?: boolean;
8
- type: Class;
9
- name: string;
10
- index?: number;
11
- };
7
+ export interface SchemaContext<T> {
8
+ cls: Class<T>;
9
+ simpleFields: Map<string, SchemaFieldConfig>;
10
+ complexFields: Map<string, SchemaFieldConfig>;
11
+ allFields: SchemaFieldConfig[];
12
+ }
13
+
14
+ export interface TableContext<T extends ModelType = ModelType> extends SchemaContext<T> {
15
+ tableName: string;
16
+ database?: string;
17
+ }
18
+
19
+ export interface ResolvedPathContext {
20
+ sqlPath: string;
21
+ leafField?: SchemaFieldConfig;
22
+ arrayField?: SchemaFieldConfig;
23
+ arrayPath?: string[];
24
+ subPath?: string[];
25
+ }
@@ -0,0 +1,125 @@
1
+ import assert from 'node:assert';
2
+
3
+ import { Model, type ModelType, TransientField } from '@travetto/model';
4
+ import { Registry } from '@travetto/registry';
5
+ import type { Class } from '@travetto/runtime';
6
+ import { DiscriminatorField, Required, Schema } from '@travetto/schema';
7
+ import { BeforeAll, Suite, Test } from '@travetto/test';
8
+
9
+ import type { AbstractANSI99Dialect } from '../../src/dialect.ts';
10
+ import { SQLModelSchemaUtil } from '../../src/schema.ts';
11
+ import type { TableContext } from '../../src/types.ts';
12
+
13
+ @Schema()
14
+ class ChildItem {
15
+ name: string;
16
+ age: number;
17
+ active: boolean;
18
+ createdDate: Date;
19
+ }
20
+
21
+ @Model('dialect_gap_parent')
22
+ class ParentModel {
23
+ id: string;
24
+ title: string;
25
+ child: ChildItem;
26
+ }
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
+
56
+ function getTableContext<T extends ModelType>(modelClass: Class<T>): TableContext<T> {
57
+ return {
58
+ tableName: modelClass.name.toLowerCase(),
59
+ ...SQLModelSchemaUtil.getSchemaContext(modelClass)
60
+ };
61
+ }
62
+
63
+ @Suite({ skip: true })
64
+ export abstract class BaseSQLDialectSuite {
65
+ abstract dialect: AbstractANSI99Dialect;
66
+
67
+ @BeforeAll()
68
+ async setup() {
69
+ await Registry.init();
70
+ SQLModelSchemaUtil.SCHEMA_CACHE.clear();
71
+ }
72
+
73
+ @Test('Verify DDL column nullability')
74
+ async testDDLNullability() {
75
+ const dialect = this.dialect;
76
+ const quote = dialect.escapeIdentifier('').substring(0, 1) || '"';
77
+
78
+ const simpleContext = getTableContext(SimpleModel);
79
+ const simpleSQL = dialect.getCreateTableSQL(simpleContext);
80
+
81
+ const requiredLine = simpleSQL.split('\n').find(line => line.includes(`${quote}requiredField${quote}`));
82
+ assert(requiredLine?.includes('NOT NULL'));
83
+
84
+ const optionalLine = simpleSQL.split('\n').find(line => line.includes(`${quote}optionalField${quote}`));
85
+ if (optionalLine) {
86
+ assert(!optionalLine.includes('NOT NULL'));
87
+ }
88
+
89
+ const transientLine = simpleSQL.split('\n').find(line => line.includes(`${quote}transientField${quote}`));
90
+ if (transientLine) {
91
+ assert(!transientLine.includes('NOT NULL'));
92
+ }
93
+
94
+ const polyContext = getTableContext(BasePolymorphic);
95
+ const polySQL = dialect.getCreateTableSQL(polyContext);
96
+
97
+ const sharedRequiredLine = polySQL.split('\n').find(line => line.includes(`${quote}sharedRequired${quote}`));
98
+ assert(sharedRequiredLine?.includes('NOT NULL'));
99
+
100
+ const subTypeLine = polySQL.split('\n').find(line => line.includes(`${quote}subTypeAOnlyRequired${quote}`));
101
+ if (subTypeLine) {
102
+ assert(!subTypeLine.includes('NOT NULL'));
103
+ }
104
+ }
105
+
106
+ @Test()
107
+ async testFormatJsonPathEscaping() {
108
+ const simpleJsonPath = this.dialect.formatJsonPath(['child', 'age']);
109
+ assert(simpleJsonPath === 'child.age');
110
+
111
+ const complexJsonPath = this.dialect.formatJsonPath(['child', 'first name']);
112
+ assert(complexJsonPath === 'child."first name"');
113
+ }
114
+
115
+ @Test()
116
+ async testPartialUpdateSetsWithoutRegex() {
117
+ const tableContext = getTableContext(ParentModel);
118
+ const quote = this.dialect.escapeIdentifier('').substring(0, 1) || '"';
119
+ const placeholder = this.dialect.getPlaceholder(1);
120
+
121
+ const { sets, values } = this.dialect.compilePartialUpdate(tableContext, { title: 'Updated Title' });
122
+ assert.deepStrictEqual(sets, [`${quote}title${quote} = ${placeholder}`]);
123
+ assert.deepStrictEqual(values, ['Updated Title']);
124
+ }
125
+ }
@@ -1,103 +1,146 @@
1
1
  import assert from 'node:assert';
2
2
 
3
+ import { Model, type ModelType } from '@travetto/model';
4
+ import { Registry } from '@travetto/registry';
5
+ import type { Class } from '@travetto/runtime';
3
6
  import { Schema, type SchemaFieldConfig } from '@travetto/schema';
4
- import { Suite, Test } from '@travetto/test';
5
- import { castTo } from '@travetto/runtime';
6
- import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
7
+ import { BeforeAll, Suite, Test } from '@travetto/test';
7
8
 
8
- import type { VisitStack } from '../../src/types.ts';
9
- import type { SQLModelService } from '../../src/service.ts';
9
+ import { AbstractANSI99Dialect } from '../../src/dialect.ts';
10
+ import { SQLModelSchemaUtil } from '../../src/schema.ts';
11
+ import type { ResolvedPathContext, TableContext } from '../../src/types.ts';
10
12
 
11
- @Schema()
13
+ @Model()
12
14
  class User {
13
15
  id: string;
14
16
  name: string;
15
17
  }
16
18
 
17
19
  @Schema()
18
- class WhereTypeAB {
19
- c: number;
20
+ class Nested {
21
+ value: string;
20
22
  }
21
23
 
22
- @Schema()
23
- class WhereTypeA {
24
- d: number;
25
- b: WhereTypeAB;
24
+ @Model()
25
+ class WhereType {
26
+ id: string;
27
+ name: string;
28
+ age: number;
29
+ nestedList: Nested[];
30
+ nestedObj: Nested;
26
31
  }
27
32
 
28
- @Schema()
29
- class WhereTypeD {
30
- e: boolean;
31
- }
33
+ // @ts-expect-error
34
+ class MockDialect extends AbstractANSI99Dialect {
35
+ complexColumnType = 'TEXT';
32
36
 
33
- @Schema()
34
- class WhereTypeG {
35
- z: string[];
37
+ getComplexColumnType(field: SchemaFieldConfig): string {
38
+ return 'TEXT';
39
+ }
40
+
41
+ getColumnType() {
42
+ return 'TEXT';
43
+ }
44
+
45
+ compileJsonIndexPath(columnName: string, jsonPath: string[]): string {
46
+ return `${columnName}->'${jsonPath.join("->'")}'`;
47
+ }
48
+
49
+ override getPlaceholder(index: number) {
50
+ return `$$${index}`;
51
+ }
52
+
53
+ compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]) {
54
+ return { sql: `${context.sqlPath} ALL ${identifier}`, formatted: value };
55
+ }
56
+
57
+ compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown) {
58
+ return { sql: `${context.sqlPath} EQUALS ${identifier}`, formatted: values };
59
+ }
60
+
61
+ compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]) {
62
+ return { sql: `${context.sqlPath} ANY ${identifier}`, formatted: values };
63
+ }
64
+
65
+ compileArrayExists(context: ResolvedPathContext, identifier?: string) {
66
+ return { sql: `${context.sqlPath} IS NOT NULL`, formatted: undefined };
67
+ }
68
+
69
+ compileArrayRegex(context: ResolvedPathContext, identifier: string, value: RegExp | string) {
70
+ return { sql: `${context.sqlPath} REGEX ${identifier}`, formatted: value };
71
+ }
72
+
73
+ getRegexOperator(caseInsensitive: boolean) {
74
+ return caseInsensitive ? '~*' : '~';
75
+ }
76
+
77
+ formatRegex(source: string) {
78
+ return source;
79
+ }
80
+
81
+ castColumn(sqlPath: string, type: unknown) {
82
+ if (type === Number) {
83
+ return `CAST(${sqlPath} AS NUMERIC)`;
84
+ }
85
+ return sqlPath;
86
+ }
87
+
88
+ async getTableExists(): Promise<boolean> {
89
+ return true;
90
+ }
91
+
92
+ async getExistingColumns(): Promise<Map<string, string>> {
93
+ return new Map();
94
+ }
95
+
96
+ async getExistingIndexes(): Promise<Map<string, string>> {
97
+ return new Map();
98
+ }
99
+
100
+ async dropIndex(): Promise<void> {}
36
101
  }
37
102
 
38
- @Schema()
39
- class WhereType {
40
- a: WhereTypeA[];
41
- d: WhereTypeD;
42
- g: WhereTypeG;
43
- name: number;
44
- age: number;
103
+ const mockDialect = new MockDialect();
104
+
105
+ function getMockContext<T extends ModelType>(modelClass: Class<T>): TableContext<T> {
106
+ return {
107
+ tableName: modelClass.name.toLowerCase(),
108
+ ...SQLModelSchemaUtil.getSchemaContext(modelClass)
109
+ };
45
110
  }
46
111
 
47
112
  @Suite()
48
- export abstract class BaseSQLTest extends BaseModelSuite<SQLModelService> {
49
-
50
- get dialect() {
51
- return this.service.then(s => s.client);
113
+ export class SQLQueryCompilerTest {
114
+ @BeforeAll()
115
+ async setup() {
116
+ await Registry.init();
52
117
  }
53
118
 
54
119
  @Test()
55
- async validateQuery() {
56
- const qry = {
57
- $and: [
58
- { a: { b: { c: 5 } } },
59
- { d: { e: true } },
60
- {
61
- $or: [{ name: 5 }, { age: 10 }]
62
- },
63
- { g: { z: { $in: ['a', 'b', 'c'] } } },
64
- { a: { d: { $gt: 20 } } }
65
- ]
66
- };
67
-
68
- const dct = await this.dialect;
69
- dct.resolveName = (stack: VisitStack[]) => {
70
- const field: SchemaFieldConfig = castTo(stack.at(-1));
71
- const parent: SchemaFieldConfig = castTo(stack.at(-2));
72
- return `${field.class ? field.class.name.toString() : parent.name.toString()}.${field.name.toString()}`;
73
- };
74
-
75
- const qryStr = dct.getWhereGroupingSQL(WhereType, qry);
76
- assert(qryStr === "(WhereTypeAB.c = 5 AND WhereTypeD.e = TRUE AND (WhereType.name = 5 OR WhereType.age = 10) AND z.z IN ('a','b','c') AND WhereTypeA.d > 20)");
120
+ async testCompileSimple() {
121
+ const context = getMockContext(User);
122
+ const { whereSQL, parameters } = mockDialect.compileWhere(context, { name: 'john' });
123
+ assert(whereSQL === '"name" = $$1');
124
+ assert.deepStrictEqual(parameters, ['john']);
77
125
  }
78
126
 
79
127
  @Test()
80
- async testRegEx() {
81
- const dct = await this.dialect;
82
- dct.resolveName = (stack: VisitStack[]) => {
83
- const field: SchemaFieldConfig = castTo(stack.at(-1));
84
- return `${field.class?.name}.${field.name.toString()}`;
85
- };
86
-
87
- const out = dct.getWhereGroupingSQL(User, {
88
- name: {
89
- $regex: /google.$/
90
- }
128
+ async testCompileOperators() {
129
+ const context = getMockContext(WhereType);
130
+ const { whereSQL, parameters } = mockDialect.compileWhere(context, {
131
+ age: { $gt: 18, $lte: 100 }
91
132
  });
133
+ assert(whereSQL === '("age" > $$1 AND "age" <= $$2)');
134
+ assert.deepStrictEqual(parameters, [18, 100]);
135
+ }
92
136
 
93
- assert(out === `User.name ${dct.SQL_OPS.$regex} 'google.$'`);
94
-
95
- const outBoundary = dct.getWhereGroupingSQL(User, {
96
- name: {
97
- $regex: /\bgoogle\b/
98
- }
137
+ @Test()
138
+ async testCompileNested() {
139
+ const context = getMockContext(WhereType);
140
+ const { whereSQL, parameters } = mockDialect.compileWhere(context, {
141
+ nestedObj: { value: 'test' }
99
142
  });
100
-
101
- assert(outBoundary === `User.name ${dct.SQL_OPS.$regex} '${dct.regexWordBoundary}google${dct.regexWordBoundary}'`);
143
+ assert(whereSQL === '"nestedObj"->\'value\' = $$1');
144
+ assert.deepStrictEqual(parameters, ['test']);
102
145
  }
103
- }
146
+ }
package/src/config.ts DELETED
@@ -1,45 +0,0 @@
1
- import { Config } from '@travetto/config';
2
- import { asFull, Runtime } from '@travetto/runtime';
3
-
4
- /**
5
- * SQL Model Config
6
- */
7
- @Config('model.sql')
8
- export class SQLModelConfig<T extends {} = {}> {
9
- /**
10
- * Host to connect to
11
- */
12
- host = '127.0.0.1';
13
- /**
14
- * Default port
15
- */
16
- port = 0;
17
- /**
18
- * Username
19
- */
20
- user = Runtime.production ? '' : 'travetto';
21
- /**
22
- * Password
23
- */
24
- password = Runtime.production ? '' : 'travetto';
25
- /**
26
- * Table prefix
27
- */
28
- namespace = '';
29
- /**
30
- * Database name
31
- */
32
- database = 'app';
33
- /**
34
- * Allow storage modification at runtime
35
- */
36
- modifyStorage?: boolean;
37
- /**
38
- * Db version
39
- */
40
- version = '';
41
- /**
42
- * Raw client options
43
- */
44
- options: T = asFull({});
45
- }
@@ -1,188 +0,0 @@
1
- import { castTo, Util } from '@travetto/runtime';
2
- import { type AsyncContext, AsyncContextValue } from '@travetto/context';
3
-
4
- export type TransactionType = 'required' | 'isolated' | 'force';
5
-
6
- /**
7
- * Connection is a common enough pattern, that it can
8
- * be separated out to allow for differences in connection
9
- * vs querying.
10
- */
11
- export abstract class Connection<C = unknown> {
12
-
13
- isolatedTransactions = true;
14
- nestedTransactions = true;
15
-
16
- transactionDialect = {
17
- begin: 'BEGIN;',
18
- beginNested: 'SAVEPOINT $1;',
19
- isolate: 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED;',
20
- rollback: 'ROLLBACK;',
21
- rollbackNested: 'ROLLBACK TO $1;',
22
- commit: 'COMMIT;',
23
- commitNested: 'RELEASE SAVEPOINT $1;'
24
- };
25
-
26
- readonly context: AsyncContext;
27
-
28
- #active = new AsyncContextValue<C>(this);
29
- #activeTx = new AsyncContextValue<boolean>(this);
30
-
31
- constructor(context: AsyncContext) {
32
- this.context = context;
33
- }
34
-
35
- /**
36
- * Get active connection
37
- */
38
- get active(): C | undefined {
39
- return this.#active.get();
40
- }
41
-
42
- /**
43
- * Get active tx state
44
- */
45
- get activeTx(): boolean {
46
- return !!this.#activeTx.get();
47
- }
48
-
49
- /**
50
- * Initialize connection source
51
- */
52
- init?(): Promise<void> | void;
53
-
54
- /**
55
- * Executes a query on the connection
56
- * @param rawConnection
57
- * @param query
58
- */
59
- abstract execute<T = unknown>(rawConnection: C, query: string, values?: unknown[]): Promise<{ records: T[], count: number }>;
60
-
61
- /**
62
- * Acquire new connection
63
- */
64
- abstract acquire(): Promise<C>;
65
-
66
- /**
67
- * Release provided connection
68
- */
69
- abstract release(rawConnection: C): void;
70
-
71
- /**
72
- * Run operation with active connection
73
- * @param context
74
- * @param operation
75
- * @param args
76
- */
77
- async runWithActive<R>(operation: () => Promise<R>): Promise<R> {
78
- if (this.active) {
79
- return operation();
80
- }
81
-
82
- return this.context.run(async () => {
83
- let connection;
84
- try {
85
- connection = await this.acquire();
86
- this.#active.set(connection);
87
- return await operation();
88
- } finally {
89
- if (connection) {
90
- this.release(connection);
91
- }
92
- }
93
- });
94
- }
95
-
96
- /**
97
- * Iterate with active connection
98
- * @param context
99
- * @param operation
100
- * @param args
101
- */
102
- async * iterateWithActive<R>(operation: () => AsyncIterable<R>): AsyncIterable<R> {
103
- if (this.active) {
104
- yield* operation();
105
- }
106
-
107
- const self = castTo<Connection>(this);
108
- yield* this.context.iterate(async function* () {
109
- try {
110
- self.#active.set(await self.acquire());
111
- yield* operation();
112
- } finally {
113
- if (self.active) {
114
- self.release(self.active);
115
- }
116
- }
117
- });
118
- }
119
-
120
- /**
121
- * Run a function within a valid sql transaction. Relies on @travetto/context.
122
- */
123
- async runWithTransaction<R>(mode: TransactionType, operation: () => Promise<R>): Promise<R> {
124
- if (this.activeTx) {
125
- if (mode === 'isolated' || mode === 'force') {
126
- const txId = mode === 'isolated' ? `tx${Util.uuid()}` : undefined;
127
- try {
128
- await this.startTx(this.active!, txId);
129
- const result = await operation();
130
- await this.commitTx(this.active!, txId);
131
- return result;
132
- } catch (error) {
133
- try { await this.rollbackTx(this.active!, txId); } catch { }
134
- throw error;
135
- }
136
- } else {
137
- return await operation();
138
- }
139
- } else {
140
- return this.runWithActive(() => {
141
- this.#activeTx.set(true);
142
- return this.runWithTransaction('force', operation);
143
- });
144
- }
145
- }
146
-
147
- /**
148
- * Start a transaction
149
- */
150
- async startTx(connection: C, transactionId?: string): Promise<void> {
151
- if (transactionId) {
152
- if (this.nestedTransactions) {
153
- await this.execute(connection, this.transactionDialect.beginNested, [transactionId]);
154
- }
155
- } else {
156
- if (this.isolatedTransactions) {
157
- await this.execute(connection, this.transactionDialect.isolate);
158
- }
159
- await this.execute(connection, this.transactionDialect.begin);
160
- }
161
- }
162
-
163
- /**
164
- * Commit active transaction
165
- */
166
- async commitTx(connection: C, transactionId?: string): Promise<void> {
167
- if (transactionId) {
168
- if (this.nestedTransactions) {
169
- await this.execute(connection, this.transactionDialect.commitNested, [transactionId]);
170
- }
171
- } else {
172
- await this.execute(connection, this.transactionDialect.commit);
173
- }
174
- }
175
-
176
- /**
177
- * Rollback active transaction
178
- */
179
- async rollbackTx(connection: C, transactionId?: string): Promise<void> {
180
- if (transactionId) {
181
- if (this.isolatedTransactions) {
182
- await this.execute(connection, this.transactionDialect.rollbackNested, [transactionId]);
183
- }
184
- } else {
185
- await this.execute(connection, this.transactionDialect.rollback);
186
- }
187
- }
188
- }
@@ -1,54 +0,0 @@
1
- import type { AsyncIterableMethodDescriptor, AsyncMethodDescriptor } from '@travetto/runtime';
2
- import type { Connection, TransactionType } from './base.ts';
3
-
4
- /**
5
- * Indicating something is aware of connections
6
- */
7
- export interface ConnectionAware<C = unknown> {
8
- connection: Connection<C>;
9
- }
10
-
11
- /**
12
- * Decorator to ensure a method runs with a valid connection
13
- * @kind decorator
14
- */
15
- export function Connected() {
16
- return function <T extends { connection?: Connection }>(
17
- target: T, property: string, descriptor: AsyncMethodDescriptor<T>
18
- ): void {
19
- const handle = descriptor.value!;
20
- descriptor.value = function (...args: unknown[]): ReturnType<typeof handle> {
21
- return this.connection!.runWithActive(() => handle.call(this, ...args));
22
- };
23
- };
24
- }
25
-
26
- /**
27
- * Decorator to ensure a method runs with a valid connection
28
- * @kind decorator
29
- */
30
- export function ConnectedIterator() {
31
- return function <T extends { connection?: Connection }>(
32
- target: T, property: string, descriptor: AsyncIterableMethodDescriptor<T>
33
- ): void {
34
- const handle = descriptor.value!;
35
- descriptor.value = async function* (...args: unknown[]): ReturnType<typeof handle> {
36
- yield* this.connection!.iterateWithActive(() => handle.call(this, ...args));
37
- };
38
- };
39
- }
40
-
41
- /**
42
- * Decorator to ensure a method runs with a valid transaction
43
- * @kind decorator
44
- */
45
- export function Transactional(mode: TransactionType = 'required') {
46
- return function <T extends { connection?: Connection }>(
47
- target: unknown, property: string, descriptor: AsyncMethodDescriptor<T>
48
- ): void {
49
- const handle = descriptor.value!;
50
- descriptor.value = function (...args: unknown[]): ReturnType<typeof handle> {
51
- return this.connection!.runWithTransaction(mode, () => handle.call(this, ...args));
52
- };
53
- };
54
- }