@atscript/db-mysql 0.1.169 → 0.1.171

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.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as mysqlOnUpdateExpr, r as mysqlName } from "./annotation-values-CsjIErsL.mjs";
1
2
  import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, NoopLogger, bucketTimeZoneUnavailable, constraintViolation, containsRelationFilter, defaultFulltextIndex, describeFulltext, fkKey, forEachResolvedRelation, isConflict, resolveFulltextIndex, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
3
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
4
  import { EMPTY_OR, SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, orderByList, parseRegexString, renameGeoDistance, replaceColumnsFor, searchRankOrder, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, vectorDistanceSource, viewReadSource, writeLimitCount } from "@atscript/db-sql-tools";
@@ -109,7 +110,8 @@ function mysqlIndexPrefix(mappedType, bytesPerChar) {
109
110
  * default of a nullable column, and `NOT NULL DEFAULT NULL` is ER 1067);
110
111
  * expression form for TEXT/BLOB/JSON/GEOMETRY; invented type default for a
111
112
  * required default-less column on `add`;
112
- * 5. COLLATE (native `@db.mysql.collate` or portable `@db.column.collate`);
113
+ * 5. COLLATE (native `@db.mysql.collate` or portable `@db.column.collate`;
114
+ * a foreign-key column's is the referenced column's);
113
115
  * 6. ON UPDATE.
114
116
  *
115
117
  * A `@db.column.derived` field (since 0.1.141) renders as a VIRTUAL generated
@@ -118,12 +120,13 @@ function mysqlIndexPrefix(mappedType, bytesPerChar) {
118
120
  */
119
121
  function buildColumnDefinition(field, ctx) {
120
122
  const sqlType = ctx.typeMapper?.(field) ?? mysqlTypeFromField(field);
123
+ const { charsetClause, collateClause } = mysqlColumnCharsetClauses(field, sqlType, ctx);
121
124
  if (field.derived) return {
122
- def: `${qi(field.physicalName)} ${sqlType}${mysqlCollateClause(field)} GENERATED ALWAYS AS (${derivedColumnExpr(mysqlDialect, field)}) VIRTUAL`,
125
+ def: `${qi(field.physicalName)} ${sqlType}${charsetClause}${collateClause} GENERATED ALWAYS AS (${derivedColumnExpr(mysqlDialect, field)}) VIRTUAL`,
123
126
  inventedDefault: false
124
127
  };
125
128
  const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.path) ?? false);
126
- let def = `${qi(field.physicalName)} ${sqlType}`;
129
+ let def = `${qi(field.physicalName)} ${sqlType}${charsetClause}`;
127
130
  if (increment) def += " AUTO_INCREMENT";
128
131
  if (ctx.purpose === "create") {
129
132
  if (!field.optional && !field.isPrimaryKey && !increment) def += " NOT NULL";
@@ -137,14 +140,53 @@ function buildColumnDefinition(field, ctx) {
137
140
  def += ` DEFAULT ${mysqlTypeDefault(sqlType, field)}`;
138
141
  inventedDefault = true;
139
142
  }
140
- def += mysqlCollateClause(field);
141
- const onUpdate = ctx.onUpdateFields?.get(field.physicalName);
142
- if (onUpdate) def += ` ON UPDATE ${/^current_timestamp(\(\))?$/i.test(onUpdate) ? currentTimestampFor(sqlType) : onUpdate}`;
143
+ def += collateClause;
144
+ const onUpdate = ctx.onUpdateFields?.get(field.path);
145
+ if (onUpdate) def += ` ON UPDATE ${mysqlOnUpdateExpr(onUpdate, currentTimestampFor(sqlType))}`;
143
146
  return {
144
147
  def,
145
148
  inventedDefault
146
149
  };
147
150
  }
151
+ /** Whether a column declares its own collation (`@db.mysql.collate` or `@db.column.collate`). */
152
+ function declaresCollation(field) {
153
+ return field.collate !== void 0 || (field.type?.metadata?.has("db.mysql.collate") ?? false);
154
+ }
155
+ /**
156
+ * The character set a column declares (`undefined`: the table's). A
157
+ * foreign-key column takes the referenced column's, as MySQL requires of a
158
+ * string foreign key: the referenced field's own `@db.mysql.charset`, else
159
+ * its table's (`ctx.keyTableCharsets`); its own declarations are not used.
160
+ */
161
+ function mysqlColumnCharset(field, ctx) {
162
+ const keyTarget = field.fkTargetField;
163
+ if (!keyTarget) return ctx.charsetFields?.get(field.path);
164
+ return keyTarget.type?.metadata?.get("db.mysql.charset") ?? ctx.keyTableCharsets?.get(field.path)?.charset;
165
+ }
166
+ /**
167
+ * The ` CHARACTER SET …` and ` COLLATE …` of a column (see
168
+ * {@link mysqlColumnCharset}); a foreign-key column's collation is the
169
+ * referenced field's own, else its table's.
170
+ */
171
+ function mysqlColumnCharsetClauses(field, sqlType, ctx) {
172
+ const charset = mysqlColumnCharset(field, ctx);
173
+ const charsetClause = mysqlCharsetClause(sqlType, charset);
174
+ const keyTarget = field.fkTargetField;
175
+ if (!keyTarget) return {
176
+ charsetClause,
177
+ collateClause: mysqlCollateClause(field, charset)
178
+ };
179
+ const ownCharset = keyTarget.type?.metadata?.get("db.mysql.charset");
180
+ if (declaresCollation(keyTarget)) return {
181
+ charsetClause,
182
+ collateClause: mysqlCollateClause(keyTarget, ownCharset)
183
+ };
184
+ const tableCollation = ownCharset === void 0 ? ctx.keyTableCharsets?.get(field.path)?.collation : void 0;
185
+ return {
186
+ charsetClause,
187
+ collateClause: tableCollation && CHARACTER_TYPE_RE.test(sqlType) ? ` COLLATE ${mysqlName("@db.mysql.collate", tableCollation)}` : ""
188
+ };
189
+ }
148
190
  /** Escapes a MySQL identifier by doubling backticks. */
149
191
  function esc(name) {
150
192
  return name.replace(/`/g, "``");
@@ -432,18 +474,39 @@ function buildAggregateSelect$1(table, where, controls) {
432
474
  function buildAggregateCount$1(table, where, controls) {
433
475
  return buildAggregateCount(mysqlDialect, table, where, controls);
434
476
  }
477
+ /** Character-typed columns (the ones a `CHARACTER SET` applies to). */
478
+ const CHARACTER_TYPE_RE = /^(VAR)?CHAR\b|^(TINY|MEDIUM|LONG)?TEXT\b|^(ENUM|SET)\b/i;
435
479
  /**
436
- * Maps portable collation values to MySQL collation names.
480
+ * ` CHARACTER SET <charset>` for a character-typed column with a field-level
481
+ * `@db.mysql.charset`, else `""`.
437
482
  */
483
+ function mysqlCharsetClause(sqlType, charset) {
484
+ return charset && CHARACTER_TYPE_RE.test(sqlType) ? ` CHARACTER SET ${mysqlName("@db.mysql.charset", charset)}` : "";
485
+ }
438
486
  /**
439
- * The `COLLATE` clause of a column (native `@db.mysql.collate`, else the
440
- * portable `@db.column.collate` mapping) — `""` when none applies.
487
+ * The column's ` COLLATE …`: a native `@db.mysql.collate`, else the portable
488
+ * `@db.column.collate` — in the column's `charset` when it sets one
489
+ * (`nocase` → `<charset>_general_ci`, `binary` → `<charset>_bin`,
490
+ * `unicode` → `<charset>_unicode_ci`), else in `utf8mb4`.
441
491
  */
442
- function mysqlCollateClause(field) {
492
+ function mysqlCollateClause(field, charset) {
443
493
  const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
444
494
  if (nativeCollate) return ` COLLATE ${nativeCollate}`;
445
- return field.collate ? ` COLLATE ${collationToMysql(field.collate)}` : "";
495
+ if (!field.collate) return "";
496
+ const collation = collationToMysql(field.collate);
497
+ return ` COLLATE ${charset ? collation.replace(/^utf8mb4_/, `${charset}_`) : collation}`;
446
498
  }
499
+ /**
500
+ * The collation a table gets when only its character set is declared:
501
+ * `utf8mb4_unicode_ci` for `utf8mb4` (the adapter's default), else none —
502
+ * MySQL then applies the set's own default collation.
503
+ */
504
+ function mysqlDefaultCollation(charset) {
505
+ return charset.toLowerCase() === "utf8mb4" ? "utf8mb4_unicode_ci" : void 0;
506
+ }
507
+ /**
508
+ * Maps portable collation values to MySQL collation names.
509
+ */
447
510
  function collationToMysql(collation) {
448
511
  switch (collation) {
449
512
  case "binary": return "utf8mb4_bin";
@@ -452,15 +515,6 @@ function collationToMysql(collation) {
452
515
  default: return "utf8mb4_unicode_ci";
453
516
  }
454
517
  }
455
- /**
456
- * Maps an Atscript field descriptor to a MySQL column type.
457
- *
458
- * Reads `designType`, primitive tags (via `type.type.tags`), and annotations
459
- * from field metadata to produce the most specific MySQL type.
460
- *
461
- * For FK fields, delegates to the target PK's type via `field.fkTargetField`
462
- * so the FK column type always matches the referenced column.
463
- */
464
518
  /** Maps integer primitive tags to MySQL integer types. */
465
519
  function intTypeFromTags(tags, unsigned) {
466
520
  if (tags?.has("int8")) return unsigned ? "TINYINT UNSIGNED" : "TINYINT";
@@ -473,8 +527,19 @@ function intTypeFromTags(tags, unsigned) {
473
527
  if (tags?.has("uint64")) return "BIGINT UNSIGNED";
474
528
  return unsigned ? "INT UNSIGNED" : "INT";
475
529
  }
476
- function mysqlTypeFromField(field) {
477
- if (field.fkTargetField) return mysqlTypeFromField(field.fkTargetField);
530
+ /**
531
+ * Maps an Atscript field descriptor to a MySQL column type.
532
+ *
533
+ * Reads `designType`, primitive tags (via `type.type.tags`), and annotations
534
+ * from field metadata to produce the most specific MySQL type.
535
+ *
536
+ * `keyed` marks a column a foreign key references (the adapter knows it
537
+ * from its space); a referencing column takes its type through
538
+ * `field.fkTargetField`. A keyed string is never TEXT: a foreign key needs
539
+ * both sides indexed in full, and MySQL indexes TEXT only by a prefix.
540
+ */
541
+ function mysqlTypeFromField(field, keyed = false) {
542
+ if (field.fkTargetField) return mysqlTypeFromField(field.fkTargetField, true);
478
543
  const tags = field.type?.type?.tags;
479
544
  const metadata = field.type?.metadata;
480
545
  const mysqlTypeOverride = metadata?.get("db.mysql.type");
@@ -499,14 +564,15 @@ function mysqlTypeFromField(field) {
499
564
  const maxLen = (metadata?.get("expect.maxLength"))?.length;
500
565
  if (maxLen !== void 0 && maxLen <= 65535) return `VARCHAR(${maxLen})`;
501
566
  if (maxLen !== void 0 && maxLen > 65535) return "LONGTEXT";
502
- if (field.isPrimaryKey || field.defaultValue || field.derived) return "VARCHAR(255)";
567
+ if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "uuid") return "CHAR(36)";
568
+ if (keyed || field.isPrimaryKey || field.defaultValue || field.derived) return "VARCHAR(255)";
503
569
  return "TEXT";
504
570
  }
505
571
  case "json":
506
572
  case "object":
507
573
  case "array": return "JSON";
508
574
  default:
509
- if (field.isPrimaryKey || field.defaultValue) return "VARCHAR(255)";
575
+ if (keyed || field.isPrimaryKey || field.defaultValue) return "VARCHAR(255)";
510
576
  return "TEXT";
511
577
  }
512
578
  }
@@ -518,6 +584,8 @@ function buildCreateTable(table, fields, foreignKeys, options) {
518
584
  const ctx = {
519
585
  incrementFields: options?.incrementFields,
520
586
  onUpdateFields: options?.onUpdateFields,
587
+ charsetFields: options?.charsetFields,
588
+ keyTableCharsets: options?.keyTableCharsets,
521
589
  typeMapper: options?.typeMapper,
522
590
  purpose: "create"
523
591
  };
@@ -541,8 +609,9 @@ function buildCreateTable(table, fields, foreignKeys, options) {
541
609
  let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${body})`;
542
610
  const engine = options?.engine ?? "InnoDB";
543
611
  const charset = options?.charset ?? "utf8mb4";
544
- const collation = options?.collation ?? "utf8mb4_unicode_ci";
545
- sql += ` ENGINE=${engine} DEFAULT CHARSET=${charset} COLLATE=${collation}`;
612
+ const collation = options?.collation ?? mysqlDefaultCollation(charset);
613
+ sql += ` ENGINE=${engine} DEFAULT CHARSET=${charset}`;
614
+ if (collation) sql += ` COLLATE=${collation}`;
546
615
  if (options?.autoIncrementStart !== void 0) sql += ` AUTO_INCREMENT=${options.autoIncrementStart}`;
547
616
  return sql;
548
617
  }
@@ -565,6 +634,14 @@ function buildWhere(filter, opts) {
565
634
  }
566
635
  //#endregion
567
636
  //#region src/mysql-adapter.ts
637
+ /** A column's own `@db.mysql.charset`, `@db.mysql.collate` and `@db.column.collate` (lowercased, `""` when absent). */
638
+ function declaredCharsetCollation(field) {
639
+ return [
640
+ field.type?.metadata?.get("db.mysql.charset"),
641
+ field.type?.metadata?.get("db.mysql.collate"),
642
+ field.collate
643
+ ].map((v) => typeof v === "string" ? v.toLowerCase() : "");
644
+ }
568
645
  /**
569
646
  * Parses a MySQL UTC datetime string (`'YYYY-MM-DD HH:MM:SS[.ffffff]'`) to
570
647
  * epoch ms — fractional seconds included (truncated to milliseconds; since
@@ -627,6 +704,33 @@ const nonStrictChecked = /* @__PURE__ */ new WeakSet();
627
704
  * loading the time zone tables fixes a running server without a restart.
628
705
  */
629
706
  const convertibleZones = /* @__PURE__ */ new WeakMap();
707
+ /** The `ALTER TABLE` that re-creates `fks`, all on `table`, as they were. */
708
+ function addForeignKeysSql(table, fks) {
709
+ return `ALTER TABLE ${table} ${fks.map((fk) => `ADD CONSTRAINT ${qi(fk.name)} FOREIGN KEY (${fk.columns.map(qi).join(", ")}) REFERENCES ${fk.refTable} (${fk.refColumns.map(qi).join(", ")}) ON DELETE ${fk.onDelete} ON UPDATE ${fk.onUpdate}`).join(", ")}`;
710
+ }
711
+ /** A map key of a live column. */
712
+ const columnKey = (c) => `${c.schema}\0${c.table}\0${c.column}`;
713
+ /** Which ends of `fk`'s `i`-th column pair are among `columns` (of the adapter's table). */
714
+ function touchedEnds(fk, i, columns) {
715
+ return {
716
+ child: fk.own && columns.has(fk.columns[i]),
717
+ parent: fk.ref && columns.has(fk.refColumns[i])
718
+ };
719
+ }
720
+ const INTEGER_TYPE_RE = /^(tinyint|smallint|mediumint|int|integer|bigint)(?:\(\d+\))?(\s+unsigned)?(?:\s+zerofill)?$/;
721
+ const CHAR_TYPE_RE = /^(?:var)?char\(\d+\)$/i;
722
+ /**
723
+ * What the two sides of a foreign key must share: an integer's type and
724
+ * signedness (display width aside), a `CHAR` / `VARCHAR`'s collation (any
725
+ * length), anything else the type.
726
+ */
727
+ function fkTypeKey(column) {
728
+ const type = column.type.trim().toLowerCase().replace(/\s+/g, " ");
729
+ const int = INTEGER_TYPE_RE.exec(type);
730
+ if (int) return `int:${int[1] === "integer" ? "int" : int[1]}:${!!int[2]}`;
731
+ if (CHAR_TYPE_RE.test(type)) return `char:${(column.collation ?? "").toLowerCase()}`;
732
+ return type;
733
+ }
630
734
  /**
631
735
  * MySQL adapter for {@link AtscriptDbTable}.
632
736
  *
@@ -649,7 +753,8 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
649
753
  static NATIVE_DEFAULT_FNS = new Set(["now", "increment"]);
650
754
  _engine = "InnoDB";
651
755
  _charset = "utf8mb4";
652
- _collation = "utf8mb4_unicode_ci";
756
+ /** `@db.mysql.collate` on the table; none → the charset's default (see `mysqlDefaultCollation`). */
757
+ _collation;
653
758
  _autoIncrementStart;
654
759
  _incrementFields = /* @__PURE__ */ new Set();
655
760
  /**
@@ -665,6 +770,11 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
665
770
  */
666
771
  _epochAfterRekey = /* @__PURE__ */ new Map();
667
772
  _onUpdateFields = /* @__PURE__ */ new Map();
773
+ /** Field path → `@db.mysql.charset` of the column. */
774
+ _charsetFields = /* @__PURE__ */ new Map();
775
+ _space;
776
+ /** Memo of {@link _keyTableCharsets} — it also makes its warning fire once. */
777
+ _keyTableCharsetsMemo;
668
778
  /** Whether the connected MySQL instance supports native VECTOR type (MySQL 9.0+). */
669
779
  _supportsVector;
670
780
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -848,11 +958,11 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
848
958
  onBeforeFlatten(_type) {
849
959
  const meta = _type.metadata;
850
960
  const engine = meta.get("db.mysql.engine");
851
- if (engine) this._engine = engine;
961
+ if (engine) this._engine = mysqlName("@db.mysql.engine", engine);
852
962
  const charset = meta.get("db.mysql.charset");
853
- if (charset) this._charset = charset;
963
+ if (charset) this._charset = mysqlName("@db.mysql.charset", charset);
854
964
  const collate = meta.get("db.mysql.collate");
855
- if (collate) this._collation = collate;
965
+ if (collate) this._collation = mysqlName("@db.mysql.collate", collate);
856
966
  }
857
967
  onFieldScanned(field, _type, metadata) {
858
968
  if (metadata.has("db.default.increment")) {
@@ -862,6 +972,8 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
862
972
  }
863
973
  const onUpdate = metadata.get("db.mysql.onUpdate");
864
974
  if (onUpdate) this._onUpdateFields.set(field, onUpdate);
975
+ const charset = metadata.get("db.mysql.charset");
976
+ if (charset) this._charsetFields.set(field, mysqlName("@db.mysql.charset", charset));
865
977
  const vectorMeta = metadata.get("db.search.vector");
866
978
  if (vectorMeta) {
867
979
  const indexName = vectorMeta.indexName || field;
@@ -875,6 +987,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
875
987
  }
876
988
  }
877
989
  getDesiredTableOptions() {
990
+ const collation = this._tableCollation();
878
991
  return [
879
992
  {
880
993
  key: "engine",
@@ -884,10 +997,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
884
997
  key: "charset",
885
998
  value: this._charset
886
999
  },
887
- {
1000
+ ...collation === void 0 ? [] : [{
888
1001
  key: "collation",
889
- value: this._collation
890
- }
1002
+ value: collation
1003
+ }]
891
1004
  ];
892
1005
  }
893
1006
  async getExistingTableOptions(tableName) {
@@ -896,18 +1009,23 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
896
1009
  WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [tableName ?? this._table.tableName, this._schema]);
897
1010
  if (!row) return [];
898
1011
  const charset = row.TABLE_COLLATION?.split("_")[0] ?? "utf8mb4";
1012
+ const desired = new Map(this.getDesiredTableOptions().map((o) => [o.key, o.value]));
1013
+ const asDesired = (key, value) => {
1014
+ const want = desired.get(key);
1015
+ return want !== void 0 && want.toLowerCase() === value.toLowerCase() ? want : value;
1016
+ };
899
1017
  return [
900
1018
  {
901
1019
  key: "engine",
902
- value: row.ENGINE ?? "InnoDB"
1020
+ value: asDesired("engine", row.ENGINE ?? "InnoDB")
903
1021
  },
904
1022
  {
905
1023
  key: "charset",
906
- value: charset
1024
+ value: asDesired("charset", charset)
907
1025
  },
908
1026
  {
909
1027
  key: "collation",
910
- value: row.TABLE_COLLATION ?? "utf8mb4_unicode_ci"
1028
+ value: asDesired("collation", row.TABLE_COLLATION ?? "utf8mb4_unicode_ci")
911
1029
  }
912
1030
  ];
913
1031
  }
@@ -1632,15 +1750,64 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1632
1750
  this._log(sql);
1633
1751
  await this._exec().exec(sql);
1634
1752
  }
1753
+ /** The table's collation: `@db.mysql.collate`, else the charset's default one (`utf8mb4_unicode_ci` for `utf8mb4`). */
1754
+ _tableCollation() {
1755
+ return this._collation ?? mysqlDefaultCollation(this._charset);
1756
+ }
1757
+ /**
1758
+ * Foreign-key column path → the referenced table's character set and
1759
+ * collation, where they differ from this table's (see
1760
+ * `mysqlColumnCharset`). Warns about a foreign-key column whose own
1761
+ * `@db.mysql.charset` / collation is replaced by the referenced column's.
1762
+ */
1763
+ _keyTableCharsets() {
1764
+ return this._keyTableCharsetsMemo ??= this._buildKeyTableCharsets();
1765
+ }
1766
+ _buildKeyTableCharsets() {
1767
+ const out = /* @__PURE__ */ new Map();
1768
+ const own = `${this._charset}\0${this._tableCollation() ?? ""}`.toLowerCase();
1769
+ for (const fk of this._table.foreignKeys.values()) {
1770
+ const meta = fk.targetTypeRef?.()?.metadata;
1771
+ const declaredCollation = meta?.get("db.mysql.collate");
1772
+ const charset = meta?.get("db.mysql.charset") ?? declaredCollation?.split("_")[0] ?? "utf8mb4";
1773
+ const collation = declaredCollation ?? mysqlDefaultCollation(charset);
1774
+ if (`${charset}\0${collation ?? ""}`.toLowerCase() === own) continue;
1775
+ for (const path of fk.fields) out.set(path, {
1776
+ charset,
1777
+ collation
1778
+ });
1779
+ }
1780
+ for (const field of this._table.fieldDescriptors) {
1781
+ const target = field.fkTargetField;
1782
+ if (!target) continue;
1783
+ const mine = declaredCharsetCollation(field);
1784
+ if (mine.some(Boolean) && mine.join("\0") !== declaredCharsetCollation(target).join("\0")) this.logger.warn?.(`[mysql] "${this._table.tableName}.${field.path}": its own character set / collation is not used — a foreign-key column takes the referenced column's ("${target.path}"), as MySQL requires`);
1785
+ }
1786
+ return out;
1787
+ }
1788
+ /**
1789
+ * The key-length prefix an index needs on `field`'s column: a function of
1790
+ * its MAPPED type and its character set (`mysqlColumnCharset`, else the
1791
+ * table's).
1792
+ */
1793
+ _indexPrefix(field) {
1794
+ const charset = mysqlColumnCharset(field, {
1795
+ charsetFields: this._charsetFields,
1796
+ keyTableCharsets: this._keyTableCharsets()
1797
+ });
1798
+ return mysqlIndexPrefix(this.typeMapper(field), mysqlBytesPerChar(charset ?? this._charset));
1799
+ }
1635
1800
  /** The CREATE TABLE options (engine/charset/collation/increment/ON UPDATE/type mapper). */
1636
1801
  _tableOptions(opts) {
1637
1802
  return {
1638
1803
  engine: this._engine,
1639
1804
  charset: this._charset,
1640
- collation: this._collation,
1805
+ collation: this._tableCollation(),
1641
1806
  autoIncrementStart: this._autoIncrementStart,
1642
1807
  incrementFields: this._incrementFields,
1643
1808
  onUpdateFields: this._onUpdateFields,
1809
+ charsetFields: this._charsetFields,
1810
+ keyTableCharsets: this._keyTableCharsets(),
1644
1811
  typeMapper: (field) => this.typeMapper(field),
1645
1812
  deferForeignKeysTo: opts?.deferForeignKeysTo
1646
1813
  };
@@ -1650,6 +1817,8 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1650
1817
  return {
1651
1818
  incrementFields: this._incrementFields,
1652
1819
  onUpdateFields: this._onUpdateFields,
1820
+ charsetFields: this._charsetFields,
1821
+ keyTableCharsets: this._keyTableCharsets(),
1653
1822
  typeMapper: (field) => this.typeMapper(field),
1654
1823
  purpose
1655
1824
  };
@@ -1834,6 +2003,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1834
2003
  return entering.has(field.physicalName);
1835
2004
  };
1836
2005
  const modified = /* @__PURE__ */ new Map();
2006
+ const retyped = [];
1837
2007
  for (const { field, existingType } of diff.typeChanged ?? []) {
1838
2008
  const epoch = epochSource(field, existingType);
1839
2009
  if (epoch) {
@@ -1851,6 +2021,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1851
2021
  continue;
1852
2022
  }
1853
2023
  modified.set(field.physicalName, field);
2024
+ retyped.push(field);
1854
2025
  }
1855
2026
  for (const { field } of diff.nullableChanged ?? []) {
1856
2027
  if (rekeyed(field)) continue;
@@ -1864,17 +2035,285 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1864
2035
  modified.set(field.physicalName, field);
1865
2036
  }
1866
2037
  for (const { field } of diff.defaultChanged ?? []) if (!rekeyed(field)) modified.set(field.physicalName, field);
1867
- for (const field of modified.values()) {
1868
- const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
1869
- this._log(ddl);
1870
- await conn.exec(ddl);
1871
- }
2038
+ await this._dropIndexesOutgrown(conn, [...modified.values()]);
2039
+ await this._withForeignKeysLifted(conn, retyped, async () => {
2040
+ for (const field of modified.values()) {
2041
+ const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
2042
+ this._log(ddl);
2043
+ await conn.exec(ddl);
2044
+ }
2045
+ });
1872
2046
  return {
1873
2047
  added,
1874
2048
  renamed
1875
2049
  };
1876
2050
  });
1877
2051
  }
2052
+ /**
2053
+ * Runs `fn` (the MODIFYs of `fields`) with the live foreign keys over those
2054
+ * columns dropped — this table's own and those of any table referencing
2055
+ * them, its own included — and re-creates them, as they were, after: also
2056
+ * when `fn` throws. Nothing is dropped when a constraint could not be
2057
+ * re-created over the new types (see {@link _liftable}): MySQL then refuses
2058
+ * the MODIFY as it always did. A constraint that cannot be re-created is
2059
+ * named, with its DDL, in the error (after `fn`'s own).
2060
+ */
2061
+ async _withForeignKeysLifted(conn, fields, fn) {
2062
+ if (fields.length === 0) return fn();
2063
+ const found = await this._foreignKeysOver(conn, fields.map((f) => f.physicalName));
2064
+ const lifted = found.length > 0 && await this._liftable(conn, found, fields) ? found : [];
2065
+ const dropped = [];
2066
+ let failure;
2067
+ try {
2068
+ for (const fk of lifted) {
2069
+ const ddl = `ALTER TABLE ${fk.table} DROP FOREIGN KEY ${qi(fk.name)}`;
2070
+ this._log(ddl);
2071
+ await conn.exec(ddl);
2072
+ dropped.push(fk);
2073
+ this.logger.info(`[mysql] foreign key ${qi(fk.name)} on ${fk.table} dropped for the type change of ${quoteTableName(this.resolveTableName())}`);
2074
+ }
2075
+ await fn();
2076
+ } catch (error) {
2077
+ failure = error;
2078
+ }
2079
+ const lost = await this._restoreForeignKeys(conn, dropped);
2080
+ if (lost.length > 0) {
2081
+ const named = lost.map((fk) => `${qi(fk.name)} on ${fk.table} (re-create it with: ${addForeignKeysSql(fk.table, [fk])})`).join("; ");
2082
+ const message = failure ? `${failure.message} — and foreign keys not restored: ${named}` : `Foreign keys not restored after the type change: ${named}`;
2083
+ throw new Error(message, { cause: failure });
2084
+ }
2085
+ if (failure) throw failure;
2086
+ }
2087
+ /**
2088
+ * Re-creates `fks` (dropped by {@link _withForeignKeysLifted}) — one
2089
+ * `ALTER TABLE` per table, with `foreign_key_checks` off on this connection
2090
+ * so MySQL adds them in place instead of rebuilding the table (the rows
2091
+ * met them before the change). A table whose statement fails has its
2092
+ * constraints re-tried one by one. Returns those that could not be
2093
+ * re-created.
2094
+ */
2095
+ async _restoreForeignKeys(conn, fks) {
2096
+ if (fks.length === 0) return [];
2097
+ const byTable = /* @__PURE__ */ new Map();
2098
+ for (const fk of fks) {
2099
+ const group = byTable.get(fk.table);
2100
+ if (group) group.push(fk);
2101
+ else byTable.set(fk.table, [fk]);
2102
+ }
2103
+ const add = async (table, group) => {
2104
+ const ddl = addForeignKeysSql(table, group);
2105
+ this._log(ddl);
2106
+ try {
2107
+ await conn.exec(ddl);
2108
+ } catch (error) {
2109
+ return error;
2110
+ }
2111
+ for (const fk of group) this.logger.info(`[mysql] foreign key ${qi(fk.name)} on ${fk.table} restored`);
2112
+ };
2113
+ const lost = [];
2114
+ await conn.exec("SET @atscript_fk_checks = @@SESSION.foreign_key_checks, SESSION foreign_key_checks = 0");
2115
+ try {
2116
+ for (const [table, group] of byTable) {
2117
+ const error = await add(table, group);
2118
+ if (!error) continue;
2119
+ for (const fk of group) {
2120
+ const single = group.length === 1 ? error : await add(table, [fk]);
2121
+ if (single) {
2122
+ this.logger.error(`[mysql] foreign key ${qi(fk.name)} on ${table} could not be re-created: ${single.message}`);
2123
+ lost.push(fk);
2124
+ }
2125
+ }
2126
+ }
2127
+ } finally {
2128
+ await conn.exec("SET SESSION foreign_key_checks = @atscript_fk_checks");
2129
+ }
2130
+ return lost;
2131
+ }
2132
+ /**
2133
+ * Whether every constraint of `fks` can be re-created once `fields` have
2134
+ * their new types: each column pair of a constraint that the change touches
2135
+ * stays compatible — the new type against the other side's live column (or
2136
+ * its new type, on this table): integers of the same type and signedness,
2137
+ * `CHAR` / `VARCHAR` either way with the same collation, anything else the
2138
+ * same type. Otherwise nothing is lifted (and MySQL refuses the change, as
2139
+ * it did before foreign keys were lifted) — a dropped constraint must
2140
+ * never be lost to a column outside this run.
2141
+ */
2142
+ async _liftable(conn, fks, fields) {
2143
+ const changed = new Map(fields.map((f) => [f.physicalName, f]));
2144
+ const columns = new Set(changed.keys());
2145
+ const pairs = [];
2146
+ for (const fk of fks) fk.columns.forEach((column, i) => {
2147
+ const { child, parent } = touchedEnds(fk, i, columns);
2148
+ if (!child && !parent) return;
2149
+ pairs.push([{
2150
+ schema: fk.schema,
2151
+ table: fk.tableName,
2152
+ column,
2153
+ ours: child
2154
+ }, {
2155
+ schema: fk.refSchema,
2156
+ table: fk.refTableName,
2157
+ column: fk.refColumns[i],
2158
+ ours: parent
2159
+ }]);
2160
+ });
2161
+ const liveEnds = /* @__PURE__ */ new Map();
2162
+ for (const end of pairs.flat()) if (!end.ours) liveEnds.set(columnKey(end), end);
2163
+ const live = [...liveEnds.values()];
2164
+ const liveRows = live.length === 0 ? [] : await conn.all(`SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLLATION_NAME
2165
+ FROM INFORMATION_SCHEMA.COLUMNS
2166
+ WHERE ${live.map(() => "(TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?)").join(" OR ")}`, live.flatMap((end) => [
2167
+ end.schema,
2168
+ end.table,
2169
+ end.column
2170
+ ]));
2171
+ const liveOf = new Map(liveRows.map((r) => [columnKey({
2172
+ schema: r.TABLE_SCHEMA,
2173
+ table: r.TABLE_NAME,
2174
+ column: r.COLUMN_NAME
2175
+ }), {
2176
+ type: r.COLUMN_TYPE,
2177
+ collation: r.COLLATION_NAME ?? void 0
2178
+ }]));
2179
+ const collations = await this._newCollations(conn, [...new Set(pairs.flat().flatMap((end) => end.ours ? [changed.get(end.column)] : []).filter((field) => CHAR_TYPE_RE.test(this.typeMapper(field).trim())))]);
2180
+ const typeOf = (end) => {
2181
+ if (!end.ours) return liveOf.get(columnKey(end));
2182
+ const field = changed.get(end.column);
2183
+ return {
2184
+ type: this.typeMapper(field),
2185
+ collation: collations.get(field.physicalName)
2186
+ };
2187
+ };
2188
+ for (const [child, parent] of pairs) {
2189
+ const a = typeOf(child);
2190
+ const b = typeOf(parent);
2191
+ if (!a || !b || fkTypeKey(a) !== fkTypeKey(b)) {
2192
+ this.logger.warn(`[mysql] ${qi(child.table)}.${qi(child.column)} → ${qi(parent.table)}.${qi(parent.column)} would not match after the type change (${a?.type ?? "?"} / ${b?.type ?? "?"}) — its foreign key stays in place, and MySQL decides on the change`);
2193
+ return false;
2194
+ }
2195
+ }
2196
+ return true;
2197
+ }
2198
+ /**
2199
+ * The collation each of `fields` (string columns) has after its MODIFY:
2200
+ * the definition's own `COLLATE`, else the default of its `CHARACTER SET`,
2201
+ * else the table's.
2202
+ */
2203
+ async _newCollations(conn, fields) {
2204
+ const out = /* @__PURE__ */ new Map();
2205
+ if (fields.length === 0) return out;
2206
+ const ctx = this._columnCtx("modify");
2207
+ const charsetDefaults = /* @__PURE__ */ new Map();
2208
+ let tableCollation = null;
2209
+ for (const field of fields) {
2210
+ const { charsetClause, collateClause } = mysqlColumnCharsetClauses(field, this.typeMapper(field), ctx);
2211
+ const collate = /COLLATE\s+`?(\w+)`?/i.exec(collateClause)?.[1];
2212
+ const charset = /CHARACTER SET\s+`?(\w+)`?/i.exec(charsetClause)?.[1];
2213
+ if (collate) out.set(field.physicalName, collate);
2214
+ else if (charset) {
2215
+ if (!charsetDefaults.has(charset)) {
2216
+ const row = await conn.get(`SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLLATIONS
2217
+ WHERE CHARACTER_SET_NAME = ? AND IS_DEFAULT = 'Yes'`, [charset]);
2218
+ charsetDefaults.set(charset, row?.COLLATION_NAME);
2219
+ }
2220
+ out.set(field.physicalName, charsetDefaults.get(charset));
2221
+ } else {
2222
+ if (tableCollation === null) tableCollation = (await conn.get(`SELECT TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES
2223
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [this._table.tableName, this._schema]))?.TABLE_COLLATION ?? void 0;
2224
+ out.set(field.physicalName, tableCollation);
2225
+ }
2226
+ }
2227
+ return out;
2228
+ }
2229
+ /**
2230
+ * The live foreign keys over any of `columns` of this table: its own (by
2231
+ * their columns) and those referencing them (by the referenced columns),
2232
+ * each with every column, in key order.
2233
+ */
2234
+ async _foreignKeysOver(conn, columns) {
2235
+ const target = [this._schema, this._table.tableName];
2236
+ const rows = await conn.all(`SELECT k.TABLE_SCHEMA, k.TABLE_NAME, k.CONSTRAINT_NAME, k.COLUMN_NAME,
2237
+ k.REFERENCED_TABLE_SCHEMA, k.REFERENCED_TABLE_NAME, k.REFERENCED_COLUMN_NAME,
2238
+ r.UPDATE_RULE, r.DELETE_RULE,
2239
+ (k.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND k.TABLE_NAME = ?) AS IS_OWN,
2240
+ (k.REFERENCED_TABLE_SCHEMA = COALESCE(?, DATABASE()) AND k.REFERENCED_TABLE_NAME = ?) AS IS_REF
2241
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k
2242
+ JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS r
2243
+ ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME
2244
+ AND r.TABLE_NAME = k.TABLE_NAME
2245
+ WHERE k.REFERENCED_TABLE_NAME IS NOT NULL
2246
+ AND ((k.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND k.TABLE_NAME = ?)
2247
+ OR (k.REFERENCED_TABLE_SCHEMA = COALESCE(?, DATABASE()) AND k.REFERENCED_TABLE_NAME = ?))
2248
+ ORDER BY k.TABLE_SCHEMA, k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION`, [
2249
+ ...target,
2250
+ ...target,
2251
+ ...target,
2252
+ ...target
2253
+ ]);
2254
+ const byConstraint = /* @__PURE__ */ new Map();
2255
+ for (const r of rows) {
2256
+ const key = `${r.TABLE_SCHEMA}\0${r.TABLE_NAME}\0${r.CONSTRAINT_NAME}`;
2257
+ let fk = byConstraint.get(key);
2258
+ if (!fk) {
2259
+ fk = {
2260
+ schema: r.TABLE_SCHEMA,
2261
+ tableName: r.TABLE_NAME,
2262
+ table: `${qi(r.TABLE_SCHEMA)}.${qi(r.TABLE_NAME)}`,
2263
+ name: r.CONSTRAINT_NAME,
2264
+ columns: [],
2265
+ refSchema: r.REFERENCED_TABLE_SCHEMA,
2266
+ refTableName: r.REFERENCED_TABLE_NAME,
2267
+ refTable: `${qi(r.REFERENCED_TABLE_SCHEMA)}.${qi(r.REFERENCED_TABLE_NAME)}`,
2268
+ refColumns: [],
2269
+ onDelete: r.DELETE_RULE,
2270
+ onUpdate: r.UPDATE_RULE,
2271
+ own: Boolean(Number(r.IS_OWN)),
2272
+ ref: Boolean(Number(r.IS_REF))
2273
+ };
2274
+ byConstraint.set(key, fk);
2275
+ }
2276
+ fk.columns.push(r.COLUMN_NAME);
2277
+ fk.refColumns.push(r.REFERENCED_COLUMN_NAME);
2278
+ }
2279
+ const over = new Set(columns);
2280
+ return [...byConstraint.values()].filter((fk) => fk.columns.some((_, i) => {
2281
+ const { child, parent } = touchedEnds(fk, i, over);
2282
+ return child || parent;
2283
+ }));
2284
+ }
2285
+ /**
2286
+ * Before `fields` are modified: drops the managed indexes that key more of
2287
+ * a column than its new type allows — a full key on a column becoming
2288
+ * TEXT (ER 1170), or on a VARCHAR growing past the key-part limit (ER
2289
+ * 1071). Index sync re-creates them with the prefix the new type needs.
2290
+ * Primary keys, FULLTEXT and unmanaged indexes are left alone.
2291
+ */
2292
+ async _dropIndexesOutgrown(conn, fields) {
2293
+ const limits = /* @__PURE__ */ new Map();
2294
+ for (const field of fields) {
2295
+ const prefix = this._indexPrefix(field);
2296
+ if (prefix !== void 0) limits.set(field.physicalName, prefix);
2297
+ }
2298
+ if (limits.size === 0) return;
2299
+ const columns = [...limits.keys()];
2300
+ const rows = await conn.all(`SELECT INDEX_NAME, COLUMN_NAME, SUB_PART, INDEX_TYPE FROM INFORMATION_SCHEMA.STATISTICS
2301
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
2302
+ AND COLUMN_NAME IN (${columns.map(() => "?").join(", ")})`, [
2303
+ this._table.tableName,
2304
+ this._schema,
2305
+ ...columns
2306
+ ]);
2307
+ const outgrown = /* @__PURE__ */ new Set();
2308
+ for (const r of rows) if (r.INDEX_TYPE !== "FULLTEXT" && r.INDEX_NAME.startsWith("atscript__") && (r.SUB_PART === null || r.SUB_PART > limits.get(r.COLUMN_NAME))) outgrown.add(r.INDEX_NAME);
2309
+ for (const name of outgrown) await this._dropIndex(name, conn);
2310
+ }
2311
+ /** `DROP INDEX name` on this table (logged). */
2312
+ async _dropIndex(name, conn = this._exec()) {
2313
+ const ddl = `DROP INDEX ${qi(name)} ON ${quoteTableName(this.resolveTableName())}`;
2314
+ this._log(ddl);
2315
+ await conn.exec(ddl);
2316
+ }
1878
2317
  async recreateTable() {
1879
2318
  const tableName = this.resolveTableName();
1880
2319
  const tempName = `${this._table.tableName}__tmp_${Date.now()}`;
@@ -1949,11 +2388,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1949
2388
  this._schema,
1950
2389
  ...columns
1951
2390
  ]);
1952
- for (const row of rows) {
1953
- const sql = `DROP INDEX ${qi(row.name)} ON ${quoteTableName(this.resolveTableName())}`;
1954
- this._log(sql);
1955
- await this._exec().exec(sql);
1956
- }
2391
+ for (const row of rows) await this._dropIndex(row.name);
1957
2392
  }
1958
2393
  async dropTableByName(tableName) {
1959
2394
  const ddl = `DROP TABLE IF EXISTS ${quoteTableName(tableName)}`;
@@ -1979,13 +2414,17 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1979
2414
  this._log(ddl);
1980
2415
  await this._exec().exec(ddl);
1981
2416
  }
2417
+ /** Keeps the space: {@link typeMapper} asks it which fields a foreign key references. */
2418
+ registerSpace(space) {
2419
+ this._space = space;
2420
+ }
1982
2421
  typeMapper(field) {
1983
2422
  if (field.encrypted) return "TEXT";
1984
2423
  if (this._vectorFields.has(field.path)) {
1985
2424
  const vec = this._vectorFields.get(field.path);
1986
2425
  return this._supportsVector ? `VECTOR(${vec.dimensions})` : "JSON";
1987
2426
  }
1988
- return mysqlTypeFromField(field);
2427
+ return mysqlTypeFromField(field, !field.fkTargetField && this._space !== void 0 && this._table !== void 0 && this._space.getReferencedFields(this._table.tableName, this._table.schema).has(field.path));
1989
2428
  }
1990
2429
  async _syncIndexes(dryRun) {
1991
2430
  const tableName = this._table.tableName;
@@ -1993,15 +2432,14 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1993
2432
  if (!dryRun) {
1994
2433
  for (const index of this._table.indexes.values()) if (index.type === "fulltext" && index.language !== void 0) this.logger.warn(`[mysql] fulltext index "${index.name}": @db.index.fulltext.language "${index.language}" is ignored — MySQL FULLTEXT has no per-index language`);
1995
2434
  }
1996
- const bytesPerChar = mysqlBytesPerChar(this._charset);
1997
2435
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
1998
2436
  const mappedType = (column) => {
1999
2437
  const field = fields.get(column);
2000
2438
  return field ? this.typeMapper(field) : void 0;
2001
2439
  };
2002
2440
  const desiredPrefix = (column) => {
2003
- const type = mappedType(column);
2004
- return type === void 0 ? void 0 : mysqlIndexPrefix(type, bytesPerChar);
2441
+ const field = fields.get(column);
2442
+ return field && this._indexPrefix(field);
2005
2443
  };
2006
2444
  const renderPart = (column, prefix) => prefix === void 0 ? column : `${column}(${prefix})`;
2007
2445
  return this.syncIndexesWithDiff({
@@ -2053,11 +2491,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2053
2491
  this._log(sql);
2054
2492
  await this._exec().exec(sql);
2055
2493
  },
2056
- dropIndex: async (name) => {
2057
- const sql = `DROP INDEX ${qi(name)} ON ${quoteTableName(this.resolveTableName())}`;
2058
- this._log(sql);
2059
- await this._exec().exec(sql);
2060
- }
2494
+ dropIndex: (name) => this._dropIndex(name)
2061
2495
  });
2062
2496
  }
2063
2497
  async syncForeignKeys() {
@@ -2068,10 +2502,11 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2068
2502
  return this._syncForeignKeys(true);
2069
2503
  }
2070
2504
  _syncForeignKeys(dryRun) {
2505
+ let existing;
2071
2506
  return this.syncForeignKeysWithDiff({
2072
2507
  dryRun,
2073
- listExisting: () => this._getExistingFkConstraints(),
2074
- dropConstraint: (name) => this._dropFkConstraint(name),
2508
+ listExisting: async () => existing = await this._getExistingFkConstraints(),
2509
+ dropConstraint: (name) => this._dropFkConstraint(name, existing.get(name)),
2075
2510
  addConstraint: async (fk) => {
2076
2511
  const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD ${foreignKeySql(qi, fk)}`;
2077
2512
  this._log(ddl);
@@ -2082,19 +2517,40 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2082
2517
  async dropForeignKeys(fkFieldKeys) {
2083
2518
  if (fkFieldKeys.length === 0) return;
2084
2519
  const keySet = new Set(fkFieldKeys);
2085
- for (const [constraintName, cols] of await this._getExistingFkConstraints()) if (keySet.has(fkKey(cols))) await this._dropFkConstraint(constraintName);
2520
+ for (const [constraintName, cols] of await this._getExistingFkConstraints()) if (keySet.has(fkKey(cols))) await this._dropFkConstraint(constraintName, cols);
2086
2521
  }
2087
- async _dropFkConstraint(name) {
2522
+ /**
2523
+ * Drops a foreign-key constraint over `columns` (in key order), then the
2524
+ * index InnoDB created for it: an unnamed constraint gets an index named
2525
+ * after its first column, which outlives the constraint — and would block
2526
+ * that column from becoming TEXT once it no longer references anything
2527
+ * (ER 1170). An index another constraint still needs is kept (ER 1553).
2528
+ */
2529
+ async _dropFkConstraint(name, columns) {
2088
2530
  const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP FOREIGN KEY ${qi(name)}`;
2089
2531
  this._log(ddl);
2090
2532
  await this._exec().exec(ddl);
2533
+ if (columns.length === 0) return;
2534
+ if ((await this._exec().all(`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS
2535
+ WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE()) AND INDEX_NAME = ?
2536
+ ORDER BY SEQ_IN_INDEX`, [
2537
+ this._table.tableName,
2538
+ this._schema,
2539
+ columns[0]
2540
+ ])).map((r) => r.COLUMN_NAME).join("\0") !== columns.join("\0")) return;
2541
+ try {
2542
+ await this._dropIndex(columns[0]);
2543
+ } catch (error) {
2544
+ if (error.errno !== 1553) throw error;
2545
+ }
2091
2546
  }
2092
2547
  /** Queries INFORMATION_SCHEMA for existing FK constraints, grouped by constraint name → column names. */
2093
2548
  async _getExistingFkConstraints() {
2094
2549
  const rows = await this._exec().all(`SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME
2095
2550
  FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
2096
2551
  WHERE kcu.TABLE_NAME = ? AND kcu.TABLE_SCHEMA = COALESCE(?, DATABASE())
2097
- AND kcu.REFERENCED_TABLE_NAME IS NOT NULL`, [this._table.tableName, this._schema]);
2552
+ AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
2553
+ ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION`, [this._table.tableName, this._schema]);
2098
2554
  const byName = /* @__PURE__ */ new Map();
2099
2555
  for (const row of rows) {
2100
2556
  let cols = byName.get(row.CONSTRAINT_NAME);