@travetto/model-mysql 8.0.0-alpha.3 → 8.0.0-alpha.30

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-mysql",
3
- "version": "8.0.0-alpha.3",
3
+ "version": "8.0.0-alpha.30",
4
4
  "type": "module",
5
5
  "description": "MySQL 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-mysql"
29
29
  },
30
30
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.3",
32
- "@travetto/context": "^8.0.0-alpha.3",
33
- "@travetto/model": "^8.0.0-alpha.3",
34
- "@travetto/model-query": "^8.0.0-alpha.3",
35
- "@travetto/model-sql": "^8.0.0-alpha.3",
36
- "mysql2": "^3.19.1"
31
+ "@travetto/config": "^8.0.0-alpha.24",
32
+ "@travetto/context": "^8.0.0-alpha.22",
33
+ "@travetto/model": "^8.0.0-alpha.25",
34
+ "@travetto/model-query": "^8.0.0-alpha.27",
35
+ "@travetto/model-sql": "^8.0.0-alpha.29",
36
+ "mysql2": "^3.23.1"
37
37
  },
38
38
  "peerDependencies": {
39
- "@travetto/cli": "^8.0.0-alpha.4"
39
+ "@travetto/cli": "^8.0.0-alpha.30"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@travetto/cli": {
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 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;
1
+ import { AbstractANSI99Dialect, type JSONSqlPathMode, type ResolvedPathContext, type TableContext } from '@travetto/model-sql';
2
+ import { type Class, castTo, JSONUtil } from '@travetto/runtime';
3
+ import { type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
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';
36
+ }
37
+
38
+ if (fieldConfiguration.type === Date) {
39
+ return 'DATETIME(6)';
37
40
  }
38
41
 
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';
42
+ if (fieldConfiguration.type === Boolean) {
43
+ return 'TINYINT(1)';
54
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';
55
54
  }
56
55
 
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;
56
+ compileJsonIndexPath(columnName: string, jsonPath: string[], mode: JSONSqlPathMode): string {
57
+ const result = `${columnName}->>'$.${jsonPath.join('.')}'`;
58
+ switch (mode) {
59
+ case 'createIndex':
60
+ return `(CAST(${result} as CHAR(255)) COLLATE utf8mb4_bin)`;
61
+ case 'orderBy':
62
+ case 'read':
63
+ return result;
123
64
  }
65
+ }
66
+
67
+ #formatSubPath(context: ResolvedPathContext): string {
68
+ if (!context.subPath || context.subPath.length === 0) {
69
+ return '';
70
+ }
71
+ let currentClass: Class | undefined = context.arrayField?.type;
72
+ const parts: string[] = [];
73
+ for (let index = 0; index < context.subPath.length; index++) {
74
+ const segment = context.subPath[index];
75
+ if (currentClass) {
76
+ const classConfig = SchemaRegistryIndex.getOptional(currentClass)?.get();
77
+ const fieldConfig = classConfig?.fields[segment];
78
+ if (fieldConfig) {
79
+ parts.push(fieldConfig.array ? `${segment}[*]` : segment);
80
+ currentClass = fieldConfig.type;
81
+ continue;
82
+ }
83
+ }
84
+ parts.push(segment);
85
+ }
86
+ return parts.join('.');
87
+ }
88
+
89
+ #getArraySqlPath(context: ResolvedPathContext): string {
90
+ if (!context.arrayPath || context.arrayPath.length === 0 || !context.subPath || context.subPath.length === 0) {
91
+ return context.sqlPath;
92
+ }
93
+ const columnName = this.escapeIdentifier(context.arrayPath[0]);
94
+ const arrayPathPart = context.arrayPath.length > 1 ? context.arrayPath.slice(1).join('.') : '';
95
+ const formattedSubPath = this.#formatSubPath(context);
96
+ const jsonPathExpr = arrayPathPart ? `$.${arrayPathPart}[*].${formattedSubPath}` : `$[*].${formattedSubPath}`;
97
+ return `JSON_EXTRACT(${columnName}, '${jsonPathExpr}')`;
98
+ }
99
+
100
+ compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown } {
101
+ const targetSqlPath = this.#getArraySqlPath(context);
102
+ return { sql: `JSON_CONTAINS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(value) };
103
+ }
104
+
105
+ compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown } {
106
+ const targetSqlPath = this.#getArraySqlPath(context);
107
+ const val = context.subPath?.length && !Array.isArray(values) ? [values] : values;
108
+ return { sql: `JSON_CONTAINS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(val) };
109
+ }
110
+
111
+ compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown } {
112
+ const targetSqlPath = this.#getArraySqlPath(context);
113
+ return { sql: `JSON_OVERLAPS(${targetSqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(values) };
114
+ }
124
115
 
116
+ compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string } {
117
+ const targetSqlPath = this.#getArraySqlPath(context);
118
+ return { sql: `(${targetSqlPath} IS NOT NULL AND JSON_LENGTH(${targetSqlPath}) > 0)` };
119
+ }
120
+
121
+ compileArrayRegex(context: ResolvedPathContext, identifier: string, value: RegExp | string): { sql: string; formatted: unknown } {
122
+ const targetSqlPath = this.#getArraySqlPath(context);
123
+ const regex = value instanceof RegExp ? value : new RegExp(String(value));
124
+ const caseInsensitive = regex.flags.includes('i');
125
+ const regexOp = this.getRegexOperator(caseInsensitive);
126
+ const regexSource = this.formatRegex(regex.source, caseInsensitive);
127
+
128
+ return {
129
+ sql: `EXISTS (SELECT 1 FROM JSON_TABLE(${targetSqlPath}, '$[*]' COLUMNS (val VARCHAR(255) PATH '$')) AS jt WHERE jt.val ${regexOp} ${identifier})`,
130
+ formatted: regexSource
131
+ };
132
+ }
133
+
134
+ compileJsonEquality(sqlPath: string, identifier: string): string {
135
+ return `CAST(${sqlPath} AS JSON) = CAST(${identifier} AS JSON)`;
136
+ }
137
+
138
+ getRegexOperator(caseInsensitive: boolean): string {
139
+ return caseInsensitive ? 'REGEXP' : 'COLLATE utf8mb4_bin REGEXP';
140
+ }
141
+
142
+ formatRegex(source: string, caseInsensitive: boolean): string {
143
+ return source;
144
+ }
145
+
146
+ castColumn(sqlPath: string, type: Class): string {
147
+ if (type === Number) {
148
+ return `CAST(${sqlPath} AS DECIMAL)`;
149
+ } else if (type === Boolean) {
150
+ return `CAST(${sqlPath} AS SIGNED)`;
151
+ } else if (type === Date) {
152
+ return `CAST(${sqlPath} AS DATETIME(6))`;
153
+ }
154
+ return sqlPath;
155
+ }
156
+
157
+ override getUpsertSQL(
158
+ context: TableContext,
159
+ columns: string[],
160
+ placeholders: string[],
161
+ conflictTarget: string[],
162
+ updates: string[]
163
+ ): string {
164
+ const mysqlUpdates = updates.map(val => val.replace(/EXCLUDED\.(.*)/g, 'VALUES($1)'));
165
+ return `
166
+ INSERT INTO
167
+ ${this.escapeIdentifier(context.tableName)} (${columns.join(', ')})
168
+ VALUES
169
+ (${placeholders.join(', ')})
170
+ ON DUPLICATE KEY UPDATE ${mysqlUpdates.join(', ')};`;
171
+ }
172
+
173
+ getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
125
174
  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
- }))
175
+ sql: `
176
+ SELECT
177
+ COUNT(*) as total
178
+ FROM information_schema.tables
179
+ WHERE table_schema = ? AND table_name = ?;
180
+ `,
181
+ parameters: [context.database, context.tableName]
140
182
  };
141
183
  }
142
184
 
143
- /**
144
- * Create table, adding in specific engine options
145
- */
146
- override getCreateTableSQL(stack: VisitStack[]): string {
147
- return super.getCreateTableSQL(stack).replace(/;$/, ` ${this.tablePostfix};`);
185
+ parseTableExistsResult(records: unknown[]): boolean {
186
+ return Number(castTo<{ total: number }>(records[0])?.total ?? 0) > 0;
148
187
  }
149
188
 
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)};`;
189
+ getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
190
+ return {
191
+ sql: `
192
+ SELECT
193
+ COLUMN_NAME as name,
194
+ DATA_TYPE as type
195
+ FROM information_schema.columns
196
+ WHERE table_schema = ? AND table_name = ?;
197
+ `,
198
+ parameters: [context.database, context.tableName]
199
+ };
200
+ }
201
+
202
+ parseExistingColumns(records: unknown[]): Map<string, string> {
203
+ return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
204
+ }
205
+
206
+ getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
207
+ return {
208
+ sql: `
209
+ SELECT DISTINCT
210
+ INDEX_NAME as name
211
+ FROM information_schema.statistics
212
+ WHERE
213
+ table_schema = ?
214
+ AND table_name = ?
215
+ AND INDEX_NAME != 'PRIMARY';
216
+ `,
217
+ parameters: [context.database, context.tableName]
218
+ };
156
219
  }
157
220
 
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}`);
221
+ parseExistingIndexes(records: unknown[]): Map<string, string> {
222
+ return new Map(castTo<{ name: string }[]>(records).map(record => [record.name, '']));
164
223
  }
165
224
 
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
- ];
225
+ override getDropIndexSQL(context: TableContext, indexName: string): string {
226
+ return `DROP INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)};`;
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
+ }
@@ -7,10 +7,11 @@ export const service: ServiceDescriptor = {
7
7
  version,
8
8
  image: `mysql:${version}`,
9
9
  port: 3306,
10
+ args: ['--skip-name-resolve', '--innodb-flush-log-at-trx-commit=0', '--sync-binlog=0'],
10
11
  env: {
11
12
  MYSQL_RANDOM_ROOT_PASSWORD: '1',
12
13
  MYSQL_PASSWORD: 'travetto',
13
14
  MYSQL_USER: 'travetto',
14
15
  MYSQL_DATABASE: 'app'
15
- },
16
- };
16
+ }
17
+ };