@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,216 @@
1
+ import { DBQuery } from '@naturalcycles/db-lib'
2
+ import { DBQueryFilterOperator } from '@naturalcycles/db-lib/src/query/dbQuery'
3
+ import { _hb } from '@naturalcycles/js-lib'
4
+ import { white, yellow } from '@naturalcycles/nodejs-lib/dist/colors'
5
+ import { QueryOptions } from 'mysql'
6
+ import * as mysql from 'mysql'
7
+ import { mapNameToMySQL } from './schema/mysql.schema.util'
8
+
9
+ const MAX_PACKET_SIZE = 1024 * 1024 // 1Mb
10
+ const MAX_ROW_SIZE = 800 * 1024 // 1Mb - margin
11
+
12
+ export function dbQueryToSQLSelect(q: DBQuery<any>): string {
13
+ const tokens = selectTokens(q)
14
+
15
+ // filters
16
+ tokens.push(...whereTokens(q))
17
+
18
+ // order
19
+ tokens.push(...orderTokens(q))
20
+
21
+ // offset/limit
22
+ tokens.push(...offsetLimitTokens(q))
23
+
24
+ return tokens.join(' ')
25
+ }
26
+
27
+ export function dbQueryToSQLDelete(q: DBQuery<any>): string {
28
+ const tokens = [`DELETE FROM`, mysql.escapeId(q.table)]
29
+
30
+ // filters
31
+ tokens.push(...whereTokens(q))
32
+
33
+ // offset/limit
34
+ tokens.push(...offsetLimitTokens(q))
35
+
36
+ return tokens.join(' ')
37
+ }
38
+
39
+ /**
40
+ * Returns array of sql statements to respect the max sql size.
41
+ */
42
+ export function insertSQL(
43
+ table: string,
44
+ records: Record<any, any>[],
45
+ verb: 'INSERT' | 'REPLACE' = 'INSERT',
46
+ ): string[] {
47
+ // INSERT INTO table_name (column1, column2, column3, ...)
48
+ // VALUES (value1, value2, value3, ...);
49
+
50
+ // eslint-disable-next-line unicorn/no-array-reduce
51
+ const fieldSet = records.reduce((set: Set<string>, rec) => {
52
+ Object.keys(rec).forEach(field => set.add(field))
53
+ return set
54
+ }, new Set<string>())
55
+ const fields = [...fieldSet]
56
+
57
+ const start = [
58
+ verb,
59
+ `INTO`,
60
+ mysql.escapeId(table),
61
+ `(` + [...fields].map(f => mysql.escapeId(mapNameToMySQL(f))).join(',') + `)`,
62
+ `VALUES\n`,
63
+ ].join(' ')
64
+
65
+ const valueRows = records.map(rec => {
66
+ return `(` + fields.map(k => mysql.escape(rec[k])).join(',') + `)`
67
+ })
68
+
69
+ const full = start + valueRows.join(',\n')
70
+
71
+ if (full.length < MAX_PACKET_SIZE) return [full]
72
+
73
+ const sqls: string[] = []
74
+ let sql: string | undefined
75
+
76
+ valueRows.forEach(vrow => {
77
+ if (!sql) {
78
+ sql = start + vrow
79
+ } else {
80
+ if (sql.length + vrow.length >= MAX_ROW_SIZE) {
81
+ sqls.push(sql)
82
+ sql = start + vrow // reset
83
+ } else {
84
+ sql += ',\n ' + vrow // add
85
+ }
86
+ }
87
+ })
88
+
89
+ if (sql) {
90
+ sqls.push(sql) // last one
91
+ }
92
+
93
+ console.log(
94
+ `${white(table)} large sql query (${yellow(_hb(full.length))}) was split into ${yellow(
95
+ sqls.length,
96
+ )} chunks`,
97
+ )
98
+
99
+ return sqls
100
+
101
+ // todo: handle "upsert" later
102
+ }
103
+
104
+ export function insertSQLSingle(table: string, record: Record<any, any>): string {
105
+ // INSERT INTO table_name (column1, column2, column3, ...)
106
+ // VALUES (value1, value2, value3, ...);
107
+
108
+ const tokens = [
109
+ `INSERT INTO`,
110
+ mysql.escapeId(table),
111
+ `(` +
112
+ Object.keys(record)
113
+ .map(f => mysql.escapeId(mapNameToMySQL(f)))
114
+ .join(',') +
115
+ `)`,
116
+ `\nVALUES`,
117
+ `(` +
118
+ Object.values(record)
119
+ .map(v => mysql.escapeId(v))
120
+ .join(',') +
121
+ `)`,
122
+ ]
123
+
124
+ return tokens.join(' ')
125
+ }
126
+
127
+ export function insertSQLSetSingle(table: string, record: Record<any, any>): QueryOptions {
128
+ return {
129
+ sql: `INSERT INTO ${mysql.escapeId(table)} SET ?`,
130
+ values: [record],
131
+ }
132
+ }
133
+
134
+ export function dbQueryToSQLUpdate(q: DBQuery<any>, record: Record<any, any>): string {
135
+ // var sql = mysql.format('UPDATE posts SET modified = ? WHERE id = ?', [CURRENT_TIMESTAMP, 42]);
136
+ const tokens = [
137
+ `UPDATE`,
138
+ mysql.escapeId(q.table),
139
+ `SET`,
140
+ Object.keys(record)
141
+ .map(f => mysql.escapeId(mapNameToMySQL(f)) + ' = ?')
142
+ .join(', '),
143
+ ...whereTokens(q),
144
+ ]
145
+
146
+ return mysql.format(tokens.join(' '), Object.values(record))
147
+ }
148
+
149
+ function selectTokens(q: DBQuery<any>): string[] {
150
+ let fields = ['*']
151
+
152
+ if (q._selectedFieldNames) {
153
+ fields = q._selectedFieldNames.length ? q._selectedFieldNames : ['id']
154
+ }
155
+
156
+ // We don't do `escapeId` cause it'll ruin e.g SELECT `count *` FROM ...
157
+ return [`SELECT`, fields.map(f => mapNameToMySQL(f)).join(', '), `FROM`, mysql.escapeId(q.table)]
158
+ }
159
+
160
+ function offsetLimitTokens(q: DBQuery<any>): string[] {
161
+ const tokens: string[] = []
162
+
163
+ if (q._limitValue) {
164
+ tokens.push(`LIMIT`, String(q._limitValue))
165
+
166
+ // In SQL OFFSET is only allowed if LIMIT is set
167
+ if (q._offsetValue) {
168
+ tokens.push(`OFFSET`, String(q._offsetValue))
169
+ }
170
+ }
171
+
172
+ return tokens
173
+ }
174
+
175
+ function orderTokens(q: DBQuery<any>): string[] {
176
+ if (!q._orders.length) return []
177
+ return [
178
+ `ORDER BY`,
179
+ q._orders.map(o => `\`${o.name}\` ${o.descending ? 'DESC' : 'ASC'}`).join(', '),
180
+ ]
181
+ }
182
+
183
+ const OP_MAP: Partial<Record<DBQueryFilterOperator, string>> = {
184
+ '==': '=',
185
+ }
186
+
187
+ function whereTokens(q: DBQuery<any>): string[] {
188
+ if (!q._filters.length) return []
189
+
190
+ return [
191
+ `WHERE`,
192
+ q._filters
193
+ .map(f => {
194
+ if (f.val === null || f.val === undefined) {
195
+ // special treatment
196
+
197
+ return [
198
+ mysql.escapeId(mapNameToMySQL(f.name)),
199
+ f.op === '==' ? 'IS NULL' : 'IS NOT NULL',
200
+ ].join(' ')
201
+ }
202
+
203
+ if (Array.isArray(f.val)) {
204
+ // special case for arrays
205
+ return `${mysql.escapeId(mapNameToMySQL(f.name))} IN (${mysql.escape(f.val)})`
206
+ }
207
+
208
+ return [
209
+ mysql.escapeId(mapNameToMySQL(f.name)),
210
+ OP_MAP[f.op] || f.op,
211
+ mysql.escape(f.val),
212
+ ].join(' ')
213
+ })
214
+ .join(' AND '),
215
+ ]
216
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ JsonSchemaBoolean,
3
+ JsonSchemaNumber,
4
+ JsonSchemaObject,
5
+ JsonSchemaRootObject,
6
+ JsonSchemaString,
7
+ } from '@naturalcycles/js-lib'
8
+ import * as mysql from 'mysql'
9
+
10
+ export interface MySQLTableStats {
11
+ Field: string // created
12
+ Type: string // int(11)
13
+ Null: string // 'YES'
14
+ }
15
+
16
+ export interface MySQLSchemaOptions {
17
+ /**
18
+ * @default 'InnoDB'
19
+ */
20
+ engine?: string
21
+ }
22
+
23
+ /**
24
+ * It currently skips nullability and declares everything as "DEFAULT NULL".
25
+ */
26
+ export function jsonSchemaToMySQLDDL(
27
+ table: string,
28
+ schema: JsonSchemaObject<any>,
29
+ opt: MySQLSchemaOptions = {},
30
+ ): string {
31
+ const { engine = 'InnoDB' } = opt
32
+
33
+ const lines: string[] = [`CREATE TABLE ${mysql.escapeId(table)} (`]
34
+
35
+ const innerLines = Object.entries(schema.properties).map(([k, s]) => {
36
+ if (k === 'id') {
37
+ return `id VARCHAR(255) NOT NULL`
38
+ }
39
+
40
+ let type
41
+
42
+ if (s.type === 'string') {
43
+ // can specify isoDate later
44
+ type = 'LONGTEXT'
45
+ } else if (s.type === 'integer') {
46
+ type = 'INT(11)'
47
+ } else if (s.type === 'number') {
48
+ if (['unixTimestamp', 'int32'].includes((s as JsonSchemaNumber).format!)) {
49
+ type = 'INT(11)'
50
+ } else {
51
+ type = 'FLOAT(11)'
52
+ }
53
+ } else if (s.type === 'boolean') {
54
+ type = 'TINYINT(1)'
55
+ } else if (s.instanceof === 'Buffer') {
56
+ type = 'LONGBLOB'
57
+ } else if (s.type === 'null') {
58
+ type = 'VARCHAR(255)'
59
+ } else if (s.type === 'array') {
60
+ type = 'LONGTEXT' // to be JSON.stringified?
61
+ } else if (s.type === 'object') {
62
+ type = 'LONGTEXT' // to be JSON.stringified?
63
+ } else {
64
+ // default
65
+ type = 'LONGTEXT'
66
+ }
67
+
68
+ const tokens: string[] = [mysql.escapeId(mapNameToMySQL(k)), type, `DEFAULT NULL`]
69
+
70
+ return tokens.join(' ')
71
+ })
72
+
73
+ innerLines.push(`PRIMARY KEY (id)`)
74
+
75
+ lines.push(innerLines.join(',\n'))
76
+ lines.push(`) ENGINE=${engine}`)
77
+
78
+ return lines.join('\n')
79
+ }
80
+
81
+ export function mysqlTableStatsToJsonSchemaField<T = any>(
82
+ table: string,
83
+ stats: MySQLTableStats[],
84
+ ): JsonSchemaRootObject<T> {
85
+ const s: JsonSchemaRootObject<T> = {
86
+ $id: `${table}.schema.json`,
87
+ type: 'object',
88
+ properties: {} as any,
89
+ required: [],
90
+ additionalProperties: true,
91
+ }
92
+
93
+ stats.forEach(stat => {
94
+ const name = stat.Field
95
+ const t = stat.Type.toLowerCase()
96
+ const notNull = stat.Null?.toUpperCase() !== 'YES'
97
+ if (notNull) {
98
+ s.required.push(name as any)
99
+ }
100
+
101
+ if (t.includes('text') || t.includes('char')) {
102
+ s.properties[name] = { type: 'string' } as JsonSchemaString
103
+ } else if (t.includes('lob')) {
104
+ s.properties[name] = { instanceof: 'Buffer' }
105
+ } else if (t.startsWith('tinyint') || t.includes('(1)')) {
106
+ s.properties[name] = { type: 'boolean' } as JsonSchemaBoolean
107
+ } else if (t.startsWith('int(')) {
108
+ s.properties[name] = { type: 'integer' } as JsonSchemaNumber
109
+ } else if (t.startsWith('float')) {
110
+ s.properties[name] = { type: 'number' } as JsonSchemaNumber
111
+ } else {
112
+ console.log(s)
113
+ throw new Error(`Unknown mysql field type ${name} ${stat.Type}`)
114
+ }
115
+ })
116
+
117
+ return s
118
+ }
119
+
120
+ /**
121
+ * Because MySQL doesn't support `.` in field names and escapes them as tableName + fieldName.
122
+ *
123
+ * @param name
124
+ */
125
+ export function mapNameToMySQL(name: string): string {
126
+ return name.replace(/\./g, '_dot_')
127
+ }
128
+
129
+ export function mapNameFromMySQL(name: string): string {
130
+ return name.replace(/_dot_/g, '.')
131
+ }