@atscript/db-mysql 0.1.105 → 0.1.107
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 +158 -10
- package/dist/index.d.cts +17 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.mjs +159 -11
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -39,9 +39,53 @@ const mysqlDialect = {
|
|
|
39
39
|
params: [pattern]
|
|
40
40
|
};
|
|
41
41
|
},
|
|
42
|
+
geoWithin(quotedCol, circle) {
|
|
43
|
+
const dist = mysqlGeoDistanceExpr(quotedCol, circle.center);
|
|
44
|
+
return {
|
|
45
|
+
sql: `${dist.sql} <= ?`,
|
|
46
|
+
params: [...dist.params, circle.radius]
|
|
47
|
+
};
|
|
48
|
+
},
|
|
42
49
|
createViewPrefix: "CREATE OR REPLACE VIEW"
|
|
43
50
|
};
|
|
44
51
|
/**
|
|
52
|
+
* Spherical distance in meters from a POINT column to a query point.
|
|
53
|
+
* Used as the `distExpr` of the shared geo search builder.
|
|
54
|
+
*/
|
|
55
|
+
function mysqlGeoDistanceExpr(quotedCol, point) {
|
|
56
|
+
return {
|
|
57
|
+
sql: `ST_Distance_Sphere(${quotedCol}, ST_SRID(POINT(?, ?), 4326))`,
|
|
58
|
+
params: [point[0], point[1]]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Encodes a `[lng, lat]` tuple in MySQL's internal geometry format
|
|
63
|
+
* (4-byte SRID LE + WKB point) — geometry columns accept it directly as a
|
|
64
|
+
* binary parameter, bypassing the WKT/WKB axis-order pitfalls entirely.
|
|
65
|
+
*/
|
|
66
|
+
function geoPointToMysqlInternal(point) {
|
|
67
|
+
const buf = Buffer.alloc(25);
|
|
68
|
+
buf.writeUInt32LE(4326, 0);
|
|
69
|
+
buf.writeUInt8(1, 4);
|
|
70
|
+
buf.writeUInt32LE(1, 5);
|
|
71
|
+
buf.writeDoubleLE(point[0], 9);
|
|
72
|
+
buf.writeDoubleLE(point[1], 17);
|
|
73
|
+
return buf;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Decodes a geo read value back to `[lng, lat]`. The mysql2 driver parses
|
|
77
|
+
* POINT columns to `{x, y}` objects (internal order: x=lng, y=lat); raw
|
|
78
|
+
* internal-format buffers are handled for drivers that don't.
|
|
79
|
+
*/
|
|
80
|
+
function mysqlGeoValueToPoint(value) {
|
|
81
|
+
if (typeof value === "object" && value !== null && typeof value.x === "number" && typeof value.y === "number") return [value.x, value.y];
|
|
82
|
+
if (value instanceof Uint8Array && value.length >= 25) {
|
|
83
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
84
|
+
const littleEndian = buf.readUInt8(4) === 1;
|
|
85
|
+
if (((littleEndian ? buf.readUInt32LE(5) : buf.readUInt32BE(5)) & 255) === 1) return [littleEndian ? buf.readDoubleLE(9) : buf.readDoubleBE(9), littleEndian ? buf.readDoubleLE(17) : buf.readDoubleBE(17)];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
45
89
|
* Builds an INSERT statement.
|
|
46
90
|
*/
|
|
47
91
|
function buildInsert(table, data) {
|
|
@@ -121,6 +165,7 @@ function mysqlTypeFromField(field) {
|
|
|
121
165
|
const metadata = field.type?.metadata;
|
|
122
166
|
const mysqlTypeOverride = metadata?.get("db.mysql.type");
|
|
123
167
|
if (mysqlTypeOverride) return mysqlTypeOverride;
|
|
168
|
+
if (field.isGeoPoint && !field.encrypted) return "POINT SRID 4326";
|
|
124
169
|
const unsigned = metadata?.has("db.mysql.unsigned") ?? false;
|
|
125
170
|
const precision = metadata?.get("db.column.precision");
|
|
126
171
|
switch (field.designType) {
|
|
@@ -409,6 +454,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
409
454
|
toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
|
|
410
455
|
fromStorage: utcDatetimeToEpochMs
|
|
411
456
|
};
|
|
457
|
+
if (field.isGeoPoint && !field.encrypted) return {
|
|
458
|
+
toStorage: (value) => {
|
|
459
|
+
const point = (0, _atscript_db_sql_tools.normalizeGeoPointValue)(value);
|
|
460
|
+
return point ? geoPointToMysqlInternal(point) : value;
|
|
461
|
+
},
|
|
462
|
+
fromStorage: (value) => mysqlGeoValueToPoint(value) ?? value
|
|
463
|
+
};
|
|
412
464
|
}
|
|
413
465
|
/**
|
|
414
466
|
* Wraps an async write operation to catch MySQL constraint errors
|
|
@@ -578,8 +630,11 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
578
630
|
this._log(sql, params);
|
|
579
631
|
return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
|
|
580
632
|
}
|
|
581
|
-
async
|
|
633
|
+
async prepareTypeMapper() {
|
|
582
634
|
if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
|
|
635
|
+
}
|
|
636
|
+
async ensureTable() {
|
|
637
|
+
await this.prepareTypeMapper();
|
|
583
638
|
if (this._table instanceof _atscript_db.AtscriptDbView) return this._ensureView();
|
|
584
639
|
const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
|
|
585
640
|
engine: this._engine,
|
|
@@ -603,12 +658,12 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
603
658
|
}
|
|
604
659
|
async getExistingColumnsForTable(tableName) {
|
|
605
660
|
const schema = this._schema;
|
|
606
|
-
return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT
|
|
661
|
+
return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, SRS_ID
|
|
607
662
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
608
663
|
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
609
664
|
ORDER BY ORDINAL_POSITION`, [tableName, schema])).map((r) => ({
|
|
610
665
|
name: r.COLUMN_NAME,
|
|
611
|
-
type: r.COLUMN_TYPE.toUpperCase()
|
|
666
|
+
type: r.SRS_ID == null ? r.COLUMN_TYPE.toUpperCase() : `${r.COLUMN_TYPE.toUpperCase()} SRID ${r.SRS_ID}`,
|
|
612
667
|
notnull: r.IS_NULLABLE === "NO",
|
|
613
668
|
pk: r.COLUMN_KEY === "PRI",
|
|
614
669
|
dflt_value: normalizeMysqlDefault(r.COLUMN_DEFAULT)
|
|
@@ -629,7 +684,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
629
684
|
let ddl = `ALTER TABLE ${quoteTableName(tableName)} ADD COLUMN ${qi(field.physicalName)} ${sqlType}`;
|
|
630
685
|
if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
|
|
631
686
|
if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueToSqlLiteral)(field.designType, field.defaultValue.value)}`;
|
|
632
|
-
else if (!field.optional && !field.isPrimaryKey) ddl += ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueForType)(field.designType)}`;
|
|
687
|
+
else if (!field.optional && !field.isPrimaryKey) ddl += field.isGeoPoint && !field.encrypted ? ` DEFAULT (ST_SRID(POINT(0, 0), 4326))` : ` DEFAULT ${(0, _atscript_db_sql_tools.defaultValueForType)(field.designType)}`;
|
|
633
688
|
if (field.collate) {
|
|
634
689
|
const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
|
|
635
690
|
ddl += ` COLLATE ${nativeCollate ?? collationToMysql(field.collate)}`;
|
|
@@ -640,6 +695,10 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
640
695
|
}
|
|
641
696
|
for (const { field } of diff.typeChanged ?? []) {
|
|
642
697
|
const sqlType = this.typeMapper(field);
|
|
698
|
+
if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
|
|
699
|
+
await this._migrateJsonColumnToPoint(tableName, field);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
643
702
|
let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
|
|
644
703
|
if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
|
|
645
704
|
this._log(ddl);
|
|
@@ -726,6 +785,21 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
726
785
|
this._log(ddl);
|
|
727
786
|
await this._exec().exec(ddl);
|
|
728
787
|
}
|
|
788
|
+
async dropIndexesForColumns(columns) {
|
|
789
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
790
|
+
const rows = await this._exec().all(`SELECT DISTINCT INDEX_NAME AS name FROM INFORMATION_SCHEMA.STATISTICS
|
|
791
|
+
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
792
|
+
AND INDEX_NAME LIKE 'atscript\\_\\_%' AND COLUMN_NAME IN (${placeholders})`, [
|
|
793
|
+
this._table.tableName,
|
|
794
|
+
this._schema,
|
|
795
|
+
...columns
|
|
796
|
+
]);
|
|
797
|
+
for (const row of rows) {
|
|
798
|
+
const sql = `DROP INDEX ${qi(row.name)} ON ${quoteTableName(this.resolveTableName())}`;
|
|
799
|
+
this._log(sql);
|
|
800
|
+
await this._exec().exec(sql);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
729
803
|
async dropTableByName(tableName) {
|
|
730
804
|
const ddl = `DROP TABLE IF EXISTS ${quoteTableName(tableName)}`;
|
|
731
805
|
this._log(ddl);
|
|
@@ -762,9 +836,28 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
762
836
|
const schema = this._schema;
|
|
763
837
|
const stringFields = new Set(this._table.fieldDescriptors.filter((f) => f.designType === "string").map((f) => f.physicalName));
|
|
764
838
|
await this.syncIndexesWithDiff({
|
|
765
|
-
listExisting: async () =>
|
|
766
|
-
|
|
839
|
+
listExisting: async () => {
|
|
840
|
+
return (await this._exec().all(`SELECT INDEX_NAME AS name,
|
|
841
|
+
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns
|
|
842
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
843
|
+
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
844
|
+
GROUP BY INDEX_NAME`, [tableName, schema])).map((r) => ({
|
|
845
|
+
name: r.name,
|
|
846
|
+
columns: r.columns ? r.columns.split(",") : void 0
|
|
847
|
+
}));
|
|
848
|
+
},
|
|
767
849
|
createIndex: async (index) => {
|
|
850
|
+
if (index.type === "geo") {
|
|
851
|
+
const fieldMeta = this._table.fieldDescriptors.find((f) => f.physicalName === index.fields[0]?.name);
|
|
852
|
+
if (fieldMeta?.optional) {
|
|
853
|
+
this.logger.warn(`[mysql] geo index "${index.name}" skipped — SPATIAL indexes require a NOT NULL column; make "${fieldMeta.path}" required to index it`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const sql = `CREATE SPATIAL INDEX ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${qi(index.fields[0].name)})`;
|
|
857
|
+
this._log(sql);
|
|
858
|
+
await this._exec().exec(sql);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
768
861
|
const unique = index.type === "unique" ? "UNIQUE " : "";
|
|
769
862
|
const fulltext = index.type === "fulltext" ? "FULLTEXT " : "";
|
|
770
863
|
const isFulltext = index.type === "fulltext";
|
|
@@ -779,10 +872,6 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
779
872
|
const sql = `DROP INDEX ${qi(name)} ON ${quoteTableName(this.resolveTableName())}`;
|
|
780
873
|
this._log(sql);
|
|
781
874
|
await this._exec().exec(sql);
|
|
782
|
-
},
|
|
783
|
-
warnUnsupportedTypes: {
|
|
784
|
-
adapter: "mysql",
|
|
785
|
-
types: ["geo"]
|
|
786
875
|
}
|
|
787
876
|
});
|
|
788
877
|
}
|
|
@@ -1009,6 +1098,65 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1009
1098
|
if (queryThreshold !== void 0) return queryThreshold;
|
|
1010
1099
|
return this._vectorThresholds.get(indexName);
|
|
1011
1100
|
}
|
|
1101
|
+
/** Native POINT SRID 4326 + ST_Distance_Sphere — available on MySQL 8.0+. */
|
|
1102
|
+
isGeoSearchable() {
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
async geoSearch(point, query, indexName) {
|
|
1106
|
+
const { sql, params } = this._buildGeoSearchSelect(this._prepareGeoSearch(point, query, indexName));
|
|
1107
|
+
this._log(sql, params);
|
|
1108
|
+
return (await this._exec().all(sql, params)).map((row) => (0, _atscript_db_sql_tools.renameGeoDistance)(row));
|
|
1109
|
+
}
|
|
1110
|
+
async geoSearchWithCount(point, query, indexName) {
|
|
1111
|
+
const ctx = this._prepareGeoSearch(point, query, indexName);
|
|
1112
|
+
const { sql, params } = this._buildGeoSearchSelect(ctx);
|
|
1113
|
+
const countFrag = (0, _atscript_db_sql_tools.buildGeoSearchCount)(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window);
|
|
1114
|
+
this._log(sql, params);
|
|
1115
|
+
this._log(countFrag.sql, countFrag.params);
|
|
1116
|
+
const [rows, countRow] = await Promise.all([this._exec().all(sql, params), this._exec().get(countFrag.sql, countFrag.params)]);
|
|
1117
|
+
return {
|
|
1118
|
+
data: rows.map((row) => (0, _atscript_db_sql_tools.renameGeoDistance)(row)),
|
|
1119
|
+
count: Number(countRow?.cnt ?? 0)
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/** Resolves the shared parts of a geo search once (column, filter, distance, window). */
|
|
1123
|
+
_prepareGeoSearch(point, query, indexName) {
|
|
1124
|
+
const column = this._resolveGeoColumn(indexName);
|
|
1125
|
+
const controls = query.controls ?? {};
|
|
1126
|
+
return {
|
|
1127
|
+
tableName: this.resolveTableName(),
|
|
1128
|
+
where: buildWhere(query.filter),
|
|
1129
|
+
dist: mysqlGeoDistanceExpr(qi(column), point),
|
|
1130
|
+
window: (0, _atscript_db_sql_tools.geoWindowFromControls)(controls),
|
|
1131
|
+
controls
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
_buildGeoSearchSelect(ctx) {
|
|
1135
|
+
return (0, _atscript_db_sql_tools.buildGeoSearchSelect)(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, {
|
|
1136
|
+
$limit: ctx.controls.$limit,
|
|
1137
|
+
$skip: ctx.controls.$skip
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
|
|
1142
|
+
* temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
|
|
1143
|
+
*/
|
|
1144
|
+
async _migrateJsonColumnToPoint(tableName, field) {
|
|
1145
|
+
const col = qi(field.physicalName);
|
|
1146
|
+
const tmp = qi(`${field.physicalName}__geo_mig`);
|
|
1147
|
+
const quotedTable = quoteTableName(tableName);
|
|
1148
|
+
const steps = [
|
|
1149
|
+
`ALTER TABLE ${quotedTable} ADD COLUMN ${tmp} POINT SRID 4326 NULL`,
|
|
1150
|
+
`UPDATE ${quotedTable} SET ${tmp} = ST_SRID(POINT(CAST(${col}->>'$[0]' AS DOUBLE), CAST(${col}->>'$[1]' AS DOUBLE)), 4326) WHERE ${col} IS NOT NULL`,
|
|
1151
|
+
`ALTER TABLE ${quotedTable} DROP COLUMN ${col}`,
|
|
1152
|
+
`ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`,
|
|
1153
|
+
...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${col} POINT SRID 4326 NOT NULL`]
|
|
1154
|
+
];
|
|
1155
|
+
for (const ddl of steps) {
|
|
1156
|
+
this._log(ddl);
|
|
1157
|
+
await this._exec().exec(ddl);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1012
1160
|
};
|
|
1013
1161
|
/**
|
|
1014
1162
|
* Normalizes MySQL INFORMATION_SCHEMA.COLUMNS.COLUMN_DEFAULT values
|
package/dist/index.d.cts
CHANGED
|
@@ -157,6 +157,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
157
157
|
replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
|
|
158
158
|
deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
159
159
|
deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
160
|
+
prepareTypeMapper(): Promise<void>;
|
|
160
161
|
ensureTable(): Promise<void>;
|
|
161
162
|
private _ensureView;
|
|
162
163
|
getExistingColumns(): Promise<TExistingColumn[]>;
|
|
@@ -165,6 +166,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
165
166
|
recreateTable(): Promise<void>;
|
|
166
167
|
dropTable(): Promise<void>;
|
|
167
168
|
dropColumns(columns: string[]): Promise<void>;
|
|
169
|
+
dropIndexesForColumns(columns: string[]): Promise<void>;
|
|
168
170
|
dropTableByName(tableName: string): Promise<void>;
|
|
169
171
|
dropViewByName(viewName: string): Promise<void>;
|
|
170
172
|
renameTable(oldName: string): Promise<void>;
|
|
@@ -200,6 +202,21 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
200
202
|
private _buildVectorSearchCountQuery;
|
|
201
203
|
/** Resolves threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
|
|
202
204
|
private _resolveVectorThreshold;
|
|
205
|
+
/** Native POINT SRID 4326 + ST_Distance_Sphere — available on MySQL 8.0+. */
|
|
206
|
+
isGeoSearchable(): boolean;
|
|
207
|
+
geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Array<Record<string, unknown>>>;
|
|
208
|
+
geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
|
|
209
|
+
data: Array<Record<string, unknown>>;
|
|
210
|
+
count: number;
|
|
211
|
+
}>;
|
|
212
|
+
/** Resolves the shared parts of a geo search once (column, filter, distance, window). */
|
|
213
|
+
private _prepareGeoSearch;
|
|
214
|
+
private _buildGeoSearchSelect;
|
|
215
|
+
/**
|
|
216
|
+
* Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
|
|
217
|
+
* temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
|
|
218
|
+
*/
|
|
219
|
+
private _migrateJsonColumnToPoint;
|
|
203
220
|
}
|
|
204
221
|
//#endregion
|
|
205
222
|
//#region src/mysql2-driver.d.ts
|
package/dist/index.d.mts
CHANGED
|
@@ -157,6 +157,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
157
157
|
replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
|
|
158
158
|
deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
159
159
|
deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
160
|
+
prepareTypeMapper(): Promise<void>;
|
|
160
161
|
ensureTable(): Promise<void>;
|
|
161
162
|
private _ensureView;
|
|
162
163
|
getExistingColumns(): Promise<TExistingColumn[]>;
|
|
@@ -165,6 +166,7 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
165
166
|
recreateTable(): Promise<void>;
|
|
166
167
|
dropTable(): Promise<void>;
|
|
167
168
|
dropColumns(columns: string[]): Promise<void>;
|
|
169
|
+
dropIndexesForColumns(columns: string[]): Promise<void>;
|
|
168
170
|
dropTableByName(tableName: string): Promise<void>;
|
|
169
171
|
dropViewByName(viewName: string): Promise<void>;
|
|
170
172
|
renameTable(oldName: string): Promise<void>;
|
|
@@ -200,6 +202,21 @@ declare class MysqlAdapter extends BaseDbAdapter {
|
|
|
200
202
|
private _buildVectorSearchCountQuery;
|
|
201
203
|
/** Resolves threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
|
|
202
204
|
private _resolveVectorThreshold;
|
|
205
|
+
/** Native POINT SRID 4326 + ST_Distance_Sphere — available on MySQL 8.0+. */
|
|
206
|
+
isGeoSearchable(): boolean;
|
|
207
|
+
geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Array<Record<string, unknown>>>;
|
|
208
|
+
geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
|
|
209
|
+
data: Array<Record<string, unknown>>;
|
|
210
|
+
count: number;
|
|
211
|
+
}>;
|
|
212
|
+
/** Resolves the shared parts of a geo search once (column, filter, distance, window). */
|
|
213
|
+
private _prepareGeoSearch;
|
|
214
|
+
private _buildGeoSearchSelect;
|
|
215
|
+
/**
|
|
216
|
+
* Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
|
|
217
|
+
* temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
|
|
218
|
+
*/
|
|
219
|
+
private _migrateJsonColumnToPoint;
|
|
203
220
|
}
|
|
204
221
|
//#endregion
|
|
205
222
|
//#region src/mysql2-driver.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
|
|
2
|
-
import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, parseRegexString, refActionToSql, toSqlValue } from "@atscript/db-sql-tools";
|
|
2
|
+
import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, toSqlValue } from "@atscript/db-sql-tools";
|
|
3
3
|
//#region src/sql-builder.ts
|
|
4
4
|
/** Escapes a MySQL identifier by doubling backticks. */
|
|
5
5
|
function esc(name) {
|
|
@@ -38,9 +38,53 @@ const mysqlDialect = {
|
|
|
38
38
|
params: [pattern]
|
|
39
39
|
};
|
|
40
40
|
},
|
|
41
|
+
geoWithin(quotedCol, circle) {
|
|
42
|
+
const dist = mysqlGeoDistanceExpr(quotedCol, circle.center);
|
|
43
|
+
return {
|
|
44
|
+
sql: `${dist.sql} <= ?`,
|
|
45
|
+
params: [...dist.params, circle.radius]
|
|
46
|
+
};
|
|
47
|
+
},
|
|
41
48
|
createViewPrefix: "CREATE OR REPLACE VIEW"
|
|
42
49
|
};
|
|
43
50
|
/**
|
|
51
|
+
* Spherical distance in meters from a POINT column to a query point.
|
|
52
|
+
* Used as the `distExpr` of the shared geo search builder.
|
|
53
|
+
*/
|
|
54
|
+
function mysqlGeoDistanceExpr(quotedCol, point) {
|
|
55
|
+
return {
|
|
56
|
+
sql: `ST_Distance_Sphere(${quotedCol}, ST_SRID(POINT(?, ?), 4326))`,
|
|
57
|
+
params: [point[0], point[1]]
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Encodes a `[lng, lat]` tuple in MySQL's internal geometry format
|
|
62
|
+
* (4-byte SRID LE + WKB point) — geometry columns accept it directly as a
|
|
63
|
+
* binary parameter, bypassing the WKT/WKB axis-order pitfalls entirely.
|
|
64
|
+
*/
|
|
65
|
+
function geoPointToMysqlInternal(point) {
|
|
66
|
+
const buf = Buffer.alloc(25);
|
|
67
|
+
buf.writeUInt32LE(4326, 0);
|
|
68
|
+
buf.writeUInt8(1, 4);
|
|
69
|
+
buf.writeUInt32LE(1, 5);
|
|
70
|
+
buf.writeDoubleLE(point[0], 9);
|
|
71
|
+
buf.writeDoubleLE(point[1], 17);
|
|
72
|
+
return buf;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decodes a geo read value back to `[lng, lat]`. The mysql2 driver parses
|
|
76
|
+
* POINT columns to `{x, y}` objects (internal order: x=lng, y=lat); raw
|
|
77
|
+
* internal-format buffers are handled for drivers that don't.
|
|
78
|
+
*/
|
|
79
|
+
function mysqlGeoValueToPoint(value) {
|
|
80
|
+
if (typeof value === "object" && value !== null && typeof value.x === "number" && typeof value.y === "number") return [value.x, value.y];
|
|
81
|
+
if (value instanceof Uint8Array && value.length >= 25) {
|
|
82
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
83
|
+
const littleEndian = buf.readUInt8(4) === 1;
|
|
84
|
+
if (((littleEndian ? buf.readUInt32LE(5) : buf.readUInt32BE(5)) & 255) === 1) return [littleEndian ? buf.readDoubleLE(9) : buf.readDoubleBE(9), littleEndian ? buf.readDoubleLE(17) : buf.readDoubleBE(17)];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
44
88
|
* Builds an INSERT statement.
|
|
45
89
|
*/
|
|
46
90
|
function buildInsert$1(table, data) {
|
|
@@ -120,6 +164,7 @@ function mysqlTypeFromField(field) {
|
|
|
120
164
|
const metadata = field.type?.metadata;
|
|
121
165
|
const mysqlTypeOverride = metadata?.get("db.mysql.type");
|
|
122
166
|
if (mysqlTypeOverride) return mysqlTypeOverride;
|
|
167
|
+
if (field.isGeoPoint && !field.encrypted) return "POINT SRID 4326";
|
|
123
168
|
const unsigned = metadata?.has("db.mysql.unsigned") ?? false;
|
|
124
169
|
const precision = metadata?.get("db.column.precision");
|
|
125
170
|
switch (field.designType) {
|
|
@@ -408,6 +453,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
408
453
|
toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
|
|
409
454
|
fromStorage: utcDatetimeToEpochMs
|
|
410
455
|
};
|
|
456
|
+
if (field.isGeoPoint && !field.encrypted) return {
|
|
457
|
+
toStorage: (value) => {
|
|
458
|
+
const point = normalizeGeoPointValue(value);
|
|
459
|
+
return point ? geoPointToMysqlInternal(point) : value;
|
|
460
|
+
},
|
|
461
|
+
fromStorage: (value) => mysqlGeoValueToPoint(value) ?? value
|
|
462
|
+
};
|
|
411
463
|
}
|
|
412
464
|
/**
|
|
413
465
|
* Wraps an async write operation to catch MySQL constraint errors
|
|
@@ -577,8 +629,11 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
577
629
|
this._log(sql, params);
|
|
578
630
|
return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
|
|
579
631
|
}
|
|
580
|
-
async
|
|
632
|
+
async prepareTypeMapper() {
|
|
581
633
|
if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
|
|
634
|
+
}
|
|
635
|
+
async ensureTable() {
|
|
636
|
+
await this.prepareTypeMapper();
|
|
582
637
|
if (this._table instanceof AtscriptDbView) return this._ensureView();
|
|
583
638
|
const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
|
|
584
639
|
engine: this._engine,
|
|
@@ -602,12 +657,12 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
602
657
|
}
|
|
603
658
|
async getExistingColumnsForTable(tableName) {
|
|
604
659
|
const schema = this._schema;
|
|
605
|
-
return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT
|
|
660
|
+
return (await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, SRS_ID
|
|
606
661
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
607
662
|
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
608
663
|
ORDER BY ORDINAL_POSITION`, [tableName, schema])).map((r) => ({
|
|
609
664
|
name: r.COLUMN_NAME,
|
|
610
|
-
type: r.COLUMN_TYPE.toUpperCase()
|
|
665
|
+
type: r.SRS_ID == null ? r.COLUMN_TYPE.toUpperCase() : `${r.COLUMN_TYPE.toUpperCase()} SRID ${r.SRS_ID}`,
|
|
611
666
|
notnull: r.IS_NULLABLE === "NO",
|
|
612
667
|
pk: r.COLUMN_KEY === "PRI",
|
|
613
668
|
dflt_value: normalizeMysqlDefault(r.COLUMN_DEFAULT)
|
|
@@ -628,7 +683,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
628
683
|
let ddl = `ALTER TABLE ${quoteTableName(tableName)} ADD COLUMN ${qi(field.physicalName)} ${sqlType}`;
|
|
629
684
|
if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
|
|
630
685
|
if (field.defaultValue?.kind === "value") ddl += ` DEFAULT ${defaultValueToSqlLiteral(field.designType, field.defaultValue.value)}`;
|
|
631
|
-
else if (!field.optional && !field.isPrimaryKey) ddl += ` DEFAULT ${defaultValueForType(field.designType)}`;
|
|
686
|
+
else if (!field.optional && !field.isPrimaryKey) ddl += field.isGeoPoint && !field.encrypted ? ` DEFAULT (ST_SRID(POINT(0, 0), 4326))` : ` DEFAULT ${defaultValueForType(field.designType)}`;
|
|
632
687
|
if (field.collate) {
|
|
633
688
|
const nativeCollate = field.type?.metadata?.get("db.mysql.collate");
|
|
634
689
|
ddl += ` COLLATE ${nativeCollate ?? collationToMysql(field.collate)}`;
|
|
@@ -639,6 +694,10 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
639
694
|
}
|
|
640
695
|
for (const { field } of diff.typeChanged ?? []) {
|
|
641
696
|
const sqlType = this.typeMapper(field);
|
|
697
|
+
if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
|
|
698
|
+
await this._migrateJsonColumnToPoint(tableName, field);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
642
701
|
let ddl = `ALTER TABLE ${quoteTableName(tableName)} MODIFY COLUMN ${qi(field.physicalName)} ${sqlType}`;
|
|
643
702
|
if (!field.optional && !field.isPrimaryKey) ddl += " NOT NULL";
|
|
644
703
|
this._log(ddl);
|
|
@@ -725,6 +784,21 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
725
784
|
this._log(ddl);
|
|
726
785
|
await this._exec().exec(ddl);
|
|
727
786
|
}
|
|
787
|
+
async dropIndexesForColumns(columns) {
|
|
788
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
789
|
+
const rows = await this._exec().all(`SELECT DISTINCT INDEX_NAME AS name FROM INFORMATION_SCHEMA.STATISTICS
|
|
790
|
+
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
791
|
+
AND INDEX_NAME LIKE 'atscript\\_\\_%' AND COLUMN_NAME IN (${placeholders})`, [
|
|
792
|
+
this._table.tableName,
|
|
793
|
+
this._schema,
|
|
794
|
+
...columns
|
|
795
|
+
]);
|
|
796
|
+
for (const row of rows) {
|
|
797
|
+
const sql = `DROP INDEX ${qi(row.name)} ON ${quoteTableName(this.resolveTableName())}`;
|
|
798
|
+
this._log(sql);
|
|
799
|
+
await this._exec().exec(sql);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
728
802
|
async dropTableByName(tableName) {
|
|
729
803
|
const ddl = `DROP TABLE IF EXISTS ${quoteTableName(tableName)}`;
|
|
730
804
|
this._log(ddl);
|
|
@@ -761,9 +835,28 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
761
835
|
const schema = this._schema;
|
|
762
836
|
const stringFields = new Set(this._table.fieldDescriptors.filter((f) => f.designType === "string").map((f) => f.physicalName));
|
|
763
837
|
await this.syncIndexesWithDiff({
|
|
764
|
-
listExisting: async () =>
|
|
765
|
-
|
|
838
|
+
listExisting: async () => {
|
|
839
|
+
return (await this._exec().all(`SELECT INDEX_NAME AS name,
|
|
840
|
+
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns
|
|
841
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
842
|
+
WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
|
|
843
|
+
GROUP BY INDEX_NAME`, [tableName, schema])).map((r) => ({
|
|
844
|
+
name: r.name,
|
|
845
|
+
columns: r.columns ? r.columns.split(",") : void 0
|
|
846
|
+
}));
|
|
847
|
+
},
|
|
766
848
|
createIndex: async (index) => {
|
|
849
|
+
if (index.type === "geo") {
|
|
850
|
+
const fieldMeta = this._table.fieldDescriptors.find((f) => f.physicalName === index.fields[0]?.name);
|
|
851
|
+
if (fieldMeta?.optional) {
|
|
852
|
+
this.logger.warn(`[mysql] geo index "${index.name}" skipped — SPATIAL indexes require a NOT NULL column; make "${fieldMeta.path}" required to index it`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const sql = `CREATE SPATIAL INDEX ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${qi(index.fields[0].name)})`;
|
|
856
|
+
this._log(sql);
|
|
857
|
+
await this._exec().exec(sql);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
767
860
|
const unique = index.type === "unique" ? "UNIQUE " : "";
|
|
768
861
|
const fulltext = index.type === "fulltext" ? "FULLTEXT " : "";
|
|
769
862
|
const isFulltext = index.type === "fulltext";
|
|
@@ -778,10 +871,6 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
778
871
|
const sql = `DROP INDEX ${qi(name)} ON ${quoteTableName(this.resolveTableName())}`;
|
|
779
872
|
this._log(sql);
|
|
780
873
|
await this._exec().exec(sql);
|
|
781
|
-
},
|
|
782
|
-
warnUnsupportedTypes: {
|
|
783
|
-
adapter: "mysql",
|
|
784
|
-
types: ["geo"]
|
|
785
874
|
}
|
|
786
875
|
});
|
|
787
876
|
}
|
|
@@ -1008,6 +1097,65 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
|
|
|
1008
1097
|
if (queryThreshold !== void 0) return queryThreshold;
|
|
1009
1098
|
return this._vectorThresholds.get(indexName);
|
|
1010
1099
|
}
|
|
1100
|
+
/** Native POINT SRID 4326 + ST_Distance_Sphere — available on MySQL 8.0+. */
|
|
1101
|
+
isGeoSearchable() {
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
async geoSearch(point, query, indexName) {
|
|
1105
|
+
const { sql, params } = this._buildGeoSearchSelect(this._prepareGeoSearch(point, query, indexName));
|
|
1106
|
+
this._log(sql, params);
|
|
1107
|
+
return (await this._exec().all(sql, params)).map((row) => renameGeoDistance(row));
|
|
1108
|
+
}
|
|
1109
|
+
async geoSearchWithCount(point, query, indexName) {
|
|
1110
|
+
const ctx = this._prepareGeoSearch(point, query, indexName);
|
|
1111
|
+
const { sql, params } = this._buildGeoSearchSelect(ctx);
|
|
1112
|
+
const countFrag = buildGeoSearchCount(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window);
|
|
1113
|
+
this._log(sql, params);
|
|
1114
|
+
this._log(countFrag.sql, countFrag.params);
|
|
1115
|
+
const [rows, countRow] = await Promise.all([this._exec().all(sql, params), this._exec().get(countFrag.sql, countFrag.params)]);
|
|
1116
|
+
return {
|
|
1117
|
+
data: rows.map((row) => renameGeoDistance(row)),
|
|
1118
|
+
count: Number(countRow?.cnt ?? 0)
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/** Resolves the shared parts of a geo search once (column, filter, distance, window). */
|
|
1122
|
+
_prepareGeoSearch(point, query, indexName) {
|
|
1123
|
+
const column = this._resolveGeoColumn(indexName);
|
|
1124
|
+
const controls = query.controls ?? {};
|
|
1125
|
+
return {
|
|
1126
|
+
tableName: this.resolveTableName(),
|
|
1127
|
+
where: buildWhere(query.filter),
|
|
1128
|
+
dist: mysqlGeoDistanceExpr(qi(column), point),
|
|
1129
|
+
window: geoWindowFromControls(controls),
|
|
1130
|
+
controls
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
_buildGeoSearchSelect(ctx) {
|
|
1134
|
+
return buildGeoSearchSelect(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, {
|
|
1135
|
+
$limit: ctx.controls.$limit,
|
|
1136
|
+
$skip: ctx.controls.$skip
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
|
|
1141
|
+
* temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
|
|
1142
|
+
*/
|
|
1143
|
+
async _migrateJsonColumnToPoint(tableName, field) {
|
|
1144
|
+
const col = qi(field.physicalName);
|
|
1145
|
+
const tmp = qi(`${field.physicalName}__geo_mig`);
|
|
1146
|
+
const quotedTable = quoteTableName(tableName);
|
|
1147
|
+
const steps = [
|
|
1148
|
+
`ALTER TABLE ${quotedTable} ADD COLUMN ${tmp} POINT SRID 4326 NULL`,
|
|
1149
|
+
`UPDATE ${quotedTable} SET ${tmp} = ST_SRID(POINT(CAST(${col}->>'$[0]' AS DOUBLE), CAST(${col}->>'$[1]' AS DOUBLE)), 4326) WHERE ${col} IS NOT NULL`,
|
|
1150
|
+
`ALTER TABLE ${quotedTable} DROP COLUMN ${col}`,
|
|
1151
|
+
`ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`,
|
|
1152
|
+
...field.optional || field.isPrimaryKey ? [] : [`ALTER TABLE ${quotedTable} MODIFY COLUMN ${col} POINT SRID 4326 NOT NULL`]
|
|
1153
|
+
];
|
|
1154
|
+
for (const ddl of steps) {
|
|
1155
|
+
this._log(ddl);
|
|
1156
|
+
await this._exec().exec(ddl);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1011
1159
|
};
|
|
1012
1160
|
/**
|
|
1013
1161
|
* Normalizes MySQL INFORMATION_SCHEMA.COLUMNS.COLUMN_DEFAULT values
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-mysql",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.107",
|
|
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.
|
|
44
|
+
"@atscript/core": "^0.1.77",
|
|
45
|
+
"@atscript/typescript": "^0.1.77",
|
|
46
46
|
"@uniqu/core": "^0.1.6",
|
|
47
47
|
"mysql2": "^3.11.0",
|
|
48
|
-
"unplugin-atscript": "^0.1.
|
|
48
|
+
"unplugin-atscript": "^0.1.77"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@atscript/core": "^0.1.
|
|
52
|
-
"@atscript/typescript": "^0.1.
|
|
51
|
+
"@atscript/core": "^0.1.77",
|
|
52
|
+
"@atscript/typescript": "^0.1.77",
|
|
53
53
|
"@uniqu/core": "^0.1.6",
|
|
54
54
|
"mysql2": ">=3.0.0",
|
|
55
|
-
"@atscript/db": "^0.1.
|
|
56
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
55
|
+
"@atscript/db": "^0.1.107",
|
|
56
|
+
"@atscript/db-sql-tools": "^0.1.107"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"mysql2": {
|