@atscript/db-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.
package/dist/index.cjs CHANGED
@@ -2,6 +2,124 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _atscript_db = require("@atscript/db");
3
3
  let _atscript_db_sql_tools = require("@atscript/db-sql-tools");
4
4
  //#region src/sql-builder.ts
5
+ /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
6
+ const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
7
+ /** Spatial types (`POINT SRID 4326`, `GEOMETRY`, `MULTI*`, …). */
8
+ const GEOMETRY_RE = /^(GEOMETRY|POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i;
9
+ /** TEXT/BLOB/JSON/GEOMETRY families only accept the expression form `DEFAULT (expr)` (MySQL ≥ 8.0.13). */
10
+ function needsExpressionDefault(sqlType) {
11
+ return TEXT_BLOB_RE.test(sqlType) || /^JSON\b/i.test(sqlType) || GEOMETRY_RE.test(sqlType);
12
+ }
13
+ /**
14
+ * Renders a model value default for a column of `sqlType`: the SQL literal,
15
+ * wrapped in the expression form for the types that require it.
16
+ */
17
+ function mysqlDefaultLiteral(sqlType, designType, value) {
18
+ const literal = (0, _atscript_db_sql_tools.defaultValueToSqlLiteral)(designType, value);
19
+ return needsExpressionDefault(sqlType) ? `(${literal})` : literal;
20
+ }
21
+ /**
22
+ * Type-aware fallback value for a column that must get a value it has no
23
+ * model default for (invented ADD default, NULL backfill before NOT NULL).
24
+ * Every value is legal for its type under strict `sql_mode`.
25
+ */
26
+ function mysqlTypeDefault(sqlType, field) {
27
+ const type = sqlType.toUpperCase();
28
+ if (type.startsWith("JSON")) return "('{}')";
29
+ if (GEOMETRY_RE.test(type)) return "(ST_SRID(POINT(0, 0), 4326))";
30
+ if (/^(TIMESTAMP|DATETIME)/.test(type)) return "CURRENT_TIMESTAMP";
31
+ if (/^DATE\b/.test(type)) return "'1970-01-01'";
32
+ if (/^YEAR\b/.test(type)) return "1970";
33
+ if (/^TIME\b/.test(type)) return "'00:00:00'";
34
+ if (TEXT_BLOB_RE.test(type)) return "('')";
35
+ if (/^(VARCHAR|CHAR|VARBINARY|BINARY|ENUM|SET)\b/.test(type)) return "''";
36
+ if (/^(TINYINT|SMALLINT|MEDIUMINT|INT|INTEGER|BIGINT|DOUBLE|FLOAT|DECIMAL|NUMERIC|REAL|BIT|BOOL)/.test(type)) return "0";
37
+ return (0, _atscript_db_sql_tools.defaultValueForType)(field.designType);
38
+ }
39
+ /** InnoDB maximum index key part in bytes (ROW_FORMAT=DYNAMIC/COMPRESSED, the default since 5.7.7). */
40
+ const MYSQL_MAX_KEY_PART_BYTES = 3072;
41
+ /** Bytes per character of a table charset (unknown charsets assume the widest, 4). */
42
+ function mysqlBytesPerChar(charset) {
43
+ switch ((charset ?? "utf8mb4").toLowerCase()) {
44
+ case "utf8mb4": return 4;
45
+ case "utf8":
46
+ case "utf8mb3": return 3;
47
+ case "latin1":
48
+ case "ascii":
49
+ case "binary": return 1;
50
+ default: return 4;
51
+ }
52
+ }
53
+ /** Declared character length of a `CHAR(n)` / `VARCHAR(n)` mapped type, else `undefined`. */
54
+ function mysqlCharLength(mappedType) {
55
+ const m = /^(?:VAR)?CHAR\((\d+)\)/i.exec(mappedType.trim());
56
+ return m ? Number(m[1]) : void 0;
57
+ }
58
+ /**
59
+ * Key-length prefix a plain/unique index needs for a column of `mappedType`:
60
+ * - `CHAR(n)` / `VARCHAR(n)` within the key-part limit (3072 bytes ÷ bytes per
61
+ * char: 768 chars on utf8mb4) → no prefix; longer → the full limit (a
62
+ * shorter constant would needlessly weaken uniqueness);
63
+ * - `BINARY(n)` / `VARBINARY(n)` → same rule with 1 byte per char;
64
+ * - TEXT / BLOB families → 255 (a prefix is mandatory);
65
+ * - everything else (numeric, ENUM, JSON, VECTOR, geometry, …) → never.
66
+ */
67
+ function mysqlIndexPrefix(mappedType, bytesPerChar) {
68
+ const type = mappedType.trim().toUpperCase();
69
+ const chars = mysqlCharLength(type);
70
+ if (chars !== void 0) {
71
+ const limit = Math.floor(MYSQL_MAX_KEY_PART_BYTES / bytesPerChar);
72
+ return chars <= limit ? void 0 : limit;
73
+ }
74
+ const bin = /^(?:VAR)?BINARY\((\d+)\)/.exec(type);
75
+ if (bin) return Number(bin[1]) <= 3072 ? void 0 : MYSQL_MAX_KEY_PART_BYTES;
76
+ if (TEXT_BLOB_RE.test(type)) return 255;
77
+ }
78
+ /**
79
+ * The ONE column-definition renderer for CREATE TABLE, ADD COLUMN and MODIFY
80
+ * COLUMN. Rules:
81
+ * 1. type from the adapter mapper (vector/encrypted folding);
82
+ * 2. `AUTO_INCREMENT` for `@db.default.increment` columns on `create` and
83
+ * `modify` — never on `add` (ER 1075 without a key; `rebuildPrimaryKey`
84
+ * owns the columns entering the key and re-declares them);
85
+ * 3. nullability — `create`: as MySQL infers it (PRIMARY KEY / AUTO_INCREMENT
86
+ * imply NOT NULL); `add`/`modify`: always explicit (`NULL` matters for
87
+ * TIMESTAMP under `explicit_defaults_for_timestamp=OFF`, and MySQL rejects
88
+ * an explicit `NULL` on a key column, so key columns say NOT NULL);
89
+ * 4. DEFAULT — a model default only; NEVER `DEFAULT NULL` (it is the implicit
90
+ * default of a nullable column, and `NOT NULL DEFAULT NULL` is ER 1067);
91
+ * expression form for TEXT/BLOB/JSON/GEOMETRY; invented type default for a
92
+ * required default-less column on `add`;
93
+ * 5. COLLATE (native `@db.mysql.collate` or portable `@db.column.collate`);
94
+ * 6. ON UPDATE.
95
+ */
96
+ function buildColumnDefinition(field, ctx) {
97
+ const sqlType = ctx.typeMapper?.(field) ?? mysqlTypeFromField(field);
98
+ const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.physicalName) ?? false);
99
+ let def = `${qi(field.physicalName)} ${sqlType}`;
100
+ if (increment) def += " AUTO_INCREMENT";
101
+ if (ctx.purpose === "create") {
102
+ if (!field.optional && !field.isPrimaryKey && !increment) def += " NOT NULL";
103
+ } else def += !field.optional || field.isPrimaryKey || increment ? " NOT NULL" : " NULL";
104
+ let inventedDefault = false;
105
+ if (field.defaultValue?.kind === "value") def += ` DEFAULT ${mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value)}`;
106
+ else if (field.defaultValue?.kind === "fn") {
107
+ if (field.defaultValue.fn === "uuid") def += " DEFAULT (UUID())";
108
+ else if (field.defaultValue.fn === "now") def += " DEFAULT CURRENT_TIMESTAMP";
109
+ } else if (ctx.purpose === "add" && !field.optional && !field.isPrimaryKey && !increment && !/^VECTOR\b/i.test(sqlType)) {
110
+ def += ` DEFAULT ${mysqlTypeDefault(sqlType, field)}`;
111
+ inventedDefault = true;
112
+ }
113
+ const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
114
+ if (nativeCollate) def += ` COLLATE ${nativeCollate}`;
115
+ else if (field.collate) def += ` COLLATE ${collationToMysql(field.collate)}`;
116
+ const onUpdate = ctx.onUpdateFields?.get(field.physicalName);
117
+ if (onUpdate) def += ` ON UPDATE ${onUpdate}`;
118
+ return {
119
+ def,
120
+ inventedDefault
121
+ };
122
+ }
5
123
  /** Escapes a MySQL identifier by doubling backticks. */
6
124
  function esc(name) {
7
125
  return name.replace(/`/g, "``");
@@ -200,45 +318,36 @@ function mysqlTypeFromField(field) {
200
318
  * Builds a CREATE TABLE IF NOT EXISTS statement with MySQL options.
201
319
  */
202
320
  function buildCreateTable(table, fields, foreignKeys, options) {
203
- const colDefs = [];
204
321
  const primaryKeys = fields.filter((f) => f.isPrimaryKey);
205
- for (const field of fields) {
206
- if (field.ignored) continue;
207
- const sqlType = mysqlTypeFromField(field);
208
- let def = `${qi(field.physicalName)} ${sqlType}`;
209
- if (options?.incrementFields?.has(field.physicalName)) def += " AUTO_INCREMENT";
210
- if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.physicalName)) def += " NOT NULL";
211
- if (field.defaultValue?.kind === "value") def += ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueToSqlLiteral)(field.designType, field.defaultValue.value)}`;
212
- else if (field.defaultValue?.kind === "fn") {
213
- if (field.defaultValue.fn === "uuid") def += " DEFAULT (UUID())";
214
- else if (field.defaultValue.fn === "now") def += " DEFAULT CURRENT_TIMESTAMP";
215
- }
216
- const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
217
- if (nativeCollate) def += ` COLLATE ${nativeCollate}`;
218
- else if (field.collate) def += ` COLLATE ${collationToMysql(field.collate)}`;
219
- const onUpdate = options?.onUpdateFields?.get(field.physicalName);
220
- if (onUpdate) def += ` ON UPDATE ${onUpdate}`;
221
- colDefs.push(def);
222
- }
322
+ const ctx = {
323
+ incrementFields: options?.incrementFields,
324
+ onUpdateFields: options?.onUpdateFields,
325
+ typeMapper: options?.typeMapper,
326
+ purpose: "create"
327
+ };
328
+ const colDefs = fields.filter((f) => !f.ignored).map((f) => ({
329
+ name: f.physicalName,
330
+ def: buildColumnDefinition(f, ctx).def
331
+ }));
332
+ const constraints = [];
223
333
  if (primaryKeys.length === 1) {
224
- const pkCol = qi(primaryKeys[0].physicalName);
225
- for (let i = 0; i < colDefs.length; i++) if (colDefs[i].startsWith(pkCol)) {
226
- colDefs[i] += " PRIMARY KEY";
227
- break;
228
- }
334
+ const pk = colDefs.find((c) => c.name === primaryKeys[0].physicalName);
335
+ if (pk) pk.def += " PRIMARY KEY";
229
336
  } else if (primaryKeys.length > 1) {
230
337
  const pkCols = primaryKeys.map((pk) => qi(pk.physicalName)).join(", ");
231
- colDefs.push(`PRIMARY KEY (${pkCols})`);
338
+ constraints.push(`PRIMARY KEY (${pkCols})`);
232
339
  }
233
340
  if (foreignKeys) for (const fk of foreignKeys.values()) {
341
+ if (options?.deferForeignKeysTo?.has(fk.targetTable)) continue;
234
342
  const localCols = fk.fields.map((f) => qi(f)).join(", ");
235
343
  const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
236
344
  let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
237
345
  if (fk.onDelete) constraint += ` ON DELETE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onDelete)}`;
238
346
  if (fk.onUpdate) constraint += ` ON UPDATE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onUpdate)}`;
239
- colDefs.push(constraint);
347
+ constraints.push(constraint);
240
348
  }
241
- let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${colDefs.join(", ")})`;
349
+ const body = [...colDefs.map((c) => c.def), ...constraints].join(", ");
350
+ let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${body})`;
242
351
  const engine = options?.engine ?? "InnoDB";
243
352
  const charset = options?.charset ?? "utf8mb4";
244
353
  const collation = options?.collation ?? "utf8mb4_unicode_ci";
@@ -306,14 +415,27 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
306
415
  _vectorFields = /* @__PURE__ */ new Map();
307
416
  /** Default similarity thresholds per vector field (from @db.search.vector.threshold). */
308
417
  _vectorThresholds = /* @__PURE__ */ new Map();
309
- /** Schema name for INFORMATION_SCHEMA queries (null falls through to DATABASE()). */
418
+ /**
419
+ * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
420
+ * table, or `null` (→ `DATABASE()`, the pool's database) when the table
421
+ * declares none or the adapter is an administrative one with no readable
422
+ * (the name-taking schema-sync primitives run on such an adapter).
423
+ */
310
424
  get _schema() {
311
- return this._table.schema ?? null;
425
+ return this._table?.schema ?? null;
312
426
  }
313
427
  constructor(driver) {
314
428
  super();
315
429
  this.driver = driver;
316
430
  }
431
+ /** Every adapter over this pool shares one transaction (since 0.1.128). */
432
+ _transactionOwner() {
433
+ return this.driver;
434
+ }
435
+ /** The dedicated connection of this pool's open transaction, if any. */
436
+ _txConnection() {
437
+ return this._getTransactionState();
438
+ }
317
439
  async _beginTransaction() {
318
440
  const conn = await this.driver.getConnection();
319
441
  await conn.exec("START TRANSACTION");
@@ -343,7 +465,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
343
465
  * otherwise the pool-based driver.
344
466
  */
345
467
  _exec() {
346
- return this._getTransactionState() ?? this.driver;
468
+ return this._txConnection() ?? this.driver;
347
469
  }
348
470
  /** MySQL InnoDB enforces FK constraints natively. */
349
471
  supportsNativeForeignKeys() {
@@ -403,10 +525,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
403
525
  }
404
526
  ];
405
527
  }
406
- async getExistingTableOptions() {
528
+ async getExistingTableOptions(tableName) {
407
529
  const row = await this._exec().get(`SELECT ENGINE, TABLE_COLLATION
408
530
  FROM INFORMATION_SCHEMA.TABLES
409
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [this._table.tableName, this._schema]);
531
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [tableName ?? this._table.tableName, this._schema]);
410
532
  if (!row) return [];
411
533
  const charset = row.TABLE_COLLATION?.split("_")[0] ?? "utf8mb4";
412
534
  return [
@@ -599,7 +721,8 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
599
721
  async replaceOne(filter, data, expectedVersion) {
600
722
  const where = buildWhere(filter);
601
723
  const versionColumn = this._table.versionColumn;
602
- const { sql, params } = buildUpdate(this.resolveTableName(), data, where, 1, void 0, versionColumn, expectedVersion);
724
+ const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, (0, _atscript_db_sql_tools.replaceColumnsFor)(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn);
725
+ const { sql, params } = buildUpdate(this.resolveTableName(), full, where, 1, void 0, versionColumn, expectedVersion);
603
726
  this._log(sql, params);
604
727
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
605
728
  return {
@@ -633,19 +756,111 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
633
756
  async prepareTypeMapper() {
634
757
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
635
758
  }
636
- async ensureTable() {
759
+ async ensureTable(opts) {
637
760
  await this.prepareTypeMapper();
638
- if (this._table instanceof _atscript_db.AtscriptDbView) return this._ensureView();
639
- const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
761
+ if (this._table.isView) return this._ensureView();
762
+ const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, this._tableOptions(opts));
763
+ this._log(sql);
764
+ await this._exec().exec(sql);
765
+ }
766
+ /** The CREATE TABLE options (engine/charset/collation/increment/ON UPDATE/type mapper). */
767
+ _tableOptions(opts) {
768
+ return {
640
769
  engine: this._engine,
641
770
  charset: this._charset,
642
771
  collation: this._collation,
643
772
  autoIncrementStart: this._autoIncrementStart,
644
773
  incrementFields: this._incrementFields,
645
- onUpdateFields: this._onUpdateFields
646
- });
774
+ onUpdateFields: this._onUpdateFields,
775
+ typeMapper: (field) => this.typeMapper(field),
776
+ deferForeignKeysTo: opts?.deferForeignKeysTo
777
+ };
778
+ }
779
+ /** The shared column-definition context for ADD/MODIFY statements. */
780
+ _columnCtx(purpose) {
781
+ return {
782
+ incrementFields: this._incrementFields,
783
+ onUpdateFields: this._onUpdateFields,
784
+ typeMapper: (field) => this.typeMapper(field),
785
+ purpose
786
+ };
787
+ }
788
+ async hasRows(tableName) {
789
+ const sql = `SELECT EXISTS(SELECT 1 FROM ${tableName ? quoteTableName(this._schema ? `${this._schema}.${tableName}` : tableName) : quoteTableName(this.resolveTableName())}) AS present`;
647
790
  this._log(sql);
648
- await this._exec().exec(sql);
791
+ const row = await this._exec().get(sql, []);
792
+ return Boolean(Number(row?.present ?? 0));
793
+ }
794
+ /** Live foreign keys referencing `tableName` (any table of the schema). */
795
+ async getReferencingForeignKeys(tableName) {
796
+ const rows = await this._exec().all(`SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_COLUMN_NAME
797
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
798
+ WHERE REFERENCED_TABLE_NAME = ? AND REFERENCED_TABLE_SCHEMA = COALESCE(?, DATABASE())
799
+ ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION`, [tableName, this._schema]);
800
+ const byConstraint = /* @__PURE__ */ new Map();
801
+ for (const r of rows) {
802
+ const key = `${r.TABLE_NAME}\0${r.CONSTRAINT_NAME}`;
803
+ let fk = byConstraint.get(key);
804
+ if (!fk) {
805
+ fk = {
806
+ table: r.TABLE_NAME,
807
+ fields: [],
808
+ targetFields: []
809
+ };
810
+ byConstraint.set(key, fk);
811
+ }
812
+ fk.fields.push(r.COLUMN_NAME);
813
+ fk.targetFields.push(r.REFERENCED_COLUMN_NAME);
814
+ }
815
+ return [...byConstraint.values()];
816
+ }
817
+ async getObjectKind(name) {
818
+ const row = await this._exec().get(`SELECT TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES
819
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [name, this._schema]);
820
+ if (!row) return;
821
+ return row.TABLE_TYPE.toUpperCase() === "VIEW" ? "view" : "table";
822
+ }
823
+ /**
824
+ * One `ALTER TABLE … [MODIFY …,] DROP PRIMARY KEY, ADD PRIMARY KEY (…)`
825
+ * statement (atomic). The sole owner of the columns entering the key:
826
+ * `syncColumns` adds them without AUTO_INCREMENT and skips their MODIFYs
827
+ * while the key change is pending, and this statement re-declares each of
828
+ * them in full (AUTO_INCREMENT when the model declares increment, explicit
829
+ * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
830
+ * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
831
+ * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
832
+ * its model no longer declares increment). Called on an empty table only.
833
+ */
834
+ async rebuildPrimaryKey(change) {
835
+ const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
836
+ const to = new Set(change.to);
837
+ const clauses = [];
838
+ for (const name of change.to) {
839
+ const field = fields.get(name);
840
+ if (field) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
841
+ }
842
+ if (change.from.length > 0) {
843
+ const placeholders = change.from.map(() => "?").join(", ");
844
+ const live = await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS
845
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
846
+ AND COLUMN_NAME IN (${placeholders})`, [
847
+ this._table.tableName,
848
+ this._schema,
849
+ ...change.from
850
+ ]);
851
+ for (const col of live) {
852
+ if (to.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
853
+ const field = fields.get(col.COLUMN_NAME);
854
+ const def = field ? buildColumnDefinition(field, this._columnCtx("modify")).def : `${qi(col.COLUMN_NAME)} ${col.COLUMN_TYPE.toUpperCase()} NOT NULL`;
855
+ clauses.push(`MODIFY COLUMN ${def}`);
856
+ }
857
+ clauses.push("DROP PRIMARY KEY");
858
+ }
859
+ if (change.to.length > 0) clauses.push(`ADD PRIMARY KEY (${change.to.map((c) => qi(c)).join(", ")})`);
860
+ if (clauses.length === 0) return;
861
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ${clauses.join(", ")}`;
862
+ this._log(ddl);
863
+ await this._exec().exec(ddl);
649
864
  }
650
865
  async _ensureView() {
651
866
  const view = this._table;
@@ -657,15 +872,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
657
872
  return this.getExistingColumnsForTable(this._table.tableName);
658
873
  }
659
874
  async getExistingColumnsForTable(tableName) {
660
- const schema = this._schema;
661
- return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, SRS_ID
662
- FROM INFORMATION_SCHEMA.COLUMNS
663
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
664
- ORDER BY ORDINAL_POSITION`, [tableName, schema])).map((r) => ({
875
+ return (await this._exec().all(`SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.SRS_ID,
876
+ (k.COLUMN_NAME IS NOT NULL) AS IS_PK
877
+ FROM INFORMATION_SCHEMA.COLUMNS c
878
+ LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k
879
+ ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME
880
+ AND k.COLUMN_NAME = c.COLUMN_NAME AND k.CONSTRAINT_NAME = 'PRIMARY'
881
+ WHERE c.TABLE_NAME = ? AND c.TABLE_SCHEMA = COALESCE(?, DATABASE())
882
+ ORDER BY c.ORDINAL_POSITION`, [tableName, this._schema])).map((r) => ({
665
883
  name: r.COLUMN_NAME,
666
884
  type: r.SRS_ID == null ? r.COLUMN_TYPE.toUpperCase() : `${r.COLUMN_TYPE.toUpperCase()} SRID ${r.SRS_ID}`,
667
885
  notnull: r.IS_NULLABLE === "NO",
668
- pk: r.COLUMN_KEY === "PRI",
886
+ pk: Boolean(Number(r.IS_PK ?? 0)),
669
887
  dflt_value: normalizeMysqlDefault(r.COLUMN_DEFAULT)
670
888
  }));
671
889
  }
@@ -679,45 +897,44 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
679
897
  await this._exec().exec(ddl);
680
898
  renamed.push(field.physicalName);
681
899
  }
900
+ const quotedTable = quoteTableName(tableName);
682
901
  for (const field of diff.added) {
683
- const sqlType = this.typeMapper(field);
684
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} ADD COLUMN ${qi(field.physicalName)} ${sqlType}`;
685
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
686
- if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueToSqlLiteral)(field.designType, field.defaultValue.value)}`;
687
- else if (!field.optional && !field.isPrimaryKey) ddl += field.isGeoPoint && !field.encrypted ? ` DEFAULT (ST_SRID(POINT(0, 0), 4326))` : ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueForType)(field.designType)}`;
688
- if (field.collate) {
689
- const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
690
- ddl += ` COLLATE ${nativeCollate ?? collationToMysql(field.collate)}`;
691
- }
902
+ const { def, inventedDefault } = buildColumnDefinition(field, this._columnCtx("add"));
903
+ const ddl = `ALTER TABLE ${quotedTable} ADD COLUMN ${def}`;
692
904
  this._log(ddl);
693
905
  await this._exec().exec(ddl);
906
+ if (inventedDefault) {
907
+ const drop = `ALTER TABLE ${quotedTable} ALTER COLUMN ${qi(field.physicalName)} DROP DEFAULT`;
908
+ this._log(drop);
909
+ await this._exec().exec(drop);
910
+ }
694
911
  added.push(field.physicalName);
695
912
  }
913
+ const enteringKey = new Set(diff.primaryKeyChanged?.to ?? []);
914
+ const modified = /* @__PURE__ */ new Map();
696
915
  for (const { field } of diff.typeChanged ?? []) {
916
+ if (enteringKey.has(field.physicalName)) continue;
697
917
  const sqlType = this.typeMapper(field);
698
918
  if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
699
919
  await this._migrateJsonColumnToPoint(tableName, field);
700
920
  continue;
701
921
  }
702
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
703
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
704
- this._log(ddl);
705
- await this._exec().exec(ddl);
922
+ modified.set(field.physicalName, field);
706
923
  }
707
924
  for (const { field } of diff.nullableChanged ?? []) {
708
- const sqlType = this.typeMapper(field);
709
- const nullability = field.optional ? "NULL" : "NOT NULL";
710
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType} ${nullability}`;
711
- this._log(ddl);
712
- await this._exec().exec(ddl);
925
+ if (enteringKey.has(field.physicalName)) continue;
926
+ if (!field.optional) {
927
+ const sqlType = this.typeMapper(field);
928
+ const fallback = field.defaultValue?.kind === "value" ? mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value) : mysqlTypeDefault(sqlType, field);
929
+ const backfill = `UPDATE ${quotedTable} SET ${qi(field.physicalName)} = ${fallback} WHERE ${qi(field.physicalName)} IS NULL`;
930
+ this._log(backfill);
931
+ await this._exec().exec(backfill);
932
+ }
933
+ modified.set(field.physicalName, field);
713
934
  }
714
- for (const { field } of diff.defaultChanged ?? []) {
715
- const sqlType = this.typeMapper(field);
716
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
717
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
718
- if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueToSqlLiteral)(field.designType, field.defaultValue.value)}`;
719
- else if (field.defaultValue?.kind === "fn") ddl += ` DEFAULT ${field.defaultValue.fn === "now" ? "CURRENT_TIMESTAMP" : field.defaultValue.fn === "uuid" ? "(UUID())" : `(${field.defaultValue.fn}())`}`;
720
- else ddl += " DEFAULT NULL";
935
+ for (const { field } of diff.defaultChanged ?? []) if (!enteringKey.has(field.physicalName)) modified.set(field.physicalName, field);
936
+ for (const field of modified.values()) {
937
+ const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
721
938
  this._log(ddl);
722
939
  await this._exec().exec(ddl);
723
940
  }
@@ -729,18 +946,14 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
729
946
  async recreateTable() {
730
947
  const tableName = this.resolveTableName();
731
948
  const tempName = `${this._table.tableName}__tmp_${Date.now()}`;
732
- await this._exec().exec("SET FOREIGN_KEY_CHECKS = 0");
949
+ const tx = this._txConnection();
950
+ const conn = tx ?? await this.driver.getConnection();
951
+ const dedicated = tx === void 0;
952
+ await conn.exec("SET FOREIGN_KEY_CHECKS = 0");
733
953
  try {
734
- const createSql = buildCreateTable(tempName, this._table.fieldDescriptors, this._table.foreignKeys, {
735
- engine: this._engine,
736
- charset: this._charset,
737
- collation: this._collation,
738
- autoIncrementStart: this._autoIncrementStart,
739
- incrementFields: this._incrementFields,
740
- onUpdateFields: this._onUpdateFields
741
- });
954
+ const createSql = buildCreateTable(tempName, this._table.fieldDescriptors, this._table.foreignKeys, this._tableOptions());
742
955
  this._log(createSql);
743
- await this._exec().exec(createSql);
956
+ await conn.exec(createSql);
744
957
  const oldCols = (await this.getExistingColumns()).map((c) => c.name);
745
958
  const newCols = this._table.fieldDescriptors.filter((f) => !f.ignored).map((f) => f.physicalName);
746
959
  const oldColSet = new Set(oldCols);
@@ -758,12 +971,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
758
971
  }).join(", ");
759
972
  const copySql = `INSERT INTO ${qi(tempName)} (${colNames}) SELECT ${selectExprs} FROM ${quoteTableName(tableName)}`;
760
973
  this._log(copySql);
761
- await this._exec().exec(copySql);
974
+ await conn.exec(copySql);
762
975
  }
763
- await this._exec().exec(`DROP TABLE IF EXISTS ${quoteTableName(tableName)}`);
764
- await this._exec().exec(`RENAME TABLE ${qi(tempName)} TO ${quoteTableName(tableName)}`);
976
+ await conn.exec(`DROP TABLE IF EXISTS ${quoteTableName(tableName)}`);
977
+ await conn.exec(`RENAME TABLE ${qi(tempName)} TO ${quoteTableName(tableName)}`);
765
978
  } finally {
766
- await this._exec().exec("SET FOREIGN_KEY_CHECKS = 1");
979
+ await conn.exec("SET FOREIGN_KEY_CHECKS = 1");
980
+ if (dedicated) conn.release();
767
981
  }
768
982
  }
769
983
  async dropTable() {
@@ -834,18 +1048,38 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
834
1048
  async syncIndexes() {
835
1049
  const tableName = this._table.tableName;
836
1050
  const schema = this._schema;
837
- const stringFields = new Set(this._table.fieldDescriptors.filter((f) => f.designType === "string").map((f) => f.physicalName));
1051
+ const bytesPerChar = mysqlBytesPerChar(this._charset);
1052
+ const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1053
+ const mappedType = (column) => {
1054
+ const field = fields.get(column);
1055
+ return field ? this.typeMapper(field) : void 0;
1056
+ };
1057
+ const desiredPrefix = (column) => {
1058
+ const type = mappedType(column);
1059
+ return type === void 0 ? void 0 : mysqlIndexPrefix(type, bytesPerChar);
1060
+ };
1061
+ const renderPart = (column, prefix) => prefix === void 0 ? column : `${column}(${prefix})`;
838
1062
  await this.syncIndexesWithDiff({
839
1063
  listExisting: async () => {
840
- return (await this._exec().all(`SELECT INDEX_NAME AS name,
841
- GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns
1064
+ const rows = await this._exec().all(`SELECT INDEX_NAME, COLUMN_NAME, SUB_PART, SEQ_IN_INDEX
842
1065
  FROM INFORMATION_SCHEMA.STATISTICS
843
1066
  WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
844
- GROUP BY INDEX_NAME`, [tableName, schema])).map((r) => ({
845
- name: r.name,
846
- columns: r.columns ? r.columns.split(",") : void 0
1067
+ ORDER BY INDEX_NAME, SEQ_IN_INDEX`, [tableName, schema]);
1068
+ const byName = /* @__PURE__ */ new Map();
1069
+ for (const r of rows) {
1070
+ let subPart = r.SUB_PART ?? void 0;
1071
+ const type = mappedType(r.COLUMN_NAME);
1072
+ if (subPart !== void 0 && type !== void 0 && mysqlCharLength(type) === subPart) subPart = void 0;
1073
+ const parts = byName.get(r.INDEX_NAME) ?? [];
1074
+ parts.push(renderPart(r.COLUMN_NAME, subPart));
1075
+ byName.set(r.INDEX_NAME, parts);
1076
+ }
1077
+ return [...byName].map(([name, columns]) => ({
1078
+ name,
1079
+ columns
847
1080
  }));
848
1081
  },
1082
+ renderDesiredColumn: (_index, f) => renderPart(f.name, desiredPrefix(f.name)),
849
1083
  createIndex: async (index) => {
850
1084
  if (index.type === "geo") {
851
1085
  const fieldMeta = this._table.fieldDescriptors.find((f) => f.physicalName === index.fields[0]?.name);
@@ -862,7 +1096,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
862
1096
  const fulltext = index.type === "fulltext" ? "FULLTEXT " : "";
863
1097
  const isFulltext = index.type === "fulltext";
864
1098
  const cols = index.fields.map((f) => {
865
- return `${qi(f.name)}${!isFulltext && stringFields.has(f.name) ? "(255)" : ""}${isFulltext ? "" : ` ${f.sort === "desc" ? "DESC" : "ASC"}`}`;
1099
+ const col = qi(f.name);
1100
+ const prefix = isFulltext ? void 0 : desiredPrefix(f.name);
1101
+ const order = isFulltext ? "" : ` ${f.sort === "desc" ? "DESC" : "ASC"}`;
1102
+ return `${col}${prefix === void 0 ? "" : `(${prefix})`}${order}`;
866
1103
  }).join(", ");
867
1104
  const sql = `CREATE ${fulltext}${unique}INDEX ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${cols})`;
868
1105
  this._log(sql);
@@ -1150,7 +1387,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1150
1387
  `UPDATE ${quotedTable} SET ${tmp} = ST_SRID(POINT(CAST(${col}->>'$[0]' AS DOUBLE), CAST(${col}->>'$[1]' AS DOUBLE)), 4326) WHERE ${col} IS NOT NULL`,
1151
1388
  `ALTER TABLE ${quotedTable} DROP COLUMN ${col}`,
1152
1389
  `ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`,
1153
- ...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${col} POINT SRID 4326 NOT NULL`]
1390
+ ...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`]
1154
1391
  ];
1155
1392
  for (const ddl of steps) {
1156
1393
  this._log(ddl);
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TExistingColumn, TExistingTableOption, TFieldOps, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
1
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
@@ -107,9 +107,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
107
107
  private _vectorFields;
108
108
  /** Default similarity thresholds per vector field (from @db.search.vector.threshold). */
109
109
  private _vectorThresholds;
110
- /** Schema name for INFORMATION_SCHEMA queries (null falls through to DATABASE()). */
110
+ /**
111
+ * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
112
+ * table, or `null` (→ `DATABASE()`, the pool's database) when the table
113
+ * declares none or the adapter is an administrative one with no readable
114
+ * (the name-taking schema-sync primitives run on such an adapter).
115
+ */
111
116
  private get _schema();
112
117
  constructor(driver: TMysqlDriver);
118
+ /** Every adapter over this pool shares one transaction (since 0.1.128). */
119
+ protected _transactionOwner(): unknown;
120
+ /** The dedicated connection of this pool's open transaction, if any. */
121
+ private _txConnection;
113
122
  protected _beginTransaction(): Promise<TMysqlConnection>;
114
123
  protected _commitTransaction(state: unknown): Promise<void>;
115
124
  protected _rollbackTransaction(state: unknown): Promise<void>;
@@ -126,7 +135,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
126
135
  onBeforeFlatten(_type: unknown): void;
127
136
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
128
137
  getDesiredTableOptions(): TExistingTableOption[];
129
- getExistingTableOptions(): Promise<TExistingTableOption[]>;
138
+ getExistingTableOptions(tableName?: string): Promise<TExistingTableOption[]>;
130
139
  applyTableOptions(changes: TTableOptionDiff["changed"]): Promise<void>;
131
140
  /**
132
141
  * Returns a value formatter for TIMESTAMP-mapped fields.
@@ -158,7 +167,27 @@ declare class MysqlAdapter extends BaseDbAdapter {
158
167
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
159
168
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
160
169
  prepareTypeMapper(): Promise<void>;
161
- ensureTable(): Promise<void>;
170
+ ensureTable(opts?: TEnsureTableOptions): Promise<void>;
171
+ /** The CREATE TABLE options (engine/charset/collation/increment/ON UPDATE/type mapper). */
172
+ private _tableOptions;
173
+ /** The shared column-definition context for ADD/MODIFY statements. */
174
+ private _columnCtx;
175
+ hasRows(tableName?: string): Promise<boolean>;
176
+ /** Live foreign keys referencing `tableName` (any table of the schema). */
177
+ getReferencingForeignKeys(tableName: string): Promise<TReferencingForeignKey[]>;
178
+ getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
179
+ /**
180
+ * One `ALTER TABLE … [MODIFY …,] DROP PRIMARY KEY, ADD PRIMARY KEY (…)`
181
+ * statement (atomic). The sole owner of the columns entering the key:
182
+ * `syncColumns` adds them without AUTO_INCREMENT and skips their MODIFYs
183
+ * while the key change is pending, and this statement re-declares each of
184
+ * them in full (AUTO_INCREMENT when the model declares increment, explicit
185
+ * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
186
+ * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
187
+ * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
188
+ * its model no longer declares increment). Called on an empty table only.
189
+ */
190
+ rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
162
191
  private _ensureView;
163
192
  getExistingColumns(): Promise<TExistingColumn[]>;
164
193
  getExistingColumnsForTable(tableName: string): Promise<TExistingColumn[]>;
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TExistingColumn, TExistingTableOption, TFieldOps, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
1
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
2
2
  import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
3
3
  import { TMetadataMap } from "@atscript/typescript/utils";
4
4
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
@@ -107,9 +107,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
107
107
  private _vectorFields;
108
108
  /** Default similarity thresholds per vector field (from @db.search.vector.threshold). */
109
109
  private _vectorThresholds;
110
- /** Schema name for INFORMATION_SCHEMA queries (null falls through to DATABASE()). */
110
+ /**
111
+ * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
112
+ * table, or `null` (→ `DATABASE()`, the pool's database) when the table
113
+ * declares none or the adapter is an administrative one with no readable
114
+ * (the name-taking schema-sync primitives run on such an adapter).
115
+ */
111
116
  private get _schema();
112
117
  constructor(driver: TMysqlDriver);
118
+ /** Every adapter over this pool shares one transaction (since 0.1.128). */
119
+ protected _transactionOwner(): unknown;
120
+ /** The dedicated connection of this pool's open transaction, if any. */
121
+ private _txConnection;
113
122
  protected _beginTransaction(): Promise<TMysqlConnection>;
114
123
  protected _commitTransaction(state: unknown): Promise<void>;
115
124
  protected _rollbackTransaction(state: unknown): Promise<void>;
@@ -126,7 +135,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
126
135
  onBeforeFlatten(_type: unknown): void;
127
136
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
128
137
  getDesiredTableOptions(): TExistingTableOption[];
129
- getExistingTableOptions(): Promise<TExistingTableOption[]>;
138
+ getExistingTableOptions(tableName?: string): Promise<TExistingTableOption[]>;
130
139
  applyTableOptions(changes: TTableOptionDiff["changed"]): Promise<void>;
131
140
  /**
132
141
  * Returns a value formatter for TIMESTAMP-mapped fields.
@@ -158,7 +167,27 @@ declare class MysqlAdapter extends BaseDbAdapter {
158
167
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
159
168
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
160
169
  prepareTypeMapper(): Promise<void>;
161
- ensureTable(): Promise<void>;
170
+ ensureTable(opts?: TEnsureTableOptions): Promise<void>;
171
+ /** The CREATE TABLE options (engine/charset/collation/increment/ON UPDATE/type mapper). */
172
+ private _tableOptions;
173
+ /** The shared column-definition context for ADD/MODIFY statements. */
174
+ private _columnCtx;
175
+ hasRows(tableName?: string): Promise<boolean>;
176
+ /** Live foreign keys referencing `tableName` (any table of the schema). */
177
+ getReferencingForeignKeys(tableName: string): Promise<TReferencingForeignKey[]>;
178
+ getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
179
+ /**
180
+ * One `ALTER TABLE … [MODIFY …,] DROP PRIMARY KEY, ADD PRIMARY KEY (…)`
181
+ * statement (atomic). The sole owner of the columns entering the key:
182
+ * `syncColumns` adds them without AUTO_INCREMENT and skips their MODIFYs
183
+ * while the key change is pending, and this statement re-declares each of
184
+ * them in full (AUTO_INCREMENT when the model declares increment, explicit
185
+ * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
186
+ * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
187
+ * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
188
+ * its model no longer declares increment). Called on an empty table only.
189
+ */
190
+ rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
162
191
  private _ensureView;
163
192
  getExistingColumns(): Promise<TExistingColumn[]>;
164
193
  getExistingColumnsForTable(tableName: string): Promise<TExistingColumn[]>;
package/dist/index.mjs CHANGED
@@ -1,6 +1,124 @@
1
- import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
2
- import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, toSqlValue } from "@atscript/db-sql-tools";
1
+ import { BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
2
+ import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, toSqlValue } from "@atscript/db-sql-tools";
3
3
  //#region src/sql-builder.ts
4
+ /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
5
+ const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
6
+ /** Spatial types (`POINT SRID 4326`, `GEOMETRY`, `MULTI*`, …). */
7
+ const GEOMETRY_RE = /^(GEOMETRY|POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i;
8
+ /** TEXT/BLOB/JSON/GEOMETRY families only accept the expression form `DEFAULT (expr)` (MySQL ≥ 8.0.13). */
9
+ function needsExpressionDefault(sqlType) {
10
+ return TEXT_BLOB_RE.test(sqlType) || /^JSON\b/i.test(sqlType) || GEOMETRY_RE.test(sqlType);
11
+ }
12
+ /**
13
+ * Renders a model value default for a column of `sqlType`: the SQL literal,
14
+ * wrapped in the expression form for the types that require it.
15
+ */
16
+ function mysqlDefaultLiteral(sqlType, designType, value) {
17
+ const literal = defaultValueToSqlLiteral(designType, value);
18
+ return needsExpressionDefault(sqlType) ? `(${literal})` : literal;
19
+ }
20
+ /**
21
+ * Type-aware fallback value for a column that must get a value it has no
22
+ * model default for (invented ADD default, NULL backfill before NOT NULL).
23
+ * Every value is legal for its type under strict `sql_mode`.
24
+ */
25
+ function mysqlTypeDefault(sqlType, field) {
26
+ const type = sqlType.toUpperCase();
27
+ if (type.startsWith("JSON")) return "('{}')";
28
+ if (GEOMETRY_RE.test(type)) return "(ST_SRID(POINT(0, 0), 4326))";
29
+ if (/^(TIMESTAMP|DATETIME)/.test(type)) return "CURRENT_TIMESTAMP";
30
+ if (/^DATE\b/.test(type)) return "'1970-01-01'";
31
+ if (/^YEAR\b/.test(type)) return "1970";
32
+ if (/^TIME\b/.test(type)) return "'00:00:00'";
33
+ if (TEXT_BLOB_RE.test(type)) return "('')";
34
+ if (/^(VARCHAR|CHAR|VARBINARY|BINARY|ENUM|SET)\b/.test(type)) return "''";
35
+ if (/^(TINYINT|SMALLINT|MEDIUMINT|INT|INTEGER|BIGINT|DOUBLE|FLOAT|DECIMAL|NUMERIC|REAL|BIT|BOOL)/.test(type)) return "0";
36
+ return defaultValueForType(field.designType);
37
+ }
38
+ /** InnoDB maximum index key part in bytes (ROW_FORMAT=DYNAMIC/COMPRESSED, the default since 5.7.7). */
39
+ const MYSQL_MAX_KEY_PART_BYTES = 3072;
40
+ /** Bytes per character of a table charset (unknown charsets assume the widest, 4). */
41
+ function mysqlBytesPerChar(charset) {
42
+ switch ((charset ?? "utf8mb4").toLowerCase()) {
43
+ case "utf8mb4": return 4;
44
+ case "utf8":
45
+ case "utf8mb3": return 3;
46
+ case "latin1":
47
+ case "ascii":
48
+ case "binary": return 1;
49
+ default: return 4;
50
+ }
51
+ }
52
+ /** Declared character length of a `CHAR(n)` / `VARCHAR(n)` mapped type, else `undefined`. */
53
+ function mysqlCharLength(mappedType) {
54
+ const m = /^(?:VAR)?CHAR\((\d+)\)/i.exec(mappedType.trim());
55
+ return m ? Number(m[1]) : void 0;
56
+ }
57
+ /**
58
+ * Key-length prefix a plain/unique index needs for a column of `mappedType`:
59
+ * - `CHAR(n)` / `VARCHAR(n)` within the key-part limit (3072 bytes ÷ bytes per
60
+ * char: 768 chars on utf8mb4) → no prefix; longer → the full limit (a
61
+ * shorter constant would needlessly weaken uniqueness);
62
+ * - `BINARY(n)` / `VARBINARY(n)` → same rule with 1 byte per char;
63
+ * - TEXT / BLOB families → 255 (a prefix is mandatory);
64
+ * - everything else (numeric, ENUM, JSON, VECTOR, geometry, …) → never.
65
+ */
66
+ function mysqlIndexPrefix(mappedType, bytesPerChar) {
67
+ const type = mappedType.trim().toUpperCase();
68
+ const chars = mysqlCharLength(type);
69
+ if (chars !== void 0) {
70
+ const limit = Math.floor(MYSQL_MAX_KEY_PART_BYTES / bytesPerChar);
71
+ return chars <= limit ? void 0 : limit;
72
+ }
73
+ const bin = /^(?:VAR)?BINARY\((\d+)\)/.exec(type);
74
+ if (bin) return Number(bin[1]) <= 3072 ? void 0 : MYSQL_MAX_KEY_PART_BYTES;
75
+ if (TEXT_BLOB_RE.test(type)) return 255;
76
+ }
77
+ /**
78
+ * The ONE column-definition renderer for CREATE TABLE, ADD COLUMN and MODIFY
79
+ * COLUMN. Rules:
80
+ * 1. type from the adapter mapper (vector/encrypted folding);
81
+ * 2. `AUTO_INCREMENT` for `@db.default.increment` columns on `create` and
82
+ * `modify` — never on `add` (ER 1075 without a key; `rebuildPrimaryKey`
83
+ * owns the columns entering the key and re-declares them);
84
+ * 3. nullability — `create`: as MySQL infers it (PRIMARY KEY / AUTO_INCREMENT
85
+ * imply NOT NULL); `add`/`modify`: always explicit (`NULL` matters for
86
+ * TIMESTAMP under `explicit_defaults_for_timestamp=OFF`, and MySQL rejects
87
+ * an explicit `NULL` on a key column, so key columns say NOT NULL);
88
+ * 4. DEFAULT — a model default only; NEVER `DEFAULT NULL` (it is the implicit
89
+ * default of a nullable column, and `NOT NULL DEFAULT NULL` is ER 1067);
90
+ * expression form for TEXT/BLOB/JSON/GEOMETRY; invented type default for a
91
+ * required default-less column on `add`;
92
+ * 5. COLLATE (native `@db.mysql.collate` or portable `@db.column.collate`);
93
+ * 6. ON UPDATE.
94
+ */
95
+ function buildColumnDefinition(field, ctx) {
96
+ const sqlType = ctx.typeMapper?.(field) ?? mysqlTypeFromField(field);
97
+ const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.physicalName) ?? false);
98
+ let def = `${qi(field.physicalName)} ${sqlType}`;
99
+ if (increment) def += " AUTO_INCREMENT";
100
+ if (ctx.purpose === "create") {
101
+ if (!field.optional && !field.isPrimaryKey && !increment) def += " NOT NULL";
102
+ } else def += !field.optional || field.isPrimaryKey || increment ? " NOT NULL" : " NULL";
103
+ let inventedDefault = false;
104
+ if (field.defaultValue?.kind === "value") def += ` DEFAULT ${mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value)}`;
105
+ else if (field.defaultValue?.kind === "fn") {
106
+ if (field.defaultValue.fn === "uuid") def += " DEFAULT (UUID())";
107
+ else if (field.defaultValue.fn === "now") def += " DEFAULT CURRENT_TIMESTAMP";
108
+ } else if (ctx.purpose === "add" && !field.optional && !field.isPrimaryKey && !increment && !/^VECTOR\b/i.test(sqlType)) {
109
+ def += ` DEFAULT ${mysqlTypeDefault(sqlType, field)}`;
110
+ inventedDefault = true;
111
+ }
112
+ const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
113
+ if (nativeCollate) def += ` COLLATE ${nativeCollate}`;
114
+ else if (field.collate) def += ` COLLATE ${collationToMysql(field.collate)}`;
115
+ const onUpdate = ctx.onUpdateFields?.get(field.physicalName);
116
+ if (onUpdate) def += ` ON UPDATE ${onUpdate}`;
117
+ return {
118
+ def,
119
+ inventedDefault
120
+ };
121
+ }
4
122
  /** Escapes a MySQL identifier by doubling backticks. */
5
123
  function esc(name) {
6
124
  return name.replace(/`/g, "``");
@@ -199,45 +317,36 @@ function mysqlTypeFromField(field) {
199
317
  * Builds a CREATE TABLE IF NOT EXISTS statement with MySQL options.
200
318
  */
201
319
  function buildCreateTable(table, fields, foreignKeys, options) {
202
- const colDefs = [];
203
320
  const primaryKeys = fields.filter((f) => f.isPrimaryKey);
204
- for (const field of fields) {
205
- if (field.ignored) continue;
206
- const sqlType = mysqlTypeFromField(field);
207
- let def = `${qi(field.physicalName)} ${sqlType}`;
208
- if (options?.incrementFields?.has(field.physicalName)) def += " AUTO_INCREMENT";
209
- if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.physicalName)) def += " NOT NULL";
210
- if (field.defaultValue?.kind === "value") def += ` DEFAULT ${defaultValueToSqlLiteral(field.designType, field.defaultValue.value)}`;
211
- else if (field.defaultValue?.kind === "fn") {
212
- if (field.defaultValue.fn === "uuid") def += " DEFAULT (UUID())";
213
- else if (field.defaultValue.fn === "now") def += " DEFAULT CURRENT_TIMESTAMP";
214
- }
215
- const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
216
- if (nativeCollate) def += ` COLLATE ${nativeCollate}`;
217
- else if (field.collate) def += ` COLLATE ${collationToMysql(field.collate)}`;
218
- const onUpdate = options?.onUpdateFields?.get(field.physicalName);
219
- if (onUpdate) def += ` ON UPDATE ${onUpdate}`;
220
- colDefs.push(def);
221
- }
321
+ const ctx = {
322
+ incrementFields: options?.incrementFields,
323
+ onUpdateFields: options?.onUpdateFields,
324
+ typeMapper: options?.typeMapper,
325
+ purpose: "create"
326
+ };
327
+ const colDefs = fields.filter((f) => !f.ignored).map((f) => ({
328
+ name: f.physicalName,
329
+ def: buildColumnDefinition(f, ctx).def
330
+ }));
331
+ const constraints = [];
222
332
  if (primaryKeys.length === 1) {
223
- const pkCol = qi(primaryKeys[0].physicalName);
224
- for (let i = 0; i < colDefs.length; i++) if (colDefs[i].startsWith(pkCol)) {
225
- colDefs[i] += " PRIMARY KEY";
226
- break;
227
- }
333
+ const pk = colDefs.find((c) => c.name === primaryKeys[0].physicalName);
334
+ if (pk) pk.def += " PRIMARY KEY";
228
335
  } else if (primaryKeys.length > 1) {
229
336
  const pkCols = primaryKeys.map((pk) => qi(pk.physicalName)).join(", ");
230
- colDefs.push(`PRIMARY KEY (${pkCols})`);
337
+ constraints.push(`PRIMARY KEY (${pkCols})`);
231
338
  }
232
339
  if (foreignKeys) for (const fk of foreignKeys.values()) {
340
+ if (options?.deferForeignKeysTo?.has(fk.targetTable)) continue;
233
341
  const localCols = fk.fields.map((f) => qi(f)).join(", ");
234
342
  const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
235
343
  let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
236
344
  if (fk.onDelete) constraint += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
237
345
  if (fk.onUpdate) constraint += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
238
- colDefs.push(constraint);
346
+ constraints.push(constraint);
239
347
  }
240
- let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${colDefs.join(", ")})`;
348
+ const body = [...colDefs.map((c) => c.def), ...constraints].join(", ");
349
+ let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${body})`;
241
350
  const engine = options?.engine ?? "InnoDB";
242
351
  const charset = options?.charset ?? "utf8mb4";
243
352
  const collation = options?.collation ?? "utf8mb4_unicode_ci";
@@ -305,14 +414,27 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
305
414
  _vectorFields = /* @__PURE__ */ new Map();
306
415
  /** Default similarity thresholds per vector field (from @db.search.vector.threshold). */
307
416
  _vectorThresholds = /* @__PURE__ */ new Map();
308
- /** Schema name for INFORMATION_SCHEMA queries (null falls through to DATABASE()). */
417
+ /**
418
+ * Schema name for INFORMATION_SCHEMA queries — `@db.schema` of the bound
419
+ * table, or `null` (→ `DATABASE()`, the pool's database) when the table
420
+ * declares none or the adapter is an administrative one with no readable
421
+ * (the name-taking schema-sync primitives run on such an adapter).
422
+ */
309
423
  get _schema() {
310
- return this._table.schema ?? null;
424
+ return this._table?.schema ?? null;
311
425
  }
312
426
  constructor(driver) {
313
427
  super();
314
428
  this.driver = driver;
315
429
  }
430
+ /** Every adapter over this pool shares one transaction (since 0.1.128). */
431
+ _transactionOwner() {
432
+ return this.driver;
433
+ }
434
+ /** The dedicated connection of this pool's open transaction, if any. */
435
+ _txConnection() {
436
+ return this._getTransactionState();
437
+ }
316
438
  async _beginTransaction() {
317
439
  const conn = await this.driver.getConnection();
318
440
  await conn.exec("START TRANSACTION");
@@ -342,7 +464,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
342
464
  * otherwise the pool-based driver.
343
465
  */
344
466
  _exec() {
345
- return this._getTransactionState() ?? this.driver;
467
+ return this._txConnection() ?? this.driver;
346
468
  }
347
469
  /** MySQL InnoDB enforces FK constraints natively. */
348
470
  supportsNativeForeignKeys() {
@@ -402,10 +524,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
402
524
  }
403
525
  ];
404
526
  }
405
- async getExistingTableOptions() {
527
+ async getExistingTableOptions(tableName) {
406
528
  const row = await this._exec().get(`SELECT ENGINE, TABLE_COLLATION
407
529
  FROM INFORMATION_SCHEMA.TABLES
408
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [this._table.tableName, this._schema]);
530
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [tableName ?? this._table.tableName, this._schema]);
409
531
  if (!row) return [];
410
532
  const charset = row.TABLE_COLLATION?.split("_")[0] ?? "utf8mb4";
411
533
  return [
@@ -598,7 +720,8 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
598
720
  async replaceOne(filter, data, expectedVersion) {
599
721
  const where = buildWhere(filter);
600
722
  const versionColumn = this._table.versionColumn;
601
- const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, 1, void 0, versionColumn, expectedVersion);
723
+ const full = fillReplacePayload(data, replaceColumnsFor(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn);
724
+ const { sql, params } = buildUpdate$1(this.resolveTableName(), full, where, 1, void 0, versionColumn, expectedVersion);
602
725
  this._log(sql, params);
603
726
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
604
727
  return {
@@ -632,19 +755,111 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
632
755
  async prepareTypeMapper() {
633
756
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
634
757
  }
635
- async ensureTable() {
758
+ async ensureTable(opts) {
636
759
  await this.prepareTypeMapper();
637
- if (this._table instanceof AtscriptDbView) return this._ensureView();
638
- const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
760
+ if (this._table.isView) return this._ensureView();
761
+ const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, this._tableOptions(opts));
762
+ this._log(sql);
763
+ await this._exec().exec(sql);
764
+ }
765
+ /** The CREATE TABLE options (engine/charset/collation/increment/ON UPDATE/type mapper). */
766
+ _tableOptions(opts) {
767
+ return {
639
768
  engine: this._engine,
640
769
  charset: this._charset,
641
770
  collation: this._collation,
642
771
  autoIncrementStart: this._autoIncrementStart,
643
772
  incrementFields: this._incrementFields,
644
- onUpdateFields: this._onUpdateFields
645
- });
773
+ onUpdateFields: this._onUpdateFields,
774
+ typeMapper: (field) => this.typeMapper(field),
775
+ deferForeignKeysTo: opts?.deferForeignKeysTo
776
+ };
777
+ }
778
+ /** The shared column-definition context for ADD/MODIFY statements. */
779
+ _columnCtx(purpose) {
780
+ return {
781
+ incrementFields: this._incrementFields,
782
+ onUpdateFields: this._onUpdateFields,
783
+ typeMapper: (field) => this.typeMapper(field),
784
+ purpose
785
+ };
786
+ }
787
+ async hasRows(tableName) {
788
+ const sql = `SELECT EXISTS(SELECT 1 FROM ${tableName ? quoteTableName(this._schema ? `${this._schema}.${tableName}` : tableName) : quoteTableName(this.resolveTableName())}) AS present`;
646
789
  this._log(sql);
647
- await this._exec().exec(sql);
790
+ const row = await this._exec().get(sql, []);
791
+ return Boolean(Number(row?.present ?? 0));
792
+ }
793
+ /** Live foreign keys referencing `tableName` (any table of the schema). */
794
+ async getReferencingForeignKeys(tableName) {
795
+ const rows = await this._exec().all(`SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_COLUMN_NAME
796
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
797
+ WHERE REFERENCED_TABLE_NAME = ? AND REFERENCED_TABLE_SCHEMA = COALESCE(?, DATABASE())
798
+ ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION`, [tableName, this._schema]);
799
+ const byConstraint = /* @__PURE__ */ new Map();
800
+ for (const r of rows) {
801
+ const key = `${r.TABLE_NAME}\0${r.CONSTRAINT_NAME}`;
802
+ let fk = byConstraint.get(key);
803
+ if (!fk) {
804
+ fk = {
805
+ table: r.TABLE_NAME,
806
+ fields: [],
807
+ targetFields: []
808
+ };
809
+ byConstraint.set(key, fk);
810
+ }
811
+ fk.fields.push(r.COLUMN_NAME);
812
+ fk.targetFields.push(r.REFERENCED_COLUMN_NAME);
813
+ }
814
+ return [...byConstraint.values()];
815
+ }
816
+ async getObjectKind(name) {
817
+ const row = await this._exec().get(`SELECT TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES
818
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [name, this._schema]);
819
+ if (!row) return;
820
+ return row.TABLE_TYPE.toUpperCase() === "VIEW" ? "view" : "table";
821
+ }
822
+ /**
823
+ * One `ALTER TABLE … [MODIFY …,] DROP PRIMARY KEY, ADD PRIMARY KEY (…)`
824
+ * statement (atomic). The sole owner of the columns entering the key:
825
+ * `syncColumns` adds them without AUTO_INCREMENT and skips their MODIFYs
826
+ * while the key change is pending, and this statement re-declares each of
827
+ * them in full (AUTO_INCREMENT when the model declares increment, explicit
828
+ * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
829
+ * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
830
+ * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
831
+ * its model no longer declares increment). Called on an empty table only.
832
+ */
833
+ async rebuildPrimaryKey(change) {
834
+ const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
835
+ const to = new Set(change.to);
836
+ const clauses = [];
837
+ for (const name of change.to) {
838
+ const field = fields.get(name);
839
+ if (field) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
840
+ }
841
+ if (change.from.length > 0) {
842
+ const placeholders = change.from.map(() => "?").join(", ");
843
+ const live = await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS
844
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
845
+ AND COLUMN_NAME IN (${placeholders})`, [
846
+ this._table.tableName,
847
+ this._schema,
848
+ ...change.from
849
+ ]);
850
+ for (const col of live) {
851
+ if (to.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
852
+ const field = fields.get(col.COLUMN_NAME);
853
+ const def = field ? buildColumnDefinition(field, this._columnCtx("modify")).def : `${qi(col.COLUMN_NAME)} ${col.COLUMN_TYPE.toUpperCase()} NOT NULL`;
854
+ clauses.push(`MODIFY COLUMN ${def}`);
855
+ }
856
+ clauses.push("DROP PRIMARY KEY");
857
+ }
858
+ if (change.to.length > 0) clauses.push(`ADD PRIMARY KEY (${change.to.map((c) => qi(c)).join(", ")})`);
859
+ if (clauses.length === 0) return;
860
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ${clauses.join(", ")}`;
861
+ this._log(ddl);
862
+ await this._exec().exec(ddl);
648
863
  }
649
864
  async _ensureView() {
650
865
  const view = this._table;
@@ -656,15 +871,18 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
656
871
  return this.getExistingColumnsForTable(this._table.tableName);
657
872
  }
658
873
  async getExistingColumnsForTable(tableName) {
659
- const schema = this._schema;
660
- return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, SRS_ID
661
- FROM INFORMATION_SCHEMA.COLUMNS
662
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
663
- ORDER BY ORDINAL_POSITION`, [tableName, schema])).map((r) => ({
874
+ return (await this._exec().all(`SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.SRS_ID,
875
+ (k.COLUMN_NAME IS NOT NULL) AS IS_PK
876
+ FROM INFORMATION_SCHEMA.COLUMNS c
877
+ LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k
878
+ ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME
879
+ AND k.COLUMN_NAME = c.COLUMN_NAME AND k.CONSTRAINT_NAME = 'PRIMARY'
880
+ WHERE c.TABLE_NAME = ? AND c.TABLE_SCHEMA = COALESCE(?, DATABASE())
881
+ ORDER BY c.ORDINAL_POSITION`, [tableName, this._schema])).map((r) => ({
664
882
  name: r.COLUMN_NAME,
665
883
  type: r.SRS_ID == null ? r.COLUMN_TYPE.toUpperCase() : `${r.COLUMN_TYPE.toUpperCase()} SRID ${r.SRS_ID}`,
666
884
  notnull: r.IS_NULLABLE === "NO",
667
- pk: r.COLUMN_KEY === "PRI",
885
+ pk: Boolean(Number(r.IS_PK ?? 0)),
668
886
  dflt_value: normalizeMysqlDefault(r.COLUMN_DEFAULT)
669
887
  }));
670
888
  }
@@ -678,45 +896,44 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
678
896
  await this._exec().exec(ddl);
679
897
  renamed.push(field.physicalName);
680
898
  }
899
+ const quotedTable = quoteTableName(tableName);
681
900
  for (const field of diff.added) {
682
- const sqlType = this.typeMapper(field);
683
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} ADD COLUMN ${qi(field.physicalName)} ${sqlType}`;
684
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
685
- if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${defaultValueToSqlLiteral(field.designType, field.defaultValue.value)}`;
686
- else if (!field.optional && !field.isPrimaryKey) ddl += field.isGeoPoint && !field.encrypted ? ` DEFAULT (ST_SRID(POINT(0, 0), 4326))` : ` DEFAULT ${defaultValueForType(field.designType)}`;
687
- if (field.collate) {
688
- const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
689
- ddl += ` COLLATE ${nativeCollate ?? collationToMysql(field.collate)}`;
690
- }
901
+ const { def, inventedDefault } = buildColumnDefinition(field, this._columnCtx("add"));
902
+ const ddl = `ALTER TABLE ${quotedTable} ADD COLUMN ${def}`;
691
903
  this._log(ddl);
692
904
  await this._exec().exec(ddl);
905
+ if (inventedDefault) {
906
+ const drop = `ALTER TABLE ${quotedTable} ALTER COLUMN ${qi(field.physicalName)} DROP DEFAULT`;
907
+ this._log(drop);
908
+ await this._exec().exec(drop);
909
+ }
693
910
  added.push(field.physicalName);
694
911
  }
912
+ const enteringKey = new Set(diff.primaryKeyChanged?.to ?? []);
913
+ const modified = /* @__PURE__ */ new Map();
695
914
  for (const { field } of diff.typeChanged ?? []) {
915
+ if (enteringKey.has(field.physicalName)) continue;
696
916
  const sqlType = this.typeMapper(field);
697
917
  if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
698
918
  await this._migrateJsonColumnToPoint(tableName, field);
699
919
  continue;
700
920
  }
701
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
702
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
703
- this._log(ddl);
704
- await this._exec().exec(ddl);
921
+ modified.set(field.physicalName, field);
705
922
  }
706
923
  for (const { field } of diff.nullableChanged ?? []) {
707
- const sqlType = this.typeMapper(field);
708
- const nullability = field.optional ? "NULL" : "NOT NULL";
709
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType} ${nullability}`;
710
- this._log(ddl);
711
- await this._exec().exec(ddl);
924
+ if (enteringKey.has(field.physicalName)) continue;
925
+ if (!field.optional) {
926
+ const sqlType = this.typeMapper(field);
927
+ const fallback = field.defaultValue?.kind === "value" ? mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value) : mysqlTypeDefault(sqlType, field);
928
+ const backfill = `UPDATE ${quotedTable} SET ${qi(field.physicalName)} = ${fallback} WHERE ${qi(field.physicalName)} IS NULL`;
929
+ this._log(backfill);
930
+ await this._exec().exec(backfill);
931
+ }
932
+ modified.set(field.physicalName, field);
712
933
  }
713
- for (const { field } of diff.defaultChanged ?? []) {
714
- const sqlType = this.typeMapper(field);
715
- let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
716
- if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
717
- if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${defaultValueToSqlLiteral(field.designType, field.defaultValue.value)}`;
718
- else if (field.defaultValue?.kind === "fn") ddl += ` DEFAULT ${field.defaultValue.fn === "now" ? "CURRENT_TIMESTAMP" : field.defaultValue.fn === "uuid" ? "(UUID())" : `(${field.defaultValue.fn}())`}`;
719
- else ddl += " DEFAULT NULL";
934
+ for (const { field } of diff.defaultChanged ?? []) if (!enteringKey.has(field.physicalName)) modified.set(field.physicalName, field);
935
+ for (const field of modified.values()) {
936
+ const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
720
937
  this._log(ddl);
721
938
  await this._exec().exec(ddl);
722
939
  }
@@ -728,18 +945,14 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
728
945
  async recreateTable() {
729
946
  const tableName = this.resolveTableName();
730
947
  const tempName = `${this._table.tableName}__tmp_${Date.now()}`;
731
- await this._exec().exec("SET FOREIGN_KEY_CHECKS = 0");
948
+ const tx = this._txConnection();
949
+ const conn = tx ?? await this.driver.getConnection();
950
+ const dedicated = tx === void 0;
951
+ await conn.exec("SET FOREIGN_KEY_CHECKS = 0");
732
952
  try {
733
- const createSql = buildCreateTable(tempName, this._table.fieldDescriptors, this._table.foreignKeys, {
734
- engine: this._engine,
735
- charset: this._charset,
736
- collation: this._collation,
737
- autoIncrementStart: this._autoIncrementStart,
738
- incrementFields: this._incrementFields,
739
- onUpdateFields: this._onUpdateFields
740
- });
953
+ const createSql = buildCreateTable(tempName, this._table.fieldDescriptors, this._table.foreignKeys, this._tableOptions());
741
954
  this._log(createSql);
742
- await this._exec().exec(createSql);
955
+ await conn.exec(createSql);
743
956
  const oldCols = (await this.getExistingColumns()).map((c) => c.name);
744
957
  const newCols = this._table.fieldDescriptors.filter((f) => !f.ignored).map((f) => f.physicalName);
745
958
  const oldColSet = new Set(oldCols);
@@ -757,12 +970,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
757
970
  }).join(", ");
758
971
  const copySql = `INSERT INTO ${qi(tempName)} (${colNames}) SELECT ${selectExprs} FROM ${quoteTableName(tableName)}`;
759
972
  this._log(copySql);
760
- await this._exec().exec(copySql);
973
+ await conn.exec(copySql);
761
974
  }
762
- await this._exec().exec(`DROP TABLE IF EXISTS ${quoteTableName(tableName)}`);
763
- await this._exec().exec(`RENAME TABLE ${qi(tempName)} TO ${quoteTableName(tableName)}`);
975
+ await conn.exec(`DROP TABLE IF EXISTS ${quoteTableName(tableName)}`);
976
+ await conn.exec(`RENAME TABLE ${qi(tempName)} TO ${quoteTableName(tableName)}`);
764
977
  } finally {
765
- await this._exec().exec("SET FOREIGN_KEY_CHECKS = 1");
978
+ await conn.exec("SET FOREIGN_KEY_CHECKS = 1");
979
+ if (dedicated) conn.release();
766
980
  }
767
981
  }
768
982
  async dropTable() {
@@ -833,18 +1047,38 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
833
1047
  async syncIndexes() {
834
1048
  const tableName = this._table.tableName;
835
1049
  const schema = this._schema;
836
- const stringFields = new Set(this._table.fieldDescriptors.filter((f) => f.designType === "string").map((f) => f.physicalName));
1050
+ const bytesPerChar = mysqlBytesPerChar(this._charset);
1051
+ const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1052
+ const mappedType = (column) => {
1053
+ const field = fields.get(column);
1054
+ return field ? this.typeMapper(field) : void 0;
1055
+ };
1056
+ const desiredPrefix = (column) => {
1057
+ const type = mappedType(column);
1058
+ return type === void 0 ? void 0 : mysqlIndexPrefix(type, bytesPerChar);
1059
+ };
1060
+ const renderPart = (column, prefix) => prefix === void 0 ? column : `${column}(${prefix})`;
837
1061
  await this.syncIndexesWithDiff({
838
1062
  listExisting: async () => {
839
- return (await this._exec().all(`SELECT INDEX_NAME AS name,
840
- GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns
1063
+ const rows = await this._exec().all(`SELECT INDEX_NAME, COLUMN_NAME, SUB_PART, SEQ_IN_INDEX
841
1064
  FROM INFORMATION_SCHEMA.STATISTICS
842
1065
  WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
843
- GROUP BY INDEX_NAME`, [tableName, schema])).map((r) => ({
844
- name: r.name,
845
- columns: r.columns ? r.columns.split(",") : void 0
1066
+ ORDER BY INDEX_NAME, SEQ_IN_INDEX`, [tableName, schema]);
1067
+ const byName = /* @__PURE__ */ new Map();
1068
+ for (const r of rows) {
1069
+ let subPart = r.SUB_PART ?? void 0;
1070
+ const type = mappedType(r.COLUMN_NAME);
1071
+ if (subPart !== void 0 && type !== void 0 && mysqlCharLength(type) === subPart) subPart = void 0;
1072
+ const parts = byName.get(r.INDEX_NAME) ?? [];
1073
+ parts.push(renderPart(r.COLUMN_NAME, subPart));
1074
+ byName.set(r.INDEX_NAME, parts);
1075
+ }
1076
+ return [...byName].map(([name, columns]) => ({
1077
+ name,
1078
+ columns
846
1079
  }));
847
1080
  },
1081
+ renderDesiredColumn: (_index, f) => renderPart(f.name, desiredPrefix(f.name)),
848
1082
  createIndex: async (index) => {
849
1083
  if (index.type === "geo") {
850
1084
  const fieldMeta = this._table.fieldDescriptors.find((f) => f.physicalName === index.fields[0]?.name);
@@ -861,7 +1095,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
861
1095
  const fulltext = index.type === "fulltext" ? "FULLTEXT " : "";
862
1096
  const isFulltext = index.type === "fulltext";
863
1097
  const cols = index.fields.map((f) => {
864
- return `${qi(f.name)}${!isFulltext && stringFields.has(f.name) ? "(255)" : ""}${isFulltext ? "" : ` ${f.sort === "desc" ? "DESC" : "ASC"}`}`;
1098
+ const col = qi(f.name);
1099
+ const prefix = isFulltext ? void 0 : desiredPrefix(f.name);
1100
+ const order = isFulltext ? "" : ` ${f.sort === "desc" ? "DESC" : "ASC"}`;
1101
+ return `${col}${prefix === void 0 ? "" : `(${prefix})`}${order}`;
865
1102
  }).join(", ");
866
1103
  const sql = `CREATE ${fulltext}${unique}INDEX ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${cols})`;
867
1104
  this._log(sql);
@@ -1149,7 +1386,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1149
1386
  `UPDATE ${quotedTable} SET ${tmp} = ST_SRID(POINT(CAST(${col}->>'$[0]' AS DOUBLE), CAST(${col}->>'$[1]' AS DOUBLE)), 4326) WHERE ${col} IS NOT NULL`,
1150
1387
  `ALTER TABLE ${quotedTable} DROP COLUMN ${col}`,
1151
1388
  `ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`,
1152
- ...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${col} POINT SRID 4326 NOT NULL`]
1389
+ ...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`]
1153
1390
  ];
1154
1391
  for (const ddl of steps) {
1155
1392
  this._log(ddl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.127",
3
+ "version": "0.1.129",
4
4
  "description": "MySQL adapter for @atscript/db with mysql2 driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -41,19 +41,19 @@
41
41
  "access": "public"
42
42
  },
43
43
  "devDependencies": {
44
- "@atscript/core": "^0.1.89",
45
- "@atscript/typescript": "^0.1.89",
46
- "@uniqu/core": "^0.1.7",
44
+ "@atscript/core": "^0.1.92",
45
+ "@atscript/typescript": "^0.1.92",
46
+ "@uniqu/core": "^0.1.8",
47
47
  "mysql2": "^3.11.0",
48
- "unplugin-atscript": "^0.1.89"
48
+ "unplugin-atscript": "^0.1.92"
49
49
  },
50
50
  "peerDependencies": {
51
- "@atscript/core": "^0.1.89",
52
- "@atscript/typescript": "^0.1.89",
53
- "@uniqu/core": "^0.1.7",
51
+ "@atscript/core": "^0.1.92",
52
+ "@atscript/typescript": "^0.1.92",
53
+ "@uniqu/core": "^0.1.8",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.127",
56
- "@atscript/db-sql-tools": "^0.1.127"
55
+ "@atscript/db-sql-tools": "^0.1.129",
56
+ "@atscript/db": "^0.1.129"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {