@travetto/model-mysql 8.0.0-alpha.9 → 8.0.1

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 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. 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 [SQLModelService](https://github.com/travetto/travetto/tree/main/module/model-sql/src/service.ts#L32) will also modify the database schema in real time to minimize impact to development.
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. Every table generated will have a `path_id` which determines it's location in the document hierarchy as well as sub tables will have a `parent_path_id` to associate records with the parent values.
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
- * [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L11)
22
- * [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L64)
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(ctx: AsyncContext, config: SQLModelConfig) {
41
- return new SQLModelService(ctx, config, new MySQLDialect(ctx, config));
39
+ static getModelService(connection: MysqlConnection) {
40
+ return new MysqlModelService(connection);
42
41
  }
43
42
  }
44
43
  ```
45
44
 
46
- where the [SQLModelConfig](https://github.com/travetto/travetto/tree/main/module/model-sql/src/config.ts#L8) is defined by:
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 SQLModelConfig**
47
+ **Code: Structure of MysqlModelConfig**
49
48
  ```typescript
50
- @Config('model.sql')
51
- export class SQLModelConfig<T extends {} = {}> {
49
+ @Config('model.mysql')
50
+ export class MysqlModelConfig {
52
51
  /**
53
- * Host to connect to
52
+ * Database host to connect to
54
53
  */
55
54
  host = '127.0.0.1';
55
+
56
56
  /**
57
- * Default port
57
+ * Database port to connect to
58
58
  */
59
59
  port = 0;
60
+
60
61
  /**
61
- * Username
62
+ * Database username
62
63
  */
63
64
  user = Runtime.production ? '' : 'travetto';
65
+
64
66
  /**
65
- * Password
67
+ * Database password
66
68
  */
67
69
  password = Runtime.production ? '' : 'travetto';
70
+
68
71
  /**
69
- * Table prefix
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 modification at runtime
78
- */
79
- modifyStorage?: boolean;
80
- /**
81
- * Db version
82
+ * Allow storage modifications (like table auto-creation and schema updates) at runtime
82
83
  */
83
- version = '';
84
+ modifyStorage = !Runtime.production;
85
+
84
86
  /**
85
- * Raw client options
87
+ * Extended client options
86
88
  */
87
- options: T = asFull({});
89
+ options?: PoolOptions;
88
90
  }
89
91
  ```
90
92
 
package/__index__.ts CHANGED
@@ -1,2 +1,3 @@
1
- export * from './src/dialect.ts';
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-alpha.9",
4
- "type": "module",
3
+ "version": "8.0.1",
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
- "email": "travetto.framework@gmail.com",
18
- "name": "Travetto Framework"
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
- "repository": {
27
- "url": "git+https://github.com/travetto/travetto.git",
28
- "directory": "module/model-mysql"
30
+ "publishConfig": {
31
+ "access": "public"
29
32
  },
30
33
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.9",
32
- "@travetto/context": "^8.0.0-alpha.9",
33
- "@travetto/model": "^8.0.0-alpha.9",
34
- "@travetto/model-query": "^8.0.0-alpha.9",
35
- "@travetto/model-sql": "^8.0.0-alpha.9",
36
- "mysql2": "^3.20.0"
34
+ "@travetto/config": "^8.0.1",
35
+ "@travetto/context": "^8.0.1",
36
+ "@travetto/model": "^8.0.1",
37
+ "@travetto/model-query": "^8.0.1",
38
+ "@travetto/model-sql": "^8.0.1",
39
+ "mysql2": "^3.24.3"
37
40
  },
38
41
  "peerDependencies": {
39
- "@travetto/cli": "^8.0.0-alpha.14"
42
+ "@travetto/cli": "^8.0.1"
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 { PoolConnection, Pool, OkPacket, ResultSetHeader, TypeCastField } from 'mysql2/promise';
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 { ExistsError } from '@travetto/model';
7
- import { Connection, type SQLModelConfig } from '@travetto/model-sql';
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 value !== null && value !== undefined && typeof value === 'object' && 'constructor' in value && (
11
- value.constructor.name === 'OkPacket' || value.constructor.name === 'ResultSetHeader'
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 support for mysql
24
+ * MySQL Connection Manager.
25
+ * Operates on mysql2 promise Pool.
17
26
  */
18
- export class MySQLConnection extends Connection<PoolConnection> {
19
-
20
- #pool: Pool;
21
- #config: SQLModelConfig;
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.#config = config;
35
+ this.config = config;
29
36
  }
30
37
 
38
+ /**
39
+ * Initializes the mysql2 connection pool
40
+ */
31
41
  async init(): Promise<void> {
32
- this.#pool = createPool({
33
- user: this.#config.user,
34
- password: this.#config.password,
35
- database: this.#config.database,
36
- host: this.#config.host,
37
- port: this.#config.port,
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
- ...(this.#config.options || {})
51
+ ...this.config.options
42
52
  }).promise();
43
53
 
44
- // Close mysql
45
- ShutdownManager.signal.addEventListener('abort', () => this.#pool.end());
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
- * Support some basic type support for JSON data
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
- async execute<T = unknown>(pool: PoolConnection, query: string, values?: unknown[]): Promise<{ count: number, records: T[] }> {
68
- console.debug('Executing query', { query });
69
- let prepared;
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
- prepared = (values?.length ?? 0) > 0 ? await pool.prepare(query) : undefined;
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
- if (isSimplePacket(results[0])) {
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': throw new ExistsError('query', query);
87
- case 'ER_DUP_KEYNAME': throw new ExistsError('index', query);
88
- default: throw error;
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
- try {
92
- await prepared?.close();
93
- } catch { }
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
- import { Injectable } from '@travetto/di';
3
- import type { AsyncContext } from '@travetto/context';
4
- import type { WhereClause } from '@travetto/model-query';
5
- import { castTo, type Class } from '@travetto/runtime';
6
- import type { ModelType, IndexConfig } from '@travetto/model';
7
- import { type SQLModelConfig, SQLDialect, type VisitStack, type SQLTableDescription, SQLModelUtil } from '@travetto/model-sql';
8
-
9
- import { MySQLConnection } from './connection.ts';
10
-
11
- /**
12
- * MYSQL Dialect for the SQL Model Source
13
- */
14
- @Injectable()
15
- export class MySQLDialect extends SQLDialect {
16
-
17
- connection: MySQLConnection;
18
- tablePostfix = 'COLLATE=utf8mb4_bin ENGINE=InnoDB';
19
-
20
- constructor(context: AsyncContext, config: SQLModelConfig) {
21
- super(config.namespace);
22
- this.connection = new MySQLConnection(context, config);
23
-
24
- // Custom types
25
- Object.assign(this.COLUMN_TYPES, {
26
- TIMESTAMP: 'DATETIME(3)',
27
- JSON: 'TEXT'
28
- });
29
-
30
- /**
31
- * Set string length limit based on version
32
- */
33
- if (/^5[.][56]/.test(config.version)) {
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 (/^5[.].*/.test(config.version)) {
40
- // Customer operators
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
- * Compute hash
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
- columns: columns.records.map(col => ({
127
- ...col,
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
- * Create table, adding in specific engine options
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
- * Define column modification
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
- * Add root alias to delete clause
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
- * Suppress foreign key checks
168
- */
169
- override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
170
- return [
171
- 'SET FOREIGN_KEY_CHECKS = 0;',
172
- ...super.getTruncateAllTablesSQL(cls),
173
- 'SET FOREIGN_KEY_CHECKS = 1;'
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
+ }
@@ -1,16 +1,19 @@
1
1
  import type { ServiceDescriptor } from '@travetto/cli';
2
2
 
3
- const version = process.env.MYSQL_VERSION || '9.6';
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
+ };