@atscript/db-mysql 0.1.105 → 0.1.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -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 ensureTable() {
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 () => this._exec().all(`SELECT DISTINCT INDEX_NAME as name FROM INFORMATION_SCHEMA.STATISTICS
766
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [tableName, schema]),
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 ensureTable() {
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 () => this._exec().all(`SELECT DISTINCT INDEX_NAME as name FROM INFORMATION_SCHEMA.STATISTICS
765
- WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())`, [tableName, schema]),
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.105",
3
+ "version": "0.1.106",
4
4
  "description": "MySQL adapter for @atscript/db with mysql2 driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -52,8 +52,8 @@
52
52
  "@atscript/typescript": "^0.1.76",
53
53
  "@uniqu/core": "^0.1.6",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.105",
56
- "@atscript/db-sql-tools": "^0.1.105"
55
+ "@atscript/db": "^0.1.106",
56
+ "@atscript/db-sql-tools": "^0.1.106"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {