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