@atscript/db-mysql 0.1.147 → 0.1.149

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
@@ -105,7 +105,7 @@ function buildColumnDefinition(field, ctx) {
105
105
  def: `${qi(field.physicalName)} ${sqlType}${mysqlCollateClause(field)} GENERATED ALWAYS AS (${(0, _atscript_db_sql_tools.derivedColumnExpr)(mysqlDialect, field)}) VIRTUAL`,
106
106
  inventedDefault: false
107
107
  };
108
- const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.physicalName) ?? false);
108
+ const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.path) ?? false);
109
109
  let def = `${qi(field.physicalName)} ${sqlType}`;
110
110
  if (increment) def += " AUTO_INCREMENT";
111
111
  if (ctx.purpose === "create") {
@@ -165,6 +165,10 @@ const mysqlDialect = {
165
165
  params: [pattern]
166
166
  };
167
167
  },
168
+ mapQueryError(error, arithmetic) {
169
+ if (error?.errno !== 1690) return void 0;
170
+ return arithmetic ? (0, _atscript_db_sql_tools.arithOverflowError)() : (0, _atscript_db_sql_tools.numericOutOfRangeError)();
171
+ },
168
172
  geoWithin(quotedCol, circle) {
169
173
  const dist = mysqlGeoDistanceExpr(quotedCol, circle.center);
170
174
  return {
@@ -518,6 +522,9 @@ function epochMsToUtcDatetime(ms) {
518
522
  * "unchanged" named-zone result is not evidence of a limited range.
519
523
  */
520
524
  const TZ_PROBE_SQL = "SELECT CONVERT_TZ('2040-06-01 12:00:00', '+00:00', ?) IS NULL AS missing, TIMESTAMPDIFF(MINUTE, '2040-06-01 12:00:00', CONVERT_TZ('2040-06-01 12:00:00', '+00:00', '+01:00')) AS shift";
525
+ const isZero = (v) => v === 0 || v === "0";
526
+ /** Drivers whose session `sql_mode` was already checked for strictness (see `_warnNonStrictMode`). */
527
+ const nonStrictChecked = /* @__PURE__ */ new WeakSet();
521
528
  /**
522
529
  * Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
523
530
  * and share a driver). Positives only: a failed zone is probed again, so
@@ -661,10 +668,14 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
661
668
  calendarBucketUnits() {
662
669
  return _atscript_db.ALL_BUCKET_UNITS;
663
670
  }
664
- /** Every aggregate function, `countDistinct` included. */
671
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
665
672
  aggregateFns() {
666
673
  return _atscript_db.ALL_AGGREGATE_FNS;
667
674
  }
675
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
676
+ supportsAggregateExpressions() {
677
+ return true;
678
+ }
668
679
  /** Computed view columns and first-row joins. */
669
680
  viewCapabilities() {
670
681
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -779,6 +790,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
779
790
  *
780
791
  * MySQL uses numeric error codes:
781
792
  * - 1062 = ER_DUP_ENTRY (unique constraint violation)
793
+ * - 1586 = ER_DUP_ENTRY_WITH_KEY_NAME (same, for a multi-row INSERT)
782
794
  * - 1451 = ER_ROW_IS_REFERENCED_2 (FK violation on delete)
783
795
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
784
796
  */
@@ -786,17 +798,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
786
798
  try {
787
799
  return await fn();
788
800
  } catch (error) {
789
- if (error && typeof error === "object" && "errno" in error) {
790
- const err = error;
791
- if (err.errno === 1062) throw new _atscript_db.DbError("CONFLICT", [{
792
- path: (err.message?.match(/for key '(?:\w+\.)?(\w+)'/))?.[1] ?? "",
793
- message: err.sqlMessage ?? err.message
794
- }]);
795
- if (err.errno === 1451 || err.errno === 1452) throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.message));
796
- }
797
- throw error;
801
+ return this._mapConstraintError(error);
798
802
  }
799
803
  }
804
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
805
+ _mapConstraintError(error) {
806
+ if (error && typeof error === "object" && "errno" in error) {
807
+ const err = error;
808
+ if (err.errno === 1062 || err.errno === 1586) throw new _atscript_db.DbError("CONFLICT", [{
809
+ path: (err.message?.match(/^[\s\S]*for key '(?:[^']*\.)?([^'.]+)'/))?.[1] ?? "",
810
+ message: err.sqlMessage ?? err.message
811
+ }]);
812
+ if (err.errno === 1451 || err.errno === 1452) throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.message));
813
+ }
814
+ throw error;
815
+ }
800
816
  _mapFkError(message) {
801
817
  const fkMatch = message.match(/FOREIGN KEY \(`(\w+)`\)/);
802
818
  if (fkMatch) {
@@ -824,16 +840,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
824
840
  };
825
841
  return this.withTransaction(async () => {
826
842
  const tableName = this.resolveTableName();
827
- const columns = (0, _atscript_db_sql_tools.insertManyColumns)(data);
828
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
843
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
829
844
  const allIds = [];
830
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
831
- const batch = data.slice(offset, offset + maxRowsPerBatch);
832
- const { sql, params } = buildInsertMany(tableName, batch, columns);
833
- this._log(sql, params);
834
- const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
835
- const firstId = Number(result.insertId);
836
- for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
845
+ const step = this._incrementStep();
846
+ const zero = this._zeroIsExplicit();
847
+ for (const batch of batches) {
848
+ const ids = Array.from({ length: batch.length });
849
+ for (const group of await this._idGroups(batch, zero)) {
850
+ const { sql, params } = buildInsertMany(tableName, group.rows, columns);
851
+ this._log(sql, params);
852
+ const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
853
+ (await this._groupInsertedIds(group, result.insertId, step)).forEach((id, k) => {
854
+ ids[group.at[k]] = id;
855
+ });
856
+ }
857
+ allIds.push(...ids);
837
858
  }
838
859
  return {
839
860
  insertedCount: allIds.length,
@@ -841,6 +862,230 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
841
862
  };
842
863
  });
843
864
  }
865
+ /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
866
+ _autoIncrementPk() {
867
+ const pks = this._table.primaryKeys;
868
+ if (pks.length !== 1 || !this._incrementFields.has(pks[0])) return void 0;
869
+ return this._table.getMetadata().physicalPath(pks[0]);
870
+ }
871
+ /**
872
+ * Splits `rows` into runs that each map to ONE statement with a derivable
873
+ * id sequence. MySQL reports only the first GENERATED id of a statement, and
874
+ * an explicit auto-increment value above the counter bumps the counter, so a
875
+ * statement mixing explicit and generated PKs cannot be mapped by
876
+ * `insertId + i * step`: the chunk is cut into CONSECUTIVE runs of one kind,
877
+ * executed in input order (so "an earlier row wins" a unique collision, even
878
+ * under a case-insensitive collation). A table without an AUTO_INCREMENT PK,
879
+ * or a chunk of one kind, stays one group.
880
+ */
881
+ async _idGroups(rows, zeroIsExplicit) {
882
+ const col = this._autoIncrementPk();
883
+ if (!col) return [{
884
+ rows,
885
+ at: rows.map((_, i) => i),
886
+ generated: false
887
+ }];
888
+ const zeroExplicit = rows.some((r) => isZero(r[col])) && await zeroIsExplicit();
889
+ const groups = [];
890
+ rows.forEach((row, i) => {
891
+ const v = row[col];
892
+ const generated = v === void 0 || v === null || isZero(v) && !zeroExplicit;
893
+ let last = groups.at(-1);
894
+ if (!last || last.generated !== generated) {
895
+ last = {
896
+ rows: [],
897
+ at: [],
898
+ generated
899
+ };
900
+ groups.push(last);
901
+ }
902
+ last.rows.push(row);
903
+ last.at.push(i);
904
+ });
905
+ return groups;
906
+ }
907
+ /** The session `sql_mode` text on the current connection (`undefined` when the server returns no row). */
908
+ async _sessionSqlMode() {
909
+ const row = await this._exec().get("SELECT @@SESSION.sql_mode AS mode", []);
910
+ if (!row) return void 0;
911
+ return typeof row.mode === "string" ? row.mode : "";
912
+ }
913
+ /** The {@link TZeroIsExplicit} of one `insertMany` / `insertManyIgnore` call. */
914
+ _zeroIsExplicit() {
915
+ let mode;
916
+ return () => mode ??= this._sessionSqlMode().then((text) => (text ?? "").includes("NO_AUTO_VALUE_ON_ZERO"));
917
+ }
918
+ /**
919
+ * Warns ONCE per driver when the session `sql_mode` is not strict: writes
920
+ * (insert-ignore included) assume `STRICT_TRANS_TABLES` / `STRICT_ALL_TABLES`
921
+ * (the MySQL 8 default); a non-strict mode coerces a NOT NULL violation to the
922
+ * column default instead of failing. Only probes when the adapter has a logger.
923
+ */
924
+ async _warnNonStrictMode() {
925
+ if (this.logger === _atscript_db.NoopLogger || nonStrictChecked.has(this.driver)) return;
926
+ nonStrictChecked.add(this.driver);
927
+ try {
928
+ const mode = await this._sessionSqlMode();
929
+ if (mode !== void 0 && !/STRICT_(TRANS|ALL)_TABLES/.test(mode)) this.logger.warn(`MySQL session sql_mode lacks STRICT_TRANS_TABLES / STRICT_ALL_TABLES (${mode}): writes assume a strict mode and a non-strict server silently coerces NOT NULL violations`);
930
+ } catch {
931
+ nonStrictChecked.delete(this.driver);
932
+ }
933
+ }
934
+ /** The {@link TIncrementStep} of one `insertMany` / `insertManyIgnore` call. */
935
+ _incrementStep() {
936
+ let step;
937
+ return () => step ??= (async () => {
938
+ const row = await this._exec().get("SELECT @@auto_increment_increment AS step", []);
939
+ const n = Number(row?.step);
940
+ return Number.isInteger(n) && n > 0 ? n : 1;
941
+ })();
942
+ }
943
+ /**
944
+ * Ids of the rows of ONE successful multi-row INSERT of a homogeneous
945
+ * {@link _idGroups} group: generated rows get ids from `insertId` stepping by
946
+ * the session's `@@auto_increment_increment` (consecutive within one
947
+ * statement under `innodb_autoinc_lock_mode` 0 / 1, and — for a known row
948
+ * count — mode 2, the 8.0 default), explicit rows keep their own value.
949
+ */
950
+ async _groupInsertedIds(group, insertId, step) {
951
+ const first = Number(insertId);
952
+ const stride = group.generated && group.rows.length > 1 && first > 0 ? await step() : 1;
953
+ return group.rows.map((row, i) => {
954
+ const generatedId = first > 0 ? first + i * stride : 0;
955
+ return group.generated ? generatedId : this._resolveInsertedId(row, generatedId);
956
+ });
957
+ }
958
+ supportsInsertIgnore() {
959
+ return true;
960
+ }
961
+ /**
962
+ * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
963
+ * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
964
+ * SELECT of the chunk's primary / unique key tuples find the stored rows
965
+ * (skipped as conflicts) and the survivors go in as one more multi-row
966
+ * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
967
+ * if that INSERT still collides (a concurrent writer raced in, or a
968
+ * collation-equal value the exact-match pre-check missed) are the survivors
969
+ * bisected: each half is retried, recursively, and a single row that still
970
+ * collides is skipped. A failed statement is rolled back by InnoDB alone, so
971
+ * the transaction stays usable. A chunk mixing explicit and generated
972
+ * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
973
+ * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
974
+ * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
975
+ * indistinguishable from an insert in the affected-rows count).
976
+ */
977
+ async insertManyIgnore(data) {
978
+ if (data.length === 0) return [];
979
+ await this._warnNonStrictMode();
980
+ return this.withTransaction(async () => {
981
+ const tableName = this.resolveTableName();
982
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
983
+ const slots = [];
984
+ const step = this._incrementStep();
985
+ const zero = this._zeroIsExplicit();
986
+ for (const batch of batches) {
987
+ const out = Array.from({ length: batch.length }, () => null);
988
+ for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step)).forEach((slot, k) => {
989
+ out[group.at[k]] = slot;
990
+ });
991
+ slots.push(...out);
992
+ }
993
+ return slots;
994
+ });
995
+ }
996
+ /**
997
+ * Indices of `rows` whose primary / unique-index key tuple is already stored
998
+ * (a row with a null / missing key component never collides). One SELECT
999
+ * covers every key set; it is split only to stay under the parameter limit.
1000
+ * Skipped entirely when no row carries a key value (generated PK, no unique
1001
+ * index values).
1002
+ */
1003
+ async _findStoredKeyConflicts(tableName, rows) {
1004
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1005
+ const rowTuples = rows.map((row) => keySets.map((fields) => (0, _atscript_db.uniqueKeyTuple)(row, fields)));
1006
+ const used = keySets.map((_, k) => rowTuples.some((t) => t[k] !== void 0));
1007
+ if (!used.includes(true)) return /* @__PURE__ */ new Set();
1008
+ const width = keySets.reduce((n, f, k) => n + (used[k] ? f.length : 0), 0);
1009
+ const sliceSize = Math.max(1, Math.floor(6e4 / width));
1010
+ const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1011
+ const stored = keySets.map(() => /* @__PURE__ */ new Set());
1012
+ for (let offset = 0; offset < rows.length; offset += sliceSize) {
1013
+ const end = Math.min(rows.length, offset + sliceSize);
1014
+ const clauses = [];
1015
+ const params = [];
1016
+ keySets.forEach((fields, k) => {
1017
+ if (!used[k]) return;
1018
+ const group = [];
1019
+ for (let i = offset; i < end; i++) if (rowTuples[i][k] !== void 0) group.push(rows[i]);
1020
+ if (group.length === 0) return;
1021
+ const values = (row) => fields.map((f) => mysqlDialect.toValue(row[f]));
1022
+ if (fields.length === 1) {
1023
+ clauses.push(`${qi(fields[0])} IN (${group.map(() => "?").join(", ")})`);
1024
+ for (const row of group) params.push(...values(row));
1025
+ } else {
1026
+ const tuple = `(${fields.map(() => "?").join(", ")})`;
1027
+ clauses.push(`(${fields.map((f) => qi(f)).join(", ")}) IN (${group.map(() => tuple).join(", ")})`);
1028
+ for (const row of group) params.push(...values(row));
1029
+ }
1030
+ });
1031
+ if (clauses.length === 0) continue;
1032
+ const sql = `SELECT ${selectCols} FROM ${quoteTableName(tableName)} WHERE ${clauses.join(" OR ")}`;
1033
+ this._log(sql, params);
1034
+ const found = await this._exec().all(sql, params);
1035
+ for (const doc of found) keySets.forEach((fields, k) => {
1036
+ const tuple = (0, _atscript_db.uniqueKeyTuple)(doc, fields);
1037
+ if (tuple !== void 0) stored[k].add(tuple);
1038
+ });
1039
+ }
1040
+ const skipped = /* @__PURE__ */ new Set();
1041
+ rowTuples.forEach((tuples, i) => {
1042
+ if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
1043
+ });
1044
+ return skipped;
1045
+ }
1046
+ /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
1047
+ async _insertIgnoringGroup(tableName, columns, group, step) {
1048
+ const direct = await this._tryInsertGroup(tableName, columns, group, step);
1049
+ if (direct) return direct;
1050
+ if (group.rows.length === 1) return [null];
1051
+ const skipped = await this._findStoredKeyConflicts(tableName, group.rows);
1052
+ if (skipped.size === 0) return this._bisectGroup(tableName, columns, group, step);
1053
+ const survivors = {
1054
+ rows: group.rows.filter((_, i) => !skipped.has(i)),
1055
+ at: [],
1056
+ generated: group.generated
1057
+ };
1058
+ const inserted = survivors.rows.length > 0 ? await this._tryInsertGroup(tableName, columns, survivors, step) ?? (survivors.rows.length === 1 ? [null] : await this._bisectGroup(tableName, columns, survivors, step)) : [];
1059
+ let next = 0;
1060
+ return group.rows.map((_, i) => skipped.has(i) ? null : inserted[next++]);
1061
+ }
1062
+ /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
1063
+ async _bisectGroup(tableName, columns, group, step) {
1064
+ const mid = group.rows.length >> 1;
1065
+ const out = [];
1066
+ for (const rows of [group.rows.slice(0, mid), group.rows.slice(mid)]) {
1067
+ const half = {
1068
+ rows,
1069
+ at: [],
1070
+ generated: group.generated
1071
+ };
1072
+ out.push(...await this._tryInsertGroup(tableName, columns, half, step) ?? (rows.length === 1 ? [null] : await this._bisectGroup(tableName, columns, half, step)));
1073
+ }
1074
+ return out;
1075
+ }
1076
+ /** ONE INSERT of a group's rows; `undefined` on a duplicate key (errno 1062 / 1586). */
1077
+ async _tryInsertGroup(tableName, columns, group, step) {
1078
+ const { sql, params } = buildInsertMany(tableName, group.rows, columns);
1079
+ this._log(sql, params);
1080
+ try {
1081
+ const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
1082
+ return (await this._groupInsertedIds(group, result.insertId, step)).map((insertedId) => ({ insertedId }));
1083
+ } catch (error) {
1084
+ if (!(0, _atscript_db.isConflict)(error)) throw error;
1085
+ if (group.generated && error.errors.some((e) => e.path === "PRIMARY")) throw error;
1086
+ return;
1087
+ }
1088
+ }
844
1089
  async findOne(query) {
845
1090
  const where = buildWhere(query.filter);
846
1091
  const controls = {
@@ -881,11 +1126,11 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
881
1126
  if (query.controls.$count) {
882
1127
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
883
1128
  this._log(sql, params);
884
- return [{ count: (await this._exec().get(sql, params))?.count ?? 0 }];
1129
+ return [{ count: (await (0, _atscript_db_sql_tools.mapQueryErrors)(mysqlDialect, () => this._exec().get(sql, params), query.controls))?.count ?? 0 }];
885
1130
  }
886
1131
  const { sql, params } = buildAggregateSelect(tableName, where, query.controls);
887
1132
  this._log(sql, params);
888
- return this._exec().all(sql, params);
1133
+ return (0, _atscript_db_sql_tools.mapQueryErrors)(mysqlDialect, () => this._exec().all(sql, params), query.controls);
889
1134
  }
890
1135
  /**
891
1136
  * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
@@ -1011,6 +1256,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1011
1256
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
1012
1257
  }
1013
1258
  async prepareTypeMapper() {
1259
+ await this._warnNonStrictMode();
1014
1260
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
1015
1261
  }
1016
1262
  async ensureTable(opts) {
@@ -1712,6 +1958,26 @@ function atscriptTypeCast(field, next) {
1712
1958
  }
1713
1959
  return next();
1714
1960
  }
1961
+ /** Appends `STRICT_TRANS_TABLES` to the session `sql_mode` unless a strict mode is already set. */
1962
+ const ENSURE_STRICT_SQL = "SET SESSION sql_mode = IF(FIND_IN_SET('STRICT_TRANS_TABLES', @@SESSION.sql_mode) OR FIND_IN_SET('STRICT_ALL_TABLES', @@SESSION.sql_mode), @@SESSION.sql_mode, CONCAT_WS(',', NULLIF(@@SESSION.sql_mode, ''), 'STRICT_TRANS_TABLES'))";
1963
+ /**
1964
+ * Runs {@link ENSURE_STRICT_SQL} once on every connection of the pool: on
1965
+ * `connection` for the ones the pool opens, and on `acquire` for a connection
1966
+ * opened before the driver was constructed (a pre-used pool). The statement is
1967
+ * queued on the connection before the pool hands it out, so it precedes every
1968
+ * query; a failure leaves the server's mode in place.
1969
+ */
1970
+ function ensureStrict(pool) {
1971
+ const raw = pool;
1972
+ const seen = /* @__PURE__ */ new WeakSet();
1973
+ const strict = (conn) => {
1974
+ if (seen.has(conn)) return;
1975
+ seen.add(conn);
1976
+ conn.query(ENSURE_STRICT_SQL, () => {});
1977
+ };
1978
+ raw.on("connection", strict);
1979
+ raw.on("acquire", strict);
1980
+ }
1715
1981
  /**
1716
1982
  * {@link TMysqlDriver} implementation backed by `mysql2/promise`.
1717
1983
  *
@@ -1739,6 +2005,11 @@ function atscriptTypeCast(field, next) {
1739
2005
  * const driver = new Mysql2Driver(pool)
1740
2006
  * ```
1741
2007
  *
2008
+ * Every new pool connection gets `STRICT_TRANS_TABLES` appended to its session
2009
+ * `sql_mode` (since 0.1.148); pass `{ strictMode: false }` as the second
2010
+ * argument to opt out. A pre-created `Pool` is covered too: connections it
2011
+ * opened earlier get the statement when they are first acquired through it.
2012
+ *
1742
2013
  * Requires `mysql2` to be installed:
1743
2014
  * ```bash
1744
2015
  * pnpm add mysql2
@@ -1747,9 +2018,12 @@ function atscriptTypeCast(field, next) {
1747
2018
  var Mysql2Driver = class {
1748
2019
  pool;
1749
2020
  poolInit;
1750
- constructor(poolOrConfig) {
1751
- if (typeof poolOrConfig === "object" && "execute" in poolOrConfig) this.pool = poolOrConfig;
1752
- else this.poolInit = import("mysql2/promise").then((mysql) => {
2021
+ constructor(poolOrConfig, options = {}) {
2022
+ const strict = options.strictMode !== false;
2023
+ if (typeof poolOrConfig === "object" && "execute" in poolOrConfig) {
2024
+ this.pool = poolOrConfig;
2025
+ if (strict) ensureStrict(this.pool);
2026
+ } else this.poolInit = import("mysql2/promise").then((mysql) => {
1753
2027
  if (typeof poolOrConfig === "string") this.pool = mysql.createPool({
1754
2028
  uri: poolOrConfig,
1755
2029
  timezone: "+00:00",
@@ -1764,6 +2038,7 @@ var Mysql2Driver = class {
1764
2038
  bigNumberStrings: false,
1765
2039
  typeCast: atscriptTypeCast
1766
2040
  });
2041
+ if (strict) ensureStrict(this.pool);
1767
2042
  return this.pool;
1768
2043
  });
1769
2044
  }
@@ -1818,8 +2093,10 @@ var Mysql2Driver = class {
1818
2093
  }
1819
2094
  };
1820
2095
  }
1821
- async close() {
1822
- await (await this.getPool()).end();
2096
+ _closing;
2097
+ /** Idempotent: every call returns the first call's promise. */
2098
+ close() {
2099
+ return this._closing ??= Promise.resolve(this.getPool()).then((pool) => pool.end());
1823
2100
  }
1824
2101
  };
1825
2102
  //#endregion
@@ -1828,15 +2105,17 @@ var Mysql2Driver = class {
1828
2105
  * Creates a {@link DbSpace} backed by a MySQL connection pool.
1829
2106
  *
1830
2107
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
1831
- * @param options - Additional pool options passed to mysql2.
2108
+ * @param options - Additional pool options passed to mysql2. `strictMode: false`
2109
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
1832
2110
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
1833
2111
  */
1834
2112
  function createAdapter(uri, options) {
2113
+ const { strictMode, ...pool } = options ?? {};
1835
2114
  const driver = new Mysql2Driver({
1836
2115
  uri,
1837
- ...options
1838
- });
1839
- return new _atscript_db.DbSpace(() => new MysqlAdapter(driver));
2116
+ ...pool
2117
+ }, { strictMode });
2118
+ return new _atscript_db.DbSpace(() => new MysqlAdapter(driver), { onClose: () => driver.close() });
1840
2119
  }
1841
2120
  //#endregion
1842
2121
  exports.Mysql2Driver = Mysql2Driver;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
@@ -155,8 +155,10 @@ declare class MysqlAdapter extends BaseDbAdapter {
155
155
  * server's time zone tables (probed per zone in `aggregate()`).
156
156
  */
157
157
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
158
- /** Every aggregate function, `countDistinct` included. */
158
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
159
159
  aggregateFns(): ReadonlySet<AggregateFn>;
160
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
161
+ supportsAggregateExpressions(): boolean;
160
162
  /** Computed view columns and first-row joins. */
161
163
  viewCapabilities(): ReadonlySet<TViewCapability>;
162
164
  onBeforeFlatten(_type: unknown): void;
@@ -176,13 +178,82 @@ declare class MysqlAdapter extends BaseDbAdapter {
176
178
  *
177
179
  * MySQL uses numeric error codes:
178
180
  * - 1062 = ER_DUP_ENTRY (unique constraint violation)
181
+ * - 1586 = ER_DUP_ENTRY_WITH_KEY_NAME (same, for a multi-row INSERT)
179
182
  * - 1451 = ER_ROW_IS_REFERENCED_2 (FK violation on delete)
180
183
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
181
184
  */
182
185
  private _wrapConstraintError;
186
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
187
+ private _mapConstraintError;
183
188
  private _mapFkError;
184
189
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
185
190
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
191
+ /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
192
+ private _autoIncrementPk;
193
+ /**
194
+ * Splits `rows` into runs that each map to ONE statement with a derivable
195
+ * id sequence. MySQL reports only the first GENERATED id of a statement, and
196
+ * an explicit auto-increment value above the counter bumps the counter, so a
197
+ * statement mixing explicit and generated PKs cannot be mapped by
198
+ * `insertId + i * step`: the chunk is cut into CONSECUTIVE runs of one kind,
199
+ * executed in input order (so "an earlier row wins" a unique collision, even
200
+ * under a case-insensitive collation). A table without an AUTO_INCREMENT PK,
201
+ * or a chunk of one kind, stays one group.
202
+ */
203
+ private _idGroups;
204
+ /** The session `sql_mode` text on the current connection (`undefined` when the server returns no row). */
205
+ private _sessionSqlMode;
206
+ /** The {@link TZeroIsExplicit} of one `insertMany` / `insertManyIgnore` call. */
207
+ private _zeroIsExplicit;
208
+ /**
209
+ * Warns ONCE per driver when the session `sql_mode` is not strict: writes
210
+ * (insert-ignore included) assume `STRICT_TRANS_TABLES` / `STRICT_ALL_TABLES`
211
+ * (the MySQL 8 default); a non-strict mode coerces a NOT NULL violation to the
212
+ * column default instead of failing. Only probes when the adapter has a logger.
213
+ */
214
+ private _warnNonStrictMode;
215
+ /** The {@link TIncrementStep} of one `insertMany` / `insertManyIgnore` call. */
216
+ private _incrementStep;
217
+ /**
218
+ * Ids of the rows of ONE successful multi-row INSERT of a homogeneous
219
+ * {@link _idGroups} group: generated rows get ids from `insertId` stepping by
220
+ * the session's `@@auto_increment_increment` (consecutive within one
221
+ * statement under `innodb_autoinc_lock_mode` 0 / 1, and — for a known row
222
+ * count — mode 2, the 8.0 default), explicit rows keep their own value.
223
+ */
224
+ private _groupInsertedIds;
225
+ supportsInsertIgnore(): boolean;
226
+ /**
227
+ * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
228
+ * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
229
+ * SELECT of the chunk's primary / unique key tuples find the stored rows
230
+ * (skipped as conflicts) and the survivors go in as one more multi-row
231
+ * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
232
+ * if that INSERT still collides (a concurrent writer raced in, or a
233
+ * collation-equal value the exact-match pre-check missed) are the survivors
234
+ * bisected: each half is retried, recursively, and a single row that still
235
+ * collides is skipped. A failed statement is rolled back by InnoDB alone, so
236
+ * the transaction stays usable. A chunk mixing explicit and generated
237
+ * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
238
+ * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
239
+ * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
240
+ * indistinguishable from an insert in the affected-rows count).
241
+ */
242
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
243
+ /**
244
+ * Indices of `rows` whose primary / unique-index key tuple is already stored
245
+ * (a row with a null / missing key component never collides). One SELECT
246
+ * covers every key set; it is split only to stay under the parameter limit.
247
+ * Skipped entirely when no row carries a key value (generated PK, no unique
248
+ * index values).
249
+ */
250
+ private _findStoredKeyConflicts;
251
+ /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
252
+ private _insertIgnoringGroup;
253
+ /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
254
+ private _bisectGroup;
255
+ /** ONE INSERT of a group's rows; `undefined` on a duplicate key (errno 1062 / 1586). */
256
+ private _tryInsertGroup;
186
257
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
187
258
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
188
259
  /**
@@ -320,6 +391,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
320
391
  }
321
392
  //#endregion
322
393
  //#region src/mysql2-driver.d.ts
394
+ /** Options of {@link Mysql2Driver}. */
395
+ interface TMysql2DriverOptions {
396
+ /**
397
+ * Add `STRICT_TRANS_TABLES` to the session `sql_mode` of every new pool
398
+ * connection (default `true`, since 0.1.148). The server's own modes are
399
+ * kept — the mode is appended, never replaced. A non-strict server (Amazon
400
+ * RDS defaults to `NO_ENGINE_SUBSTITUTION`) otherwise coerces a `NOT NULL`,
401
+ * out-of-range or too-long value instead of failing the write. Pass `false`
402
+ * to keep the server's `sql_mode` untouched.
403
+ */
404
+ strictMode?: boolean;
405
+ }
323
406
  /**
324
407
  * {@link TMysqlDriver} implementation backed by `mysql2/promise`.
325
408
  *
@@ -347,6 +430,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
347
430
  * const driver = new Mysql2Driver(pool)
348
431
  * ```
349
432
  *
433
+ * Every new pool connection gets `STRICT_TRANS_TABLES` appended to its session
434
+ * `sql_mode` (since 0.1.148); pass `{ strictMode: false }` as the second
435
+ * argument to opt out. A pre-created `Pool` is covered too: connections it
436
+ * opened earlier get the statement when they are first acquired through it.
437
+ *
350
438
  * Requires `mysql2` to be installed:
351
439
  * ```bash
352
440
  * pnpm add mysql2
@@ -355,13 +443,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
355
443
  declare class Mysql2Driver implements TMysqlDriver {
356
444
  private pool;
357
445
  private poolInit;
358
- constructor(poolOrConfig: string | import("mysql2/promise").Pool | import("mysql2/promise").PoolOptions);
446
+ constructor(poolOrConfig: string | import("mysql2/promise").Pool | import("mysql2/promise").PoolOptions, options?: TMysql2DriverOptions);
359
447
  private getPool;
360
448
  run(sql: string, params?: unknown[]): Promise<TMysqlRunResult>;
361
449
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
362
450
  get<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null>;
363
451
  exec(sql: string): Promise<void>;
364
452
  getConnection(): Promise<TMysqlConnection>;
453
+ private _closing?;
454
+ /** Idempotent: every call returns the first call's promise. */
365
455
  close(): Promise<void>;
366
456
  }
367
457
  //#endregion
@@ -385,9 +475,10 @@ declare function buildWhere(filter: FilterExpr$1, opts?: TFilterVisitorOptions):
385
475
  * Creates a {@link DbSpace} backed by a MySQL connection pool.
386
476
  *
387
477
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
388
- * @param options - Additional pool options passed to mysql2.
478
+ * @param options - Additional pool options passed to mysql2. `strictMode: false`
479
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
389
480
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
390
481
  */
391
482
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
392
483
  //#endregion
393
- export { Mysql2Driver, MysqlAdapter, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
484
+ export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TMetadataMap } from "@atscript/typescript/utils";
@@ -155,8 +155,10 @@ declare class MysqlAdapter extends BaseDbAdapter {
155
155
  * server's time zone tables (probed per zone in `aggregate()`).
156
156
  */
157
157
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
158
- /** Every aggregate function, `countDistinct` included. */
158
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
159
159
  aggregateFns(): ReadonlySet<AggregateFn>;
160
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
161
+ supportsAggregateExpressions(): boolean;
160
162
  /** Computed view columns and first-row joins. */
161
163
  viewCapabilities(): ReadonlySet<TViewCapability>;
162
164
  onBeforeFlatten(_type: unknown): void;
@@ -176,13 +178,82 @@ declare class MysqlAdapter extends BaseDbAdapter {
176
178
  *
177
179
  * MySQL uses numeric error codes:
178
180
  * - 1062 = ER_DUP_ENTRY (unique constraint violation)
181
+ * - 1586 = ER_DUP_ENTRY_WITH_KEY_NAME (same, for a multi-row INSERT)
179
182
  * - 1451 = ER_ROW_IS_REFERENCED_2 (FK violation on delete)
180
183
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
181
184
  */
182
185
  private _wrapConstraintError;
186
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
187
+ private _mapConstraintError;
183
188
  private _mapFkError;
184
189
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
185
190
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
191
+ /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
192
+ private _autoIncrementPk;
193
+ /**
194
+ * Splits `rows` into runs that each map to ONE statement with a derivable
195
+ * id sequence. MySQL reports only the first GENERATED id of a statement, and
196
+ * an explicit auto-increment value above the counter bumps the counter, so a
197
+ * statement mixing explicit and generated PKs cannot be mapped by
198
+ * `insertId + i * step`: the chunk is cut into CONSECUTIVE runs of one kind,
199
+ * executed in input order (so "an earlier row wins" a unique collision, even
200
+ * under a case-insensitive collation). A table without an AUTO_INCREMENT PK,
201
+ * or a chunk of one kind, stays one group.
202
+ */
203
+ private _idGroups;
204
+ /** The session `sql_mode` text on the current connection (`undefined` when the server returns no row). */
205
+ private _sessionSqlMode;
206
+ /** The {@link TZeroIsExplicit} of one `insertMany` / `insertManyIgnore` call. */
207
+ private _zeroIsExplicit;
208
+ /**
209
+ * Warns ONCE per driver when the session `sql_mode` is not strict: writes
210
+ * (insert-ignore included) assume `STRICT_TRANS_TABLES` / `STRICT_ALL_TABLES`
211
+ * (the MySQL 8 default); a non-strict mode coerces a NOT NULL violation to the
212
+ * column default instead of failing. Only probes when the adapter has a logger.
213
+ */
214
+ private _warnNonStrictMode;
215
+ /** The {@link TIncrementStep} of one `insertMany` / `insertManyIgnore` call. */
216
+ private _incrementStep;
217
+ /**
218
+ * Ids of the rows of ONE successful multi-row INSERT of a homogeneous
219
+ * {@link _idGroups} group: generated rows get ids from `insertId` stepping by
220
+ * the session's `@@auto_increment_increment` (consecutive within one
221
+ * statement under `innodb_autoinc_lock_mode` 0 / 1, and — for a known row
222
+ * count — mode 2, the 8.0 default), explicit rows keep their own value.
223
+ */
224
+ private _groupInsertedIds;
225
+ supportsInsertIgnore(): boolean;
226
+ /**
227
+ * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
228
+ * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
229
+ * SELECT of the chunk's primary / unique key tuples find the stored rows
230
+ * (skipped as conflicts) and the survivors go in as one more multi-row
231
+ * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
232
+ * if that INSERT still collides (a concurrent writer raced in, or a
233
+ * collation-equal value the exact-match pre-check missed) are the survivors
234
+ * bisected: each half is retried, recursively, and a single row that still
235
+ * collides is skipped. A failed statement is rolled back by InnoDB alone, so
236
+ * the transaction stays usable. A chunk mixing explicit and generated
237
+ * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
238
+ * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
239
+ * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
240
+ * indistinguishable from an insert in the affected-rows count).
241
+ */
242
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
243
+ /**
244
+ * Indices of `rows` whose primary / unique-index key tuple is already stored
245
+ * (a row with a null / missing key component never collides). One SELECT
246
+ * covers every key set; it is split only to stay under the parameter limit.
247
+ * Skipped entirely when no row carries a key value (generated PK, no unique
248
+ * index values).
249
+ */
250
+ private _findStoredKeyConflicts;
251
+ /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
252
+ private _insertIgnoringGroup;
253
+ /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
254
+ private _bisectGroup;
255
+ /** ONE INSERT of a group's rows; `undefined` on a duplicate key (errno 1062 / 1586). */
256
+ private _tryInsertGroup;
186
257
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
187
258
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
188
259
  /**
@@ -320,6 +391,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
320
391
  }
321
392
  //#endregion
322
393
  //#region src/mysql2-driver.d.ts
394
+ /** Options of {@link Mysql2Driver}. */
395
+ interface TMysql2DriverOptions {
396
+ /**
397
+ * Add `STRICT_TRANS_TABLES` to the session `sql_mode` of every new pool
398
+ * connection (default `true`, since 0.1.148). The server's own modes are
399
+ * kept — the mode is appended, never replaced. A non-strict server (Amazon
400
+ * RDS defaults to `NO_ENGINE_SUBSTITUTION`) otherwise coerces a `NOT NULL`,
401
+ * out-of-range or too-long value instead of failing the write. Pass `false`
402
+ * to keep the server's `sql_mode` untouched.
403
+ */
404
+ strictMode?: boolean;
405
+ }
323
406
  /**
324
407
  * {@link TMysqlDriver} implementation backed by `mysql2/promise`.
325
408
  *
@@ -347,6 +430,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
347
430
  * const driver = new Mysql2Driver(pool)
348
431
  * ```
349
432
  *
433
+ * Every new pool connection gets `STRICT_TRANS_TABLES` appended to its session
434
+ * `sql_mode` (since 0.1.148); pass `{ strictMode: false }` as the second
435
+ * argument to opt out. A pre-created `Pool` is covered too: connections it
436
+ * opened earlier get the statement when they are first acquired through it.
437
+ *
350
438
  * Requires `mysql2` to be installed:
351
439
  * ```bash
352
440
  * pnpm add mysql2
@@ -355,13 +443,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
355
443
  declare class Mysql2Driver implements TMysqlDriver {
356
444
  private pool;
357
445
  private poolInit;
358
- constructor(poolOrConfig: string | import("mysql2/promise").Pool | import("mysql2/promise").PoolOptions);
446
+ constructor(poolOrConfig: string | import("mysql2/promise").Pool | import("mysql2/promise").PoolOptions, options?: TMysql2DriverOptions);
359
447
  private getPool;
360
448
  run(sql: string, params?: unknown[]): Promise<TMysqlRunResult>;
361
449
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
362
450
  get<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null>;
363
451
  exec(sql: string): Promise<void>;
364
452
  getConnection(): Promise<TMysqlConnection>;
453
+ private _closing?;
454
+ /** Idempotent: every call returns the first call's promise. */
365
455
  close(): Promise<void>;
366
456
  }
367
457
  //#endregion
@@ -385,9 +475,10 @@ declare function buildWhere(filter: FilterExpr$1, opts?: TFilterVisitorOptions):
385
475
  * Creates a {@link DbSpace} backed by a MySQL connection pool.
386
476
  *
387
477
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
388
- * @param options - Additional pool options passed to mysql2.
478
+ * @param options - Additional pool options passed to mysql2. `strictMode: false`
479
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
389
480
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
390
481
  */
391
482
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
392
483
  //#endregion
393
- export { Mysql2Driver, MysqlAdapter, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
484
+ export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, containsRelationFilter, fkColumns, forEachResolvedRelation, vectorIndexNotFoundMessage } from "@atscript/db";
1
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, NoopLogger, bucketTimeZoneUnavailable, containsRelationFilter, fkColumns, forEachResolvedRelation, isConflict, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, foreignKeySql, geoWindowFromControls, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource } from "@atscript/db-sql-tools";
3
+ import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, foreignKeySql, geoWindowFromControls, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/sql-builder.ts
6
6
  /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
@@ -104,7 +104,7 @@ function buildColumnDefinition(field, ctx) {
104
104
  def: `${qi(field.physicalName)} ${sqlType}${mysqlCollateClause(field)} GENERATED ALWAYS AS (${derivedColumnExpr(mysqlDialect, field)}) VIRTUAL`,
105
105
  inventedDefault: false
106
106
  };
107
- const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.physicalName) ?? false);
107
+ const increment = ctx.purpose !== "add" && (ctx.incrementFields?.has(field.path) ?? false);
108
108
  let def = `${qi(field.physicalName)} ${sqlType}`;
109
109
  if (increment) def += " AUTO_INCREMENT";
110
110
  if (ctx.purpose === "create") {
@@ -164,6 +164,10 @@ const mysqlDialect = {
164
164
  params: [pattern]
165
165
  };
166
166
  },
167
+ mapQueryError(error, arithmetic) {
168
+ if (error?.errno !== 1690) return void 0;
169
+ return arithmetic ? arithOverflowError() : numericOutOfRangeError();
170
+ },
167
171
  geoWithin(quotedCol, circle) {
168
172
  const dist = mysqlGeoDistanceExpr(quotedCol, circle.center);
169
173
  return {
@@ -517,6 +521,9 @@ function epochMsToUtcDatetime(ms) {
517
521
  * "unchanged" named-zone result is not evidence of a limited range.
518
522
  */
519
523
  const TZ_PROBE_SQL = "SELECT CONVERT_TZ('2040-06-01 12:00:00', '+00:00', ?) IS NULL AS missing, TIMESTAMPDIFF(MINUTE, '2040-06-01 12:00:00', CONVERT_TZ('2040-06-01 12:00:00', '+00:00', '+01:00')) AS shift";
524
+ const isZero = (v) => v === 0 || v === "0";
525
+ /** Drivers whose session `sql_mode` was already checked for strictness (see `_warnNonStrictMode`). */
526
+ const nonStrictChecked = /* @__PURE__ */ new WeakSet();
520
527
  /**
521
528
  * Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
522
529
  * and share a driver). Positives only: a failed zone is probed again, so
@@ -660,10 +667,14 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
660
667
  calendarBucketUnits() {
661
668
  return ALL_BUCKET_UNITS;
662
669
  }
663
- /** Every aggregate function, `countDistinct` included. */
670
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
664
671
  aggregateFns() {
665
672
  return ALL_AGGREGATE_FNS;
666
673
  }
674
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
675
+ supportsAggregateExpressions() {
676
+ return true;
677
+ }
667
678
  /** Computed view columns and first-row joins. */
668
679
  viewCapabilities() {
669
680
  return ALL_VIEW_CAPABILITIES;
@@ -778,6 +789,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
778
789
  *
779
790
  * MySQL uses numeric error codes:
780
791
  * - 1062 = ER_DUP_ENTRY (unique constraint violation)
792
+ * - 1586 = ER_DUP_ENTRY_WITH_KEY_NAME (same, for a multi-row INSERT)
781
793
  * - 1451 = ER_ROW_IS_REFERENCED_2 (FK violation on delete)
782
794
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
783
795
  */
@@ -785,17 +797,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
785
797
  try {
786
798
  return await fn();
787
799
  } catch (error) {
788
- if (error && typeof error === "object" && "errno" in error) {
789
- const err = error;
790
- if (err.errno === 1062) throw new DbError("CONFLICT", [{
791
- path: (err.message?.match(/for key '(?:\w+\.)?(\w+)'/))?.[1] ?? "",
792
- message: err.sqlMessage ?? err.message
793
- }]);
794
- if (err.errno === 1451 || err.errno === 1452) throw new DbError("FK_VIOLATION", this._mapFkError(err.message));
795
- }
796
- throw error;
800
+ return this._mapConstraintError(error);
797
801
  }
798
802
  }
803
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
804
+ _mapConstraintError(error) {
805
+ if (error && typeof error === "object" && "errno" in error) {
806
+ const err = error;
807
+ if (err.errno === 1062 || err.errno === 1586) throw new DbError("CONFLICT", [{
808
+ path: (err.message?.match(/^[\s\S]*for key '(?:[^']*\.)?([^'.]+)'/))?.[1] ?? "",
809
+ message: err.sqlMessage ?? err.message
810
+ }]);
811
+ if (err.errno === 1451 || err.errno === 1452) throw new DbError("FK_VIOLATION", this._mapFkError(err.message));
812
+ }
813
+ throw error;
814
+ }
799
815
  _mapFkError(message) {
800
816
  const fkMatch = message.match(/FOREIGN KEY \(`(\w+)`\)/);
801
817
  if (fkMatch) {
@@ -823,16 +839,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
823
839
  };
824
840
  return this.withTransaction(async () => {
825
841
  const tableName = this.resolveTableName();
826
- const columns = insertManyColumns(data);
827
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
842
+ const { columns, batches } = chunkInsertRows(data);
828
843
  const allIds = [];
829
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
830
- const batch = data.slice(offset, offset + maxRowsPerBatch);
831
- const { sql, params } = buildInsertMany$1(tableName, batch, columns);
832
- this._log(sql, params);
833
- const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
834
- const firstId = Number(result.insertId);
835
- for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
844
+ const step = this._incrementStep();
845
+ const zero = this._zeroIsExplicit();
846
+ for (const batch of batches) {
847
+ const ids = Array.from({ length: batch.length });
848
+ for (const group of await this._idGroups(batch, zero)) {
849
+ const { sql, params } = buildInsertMany$1(tableName, group.rows, columns);
850
+ this._log(sql, params);
851
+ const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
852
+ (await this._groupInsertedIds(group, result.insertId, step)).forEach((id, k) => {
853
+ ids[group.at[k]] = id;
854
+ });
855
+ }
856
+ allIds.push(...ids);
836
857
  }
837
858
  return {
838
859
  insertedCount: allIds.length,
@@ -840,6 +861,230 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
840
861
  };
841
862
  });
842
863
  }
864
+ /** Physical column of the single-column AUTO_INCREMENT primary key, if the table has one. */
865
+ _autoIncrementPk() {
866
+ const pks = this._table.primaryKeys;
867
+ if (pks.length !== 1 || !this._incrementFields.has(pks[0])) return void 0;
868
+ return this._table.getMetadata().physicalPath(pks[0]);
869
+ }
870
+ /**
871
+ * Splits `rows` into runs that each map to ONE statement with a derivable
872
+ * id sequence. MySQL reports only the first GENERATED id of a statement, and
873
+ * an explicit auto-increment value above the counter bumps the counter, so a
874
+ * statement mixing explicit and generated PKs cannot be mapped by
875
+ * `insertId + i * step`: the chunk is cut into CONSECUTIVE runs of one kind,
876
+ * executed in input order (so "an earlier row wins" a unique collision, even
877
+ * under a case-insensitive collation). A table without an AUTO_INCREMENT PK,
878
+ * or a chunk of one kind, stays one group.
879
+ */
880
+ async _idGroups(rows, zeroIsExplicit) {
881
+ const col = this._autoIncrementPk();
882
+ if (!col) return [{
883
+ rows,
884
+ at: rows.map((_, i) => i),
885
+ generated: false
886
+ }];
887
+ const zeroExplicit = rows.some((r) => isZero(r[col])) && await zeroIsExplicit();
888
+ const groups = [];
889
+ rows.forEach((row, i) => {
890
+ const v = row[col];
891
+ const generated = v === void 0 || v === null || isZero(v) && !zeroExplicit;
892
+ let last = groups.at(-1);
893
+ if (!last || last.generated !== generated) {
894
+ last = {
895
+ rows: [],
896
+ at: [],
897
+ generated
898
+ };
899
+ groups.push(last);
900
+ }
901
+ last.rows.push(row);
902
+ last.at.push(i);
903
+ });
904
+ return groups;
905
+ }
906
+ /** The session `sql_mode` text on the current connection (`undefined` when the server returns no row). */
907
+ async _sessionSqlMode() {
908
+ const row = await this._exec().get("SELECT @@SESSION.sql_mode AS mode", []);
909
+ if (!row) return void 0;
910
+ return typeof row.mode === "string" ? row.mode : "";
911
+ }
912
+ /** The {@link TZeroIsExplicit} of one `insertMany` / `insertManyIgnore` call. */
913
+ _zeroIsExplicit() {
914
+ let mode;
915
+ return () => mode ??= this._sessionSqlMode().then((text) => (text ?? "").includes("NO_AUTO_VALUE_ON_ZERO"));
916
+ }
917
+ /**
918
+ * Warns ONCE per driver when the session `sql_mode` is not strict: writes
919
+ * (insert-ignore included) assume `STRICT_TRANS_TABLES` / `STRICT_ALL_TABLES`
920
+ * (the MySQL 8 default); a non-strict mode coerces a NOT NULL violation to the
921
+ * column default instead of failing. Only probes when the adapter has a logger.
922
+ */
923
+ async _warnNonStrictMode() {
924
+ if (this.logger === NoopLogger || nonStrictChecked.has(this.driver)) return;
925
+ nonStrictChecked.add(this.driver);
926
+ try {
927
+ const mode = await this._sessionSqlMode();
928
+ if (mode !== void 0 && !/STRICT_(TRANS|ALL)_TABLES/.test(mode)) this.logger.warn(`MySQL session sql_mode lacks STRICT_TRANS_TABLES / STRICT_ALL_TABLES (${mode}): writes assume a strict mode and a non-strict server silently coerces NOT NULL violations`);
929
+ } catch {
930
+ nonStrictChecked.delete(this.driver);
931
+ }
932
+ }
933
+ /** The {@link TIncrementStep} of one `insertMany` / `insertManyIgnore` call. */
934
+ _incrementStep() {
935
+ let step;
936
+ return () => step ??= (async () => {
937
+ const row = await this._exec().get("SELECT @@auto_increment_increment AS step", []);
938
+ const n = Number(row?.step);
939
+ return Number.isInteger(n) && n > 0 ? n : 1;
940
+ })();
941
+ }
942
+ /**
943
+ * Ids of the rows of ONE successful multi-row INSERT of a homogeneous
944
+ * {@link _idGroups} group: generated rows get ids from `insertId` stepping by
945
+ * the session's `@@auto_increment_increment` (consecutive within one
946
+ * statement under `innodb_autoinc_lock_mode` 0 / 1, and — for a known row
947
+ * count — mode 2, the 8.0 default), explicit rows keep their own value.
948
+ */
949
+ async _groupInsertedIds(group, insertId, step) {
950
+ const first = Number(insertId);
951
+ const stride = group.generated && group.rows.length > 1 && first > 0 ? await step() : 1;
952
+ return group.rows.map((row, i) => {
953
+ const generatedId = first > 0 ? first + i * stride : 0;
954
+ return group.generated ? generatedId : this._resolveInsertedId(row, generatedId);
955
+ });
956
+ }
957
+ supportsInsertIgnore() {
958
+ return true;
959
+ }
960
+ /**
961
+ * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
962
+ * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
963
+ * SELECT of the chunk's primary / unique key tuples find the stored rows
964
+ * (skipped as conflicts) and the survivors go in as one more multi-row
965
+ * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
966
+ * if that INSERT still collides (a concurrent writer raced in, or a
967
+ * collation-equal value the exact-match pre-check missed) are the survivors
968
+ * bisected: each half is retried, recursively, and a single row that still
969
+ * collides is skipped. A failed statement is rolled back by InnoDB alone, so
970
+ * the transaction stays usable. A chunk mixing explicit and generated
971
+ * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
972
+ * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
973
+ * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
974
+ * indistinguishable from an insert in the affected-rows count).
975
+ */
976
+ async insertManyIgnore(data) {
977
+ if (data.length === 0) return [];
978
+ await this._warnNonStrictMode();
979
+ return this.withTransaction(async () => {
980
+ const tableName = this.resolveTableName();
981
+ const { columns, batches } = chunkInsertRows(data);
982
+ const slots = [];
983
+ const step = this._incrementStep();
984
+ const zero = this._zeroIsExplicit();
985
+ for (const batch of batches) {
986
+ const out = Array.from({ length: batch.length }, () => null);
987
+ for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step)).forEach((slot, k) => {
988
+ out[group.at[k]] = slot;
989
+ });
990
+ slots.push(...out);
991
+ }
992
+ return slots;
993
+ });
994
+ }
995
+ /**
996
+ * Indices of `rows` whose primary / unique-index key tuple is already stored
997
+ * (a row with a null / missing key component never collides). One SELECT
998
+ * covers every key set; it is split only to stay under the parameter limit.
999
+ * Skipped entirely when no row carries a key value (generated PK, no unique
1000
+ * index values).
1001
+ */
1002
+ async _findStoredKeyConflicts(tableName, rows) {
1003
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1004
+ const rowTuples = rows.map((row) => keySets.map((fields) => uniqueKeyTuple(row, fields)));
1005
+ const used = keySets.map((_, k) => rowTuples.some((t) => t[k] !== void 0));
1006
+ if (!used.includes(true)) return /* @__PURE__ */ new Set();
1007
+ const width = keySets.reduce((n, f, k) => n + (used[k] ? f.length : 0), 0);
1008
+ const sliceSize = Math.max(1, Math.floor(6e4 / width));
1009
+ const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1010
+ const stored = keySets.map(() => /* @__PURE__ */ new Set());
1011
+ for (let offset = 0; offset < rows.length; offset += sliceSize) {
1012
+ const end = Math.min(rows.length, offset + sliceSize);
1013
+ const clauses = [];
1014
+ const params = [];
1015
+ keySets.forEach((fields, k) => {
1016
+ if (!used[k]) return;
1017
+ const group = [];
1018
+ for (let i = offset; i < end; i++) if (rowTuples[i][k] !== void 0) group.push(rows[i]);
1019
+ if (group.length === 0) return;
1020
+ const values = (row) => fields.map((f) => mysqlDialect.toValue(row[f]));
1021
+ if (fields.length === 1) {
1022
+ clauses.push(`${qi(fields[0])} IN (${group.map(() => "?").join(", ")})`);
1023
+ for (const row of group) params.push(...values(row));
1024
+ } else {
1025
+ const tuple = `(${fields.map(() => "?").join(", ")})`;
1026
+ clauses.push(`(${fields.map((f) => qi(f)).join(", ")}) IN (${group.map(() => tuple).join(", ")})`);
1027
+ for (const row of group) params.push(...values(row));
1028
+ }
1029
+ });
1030
+ if (clauses.length === 0) continue;
1031
+ const sql = `SELECT ${selectCols} FROM ${quoteTableName(tableName)} WHERE ${clauses.join(" OR ")}`;
1032
+ this._log(sql, params);
1033
+ const found = await this._exec().all(sql, params);
1034
+ for (const doc of found) keySets.forEach((fields, k) => {
1035
+ const tuple = uniqueKeyTuple(doc, fields);
1036
+ if (tuple !== void 0) stored[k].add(tuple);
1037
+ });
1038
+ }
1039
+ const skipped = /* @__PURE__ */ new Set();
1040
+ rowTuples.forEach((tuples, i) => {
1041
+ if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
1042
+ });
1043
+ return skipped;
1044
+ }
1045
+ /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
1046
+ async _insertIgnoringGroup(tableName, columns, group, step) {
1047
+ const direct = await this._tryInsertGroup(tableName, columns, group, step);
1048
+ if (direct) return direct;
1049
+ if (group.rows.length === 1) return [null];
1050
+ const skipped = await this._findStoredKeyConflicts(tableName, group.rows);
1051
+ if (skipped.size === 0) return this._bisectGroup(tableName, columns, group, step);
1052
+ const survivors = {
1053
+ rows: group.rows.filter((_, i) => !skipped.has(i)),
1054
+ at: [],
1055
+ generated: group.generated
1056
+ };
1057
+ const inserted = survivors.rows.length > 0 ? await this._tryInsertGroup(tableName, columns, survivors, step) ?? (survivors.rows.length === 1 ? [null] : await this._bisectGroup(tableName, columns, survivors, step)) : [];
1058
+ let next = 0;
1059
+ return group.rows.map((_, i) => skipped.has(i) ? null : inserted[next++]);
1060
+ }
1061
+ /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
1062
+ async _bisectGroup(tableName, columns, group, step) {
1063
+ const mid = group.rows.length >> 1;
1064
+ const out = [];
1065
+ for (const rows of [group.rows.slice(0, mid), group.rows.slice(mid)]) {
1066
+ const half = {
1067
+ rows,
1068
+ at: [],
1069
+ generated: group.generated
1070
+ };
1071
+ out.push(...await this._tryInsertGroup(tableName, columns, half, step) ?? (rows.length === 1 ? [null] : await this._bisectGroup(tableName, columns, half, step)));
1072
+ }
1073
+ return out;
1074
+ }
1075
+ /** ONE INSERT of a group's rows; `undefined` on a duplicate key (errno 1062 / 1586). */
1076
+ async _tryInsertGroup(tableName, columns, group, step) {
1077
+ const { sql, params } = buildInsertMany$1(tableName, group.rows, columns);
1078
+ this._log(sql, params);
1079
+ try {
1080
+ const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
1081
+ return (await this._groupInsertedIds(group, result.insertId, step)).map((insertedId) => ({ insertedId }));
1082
+ } catch (error) {
1083
+ if (!isConflict(error)) throw error;
1084
+ if (group.generated && error.errors.some((e) => e.path === "PRIMARY")) throw error;
1085
+ return;
1086
+ }
1087
+ }
843
1088
  async findOne(query) {
844
1089
  const where = buildWhere(query.filter);
845
1090
  const controls = {
@@ -880,11 +1125,11 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
880
1125
  if (query.controls.$count) {
881
1126
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
882
1127
  this._log(sql, params);
883
- return [{ count: (await this._exec().get(sql, params))?.count ?? 0 }];
1128
+ return [{ count: (await mapQueryErrors(mysqlDialect, () => this._exec().get(sql, params), query.controls))?.count ?? 0 }];
884
1129
  }
885
1130
  const { sql, params } = buildAggregateSelect$1(tableName, where, query.controls);
886
1131
  this._log(sql, params);
887
- return this._exec().all(sql, params);
1132
+ return mapQueryErrors(mysqlDialect, () => this._exec().all(sql, params), query.controls);
888
1133
  }
889
1134
  /**
890
1135
  * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
@@ -1010,6 +1255,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1010
1255
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
1011
1256
  }
1012
1257
  async prepareTypeMapper() {
1258
+ await this._warnNonStrictMode();
1013
1259
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
1014
1260
  }
1015
1261
  async ensureTable(opts) {
@@ -1711,6 +1957,26 @@ function atscriptTypeCast(field, next) {
1711
1957
  }
1712
1958
  return next();
1713
1959
  }
1960
+ /** Appends `STRICT_TRANS_TABLES` to the session `sql_mode` unless a strict mode is already set. */
1961
+ const ENSURE_STRICT_SQL = "SET SESSION sql_mode = IF(FIND_IN_SET('STRICT_TRANS_TABLES', @@SESSION.sql_mode) OR FIND_IN_SET('STRICT_ALL_TABLES', @@SESSION.sql_mode), @@SESSION.sql_mode, CONCAT_WS(',', NULLIF(@@SESSION.sql_mode, ''), 'STRICT_TRANS_TABLES'))";
1962
+ /**
1963
+ * Runs {@link ENSURE_STRICT_SQL} once on every connection of the pool: on
1964
+ * `connection` for the ones the pool opens, and on `acquire` for a connection
1965
+ * opened before the driver was constructed (a pre-used pool). The statement is
1966
+ * queued on the connection before the pool hands it out, so it precedes every
1967
+ * query; a failure leaves the server's mode in place.
1968
+ */
1969
+ function ensureStrict(pool) {
1970
+ const raw = pool;
1971
+ const seen = /* @__PURE__ */ new WeakSet();
1972
+ const strict = (conn) => {
1973
+ if (seen.has(conn)) return;
1974
+ seen.add(conn);
1975
+ conn.query(ENSURE_STRICT_SQL, () => {});
1976
+ };
1977
+ raw.on("connection", strict);
1978
+ raw.on("acquire", strict);
1979
+ }
1714
1980
  /**
1715
1981
  * {@link TMysqlDriver} implementation backed by `mysql2/promise`.
1716
1982
  *
@@ -1738,6 +2004,11 @@ function atscriptTypeCast(field, next) {
1738
2004
  * const driver = new Mysql2Driver(pool)
1739
2005
  * ```
1740
2006
  *
2007
+ * Every new pool connection gets `STRICT_TRANS_TABLES` appended to its session
2008
+ * `sql_mode` (since 0.1.148); pass `{ strictMode: false }` as the second
2009
+ * argument to opt out. A pre-created `Pool` is covered too: connections it
2010
+ * opened earlier get the statement when they are first acquired through it.
2011
+ *
1741
2012
  * Requires `mysql2` to be installed:
1742
2013
  * ```bash
1743
2014
  * pnpm add mysql2
@@ -1746,9 +2017,12 @@ function atscriptTypeCast(field, next) {
1746
2017
  var Mysql2Driver = class {
1747
2018
  pool;
1748
2019
  poolInit;
1749
- constructor(poolOrConfig) {
1750
- if (typeof poolOrConfig === "object" && "execute" in poolOrConfig) this.pool = poolOrConfig;
1751
- else this.poolInit = import("mysql2/promise").then((mysql) => {
2020
+ constructor(poolOrConfig, options = {}) {
2021
+ const strict = options.strictMode !== false;
2022
+ if (typeof poolOrConfig === "object" && "execute" in poolOrConfig) {
2023
+ this.pool = poolOrConfig;
2024
+ if (strict) ensureStrict(this.pool);
2025
+ } else this.poolInit = import("mysql2/promise").then((mysql) => {
1752
2026
  if (typeof poolOrConfig === "string") this.pool = mysql.createPool({
1753
2027
  uri: poolOrConfig,
1754
2028
  timezone: "+00:00",
@@ -1763,6 +2037,7 @@ var Mysql2Driver = class {
1763
2037
  bigNumberStrings: false,
1764
2038
  typeCast: atscriptTypeCast
1765
2039
  });
2040
+ if (strict) ensureStrict(this.pool);
1766
2041
  return this.pool;
1767
2042
  });
1768
2043
  }
@@ -1817,8 +2092,10 @@ var Mysql2Driver = class {
1817
2092
  }
1818
2093
  };
1819
2094
  }
1820
- async close() {
1821
- await (await this.getPool()).end();
2095
+ _closing;
2096
+ /** Idempotent: every call returns the first call's promise. */
2097
+ close() {
2098
+ return this._closing ??= Promise.resolve(this.getPool()).then((pool) => pool.end());
1822
2099
  }
1823
2100
  };
1824
2101
  //#endregion
@@ -1827,15 +2104,17 @@ var Mysql2Driver = class {
1827
2104
  * Creates a {@link DbSpace} backed by a MySQL connection pool.
1828
2105
  *
1829
2106
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
1830
- * @param options - Additional pool options passed to mysql2.
2107
+ * @param options - Additional pool options passed to mysql2. `strictMode: false`
2108
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
1831
2109
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
1832
2110
  */
1833
2111
  function createAdapter(uri, options) {
2112
+ const { strictMode, ...pool } = options ?? {};
1834
2113
  const driver = new Mysql2Driver({
1835
2114
  uri,
1836
- ...options
1837
- });
1838
- return new DbSpace(() => new MysqlAdapter(driver));
2115
+ ...pool
2116
+ }, { strictMode });
2117
+ return new DbSpace(() => new MysqlAdapter(driver), { onClose: () => driver.close() });
1839
2118
  }
1840
2119
  //#endregion
1841
2120
  export { Mysql2Driver, MysqlAdapter, buildWhere, createAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.147",
3
+ "version": "0.1.149",
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.99",
45
- "@atscript/typescript": "^0.1.99",
46
- "@uniqu/core": "^0.1.12",
44
+ "@atscript/core": "^0.1.100",
45
+ "@atscript/typescript": "^0.1.100",
46
+ "@uniqu/core": "^0.1.13",
47
47
  "mysql2": "^3.11.0",
48
- "unplugin-atscript": "^0.1.99"
48
+ "unplugin-atscript": "^0.1.100"
49
49
  },
50
50
  "peerDependencies": {
51
- "@atscript/core": "^0.1.99",
52
- "@atscript/typescript": "^0.1.99",
53
- "@uniqu/core": "^0.1.12",
51
+ "@atscript/core": "^0.1.100",
52
+ "@atscript/typescript": "^0.1.100",
53
+ "@uniqu/core": "^0.1.13",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.147",
56
- "@atscript/db-sql-tools": "^0.1.147"
55
+ "@atscript/db": "^0.1.149",
56
+ "@atscript/db-sql-tools": "^0.1.149"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {