@naturalcycles/mysql-lib 3.8.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.
@@ -0,0 +1,24 @@
1
+ import { JsonSchemaObject, JsonSchemaRootObject } from '@naturalcycles/js-lib';
2
+ export interface MySQLTableStats {
3
+ Field: string;
4
+ Type: string;
5
+ Null: string;
6
+ }
7
+ export interface MySQLSchemaOptions {
8
+ /**
9
+ * @default 'InnoDB'
10
+ */
11
+ engine?: string;
12
+ }
13
+ /**
14
+ * It currently skips nullability and declares everything as "DEFAULT NULL".
15
+ */
16
+ export declare function jsonSchemaToMySQLDDL(table: string, schema: JsonSchemaObject<any>, opt?: MySQLSchemaOptions): string;
17
+ export declare function mysqlTableStatsToJsonSchemaField<T = any>(table: string, stats: MySQLTableStats[]): JsonSchemaRootObject<T>;
18
+ /**
19
+ * Because MySQL doesn't support `.` in field names and escapes them as tableName + fieldName.
20
+ *
21
+ * @param name
22
+ */
23
+ export declare function mapNameToMySQL(name: string): string;
24
+ export declare function mapNameFromMySQL(name: string): string;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapNameFromMySQL = exports.mapNameToMySQL = exports.mysqlTableStatsToJsonSchemaField = exports.jsonSchemaToMySQLDDL = void 0;
4
+ const mysql = require("mysql");
5
+ /**
6
+ * It currently skips nullability and declares everything as "DEFAULT NULL".
7
+ */
8
+ function jsonSchemaToMySQLDDL(table, schema, opt = {}) {
9
+ const { engine = 'InnoDB' } = opt;
10
+ const lines = [`CREATE TABLE ${mysql.escapeId(table)} (`];
11
+ const innerLines = Object.entries(schema.properties).map(([k, s]) => {
12
+ if (k === 'id') {
13
+ return `id VARCHAR(255) NOT NULL`;
14
+ }
15
+ let type;
16
+ if (s.type === 'string') {
17
+ // can specify isoDate later
18
+ type = 'LONGTEXT';
19
+ }
20
+ else if (s.type === 'integer') {
21
+ type = 'INT(11)';
22
+ }
23
+ else if (s.type === 'number') {
24
+ if (['unixTimestamp', 'int32'].includes(s.format)) {
25
+ type = 'INT(11)';
26
+ }
27
+ else {
28
+ type = 'FLOAT(11)';
29
+ }
30
+ }
31
+ else if (s.type === 'boolean') {
32
+ type = 'TINYINT(1)';
33
+ }
34
+ else if (s.instanceof === 'Buffer') {
35
+ type = 'LONGBLOB';
36
+ }
37
+ else if (s.type === 'null') {
38
+ type = 'VARCHAR(255)';
39
+ }
40
+ else if (s.type === 'array') {
41
+ type = 'LONGTEXT'; // to be JSON.stringified?
42
+ }
43
+ else if (s.type === 'object') {
44
+ type = 'LONGTEXT'; // to be JSON.stringified?
45
+ }
46
+ else {
47
+ // default
48
+ type = 'LONGTEXT';
49
+ }
50
+ const tokens = [mysql.escapeId(mapNameToMySQL(k)), type, `DEFAULT NULL`];
51
+ return tokens.join(' ');
52
+ });
53
+ innerLines.push(`PRIMARY KEY (id)`);
54
+ lines.push(innerLines.join(',\n'));
55
+ lines.push(`) ENGINE=${engine}`);
56
+ return lines.join('\n');
57
+ }
58
+ exports.jsonSchemaToMySQLDDL = jsonSchemaToMySQLDDL;
59
+ function mysqlTableStatsToJsonSchemaField(table, stats) {
60
+ const s = {
61
+ $id: `${table}.schema.json`,
62
+ type: 'object',
63
+ properties: {},
64
+ required: [],
65
+ additionalProperties: true,
66
+ };
67
+ stats.forEach(stat => {
68
+ const name = stat.Field;
69
+ const t = stat.Type.toLowerCase();
70
+ const notNull = stat.Null?.toUpperCase() !== 'YES';
71
+ if (notNull) {
72
+ s.required.push(name);
73
+ }
74
+ if (t.includes('text') || t.includes('char')) {
75
+ s.properties[name] = { type: 'string' };
76
+ }
77
+ else if (t.includes('lob')) {
78
+ s.properties[name] = { instanceof: 'Buffer' };
79
+ }
80
+ else if (t.startsWith('tinyint') || t.includes('(1)')) {
81
+ s.properties[name] = { type: 'boolean' };
82
+ }
83
+ else if (t.startsWith('int(')) {
84
+ s.properties[name] = { type: 'integer' };
85
+ }
86
+ else if (t.startsWith('float')) {
87
+ s.properties[name] = { type: 'number' };
88
+ }
89
+ else {
90
+ console.log(s);
91
+ throw new Error(`Unknown mysql field type ${name} ${stat.Type}`);
92
+ }
93
+ });
94
+ return s;
95
+ }
96
+ exports.mysqlTableStatsToJsonSchemaField = mysqlTableStatsToJsonSchemaField;
97
+ /**
98
+ * Because MySQL doesn't support `.` in field names and escapes them as tableName + fieldName.
99
+ *
100
+ * @param name
101
+ */
102
+ function mapNameToMySQL(name) {
103
+ return name.replace(/\./g, '_dot_');
104
+ }
105
+ exports.mapNameToMySQL = mapNameToMySQL;
106
+ function mapNameFromMySQL(name) {
107
+ return name.replace(/_dot_/g, '.');
108
+ }
109
+ exports.mapNameFromMySQL = mapNameFromMySQL;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@naturalcycles/mysql-lib",
3
+ "scripts": {
4
+ "prepare": "husky install"
5
+ },
6
+ "peerDependencies": {
7
+ "@naturalcycles/db-lib": ">=7.0.0"
8
+ },
9
+ "dependencies": {
10
+ "@naturalcycles/db-lib": "^8.16.2",
11
+ "@naturalcycles/js-lib": "^14.47.0",
12
+ "@naturalcycles/nodejs-lib": "^12.37.0",
13
+ "@types/mysql": "^2.15.19",
14
+ "mysql": "^2.17.1"
15
+ },
16
+ "devDependencies": {
17
+ "@naturalcycles/dev-lib": "^12.7.5",
18
+ "@types/node": "^16.10.2",
19
+ "dotenv": "^10.0.0",
20
+ "jest": "^27.2.4"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "!src/test",
26
+ "!src/**/*.test.ts",
27
+ "!src/**/__snapshots__",
28
+ "!src/**/__exclude"
29
+ ],
30
+ "main": "dist/index.js",
31
+ "types": "dist/index.d.ts",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/NaturalCycles/mysql-lib"
38
+ },
39
+ "engines": {
40
+ "node": ">=14.15.0"
41
+ },
42
+ "version": "3.8.0",
43
+ "description": "MySQL client implementing CommonDB interface",
44
+ "keywords": [
45
+ "mysql",
46
+ "lib",
47
+ "db-lib",
48
+ "commondb"
49
+ ],
50
+ "author": "Natural Cycles Team",
51
+ "license": "MIT"
52
+ }
package/readme.md ADDED
@@ -0,0 +1,14 @@
1
+ ## @naturalcycles/mysql-lib
2
+
3
+ > MySQL client implementing CommonDB interface
4
+
5
+ [![npm](https://img.shields.io/npm/v/@naturalcycles/mysql-lib/latest.svg)](https://www.npmjs.com/package/@naturalcycles/mysql-lib)
6
+ [![](https://circleci.com/gh/NaturalCycles/mysql-lib.svg?style=shield&circle-token=123)](https://circleci.com/gh/NaturalCycles/mysql-lib)
7
+ [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
8
+
9
+ # Packaging
10
+
11
+ - `engines.node >= 10.13`: Latest Node.js LTS
12
+ - `main: dist/index.js`: commonjs, es2018
13
+ - `types: dist/index.d.ts`: typescript types
14
+ - `/src` folder with source `*.ts` files included
@@ -0,0 +1,6 @@
1
+ import { MysqlDB } from './mysql.db'
2
+
3
+ export function getDBAdapter(cfgStr: string = '{}'): MysqlDB {
4
+ const cfg = JSON.parse(cfgStr)
5
+ return new MysqlDB(cfg)
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { getDBAdapter } from './dbAdapter'
2
+ import { MysqlDB, MysqlDBCfg, MysqlDBOptions, MysqlDBSaveOptions } from './mysql.db'
3
+ import { jsonSchemaToMySQLDDL } from './schema/mysql.schema.util'
4
+
5
+ export type { MysqlDBCfg, MysqlDBOptions, MysqlDBSaveOptions }
6
+
7
+ export { MysqlDB, jsonSchemaToMySQLDDL, getDBAdapter }
@@ -0,0 +1,312 @@
1
+ import { Transform } from 'stream'
2
+ import { promisify } from 'util'
3
+ import {
4
+ BaseCommonDB,
5
+ BaseDBEntity,
6
+ CommonDB,
7
+ CommonDBCreateOptions,
8
+ CommonDBOptions,
9
+ CommonDBSaveOptions,
10
+ DBQuery,
11
+ ObjectWithId,
12
+ RunQueryResult,
13
+ } from '@naturalcycles/db-lib'
14
+ import {
15
+ _filterNullishValues,
16
+ _mapKeys,
17
+ _mapValues,
18
+ _Memo,
19
+ JsonSchemaObject,
20
+ JsonSchemaRootObject,
21
+ } from '@naturalcycles/js-lib'
22
+ import { Debug, ReadableTyped } from '@naturalcycles/nodejs-lib'
23
+ import { white } from '@naturalcycles/nodejs-lib/dist/colors'
24
+ import { Connection, Pool, PoolConfig, PoolConnection, QueryOptions, TypeCast } from 'mysql'
25
+ import * as mysql from 'mysql'
26
+ import { dbQueryToSQLDelete, dbQueryToSQLSelect, insertSQL } from './query.util'
27
+ import {
28
+ jsonSchemaToMySQLDDL,
29
+ mapNameFromMySQL,
30
+ MySQLTableStats,
31
+ mysqlTableStatsToJsonSchemaField,
32
+ } from './schema/mysql.schema.util'
33
+
34
+ export interface MysqlDBOptions extends CommonDBOptions {}
35
+ export interface MysqlDBSaveOptions extends CommonDBSaveOptions {}
36
+
37
+ /**
38
+ * @default false / undefined
39
+ */
40
+ export interface MysqlDBCfg extends PoolConfig {
41
+ /**
42
+ * If true - will log all produced SQL queries.
43
+ */
44
+ logSQL?: boolean
45
+
46
+ /**
47
+ * If true - will emit logs of connection events.
48
+ */
49
+ debugConnections?: boolean
50
+ }
51
+
52
+ const log = Debug('nc:mysql-lib')
53
+
54
+ const BOOLEAN_TYPES = new Set(['TINY', 'TINYINT', 'INT'])
55
+ const BOOLEAN_BIT_TYPES = new Set(['BIT'])
56
+
57
+ const typeCast: TypeCast = (field, next) => {
58
+ // cast to boolean
59
+ if (field.length === 1) {
60
+ if (BOOLEAN_BIT_TYPES.has(field.type)) {
61
+ const b = field.buffer()
62
+ if (b === null || b === undefined) return undefined // null = undefined
63
+ return b[0] === 1 // 1 = true, 0 = false
64
+ }
65
+
66
+ if (BOOLEAN_TYPES.has(field.type)) {
67
+ const s = field.string()
68
+ // console.log(field.name, field.type, s, s?.charCodeAt(0))
69
+ if (s === null || s === undefined) return undefined // null = undefined
70
+ return s === '1' // 1 = true, 0 = false
71
+ }
72
+ }
73
+
74
+ return next()
75
+ }
76
+
77
+ export class MysqlDB extends BaseCommonDB implements CommonDB {
78
+ constructor(cfg: MysqlDBCfg = {}) {
79
+ super()
80
+ this.cfg = {
81
+ typeCast,
82
+ // charset: 'utf8mb4', // for emoji support
83
+ // host: 'localhost',
84
+ // user: MYSQL_USER,
85
+ // password: MYSQL_PW,
86
+ // database: MYSQL_DB,
87
+ ...cfg,
88
+ }
89
+ }
90
+
91
+ cfg!: MysqlDBCfg
92
+
93
+ override async ping(): Promise<void> {
94
+ const con = await this.getConnection()
95
+ con.release()
96
+ }
97
+
98
+ @_Memo()
99
+ pool(): Pool {
100
+ const pool = mysql.createPool(this.cfg)
101
+ const { host, database } = this.cfg
102
+ log(`connected to ${white(host + '/' + database)}`)
103
+
104
+ if (this.cfg.debugConnections) {
105
+ pool.on('acquire', con => {
106
+ log(`acquire(${con.threadId})`)
107
+ })
108
+
109
+ pool.on('connection', con => {
110
+ log(`connection(${con.threadId})`)
111
+ })
112
+
113
+ pool.on('enqueue', () => {
114
+ log(`enqueue`)
115
+ })
116
+
117
+ pool.on('release', con => {
118
+ log(`release(${con.threadId})`)
119
+ })
120
+ }
121
+
122
+ pool.on('error', err => {
123
+ log.error(`error`, err)
124
+ })
125
+
126
+ return pool
127
+ }
128
+
129
+ async close(): Promise<void> {
130
+ const pool = this.pool()
131
+ // eslint-disable-next-line @typescript-eslint/await-thenable
132
+ await promisify(pool.end.bind(pool))
133
+ }
134
+
135
+ /**
136
+ * Be careful to always call `con.release()` when you get connection with this method.
137
+ */
138
+ async getConnection(): Promise<PoolConnection> {
139
+ const pool = this.pool()
140
+ return await promisify(pool.getConnection.bind(pool))()
141
+ }
142
+
143
+ /**
144
+ * Manually create a single (not pool) connection.
145
+ * Be careful to manage this connection yourself (open, release, etc).
146
+ */
147
+ async createSingleConnection(): Promise<Connection> {
148
+ const con = mysql.createConnection(this.cfg)
149
+ await promisify(con.connect.bind(con))()
150
+ const { threadId } = con
151
+
152
+ if (this.cfg.debugConnections) {
153
+ log(`createSingleConnection(${threadId})`)
154
+
155
+ con.on('connect', () => log(`createSingleConnection(${threadId}).connect`))
156
+ con.on('drain', () => log(`createSingleConnection(${threadId}).drain`))
157
+ con.on('enqueue', () => log(`createSingleConnection(${threadId}).enqueue`))
158
+ con.on('end', () => log(`createSingleConnection(${threadId}).end`))
159
+ }
160
+
161
+ con.on('error', err => {
162
+ log.error(`createSingleConnection(${threadId}).error`, err)
163
+ })
164
+
165
+ return con
166
+ }
167
+
168
+ // GET
169
+ override async getByIds<ROW extends ObjectWithId>(
170
+ table: string,
171
+ ids: string[],
172
+ opt: MysqlDBOptions = {},
173
+ ): Promise<ROW[]> {
174
+ if (!ids.length) return []
175
+ const q = new DBQuery<ROW>(table).filterEq('id', ids)
176
+ const { rows } = await this.runQuery(q, opt)
177
+ return rows.map(r => _mapKeys(r, k => mapNameFromMySQL(k)) as any)
178
+ }
179
+
180
+ // QUERY
181
+ override async runQuery<ROW extends ObjectWithId, OUT = ROW>(
182
+ q: DBQuery<ROW>,
183
+ _opt: MysqlDBOptions = {},
184
+ ): Promise<RunQueryResult<OUT>> {
185
+ const sql = dbQueryToSQLSelect(q)
186
+ const rows = await this.runSQL<ROW[]>({ sql })
187
+ return {
188
+ rows: rows.map(r => _mapKeys(_filterNullishValues(r, true), k => mapNameFromMySQL(k)) as any),
189
+ }
190
+ }
191
+
192
+ async runSQL<RESULT>(q: QueryOptions): Promise<RESULT> {
193
+ if (this.cfg.logSQL) log(q.sql, q.values)
194
+
195
+ return await new Promise<RESULT>((resolve, reject) => {
196
+ this.pool().query(q, (err, res) => {
197
+ if (err) return reject(err)
198
+ resolve(res)
199
+ })
200
+ })
201
+ }
202
+
203
+ override async runQueryCount<ROW extends ObjectWithId>(
204
+ q: DBQuery<ROW>,
205
+ _opt?: CommonDBOptions,
206
+ ): Promise<number> {
207
+ const { rows } = await this.runQuery(q.select(['count(*) as _count']))
208
+ return (rows[0] as any)._count
209
+ }
210
+
211
+ override streamQuery<ROW extends ObjectWithId, OUT = ROW>(
212
+ q: DBQuery<ROW>,
213
+ _opt: MysqlDBOptions = {},
214
+ ): ReadableTyped<OUT> {
215
+ const sql = dbQueryToSQLSelect(q)
216
+
217
+ if (this.cfg.logSQL) log(`stream: ${sql}`)
218
+
219
+ // return this.streamSQL(sql, opt)
220
+ return this.pool()
221
+ .query(sql)
222
+ .stream()
223
+ .pipe(
224
+ new Transform({
225
+ objectMode: true,
226
+ transform(row, _encoding, cb) {
227
+ cb(null, _filterNullishValues(row, true))
228
+ },
229
+ }),
230
+ )
231
+ }
232
+
233
+ // SAVE
234
+ override async saveBatch<ROW extends BaseDBEntity>(
235
+ table: string,
236
+ _rows: ROW[],
237
+ _opt?: MysqlDBSaveOptions,
238
+ ): Promise<void> {
239
+ if (!_rows.length) return
240
+
241
+ // Stringify object values
242
+ const rows = _rows.map(row =>
243
+ _mapValues(row, v => {
244
+ return v && typeof v === 'object' && !Buffer.isBuffer(v) ? JSON.stringify(v) : v
245
+ }),
246
+ )
247
+
248
+ // inserts are split into multiple sentenses to respect the max_packet_size (1Mb usually)
249
+ const sqls = insertSQL(table, rows)
250
+
251
+ for await (const sql of sqls) {
252
+ await this.runSQL({ sql })
253
+ }
254
+ }
255
+
256
+ // DELETE
257
+ /**
258
+ * Limitation: always returns [], regardless of which rows are actually deleted
259
+ */
260
+ override async deleteByIds(table: string, ids: string[], _opt?: MysqlDBOptions): Promise<number> {
261
+ if (!ids.length) return 0
262
+ const sql = dbQueryToSQLDelete(new DBQuery(table).filterEq('id', ids))
263
+ const res = await this.runSQL<any>({ sql })
264
+ return res.affectedRows
265
+ }
266
+
267
+ override async deleteByQuery<ROW extends ObjectWithId>(
268
+ q: DBQuery<ROW>,
269
+ _opt?: CommonDBOptions,
270
+ ): Promise<number> {
271
+ const sql = dbQueryToSQLDelete(q)
272
+ const res = await this.runSQL<any>({ sql })
273
+ return res.affectedRows
274
+ }
275
+
276
+ /**
277
+ * Use with caution!
278
+ */
279
+ async dropTable(table: string): Promise<void> {
280
+ await this.runSQL({ sql: `DROP TABLE IF EXISTS ${table}` })
281
+ }
282
+
283
+ /**
284
+ * dropIfExists=true needed as a safety check
285
+ */
286
+ override async createTable(
287
+ table: string,
288
+ schema: JsonSchemaObject,
289
+ opt: CommonDBCreateOptions = {},
290
+ ): Promise<void> {
291
+ if (opt.dropIfExists) await this.dropTable(table)
292
+
293
+ const sql = jsonSchemaToMySQLDDL(table, schema)
294
+ await this.runSQL({ sql })
295
+ }
296
+
297
+ override async getTables(): Promise<string[]> {
298
+ return (await this.runSQL<Record<any, any>[]>({ sql: `show tables` }))
299
+ .map(r => Object.values(r)[0])
300
+ .filter(Boolean)
301
+ }
302
+
303
+ override async getTableSchema<ROW extends ObjectWithId>(
304
+ table: string,
305
+ ): Promise<JsonSchemaRootObject<ROW>> {
306
+ const stats = await this.runSQL<MySQLTableStats[]>({
307
+ sql: `describe ${mysql.escapeId(table)}`,
308
+ })
309
+
310
+ return mysqlTableStatsToJsonSchemaField<ROW>(table, stats)
311
+ }
312
+ }