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