@jskit-ai/database-runtime-mysql 0.1.127 → 0.1.129

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.
@@ -13,7 +13,7 @@ const CI_DATABASE = Object.freeze({
13
13
  export default Object.freeze({
14
14
  packageVersion: 1,
15
15
  packageId: "@jskit-ai/database-runtime-mysql",
16
- version: "0.1.127",
16
+ version: "0.1.129",
17
17
  kind: "runtime",
18
18
  options: {
19
19
  "db-host": {
@@ -133,7 +133,7 @@ export default Object.freeze({
133
133
  mutations: {
134
134
  dependencies: {
135
135
  runtime: {
136
- "@jskit-ai/database-runtime": "0.1.128",
136
+ "@jskit-ai/database-runtime": "0.1.130",
137
137
  "mysql2": "^3.11.2"
138
138
  },
139
139
  dev: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/database-runtime-mysql",
3
- "version": "0.1.127",
3
+ "version": "0.1.129",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -12,7 +12,7 @@
12
12
  "./shared/dialect": "./src/shared/dialect.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/database-runtime": "0.1.128",
16
- "@jskit-ai/kernel": "0.1.129"
15
+ "@jskit-ai/database-runtime": "0.1.130",
16
+ "@jskit-ai/kernel": "0.1.131"
17
17
  }
18
18
  }
@@ -2,7 +2,11 @@ import { normalizeText } from "@jskit-ai/database-runtime/shared";
2
2
  import { toCamelCase } from "@jskit-ai/kernel/shared/support/stringCase";
3
3
 
4
4
  const BOOLEAN_TINYINT_PATTERN = /^tinyint\(1\)/;
5
+ const CURRENT_TIMESTAMP_EXPRESSION_PATTERN = /^current_timestamp(?:\((\d*)\))?$/i;
6
+ const ON_UPDATE_CURRENT_TIMESTAMP_PATTERN =
7
+ /(?:^|\s)on\s+update\s+(current_timestamp(?:\((\d*)\))?)(?:\s|$)/i;
5
8
  const TABLE_NAME_PATTERN = /^[A-Za-z0-9_]+$/;
9
+ const TEMPORAL_DATA_TYPES = new Set(["datetime", "timestamp"]);
6
10
 
7
11
  function requireKnexRaw(knex) {
8
12
  if (!knex || typeof knex.raw !== "function") {
@@ -77,6 +81,31 @@ function normalizeColumnDefault(value) {
77
81
  return value;
78
82
  }
79
83
 
84
+ function normalizeCurrentTimestampExpression(value) {
85
+ const match = normalizeText(value).match(CURRENT_TIMESTAMP_EXPRESSION_PATTERN);
86
+ if (!match) {
87
+ return null;
88
+ }
89
+
90
+ const precision =
91
+ match[1] == null || match[1] === ""
92
+ ? null
93
+ : Number.parseInt(match[1], 10);
94
+ if (precision != null && (!Number.isInteger(precision) || precision < 0 || precision > 6)) {
95
+ return null;
96
+ }
97
+
98
+ return Object.freeze({
99
+ kind: "current_timestamp",
100
+ precision
101
+ });
102
+ }
103
+
104
+ function normalizeOnUpdateExpression(value) {
105
+ const match = normalizeText(value).match(ON_UPDATE_CURRENT_TIMESTAMP_PATTERN);
106
+ return match ? normalizeCurrentTimestampExpression(match[1]) : null;
107
+ }
108
+
80
109
  function parseEnumValues(columnType = "") {
81
110
  const source = normalizeText(columnType);
82
111
  if (!source.toLowerCase().startsWith("enum(") || !source.endsWith(")")) {
@@ -180,6 +209,7 @@ function normalizeColumn(row = {}) {
180
209
  const autoIncrement = extra.includes("auto_increment");
181
210
  const unsigned = columnTypeLower.includes("unsigned");
182
211
  const enumValues = parseEnumValues(columnType);
212
+ const supportsTemporalExpressions = TEMPORAL_DATA_TYPES.has(dataType);
183
213
 
184
214
  const normalized = Object.freeze({
185
215
  name,
@@ -194,8 +224,14 @@ function normalizeColumn(row = {}) {
194
224
  }),
195
225
  nullable,
196
226
  defaultValue,
227
+ defaultExpression: supportsTemporalExpressions
228
+ ? normalizeCurrentTimestampExpression(defaultValue)
229
+ : null,
197
230
  hasDefault,
198
231
  autoIncrement,
232
+ onUpdateExpression: supportsTemporalExpressions
233
+ ? normalizeOnUpdateExpression(extra)
234
+ : null,
199
235
  unsigned,
200
236
  maxLength: toNullableNumber(row.characterMaximumLength ?? row.character_maximum_length),
201
237
  numericPrecision: toNullableNumber(row.numericPrecision ?? row.numeric_precision),
@@ -218,6 +254,36 @@ function normalizePrimaryKeyColumns(rows = []) {
218
254
  );
219
255
  }
220
256
 
257
+ function normalizePrimaryKeyColumnsByTable(rows = []) {
258
+ const grouped = new Map();
259
+
260
+ for (const row of Array.isArray(rows) ? rows : []) {
261
+ const tableName = normalizeText(row.tableName || row.table_name);
262
+ const columnName = normalizeText(row.columnName || row.column_name);
263
+ if (!tableName || !columnName) {
264
+ continue;
265
+ }
266
+
267
+ const columns = grouped.get(tableName) || [];
268
+ columns.push({
269
+ name: columnName,
270
+ order: toNullableNumber(row.ordinalPosition ?? row.ordinal_position) || 0
271
+ });
272
+ grouped.set(tableName, columns);
273
+ }
274
+
275
+ return new Map(
276
+ [...grouped.entries()].map(([tableName, columns]) => [
277
+ tableName,
278
+ Object.freeze(
279
+ columns
280
+ .sort((left, right) => left.order - right.order)
281
+ .map((column) => column.name)
282
+ )
283
+ ])
284
+ );
285
+ }
286
+
221
287
  function normalizeIndexes(rows = []) {
222
288
  const byName = new Map();
223
289
 
@@ -369,6 +435,32 @@ function requirePrimaryKeyContainsId(primaryKeyColumns, idColumn) {
369
435
  }
370
436
  }
371
437
 
438
+ function requireSupportedForeignKeys(foreignKeys, primaryKeyColumnsByTable) {
439
+ for (const foreignKey of foreignKeys) {
440
+ const columns = Array.isArray(foreignKey?.columns) ? foreignKey.columns : [];
441
+ if (columns.length !== 1) {
442
+ throw new Error(
443
+ `CRUD generation supports only single-column foreign keys. Constraint "${foreignKey.name}" has ${columns.length} columns.`
444
+ );
445
+ }
446
+
447
+ const targetTable = normalizeText(foreignKey.referencedTableName);
448
+ const targetColumn = normalizeText(columns[0]?.referencedName);
449
+ const targetPrimaryKey = primaryKeyColumnsByTable.get(targetTable) || [];
450
+ if (targetPrimaryKey.length !== 1) {
451
+ const actual = targetPrimaryKey.length > 0 ? targetPrimaryKey.join(", ") : "none";
452
+ throw new Error(
453
+ `CRUD foreign key "${foreignKey.name}" targets table "${targetTable}", whose primary key is not single-column (found: ${actual}).`
454
+ );
455
+ }
456
+ if (targetColumn !== targetPrimaryKey[0]) {
457
+ throw new Error(
458
+ `CRUD foreign key "${foreignKey.name}" must target the primary key "${targetTable}.${targetPrimaryKey[0]}", not "${targetTable}.${targetColumn}".`
459
+ );
460
+ }
461
+ }
462
+ }
463
+
372
464
  async function introspectCrudTableSnapshot(knex, { tableName = "", idColumn = "id" } = {}) {
373
465
  requireKnexRaw(knex);
374
466
  const resolvedTableName = requireTableName(tableName);
@@ -482,6 +574,22 @@ async function introspectCrudTableSnapshot(knex, { tableName = "", idColumn = "i
482
574
  )
483
575
  );
484
576
 
577
+ const schemaPrimaryKeyRows = normalizeRows(
578
+ await knex.raw(
579
+ `
580
+ SELECT
581
+ s.table_name AS tableName,
582
+ s.column_name AS columnName,
583
+ s.seq_in_index AS ordinalPosition
584
+ FROM information_schema.statistics s
585
+ WHERE s.table_schema = ?
586
+ AND s.index_name = 'PRIMARY'
587
+ ORDER BY s.table_name ASC, s.seq_in_index ASC
588
+ `,
589
+ [schemaName]
590
+ )
591
+ );
592
+
485
593
  const checkConstraintRows = normalizeRows(
486
594
  await knex.raw(
487
595
  `
@@ -505,6 +613,11 @@ async function introspectCrudTableSnapshot(knex, { tableName = "", idColumn = "i
505
613
  const resolvedIdColumn = requireIdColumn(columns, idColumn);
506
614
  const primaryKeyColumns = normalizePrimaryKeyColumns(primaryRows);
507
615
  requirePrimaryKeyContainsId(primaryKeyColumns, resolvedIdColumn);
616
+ const foreignKeys = normalizeForeignKeys(foreignKeyRows);
617
+ requireSupportedForeignKeys(
618
+ foreignKeys,
619
+ normalizePrimaryKeyColumnsByTable(schemaPrimaryKeyRows)
620
+ );
508
621
  const firstTableRow = Array.isArray(tableRows) ? tableRows[0] : null;
509
622
 
510
623
  const snapshot = Object.freeze({
@@ -518,7 +631,7 @@ async function introspectCrudTableSnapshot(knex, { tableName = "", idColumn = "i
518
631
  hasUserIdColumn: columns.some((column) => column.name === "user_id"),
519
632
  columns,
520
633
  indexes: normalizeIndexes(indexRows),
521
- foreignKeys: normalizeForeignKeys(foreignKeyRows),
634
+ foreignKeys,
522
635
  checkConstraints: normalizeCheckConstraints(checkConstraintRows)
523
636
  });
524
637
 
@@ -10,6 +10,7 @@ function createKnexRawDouble({
10
10
  primaryKeyColumns = [],
11
11
  indexes = [],
12
12
  foreignKeys = [],
13
+ primaryKeysByTable = {},
13
14
  checkConstraints = []
14
15
  } = {}) {
15
16
  const calls = [];
@@ -37,6 +38,21 @@ function createKnexRawDouble({
37
38
  if (normalizedSql.includes("from information_schema.table_constraints")) {
38
39
  return [[...primaryKeyColumns], []];
39
40
  }
41
+ if (
42
+ normalizedSql.includes("from information_schema.statistics") &&
43
+ normalizedSql.includes("s.index_name = 'primary'")
44
+ ) {
45
+ return [
46
+ Object.entries(primaryKeysByTable).flatMap(([tableName, columnNames]) =>
47
+ columnNames.map((columnName, index) => ({
48
+ tableName,
49
+ columnName,
50
+ ordinalPosition: index + 1
51
+ }))
52
+ ),
53
+ []
54
+ ];
55
+ }
40
56
  if (normalizedSql.includes("from information_schema.statistics")) {
41
57
  return [[...indexes], []];
42
58
  }
@@ -150,16 +166,16 @@ test("introspectCrudTableSnapshot maps MySQL table metadata to normalized snapsh
150
166
  {
151
167
  columnName: "updated_at",
152
168
  dataType: "datetime",
153
- columnType: "datetime",
169
+ columnType: "datetime(3)",
154
170
  isNullable: "NO",
155
- columnDefault: "CURRENT_TIMESTAMP",
156
- extra: "",
171
+ columnDefault: "CURRENT_TIMESTAMP(3)",
172
+ extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP(3)",
157
173
  characterMaximumLength: null,
158
174
  characterSetName: null,
159
175
  collationName: null,
160
176
  numericPrecision: null,
161
177
  numericScale: null,
162
- datetimePrecision: 0,
178
+ datetimePrecision: 3,
163
179
  ordinalPosition: 7
164
180
  },
165
181
  {
@@ -204,6 +220,10 @@ test("introspectCrudTableSnapshot maps MySQL table metadata to normalized snapsh
204
220
  deleteRule: "SET NULL"
205
221
  }
206
222
  ],
223
+ primaryKeysByTable: {
224
+ contacts: ["id"],
225
+ workspaces: ["id"]
226
+ },
207
227
  checkConstraints: [
208
228
  {
209
229
  constraintName: "settings_json",
@@ -249,6 +269,16 @@ test("introspectCrudTableSnapshot maps MySQL table metadata to normalized snapsh
249
269
  assert.equal(settingsJson.characterSetName, "utf8mb4");
250
270
  assert.equal(settingsJson.collationName, "utf8mb4_bin");
251
271
 
272
+ const updatedAt = snapshot.columns.find((column) => column.name === "updated_at");
273
+ assert.deepEqual(updatedAt.defaultExpression, {
274
+ kind: "current_timestamp",
275
+ precision: 3
276
+ });
277
+ assert.deepEqual(updatedAt.onUpdateExpression, {
278
+ kind: "current_timestamp",
279
+ precision: 3
280
+ });
281
+
252
282
  assert.deepEqual(snapshot.indexes, [
253
283
  {
254
284
  name: "idx_contacts_first_name",
@@ -355,3 +385,184 @@ test("introspectCrudTableSnapshot rejects when primary key does not include id c
355
385
  /Primary key must include id column "id"/
356
386
  );
357
387
  });
388
+
389
+ test("introspectCrudTableSnapshot classifies only allowlisted temporal defaults as SQL expressions", async () => {
390
+ const { knex } = createKnexRawDouble({
391
+ columns: [
392
+ ...validIdColumns(),
393
+ {
394
+ columnName: "label",
395
+ dataType: "varchar",
396
+ columnType: "varchar(190)",
397
+ isNullable: "NO",
398
+ columnDefault: "CURRENT_TIMESTAMP(3)",
399
+ extra: "",
400
+ characterMaximumLength: 190,
401
+ characterSetName: "utf8mb4",
402
+ collationName: "utf8mb4_general_ci",
403
+ ordinalPosition: 2
404
+ },
405
+ {
406
+ columnName: "created_at",
407
+ dataType: "datetime",
408
+ columnType: "datetime",
409
+ isNullable: "NO",
410
+ columnDefault: "CURRENT_TIMESTAMP()",
411
+ extra: "",
412
+ datetimePrecision: 0,
413
+ ordinalPosition: 3
414
+ }
415
+ ],
416
+ primaryKeyColumns: [{ columnName: "id" }]
417
+ });
418
+
419
+ const snapshot = await introspectCrudTableSnapshot(knex, {
420
+ tableName: "labels"
421
+ });
422
+ const label = snapshot.columns.find((column) => column.name === "label");
423
+
424
+ assert.equal(label.defaultValue, "CURRENT_TIMESTAMP(3)");
425
+ assert.equal(label.defaultExpression, null);
426
+ assert.equal(label.onUpdateExpression, null);
427
+ assert.deepEqual(
428
+ snapshot.columns.find((column) => column.name === "created_at").defaultExpression,
429
+ {
430
+ kind: "current_timestamp",
431
+ precision: null
432
+ }
433
+ );
434
+ });
435
+
436
+ function validIdColumns(extraColumns = []) {
437
+ return [
438
+ {
439
+ columnName: "id",
440
+ dataType: "bigint",
441
+ columnType: "bigint unsigned",
442
+ isNullable: "NO",
443
+ columnDefault: null,
444
+ extra: "auto_increment",
445
+ numericPrecision: 20,
446
+ numericScale: 0,
447
+ ordinalPosition: 1
448
+ },
449
+ ...extraColumns
450
+ ];
451
+ }
452
+
453
+ test("introspectCrudTableSnapshot rejects composite foreign keys", async () => {
454
+ const { knex } = createKnexRawDouble({
455
+ columns: validIdColumns([
456
+ {
457
+ columnName: "workspace_id",
458
+ dataType: "bigint",
459
+ columnType: "bigint unsigned",
460
+ isNullable: "NO",
461
+ extra: "",
462
+ ordinalPosition: 2
463
+ },
464
+ {
465
+ columnName: "parent_id",
466
+ dataType: "bigint",
467
+ columnType: "bigint unsigned",
468
+ isNullable: "NO",
469
+ extra: "",
470
+ ordinalPosition: 3
471
+ }
472
+ ]),
473
+ primaryKeyColumns: [{ columnName: "id" }],
474
+ foreignKeys: [
475
+ {
476
+ constraintName: "items_parent_foreign",
477
+ columnName: "workspace_id",
478
+ referencedTableName: "parents",
479
+ referencedColumnName: "workspace_id",
480
+ ordinalPosition: 1
481
+ },
482
+ {
483
+ constraintName: "items_parent_foreign",
484
+ columnName: "parent_id",
485
+ referencedTableName: "parents",
486
+ referencedColumnName: "id",
487
+ ordinalPosition: 2
488
+ }
489
+ ],
490
+ primaryKeysByTable: {
491
+ items: ["id"],
492
+ parents: ["id"]
493
+ }
494
+ });
495
+
496
+ await assert.rejects(
497
+ () => introspectCrudTableSnapshot(knex, { tableName: "items" }),
498
+ /supports only single-column foreign keys.*2 columns/
499
+ );
500
+ });
501
+
502
+ test("introspectCrudTableSnapshot rejects foreign keys to non-primary business keys", async () => {
503
+ const { knex } = createKnexRawDouble({
504
+ columns: validIdColumns([
505
+ {
506
+ columnName: "workspace_slug",
507
+ dataType: "varchar",
508
+ columnType: "varchar(190)",
509
+ isNullable: "NO",
510
+ extra: "",
511
+ ordinalPosition: 2
512
+ }
513
+ ]),
514
+ primaryKeyColumns: [{ columnName: "id" }],
515
+ foreignKeys: [
516
+ {
517
+ constraintName: "items_workspace_slug_foreign",
518
+ columnName: "workspace_slug",
519
+ referencedTableName: "workspaces",
520
+ referencedColumnName: "slug",
521
+ ordinalPosition: 1
522
+ }
523
+ ],
524
+ primaryKeysByTable: {
525
+ items: ["id"],
526
+ workspaces: ["id"]
527
+ }
528
+ });
529
+
530
+ await assert.rejects(
531
+ () => introspectCrudTableSnapshot(knex, { tableName: "items" }),
532
+ /must target the primary key "workspaces.id", not "workspaces.slug"/
533
+ );
534
+ });
535
+
536
+ test("introspectCrudTableSnapshot rejects foreign keys to composite primary keys", async () => {
537
+ const { knex } = createKnexRawDouble({
538
+ columns: validIdColumns([
539
+ {
540
+ columnName: "parent_id",
541
+ dataType: "bigint",
542
+ columnType: "bigint unsigned",
543
+ isNullable: "NO",
544
+ extra: "",
545
+ ordinalPosition: 2
546
+ }
547
+ ]),
548
+ primaryKeyColumns: [{ columnName: "id" }],
549
+ foreignKeys: [
550
+ {
551
+ constraintName: "items_parent_foreign",
552
+ columnName: "parent_id",
553
+ referencedTableName: "parents",
554
+ referencedColumnName: "id",
555
+ ordinalPosition: 1
556
+ }
557
+ ],
558
+ primaryKeysByTable: {
559
+ items: ["id"],
560
+ parents: ["workspace_id", "id"]
561
+ }
562
+ });
563
+
564
+ await assert.rejects(
565
+ () => introspectCrudTableSnapshot(knex, { tableName: "items" }),
566
+ /primary key is not single-column.*workspace_id, id/
567
+ );
568
+ });