@travetto/model-mysql 8.0.0-alpha.25 → 8.0.0-alpha.26

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,13 +13,14 @@ 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#L69) 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
21
  * [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L60)
22
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)
23
24
  * [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
24
25
  * [Query Crud](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/crud.ts#L11)
25
26
  * [Facet](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/facet.ts#L14)
@@ -30,61 +31,62 @@ Out of the box, by installing the module, everything should be wired up by defau
30
31
 
31
32
  **Code: Wiring up a custom Model Source**
32
33
  ```typescript
33
- import type { AsyncContext } from '@travetto/context';
34
34
  import { InjectableFactory } from '@travetto/di';
35
- import { MySQLDialect } from '@travetto/model-mysql';
36
- import { type SQLModelConfig, SQLModelService } from '@travetto/model-sql';
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/config.ts';
1
2
  export * from './src/connection.ts';
2
- export * from './src/dialect.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.25",
3
+ "version": "8.0.0-alpha.26",
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": [
@@ -32,7 +32,7 @@
32
32
  "@travetto/context": "^8.0.0-alpha.20",
33
33
  "@travetto/model": "^8.0.0-alpha.23",
34
34
  "@travetto/model-query": "^8.0.0-alpha.24",
35
- "@travetto/model-sql": "^8.0.0-alpha.25",
35
+ "@travetto/model-sql": "^8.0.0-alpha.26",
36
36
  "mysql2": "^3.22.6"
37
37
  },
38
38
  "peerDependencies": {
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,10 +1,14 @@
1
1
  import { createPool } from 'mysql2';
2
- import type { OkPacket, Pool, PoolConnection, PreparedStatementInfo, ResultSetHeader, TypeCastField } from 'mysql2/promise';
2
+ import type { OkPacket, Pool, PoolConnection, ResultSetHeader, TypeCastField } from 'mysql2/promise';
3
3
 
4
4
  import type { AsyncContext } from '@travetto/context';
5
- import { ExistsError } from '@travetto/model';
6
- import { Connection, type SQLModelConfig } from '@travetto/model-sql';
7
- import { castTo, JSONUtil, ShutdownManager } from '@travetto/runtime';
5
+ import { Injectable } from '@travetto/di';
6
+ import { ExistsError, type ModelType } 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
14
  return (
@@ -17,36 +21,48 @@ function isSimplePacket(value: unknown): value is OkPacket | ResultSetHeader {
17
21
  }
18
22
 
19
23
  /**
20
- * Connection support for mysql
24
+ * MySQL Connection Manager.
25
+ * Operates on mysql2 promise Pool.
21
26
  */
22
- export class MySQLConnection extends Connection<PoolConnection> {
23
- #pool: Pool;
24
- #config: SQLModelConfig;
27
+ @Injectable()
28
+ export class MysqlConnection extends SQLConnection<PoolConnection> {
29
+ readonly dialect = new MysqlDialect();
30
+ pool: Pool;
31
+ readonly config: MysqlModelConfig;
25
32
 
26
- constructor(context: AsyncContext, config: SQLModelConfig) {
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();
@@ -64,23 +80,37 @@ 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: PreparedStatementInfo | undefined;
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
116
  case 'ER_DUP_ENTRY':
@@ -91,17 +121,9 @@ export class MySQLConnection extends Connection<PoolConnection> {
91
121
  throw error;
92
122
  }
93
123
  } finally {
94
- try {
95
- await prepared?.close();
96
- } catch {}
124
+ if (!this.active) {
125
+ this.release(client);
126
+ }
97
127
  }
98
128
  }
99
-
100
- acquire(): Promise<PoolConnection> {
101
- return this.#pool.getConnection();
102
- }
103
-
104
- release(pool: PoolConnection): void {
105
- pool.release();
106
- }
107
129
  }
package/src/dialect.ts CHANGED
@@ -1,171 +1,195 @@
1
- import type { AsyncContext } from '@travetto/context';
2
- import { Injectable } from '@travetto/di';
3
- import type { IndexConfig, ModelType } from '@travetto/model';
4
- import type { WhereClause } from '@travetto/model-query';
5
- import { SQLDialect, type SQLModelConfig, SQLModelUtil, type SQLTableDescription, type VisitStack } from '@travetto/model-sql';
6
- import { type Class, castTo } from '@travetto/runtime';
1
+ import { AbstractANSI99Dialect, type JSONSqlPathMode, type TableContext } from '@travetto/model-sql';
2
+ import { type Class, castTo, JSONUtil } from '@travetto/runtime';
7
3
  import type { SchemaFieldConfig } from '@travetto/schema';
8
4
 
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
- connection: MySQLConnection;
17
- tablePostfix = 'COLLATE=utf8mb4_bin ENGINE=InnoDB';
18
-
19
- constructor(context: AsyncContext, config: SQLModelConfig) {
20
- super(config.namespace);
21
- this.connection = new MySQLConnection(context, config);
22
-
23
- // Custom types
24
- Object.assign(this.COLUMN_TYPES, {
25
- TIMESTAMP: 'DATETIME(3)',
26
- JSON: 'TEXT'
27
- });
28
-
29
- /**
30
- * Set string length limit based on version
31
- */
32
- if (/^5[.][56]/.test(config.version)) {
33
- this.DEFAULT_STRING_LENGTH = 191; // Mysql limitation with utf8 and keys
34
- } else {
35
- this.DEFAULT_STRING_LENGTH = 3072 / 4 - 1;
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)';
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})`;
36
51
  }
37
52
 
38
- if (/^5[.].*/.test(config.version)) {
39
- // Customer operators
40
- Object.assign(this.SQL_OPS, {
41
- $regex: 'REGEXP BINARY',
42
- $iregex: 'REGEXP'
43
- });
44
-
45
- this.regexWordBoundary = '([[:<:]]|[[:>:]])';
46
- } else {
47
- // Customer operators
48
- Object.assign(this.SQL_OPS, {
49
- $regex: 'REGEXP'
50
- });
51
- // Double escape
52
- this.regexWordBoundary = '\\\\b';
53
+ return 'JSON';
54
+ }
55
+
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;
53
64
  }
54
65
  }
55
66
 
56
- /**
57
- * Compute hash
58
- */
59
- hash(value: string): string {
60
- return `SHA2('${value}', '256')`;
61
- }
62
-
63
- /**
64
- * Get DROP INDEX sql
65
- */
66
- getDropIndexSQL<T extends ModelType>(cls: Class<T>, idx: IndexConfig | string): string {
67
- const constraint = typeof idx === 'string' ? idx : this.getIndexName(cls, idx);
68
- return `DROP INDEX ${this.identifier(constraint)} ON ${this.table(SQLModelUtil.classToStack(cls))};`;
69
- }
70
-
71
- async describeTable(table: string): Promise<SQLTableDescription | undefined> {
72
- const IGNORE_FIELDS = [this.pathField.name, this.parentPathField.name, this.idxField.name].map(field => `'${field}'`);
73
- const [columns, foreignKeys, indices] = await Promise.all([
74
- // 1. Columns
75
- this.executeSQL<{ name: string; type: string; is_not_null: boolean }>(`
76
- SELECT
77
- COLUMN_NAME AS name,
78
- COLUMN_TYPE AS type,
79
- IS_NULLABLE <> 'YES' AS is_not_null
80
- FROM information_schema.COLUMNS
81
- WHERE TABLE_NAME = '${table}'
82
- AND TABLE_SCHEMA = DATABASE()
83
- AND COLUMN_NAME NOT IN (${IGNORE_FIELDS.join(',')})
84
- ORDER BY ORDINAL_POSITION
85
- `),
86
-
87
- // 2. Foreign Keys
88
- this.executeSQL<{ name: string; from_column: string; to_column: string; to_table: string }>(`
89
- SELECT
90
- CONSTRAINT_NAME AS name,
91
- COLUMN_NAME AS from_column,
92
- REFERENCED_COLUMN_NAME AS to_column,
93
- REFERENCED_TABLE_NAME AS to_table
94
- FROM information_schema.KEY_COLUMN_USAGE
95
- WHERE TABLE_NAME = '${table}'
96
- AND TABLE_SCHEMA = DATABASE()
97
- AND REFERENCED_TABLE_NAME IS NOT NULL
98
- `),
99
-
100
- // 3. Indices
101
- this.executeSQL<{ name: string; is_unique: number; columns: string }>(`
102
- SELECT
103
- stat.INDEX_NAME AS name,
104
- stat.NON_UNIQUE = 0 AS is_unique,
105
- GROUP_CONCAT(CONCAT(stat.COLUMN_NAME, ' ', stat.COLLATION, ' ') ORDER BY stat.SEQ_IN_INDEX) AS columns
106
- FROM information_schema.STATISTICS stat
107
- LEFT OUTER JOIN information_schema.TABLE_CONSTRAINTS AS tc
108
- ON tc.CONSTRAINT_NAME = stat.INDEX_NAME
109
- AND tc.TABLE_NAME = stat.TABLE_NAME
110
- AND tc.TABLE_SCHEMA = stat.TABLE_SCHEMA
111
- WHERE
112
- stat.TABLE_NAME = '${table}'
113
- AND stat.TABLE_SCHEMA = DATABASE()
114
- AND (tc.CONSTRAINT_TYPE IS NULL OR tc.CONSTRAINT_TYPE = 'UNIQUE')
115
- AND stat.COLUMN_NAME NOT IN (${IGNORE_FIELDS.join(',')})
116
- GROUP BY stat.INDEX_NAME, stat.NON_UNIQUE
117
- `)
118
- ]);
119
-
120
- if (!columns.count) {
121
- return undefined;
67
+ compileArrayAll(
68
+ sqlPath: string,
69
+ identifier: string,
70
+ value: unknown[],
71
+ field: SchemaFieldConfig,
72
+ topLevel?: boolean
73
+ ): { sql: string; formatted: unknown } {
74
+ return { sql: `JSON_CONTAINS(${sqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(value) };
75
+ }
76
+
77
+ compileArrayEquals(
78
+ sqlPath: string,
79
+ identifier: string,
80
+ values: unknown,
81
+ field: SchemaFieldConfig,
82
+ topLevel?: boolean
83
+ ): { sql: string; formatted: unknown } {
84
+ return { sql: `JSON_CONTAINS(${sqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(values) };
85
+ }
86
+
87
+ compileArrayAny(
88
+ sqlPath: string,
89
+ identifier: string,
90
+ values: unknown[],
91
+ field: SchemaFieldConfig,
92
+ topLevel?: boolean
93
+ ): { sql: string; formatted: unknown } {
94
+ return { sql: `JSON_OVERLAPS(${sqlPath}, ${identifier})`, formatted: JSONUtil.toUTF8(values) };
95
+ }
96
+
97
+ compileArrayExists(sqlPath: string, identifier: string, field: SchemaFieldConfig, topLevel?: boolean): { sql: string } {
98
+ return { sql: `(${sqlPath} IS NOT NULL AND JSON_LENGTH(${sqlPath}) > 0)` };
99
+ }
100
+
101
+ compileJsonEquality(sqlPath: string, identifier: string): string {
102
+ return `CAST(${sqlPath} AS JSON) = CAST(${identifier} AS JSON)`;
103
+ }
104
+
105
+ getRegexOperator(caseInsensitive: boolean): string {
106
+ return caseInsensitive ? 'REGEXP' : 'COLLATE utf8mb4_bin REGEXP';
107
+ }
108
+
109
+ formatRegex(source: string, caseInsensitive: boolean): string {
110
+ return source;
111
+ }
112
+
113
+ castColumn(sqlPath: string, type: Class): string {
114
+ if (type === Number) {
115
+ return `CAST(${sqlPath} AS DECIMAL)`;
116
+ } else if (type === Boolean) {
117
+ return `CAST(${sqlPath} AS SIGNED)`;
118
+ } else if (type === Date) {
119
+ return `CAST(${sqlPath} AS DATETIME(6))`;
122
120
  }
121
+ return sqlPath;
122
+ }
123
123
 
124
+ override getUpsertSQL(
125
+ context: TableContext,
126
+ columns: string[],
127
+ placeholders: string[],
128
+ conflictTarget: string[],
129
+ updates: string[]
130
+ ): string {
131
+ const mysqlUpdates = updates.map(val => val.replace(/EXCLUDED\.(.*)/g, 'VALUES($1)'));
132
+ return `
133
+ INSERT INTO
134
+ ${this.escapeIdentifier(context.tableName)} (${columns.join(', ')})
135
+ VALUES
136
+ (${placeholders.join(', ')})
137
+ ON DUPLICATE KEY UPDATE ${mysqlUpdates.join(', ')};`;
138
+ }
139
+
140
+ getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
141
+ return {
142
+ sql: `
143
+ SELECT
144
+ COUNT(*) as total
145
+ FROM information_schema.tables
146
+ WHERE table_schema = ? AND table_name = ?;
147
+ `,
148
+ parameters: [context.database, context.tableName]
149
+ };
150
+ }
151
+
152
+ parseTableExistsResult(records: unknown[]): boolean {
153
+ return Number(castTo<{ total: number }>(records[0])?.total ?? 0) > 0;
154
+ }
155
+
156
+ getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
124
157
  return {
125
- columns: columns.records.map(col => ({
126
- ...col,
127
- type: col.type.toUpperCase(),
128
- is_not_null: !!col.is_not_null
129
- })),
130
- foreignKeys: foreignKeys.records,
131
- indices: indices.records.map(idx => ({
132
- name: idx.name,
133
- is_unique: !!idx.is_unique,
134
- columns: idx.columns
135
- .split(',')
136
- .map(column => column.split(' '))
137
- .map(([name, desc]) => ({ name, desc: desc === 'D' }))
138
- }))
158
+ sql: `
159
+ SELECT
160
+ COLUMN_NAME as name,
161
+ DATA_TYPE as type
162
+ FROM information_schema.columns
163
+ WHERE table_schema = ? AND table_name = ?;
164
+ `,
165
+ parameters: [context.database, context.tableName]
139
166
  };
140
167
  }
141
168
 
142
- /**
143
- * Create table, adding in specific engine options
144
- */
145
- override getCreateTableSQL(stack: VisitStack[]): string {
146
- return super.getCreateTableSQL(stack).replace(/;$/, ` ${this.tablePostfix};`);
169
+ parseExistingColumns(records: unknown[]): Map<string, string> {
170
+ return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
147
171
  }
148
172
 
149
- /**
150
- * Define column modification
151
- */
152
- getModifyColumnSQL(stack: VisitStack[]): string {
153
- const field: SchemaFieldConfig = castTo(stack.at(-1));
154
- return `ALTER TABLE ${this.parentTable(stack)} MODIFY COLUMN ${this.getColumnDefinition(field)};`;
173
+ getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
174
+ return {
175
+ sql: `
176
+ SELECT DISTINCT
177
+ INDEX_NAME as name
178
+ FROM information_schema.statistics
179
+ WHERE
180
+ table_schema = ?
181
+ AND table_name = ?
182
+ AND INDEX_NAME != 'PRIMARY';
183
+ `,
184
+ parameters: [context.database, context.tableName]
185
+ };
155
186
  }
156
187
 
157
- /**
158
- * Add root alias to delete clause
159
- */
160
- override getDeleteSQL(stack: VisitStack[], where?: WhereClause<unknown>): string {
161
- const sql = super.getDeleteSQL(stack, where);
162
- return sql.replace(/\bDELETE\b/g, `DELETE ${this.rootAlias}`);
188
+ parseExistingIndexes(records: unknown[]): Map<string, string> {
189
+ return new Map(castTo<{ name: string }[]>(records).map(record => [record.name, '']));
163
190
  }
164
191
 
165
- /**
166
- * Suppress foreign key checks
167
- */
168
- override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
169
- return ['SET FOREIGN_KEY_CHECKS = 0;', ...super.getTruncateAllTablesSQL(cls), 'SET FOREIGN_KEY_CHECKS = 1;'];
192
+ override getDropIndexSQL(context: TableContext, indexName: string): string {
193
+ return `DROP INDEX ${this.escapeIdentifier(indexName)} ON ${this.escapeIdentifier(context.tableName)};`;
170
194
  }
171
195
  }
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,6 +7,7 @@ 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',