@jskit-ai/database-runtime-mysql 0.1.15 → 0.1.17
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/package.descriptor.mjs +3 -2
- package/package.json +2 -2
- package/src/server/providers/DatabaseRuntimeMysqlServiceProvider.js +2 -4
- package/src/shared/index.js +1 -0
- package/src/shared/introspectCrudTable.js +384 -0
- package/test/entrypoints.boundary.test.js +1 -0
- package/test/introspectCrudTable.test.js +265 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/database-runtime-mysql",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.17",
|
|
5
|
+
kind: "runtime",
|
|
5
6
|
options: {
|
|
6
7
|
"db-host": {
|
|
7
8
|
required: false,
|
|
@@ -90,7 +91,7 @@ export default Object.freeze({
|
|
|
90
91
|
mutations: {
|
|
91
92
|
dependencies: {
|
|
92
93
|
runtime: {
|
|
93
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
94
|
+
"@jskit-ai/database-runtime": "0.1.18",
|
|
94
95
|
"mysql2": "^3.11.2"
|
|
95
96
|
},
|
|
96
97
|
dev: {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/database-runtime-mysql",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
"./shared/dialect": "./src/shared/dialect.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
15
|
+
"@jskit-ai/database-runtime": "0.1.18"
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import * as mysqlDialect from "../../shared/index.js";
|
|
2
2
|
|
|
3
|
-
const DATABASE_DRIVER_MYSQL_TOKEN = "runtime.database.driver.mysql";
|
|
4
|
-
|
|
5
3
|
const MYSQL_DATABASE_DRIVER_API = Object.freeze({
|
|
6
4
|
...mysqlDialect
|
|
7
5
|
});
|
|
8
6
|
|
|
9
7
|
class DatabaseRuntimeMysqlServiceProvider {
|
|
10
|
-
static id =
|
|
8
|
+
static id = "runtime.database.driver.mysql";
|
|
11
9
|
|
|
12
10
|
register(app) {
|
|
13
11
|
if (!app || typeof app.singleton !== "function") {
|
|
14
12
|
throw new Error("DatabaseRuntimeMysqlServiceProvider requires application singleton().");
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
app.singleton(
|
|
15
|
+
app.singleton("runtime.database.driver.mysql", () => MYSQL_DATABASE_DRIVER_API);
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
boot() {}
|
package/src/shared/index.js
CHANGED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { normalizeText } from "@jskit-ai/database-runtime/shared";
|
|
2
|
+
import { toCamelCase } from "@jskit-ai/kernel/shared/support/stringCase";
|
|
3
|
+
|
|
4
|
+
const BOOLEAN_TINYINT_PATTERN = /^tinyint\(1\)/;
|
|
5
|
+
const TABLE_NAME_PATTERN = /^[A-Za-z0-9_]+$/;
|
|
6
|
+
|
|
7
|
+
function requireKnexRaw(knex) {
|
|
8
|
+
if (!knex || typeof knex.raw !== "function") {
|
|
9
|
+
throw new TypeError("introspectCrudTableSnapshot requires knex with raw().");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireTableName(value) {
|
|
14
|
+
const tableName = normalizeText(value);
|
|
15
|
+
if (!tableName) {
|
|
16
|
+
throw new TypeError("introspectCrudTableSnapshot requires tableName.");
|
|
17
|
+
}
|
|
18
|
+
if (!TABLE_NAME_PATTERN.test(tableName)) {
|
|
19
|
+
throw new Error(`Invalid table name "${tableName}". Use letters, numbers, and underscore only.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return tableName;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeRows(rawResult) {
|
|
26
|
+
if (Array.isArray(rawResult)) {
|
|
27
|
+
if (rawResult.length > 0 && Array.isArray(rawResult[0])) {
|
|
28
|
+
return rawResult[0];
|
|
29
|
+
}
|
|
30
|
+
return rawResult;
|
|
31
|
+
}
|
|
32
|
+
if (rawResult && typeof rawResult === "object" && Array.isArray(rawResult.rows)) {
|
|
33
|
+
return rawResult.rows;
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeDbSchemaName(rows = []) {
|
|
39
|
+
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
|
40
|
+
const schemaName = normalizeText(firstRow?.schemaName || firstRow?.schema_name || "");
|
|
41
|
+
if (!schemaName) {
|
|
42
|
+
throw new Error("Could not resolve current database schema name.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return schemaName;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toBoolean(value) {
|
|
49
|
+
if (typeof value === "boolean") {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === "number") {
|
|
53
|
+
return value !== 0;
|
|
54
|
+
}
|
|
55
|
+
const text = normalizeText(value).toLowerCase();
|
|
56
|
+
return text === "1" || text === "true" || text === "yes" || text === "y";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toNullableNumber(value) {
|
|
60
|
+
if (value == null) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
64
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeColumnDefault(value) {
|
|
68
|
+
if (value == null) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
73
|
+
if (normalized === "null") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseEnumValues(columnType = "") {
|
|
81
|
+
const source = normalizeText(columnType);
|
|
82
|
+
if (!source.toLowerCase().startsWith("enum(") || !source.endsWith(")")) {
|
|
83
|
+
return Object.freeze([]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = source.slice(5, -1);
|
|
87
|
+
const values = [];
|
|
88
|
+
const pattern = /'((?:\\'|[^'])*)'/g;
|
|
89
|
+
let match = null;
|
|
90
|
+
while ((match = pattern.exec(body)) != null) {
|
|
91
|
+
values.push(match[1].replace(/\\'/g, "'"));
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze(values);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveTypeKind(column) {
|
|
97
|
+
const dataType = normalizeText(column?.dataType).toLowerCase();
|
|
98
|
+
const columnType = normalizeText(column?.columnType).toLowerCase();
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
dataType === "varchar" ||
|
|
102
|
+
dataType === "char" ||
|
|
103
|
+
dataType === "text" ||
|
|
104
|
+
dataType === "tinytext" ||
|
|
105
|
+
dataType === "mediumtext" ||
|
|
106
|
+
dataType === "longtext" ||
|
|
107
|
+
dataType === "enum" ||
|
|
108
|
+
dataType === "set"
|
|
109
|
+
) {
|
|
110
|
+
return "string";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
dataType === "int" ||
|
|
115
|
+
dataType === "integer" ||
|
|
116
|
+
dataType === "smallint" ||
|
|
117
|
+
dataType === "mediumint" ||
|
|
118
|
+
dataType === "bigint"
|
|
119
|
+
) {
|
|
120
|
+
return "integer";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (dataType === "tinyint") {
|
|
124
|
+
if (BOOLEAN_TINYINT_PATTERN.test(columnType)) {
|
|
125
|
+
return "boolean";
|
|
126
|
+
}
|
|
127
|
+
return "integer";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
dataType === "decimal" ||
|
|
132
|
+
dataType === "numeric" ||
|
|
133
|
+
dataType === "float" ||
|
|
134
|
+
dataType === "double" ||
|
|
135
|
+
dataType === "real"
|
|
136
|
+
) {
|
|
137
|
+
return "number";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (dataType === "boolean" || dataType === "bool") {
|
|
141
|
+
return "boolean";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (dataType === "datetime" || dataType === "timestamp") {
|
|
145
|
+
return "datetime";
|
|
146
|
+
}
|
|
147
|
+
if (dataType === "date") {
|
|
148
|
+
return "date";
|
|
149
|
+
}
|
|
150
|
+
if (dataType === "time") {
|
|
151
|
+
return "time";
|
|
152
|
+
}
|
|
153
|
+
if (dataType === "json") {
|
|
154
|
+
return "json";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Unsupported MySQL column type "${dataType}" for column "${String(column?.name || "")}".`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeColumn(row = {}) {
|
|
163
|
+
const name = normalizeText(row.columnName || row.column_name);
|
|
164
|
+
if (!name) {
|
|
165
|
+
throw new Error("MySQL introspection returned a column without column name.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const dataType = normalizeText(row.dataType || row.data_type).toLowerCase();
|
|
169
|
+
const columnType = normalizeText(row.columnType || row.column_type);
|
|
170
|
+
const columnTypeLower = columnType.toLowerCase();
|
|
171
|
+
const nullable = normalizeText(row.isNullable || row.is_nullable).toUpperCase() === "YES";
|
|
172
|
+
const rawDefaultValue = Object.prototype.hasOwnProperty.call(row, "columnDefault")
|
|
173
|
+
? row.columnDefault
|
|
174
|
+
: Object.prototype.hasOwnProperty.call(row, "column_default")
|
|
175
|
+
? row.column_default
|
|
176
|
+
: null;
|
|
177
|
+
const defaultValue = normalizeColumnDefault(rawDefaultValue);
|
|
178
|
+
const hasDefault = defaultValue != null;
|
|
179
|
+
const extra = normalizeText(row.extra).toLowerCase();
|
|
180
|
+
const autoIncrement = extra.includes("auto_increment");
|
|
181
|
+
const unsigned = columnTypeLower.includes("unsigned");
|
|
182
|
+
const enumValues = parseEnumValues(columnType);
|
|
183
|
+
|
|
184
|
+
const normalized = Object.freeze({
|
|
185
|
+
name,
|
|
186
|
+
key: toCamelCase(name),
|
|
187
|
+
dataType,
|
|
188
|
+
columnType,
|
|
189
|
+
extra,
|
|
190
|
+
typeKind: resolveTypeKind({
|
|
191
|
+
name,
|
|
192
|
+
dataType,
|
|
193
|
+
columnType
|
|
194
|
+
}),
|
|
195
|
+
nullable,
|
|
196
|
+
defaultValue,
|
|
197
|
+
hasDefault,
|
|
198
|
+
autoIncrement,
|
|
199
|
+
unsigned,
|
|
200
|
+
maxLength: toNullableNumber(row.characterMaximumLength ?? row.character_maximum_length),
|
|
201
|
+
numericPrecision: toNullableNumber(row.numericPrecision ?? row.numeric_precision),
|
|
202
|
+
numericScale: toNullableNumber(row.numericScale ?? row.numeric_scale),
|
|
203
|
+
datetimePrecision: toNullableNumber(row.datetimePrecision ?? row.datetime_precision),
|
|
204
|
+
ordinalPosition: toNullableNumber(row.ordinalPosition ?? row.ordinal_position),
|
|
205
|
+
enumValues
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return normalized;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizePrimaryKeyColumns(rows = []) {
|
|
212
|
+
return Object.freeze(
|
|
213
|
+
rows
|
|
214
|
+
.map((row) => normalizeText(row.columnName || row.column_name))
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeIndexes(rows = []) {
|
|
220
|
+
const byName = new Map();
|
|
221
|
+
|
|
222
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
223
|
+
const indexName = normalizeText(row.indexName || row.index_name);
|
|
224
|
+
const columnName = normalizeText(row.columnName || row.column_name);
|
|
225
|
+
if (!indexName || !columnName) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const seqInIndex = toNullableNumber(row.seqInIndex ?? row.seq_in_index) || 0;
|
|
230
|
+
const nonUnique = toBoolean(row.nonUnique ?? row.non_unique);
|
|
231
|
+
const existing = byName.get(indexName) || {
|
|
232
|
+
name: indexName,
|
|
233
|
+
unique: !nonUnique,
|
|
234
|
+
columns: []
|
|
235
|
+
};
|
|
236
|
+
existing.columns.push({
|
|
237
|
+
name: columnName,
|
|
238
|
+
order: seqInIndex
|
|
239
|
+
});
|
|
240
|
+
byName.set(indexName, existing);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Object.freeze(
|
|
244
|
+
[...byName.values()]
|
|
245
|
+
.map((index) =>
|
|
246
|
+
Object.freeze({
|
|
247
|
+
name: index.name,
|
|
248
|
+
unique: index.unique,
|
|
249
|
+
columns: Object.freeze(
|
|
250
|
+
index.columns
|
|
251
|
+
.sort((left, right) => left.order - right.order)
|
|
252
|
+
.map((column) => column.name)
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function requireIdColumn(columns, idColumn) {
|
|
261
|
+
const normalizedIdColumn = normalizeText(idColumn) || "id";
|
|
262
|
+
const idSpec = columns.find((column) => column.name === normalizedIdColumn) || null;
|
|
263
|
+
if (!idSpec) {
|
|
264
|
+
throw new Error(`Could not find id column "${normalizedIdColumn}" in table.`);
|
|
265
|
+
}
|
|
266
|
+
if (idSpec.typeKind !== "integer") {
|
|
267
|
+
throw new Error(`Id column "${normalizedIdColumn}" must use an integer type.`);
|
|
268
|
+
}
|
|
269
|
+
if (idSpec.nullable) {
|
|
270
|
+
throw new Error(`Id column "${normalizedIdColumn}" must be not-null.`);
|
|
271
|
+
}
|
|
272
|
+
if (!idSpec.autoIncrement && !idSpec.hasDefault) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Id column "${normalizedIdColumn}" must be auto_increment or have a database default.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return normalizedIdColumn;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function requirePrimaryKeyContainsId(primaryKeyColumns, idColumn) {
|
|
282
|
+
if (!Array.isArray(primaryKeyColumns) || !primaryKeyColumns.includes(idColumn)) {
|
|
283
|
+
throw new Error(`Primary key must include id column "${idColumn}".`);
|
|
284
|
+
}
|
|
285
|
+
if (primaryKeyColumns.length !== 1 || primaryKeyColumns[0] !== idColumn) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Composite primary keys are not supported for CRUD generation. Primary key must be only "${idColumn}".`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function introspectCrudTableSnapshot(knex, { tableName = "", idColumn = "id" } = {}) {
|
|
293
|
+
requireKnexRaw(knex);
|
|
294
|
+
const resolvedTableName = requireTableName(tableName);
|
|
295
|
+
|
|
296
|
+
const schemaRows = normalizeRows(await knex.raw("SELECT DATABASE() AS schemaName"));
|
|
297
|
+
const schemaName = normalizeDbSchemaName(schemaRows);
|
|
298
|
+
|
|
299
|
+
const columnRows = normalizeRows(
|
|
300
|
+
await knex.raw(
|
|
301
|
+
`
|
|
302
|
+
SELECT
|
|
303
|
+
c.column_name AS columnName,
|
|
304
|
+
c.data_type AS dataType,
|
|
305
|
+
c.column_type AS columnType,
|
|
306
|
+
c.is_nullable AS isNullable,
|
|
307
|
+
c.column_default AS columnDefault,
|
|
308
|
+
c.extra AS extra,
|
|
309
|
+
c.character_maximum_length AS characterMaximumLength,
|
|
310
|
+
c.numeric_precision AS numericPrecision,
|
|
311
|
+
c.numeric_scale AS numericScale,
|
|
312
|
+
c.datetime_precision AS datetimePrecision,
|
|
313
|
+
c.ordinal_position AS ordinalPosition
|
|
314
|
+
FROM information_schema.columns c
|
|
315
|
+
WHERE c.table_schema = ?
|
|
316
|
+
AND c.table_name = ?
|
|
317
|
+
ORDER BY c.ordinal_position ASC
|
|
318
|
+
`,
|
|
319
|
+
[schemaName, resolvedTableName]
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
if (columnRows.length < 1) {
|
|
323
|
+
throw new Error(`Could not introspect table "${resolvedTableName}" in schema "${schemaName}".`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const primaryRows = normalizeRows(
|
|
327
|
+
await knex.raw(
|
|
328
|
+
`
|
|
329
|
+
SELECT
|
|
330
|
+
k.column_name AS columnName,
|
|
331
|
+
k.ordinal_position AS ordinalPosition
|
|
332
|
+
FROM information_schema.table_constraints t
|
|
333
|
+
JOIN information_schema.key_column_usage k
|
|
334
|
+
ON k.constraint_name = t.constraint_name
|
|
335
|
+
AND k.table_schema = t.table_schema
|
|
336
|
+
AND k.table_name = t.table_name
|
|
337
|
+
WHERE t.table_schema = ?
|
|
338
|
+
AND t.table_name = ?
|
|
339
|
+
AND t.constraint_type = 'PRIMARY KEY'
|
|
340
|
+
ORDER BY k.ordinal_position ASC
|
|
341
|
+
`,
|
|
342
|
+
[schemaName, resolvedTableName]
|
|
343
|
+
)
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const indexRows = normalizeRows(
|
|
347
|
+
await knex.raw(
|
|
348
|
+
`
|
|
349
|
+
SELECT
|
|
350
|
+
s.index_name AS indexName,
|
|
351
|
+
s.non_unique AS nonUnique,
|
|
352
|
+
s.column_name AS columnName,
|
|
353
|
+
s.seq_in_index AS seqInIndex
|
|
354
|
+
FROM information_schema.statistics s
|
|
355
|
+
WHERE s.table_schema = ?
|
|
356
|
+
AND s.table_name = ?
|
|
357
|
+
AND s.index_name <> 'PRIMARY'
|
|
358
|
+
ORDER BY s.index_name ASC, s.seq_in_index ASC
|
|
359
|
+
`,
|
|
360
|
+
[schemaName, resolvedTableName]
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const columns = Object.freeze(columnRows.map((row) => normalizeColumn(row)));
|
|
365
|
+
const resolvedIdColumn = requireIdColumn(columns, idColumn);
|
|
366
|
+
const primaryKeyColumns = normalizePrimaryKeyColumns(primaryRows);
|
|
367
|
+
requirePrimaryKeyContainsId(primaryKeyColumns, resolvedIdColumn);
|
|
368
|
+
|
|
369
|
+
const snapshot = Object.freeze({
|
|
370
|
+
dialect: "mysql2",
|
|
371
|
+
schemaName,
|
|
372
|
+
tableName: resolvedTableName,
|
|
373
|
+
idColumn: resolvedIdColumn,
|
|
374
|
+
primaryKeyColumns,
|
|
375
|
+
hasWorkspaceOwnerColumn: columns.some((column) => column.name === "workspace_owner_id"),
|
|
376
|
+
hasUserOwnerColumn: columns.some((column) => column.name === "user_owner_id"),
|
|
377
|
+
columns,
|
|
378
|
+
indexes: normalizeIndexes(indexRows)
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return snapshot;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export { introspectCrudTableSnapshot };
|
|
@@ -28,5 +28,6 @@ test("server provider module exports mysql service provider only", () => {
|
|
|
28
28
|
test("shared entrypoint exports mysql dialect helpers", () => {
|
|
29
29
|
assert.equal(sharedApi.DIALECT_ID, "mysql2");
|
|
30
30
|
assert.equal(sharedApi.getDialectId(), "mysql2");
|
|
31
|
+
assert.equal(typeof sharedApi.introspectCrudTableSnapshot, "function");
|
|
31
32
|
assert.equal(typeof sharedApi.DatabaseRuntimeMysqlServiceProvider, "undefined");
|
|
32
33
|
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { introspectCrudTableSnapshot } from "../src/shared/introspectCrudTable.js";
|
|
5
|
+
|
|
6
|
+
function createKnexRawDouble({
|
|
7
|
+
schemaName = "appdb",
|
|
8
|
+
columns = [],
|
|
9
|
+
primaryKeyColumns = [],
|
|
10
|
+
indexes = []
|
|
11
|
+
} = {}) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
|
|
14
|
+
const knex = {
|
|
15
|
+
async raw(sql, bindings = []) {
|
|
16
|
+
const normalizedSql = String(sql || "").toLowerCase();
|
|
17
|
+
calls.push({
|
|
18
|
+
sql: normalizedSql,
|
|
19
|
+
bindings: Array.isArray(bindings) ? [...bindings] : []
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (normalizedSql.includes("select database() as schemaname")) {
|
|
23
|
+
return [[{ schemaName }], []];
|
|
24
|
+
}
|
|
25
|
+
if (normalizedSql.includes("from information_schema.columns")) {
|
|
26
|
+
return [[...columns], []];
|
|
27
|
+
}
|
|
28
|
+
if (normalizedSql.includes("from information_schema.table_constraints")) {
|
|
29
|
+
return [[...primaryKeyColumns], []];
|
|
30
|
+
}
|
|
31
|
+
if (normalizedSql.includes("from information_schema.statistics")) {
|
|
32
|
+
return [[...indexes], []];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new Error(`Unexpected SQL in test double: ${normalizedSql}`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
knex,
|
|
41
|
+
calls
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("introspectCrudTableSnapshot maps MySQL table metadata to normalized snapshot", async () => {
|
|
46
|
+
const { knex } = createKnexRawDouble({
|
|
47
|
+
columns: [
|
|
48
|
+
{
|
|
49
|
+
columnName: "id",
|
|
50
|
+
dataType: "int",
|
|
51
|
+
columnType: "int unsigned",
|
|
52
|
+
isNullable: "NO",
|
|
53
|
+
columnDefault: null,
|
|
54
|
+
extra: "auto_increment",
|
|
55
|
+
characterMaximumLength: null,
|
|
56
|
+
numericPrecision: 10,
|
|
57
|
+
numericScale: 0,
|
|
58
|
+
datetimePrecision: null,
|
|
59
|
+
ordinalPosition: 1
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
columnName: "workspace_owner_id",
|
|
63
|
+
dataType: "int",
|
|
64
|
+
columnType: "int unsigned",
|
|
65
|
+
isNullable: "YES",
|
|
66
|
+
columnDefault: "NULL",
|
|
67
|
+
extra: "",
|
|
68
|
+
characterMaximumLength: null,
|
|
69
|
+
numericPrecision: 10,
|
|
70
|
+
numericScale: 0,
|
|
71
|
+
datetimePrecision: null,
|
|
72
|
+
ordinalPosition: 2
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
columnName: "user_owner_id",
|
|
76
|
+
dataType: "int",
|
|
77
|
+
columnType: "int unsigned",
|
|
78
|
+
isNullable: "YES",
|
|
79
|
+
columnDefault: "NULL",
|
|
80
|
+
extra: "",
|
|
81
|
+
characterMaximumLength: null,
|
|
82
|
+
numericPrecision: 10,
|
|
83
|
+
numericScale: 0,
|
|
84
|
+
datetimePrecision: null,
|
|
85
|
+
ordinalPosition: 3
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
columnName: "first_name",
|
|
89
|
+
dataType: "varchar",
|
|
90
|
+
columnType: "varchar(160)",
|
|
91
|
+
isNullable: "NO",
|
|
92
|
+
columnDefault: null,
|
|
93
|
+
extra: "",
|
|
94
|
+
characterMaximumLength: 160,
|
|
95
|
+
numericPrecision: null,
|
|
96
|
+
numericScale: null,
|
|
97
|
+
datetimePrecision: null,
|
|
98
|
+
ordinalPosition: 4
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
columnName: "vip",
|
|
102
|
+
dataType: "tinyint",
|
|
103
|
+
columnType: "tinyint(1)",
|
|
104
|
+
isNullable: "NO",
|
|
105
|
+
columnDefault: "0",
|
|
106
|
+
extra: "",
|
|
107
|
+
characterMaximumLength: null,
|
|
108
|
+
numericPrecision: 3,
|
|
109
|
+
numericScale: 0,
|
|
110
|
+
datetimePrecision: null,
|
|
111
|
+
ordinalPosition: 5
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
columnName: "contact_tier",
|
|
115
|
+
dataType: "enum",
|
|
116
|
+
columnType: "enum('VIP','New')",
|
|
117
|
+
isNullable: "NO",
|
|
118
|
+
columnDefault: "VIP",
|
|
119
|
+
extra: "",
|
|
120
|
+
characterMaximumLength: null,
|
|
121
|
+
numericPrecision: null,
|
|
122
|
+
numericScale: null,
|
|
123
|
+
datetimePrecision: null,
|
|
124
|
+
ordinalPosition: 6
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
columnName: "updated_at",
|
|
128
|
+
dataType: "datetime",
|
|
129
|
+
columnType: "datetime",
|
|
130
|
+
isNullable: "NO",
|
|
131
|
+
columnDefault: "CURRENT_TIMESTAMP",
|
|
132
|
+
extra: "",
|
|
133
|
+
characterMaximumLength: null,
|
|
134
|
+
numericPrecision: null,
|
|
135
|
+
numericScale: null,
|
|
136
|
+
datetimePrecision: 0,
|
|
137
|
+
ordinalPosition: 7
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
primaryKeyColumns: [{ columnName: "id" }],
|
|
141
|
+
indexes: [
|
|
142
|
+
{
|
|
143
|
+
indexName: "idx_contacts_first_name",
|
|
144
|
+
nonUnique: 1,
|
|
145
|
+
columnName: "first_name",
|
|
146
|
+
seqInIndex: 1
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
indexName: "uq_contacts_vip",
|
|
150
|
+
nonUnique: 0,
|
|
151
|
+
columnName: "vip",
|
|
152
|
+
seqInIndex: 1
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const snapshot = await introspectCrudTableSnapshot(knex, {
|
|
158
|
+
tableName: "contacts",
|
|
159
|
+
idColumn: "id"
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
assert.equal(snapshot.dialect, "mysql2");
|
|
163
|
+
assert.equal(snapshot.tableName, "contacts");
|
|
164
|
+
assert.equal(snapshot.idColumn, "id");
|
|
165
|
+
assert.deepEqual(snapshot.primaryKeyColumns, ["id"]);
|
|
166
|
+
assert.equal(snapshot.hasWorkspaceOwnerColumn, true);
|
|
167
|
+
assert.equal(snapshot.hasUserOwnerColumn, true);
|
|
168
|
+
|
|
169
|
+
const firstName = snapshot.columns.find((column) => column.name === "first_name");
|
|
170
|
+
assert.ok(firstName);
|
|
171
|
+
assert.equal(firstName.key, "firstName");
|
|
172
|
+
assert.equal(firstName.typeKind, "string");
|
|
173
|
+
assert.equal(firstName.maxLength, 160);
|
|
174
|
+
|
|
175
|
+
const workspaceOwnerId = snapshot.columns.find((column) => column.name === "workspace_owner_id");
|
|
176
|
+
assert.ok(workspaceOwnerId);
|
|
177
|
+
assert.equal(workspaceOwnerId.hasDefault, false);
|
|
178
|
+
|
|
179
|
+
const vip = snapshot.columns.find((column) => column.name === "vip");
|
|
180
|
+
assert.ok(vip);
|
|
181
|
+
assert.equal(vip.typeKind, "boolean");
|
|
182
|
+
assert.equal(vip.hasDefault, true);
|
|
183
|
+
|
|
184
|
+
const contactTier = snapshot.columns.find((column) => column.name === "contact_tier");
|
|
185
|
+
assert.ok(contactTier);
|
|
186
|
+
assert.deepEqual(contactTier.enumValues, ["VIP", "New"]);
|
|
187
|
+
|
|
188
|
+
assert.deepEqual(snapshot.indexes, [
|
|
189
|
+
{
|
|
190
|
+
name: "idx_contacts_first_name",
|
|
191
|
+
unique: false,
|
|
192
|
+
columns: ["first_name"]
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "uq_contacts_vip",
|
|
196
|
+
unique: true,
|
|
197
|
+
columns: ["vip"]
|
|
198
|
+
}
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("introspectCrudTableSnapshot rejects unsupported column types", async () => {
|
|
203
|
+
const { knex } = createKnexRawDouble({
|
|
204
|
+
columns: [
|
|
205
|
+
{
|
|
206
|
+
columnName: "id",
|
|
207
|
+
dataType: "int",
|
|
208
|
+
columnType: "int unsigned",
|
|
209
|
+
isNullable: "NO",
|
|
210
|
+
columnDefault: null,
|
|
211
|
+
extra: "auto_increment",
|
|
212
|
+
characterMaximumLength: null,
|
|
213
|
+
numericPrecision: 10,
|
|
214
|
+
numericScale: 0,
|
|
215
|
+
datetimePrecision: null,
|
|
216
|
+
ordinalPosition: 1
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
columnName: "location",
|
|
220
|
+
dataType: "point",
|
|
221
|
+
columnType: "point",
|
|
222
|
+
isNullable: "YES",
|
|
223
|
+
columnDefault: null,
|
|
224
|
+
extra: "",
|
|
225
|
+
characterMaximumLength: null,
|
|
226
|
+
numericPrecision: null,
|
|
227
|
+
numericScale: null,
|
|
228
|
+
datetimePrecision: null,
|
|
229
|
+
ordinalPosition: 2
|
|
230
|
+
}
|
|
231
|
+
],
|
|
232
|
+
primaryKeyColumns: [{ columnName: "id" }]
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await assert.rejects(
|
|
236
|
+
() => introspectCrudTableSnapshot(knex, { tableName: "contacts" }),
|
|
237
|
+
/Unsupported MySQL column type "point"/
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("introspectCrudTableSnapshot rejects when primary key does not include id column", async () => {
|
|
242
|
+
const { knex } = createKnexRawDouble({
|
|
243
|
+
columns: [
|
|
244
|
+
{
|
|
245
|
+
columnName: "id",
|
|
246
|
+
dataType: "int",
|
|
247
|
+
columnType: "int unsigned",
|
|
248
|
+
isNullable: "NO",
|
|
249
|
+
columnDefault: null,
|
|
250
|
+
extra: "auto_increment",
|
|
251
|
+
characterMaximumLength: null,
|
|
252
|
+
numericPrecision: 10,
|
|
253
|
+
numericScale: 0,
|
|
254
|
+
datetimePrecision: null,
|
|
255
|
+
ordinalPosition: 1
|
|
256
|
+
}
|
|
257
|
+
],
|
|
258
|
+
primaryKeyColumns: [{ columnName: "other_id" }]
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
() => introspectCrudTableSnapshot(knex, { tableName: "contacts" }),
|
|
263
|
+
/Primary key must include id column "id"/
|
|
264
|
+
);
|
|
265
|
+
});
|