@travetto/model-mysql 8.0.0-alpha.9 → 8.0.0
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/README.md +30 -28
- package/__index__.ts +3 -2
- package/package.json +19 -19
- package/src/config.ts +50 -0
- package/src/connection.ts +81 -52
- package/src/dialect.ts +209 -157
- package/src/service.ts +28 -0
- package/support/service.mysql.ts +6 -3
package/README.md
CHANGED
|
@@ -13,78 +13,80 @@ npm install @travetto/model-mysql
|
|
|
13
13
|
yarn add @travetto/model-mysql
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
This module provides a [MySQL](https://www.mysql.com/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module.
|
|
16
|
+
This module provides a [MySQL](https://www.mysql.com/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module. This source allows the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module to read, write and query against [SQL](https://en.wikipedia.org/wiki/SQL) databases. In development mode, the [MysqlModelService](https://github.com/travetto/travetto/tree/main/module/model-mysql/src/service.ts#L12) will also modify the database schema in real time to minimize impact to development.
|
|
17
17
|
|
|
18
|
-
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database.
|
|
18
|
+
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database. Every table generated maps to a model, with simple fields mapped as individual columns and complex fields/arrays mapped as native `JSON` columns.
|
|
19
19
|
|
|
20
20
|
Supported features:
|
|
21
|
-
* [
|
|
22
|
-
* [
|
|
21
|
+
* [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L60)
|
|
22
|
+
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L10)
|
|
23
|
+
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/types/expiry.ts#L10)
|
|
24
|
+
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
|
|
23
25
|
* [Query Crud](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/crud.ts#L11)
|
|
24
26
|
* [Facet](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/facet.ts#L14)
|
|
25
|
-
* [Query](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/query.ts#L10)
|
|
26
27
|
* [Suggest](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/suggest.ts#L12)
|
|
28
|
+
* [Query](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/query.ts#L10)
|
|
27
29
|
|
|
28
30
|
Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
|
|
29
31
|
|
|
30
32
|
**Code: Wiring up a custom Model Source**
|
|
31
33
|
```typescript
|
|
32
|
-
import type { AsyncContext } from '@travetto/context';
|
|
33
34
|
import { InjectableFactory } from '@travetto/di';
|
|
34
|
-
|
|
35
|
-
import { SQLModelService, type SQLModelConfig } from '@travetto/model-sql';
|
|
36
|
-
import { MySQLDialect } from '@travetto/model-mysql';
|
|
35
|
+
import { type MysqlConnection, MysqlModelService } from '@travetto/model-mysql';
|
|
37
36
|
|
|
38
37
|
export class Init {
|
|
39
38
|
@InjectableFactory({ primary: true })
|
|
40
|
-
static getModelService(
|
|
41
|
-
return new
|
|
39
|
+
static getModelService(connection: MysqlConnection) {
|
|
40
|
+
return new MysqlModelService(connection);
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
```
|
|
45
44
|
|
|
46
|
-
where the [
|
|
45
|
+
where the [MysqlModelConfig](https://github.com/travetto/travetto/tree/main/module/model-mysql/src/config.ts#L10) is defined by:
|
|
47
46
|
|
|
48
|
-
**Code: Structure of
|
|
47
|
+
**Code: Structure of MysqlModelConfig**
|
|
49
48
|
```typescript
|
|
50
|
-
@Config('model.
|
|
51
|
-
export class
|
|
49
|
+
@Config('model.mysql')
|
|
50
|
+
export class MysqlModelConfig {
|
|
52
51
|
/**
|
|
53
|
-
*
|
|
52
|
+
* Database host to connect to
|
|
54
53
|
*/
|
|
55
54
|
host = '127.0.0.1';
|
|
55
|
+
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* Database port to connect to
|
|
58
58
|
*/
|
|
59
59
|
port = 0;
|
|
60
|
+
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
+
* Database username
|
|
62
63
|
*/
|
|
63
64
|
user = Runtime.production ? '' : 'travetto';
|
|
65
|
+
|
|
64
66
|
/**
|
|
65
|
-
*
|
|
67
|
+
* Database password
|
|
66
68
|
*/
|
|
67
69
|
password = Runtime.production ? '' : 'travetto';
|
|
70
|
+
|
|
68
71
|
/**
|
|
69
|
-
*
|
|
72
|
+
* Namespace/schema prefix for table names
|
|
70
73
|
*/
|
|
71
74
|
namespace = '';
|
|
75
|
+
|
|
72
76
|
/**
|
|
73
77
|
* Database name
|
|
74
78
|
*/
|
|
75
79
|
database = 'app';
|
|
80
|
+
|
|
76
81
|
/**
|
|
77
|
-
* Allow storage
|
|
78
|
-
*/
|
|
79
|
-
modifyStorage?: boolean;
|
|
80
|
-
/**
|
|
81
|
-
* Db version
|
|
82
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
82
83
|
*/
|
|
83
|
-
|
|
84
|
+
modifyStorage = !Runtime.production;
|
|
85
|
+
|
|
84
86
|
/**
|
|
85
|
-
*
|
|
87
|
+
* Extended client options
|
|
86
88
|
*/
|
|
87
|
-
options
|
|
89
|
+
options?: PoolOptions;
|
|
88
90
|
}
|
|
89
91
|
```
|
|
90
92
|
|
package/__index__.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export * from './src/
|
|
2
|
-
export * from './src/connection.ts';
|
|
1
|
+
export * from './src/config.ts';
|
|
2
|
+
export * from './src/connection.ts';
|
|
3
|
+
export * from './src/service.ts';
|
package/package.json
CHANGED
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-mysql",
|
|
3
|
-
"version": "8.0.0
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "8.0.0",
|
|
5
4
|
"description": "MySQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
|
|
6
5
|
"keywords": [
|
|
7
|
-
"sql",
|
|
8
6
|
"data-modeling",
|
|
9
|
-
"real-time",
|
|
10
7
|
"model",
|
|
8
|
+
"real-time",
|
|
9
|
+
"sql",
|
|
11
10
|
"travetto",
|
|
12
11
|
"typescript"
|
|
13
12
|
],
|
|
14
13
|
"homepage": "https://travetto.io",
|
|
15
14
|
"license": "MIT",
|
|
16
15
|
"author": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
16
|
+
"name": "Travetto Framework",
|
|
17
|
+
"email": "travetto.framework@gmail.com"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"url": "git+https://github.com/travetto/travetto.git",
|
|
21
|
+
"directory": "module/model-mysql"
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
21
24
|
"__index__.ts",
|
|
22
25
|
"src",
|
|
23
26
|
"support"
|
|
24
27
|
],
|
|
28
|
+
"type": "module",
|
|
25
29
|
"main": "__index__.ts",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"directory": "module/model-mysql"
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
29
32
|
},
|
|
30
33
|
"dependencies": {
|
|
31
|
-
"@travetto/config": "^8.0.0
|
|
32
|
-
"@travetto/context": "^8.0.0
|
|
33
|
-
"@travetto/model": "^8.0.0
|
|
34
|
-
"@travetto/model-query": "^8.0.0
|
|
35
|
-
"@travetto/model-sql": "^8.0.0
|
|
36
|
-
"mysql2": "^3.
|
|
34
|
+
"@travetto/config": "^8.0.0",
|
|
35
|
+
"@travetto/context": "^8.0.0",
|
|
36
|
+
"@travetto/model": "^8.0.0",
|
|
37
|
+
"@travetto/model-query": "^8.0.0",
|
|
38
|
+
"@travetto/model-sql": "^8.0.0",
|
|
39
|
+
"mysql2": "^3.23.4"
|
|
37
40
|
},
|
|
38
41
|
"peerDependencies": {
|
|
39
|
-
"@travetto/cli": "^8.0.0
|
|
42
|
+
"@travetto/cli": "^8.0.0"
|
|
40
43
|
},
|
|
41
44
|
"peerDependenciesMeta": {
|
|
42
45
|
"@travetto/cli": {
|
|
@@ -45,8 +48,5 @@
|
|
|
45
48
|
},
|
|
46
49
|
"travetto": {
|
|
47
50
|
"displayName": "MySQL Model Service"
|
|
48
|
-
},
|
|
49
|
-
"publishConfig": {
|
|
50
|
-
"access": "public"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PoolOptions } from 'mysql2';
|
|
2
|
+
|
|
3
|
+
import { Config } from '@travetto/config';
|
|
4
|
+
import { Runtime } from '@travetto/runtime';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MySQL Model Configuration
|
|
8
|
+
*/
|
|
9
|
+
@Config('model.mysql')
|
|
10
|
+
export class MysqlModelConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Database host to connect to
|
|
13
|
+
*/
|
|
14
|
+
host = '127.0.0.1';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Database port to connect to
|
|
18
|
+
*/
|
|
19
|
+
port = 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Database username
|
|
23
|
+
*/
|
|
24
|
+
user = Runtime.production ? '' : 'travetto';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Database password
|
|
28
|
+
*/
|
|
29
|
+
password = Runtime.production ? '' : 'travetto';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Namespace/schema prefix for table names
|
|
33
|
+
*/
|
|
34
|
+
namespace = '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Database name
|
|
38
|
+
*/
|
|
39
|
+
database = 'app';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
43
|
+
*/
|
|
44
|
+
modifyStorage = !Runtime.production;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extended client options
|
|
48
|
+
*/
|
|
49
|
+
options?: PoolOptions;
|
|
50
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -1,52 +1,68 @@
|
|
|
1
1
|
import { createPool } from 'mysql2';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OkPacket, Pool, PoolConnection, ResultSetHeader, TypeCastField } from 'mysql2/promise';
|
|
3
3
|
|
|
4
|
-
import { castTo, JSONUtil, ShutdownManager } from '@travetto/runtime';
|
|
5
4
|
import type { AsyncContext } from '@travetto/context';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { Injectable } from '@travetto/di';
|
|
6
|
+
import { ExistsError, type ModelType, UniqueError } from '@travetto/model';
|
|
7
|
+
import { SQLConnection, type TableContext } from '@travetto/model-sql';
|
|
8
|
+
import { type Class, castTo, JSONUtil, ShutdownManager } from '@travetto/runtime';
|
|
9
|
+
|
|
10
|
+
import type { MysqlModelConfig } from './config.ts';
|
|
11
|
+
import { MysqlDialect } from './dialect.ts';
|
|
8
12
|
|
|
9
13
|
function isSimplePacket(value: unknown): value is OkPacket | ResultSetHeader {
|
|
10
|
-
return
|
|
11
|
-
value
|
|
14
|
+
return (
|
|
15
|
+
value !== null &&
|
|
16
|
+
value !== undefined &&
|
|
17
|
+
typeof value === 'object' &&
|
|
18
|
+
'constructor' in value &&
|
|
19
|
+
(value.constructor.name === 'OkPacket' || value.constructor.name === 'ResultSetHeader')
|
|
12
20
|
);
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
* Connection
|
|
24
|
+
* MySQL Connection Manager.
|
|
25
|
+
* Operates on mysql2 promise Pool.
|
|
17
26
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class MysqlConnection extends SQLConnection<PoolConnection> {
|
|
29
|
+
readonly dialect = new MysqlDialect();
|
|
30
|
+
pool: Pool;
|
|
31
|
+
readonly config: MysqlModelConfig;
|
|
22
32
|
|
|
23
|
-
constructor(
|
|
24
|
-
context: AsyncContext,
|
|
25
|
-
config: SQLModelConfig
|
|
26
|
-
) {
|
|
33
|
+
constructor(context: AsyncContext, config: MysqlModelConfig) {
|
|
27
34
|
super(context);
|
|
28
|
-
this
|
|
35
|
+
this.config = config;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Initializes the mysql2 connection pool
|
|
40
|
+
*/
|
|
31
41
|
async init(): Promise<void> {
|
|
32
|
-
this
|
|
33
|
-
user: this
|
|
34
|
-
password: this
|
|
35
|
-
database: this
|
|
36
|
-
host: this
|
|
37
|
-
port: this
|
|
42
|
+
this.pool = createPool({
|
|
43
|
+
user: this.config.user,
|
|
44
|
+
password: this.config.password,
|
|
45
|
+
database: this.config.database,
|
|
46
|
+
host: this.config.host,
|
|
47
|
+
port: this.config.port,
|
|
38
48
|
supportBigNumbers: true,
|
|
39
49
|
timezone: '+00:00',
|
|
40
50
|
typeCast: this.typeCast.bind(this),
|
|
41
|
-
...
|
|
51
|
+
...this.config.options
|
|
42
52
|
}).promise();
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getContext<T extends ModelType>(modelClass: Class<T>): TableContext<T> {
|
|
58
|
+
return {
|
|
59
|
+
...super.getContext(modelClass),
|
|
60
|
+
database: this.config.database
|
|
61
|
+
};
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
/**
|
|
49
|
-
*
|
|
65
|
+
* Typecasting parser for JSON and BLOB mysql columns
|
|
50
66
|
*/
|
|
51
67
|
typeCast(field: TypeCastField, next: () => unknown): unknown {
|
|
52
68
|
const result = next();
|
|
@@ -56,7 +72,7 @@ export class MySQLConnection extends Connection<PoolConnection> {
|
|
|
56
72
|
if (typeof result === 'string' && result.charAt(0) === '{' && result.charAt(result.length - 1) === '}') {
|
|
57
73
|
try {
|
|
58
74
|
return JSONUtil.fromUTF8(result);
|
|
59
|
-
} catch {
|
|
75
|
+
} catch {}
|
|
60
76
|
}
|
|
61
77
|
break;
|
|
62
78
|
}
|
|
@@ -64,41 +80,54 @@ export class MySQLConnection extends Connection<PoolConnection> {
|
|
|
64
80
|
return result;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Acquires a PoolConnection from the pool
|
|
85
|
+
*/
|
|
86
|
+
acquire(): Promise<PoolConnection> {
|
|
87
|
+
return this.pool.getConnection();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Releases a PoolConnection back to the pool
|
|
92
|
+
*/
|
|
93
|
+
release(connection: PoolConnection): void {
|
|
94
|
+
connection.release();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Executes a query on the active client or pool directly
|
|
99
|
+
*/
|
|
100
|
+
async execute<Type = unknown>(query: string, values?: unknown[]): Promise<{ count: number; records: Type[] }> {
|
|
101
|
+
console.debug('Executing MySQL query', { query, values });
|
|
102
|
+
const client = this.active ?? (await this.acquire());
|
|
70
103
|
try {
|
|
71
|
-
|
|
72
|
-
const [results,] = await (prepared ? prepared.execute(values) : pool.query(query));
|
|
104
|
+
const [results] = await client.execute(query, castTo(values ?? []));
|
|
73
105
|
if (isSimplePacket(results)) {
|
|
74
106
|
return { records: [], count: results.affectedRows };
|
|
107
|
+
} else if (isSimplePacket(results[0])) {
|
|
108
|
+
return { records: [], count: results[0].affectedRows };
|
|
75
109
|
} else {
|
|
76
|
-
|
|
77
|
-
return { records: [], count: results[0].affectedRows };
|
|
78
|
-
}
|
|
79
|
-
const records: T[] = [...results].map(value => castTo({ ...value }));
|
|
110
|
+
const records: Type[] = castTo(results);
|
|
80
111
|
return { records, count: records.length };
|
|
81
112
|
}
|
|
82
113
|
} catch (error) {
|
|
83
|
-
console.debug('Failed query', { error, query });
|
|
84
114
|
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
85
115
|
switch (code) {
|
|
86
|
-
case 'ER_DUP_ENTRY':
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
case 'ER_DUP_ENTRY': {
|
|
117
|
+
const message = error instanceof Error ? error.message : '';
|
|
118
|
+
const match = message.match(/for key '([^']+)'/);
|
|
119
|
+
const key = match ? match[1] : 'query';
|
|
120
|
+
throw new UniqueError('query', key, { message, query });
|
|
121
|
+
}
|
|
122
|
+
case 'ER_DUP_KEYNAME':
|
|
123
|
+
throw new ExistsError('index', query);
|
|
124
|
+
default:
|
|
125
|
+
throw error;
|
|
89
126
|
}
|
|
90
127
|
} finally {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
128
|
+
if (!this.active) {
|
|
129
|
+
this.release(client);
|
|
130
|
+
}
|
|
94
131
|
}
|
|
95
132
|
}
|
|
96
|
-
|
|
97
|
-
acquire(): Promise<PoolConnection> {
|
|
98
|
-
return this.#pool.getConnection();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
release(pool: PoolConnection): void {
|
|
102
|
-
pool.release();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
133
|
+
}
|
package/src/dialect.ts
CHANGED
|
@@ -1,176 +1,228 @@
|
|
|
1
|
+
import { AbstractANSI99Dialect, type JSONSqlPathMode, type ResolvedPathContext, type TableContext } from '@travetto/model-sql';
|
|
2
|
+
import { type Class, castTo, JSONUtil } from '@travetto/runtime';
|
|
1
3
|
import type { SchemaFieldConfig } from '@travetto/schema';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.DEFAULT_STRING_LENGTH = 191; // Mysql limitation with utf8 and keys
|
|
35
|
-
} else {
|
|
36
|
-
this.DEFAULT_STRING_LENGTH = 3072 / 4 - 1;
|
|
4
|
+
|
|
5
|
+
export class MysqlDialect extends AbstractANSI99Dialect {
|
|
6
|
+
override returningSupport = false;
|
|
7
|
+
|
|
8
|
+
override escapeIdentifier(name: string): string {
|
|
9
|
+
return `\`${name.replaceAll('`', '``')}\``;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getComplexColumnType(field: SchemaFieldConfig): string {
|
|
13
|
+
return 'JSON';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getColumnType(fieldConfiguration: SchemaFieldConfig): string {
|
|
17
|
+
if (fieldConfiguration.type === castTo(BigInt)) {
|
|
18
|
+
return 'BIGINT';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (fieldConfiguration.type === Number) {
|
|
22
|
+
if (fieldConfiguration.precision) {
|
|
23
|
+
const [digits, decimals] = fieldConfiguration.precision;
|
|
24
|
+
if (decimals) {
|
|
25
|
+
return `DECIMAL(${digits},${decimals})`;
|
|
26
|
+
}
|
|
27
|
+
if (digits < 5) {
|
|
28
|
+
return 'SMALLINT';
|
|
29
|
+
}
|
|
30
|
+
if (digits < 10) {
|
|
31
|
+
return 'INT';
|
|
32
|
+
}
|
|
33
|
+
return 'BIGINT';
|
|
34
|
+
}
|
|
35
|
+
return 'INT';
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
Object.assign(this.SQL_OPS, {
|
|
42
|
-
$regex: 'REGEXP BINARY',
|
|
43
|
-
$iregex: 'REGEXP'
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
this.regexWordBoundary = '([[:<:]]|[[:>:]])';
|
|
47
|
-
} else {
|
|
48
|
-
// Customer operators
|
|
49
|
-
Object.assign(this.SQL_OPS, {
|
|
50
|
-
$regex: 'REGEXP',
|
|
51
|
-
});
|
|
52
|
-
// Double escape
|
|
53
|
-
this.regexWordBoundary = '\\\\b';
|
|
38
|
+
if (fieldConfiguration.type === Date) {
|
|
39
|
+
return 'DATETIME(6)';
|
|
54
40
|
}
|
|
41
|
+
|
|
42
|
+
if (fieldConfiguration.type === Boolean) {
|
|
43
|
+
return 'TINYINT(1)';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fieldConfiguration.type === String) {
|
|
47
|
+
if (fieldConfiguration.specifiers?.includes('text')) {
|
|
48
|
+
return 'TEXT';
|
|
49
|
+
}
|
|
50
|
+
return `VARCHAR(${fieldConfiguration.maxlength?.limit ?? 767})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return 'JSON';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
compileJsonIndexPath(columnName: string, jsonPath: string[], mode: JSONSqlPathMode): string {
|
|
57
|
+
return `${columnName}->>'$.${this.formatJsonPath(jsonPath)}'`;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
hash(value: string): string {
|
|
61
|
-
return `SHA2('${value}', '256')`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Get DROP INDEX sql
|
|
66
|
-
*/
|
|
67
|
-
getDropIndexSQL<T extends ModelType>(cls: Class<T>, idx: IndexConfig<T> | string): string {
|
|
68
|
-
const constraint = typeof idx === 'string' ? idx : this.getIndexName(cls, idx);
|
|
69
|
-
return `DROP INDEX ${this.identifier(constraint)} ON ${this.table(SQLModelUtil.classToStack(cls))};`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async describeTable(table: string): Promise<SQLTableDescription | undefined> {
|
|
73
|
-
const IGNORE_FIELDS = [this.pathField.name, this.parentPathField.name, this.idxField.name].map(field => `'${field}'`);
|
|
74
|
-
const [columns, foreignKeys, indices] = await Promise.all([
|
|
75
|
-
// 1. Columns
|
|
76
|
-
this.executeSQL<{ name: string, type: string, is_not_null: boolean }>(`
|
|
77
|
-
SELECT
|
|
78
|
-
COLUMN_NAME AS name,
|
|
79
|
-
COLUMN_TYPE AS type,
|
|
80
|
-
IS_NULLABLE <> 'YES' AS is_not_null
|
|
81
|
-
FROM information_schema.COLUMNS
|
|
82
|
-
WHERE TABLE_NAME = '${table}'
|
|
83
|
-
AND TABLE_SCHEMA = DATABASE()
|
|
84
|
-
AND COLUMN_NAME NOT IN (${IGNORE_FIELDS.join(',')})
|
|
85
|
-
ORDER BY ORDINAL_POSITION
|
|
86
|
-
`),
|
|
87
|
-
|
|
88
|
-
// 2. Foreign Keys
|
|
89
|
-
this.executeSQL<{ name: string, from_column: string, to_column: string, to_table: string }>(`
|
|
90
|
-
SELECT
|
|
91
|
-
CONSTRAINT_NAME AS name,
|
|
92
|
-
COLUMN_NAME AS from_column,
|
|
93
|
-
REFERENCED_COLUMN_NAME AS to_column,
|
|
94
|
-
REFERENCED_TABLE_NAME AS to_table
|
|
95
|
-
FROM information_schema.KEY_COLUMN_USAGE
|
|
96
|
-
WHERE TABLE_NAME = '${table}'
|
|
97
|
-
AND TABLE_SCHEMA = DATABASE()
|
|
98
|
-
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
99
|
-
`),
|
|
100
|
-
|
|
101
|
-
// 3. Indices
|
|
102
|
-
this.executeSQL<{ name: string, is_unique: number, columns: string }>(`
|
|
103
|
-
SELECT
|
|
104
|
-
stat.INDEX_NAME AS name,
|
|
105
|
-
stat.NON_UNIQUE = 0 AS is_unique,
|
|
106
|
-
GROUP_CONCAT(CONCAT(stat.COLUMN_NAME, ' ', stat.COLLATION, ' ') ORDER BY stat.SEQ_IN_INDEX) AS columns
|
|
107
|
-
FROM information_schema.STATISTICS stat
|
|
108
|
-
LEFT OUTER JOIN information_schema.TABLE_CONSTRAINTS AS tc
|
|
109
|
-
ON tc.CONSTRAINT_NAME = stat.INDEX_NAME
|
|
110
|
-
AND tc.TABLE_NAME = stat.TABLE_NAME
|
|
111
|
-
AND tc.TABLE_SCHEMA = stat.TABLE_SCHEMA
|
|
112
|
-
WHERE
|
|
113
|
-
stat.TABLE_NAME = '${table}'
|
|
114
|
-
AND stat.TABLE_SCHEMA = DATABASE()
|
|
115
|
-
AND tc.CONSTRAINT_TYPE IS NULL
|
|
116
|
-
AND stat.COLUMN_NAME NOT IN (${IGNORE_FIELDS.join(',')})
|
|
117
|
-
GROUP BY stat.INDEX_NAME, stat.NON_UNIQUE
|
|
118
|
-
`)
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
|
-
if (!columns.count) {
|
|
122
|
-
return undefined;
|
|
60
|
+
#formatSubPath(context: ResolvedPathContext): string {
|
|
61
|
+
if (!context.subPath || context.subPath.length === 0) {
|
|
62
|
+
return '';
|
|
123
63
|
}
|
|
64
|
+
const subPathMetadata = this.getSchemaSubPathMetadata(context.arrayField?.type, context.subPath);
|
|
65
|
+
return subPathMetadata.map(({ segment, isArray }) => (isArray ? `${segment}[*]` : segment)).join('.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#getArraySqlPath(context: ResolvedPathContext): string {
|
|
69
|
+
if (!context.arrayPath || context.arrayPath.length === 0 || !context.subPath || context.subPath.length === 0) {
|
|
70
|
+
return context.sqlPath;
|
|
71
|
+
}
|
|
72
|
+
const columnName = this.escapeIdentifier(context.arrayPath[0]);
|
|
73
|
+
const arrayPathPart = context.arrayPath.length > 1 ? context.arrayPath.slice(1).join('.') : '';
|
|
74
|
+
const formattedSubPath = this.#formatSubPath(context);
|
|
75
|
+
const jsonPathExpr = arrayPathPart ? `$.${arrayPathPart}[*].${formattedSubPath}` : `$[*].${formattedSubPath}`;
|
|
76
|
+
return `JSON_EXTRACT(${columnName}, '${jsonPathExpr}')`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown } {
|
|
80
|
+
const targetSqlPath = this.#getArraySqlPath(context);
|
|
81
|
+
return { sql: `JSON_CONTAINS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(value) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown } {
|
|
85
|
+
const targetSqlPath = this.#getArraySqlPath(context);
|
|
86
|
+
const val = context.subPath?.length && !Array.isArray(values) ? [values] : values;
|
|
87
|
+
return { sql: `JSON_CONTAINS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(val) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown } {
|
|
91
|
+
const targetSqlPath = this.#getArraySqlPath(context);
|
|
92
|
+
return { sql: `JSON_OVERLAPS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(values) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string } {
|
|
96
|
+
const targetSqlPath = this.#getArraySqlPath(context);
|
|
97
|
+
return { sql: `(${targetSqlPath} IS NOT NULL AND JSON_LENGTH(${targetSqlPath}) > 0)` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
compileArrayRegex(context: ResolvedPathContext, identifier: string, value: RegExp | string): { sql: string; formatted: unknown } {
|
|
101
|
+
const targetSqlPath = this.#getArraySqlPath(context);
|
|
102
|
+
const regex = value instanceof RegExp ? value : new RegExp(String(value));
|
|
103
|
+
const caseInsensitive = regex.flags.includes('i');
|
|
104
|
+
const regexOp = this.getRegexOperator(caseInsensitive);
|
|
105
|
+
const regexSource = this.formatRegex(regex.source, caseInsensitive);
|
|
124
106
|
|
|
125
107
|
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
type: col.type.toUpperCase(),
|
|
129
|
-
is_not_null: !!col.is_not_null
|
|
130
|
-
})),
|
|
131
|
-
foreignKeys: foreignKeys.records,
|
|
132
|
-
indices: indices.records.map(idx => ({
|
|
133
|
-
name: idx.name,
|
|
134
|
-
is_unique: !!idx.is_unique,
|
|
135
|
-
columns: idx.columns
|
|
136
|
-
.split(',')
|
|
137
|
-
.map(column => column.split(' '))
|
|
138
|
-
.map(([name, desc]) => ({ name, desc: desc === 'D' }))
|
|
139
|
-
}))
|
|
108
|
+
sql: `EXISTS (SELECT 1 FROM JSON_TABLE(${targetSqlPath}, '$[*]' COLUMNS (val VARCHAR(255) PATH '$')) AS jt WHERE jt.val ${regexOp} ${identifier})`,
|
|
109
|
+
formatted: regexSource
|
|
140
110
|
};
|
|
141
111
|
}
|
|
142
112
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*/
|
|
146
|
-
override getCreateTableSQL(stack: VisitStack[]): string {
|
|
147
|
-
return super.getCreateTableSQL(stack).replace(/;$/, ` ${this.tablePostfix};`);
|
|
113
|
+
compileJsonEquality(sqlPath: string, identifier: string): string {
|
|
114
|
+
return `CAST(${sqlPath} AS JSON) = CAST(${identifier} AS JSON)`;
|
|
148
115
|
}
|
|
149
116
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
*/
|
|
153
|
-
getModifyColumnSQL(stack: VisitStack[]): string {
|
|
154
|
-
const field: SchemaFieldConfig = castTo(stack.at(-1));
|
|
155
|
-
return `ALTER TABLE ${this.parentTable(stack)} MODIFY COLUMN ${this.getColumnDefinition(field)};`;
|
|
117
|
+
getRegexOperator(caseInsensitive: boolean): string {
|
|
118
|
+
return caseInsensitive ? 'REGEXP' : 'COLLATE utf8mb4_bin REGEXP';
|
|
156
119
|
}
|
|
157
120
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
*/
|
|
161
|
-
override getDeleteSQL(stack: VisitStack[], where?: WhereClause<unknown>): string {
|
|
162
|
-
const sql = super.getDeleteSQL(stack, where);
|
|
163
|
-
return sql.replace(/\bDELETE\b/g, `DELETE ${this.rootAlias}`);
|
|
121
|
+
formatRegex(source: string, caseInsensitive: boolean): string {
|
|
122
|
+
return source;
|
|
164
123
|
}
|
|
165
124
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
125
|
+
castColumn(sqlPath: string, type: Class): string {
|
|
126
|
+
if (type === Number) {
|
|
127
|
+
return `CAST(${sqlPath} AS DECIMAL)`;
|
|
128
|
+
} else if (type === Boolean) {
|
|
129
|
+
return `CAST(${sqlPath} AS SIGNED)`;
|
|
130
|
+
} else if (type === Date) {
|
|
131
|
+
return `CAST(${sqlPath} AS DATETIME(6))`;
|
|
132
|
+
} else if (type === String) {
|
|
133
|
+
return `(CAST(${sqlPath} AS CHAR(255)) COLLATE utf8mb4_bin)`;
|
|
134
|
+
}
|
|
135
|
+
return sqlPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
override getUpsertSQL(
|
|
139
|
+
context: TableContext,
|
|
140
|
+
columns: string[],
|
|
141
|
+
placeholders: string[],
|
|
142
|
+
conflictTarget: string[],
|
|
143
|
+
updates: string[]
|
|
144
|
+
): string {
|
|
145
|
+
const mysqlUpdateStatements = updates.map(statement => statement.replaceAll(/EXCLUDED\.(.*)/g, 'new_row.$1'));
|
|
146
|
+
return `INSERT INTO ${this.escapeIdentifier(context.tableName)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) AS new_row ON DUPLICATE KEY UPDATE ${mysqlUpdateStatements.join(', ')};`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
150
|
+
return {
|
|
151
|
+
sql: `
|
|
152
|
+
SELECT
|
|
153
|
+
COUNT(*) as total
|
|
154
|
+
FROM information_schema.tables
|
|
155
|
+
WHERE table_schema = ? AND table_name = ?;
|
|
156
|
+
`,
|
|
157
|
+
parameters: [context.database, context.tableName]
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
parseTableExistsResult(records: unknown[]): boolean {
|
|
162
|
+
return Number(castTo<{ total: number }>(records[0])?.total ?? 0) > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
166
|
+
return {
|
|
167
|
+
sql: `
|
|
168
|
+
SELECT
|
|
169
|
+
COLUMN_NAME as name,
|
|
170
|
+
COLUMN_TYPE as type
|
|
171
|
+
FROM information_schema.columns
|
|
172
|
+
WHERE table_schema = ? AND table_name = ?;
|
|
173
|
+
`,
|
|
174
|
+
parameters: [context.database, context.tableName]
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
parseExistingColumns(records: unknown[]): Map<string, string> {
|
|
179
|
+
return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
183
|
+
return {
|
|
184
|
+
sql: `
|
|
185
|
+
SELECT
|
|
186
|
+
INDEX_NAME as name,
|
|
187
|
+
TABLE_NAME as tableName,
|
|
188
|
+
NON_UNIQUE as nonUnique,
|
|
189
|
+
GROUP_CONCAT(COALESCE(EXPRESSION, COLUMN_NAME) ORDER BY SEQ_IN_INDEX SEPARATOR ', ') as indexColumns
|
|
190
|
+
FROM information_schema.statistics
|
|
191
|
+
WHERE
|
|
192
|
+
table_schema = ?
|
|
193
|
+
AND table_name = ?
|
|
194
|
+
AND INDEX_NAME != 'PRIMARY'
|
|
195
|
+
GROUP BY INDEX_NAME, TABLE_NAME, NON_UNIQUE;
|
|
196
|
+
`,
|
|
197
|
+
parameters: [context.database, context.tableName]
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
parseExistingIndexes(records: unknown[]): Map<string, string> {
|
|
202
|
+
return new Map(
|
|
203
|
+
castTo<{ name: string; tableName: string; nonUnique: number; indexColumns: string }[]>(records).map(record => {
|
|
204
|
+
const isUnique = Number(record.nonUnique) === 0;
|
|
205
|
+
const indexDefinition = `CREATE ${isUnique ? 'UNIQUE ' : ''}INDEX ${this.escapeIdentifier(record.name)} ON ${this.escapeIdentifier(record.tableName)} (${record.indexColumns});`;
|
|
206
|
+
return [record.name, indexDefinition];
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
override getDropIndexSQL(context: TableContext, indexName: string): string {
|
|
212
|
+
return `DROP INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)};`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
override getTruncateTableSQL(context: TableContext): string {
|
|
216
|
+
return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)};`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
override getAlterColumnTypeSQL(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined {
|
|
220
|
+
const normalizedExisting = existingType.replaceAll('CHARACTER VARYING', 'VARCHAR').replaceAll('INTEGER', 'INT');
|
|
221
|
+
const normalizedRequested = columnType.toUpperCase().replaceAll('CHARACTER VARYING', 'VARCHAR').replaceAll('INTEGER', 'INT');
|
|
222
|
+
|
|
223
|
+
if (!normalizedExisting.startsWith(normalizedRequested) && !normalizedRequested.startsWith(normalizedExisting)) {
|
|
224
|
+
return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} MODIFY COLUMN ${this.escapeIdentifier(columnName)} ${columnType};`;
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
175
227
|
}
|
|
176
|
-
}
|
|
228
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PoolConnection } from 'mysql2/promise';
|
|
2
|
+
|
|
3
|
+
import { Injectable, PostConstruct } from '@travetto/di';
|
|
4
|
+
import { BaseSQLModelService } from '@travetto/model-sql';
|
|
5
|
+
|
|
6
|
+
import type { MysqlConnection } from './connection.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A MySQL JSON-based document store model service
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class MysqlModelService extends BaseSQLModelService {
|
|
13
|
+
connection: MysqlConnection;
|
|
14
|
+
|
|
15
|
+
constructor(connection: MysqlConnection) {
|
|
16
|
+
super();
|
|
17
|
+
this.connection = connection;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get client(): PoolConnection {
|
|
21
|
+
return this.connection.active!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@PostConstruct()
|
|
25
|
+
override async initialize(): Promise<void> {
|
|
26
|
+
await super.initialize();
|
|
27
|
+
}
|
|
28
|
+
}
|
package/support/service.mysql.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { ServiceDescriptor } from '@travetto/cli';
|
|
2
2
|
|
|
3
|
-
const version = process.env.MYSQL_VERSION || '9.
|
|
3
|
+
const version = process.env.MYSQL_VERSION || '9.7';
|
|
4
|
+
|
|
5
|
+
/* cspell:words innodb binlog */
|
|
4
6
|
|
|
5
7
|
export const service: ServiceDescriptor = {
|
|
6
8
|
name: 'mysql',
|
|
7
9
|
version,
|
|
8
10
|
image: `mysql:${version}`,
|
|
9
11
|
port: 3306,
|
|
12
|
+
args: ['--skip-name-resolve', '--innodb-flush-log-at-trx-commit=0', '--sync-binlog=0'],
|
|
10
13
|
env: {
|
|
11
14
|
MYSQL_RANDOM_ROOT_PASSWORD: '1',
|
|
12
15
|
MYSQL_PASSWORD: 'travetto',
|
|
13
16
|
MYSQL_USER: 'travetto',
|
|
14
17
|
MYSQL_DATABASE: 'app'
|
|
15
|
-
}
|
|
16
|
-
};
|
|
18
|
+
}
|
|
19
|
+
};
|