@atscript/db-mysql 0.1.146 → 0.1.148

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 {
@@ -175,6 +179,7 @@ const mysqlDialect = {
175
179
  calendarBucket: mysqlCalendarBucket,
176
180
  bucketAliasInHaving: true,
177
181
  jsonExtract: mysqlJsonExtract,
182
+ castDouble: (expr) => `CAST(${expr} AS DOUBLE)`,
178
183
  createViewPrefix: "CREATE OR REPLACE VIEW"
179
184
  };
180
185
  /** `JSON_TYPE()` names of JSON numbers (MariaDB reports only INTEGER / DOUBLE). */
@@ -210,7 +215,8 @@ function isMysqlTimestampColumn(fd) {
210
215
  const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
211
216
  /**
212
217
  * Calendar-bucket label: TEXT `'YYYY-MM-DD'` — the local date of the
213
- * bucket's first day in `b.tz` — or NULL for a NULL source or one outside
218
+ * bucket's first day in `b.tz` — or, for `hour`, `'YYYY-MM-DDTHH:00'` — the
219
+ * local wall-clock hour — or NULL for a NULL source or one outside
214
220
  * `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
215
221
  *
216
222
  * The source's UTC wall time `U` (a DATETIME) depends on storage:
@@ -222,9 +228,12 @@ const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
222
228
  * return whatever the server's session zone is (`UNIX_TIMESTAMP(col)` would
223
229
  * agree only under a UTC session zone).
224
230
  *
225
- * The local date is `DATE(CONVERT_TZ(U, '+00:00', '<tz>'))`, or `DATE(U)` for
226
- * `UTC` (no time zone tables needed). Truncation is calendar arithmetic on
227
- * that date. `CONVERT_TZ` returns NULL when the zone is unknown to the
231
+ * The local wall time is `CONVERT_TZ(U, '+00:00', '<tz>')`, or `U` for `UTC`
232
+ * (no time zone tables needed) — UTC → local is never ambiguous, so a
233
+ * `+05:30` zone's hour starts at :30 UTC and a DST fall-back's repeated hour
234
+ * is one label. The hour is formatted from it; the other units truncate
235
+ * its `DATE()` with calendar arithmetic. `CONVERT_TZ`
236
+ * returns NULL when the zone is unknown to the
228
237
  * server and its input unchanged outside its range — `MysqlAdapter` probes
229
238
  * each zone before running the query (`BUCKET_TZ_UNAVAILABLE`).
230
239
  *
@@ -241,24 +250,33 @@ function mysqlCalendarBucket(quotedCol, b) {
241
250
  utc = `(${MYSQL_EPOCH} + INTERVAL FLOOR(${quotedCol} / 1000) SECOND)`;
242
251
  inRange = `${quotedCol} >= ${_uniqu_core.BUCKET_MIN_INSTANT} AND ${quotedCol} < ${_uniqu_core.BUCKET_MAX_INSTANT}`;
243
252
  }
244
- const local = b.tz === "UTC" ? `DATE(${utc})` : `DATE(CONVERT_TZ(${utc}, '+00:00', ${(0, _atscript_db_sql_tools.sqlTimeZoneLiteral)(b.tz)}))`;
245
- let first;
253
+ const wall = b.tz === "UTC" ? utc : `CONVERT_TZ(${utc}, '+00:00', ${(0, _atscript_db_sql_tools.sqlTimeZoneLiteral)(b.tz)})`;
254
+ const local = `DATE(${wall})`;
255
+ let value;
256
+ let format = "%Y-%m-%d";
246
257
  switch (b.unit) {
258
+ case "hour":
259
+ value = wall;
260
+ format = "%Y-%m-%dT%H:00";
261
+ break;
247
262
  case "day":
248
- first = local;
263
+ value = local;
249
264
  break;
250
265
  case "week":
251
- first = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
266
+ value = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
252
267
  break;
253
268
  case "month":
254
- first = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
269
+ value = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
255
270
  break;
256
271
  case "quarter":
257
- first = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
272
+ value = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
273
+ break;
274
+ case "year":
275
+ value = `MAKEDATE(YEAR(${local}), 1)`;
258
276
  break;
259
- default: first = `MAKEDATE(YEAR(${local}), 1)`;
277
+ default: throw new Error(`Unhandled calendar bucket unit "${String(b.unit)}"`);
260
278
  }
261
- return `CASE WHEN ${inRange} THEN DATE_FORMAT(${first}, '%Y-%m-%d') END`;
279
+ return `CASE WHEN ${inRange} THEN DATE_FORMAT(${value}, '${format}') END`;
262
280
  }
263
281
  /**
264
282
  * Spherical distance in meters from a POINT column to a query point.
@@ -446,12 +464,7 @@ function buildCreateTable(table, fields, foreignKeys, options) {
446
464
  }
447
465
  if (foreignKeys) for (const fk of foreignKeys.values()) {
448
466
  if (options?.deferForeignKeysTo?.has(fk.targetTable)) continue;
449
- const localCols = fk.fields.map((f) => qi(f)).join(", ");
450
- const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
451
- let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
452
- if (fk.onDelete) constraint += ` ON DELETE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onDelete)}`;
453
- if (fk.onUpdate) constraint += ` ON UPDATE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onUpdate)}`;
454
- constraints.push(constraint);
467
+ constraints.push((0, _atscript_db_sql_tools.foreignKeySql)(qi, fk));
455
468
  }
456
469
  const body = [...colDefs.map((c) => c.def), ...constraints].join(", ");
457
470
  let sql = `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${body})`;
@@ -469,9 +482,15 @@ function buildCreateTable(table, fields, foreignKeys, options) {
469
482
  *
470
483
  * @returns `{ sql, params }` — the WHERE clause (without "WHERE") and bound params.
471
484
  * Returns `{ sql: '1=1', params: [] }` for empty/null filters.
485
+ *
486
+ * Relational predicates (`$some` / `$none`) render as correlated `EXISTS`
487
+ * subqueries referencing the outer table by `opts.qualifier` (default: the
488
+ * source table) — a statement with an aliased FROM must pass its alias.
489
+ *
490
+ * @param opts - since 0.1.147
472
491
  */
473
- function buildWhere(filter) {
474
- return (0, _atscript_db_sql_tools.buildWhere)(mysqlDialect, filter);
492
+ function buildWhere(filter, opts) {
493
+ return (0, _atscript_db_sql_tools.buildWhere)(mysqlDialect, filter, opts);
475
494
  }
476
495
  //#endregion
477
496
  //#region src/mysql-adapter.ts
@@ -503,6 +522,9 @@ function epochMsToUtcDatetime(ms) {
503
522
  * "unchanged" named-zone result is not evidence of a limited range.
504
523
  */
505
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();
506
528
  /**
507
529
  * Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
508
530
  * and share a driver). Positives only: a failed zone is probed again, so
@@ -615,6 +637,17 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
615
637
  if (!tx) conn.release();
616
638
  }
617
639
  }
640
+ /**
641
+ * Relational predicates (`$some` / `$none`) render as correlated
642
+ * `[NOT] EXISTS` subqueries — in reads and in mutation filters alike.
643
+ * An UPDATE / DELETE whose predicate reads the mutated table itself is
644
+ * rewritten through a materialized derived table (MySQL error 1093).
645
+ *
646
+ * @since 0.1.147
647
+ */
648
+ supportsRelationFilters(_mode) {
649
+ return true;
650
+ }
618
651
  /** MySQL InnoDB enforces FK constraints natively. */
619
652
  supportsNativeForeignKeys() {
620
653
  return true;
@@ -635,10 +668,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
635
668
  calendarBucketUnits() {
636
669
  return _atscript_db.ALL_BUCKET_UNITS;
637
670
  }
638
- /** Every aggregate function, `countDistinct` included. */
671
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
639
672
  aggregateFns() {
640
673
  return _atscript_db.ALL_AGGREGATE_FNS;
641
674
  }
675
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
676
+ supportsAggregateExpressions() {
677
+ return true;
678
+ }
679
+ /** Computed view columns and first-row joins. */
680
+ viewCapabilities() {
681
+ return _atscript_db.ALL_VIEW_CAPABILITIES;
682
+ }
642
683
  onBeforeFlatten(_type) {
643
684
  const meta = _type.metadata;
644
685
  const engine = meta.get("db.mysql.engine");
@@ -749,6 +790,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
749
790
  *
750
791
  * MySQL uses numeric error codes:
751
792
  * - 1062 = ER_DUP_ENTRY (unique constraint violation)
793
+ * - 1586 = ER_DUP_ENTRY_WITH_KEY_NAME (same, for a multi-row INSERT)
752
794
  * - 1451 = ER_ROW_IS_REFERENCED_2 (FK violation on delete)
753
795
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
754
796
  */
@@ -756,16 +798,20 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
756
798
  try {
757
799
  return await fn();
758
800
  } catch (error) {
759
- if (error && typeof error === "object" && "errno" in error) {
760
- const err = error;
761
- if (err.errno === 1062) throw new _atscript_db.DbError("CONFLICT", [{
762
- path: (err.message?.match(/for key '(?:\w+\.)?(\w+)'/))?.[1] ?? "",
763
- message: err.sqlMessage ?? err.message
764
- }]);
765
- if (err.errno === 1451 || err.errno === 1452) throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.message));
766
- }
767
- throw error;
801
+ return this._mapConstraintError(error);
802
+ }
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));
768
813
  }
814
+ throw error;
769
815
  }
770
816
  _mapFkError(message) {
771
817
  const fkMatch = message.match(/FOREIGN KEY \(`(\w+)`\)/);
@@ -794,16 +840,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
794
840
  };
795
841
  return this.withTransaction(async () => {
796
842
  const tableName = this.resolveTableName();
797
- const columns = (0, _atscript_db_sql_tools.insertManyColumns)(data);
798
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
843
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
799
844
  const allIds = [];
800
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
801
- const batch = data.slice(offset, offset + maxRowsPerBatch);
802
- const { sql, params } = buildInsertMany(tableName, batch, columns);
803
- this._log(sql, params);
804
- const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
805
- const firstId = Number(result.insertId);
806
- 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);
807
858
  }
808
859
  return {
809
860
  insertedCount: allIds.length,
@@ -811,6 +862,230 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
811
862
  };
812
863
  });
813
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
+ }
814
1089
  async findOne(query) {
815
1090
  const where = buildWhere(query.filter);
816
1091
  const controls = {
@@ -827,6 +1102,16 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
827
1102
  this._log(sql, params);
828
1103
  return this._exec().all(sql, params);
829
1104
  }
1105
+ /**
1106
+ * `$skip` / `$limit` per partition in one statement: a `ROW_NUMBER()`
1107
+ * window over `partitionBy` (the generic `$with` loader's per-parent page).
1108
+ */
1109
+ async findManyPerPartition(query, partitionBy) {
1110
+ const where = buildWhere(query.filter);
1111
+ const { sql, params } = (0, _atscript_db_sql_tools.buildPartitionedSelect)(mysqlDialect, this.resolveTableName(), where, query.controls, partitionBy);
1112
+ this._log(sql, params);
1113
+ return (0, _atscript_db_sql_tools.stripPartitionRowNumber)(await this._exec().all(sql, params));
1114
+ }
830
1115
  async count(query) {
831
1116
  const where = buildWhere(query.filter);
832
1117
  const sql = `SELECT COUNT(*) as cnt FROM ${quoteTableName(this.resolveTableName())} WHERE ${where.sql}`;
@@ -841,11 +1126,11 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
841
1126
  if (query.controls.$count) {
842
1127
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
843
1128
  this._log(sql, params);
844
- 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 }];
845
1130
  }
846
1131
  const { sql, params } = buildAggregateSelect(tableName, where, query.controls);
847
1132
  this._log(sql, params);
848
- return this._exec().all(sql, params);
1133
+ return (0, _atscript_db_sql_tools.mapQueryErrors)(mysqlDialect, () => this._exec().all(sql, params), query.controls);
849
1134
  }
850
1135
  /**
851
1136
  * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
@@ -871,8 +1156,50 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
871
1156
  }
872
1157
  zones.add(tz);
873
1158
  }
874
- async updateOne(filter, data, ops, expectedVersion) {
1159
+ /**
1160
+ * The WHERE of an UPDATE / DELETE on this table. MySQL rejects a statement
1161
+ * whose WHERE reads the mutated table in a subquery (error 1093,
1162
+ * `ER_UPDATE_TABLE_USED`) — what a relational predicate (`$some` / `$none`)
1163
+ * does when its target or junction table, at any nesting level, is this
1164
+ * table (a self relation such as `parent`). Such a filter is re-keyed on the
1165
+ * primary key through a materialized derived table:
1166
+ *
1167
+ * ```sql
1168
+ * WHERE (<pk…>) IN (SELECT * FROM (SELECT DISTINCT <pk…> FROM t WHERE <where>) AS `_rfm`)
1169
+ * ```
1170
+ *
1171
+ * `DISTINCT` keeps the optimizer from merging the derived table back into
1172
+ * the statement. Every other filter renders as-is.
1173
+ *
1174
+ * @since 0.1.147
1175
+ */
1176
+ _mutationWhere(filter) {
875
1177
  const where = buildWhere(filter);
1178
+ if (!this._filterReadsOwnTable(filter)) return where;
1179
+ const tableName = this.resolveTableName();
1180
+ const keys = this._table.primaryKeys.map((key) => this._table.physicalPath(key));
1181
+ if (keys.length === 0) throw new _atscript_db.DbError("REL_FILTER_NOT_SUPPORTED", [{
1182
+ path: "",
1183
+ message: "MySQL cannot update or delete by a relational predicate that reads the table itself when the table has no primary key"
1184
+ }]);
1185
+ const cols = keys.map((key) => qi(key)).join(", ");
1186
+ return {
1187
+ sql: `${keys.length === 1 ? cols : `(${cols})`} IN (SELECT * FROM (SELECT DISTINCT ${cols} FROM ${quoteTableName(tableName)} WHERE ${where.sql}) AS ${qi("_rfm")})`,
1188
+ params: where.params
1189
+ };
1190
+ }
1191
+ /** `true` when a relational predicate of `filter` (nested ones included) reads this table. */
1192
+ _filterReadsOwnTable(filter) {
1193
+ if (!(0, _atscript_db.containsRelationFilter)(filter)) return false;
1194
+ const own = this.resolveTableName();
1195
+ let hit = false;
1196
+ (0, _atscript_db.forEachResolvedRelation)(filter, (node) => {
1197
+ if (node.target.table === own || node.junction?.table === own) hit = true;
1198
+ });
1199
+ return hit;
1200
+ }
1201
+ async updateOne(filter, data, ops, expectedVersion) {
1202
+ const where = this._mutationWhere(filter);
876
1203
  const versionColumn = this._table.versionColumnPhysical;
877
1204
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, 1, ops, versionColumn, expectedVersion);
878
1205
  this._log(sql, params);
@@ -883,7 +1210,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
883
1210
  };
884
1211
  }
885
1212
  async updateMany(filter, data, ops) {
886
- const where = buildWhere(filter);
1213
+ const where = this._mutationWhere(filter);
887
1214
  const versionColumn = this._table.versionColumnPhysical;
888
1215
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, void 0, ops, versionColumn);
889
1216
  this._log(sql, params);
@@ -894,7 +1221,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
894
1221
  };
895
1222
  }
896
1223
  async replaceOne(filter, data, expectedVersion) {
897
- const where = buildWhere(filter);
1224
+ const where = this._mutationWhere(filter);
898
1225
  const versionColumn = this._table.versionColumnPhysical;
899
1226
  const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, (0, _atscript_db_sql_tools.replaceColumnsFor)(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn);
900
1227
  const { sql, params } = buildUpdate(this.resolveTableName(), full, where, 1, void 0, versionColumn, expectedVersion);
@@ -906,7 +1233,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
906
1233
  };
907
1234
  }
908
1235
  async replaceMany(filter, data) {
909
- const where = buildWhere(filter);
1236
+ const where = this._mutationWhere(filter);
910
1237
  const versionColumn = this._table.versionColumnPhysical;
911
1238
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, void 0, void 0, versionColumn);
912
1239
  this._log(sql, params);
@@ -917,18 +1244,19 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
917
1244
  };
918
1245
  }
919
1246
  async deleteOne(filter) {
920
- const where = buildWhere(filter);
1247
+ const where = this._mutationWhere(filter);
921
1248
  const { sql, params } = buildDelete(this.resolveTableName(), where, 1);
922
1249
  this._log(sql, params);
923
1250
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
924
1251
  }
925
1252
  async deleteMany(filter) {
926
- const where = buildWhere(filter);
1253
+ const where = this._mutationWhere(filter);
927
1254
  const { sql, params } = buildDelete(this.resolveTableName(), where);
928
1255
  this._log(sql, params);
929
1256
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
930
1257
  }
931
1258
  async prepareTypeMapper() {
1259
+ await this._warnNonStrictMode();
932
1260
  if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
933
1261
  }
934
1262
  async ensureTable(opts) {
@@ -1295,7 +1623,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1295
1623
  async syncForeignKeys() {
1296
1624
  const existingByName = await this._getExistingFkConstraints();
1297
1625
  const desiredFkKeys = /* @__PURE__ */ new Set();
1298
- for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...fk.fields].toSorted().join(","));
1626
+ for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...(0, _atscript_db.fkColumns)(fk).fields].toSorted().join(","));
1299
1627
  for (const [constraintName, columns] of existingByName) {
1300
1628
  const key = columns.toSorted().join(",");
1301
1629
  if (!desiredFkKeys.has(key)) {
@@ -1306,13 +1634,9 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1306
1634
  }
1307
1635
  const existingKeys = new Set([...existingByName.values()].map((cols) => cols.toSorted().join(",")));
1308
1636
  for (const fk of this._table.foreignKeys.values()) {
1309
- const key = [...fk.fields].toSorted().join(",");
1637
+ const key = [...(0, _atscript_db.fkColumns)(fk).fields].toSorted().join(",");
1310
1638
  if (!existingKeys.has(key)) {
1311
- const localCols = fk.fields.map((f) => qi(f)).join(", ");
1312
- const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
1313
- let ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
1314
- if (fk.onDelete) ddl += ` ON DELETE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onDelete)}`;
1315
- if (fk.onUpdate) ddl += ` ON UPDATE ${(0, _atscript_db_sql_tools.refActionToSql)(fk.onUpdate)}`;
1639
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD ${(0, _atscript_db_sql_tools.foreignKeySql)(qi, fk)}`;
1316
1640
  this._log(ddl);
1317
1641
  await this._exec().exec(ddl);
1318
1642
  }
@@ -1472,7 +1796,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1472
1796
  vec = first.value[1];
1473
1797
  }
1474
1798
  const distanceFn = similarityToMysqlFn(vec.similarity);
1475
- const where = buildWhere(query.filter);
1799
+ const where = buildWhere(query.filter, { qualifier: mysqlDialect.quoteTable(_atscript_db_sql_tools.SEARCH_SOURCE_ALIAS) });
1476
1800
  const controls = query.controls || {};
1477
1801
  const threshold = this._resolveVectorThreshold(controls, vec.indexName);
1478
1802
  return {
@@ -1550,7 +1874,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1550
1874
  const controls = query.controls ?? {};
1551
1875
  return {
1552
1876
  tableName: this.resolveTableName(),
1553
- where: buildWhere(query.filter),
1877
+ where: buildWhere(query.filter, { qualifier: mysqlDialect.quoteTable(_atscript_db_sql_tools.SEARCH_SOURCE_ALIAS) }),
1554
1878
  dist: mysqlGeoDistanceExpr(qi(column), point),
1555
1879
  window: (0, _atscript_db_sql_tools.geoWindowFromControls)(controls),
1556
1880
  controls
@@ -1634,6 +1958,26 @@ function atscriptTypeCast(field, next) {
1634
1958
  }
1635
1959
  return next();
1636
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
+ }
1637
1981
  /**
1638
1982
  * {@link TMysqlDriver} implementation backed by `mysql2/promise`.
1639
1983
  *
@@ -1661,6 +2005,11 @@ function atscriptTypeCast(field, next) {
1661
2005
  * const driver = new Mysql2Driver(pool)
1662
2006
  * ```
1663
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
+ *
1664
2013
  * Requires `mysql2` to be installed:
1665
2014
  * ```bash
1666
2015
  * pnpm add mysql2
@@ -1669,9 +2018,12 @@ function atscriptTypeCast(field, next) {
1669
2018
  var Mysql2Driver = class {
1670
2019
  pool;
1671
2020
  poolInit;
1672
- constructor(poolOrConfig) {
1673
- if (typeof poolOrConfig === "object" && "execute" in poolOrConfig) this.pool = poolOrConfig;
1674
- 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) => {
1675
2027
  if (typeof poolOrConfig === "string") this.pool = mysql.createPool({
1676
2028
  uri: poolOrConfig,
1677
2029
  timezone: "+00:00",
@@ -1686,6 +2038,7 @@ var Mysql2Driver = class {
1686
2038
  bigNumberStrings: false,
1687
2039
  typeCast: atscriptTypeCast
1688
2040
  });
2041
+ if (strict) ensureStrict(this.pool);
1689
2042
  return this.pool;
1690
2043
  });
1691
2044
  }
@@ -1740,8 +2093,10 @@ var Mysql2Driver = class {
1740
2093
  }
1741
2094
  };
1742
2095
  }
1743
- async close() {
1744
- 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());
1745
2100
  }
1746
2101
  };
1747
2102
  //#endregion
@@ -1750,15 +2105,17 @@ var Mysql2Driver = class {
1750
2105
  * Creates a {@link DbSpace} backed by a MySQL connection pool.
1751
2106
  *
1752
2107
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
1753
- * @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}).
1754
2110
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
1755
2111
  */
1756
2112
  function createAdapter(uri, options) {
2113
+ const { strictMode, ...pool } = options ?? {};
1757
2114
  const driver = new Mysql2Driver({
1758
2115
  uri,
1759
- ...options
1760
- });
1761
- return new _atscript_db.DbSpace(() => new MysqlAdapter(driver));
2116
+ ...pool
2117
+ }, { strictMode });
2118
+ return new _atscript_db.DbSpace(() => new MysqlAdapter(driver), { onClose: () => driver.close() });
1762
2119
  }
1763
2120
  //#endregion
1764
2121
  exports.Mysql2Driver = Mysql2Driver;