@atscript/db-mysql 0.1.151 → 0.1.153

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
@@ -153,6 +153,9 @@ const mysqlDialect = {
153
153
  return quoteTableName(name);
154
154
  },
155
155
  unlimitedLimit: "18446744073709551615",
156
+ derivedMergeHint(alias) {
157
+ return `/*+ MERGE(${qi(alias)}) */`;
158
+ },
156
159
  toValue: _atscript_db_sql_tools.toSqlValue,
157
160
  toParam(value) {
158
161
  if (value === void 0) return null;
@@ -618,9 +621,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
618
621
  get _schema() {
619
622
  return this._table?.schema ?? null;
620
623
  }
621
- constructor(driver) {
624
+ constructor(driver, options) {
622
625
  super();
623
626
  this.driver = driver;
627
+ this.viewJoinPruning = options?.viewJoinPruning !== false;
628
+ }
629
+ /**
630
+ * The FROM source of a read of this adapter's table or view: a managed
631
+ * view read that needs only some of its LEFT joins reads an inline,
632
+ * pruned definition (`viewReadSource`, since 0.1.153) — MySQL has no
633
+ * outer-join elimination, so every LEFT JOIN of a view is otherwise probed
634
+ * per row, even for a `COUNT(*)`. Off with `viewJoinPruning: false`.
635
+ */
636
+ _readSource(query, kind, partitionBy) {
637
+ const name = this.resolveTableName();
638
+ return this.viewJoinPruning ? (0, _atscript_db_sql_tools.viewReadSource)(mysqlDialect, this._table, name, query, kind, partitionBy) : name;
624
639
  }
625
640
  /** Every adapter over this pool shares one transaction (since 0.1.128). */
626
641
  _transactionOwner() {
@@ -722,6 +737,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
722
737
  supportsAggregateExpressions() {
723
738
  return true;
724
739
  }
740
+ /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
741
+ supportsNullsPlacement() {
742
+ return true;
743
+ }
725
744
  /** Computed view columns and first-row joins. */
726
745
  viewCapabilities() {
727
746
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -850,7 +869,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
850
869
  return this._mapConstraintError(error);
851
870
  }
852
871
  }
853
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
872
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock wait timeout, else as is. */
854
873
  _mapConstraintError(error) {
855
874
  if (error && typeof error === "object" && "errno" in error) {
856
875
  const err = error;
@@ -859,6 +878,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
859
878
  message: err.sqlMessage ?? err.message
860
879
  }]);
861
880
  if (err.errno === 1451 || err.errno === 1452) throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.message));
881
+ if (err.errno === 1213 || err.errno === 1205) throw new _atscript_db.DbError(err.errno === 1213 ? "DEADLOCK" : "LOCK_TIMEOUT", [{
882
+ path: "",
883
+ message: err.sqlMessage ?? err.message
884
+ }]);
862
885
  }
863
886
  throw error;
864
887
  }
@@ -1059,27 +1082,49 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1059
1082
  return true;
1060
1083
  }
1061
1084
  /**
1062
- * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
1063
- * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
1064
- * SELECT of the chunk's primary / unique key tuples find the stored rows
1065
- * (skipped as conflicts) and the survivors go in as one more multi-row
1066
- * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
1067
- * if that INSERT still collides (a concurrent writer raced in, or a
1068
- * collation-equal value the exact-match pre-check missed) are the survivors
1069
- * bisected: each half is retried, recursively, and a single row that still
1070
- * collides is skipped. A failed statement is rolled back by InnoDB alone, so
1071
- * the transaction stays usable. A chunk mixing explicit and generated
1072
- * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
1073
- * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
1074
- * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
1075
- * indistinguishable from an insert in the affected-rows count).
1085
+ * Standalone (no caller transaction): per chunk ONE optimistic multi-row
1086
+ * INSERT (an all-new batch costs a single statement). Only when it hits a
1087
+ * duplicate key (errno 1062 / 1586) does ONE SELECT of the chunk's primary /
1088
+ * unique key tuples find the stored rows (skipped as conflicts) and the
1089
+ * survivors go in as one more multi-row INSERT — a dense-duplicate chunk is
1090
+ * three statements, never O(rows).
1091
+ *
1092
+ * Inside a CALLER's transaction (`opts.inCallerTransaction`, else
1093
+ * `isInTransaction()` on entry) the key SELECT runs FIRST and only the
1094
+ * missing rows are inserted, in primary-key order: a failed duplicate INSERT
1095
+ * leaves InnoDB's shared lock on the duplicate record until COMMIT, and a
1096
+ * later UPDATE of that row in two such transactions would deadlock (S→X).
1097
+ * The SELECT is a non-locking consistent read, so the common case takes no
1098
+ * lock on a stored row at all. `opts.lockConflicts` then locks the stored
1099
+ * conflicting rows exclusively — `SELECT … WHERE pk IN (found) ORDER BY pk
1100
+ * FOR UPDATE` (by the unique key a row collided on when that was not the
1101
+ * primary key), existing keys only, so record locks and no gap locks —
1102
+ * before inserting the rest.
1103
+ *
1104
+ * Either way, only if an INSERT still collides (a concurrent writer raced in
1105
+ * — committed after the transaction's snapshot or still uncommitted — or a
1106
+ * collation-equal value the exact-match pre-check missed) are the rows
1107
+ * bisected in input order: each half is retried, recursively, and a single
1108
+ * row that still collides is skipped (holding the failed INSERT's shared
1109
+ * lock). A failed statement is rolled back by InnoDB alone, so the
1110
+ * transaction stays usable. A chunk mixing explicit and generated
1111
+ * auto-increment ids is processed as one such sequence per consecutive run
1112
+ * of a kind. Deliberately NOT `INSERT IGNORE` (it would downgrade NOT NULL /
1113
+ * FK / truncation errors to warnings) and not `ON DUPLICATE KEY UPDATE` (a
1114
+ * no-op update is indistinguishable from an insert in the affected-rows
1115
+ * count).
1076
1116
  */
1077
- async insertManyIgnore(data) {
1117
+ async insertManyIgnore(data, opts) {
1078
1118
  if (data.length === 0) return [];
1079
1119
  await this._warnNonStrictMode();
1080
1120
  const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
1081
- const single = data.length === 1 && !this._txConnection() ? this._uniformIdGroup(data) : void 0;
1082
- if (single) return this._insertIgnoringGroup(this.resolveTableName(), columns, single, this._incrementStep());
1121
+ const inTx = this._txConnection() !== void 0;
1122
+ const mode = {
1123
+ precheck: opts?.inCallerTransaction ?? inTx,
1124
+ lock: opts?.lockConflicts === true
1125
+ };
1126
+ const single = data.length === 1 && !inTx ? this._uniformIdGroup(data) : void 0;
1127
+ if (single) return this._insertIgnoringGroup(this.resolveTableName(), columns, single, this._incrementStep(), mode);
1083
1128
  return this.withTransaction(async () => {
1084
1129
  const tableName = this.resolveTableName();
1085
1130
  const slots = [];
@@ -1087,7 +1132,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1087
1132
  const zero = this._zeroIsExplicit();
1088
1133
  for (const batch of batches) {
1089
1134
  const out = Array.from({ length: batch.length }, () => null);
1090
- for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step)).forEach((slot, k) => {
1135
+ for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step, mode)).forEach((slot, k) => {
1091
1136
  out[group.at[k]] = slot;
1092
1137
  });
1093
1138
  slots.push(...out);
@@ -1097,20 +1142,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1097
1142
  }
1098
1143
  /**
1099
1144
  * Indices of `rows` whose primary / unique-index key tuple is already stored
1100
- * (a row with a null / missing key component never collides). One SELECT
1101
- * covers every key set; it is split only to stay under the parameter limit.
1102
- * Skipped entirely when no row carries a key value (generated PK, no unique
1103
- * index values).
1145
+ * (a row with a null / missing key component never collides), each mapped
1146
+ * to the index (in `uniqueKeySets` order — the primary key first) of the
1147
+ * first key set it collides on. One non-locking SELECT covers every key set;
1148
+ * it is split only to stay under the parameter limit. Skipped entirely when
1149
+ * no row carries a key value (generated PK, no unique index values).
1104
1150
  */
1105
1151
  async _findStoredKeyConflicts(tableName, rows) {
1106
1152
  const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1107
1153
  const rowTuples = rows.map((row) => keySets.map((fields) => (0, _atscript_db.uniqueKeyTuple)(row, fields)));
1108
1154
  const used = keySets.map((_, k) => rowTuples.some((t) => t[k] !== void 0));
1109
- if (!used.includes(true)) return /* @__PURE__ */ new Set();
1155
+ if (!used.includes(true)) return /* @__PURE__ */ new Map();
1110
1156
  const width = keySets.reduce((n, f, k) => n + (used[k] ? f.length : 0), 0);
1111
1157
  const sliceSize = Math.max(1, Math.floor(6e4 / width));
1112
1158
  const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1113
- const stored = keySets.map(() => /* @__PURE__ */ new Set());
1159
+ const storedTuples = keySets.map(() => /* @__PURE__ */ new Set());
1114
1160
  for (let offset = 0; offset < rows.length; offset += sliceSize) {
1115
1161
  const end = Math.min(rows.length, offset + sliceSize);
1116
1162
  const clauses = [];
@@ -1136,30 +1182,103 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1136
1182
  const found = await this._exec().all(sql, params);
1137
1183
  for (const doc of found) keySets.forEach((fields, k) => {
1138
1184
  const tuple = (0, _atscript_db.uniqueKeyTuple)(doc, fields);
1139
- if (tuple !== void 0) stored[k].add(tuple);
1185
+ if (tuple !== void 0) storedTuples[k].add(tuple);
1140
1186
  });
1141
1187
  }
1142
- const skipped = /* @__PURE__ */ new Set();
1188
+ const skipped = /* @__PURE__ */ new Map();
1143
1189
  rowTuples.forEach((tuples, i) => {
1144
- if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
1190
+ const k = tuples.findIndex((t, k) => t !== void 0 && storedTuples[k].has(t));
1191
+ if (k >= 0) skipped.set(i, k);
1145
1192
  });
1146
1193
  return skipped;
1147
1194
  }
1148
- /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
1149
- async _insertIgnoringGroup(tableName, columns, group, step) {
1150
- const direct = await this._tryInsertGroup(tableName, columns, group, step);
1151
- if (direct) return direct;
1152
- if (group.rows.length === 1) return [null];
1195
+ /**
1196
+ * Exclusive locks on the stored rows the `skipped` input rows collide with
1197
+ * (found by {@link _findStoredKeyConflicts}, so every key exists): `SELECT …
1198
+ * FOR UPDATE` by the key set each row collided on — the primary key when it
1199
+ * did — ascending, one statement per key set. The keys are the INPUT rows'
1200
+ * own (write-form) values, which the pre-check matched exactly; a full
1201
+ * unique-key equality on existing records takes record locks only, never a
1202
+ * gap lock.
1203
+ */
1204
+ async _lockStoredRows(tableName, rows, skipped) {
1205
+ if (skipped.size === 0) return;
1206
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1207
+ const byKeySet = /* @__PURE__ */ new Map();
1208
+ for (const [i, k] of skipped) {
1209
+ let keyed = byKeySet.get(k);
1210
+ if (!keyed) byKeySet.set(k, keyed = /* @__PURE__ */ new Map());
1211
+ keyed.set((0, _atscript_db.uniqueKeyTuple)(rows[i], keySets[k]), rows[i]);
1212
+ }
1213
+ for (const [k, keyed] of byKeySet) {
1214
+ const fields = keySets[k];
1215
+ const keyRows = [...keyed.values()];
1216
+ const sliceSize = Math.max(1, Math.floor(6e4 / fields.length));
1217
+ const cols = fields.map((f) => qi(f)).join(", ");
1218
+ const tuple = fields.length === 1 ? "?" : `(${fields.map(() => "?").join(", ")})`;
1219
+ for (let offset = 0; offset < keyRows.length; offset += sliceSize) {
1220
+ const slice = keyRows.slice(offset, offset + sliceSize);
1221
+ const params = [];
1222
+ for (const row of slice) params.push(...fields.map((f) => mysqlDialect.toValue(row[f])));
1223
+ const target = fields.length === 1 ? cols : `(${cols})`;
1224
+ const sql = `SELECT ${cols} FROM ${quoteTableName(tableName)} WHERE ${target} IN (${slice.map(() => tuple).join(", ")}) ORDER BY ${cols} FOR UPDATE`;
1225
+ this._log(sql, params);
1226
+ await this._wrapConstraintError(() => this._exec().all(sql, params));
1227
+ }
1228
+ }
1229
+ }
1230
+ /** Input positions of `rows` in ascending primary-key order (input order without a complete key). */
1231
+ _pkOrder(rows, at) {
1232
+ const pkCols = this._table.primaryKeys.map((k) => this._table.physicalPath(k));
1233
+ if (pkCols.length === 0 || at.length < 2) return at;
1234
+ if (at.some((i) => pkCols.some((c) => rows[i][c] === void 0 || rows[i][c] === null))) return at;
1235
+ return at.toSorted((a, b) => {
1236
+ for (const c of pkCols) {
1237
+ const x = rows[a][c];
1238
+ const y = rows[b][c];
1239
+ if (x === y) continue;
1240
+ return x < y ? -1 : 1;
1241
+ }
1242
+ return a - b;
1243
+ });
1244
+ }
1245
+ /**
1246
+ * One {@link _idGroups} group. Standalone: optimistic INSERT, then pre-check
1247
+ * + survivor INSERT (+ bisect on a race). `mode.precheck`: pre-check (and
1248
+ * `mode.lock`: lock the stored conflicts) FIRST, then insert the survivors in
1249
+ * primary-key order.
1250
+ */
1251
+ async _insertIgnoringGroup(tableName, columns, group, step, mode) {
1252
+ if (!mode.precheck && !mode.lock) {
1253
+ const direct = await this._tryInsertGroup(tableName, columns, group, step);
1254
+ if (direct) return direct;
1255
+ if (group.rows.length === 1) return [null];
1256
+ }
1153
1257
  const skipped = await this._findStoredKeyConflicts(tableName, group.rows);
1154
- if (skipped.size === 0) return this._bisectGroup(tableName, columns, group, step);
1155
- const survivors = {
1156
- rows: group.rows.filter((_, i) => !skipped.has(i)),
1157
- at: [],
1158
- generated: group.generated
1159
- };
1160
- 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)) : [];
1161
- let next = 0;
1162
- return group.rows.map((_, i) => skipped.has(i) ? null : inserted[next++]);
1258
+ if (mode.lock) await this._lockStoredRows(tableName, group.rows, skipped);
1259
+ if (skipped.size === 0 && !mode.precheck && !mode.lock) return this._bisectGroup(tableName, columns, group, step);
1260
+ const keep = group.rows.map((_, i) => i).filter((i) => !skipped.has(i));
1261
+ const ordered = mode.precheck && !group.generated ? this._pkOrder(group.rows, keep) : keep;
1262
+ const inserted = /* @__PURE__ */ new Map();
1263
+ if (ordered.length > 0) {
1264
+ const sorted = {
1265
+ rows: ordered.map((i) => group.rows[i]),
1266
+ at: [],
1267
+ generated: group.generated
1268
+ };
1269
+ let slots = await this._tryInsertGroup(tableName, columns, sorted, step);
1270
+ if (slots) slots.forEach((slot, k) => inserted.set(ordered[k], slot));
1271
+ else {
1272
+ const survivors = {
1273
+ rows: keep.map((i) => group.rows[i]),
1274
+ at: [],
1275
+ generated: group.generated
1276
+ };
1277
+ slots = keep.length === 1 ? [null] : await this._bisectGroup(tableName, columns, survivors, step);
1278
+ slots.forEach((slot, k) => inserted.set(keep[k], slot));
1279
+ }
1280
+ }
1281
+ return group.rows.map((_, i) => skipped.has(i) ? null : inserted.get(i));
1163
1282
  }
1164
1283
  /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
1165
1284
  async _bisectGroup(tableName, columns, group, step) {
@@ -1194,13 +1313,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1194
1313
  ...query.controls,
1195
1314
  $limit: 1
1196
1315
  };
1197
- const { sql, params } = buildSelect(this.resolveTableName(), where, controls);
1316
+ const { sql, params } = buildSelect(this._readSource(query, "rows"), where, controls);
1198
1317
  this._log(sql, params);
1199
1318
  return this._exec().get(sql, params);
1200
1319
  }
1201
1320
  async findMany(query) {
1202
1321
  const where = buildWhere(query.filter);
1203
- const { sql, params } = buildSelect(this.resolveTableName(), where, query.controls);
1322
+ const { sql, params } = buildSelect(this._readSource(query, "rows"), where, query.controls);
1204
1323
  this._log(sql, params);
1205
1324
  return this._exec().all(sql, params);
1206
1325
  }
@@ -1210,20 +1329,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1210
1329
  */
1211
1330
  async findManyPerPartition(query, partitionBy) {
1212
1331
  const where = buildWhere(query.filter);
1213
- const { sql, params } = (0, _atscript_db_sql_tools.buildPartitionedSelect)(mysqlDialect, this.resolveTableName(), where, query.controls, partitionBy);
1332
+ const { sql, params } = (0, _atscript_db_sql_tools.buildPartitionedSelect)(mysqlDialect, this._readSource(query, "rows", partitionBy), where, query.controls, partitionBy);
1214
1333
  this._log(sql, params);
1215
1334
  return (0, _atscript_db_sql_tools.stripPartitionRowNumber)(await this._exec().all(sql, params));
1216
1335
  }
1217
1336
  async count(query) {
1218
1337
  const where = buildWhere(query.filter);
1219
- const sql = `SELECT COUNT(*) as cnt FROM ${quoteTableName(this.resolveTableName())} WHERE ${where.sql}`;
1338
+ const source = this._readSource(query, "count");
1339
+ const sql = `SELECT ${(0, _atscript_db_sql_tools.fromSourceHint)(source)}COUNT(*) as cnt FROM ${(0, _atscript_db_sql_tools.fromSourceSql)(mysqlDialect, source)} WHERE ${where.sql}`;
1220
1340
  this._log(sql, where.params);
1221
1341
  return (await this._exec().get(sql, where.params))?.cnt ?? 0;
1222
1342
  }
1223
1343
  async aggregate(query) {
1224
1344
  const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
1225
1345
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1226
- const tableName = this.resolveTableName();
1346
+ const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1227
1347
  for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1228
1348
  if (query.controls.$count) {
1229
1349
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
@@ -2269,16 +2389,19 @@ var Mysql2Driver = class {
2269
2389
  *
2270
2390
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
2271
2391
  * @param options - Additional pool options passed to mysql2. `strictMode: false`
2272
- * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
2392
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver});
2393
+ * `viewJoinPruning: false` (not a pool option, since 0.1.153) reads managed views by name
2394
+ * ({@link TMysqlAdapterOptions}).
2273
2395
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
2274
2396
  */
2275
2397
  function createAdapter(uri, options) {
2276
- const { strictMode, ...pool } = options ?? {};
2398
+ const { strictMode, viewJoinPruning, ...pool } = options ?? {};
2277
2399
  const driver = new Mysql2Driver({
2278
2400
  uri,
2279
2401
  ...pool
2280
2402
  }, { strictMode });
2281
- return new _atscript_db.DbSpace(() => new MysqlAdapter(driver), { onClose: () => driver.close() });
2403
+ const adapterOptions = { viewJoinPruning };
2404
+ return new _atscript_db.DbSpace(() => new MysqlAdapter(driver, adapterOptions), { onClose: () => driver.close() });
2282
2405
  }
2283
2406
  //#endregion
2284
2407
  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, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, 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, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, 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";
@@ -73,6 +73,18 @@ interface TMysqlConnection {
73
73
  /** Release this connection back to the pool. */
74
74
  release(): void;
75
75
  }
76
+ /**
77
+ * `MysqlAdapter` options.
78
+ * @since 0.1.153
79
+ */
80
+ interface TMysqlAdapterOptions {
81
+ /**
82
+ * Read a managed view through an inline definition without the LEFT joins
83
+ * the query does not need (default `true`) — see the views guide,
84
+ * "Performance". `false` always reads the stored view by name.
85
+ */
86
+ viewJoinPruning?: boolean;
87
+ }
76
88
  //#endregion
77
89
  //#region src/mysql-adapter.d.ts
78
90
  /**
@@ -117,7 +129,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
117
129
  * (the name-taking schema-sync primitives run on such an adapter).
118
130
  */
119
131
  private get _schema();
120
- constructor(driver: TMysqlDriver);
132
+ constructor(driver: TMysqlDriver, options?: TMysqlAdapterOptions);
133
+ /**
134
+ * The FROM source of a read of this adapter's table or view: a managed
135
+ * view read that needs only some of its LEFT joins reads an inline,
136
+ * pruned definition (`viewReadSource`, since 0.1.153) — MySQL has no
137
+ * outer-join elimination, so every LEFT JOIN of a view is otherwise probed
138
+ * per row, even for a `COUNT(*)`. Off with `viewJoinPruning: false`.
139
+ */
140
+ private _readSource;
121
141
  /** Every adapter over this pool shares one transaction (since 0.1.128). */
122
142
  protected _transactionOwner(): unknown;
123
143
  /** The dedicated connection of this pool's open transaction, if any. */
@@ -162,6 +182,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
162
182
  aggregateFns(): ReadonlySet<AggregateFn>;
163
183
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
164
184
  supportsAggregateExpressions(): boolean;
185
+ /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
186
+ supportsNullsPlacement(): boolean;
165
187
  /** Computed view columns and first-row joins. */
166
188
  viewCapabilities(): ReadonlySet<TViewCapability>;
167
189
  onBeforeFlatten(_type: unknown): void;
@@ -188,7 +210,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
188
210
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
189
211
  */
190
212
  private _wrapConstraintError;
191
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
213
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock wait timeout, else as is. */
192
214
  private _mapConstraintError;
193
215
  private _mapFkError;
194
216
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
@@ -246,31 +268,66 @@ declare class MysqlAdapter extends BaseDbAdapter {
246
268
  private _groupInsertedIds;
247
269
  supportsInsertIgnore(): boolean;
248
270
  /**
249
- * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
250
- * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
251
- * SELECT of the chunk's primary / unique key tuples find the stored rows
252
- * (skipped as conflicts) and the survivors go in as one more multi-row
253
- * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
254
- * if that INSERT still collides (a concurrent writer raced in, or a
255
- * collation-equal value the exact-match pre-check missed) are the survivors
256
- * bisected: each half is retried, recursively, and a single row that still
257
- * collides is skipped. A failed statement is rolled back by InnoDB alone, so
258
- * the transaction stays usable. A chunk mixing explicit and generated
259
- * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
260
- * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
261
- * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
262
- * indistinguishable from an insert in the affected-rows count).
263
- */
264
- insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
271
+ * Standalone (no caller transaction): per chunk ONE optimistic multi-row
272
+ * INSERT (an all-new batch costs a single statement). Only when it hits a
273
+ * duplicate key (errno 1062 / 1586) does ONE SELECT of the chunk's primary /
274
+ * unique key tuples find the stored rows (skipped as conflicts) and the
275
+ * survivors go in as one more multi-row INSERT — a dense-duplicate chunk is
276
+ * three statements, never O(rows).
277
+ *
278
+ * Inside a CALLER's transaction (`opts.inCallerTransaction`, else
279
+ * `isInTransaction()` on entry) the key SELECT runs FIRST and only the
280
+ * missing rows are inserted, in primary-key order: a failed duplicate INSERT
281
+ * leaves InnoDB's shared lock on the duplicate record until COMMIT, and a
282
+ * later UPDATE of that row in two such transactions would deadlock (S→X).
283
+ * The SELECT is a non-locking consistent read, so the common case takes no
284
+ * lock on a stored row at all. `opts.lockConflicts` then locks the stored
285
+ * conflicting rows exclusively — `SELECT … WHERE pk IN (found) ORDER BY pk
286
+ * FOR UPDATE` (by the unique key a row collided on when that was not the
287
+ * primary key), existing keys only, so record locks and no gap locks —
288
+ * before inserting the rest.
289
+ *
290
+ * Either way, only if an INSERT still collides (a concurrent writer raced in
291
+ * — committed after the transaction's snapshot or still uncommitted — or a
292
+ * collation-equal value the exact-match pre-check missed) are the rows
293
+ * bisected in input order: each half is retried, recursively, and a single
294
+ * row that still collides is skipped (holding the failed INSERT's shared
295
+ * lock). A failed statement is rolled back by InnoDB alone, so the
296
+ * transaction stays usable. A chunk mixing explicit and generated
297
+ * auto-increment ids is processed as one such sequence per consecutive run
298
+ * of a kind. Deliberately NOT `INSERT IGNORE` (it would downgrade NOT NULL /
299
+ * FK / truncation errors to warnings) and not `ON DUPLICATE KEY UPDATE` (a
300
+ * no-op update is indistinguishable from an insert in the affected-rows
301
+ * count).
302
+ */
303
+ insertManyIgnore(data: Array<Record<string, unknown>>, opts?: TDbInsertIgnoreOptions): Promise<TDbInsertIgnoreSlot[]>;
265
304
  /**
266
305
  * Indices of `rows` whose primary / unique-index key tuple is already stored
267
- * (a row with a null / missing key component never collides). One SELECT
268
- * covers every key set; it is split only to stay under the parameter limit.
269
- * Skipped entirely when no row carries a key value (generated PK, no unique
270
- * index values).
306
+ * (a row with a null / missing key component never collides), each mapped
307
+ * to the index (in `uniqueKeySets` order — the primary key first) of the
308
+ * first key set it collides on. One non-locking SELECT covers every key set;
309
+ * it is split only to stay under the parameter limit. Skipped entirely when
310
+ * no row carries a key value (generated PK, no unique index values).
271
311
  */
272
312
  private _findStoredKeyConflicts;
273
- /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
313
+ /**
314
+ * Exclusive locks on the stored rows the `skipped` input rows collide with
315
+ * (found by {@link _findStoredKeyConflicts}, so every key exists): `SELECT …
316
+ * FOR UPDATE` by the key set each row collided on — the primary key when it
317
+ * did — ascending, one statement per key set. The keys are the INPUT rows'
318
+ * own (write-form) values, which the pre-check matched exactly; a full
319
+ * unique-key equality on existing records takes record locks only, never a
320
+ * gap lock.
321
+ */
322
+ private _lockStoredRows;
323
+ /** Input positions of `rows` in ascending primary-key order (input order without a complete key). */
324
+ private _pkOrder;
325
+ /**
326
+ * One {@link _idGroups} group. Standalone: optimistic INSERT, then pre-check
327
+ * + survivor INSERT (+ bisect on a race). `mode.precheck`: pre-check (and
328
+ * `mode.lock`: lock the stored conflicts) FIRST, then insert the survivors in
329
+ * primary-key order.
330
+ */
274
331
  private _insertIgnoringGroup;
275
332
  /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
276
333
  private _bisectGroup;
@@ -503,9 +560,11 @@ declare function buildWhere(filter: FilterExpr$1, opts?: TFilterVisitorOptions):
503
560
  *
504
561
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
505
562
  * @param options - Additional pool options passed to mysql2. `strictMode: false`
506
- * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
563
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver});
564
+ * `viewJoinPruning: false` (not a pool option, since 0.1.153) reads managed views by name
565
+ * ({@link TMysqlAdapterOptions}).
507
566
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
508
567
  */
509
568
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
510
569
  //#endregion
511
- export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
570
+ export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlAdapterOptions, 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, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, 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, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, 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";
@@ -73,6 +73,18 @@ interface TMysqlConnection {
73
73
  /** Release this connection back to the pool. */
74
74
  release(): void;
75
75
  }
76
+ /**
77
+ * `MysqlAdapter` options.
78
+ * @since 0.1.153
79
+ */
80
+ interface TMysqlAdapterOptions {
81
+ /**
82
+ * Read a managed view through an inline definition without the LEFT joins
83
+ * the query does not need (default `true`) — see the views guide,
84
+ * "Performance". `false` always reads the stored view by name.
85
+ */
86
+ viewJoinPruning?: boolean;
87
+ }
76
88
  //#endregion
77
89
  //#region src/mysql-adapter.d.ts
78
90
  /**
@@ -117,7 +129,15 @@ declare class MysqlAdapter extends BaseDbAdapter {
117
129
  * (the name-taking schema-sync primitives run on such an adapter).
118
130
  */
119
131
  private get _schema();
120
- constructor(driver: TMysqlDriver);
132
+ constructor(driver: TMysqlDriver, options?: TMysqlAdapterOptions);
133
+ /**
134
+ * The FROM source of a read of this adapter's table or view: a managed
135
+ * view read that needs only some of its LEFT joins reads an inline,
136
+ * pruned definition (`viewReadSource`, since 0.1.153) — MySQL has no
137
+ * outer-join elimination, so every LEFT JOIN of a view is otherwise probed
138
+ * per row, even for a `COUNT(*)`. Off with `viewJoinPruning: false`.
139
+ */
140
+ private _readSource;
121
141
  /** Every adapter over this pool shares one transaction (since 0.1.128). */
122
142
  protected _transactionOwner(): unknown;
123
143
  /** The dedicated connection of this pool's open transaction, if any. */
@@ -162,6 +182,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
162
182
  aggregateFns(): ReadonlySet<AggregateFn>;
163
183
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
164
184
  supportsAggregateExpressions(): boolean;
185
+ /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
186
+ supportsNullsPlacement(): boolean;
165
187
  /** Computed view columns and first-row joins. */
166
188
  viewCapabilities(): ReadonlySet<TViewCapability>;
167
189
  onBeforeFlatten(_type: unknown): void;
@@ -188,7 +210,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
188
210
  * - 1452 = ER_NO_REFERENCED_ROW_2 (FK violation on insert/update)
189
211
  */
190
212
  private _wrapConstraintError;
191
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
213
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock wait timeout, else as is. */
192
214
  private _mapConstraintError;
193
215
  private _mapFkError;
194
216
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
@@ -246,31 +268,66 @@ declare class MysqlAdapter extends BaseDbAdapter {
246
268
  private _groupInsertedIds;
247
269
  supportsInsertIgnore(): boolean;
248
270
  /**
249
- * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
250
- * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
251
- * SELECT of the chunk's primary / unique key tuples find the stored rows
252
- * (skipped as conflicts) and the survivors go in as one more multi-row
253
- * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
254
- * if that INSERT still collides (a concurrent writer raced in, or a
255
- * collation-equal value the exact-match pre-check missed) are the survivors
256
- * bisected: each half is retried, recursively, and a single row that still
257
- * collides is skipped. A failed statement is rolled back by InnoDB alone, so
258
- * the transaction stays usable. A chunk mixing explicit and generated
259
- * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
260
- * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
261
- * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
262
- * indistinguishable from an insert in the affected-rows count).
263
- */
264
- insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
271
+ * Standalone (no caller transaction): per chunk ONE optimistic multi-row
272
+ * INSERT (an all-new batch costs a single statement). Only when it hits a
273
+ * duplicate key (errno 1062 / 1586) does ONE SELECT of the chunk's primary /
274
+ * unique key tuples find the stored rows (skipped as conflicts) and the
275
+ * survivors go in as one more multi-row INSERT — a dense-duplicate chunk is
276
+ * three statements, never O(rows).
277
+ *
278
+ * Inside a CALLER's transaction (`opts.inCallerTransaction`, else
279
+ * `isInTransaction()` on entry) the key SELECT runs FIRST and only the
280
+ * missing rows are inserted, in primary-key order: a failed duplicate INSERT
281
+ * leaves InnoDB's shared lock on the duplicate record until COMMIT, and a
282
+ * later UPDATE of that row in two such transactions would deadlock (S→X).
283
+ * The SELECT is a non-locking consistent read, so the common case takes no
284
+ * lock on a stored row at all. `opts.lockConflicts` then locks the stored
285
+ * conflicting rows exclusively — `SELECT … WHERE pk IN (found) ORDER BY pk
286
+ * FOR UPDATE` (by the unique key a row collided on when that was not the
287
+ * primary key), existing keys only, so record locks and no gap locks —
288
+ * before inserting the rest.
289
+ *
290
+ * Either way, only if an INSERT still collides (a concurrent writer raced in
291
+ * — committed after the transaction's snapshot or still uncommitted — or a
292
+ * collation-equal value the exact-match pre-check missed) are the rows
293
+ * bisected in input order: each half is retried, recursively, and a single
294
+ * row that still collides is skipped (holding the failed INSERT's shared
295
+ * lock). A failed statement is rolled back by InnoDB alone, so the
296
+ * transaction stays usable. A chunk mixing explicit and generated
297
+ * auto-increment ids is processed as one such sequence per consecutive run
298
+ * of a kind. Deliberately NOT `INSERT IGNORE` (it would downgrade NOT NULL /
299
+ * FK / truncation errors to warnings) and not `ON DUPLICATE KEY UPDATE` (a
300
+ * no-op update is indistinguishable from an insert in the affected-rows
301
+ * count).
302
+ */
303
+ insertManyIgnore(data: Array<Record<string, unknown>>, opts?: TDbInsertIgnoreOptions): Promise<TDbInsertIgnoreSlot[]>;
265
304
  /**
266
305
  * Indices of `rows` whose primary / unique-index key tuple is already stored
267
- * (a row with a null / missing key component never collides). One SELECT
268
- * covers every key set; it is split only to stay under the parameter limit.
269
- * Skipped entirely when no row carries a key value (generated PK, no unique
270
- * index values).
306
+ * (a row with a null / missing key component never collides), each mapped
307
+ * to the index (in `uniqueKeySets` order — the primary key first) of the
308
+ * first key set it collides on. One non-locking SELECT covers every key set;
309
+ * it is split only to stay under the parameter limit. Skipped entirely when
310
+ * no row carries a key value (generated PK, no unique index values).
271
311
  */
272
312
  private _findStoredKeyConflicts;
273
- /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
313
+ /**
314
+ * Exclusive locks on the stored rows the `skipped` input rows collide with
315
+ * (found by {@link _findStoredKeyConflicts}, so every key exists): `SELECT …
316
+ * FOR UPDATE` by the key set each row collided on — the primary key when it
317
+ * did — ascending, one statement per key set. The keys are the INPUT rows'
318
+ * own (write-form) values, which the pre-check matched exactly; a full
319
+ * unique-key equality on existing records takes record locks only, never a
320
+ * gap lock.
321
+ */
322
+ private _lockStoredRows;
323
+ /** Input positions of `rows` in ascending primary-key order (input order without a complete key). */
324
+ private _pkOrder;
325
+ /**
326
+ * One {@link _idGroups} group. Standalone: optimistic INSERT, then pre-check
327
+ * + survivor INSERT (+ bisect on a race). `mode.precheck`: pre-check (and
328
+ * `mode.lock`: lock the stored conflicts) FIRST, then insert the survivors in
329
+ * primary-key order.
330
+ */
274
331
  private _insertIgnoringGroup;
275
332
  /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
276
333
  private _bisectGroup;
@@ -503,9 +560,11 @@ declare function buildWhere(filter: FilterExpr$1, opts?: TFilterVisitorOptions):
503
560
  *
504
561
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
505
562
  * @param options - Additional pool options passed to mysql2. `strictMode: false`
506
- * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
563
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver});
564
+ * `viewJoinPruning: false` (not a pool option, since 0.1.153) reads managed views by name
565
+ * ({@link TMysqlAdapterOptions}).
507
566
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
508
567
  */
509
568
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
510
569
  //#endregion
511
- export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
570
+ export { Mysql2Driver, MysqlAdapter, type TMysql2DriverOptions, type TMysqlAdapterOptions, type TMysqlConnection, type TMysqlDriver, type TMysqlRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, NoopLogger, bucketTimeZoneUnavailable, containsRelationFilter, defaultFulltextIndex, describeFulltext, fkColumns, forEachResolvedRelation, isConflict, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { EMPTY_OR, 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, orFragment, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource } from "@atscript/db-sql-tools";
3
+ import { EMPTY_OR, 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, fromSourceHint, fromSourceSql, geoWindowFromControls, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource } 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`). */
@@ -152,6 +152,9 @@ const mysqlDialect = {
152
152
  return quoteTableName(name);
153
153
  },
154
154
  unlimitedLimit: "18446744073709551615",
155
+ derivedMergeHint(alias) {
156
+ return `/*+ MERGE(${qi(alias)}) */`;
157
+ },
155
158
  toValue: toSqlValue,
156
159
  toParam(value) {
157
160
  if (value === void 0) return null;
@@ -617,9 +620,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
617
620
  get _schema() {
618
621
  return this._table?.schema ?? null;
619
622
  }
620
- constructor(driver) {
623
+ constructor(driver, options) {
621
624
  super();
622
625
  this.driver = driver;
626
+ this.viewJoinPruning = options?.viewJoinPruning !== false;
627
+ }
628
+ /**
629
+ * The FROM source of a read of this adapter's table or view: a managed
630
+ * view read that needs only some of its LEFT joins reads an inline,
631
+ * pruned definition (`viewReadSource`, since 0.1.153) — MySQL has no
632
+ * outer-join elimination, so every LEFT JOIN of a view is otherwise probed
633
+ * per row, even for a `COUNT(*)`. Off with `viewJoinPruning: false`.
634
+ */
635
+ _readSource(query, kind, partitionBy) {
636
+ const name = this.resolveTableName();
637
+ return this.viewJoinPruning ? viewReadSource(mysqlDialect, this._table, name, query, kind, partitionBy) : name;
623
638
  }
624
639
  /** Every adapter over this pool shares one transaction (since 0.1.128). */
625
640
  _transactionOwner() {
@@ -721,6 +736,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
721
736
  supportsAggregateExpressions() {
722
737
  return true;
723
738
  }
739
+ /** `$nulls` / `@db.sort.nulls` NULL placement — a leading `(col IS NULL)` key where the native order differs. */
740
+ supportsNullsPlacement() {
741
+ return true;
742
+ }
724
743
  /** Computed view columns and first-row joins. */
725
744
  viewCapabilities() {
726
745
  return ALL_VIEW_CAPABILITIES;
@@ -849,7 +868,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
849
868
  return this._mapConstraintError(error);
850
869
  }
851
870
  }
852
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
871
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock wait timeout, else as is. */
853
872
  _mapConstraintError(error) {
854
873
  if (error && typeof error === "object" && "errno" in error) {
855
874
  const err = error;
@@ -858,6 +877,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
858
877
  message: err.sqlMessage ?? err.message
859
878
  }]);
860
879
  if (err.errno === 1451 || err.errno === 1452) throw new DbError("FK_VIOLATION", this._mapFkError(err.message));
880
+ if (err.errno === 1213 || err.errno === 1205) throw new DbError(err.errno === 1213 ? "DEADLOCK" : "LOCK_TIMEOUT", [{
881
+ path: "",
882
+ message: err.sqlMessage ?? err.message
883
+ }]);
861
884
  }
862
885
  throw error;
863
886
  }
@@ -1058,27 +1081,49 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1058
1081
  return true;
1059
1082
  }
1060
1083
  /**
1061
- * Per chunk: ONE optimistic multi-row INSERT (an all-new batch costs a single
1062
- * statement). Only when it hits a duplicate key (errno 1062 / 1586) does ONE
1063
- * SELECT of the chunk's primary / unique key tuples find the stored rows
1064
- * (skipped as conflicts) and the survivors go in as one more multi-row
1065
- * INSERT — a dense-duplicate chunk is three statements, never O(rows). Only
1066
- * if that INSERT still collides (a concurrent writer raced in, or a
1067
- * collation-equal value the exact-match pre-check missed) are the survivors
1068
- * bisected: each half is retried, recursively, and a single row that still
1069
- * collides is skipped. A failed statement is rolled back by InnoDB alone, so
1070
- * the transaction stays usable. A chunk mixing explicit and generated
1071
- * auto-increment ids is processed as one such sequence per consecutive run of a kind. Deliberately
1072
- * NOT `INSERT IGNORE` (it would downgrade NOT NULL / FK / truncation errors
1073
- * to warnings) and not `ON DUPLICATE KEY UPDATE` (a no-op update is
1074
- * indistinguishable from an insert in the affected-rows count).
1084
+ * Standalone (no caller transaction): per chunk ONE optimistic multi-row
1085
+ * INSERT (an all-new batch costs a single statement). Only when it hits a
1086
+ * duplicate key (errno 1062 / 1586) does ONE SELECT of the chunk's primary /
1087
+ * unique key tuples find the stored rows (skipped as conflicts) and the
1088
+ * survivors go in as one more multi-row INSERT — a dense-duplicate chunk is
1089
+ * three statements, never O(rows).
1090
+ *
1091
+ * Inside a CALLER's transaction (`opts.inCallerTransaction`, else
1092
+ * `isInTransaction()` on entry) the key SELECT runs FIRST and only the
1093
+ * missing rows are inserted, in primary-key order: a failed duplicate INSERT
1094
+ * leaves InnoDB's shared lock on the duplicate record until COMMIT, and a
1095
+ * later UPDATE of that row in two such transactions would deadlock (S→X).
1096
+ * The SELECT is a non-locking consistent read, so the common case takes no
1097
+ * lock on a stored row at all. `opts.lockConflicts` then locks the stored
1098
+ * conflicting rows exclusively — `SELECT … WHERE pk IN (found) ORDER BY pk
1099
+ * FOR UPDATE` (by the unique key a row collided on when that was not the
1100
+ * primary key), existing keys only, so record locks and no gap locks —
1101
+ * before inserting the rest.
1102
+ *
1103
+ * Either way, only if an INSERT still collides (a concurrent writer raced in
1104
+ * — committed after the transaction's snapshot or still uncommitted — or a
1105
+ * collation-equal value the exact-match pre-check missed) are the rows
1106
+ * bisected in input order: each half is retried, recursively, and a single
1107
+ * row that still collides is skipped (holding the failed INSERT's shared
1108
+ * lock). A failed statement is rolled back by InnoDB alone, so the
1109
+ * transaction stays usable. A chunk mixing explicit and generated
1110
+ * auto-increment ids is processed as one such sequence per consecutive run
1111
+ * of a kind. Deliberately NOT `INSERT IGNORE` (it would downgrade NOT NULL /
1112
+ * FK / truncation errors to warnings) and not `ON DUPLICATE KEY UPDATE` (a
1113
+ * no-op update is indistinguishable from an insert in the affected-rows
1114
+ * count).
1075
1115
  */
1076
- async insertManyIgnore(data) {
1116
+ async insertManyIgnore(data, opts) {
1077
1117
  if (data.length === 0) return [];
1078
1118
  await this._warnNonStrictMode();
1079
1119
  const { columns, batches } = chunkInsertRows(data);
1080
- const single = data.length === 1 && !this._txConnection() ? this._uniformIdGroup(data) : void 0;
1081
- if (single) return this._insertIgnoringGroup(this.resolveTableName(), columns, single, this._incrementStep());
1120
+ const inTx = this._txConnection() !== void 0;
1121
+ const mode = {
1122
+ precheck: opts?.inCallerTransaction ?? inTx,
1123
+ lock: opts?.lockConflicts === true
1124
+ };
1125
+ const single = data.length === 1 && !inTx ? this._uniformIdGroup(data) : void 0;
1126
+ if (single) return this._insertIgnoringGroup(this.resolveTableName(), columns, single, this._incrementStep(), mode);
1082
1127
  return this.withTransaction(async () => {
1083
1128
  const tableName = this.resolveTableName();
1084
1129
  const slots = [];
@@ -1086,7 +1131,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1086
1131
  const zero = this._zeroIsExplicit();
1087
1132
  for (const batch of batches) {
1088
1133
  const out = Array.from({ length: batch.length }, () => null);
1089
- for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step)).forEach((slot, k) => {
1134
+ for (const group of await this._idGroups(batch, zero)) (await this._insertIgnoringGroup(tableName, columns, group, step, mode)).forEach((slot, k) => {
1090
1135
  out[group.at[k]] = slot;
1091
1136
  });
1092
1137
  slots.push(...out);
@@ -1096,20 +1141,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1096
1141
  }
1097
1142
  /**
1098
1143
  * Indices of `rows` whose primary / unique-index key tuple is already stored
1099
- * (a row with a null / missing key component never collides). One SELECT
1100
- * covers every key set; it is split only to stay under the parameter limit.
1101
- * Skipped entirely when no row carries a key value (generated PK, no unique
1102
- * index values).
1144
+ * (a row with a null / missing key component never collides), each mapped
1145
+ * to the index (in `uniqueKeySets` order — the primary key first) of the
1146
+ * first key set it collides on. One non-locking SELECT covers every key set;
1147
+ * it is split only to stay under the parameter limit. Skipped entirely when
1148
+ * no row carries a key value (generated PK, no unique index values).
1103
1149
  */
1104
1150
  async _findStoredKeyConflicts(tableName, rows) {
1105
1151
  const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1106
1152
  const rowTuples = rows.map((row) => keySets.map((fields) => uniqueKeyTuple(row, fields)));
1107
1153
  const used = keySets.map((_, k) => rowTuples.some((t) => t[k] !== void 0));
1108
- if (!used.includes(true)) return /* @__PURE__ */ new Set();
1154
+ if (!used.includes(true)) return /* @__PURE__ */ new Map();
1109
1155
  const width = keySets.reduce((n, f, k) => n + (used[k] ? f.length : 0), 0);
1110
1156
  const sliceSize = Math.max(1, Math.floor(6e4 / width));
1111
1157
  const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1112
- const stored = keySets.map(() => /* @__PURE__ */ new Set());
1158
+ const storedTuples = keySets.map(() => /* @__PURE__ */ new Set());
1113
1159
  for (let offset = 0; offset < rows.length; offset += sliceSize) {
1114
1160
  const end = Math.min(rows.length, offset + sliceSize);
1115
1161
  const clauses = [];
@@ -1135,30 +1181,103 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1135
1181
  const found = await this._exec().all(sql, params);
1136
1182
  for (const doc of found) keySets.forEach((fields, k) => {
1137
1183
  const tuple = uniqueKeyTuple(doc, fields);
1138
- if (tuple !== void 0) stored[k].add(tuple);
1184
+ if (tuple !== void 0) storedTuples[k].add(tuple);
1139
1185
  });
1140
1186
  }
1141
- const skipped = /* @__PURE__ */ new Set();
1187
+ const skipped = /* @__PURE__ */ new Map();
1142
1188
  rowTuples.forEach((tuples, i) => {
1143
- if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
1189
+ const k = tuples.findIndex((t, k) => t !== void 0 && storedTuples[k].has(t));
1190
+ if (k >= 0) skipped.set(i, k);
1144
1191
  });
1145
1192
  return skipped;
1146
1193
  }
1147
- /** Optimistic INSERT of a group, then pre-check + survivor INSERT (+ bisect on a race). */
1148
- async _insertIgnoringGroup(tableName, columns, group, step) {
1149
- const direct = await this._tryInsertGroup(tableName, columns, group, step);
1150
- if (direct) return direct;
1151
- if (group.rows.length === 1) return [null];
1194
+ /**
1195
+ * Exclusive locks on the stored rows the `skipped` input rows collide with
1196
+ * (found by {@link _findStoredKeyConflicts}, so every key exists): `SELECT …
1197
+ * FOR UPDATE` by the key set each row collided on — the primary key when it
1198
+ * did — ascending, one statement per key set. The keys are the INPUT rows'
1199
+ * own (write-form) values, which the pre-check matched exactly; a full
1200
+ * unique-key equality on existing records takes record locks only, never a
1201
+ * gap lock.
1202
+ */
1203
+ async _lockStoredRows(tableName, rows, skipped) {
1204
+ if (skipped.size === 0) return;
1205
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1206
+ const byKeySet = /* @__PURE__ */ new Map();
1207
+ for (const [i, k] of skipped) {
1208
+ let keyed = byKeySet.get(k);
1209
+ if (!keyed) byKeySet.set(k, keyed = /* @__PURE__ */ new Map());
1210
+ keyed.set(uniqueKeyTuple(rows[i], keySets[k]), rows[i]);
1211
+ }
1212
+ for (const [k, keyed] of byKeySet) {
1213
+ const fields = keySets[k];
1214
+ const keyRows = [...keyed.values()];
1215
+ const sliceSize = Math.max(1, Math.floor(6e4 / fields.length));
1216
+ const cols = fields.map((f) => qi(f)).join(", ");
1217
+ const tuple = fields.length === 1 ? "?" : `(${fields.map(() => "?").join(", ")})`;
1218
+ for (let offset = 0; offset < keyRows.length; offset += sliceSize) {
1219
+ const slice = keyRows.slice(offset, offset + sliceSize);
1220
+ const params = [];
1221
+ for (const row of slice) params.push(...fields.map((f) => mysqlDialect.toValue(row[f])));
1222
+ const target = fields.length === 1 ? cols : `(${cols})`;
1223
+ const sql = `SELECT ${cols} FROM ${quoteTableName(tableName)} WHERE ${target} IN (${slice.map(() => tuple).join(", ")}) ORDER BY ${cols} FOR UPDATE`;
1224
+ this._log(sql, params);
1225
+ await this._wrapConstraintError(() => this._exec().all(sql, params));
1226
+ }
1227
+ }
1228
+ }
1229
+ /** Input positions of `rows` in ascending primary-key order (input order without a complete key). */
1230
+ _pkOrder(rows, at) {
1231
+ const pkCols = this._table.primaryKeys.map((k) => this._table.physicalPath(k));
1232
+ if (pkCols.length === 0 || at.length < 2) return at;
1233
+ if (at.some((i) => pkCols.some((c) => rows[i][c] === void 0 || rows[i][c] === null))) return at;
1234
+ return at.toSorted((a, b) => {
1235
+ for (const c of pkCols) {
1236
+ const x = rows[a][c];
1237
+ const y = rows[b][c];
1238
+ if (x === y) continue;
1239
+ return x < y ? -1 : 1;
1240
+ }
1241
+ return a - b;
1242
+ });
1243
+ }
1244
+ /**
1245
+ * One {@link _idGroups} group. Standalone: optimistic INSERT, then pre-check
1246
+ * + survivor INSERT (+ bisect on a race). `mode.precheck`: pre-check (and
1247
+ * `mode.lock`: lock the stored conflicts) FIRST, then insert the survivors in
1248
+ * primary-key order.
1249
+ */
1250
+ async _insertIgnoringGroup(tableName, columns, group, step, mode) {
1251
+ if (!mode.precheck && !mode.lock) {
1252
+ const direct = await this._tryInsertGroup(tableName, columns, group, step);
1253
+ if (direct) return direct;
1254
+ if (group.rows.length === 1) return [null];
1255
+ }
1152
1256
  const skipped = await this._findStoredKeyConflicts(tableName, group.rows);
1153
- if (skipped.size === 0) return this._bisectGroup(tableName, columns, group, step);
1154
- const survivors = {
1155
- rows: group.rows.filter((_, i) => !skipped.has(i)),
1156
- at: [],
1157
- generated: group.generated
1158
- };
1159
- 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)) : [];
1160
- let next = 0;
1161
- return group.rows.map((_, i) => skipped.has(i) ? null : inserted[next++]);
1257
+ if (mode.lock) await this._lockStoredRows(tableName, group.rows, skipped);
1258
+ if (skipped.size === 0 && !mode.precheck && !mode.lock) return this._bisectGroup(tableName, columns, group, step);
1259
+ const keep = group.rows.map((_, i) => i).filter((i) => !skipped.has(i));
1260
+ const ordered = mode.precheck && !group.generated ? this._pkOrder(group.rows, keep) : keep;
1261
+ const inserted = /* @__PURE__ */ new Map();
1262
+ if (ordered.length > 0) {
1263
+ const sorted = {
1264
+ rows: ordered.map((i) => group.rows[i]),
1265
+ at: [],
1266
+ generated: group.generated
1267
+ };
1268
+ let slots = await this._tryInsertGroup(tableName, columns, sorted, step);
1269
+ if (slots) slots.forEach((slot, k) => inserted.set(ordered[k], slot));
1270
+ else {
1271
+ const survivors = {
1272
+ rows: keep.map((i) => group.rows[i]),
1273
+ at: [],
1274
+ generated: group.generated
1275
+ };
1276
+ slots = keep.length === 1 ? [null] : await this._bisectGroup(tableName, columns, survivors, step);
1277
+ slots.forEach((slot, k) => inserted.set(keep[k], slot));
1278
+ }
1279
+ }
1280
+ return group.rows.map((_, i) => skipped.has(i) ? null : inserted.get(i));
1162
1281
  }
1163
1282
  /** Retries the halves of a group whose one INSERT is known to collide; a lone colliding row is skipped. */
1164
1283
  async _bisectGroup(tableName, columns, group, step) {
@@ -1193,13 +1312,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1193
1312
  ...query.controls,
1194
1313
  $limit: 1
1195
1314
  };
1196
- const { sql, params } = buildSelect$1(this.resolveTableName(), where, controls);
1315
+ const { sql, params } = buildSelect$1(this._readSource(query, "rows"), where, controls);
1197
1316
  this._log(sql, params);
1198
1317
  return this._exec().get(sql, params);
1199
1318
  }
1200
1319
  async findMany(query) {
1201
1320
  const where = buildWhere(query.filter);
1202
- const { sql, params } = buildSelect$1(this.resolveTableName(), where, query.controls);
1321
+ const { sql, params } = buildSelect$1(this._readSource(query, "rows"), where, query.controls);
1203
1322
  this._log(sql, params);
1204
1323
  return this._exec().all(sql, params);
1205
1324
  }
@@ -1209,20 +1328,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1209
1328
  */
1210
1329
  async findManyPerPartition(query, partitionBy) {
1211
1330
  const where = buildWhere(query.filter);
1212
- const { sql, params } = buildPartitionedSelect(mysqlDialect, this.resolveTableName(), where, query.controls, partitionBy);
1331
+ const { sql, params } = buildPartitionedSelect(mysqlDialect, this._readSource(query, "rows", partitionBy), where, query.controls, partitionBy);
1213
1332
  this._log(sql, params);
1214
1333
  return stripPartitionRowNumber(await this._exec().all(sql, params));
1215
1334
  }
1216
1335
  async count(query) {
1217
1336
  const where = buildWhere(query.filter);
1218
- const sql = `SELECT COUNT(*) as cnt FROM ${quoteTableName(this.resolveTableName())} WHERE ${where.sql}`;
1337
+ const source = this._readSource(query, "count");
1338
+ const sql = `SELECT ${fromSourceHint(source)}COUNT(*) as cnt FROM ${fromSourceSql(mysqlDialect, source)} WHERE ${where.sql}`;
1219
1339
  this._log(sql, where.params);
1220
1340
  return (await this._exec().get(sql, where.params))?.cnt ?? 0;
1221
1341
  }
1222
1342
  async aggregate(query) {
1223
1343
  const search = resolveAggregateSearch(query.controls);
1224
1344
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
1225
- const tableName = this.resolveTableName();
1345
+ const tableName = search ? this.resolveTableName() : this._readSource(query, "aggregate");
1226
1346
  for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
1227
1347
  if (query.controls.$count) {
1228
1348
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
@@ -2268,16 +2388,19 @@ var Mysql2Driver = class {
2268
2388
  *
2269
2389
  * @param uri - MySQL connection URI (e.g., `mysql://root@localhost:3306/mydb`)
2270
2390
  * @param options - Additional pool options passed to mysql2. `strictMode: false`
2271
- * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver}).
2391
+ * (not a pool option) opts out of the per-session `STRICT_TRANS_TABLES` ({@link Mysql2Driver});
2392
+ * `viewJoinPruning: false` (not a pool option, since 0.1.153) reads managed views by name
2393
+ * ({@link TMysqlAdapterOptions}).
2272
2394
  * @returns A `DbSpace` that creates `MysqlAdapter` instances per table.
2273
2395
  */
2274
2396
  function createAdapter(uri, options) {
2275
- const { strictMode, ...pool } = options ?? {};
2397
+ const { strictMode, viewJoinPruning, ...pool } = options ?? {};
2276
2398
  const driver = new Mysql2Driver({
2277
2399
  uri,
2278
2400
  ...pool
2279
2401
  }, { strictMode });
2280
- return new DbSpace(() => new MysqlAdapter(driver), { onClose: () => driver.close() });
2402
+ const adapterOptions = { viewJoinPruning };
2403
+ return new DbSpace(() => new MysqlAdapter(driver, adapterOptions), { onClose: () => driver.close() });
2281
2404
  }
2282
2405
  //#endregion
2283
2406
  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.151",
3
+ "version": "0.1.153",
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.102",
45
- "@atscript/typescript": "^0.1.102",
46
- "@uniqu/core": "^0.1.14",
44
+ "@atscript/core": "^0.1.103",
45
+ "@atscript/typescript": "^0.1.103",
46
+ "@uniqu/core": "^0.1.15",
47
47
  "mysql2": "^3.11.0",
48
- "unplugin-atscript": "^0.1.102"
48
+ "unplugin-atscript": "^0.1.103"
49
49
  },
50
50
  "peerDependencies": {
51
- "@atscript/core": "^0.1.102",
52
- "@atscript/typescript": "^0.1.102",
53
- "@uniqu/core": "^0.1.14",
51
+ "@atscript/core": "^0.1.103",
52
+ "@atscript/typescript": "^0.1.103",
53
+ "@uniqu/core": "^0.1.15",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.151",
56
- "@atscript/db-sql-tools": "^0.1.151"
55
+ "@atscript/db": "^0.1.153",
56
+ "@atscript/db-sql-tools": "^0.1.153"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {