@atscript/db-mysql 0.1.131 → 0.1.132
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 +128 -17
- package/dist/index.d.cts +16 -1
- package/dist/index.d.mts +17 -2
- package/dist/index.mjs +130 -19
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let _atscript_db = require("@atscript/db");
|
|
3
3
|
let _atscript_db_agg = require("@atscript/db/agg");
|
|
4
4
|
let _atscript_db_sql_tools = require("@atscript/db-sql-tools");
|
|
5
|
+
let _uniqu_core = require("@uniqu/core");
|
|
5
6
|
//#region src/sql-builder.ts
|
|
6
7
|
/** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
|
|
7
8
|
const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
|
|
@@ -165,9 +166,74 @@ const mysqlDialect = {
|
|
|
165
166
|
params: [...dist.params, circle.radius]
|
|
166
167
|
};
|
|
167
168
|
},
|
|
169
|
+
calendarBucket: mysqlCalendarBucket,
|
|
170
|
+
bucketAliasInHaving: true,
|
|
168
171
|
createViewPrefix: "CREATE OR REPLACE VIEW"
|
|
169
172
|
};
|
|
170
173
|
/**
|
|
174
|
+
* Whether a field is stored as a native MySQL `TIMESTAMP`: a `number` with
|
|
175
|
+
* `@db.default.now`. The adapter writes such values as UTC
|
|
176
|
+
* `'YYYY-MM-DD HH:MM:SS'` strings and reads them back as epoch ms; every other
|
|
177
|
+
* numeric timestamp is a DOUBLE / BIGINT epoch-ms column.
|
|
178
|
+
*/
|
|
179
|
+
function isMysqlTimestampColumn(fd) {
|
|
180
|
+
return fd.designType === "number" && fd.defaultValue?.kind === "fn" && fd.defaultValue.fn === "now";
|
|
181
|
+
}
|
|
182
|
+
/** `'1970-01-01 00:00:00'` as a DATETIME: the base for epoch arithmetic free of the session zone. */
|
|
183
|
+
const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
|
|
184
|
+
/**
|
|
185
|
+
* Calendar-bucket label: TEXT `'YYYY-MM-DD'` — the local date of the
|
|
186
|
+
* bucket's first day in `b.tz` — or NULL for a NULL source or one outside
|
|
187
|
+
* `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
|
|
188
|
+
*
|
|
189
|
+
* The source's UTC wall time `U` (a DATETIME) depends on storage:
|
|
190
|
+
* - DOUBLE / BIGINT epoch ms: `epoch + INTERVAL FLOOR(col / 1000) SECOND`
|
|
191
|
+
* (never `FROM_UNIXTIME`, which converts to the session zone);
|
|
192
|
+
* - native `TIMESTAMP` ({@link isMysqlTimestampColumn}): `CAST(col AS DATETIME)`
|
|
193
|
+
* — the session-zone rendering, which is exactly the UTC string the adapter
|
|
194
|
+
* wrote and parses back on read, so the label agrees with the value reads
|
|
195
|
+
* return whatever the server's session zone is (`UNIX_TIMESTAMP(col)` would
|
|
196
|
+
* agree only under a UTC session zone).
|
|
197
|
+
*
|
|
198
|
+
* The local date is `DATE(CONVERT_TZ(U, '+00:00', '<tz>'))`, or `DATE(U)` for
|
|
199
|
+
* `UTC` (no time zone tables needed). Truncation is calendar arithmetic on
|
|
200
|
+
* that date. `CONVERT_TZ` returns NULL when the zone is unknown to the
|
|
201
|
+
* server and its input unchanged outside its range — `MysqlAdapter` probes
|
|
202
|
+
* each zone before running the query (`BUCKET_TZ_UNAVAILABLE`).
|
|
203
|
+
*
|
|
204
|
+
* Parameter-free (the zone is an inlined, charset-checked literal), so the
|
|
205
|
+
* SELECT and GROUP BY renderings are identical (`ONLY_FULL_GROUP_BY`).
|
|
206
|
+
*/
|
|
207
|
+
function mysqlCalendarBucket(quotedCol, b) {
|
|
208
|
+
let utc;
|
|
209
|
+
let inRange;
|
|
210
|
+
if (isMysqlTimestampColumn(b.fd)) {
|
|
211
|
+
utc = `CAST(${quotedCol} AS DATETIME)`;
|
|
212
|
+
inRange = `${utc} >= '1970-01-02 00:00:00'`;
|
|
213
|
+
} else {
|
|
214
|
+
utc = `(${MYSQL_EPOCH} + INTERVAL FLOOR(${quotedCol} / 1000) SECOND)`;
|
|
215
|
+
inRange = `${quotedCol} >= ${_uniqu_core.BUCKET_MIN_INSTANT} AND ${quotedCol} < ${_uniqu_core.BUCKET_MAX_INSTANT}`;
|
|
216
|
+
}
|
|
217
|
+
const local = b.tz === "UTC" ? `DATE(${utc})` : `DATE(CONVERT_TZ(${utc}, '+00:00', ${(0, _atscript_db_sql_tools.sqlTimeZoneLiteral)(b.tz)}))`;
|
|
218
|
+
let first;
|
|
219
|
+
switch (b.unit) {
|
|
220
|
+
case "day":
|
|
221
|
+
first = local;
|
|
222
|
+
break;
|
|
223
|
+
case "week":
|
|
224
|
+
first = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
|
|
225
|
+
break;
|
|
226
|
+
case "month":
|
|
227
|
+
first = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
|
|
228
|
+
break;
|
|
229
|
+
case "quarter":
|
|
230
|
+
first = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
|
|
231
|
+
break;
|
|
232
|
+
default: first = `MAKEDATE(YEAR(${local}), 1)`;
|
|
233
|
+
}
|
|
234
|
+
return `CASE WHEN ${inRange} THEN DATE_FORMAT(${first}, '%Y-%m-%d') END`;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
171
237
|
* Spherical distance in meters from a POINT column to a query point.
|
|
172
238
|
* Used as the `distExpr` of the shared geo search builder.
|
|
173
239
|
*/
|
|
@@ -210,6 +276,10 @@ function mysqlGeoValueToPoint(value) {
|
|
|
210
276
|
function buildInsert(table, data) {
|
|
211
277
|
return (0, _atscript_db_sql_tools.buildInsert)(mysqlDialect, table, data);
|
|
212
278
|
}
|
|
279
|
+
/** Multi-row INSERT over `columns` (a missing column → `DEFAULT`). */
|
|
280
|
+
function buildInsertMany(table, rows, columns) {
|
|
281
|
+
return (0, _atscript_db_sql_tools.buildInsertMany)(mysqlDialect, table, rows, columns);
|
|
282
|
+
}
|
|
213
283
|
/**
|
|
214
284
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
215
285
|
*/
|
|
@@ -291,7 +361,7 @@ function mysqlTypeFromField(field) {
|
|
|
291
361
|
case "number":
|
|
292
362
|
if (precision) return `DECIMAL(${precision.precision},${precision.scale})`;
|
|
293
363
|
if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "increment") return unsigned ? "BIGINT UNSIGNED" : "BIGINT";
|
|
294
|
-
if (field
|
|
364
|
+
if (isMysqlTimestampColumn(field)) return "TIMESTAMP";
|
|
295
365
|
if (tags?.has("int")) return intTypeFromTags(tags, unsigned);
|
|
296
366
|
return "DOUBLE";
|
|
297
367
|
case "integer": return intTypeFromTags(tags, unsigned);
|
|
@@ -385,6 +455,25 @@ function epochMsToUtcDatetime(ms) {
|
|
|
385
455
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
|
386
456
|
}
|
|
387
457
|
/**
|
|
458
|
+
* One statement answers both questions `CONVERT_TZ` never raises an error
|
|
459
|
+
* for: `missing` — the named zone (bound as `?`) converts to NULL, i.e. the
|
|
460
|
+
* time zone tables are not loaded or lack it; `shift` — a fixed `+01:00`
|
|
461
|
+
* conversion after 2038 is not applied (0 instead of 60 minutes), i.e. the
|
|
462
|
+
* server's `CONVERT_TZ` range ends in 2038 (before 8.0.28, or 32-bit).
|
|
463
|
+
*
|
|
464
|
+
* The range half deliberately uses a fixed offset, not the named zone: MySQL
|
|
465
|
+
* tz tables hold no transitions after 2037, so a zone such as `Europe/London`
|
|
466
|
+
* legitimately converts to +00:00 in June 2040 on a current server — an
|
|
467
|
+
* "unchanged" named-zone result is not evidence of a limited range.
|
|
468
|
+
*/
|
|
469
|
+
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";
|
|
470
|
+
/**
|
|
471
|
+
* Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
|
|
472
|
+
* and share a driver). Positives only: a failed zone is probed again, so
|
|
473
|
+
* loading the time zone tables fixes a running server without a restart.
|
|
474
|
+
*/
|
|
475
|
+
const convertibleZones = /* @__PURE__ */ new WeakMap();
|
|
476
|
+
/**
|
|
388
477
|
* MySQL adapter for {@link AtscriptDbTable}.
|
|
389
478
|
*
|
|
390
479
|
* Accepts any {@link TMysqlDriver} implementation — the actual MySQL driver
|
|
@@ -481,6 +570,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
481
570
|
nativeDefaultFns() {
|
|
482
571
|
return MysqlAdapter.NATIVE_DEFAULT_FNS;
|
|
483
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Every unit: `mysqlCalendarBucket` renders them all; named zones need the
|
|
575
|
+
* server's time zone tables (probed per zone in `aggregate()`).
|
|
576
|
+
*/
|
|
577
|
+
calendarBucketUnits() {
|
|
578
|
+
return _atscript_db.ALL_BUCKET_UNITS;
|
|
579
|
+
}
|
|
484
580
|
onBeforeFlatten(_type) {
|
|
485
581
|
const meta = _type.metadata;
|
|
486
582
|
const engine = meta.get("db.mysql.engine");
|
|
@@ -573,7 +669,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
573
669
|
* converts epoch ms to a UTC datetime string for the wire protocol.
|
|
574
670
|
*/
|
|
575
671
|
formatValue(field) {
|
|
576
|
-
if (field
|
|
672
|
+
if (isMysqlTimestampColumn(field)) return {
|
|
577
673
|
toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
|
|
578
674
|
fromStorage: utcDatetimeToEpochMs
|
|
579
675
|
};
|
|
@@ -636,26 +732,16 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
636
732
|
};
|
|
637
733
|
return this.withTransaction(async () => {
|
|
638
734
|
const tableName = this.resolveTableName();
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
const paramsPerRow = keys.length;
|
|
642
|
-
const maxRowsPerBatch = paramsPerRow > 0 ? Math.floor(6e4 / paramsPerRow) : data.length;
|
|
735
|
+
const columns = (0, _atscript_db_sql_tools.insertManyColumns)(data);
|
|
736
|
+
const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
|
|
643
737
|
const allIds = [];
|
|
644
|
-
const rowPlaceholderClause = `(${keys.map(() => "?").join(", ")})`;
|
|
645
738
|
for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
const params = [];
|
|
649
|
-
for (let i = offset; i < batchEnd; i++) {
|
|
650
|
-
const row = data[i];
|
|
651
|
-
for (const k of keys) params.push(mysqlDialect.toValue(row[k]));
|
|
652
|
-
}
|
|
653
|
-
const valuesClause = Array(batchSize).fill(rowPlaceholderClause).join(", ");
|
|
654
|
-
const sql = `INSERT INTO ${quoteTableName(tableName)} (${colsClause}) VALUES ${valuesClause}`;
|
|
739
|
+
const batch = data.slice(offset, offset + maxRowsPerBatch);
|
|
740
|
+
const { sql, params } = buildInsertMany(tableName, batch, columns);
|
|
655
741
|
this._log(sql, params);
|
|
656
742
|
const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
|
|
657
743
|
const firstId = Number(result.insertId);
|
|
658
|
-
for (let i = 0; i <
|
|
744
|
+
for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
|
|
659
745
|
}
|
|
660
746
|
return {
|
|
661
747
|
insertedCount: allIds.length,
|
|
@@ -689,6 +775,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
689
775
|
const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
|
|
690
776
|
const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
|
|
691
777
|
const tableName = this.resolveTableName();
|
|
778
|
+
for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
|
|
692
779
|
if (query.controls.$count) {
|
|
693
780
|
const { sql, params } = buildAggregateCount(tableName, where, query.controls);
|
|
694
781
|
this._log(sql, params);
|
|
@@ -698,6 +785,30 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
698
785
|
this._log(sql, params);
|
|
699
786
|
return this._exec().all(sql, params);
|
|
700
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
|
|
790
|
+
* any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
|
|
791
|
+
* the time zone tables are not loaded (or lack the zone) and its input
|
|
792
|
+
* unchanged outside its supported range (before 8.0.28 that range ends in
|
|
793
|
+
* 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
|
|
794
|
+
* `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
|
|
795
|
+
* expression skips `CONVERT_TZ`. Successes are cached per driver.
|
|
796
|
+
*/
|
|
797
|
+
async _ensureBucketZone(tz) {
|
|
798
|
+
if (tz === "UTC" || convertibleZones.get(this.driver)?.has(tz)) return;
|
|
799
|
+
this._log(TZ_PROBE_SQL, [tz]);
|
|
800
|
+
const row = await this._exec().get(TZ_PROBE_SQL, [tz]);
|
|
801
|
+
let problem;
|
|
802
|
+
if (!row || Number(row.missing) !== 0) problem = "its time zone tables are not loaded or lack this zone — load them with mysql_tzinfo_to_sql";
|
|
803
|
+
else if (Number(row.shift) !== 60) problem = "its CONVERT_TZ does not convert instants after 2038 — MySQL 8.0.28 or later (64-bit) is required";
|
|
804
|
+
if (problem) throw (0, _atscript_db.bucketTimeZoneUnavailable)(`MySQL cannot convert to time zone "${tz}": ${problem}`);
|
|
805
|
+
let zones = convertibleZones.get(this.driver);
|
|
806
|
+
if (!zones) {
|
|
807
|
+
zones = /* @__PURE__ */ new Set();
|
|
808
|
+
convertibleZones.set(this.driver, zones);
|
|
809
|
+
}
|
|
810
|
+
zones.add(tz);
|
|
811
|
+
}
|
|
701
812
|
async updateOne(filter, data, ops, expectedVersion) {
|
|
702
813
|
const where = buildWhere(filter);
|
|
703
814
|
const versionColumn = this._table.versionColumn;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
|
|
1
|
+
import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
|
|
2
2
|
import { TMetadataMap } from "@atscript/typescript/utils";
|
|
3
3
|
import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
|
|
4
4
|
import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
|
|
@@ -132,6 +132,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
132
132
|
prepareId(id: unknown, _fieldType: unknown): unknown;
|
|
133
133
|
supportsNativeValueDefaults(): boolean;
|
|
134
134
|
nativeDefaultFns(): ReadonlySet<TDbDefaultFn>;
|
|
135
|
+
/**
|
|
136
|
+
* Every unit: `mysqlCalendarBucket` renders them all; named zones need the
|
|
137
|
+
* server's time zone tables (probed per zone in `aggregate()`).
|
|
138
|
+
*/
|
|
139
|
+
calendarBucketUnits(): ReadonlySet<BucketUnit>;
|
|
135
140
|
onBeforeFlatten(_type: unknown): void;
|
|
136
141
|
onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
|
|
137
142
|
getDesiredTableOptions(): TExistingTableOption[];
|
|
@@ -160,6 +165,16 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
160
165
|
findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
161
166
|
count(query: DbQuery): Promise<number>;
|
|
162
167
|
aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
168
|
+
/**
|
|
169
|
+
* Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
|
|
170
|
+
* any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
|
|
171
|
+
* the time zone tables are not loaded (or lack the zone) and its input
|
|
172
|
+
* unchanged outside its supported range (before 8.0.28 that range ends in
|
|
173
|
+
* 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
|
|
174
|
+
* `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
|
|
175
|
+
* expression skips `CONVERT_TZ`. Successes are cached per driver.
|
|
176
|
+
*/
|
|
177
|
+
private _ensureBucketZone;
|
|
163
178
|
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
164
179
|
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
|
|
165
180
|
replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
|
|
1
|
+
import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
|
|
2
2
|
import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
|
|
3
|
-
import { TMetadataMap } from "@atscript/typescript/utils";
|
|
4
3
|
import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
|
|
4
|
+
import { TMetadataMap } from "@atscript/typescript/utils";
|
|
5
5
|
|
|
6
6
|
//#region src/types.d.ts
|
|
7
7
|
/**
|
|
@@ -132,6 +132,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
132
132
|
prepareId(id: unknown, _fieldType: unknown): unknown;
|
|
133
133
|
supportsNativeValueDefaults(): boolean;
|
|
134
134
|
nativeDefaultFns(): ReadonlySet<TDbDefaultFn>;
|
|
135
|
+
/**
|
|
136
|
+
* Every unit: `mysqlCalendarBucket` renders them all; named zones need the
|
|
137
|
+
* server's time zone tables (probed per zone in `aggregate()`).
|
|
138
|
+
*/
|
|
139
|
+
calendarBucketUnits(): ReadonlySet<BucketUnit>;
|
|
135
140
|
onBeforeFlatten(_type: unknown): void;
|
|
136
141
|
onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
|
|
137
142
|
getDesiredTableOptions(): TExistingTableOption[];
|
|
@@ -160,6 +165,16 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
160
165
|
findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
161
166
|
count(query: DbQuery): Promise<number>;
|
|
162
167
|
aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
168
|
+
/**
|
|
169
|
+
* Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
|
|
170
|
+
* any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
|
|
171
|
+
* the time zone tables are not loaded (or lack the zone) and its input
|
|
172
|
+
* unchanged outside its supported range (before 8.0.28 that range ends in
|
|
173
|
+
* 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
|
|
174
|
+
* `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
|
|
175
|
+
* expression skips `CONVERT_TZ`. Successes are cached per driver.
|
|
176
|
+
*/
|
|
177
|
+
private _ensureBucketZone;
|
|
163
178
|
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
164
179
|
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
|
|
165
180
|
replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
|
|
1
|
+
import { ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable } from "@atscript/db";
|
|
2
2
|
import { resolveAggregateSearch } from "@atscript/db/agg";
|
|
3
|
-
import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, toSqlValue } from "@atscript/db-sql-tools";
|
|
3
|
+
import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, geoWindowFromControls, insertManyColumns, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, toSqlValue } from "@atscript/db-sql-tools";
|
|
4
|
+
import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
|
|
4
5
|
//#region src/sql-builder.ts
|
|
5
6
|
/** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
|
|
6
7
|
const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
|
|
@@ -164,9 +165,74 @@ const mysqlDialect = {
|
|
|
164
165
|
params: [...dist.params, circle.radius]
|
|
165
166
|
};
|
|
166
167
|
},
|
|
168
|
+
calendarBucket: mysqlCalendarBucket,
|
|
169
|
+
bucketAliasInHaving: true,
|
|
167
170
|
createViewPrefix: "CREATE OR REPLACE VIEW"
|
|
168
171
|
};
|
|
169
172
|
/**
|
|
173
|
+
* Whether a field is stored as a native MySQL `TIMESTAMP`: a `number` with
|
|
174
|
+
* `@db.default.now`. The adapter writes such values as UTC
|
|
175
|
+
* `'YYYY-MM-DD HH:MM:SS'` strings and reads them back as epoch ms; every other
|
|
176
|
+
* numeric timestamp is a DOUBLE / BIGINT epoch-ms column.
|
|
177
|
+
*/
|
|
178
|
+
function isMysqlTimestampColumn(fd) {
|
|
179
|
+
return fd.designType === "number" && fd.defaultValue?.kind === "fn" && fd.defaultValue.fn === "now";
|
|
180
|
+
}
|
|
181
|
+
/** `'1970-01-01 00:00:00'` as a DATETIME: the base for epoch arithmetic free of the session zone. */
|
|
182
|
+
const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
|
|
183
|
+
/**
|
|
184
|
+
* Calendar-bucket label: TEXT `'YYYY-MM-DD'` — the local date of the
|
|
185
|
+
* bucket's first day in `b.tz` — or NULL for a NULL source or one outside
|
|
186
|
+
* `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
|
|
187
|
+
*
|
|
188
|
+
* The source's UTC wall time `U` (a DATETIME) depends on storage:
|
|
189
|
+
* - DOUBLE / BIGINT epoch ms: `epoch + INTERVAL FLOOR(col / 1000) SECOND`
|
|
190
|
+
* (never `FROM_UNIXTIME`, which converts to the session zone);
|
|
191
|
+
* - native `TIMESTAMP` ({@link isMysqlTimestampColumn}): `CAST(col AS DATETIME)`
|
|
192
|
+
* — the session-zone rendering, which is exactly the UTC string the adapter
|
|
193
|
+
* wrote and parses back on read, so the label agrees with the value reads
|
|
194
|
+
* return whatever the server's session zone is (`UNIX_TIMESTAMP(col)` would
|
|
195
|
+
* agree only under a UTC session zone).
|
|
196
|
+
*
|
|
197
|
+
* The local date is `DATE(CONVERT_TZ(U, '+00:00', '<tz>'))`, or `DATE(U)` for
|
|
198
|
+
* `UTC` (no time zone tables needed). Truncation is calendar arithmetic on
|
|
199
|
+
* that date. `CONVERT_TZ` returns NULL when the zone is unknown to the
|
|
200
|
+
* server and its input unchanged outside its range — `MysqlAdapter` probes
|
|
201
|
+
* each zone before running the query (`BUCKET_TZ_UNAVAILABLE`).
|
|
202
|
+
*
|
|
203
|
+
* Parameter-free (the zone is an inlined, charset-checked literal), so the
|
|
204
|
+
* SELECT and GROUP BY renderings are identical (`ONLY_FULL_GROUP_BY`).
|
|
205
|
+
*/
|
|
206
|
+
function mysqlCalendarBucket(quotedCol, b) {
|
|
207
|
+
let utc;
|
|
208
|
+
let inRange;
|
|
209
|
+
if (isMysqlTimestampColumn(b.fd)) {
|
|
210
|
+
utc = `CAST(${quotedCol} AS DATETIME)`;
|
|
211
|
+
inRange = `${utc} >= '1970-01-02 00:00:00'`;
|
|
212
|
+
} else {
|
|
213
|
+
utc = `(${MYSQL_EPOCH} + INTERVAL FLOOR(${quotedCol} / 1000) SECOND)`;
|
|
214
|
+
inRange = `${quotedCol} >= ${BUCKET_MIN_INSTANT} AND ${quotedCol} < ${BUCKET_MAX_INSTANT}`;
|
|
215
|
+
}
|
|
216
|
+
const local = b.tz === "UTC" ? `DATE(${utc})` : `DATE(CONVERT_TZ(${utc}, '+00:00', ${sqlTimeZoneLiteral(b.tz)}))`;
|
|
217
|
+
let first;
|
|
218
|
+
switch (b.unit) {
|
|
219
|
+
case "day":
|
|
220
|
+
first = local;
|
|
221
|
+
break;
|
|
222
|
+
case "week":
|
|
223
|
+
first = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
|
|
224
|
+
break;
|
|
225
|
+
case "month":
|
|
226
|
+
first = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
|
|
227
|
+
break;
|
|
228
|
+
case "quarter":
|
|
229
|
+
first = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
|
|
230
|
+
break;
|
|
231
|
+
default: first = `MAKEDATE(YEAR(${local}), 1)`;
|
|
232
|
+
}
|
|
233
|
+
return `CASE WHEN ${inRange} THEN DATE_FORMAT(${first}, '%Y-%m-%d') END`;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
170
236
|
* Spherical distance in meters from a POINT column to a query point.
|
|
171
237
|
* Used as the `distExpr` of the shared geo search builder.
|
|
172
238
|
*/
|
|
@@ -209,6 +275,10 @@ function mysqlGeoValueToPoint(value) {
|
|
|
209
275
|
function buildInsert$1(table, data) {
|
|
210
276
|
return buildInsert(mysqlDialect, table, data);
|
|
211
277
|
}
|
|
278
|
+
/** Multi-row INSERT over `columns` (a missing column → `DEFAULT`). */
|
|
279
|
+
function buildInsertMany$1(table, rows, columns) {
|
|
280
|
+
return buildInsertMany(mysqlDialect, table, rows, columns);
|
|
281
|
+
}
|
|
212
282
|
/**
|
|
213
283
|
* Builds a SELECT statement with optional sort, limit, offset, projection.
|
|
214
284
|
*/
|
|
@@ -290,7 +360,7 @@ function mysqlTypeFromField(field) {
|
|
|
290
360
|
case "number":
|
|
291
361
|
if (precision) return `DECIMAL(${precision.precision},${precision.scale})`;
|
|
292
362
|
if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "increment") return unsigned ? "BIGINT UNSIGNED" : "BIGINT";
|
|
293
|
-
if (field
|
|
363
|
+
if (isMysqlTimestampColumn(field)) return "TIMESTAMP";
|
|
294
364
|
if (tags?.has("int")) return intTypeFromTags(tags, unsigned);
|
|
295
365
|
return "DOUBLE";
|
|
296
366
|
case "integer": return intTypeFromTags(tags, unsigned);
|
|
@@ -384,6 +454,25 @@ function epochMsToUtcDatetime(ms) {
|
|
|
384
454
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
|
385
455
|
}
|
|
386
456
|
/**
|
|
457
|
+
* One statement answers both questions `CONVERT_TZ` never raises an error
|
|
458
|
+
* for: `missing` — the named zone (bound as `?`) converts to NULL, i.e. the
|
|
459
|
+
* time zone tables are not loaded or lack it; `shift` — a fixed `+01:00`
|
|
460
|
+
* conversion after 2038 is not applied (0 instead of 60 minutes), i.e. the
|
|
461
|
+
* server's `CONVERT_TZ` range ends in 2038 (before 8.0.28, or 32-bit).
|
|
462
|
+
*
|
|
463
|
+
* The range half deliberately uses a fixed offset, not the named zone: MySQL
|
|
464
|
+
* tz tables hold no transitions after 2037, so a zone such as `Europe/London`
|
|
465
|
+
* legitimately converts to +00:00 in June 2040 on a current server — an
|
|
466
|
+
* "unchanged" named-zone result is not evidence of a limited range.
|
|
467
|
+
*/
|
|
468
|
+
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";
|
|
469
|
+
/**
|
|
470
|
+
* Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
|
|
471
|
+
* and share a driver). Positives only: a failed zone is probed again, so
|
|
472
|
+
* loading the time zone tables fixes a running server without a restart.
|
|
473
|
+
*/
|
|
474
|
+
const convertibleZones = /* @__PURE__ */ new WeakMap();
|
|
475
|
+
/**
|
|
387
476
|
* MySQL adapter for {@link AtscriptDbTable}.
|
|
388
477
|
*
|
|
389
478
|
* Accepts any {@link TMysqlDriver} implementation — the actual MySQL driver
|
|
@@ -480,6 +569,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
480
569
|
nativeDefaultFns() {
|
|
481
570
|
return MysqlAdapter.NATIVE_DEFAULT_FNS;
|
|
482
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Every unit: `mysqlCalendarBucket` renders them all; named zones need the
|
|
574
|
+
* server's time zone tables (probed per zone in `aggregate()`).
|
|
575
|
+
*/
|
|
576
|
+
calendarBucketUnits() {
|
|
577
|
+
return ALL_BUCKET_UNITS;
|
|
578
|
+
}
|
|
483
579
|
onBeforeFlatten(_type) {
|
|
484
580
|
const meta = _type.metadata;
|
|
485
581
|
const engine = meta.get("db.mysql.engine");
|
|
@@ -572,7 +668,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
572
668
|
* converts epoch ms to a UTC datetime string for the wire protocol.
|
|
573
669
|
*/
|
|
574
670
|
formatValue(field) {
|
|
575
|
-
if (field
|
|
671
|
+
if (isMysqlTimestampColumn(field)) return {
|
|
576
672
|
toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
|
|
577
673
|
fromStorage: utcDatetimeToEpochMs
|
|
578
674
|
};
|
|
@@ -635,26 +731,16 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
635
731
|
};
|
|
636
732
|
return this.withTransaction(async () => {
|
|
637
733
|
const tableName = this.resolveTableName();
|
|
638
|
-
const
|
|
639
|
-
const
|
|
640
|
-
const paramsPerRow = keys.length;
|
|
641
|
-
const maxRowsPerBatch = paramsPerRow > 0 ? Math.floor(6e4 / paramsPerRow) : data.length;
|
|
734
|
+
const columns = insertManyColumns(data);
|
|
735
|
+
const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
|
|
642
736
|
const allIds = [];
|
|
643
|
-
const rowPlaceholderClause = `(${keys.map(() => "?").join(", ")})`;
|
|
644
737
|
for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
const params = [];
|
|
648
|
-
for (let i = offset; i < batchEnd; i++) {
|
|
649
|
-
const row = data[i];
|
|
650
|
-
for (const k of keys) params.push(mysqlDialect.toValue(row[k]));
|
|
651
|
-
}
|
|
652
|
-
const valuesClause = Array(batchSize).fill(rowPlaceholderClause).join(", ");
|
|
653
|
-
const sql = `INSERT INTO ${quoteTableName(tableName)} (${colsClause}) VALUES ${valuesClause}`;
|
|
738
|
+
const batch = data.slice(offset, offset + maxRowsPerBatch);
|
|
739
|
+
const { sql, params } = buildInsertMany$1(tableName, batch, columns);
|
|
654
740
|
this._log(sql, params);
|
|
655
741
|
const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
|
|
656
742
|
const firstId = Number(result.insertId);
|
|
657
|
-
for (let i = 0; i <
|
|
743
|
+
for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
|
|
658
744
|
}
|
|
659
745
|
return {
|
|
660
746
|
insertedCount: allIds.length,
|
|
@@ -688,6 +774,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
688
774
|
const search = resolveAggregateSearch(query.controls);
|
|
689
775
|
const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
|
|
690
776
|
const tableName = this.resolveTableName();
|
|
777
|
+
for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
|
|
691
778
|
if (query.controls.$count) {
|
|
692
779
|
const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
|
|
693
780
|
this._log(sql, params);
|
|
@@ -697,6 +784,30 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
697
784
|
this._log(sql, params);
|
|
698
785
|
return this._exec().all(sql, params);
|
|
699
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
|
|
789
|
+
* any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
|
|
790
|
+
* the time zone tables are not loaded (or lack the zone) and its input
|
|
791
|
+
* unchanged outside its supported range (before 8.0.28 that range ends in
|
|
792
|
+
* 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
|
|
793
|
+
* `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
|
|
794
|
+
* expression skips `CONVERT_TZ`. Successes are cached per driver.
|
|
795
|
+
*/
|
|
796
|
+
async _ensureBucketZone(tz) {
|
|
797
|
+
if (tz === "UTC" || convertibleZones.get(this.driver)?.has(tz)) return;
|
|
798
|
+
this._log(TZ_PROBE_SQL, [tz]);
|
|
799
|
+
const row = await this._exec().get(TZ_PROBE_SQL, [tz]);
|
|
800
|
+
let problem;
|
|
801
|
+
if (!row || Number(row.missing) !== 0) problem = "its time zone tables are not loaded or lack this zone — load them with mysql_tzinfo_to_sql";
|
|
802
|
+
else if (Number(row.shift) !== 60) problem = "its CONVERT_TZ does not convert instants after 2038 — MySQL 8.0.28 or later (64-bit) is required";
|
|
803
|
+
if (problem) throw bucketTimeZoneUnavailable(`MySQL cannot convert to time zone "${tz}": ${problem}`);
|
|
804
|
+
let zones = convertibleZones.get(this.driver);
|
|
805
|
+
if (!zones) {
|
|
806
|
+
zones = /* @__PURE__ */ new Set();
|
|
807
|
+
convertibleZones.set(this.driver, zones);
|
|
808
|
+
}
|
|
809
|
+
zones.add(tz);
|
|
810
|
+
}
|
|
700
811
|
async updateOne(filter, data, ops, expectedVersion) {
|
|
701
812
|
const where = buildWhere(filter);
|
|
702
813
|
const versionColumn = this._table.versionColumn;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-mysql",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.132",
|
|
4
4
|
"description": "MySQL adapter for @atscript/db with mysql2 driver support.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -43,17 +43,17 @@
|
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@atscript/core": "^0.1.92",
|
|
45
45
|
"@atscript/typescript": "^0.1.92",
|
|
46
|
-
"@uniqu/core": "^0.1.
|
|
46
|
+
"@uniqu/core": "^0.1.9",
|
|
47
47
|
"mysql2": "^3.11.0",
|
|
48
48
|
"unplugin-atscript": "^0.1.92"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@atscript/core": "^0.1.92",
|
|
52
52
|
"@atscript/typescript": "^0.1.92",
|
|
53
|
-
"@uniqu/core": "^0.1.
|
|
53
|
+
"@uniqu/core": "^0.1.9",
|
|
54
54
|
"mysql2": ">=3.0.0",
|
|
55
|
-
"@atscript/db": "^0.1.
|
|
56
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
55
|
+
"@atscript/db": "^0.1.132",
|
|
56
|
+
"@atscript/db-sql-tools": "^0.1.132"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"mysql2": {
|