diff --git a/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java b/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java index 96af67a6f6..8c171d4780 100644 --- a/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java +++ b/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java @@ -81,6 +81,85 @@ public final class EwkbWktDecoder { return value.toString(); } + /** Decode a geometry value while retaining SRID outside the display WKT. */ + public static SpatialValue decodeSpatial(Object value) { + if (value == null) { + return new SpatialValue(null, null); + } + if (value instanceof byte[]) { + byte[] raw = (byte[]) value; + String wkt = decodeBytes(raw); + return new SpatialValue(wkt, wkt.startsWith("0x") ? null : topLevelSrid(raw)); + } + String text; + if (value instanceof String) { + text = (String) value; + } else { + String pgValue = tryReflectiveGetValue(value); + text = pgValue == null ? value.toString() : pgValue; + } + + SpatialValue ewkt = decodeEwkt(text); + if (ewkt != null) { + return ewkt; + } + byte[] raw = tryParseHex(text); + if (raw != null) { + String wkt = decodeBytes(raw); + return new SpatialValue(wkt, wkt.startsWith("0x") ? null : topLevelSrid(raw)); + } + return new SpatialValue(text, null); + } + + private static SpatialValue decodeEwkt(String value) { + if (value == null || value.length() < 7 || !value.regionMatches(true, 0, "SRID=", 0, 5)) { + return null; + } + int separator = value.indexOf(';', 5); + if (separator < 0) { + return null; + } + try { + int srid = Integer.parseInt(value.substring(5, separator)); + return new SpatialValue(value.substring(separator + 1), srid <= 0 ? null : srid); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static Integer topLevelSrid(byte[] raw) { + if (raw == null || raw.length < 9) { + return null; + } + boolean little; + if (raw[0] == 0) { + little = false; + } else if (raw[0] == 1) { + little = true; + } else { + return null; + } + long typeWord = readU32(raw, 1, little); + if ((typeWord & 0x2000_0000L) == 0) { + return null; + } + long srid = readU32(raw, 5, little); + return srid == 0 || srid > Integer.MAX_VALUE ? null : (int) srid; + } + + private static long readU32(byte[] raw, int offset, boolean little) { + if (little) { + return ((long) raw[offset] & 0xFF) + | (((long) raw[offset + 1] & 0xFF) << 8) + | (((long) raw[offset + 2] & 0xFF) << 16) + | (((long) raw[offset + 3] & 0xFF) << 24); + } + return (((long) raw[offset] & 0xFF) << 24) + | (((long) raw[offset + 1] & 0xFF) << 16) + | (((long) raw[offset + 2] & 0xFF) << 8) + | ((long) raw[offset + 3] & 0xFF); + } + /** Decode a raw EWKB byte sequence; returns hex {@code 0x..} on failure. */ static String decodeBytes(byte[] raw) { if (raw == null) { diff --git a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java index 7c74578264..bba38fe52d 100644 --- a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java @@ -84,9 +84,9 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent { if (isPostgisGeometryTypeName(columnTypeName)) { Object raw = rs.getObject(index); if (rs.wasNull() || raw == null) { - return null; + return new SpatialValue(null, null); } - return EwkbWktDecoder.decode(raw); + return EwkbWktDecoder.decodeSpatial(raw); } return resultValue(rs, index, sqlType, columnTypeName); }; diff --git a/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java index 051cb0a88c..ad1c4315fc 100644 --- a/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java +++ b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java @@ -7,6 +7,8 @@ import java.util.Objects; public final class QueryPageResult { private List columns; private List column_types; + private List spatial_columns; + private List> spatial_values; private List> rows; private long affected_rows; private long execution_time_ms; @@ -56,7 +58,10 @@ public final class QueryPageResult { ) { this.columns = columns == null ? Collections.emptyList() : columns; this.column_types = column_types == null ? Collections.emptyList() : column_types; - this.rows = QueryResult.normalizeRows(rows); + QueryResult.NormalizedRows normalized = QueryResult.normalizeRowsWithSpatial(rows); + this.rows = normalized.rows; + this.spatial_columns = normalized.spatialColumns.isEmpty() ? null : normalized.spatialColumns; + this.spatial_values = normalized.spatialValues.isEmpty() ? null : normalized.spatialValues; this.affected_rows = affected_rows; this.execution_time_ms = execution_time_ms; this.truncated = truncated; @@ -76,6 +81,14 @@ public final class QueryPageResult { return rows; } + public List getSpatial_columns() { + return spatial_columns == null ? Collections.emptyList() : spatial_columns; + } + + public List> getSpatial_values() { + return spatial_values == null ? Collections.emptyList() : spatial_values; + } + public long getAffected_rows() { return affected_rows; } @@ -105,7 +118,18 @@ public final class QueryPageResult { } public void setRows(List> rows) { - this.rows = rows; + QueryResult.NormalizedRows normalized = QueryResult.normalizeRowsWithSpatial(rows); + this.rows = normalized.rows; + this.spatial_columns = normalized.spatialColumns.isEmpty() ? null : normalized.spatialColumns; + this.spatial_values = normalized.spatialValues.isEmpty() ? null : normalized.spatialValues; + } + + public void setSpatial_columns(List spatial_columns) { + this.spatial_columns = spatial_columns == null || spatial_columns.isEmpty() ? null : spatial_columns; + } + + public void setSpatial_values(List> spatial_values) { + this.spatial_values = spatial_values == null || spatial_values.isEmpty() ? null : spatial_values; } public void setAffected_rows(long affected_rows) { @@ -139,19 +163,23 @@ public final class QueryPageResult { && has_more == that.has_more && Objects.equals(columns, that.columns) && Objects.equals(column_types, that.column_types) + && Objects.equals(spatial_columns, that.spatial_columns) + && Objects.equals(spatial_values, that.spatial_values) && Objects.equals(rows, that.rows) && Objects.equals(session_id, that.session_id); } @Override public int hashCode() { - return Objects.hash(columns, column_types, rows, affected_rows, execution_time_ms, truncated, session_id, has_more); + return Objects.hash(columns, column_types, spatial_columns, spatial_values, rows, affected_rows, execution_time_ms, truncated, session_id, has_more); } @Override public String toString() { return "QueryPageResult(columns=" + columns + ", column_types=" + column_types + + ", spatial_columns=" + spatial_columns + + ", spatial_values=" + spatial_values + ", rows=" + rows + ", affected_rows=" + affected_rows + ", execution_time_ms=" + execution_time_ms diff --git a/agents/common/src/main/java/com/dbx/agent/QueryResult.java b/agents/common/src/main/java/com/dbx/agent/QueryResult.java index c38881aa77..8b61b29f66 100644 --- a/agents/common/src/main/java/com/dbx/agent/QueryResult.java +++ b/agents/common/src/main/java/com/dbx/agent/QueryResult.java @@ -3,11 +3,17 @@ package com.dbx.agent; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; public final class QueryResult { private List columns; private List column_types; + private List spatial_columns; + private List> spatial_values; private List> rows; private long affected_rows; private long execution_time_ms; @@ -41,7 +47,10 @@ public final class QueryResult { ) { this.columns = columns == null ? Collections.emptyList() : columns; this.column_types = column_types == null ? Collections.emptyList() : column_types; - this.rows = normalizeRows(rows); + NormalizedRows normalized = normalizeRowsWithSpatial(rows); + this.rows = normalized.rows; + this.spatial_columns = normalized.spatialColumns.isEmpty() ? null : normalized.spatialColumns; + this.spatial_values = normalized.spatialValues.isEmpty() ? null : normalized.spatialValues; this.affected_rows = affected_rows; this.execution_time_ms = execution_time_ms; this.truncated = truncated; @@ -59,6 +68,14 @@ public final class QueryResult { return rows; } + public List getSpatial_columns() { + return spatial_columns == null ? Collections.emptyList() : spatial_columns; + } + + public List> getSpatial_values() { + return spatial_values == null ? Collections.emptyList() : spatial_values; + } + public long getAffected_rows() { return affected_rows; } @@ -80,7 +97,18 @@ public final class QueryResult { } public void setRows(List> rows) { - this.rows = rows; + NormalizedRows normalized = normalizeRowsWithSpatial(rows); + this.rows = normalized.rows; + this.spatial_columns = normalized.spatialColumns.isEmpty() ? null : normalized.spatialColumns; + this.spatial_values = normalized.spatialValues.isEmpty() ? null : normalized.spatialValues; + } + + public void setSpatial_columns(List spatial_columns) { + this.spatial_columns = spatial_columns == null || spatial_columns.isEmpty() ? null : spatial_columns; + } + + public void setSpatial_values(List> spatial_values) { + this.spatial_values = spatial_values == null || spatial_values.isEmpty() ? null : spatial_values; } public void setAffected_rows(long affected_rows) { @@ -96,14 +124,63 @@ public final class QueryResult { } static List> normalizeRows(List> input) { + return normalizeRowsWithSpatial(input).rows; + } + + static NormalizedRows normalizeRowsWithSpatial(List> input) { if (input == null) { - return Collections.emptyList(); + return new NormalizedRows(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); } List> normalized = new ArrayList<>(input.size()); - for (List row : input) { - normalized.add(row == null ? Collections.emptyList() : new ArrayList(row)); + List> spatialValues = new ArrayList<>(input.size()); + // column_index -> first non-null SRID seen (TreeMap keeps output ordered) + Map sridByColumn = new TreeMap<>(); + Set spatialColumnIndexes = new TreeSet<>(); + for (List inputRow : input) { + List row = inputRow == null ? Collections.emptyList() : inputRow; + List values = new ArrayList<>(row.size()); + List rowSrids = new ArrayList<>(row.size()); + for (int columnIndex = 0; columnIndex < row.size(); columnIndex++) { + Object value = row.get(columnIndex); + if (value instanceof SpatialValue) { + SpatialValue spatialValue = (SpatialValue) value; + spatialColumnIndexes.add(columnIndex); + Integer srid = spatialValue.getSrid(); + // SRID 0 means unknown, mirroring the Rust drivers. + rowSrids.add(srid == null || srid == 0 ? null : srid); + if (srid != null && srid != 0 && !sridByColumn.containsKey(columnIndex)) { + sridByColumn.put(columnIndex, srid); + } + values.add(spatialValue.getWkt()); + } else { + rowSrids.add(null); + values.add(value); + } + } + normalized.add(values); + spatialValues.add(rowSrids); + } + List spatialColumns = new ArrayList<>(spatialColumnIndexes.size()); + for (Integer columnIndex : spatialColumnIndexes) { + spatialColumns.add(new SpatialColumn(columnIndex, sridByColumn.get(columnIndex))); + } + return new NormalizedRows( + normalized, + spatialColumns, + spatialColumns.isEmpty() ? Collections.emptyList() : spatialValues + ); + } + + static final class NormalizedRows { + final List> rows; + final List spatialColumns; + final List> spatialValues; + + NormalizedRows(List> rows, List spatialColumns, List> spatialValues) { + this.rows = rows; + this.spatialColumns = spatialColumns; + this.spatialValues = spatialValues; } - return normalized; } @Override @@ -116,18 +193,22 @@ public final class QueryResult { && truncated == that.truncated && Objects.equals(columns, that.columns) && Objects.equals(column_types, that.column_types) + && Objects.equals(spatial_columns, that.spatial_columns) + && Objects.equals(spatial_values, that.spatial_values) && Objects.equals(rows, that.rows); } @Override public int hashCode() { - return Objects.hash(columns, column_types, rows, affected_rows, execution_time_ms, truncated); + return Objects.hash(columns, column_types, spatial_columns, spatial_values, rows, affected_rows, execution_time_ms, truncated); } @Override public String toString() { return "QueryResult(columns=" + columns + ", column_types=" + column_types + + ", spatial_columns=" + spatial_columns + + ", spatial_values=" + spatial_values + ", rows=" + rows + ", affected_rows=" + affected_rows + ", execution_time_ms=" + execution_time_ms diff --git a/agents/common/src/main/java/com/dbx/agent/SpatialColumn.java b/agents/common/src/main/java/com/dbx/agent/SpatialColumn.java new file mode 100644 index 0000000000..dfdebe9b53 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/SpatialColumn.java @@ -0,0 +1,39 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class SpatialColumn { + private final int column_index; + private final Integer srid; + + public SpatialColumn(int columnIndex, Integer srid) { + this.column_index = columnIndex; + this.srid = srid; + } + + public int getColumn_index() { + return column_index; + } + + public Integer getSrid() { + return srid; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof SpatialColumn)) return false; + SpatialColumn that = (SpatialColumn) other; + return column_index == that.column_index && Objects.equals(srid, that.srid); + } + + @Override + public int hashCode() { + return Objects.hash(column_index, srid); + } + + @Override + public String toString() { + return "SpatialColumn(column_index=" + column_index + ", srid=" + srid + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/SpatialValue.java b/agents/common/src/main/java/com/dbx/agent/SpatialValue.java new file mode 100644 index 0000000000..a9ebf3969c --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/SpatialValue.java @@ -0,0 +1,20 @@ +package com.dbx.agent; + +/** Internal JDBC result value carrying display WKT and its optional SRID. */ +public final class SpatialValue { + private final String wkt; + private final Integer srid; + + public SpatialValue(String wkt, Integer srid) { + this.wkt = wkt; + this.srid = srid; + } + + public String getWkt() { + return wkt; + } + + public Integer getSrid() { + return srid; + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java b/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java index 3be543161d..1d460d5616 100644 --- a/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java +++ b/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java @@ -82,6 +82,20 @@ class EwkbWktDecoderTest { assertEquals(ewkt, EwkbWktDecoder.decode(ewkt)); } + @Test + void spatialDecodeSeparatesSridFromVisibleWkt() { + SpatialValue value = EwkbWktDecoder.decodeSpatial("SRID=3857;POINT(1 2)"); + assertEquals("POINT(1 2)", value.getWkt()); + assertEquals(3857, value.getSrid()); + } + + @Test + void spatialDecodeRetainsEwkbSrid() { + SpatialValue value = EwkbWktDecoder.decodeSpatial(hex("0101000020E6100000C520B07268195D404E62105839F44340")); + assertEquals("POINT(116.397 39.908)", value.getWkt()); + assertEquals(4326, value.getSrid()); + } + @Test void truncatedBytesFallBackToHex() { // First few bytes of a valid point but truncated mid-coordinate. diff --git a/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java index 7779b6fb86..5984bffd40 100644 --- a/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java +++ b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java @@ -184,6 +184,54 @@ class PostgresLikeAgentTest { assertEquals(1, result.getRows().size()); assertEquals(1, result.getRows().get(0).get(0)); assertEquals("POINT(116.397 39.908)", result.getRows().get(0).get(1)); + assertEquals(1, result.getSpatial_columns().size()); + assertEquals(1, result.getSpatial_columns().get(0).getColumn_index()); + assertEquals(4326, result.getSpatial_columns().get(0).getSrid()); + } + + @Test + void normalizeCollapsesFirstNonNullSridPerColumn() { + List firstRow = java.util.Arrays.asList(new SpatialValue("POINT(1 2)", null), "keep"); + List secondRow = java.util.Arrays.asList(new SpatialValue("POINT(3 4)", 4326), "keep"); + List thirdRow = java.util.Arrays.asList(new SpatialValue("POINT(5 6)", 3857), "keep"); + List> rows = java.util.Arrays.asList(firstRow, secondRow, thirdRow); + + QueryResult result = new QueryResult( + java.util.Arrays.asList("geom", "note"), + java.util.Arrays.asList("geometry", "text"), + rows, 0L, 0L, false); + + assertEquals( + java.util.Collections.singletonList(new SpatialColumn(0, 4326)), + result.getSpatial_columns()); + assertEquals("POINT(1 2)", result.getRows().get(0).get(0)); + // Per-cell SRIDs are preserved for every row instead of collapsing: + // unknown, then 4326, then 3857 in the same column. + assertEquals( + java.util.Arrays.asList( + java.util.Arrays.asList(null, null), + java.util.Arrays.asList(4326, null), + java.util.Arrays.asList(3857, null)), + result.getSpatial_values()); + } + + @Test + void normalizeOmitsSpatialValuesForNonSpatialRows() { + List> rows = java.util.Collections.singletonList(java.util.Arrays.asList(1, "plain text")); + + QueryResult result = new QueryResult( + java.util.Arrays.asList("id", "note"), + java.util.Arrays.asList("int4", "text"), + rows, 0L, 0L, false); + QueryPageResult page = new QueryPageResult( + java.util.Arrays.asList("id", "note"), + java.util.Arrays.asList("int4", "text"), + rows, 0L, 0L, false, "session", false); + + assertTrue(result.getSpatial_columns().isEmpty()); + assertTrue(result.getSpatial_values().isEmpty()); + assertTrue(page.getSpatial_columns().isEmpty()); + assertTrue(page.getSpatial_values().isEmpty()); } private static final class TestPostgresLikeAgent extends PostgresLikeAgent { @@ -224,7 +272,7 @@ class PostgresLikeAgentTest { if (rs.wasNull() || raw == null) { return null; } - return EwkbWktDecoder.decode(raw); + return EwkbWktDecoder.decodeSpatial(raw); } if (sqlType == Types.INTEGER) { return rs.getInt(index); diff --git a/apps/desktop/src/components/grid/LayerPreviewDialog.vue b/apps/desktop/src/components/grid/LayerPreviewDialog.vue index 4ea5c780e1..3481317c83 100644 --- a/apps/desktop/src/components/grid/LayerPreviewDialog.vue +++ b/apps/desktop/src/components/grid/LayerPreviewDialog.vue @@ -4,6 +4,10 @@ import { useI18n } from "vue-i18n"; import { Camera, Loader2, Map, Maximize2, Minimize2, X } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { renderGeometryFeaturesIndependently } from "@/lib/dataGrid/geometryLayerPreview"; +import { prepareCoordsToLatLng, type CoordinateToLatLng } from "@/lib/dataGrid/geometryProjection"; +import { getSpatialReference, SHORTLIST_SRIDS } from "@/lib/dataGrid/spatialReferenceCatalog"; +import type { GeoJsonGeometry } from "@/lib/dataGrid/geometryPreview"; import "leaflet/dist/leaflet.css"; import type L from "leaflet"; @@ -20,7 +24,23 @@ const emit = defineEmits<{ const { t } = useI18n(); const mapContainer = ref(); const mapError = ref(""); +const isLoadingMap = ref(false); const isExporting = ref(false); +const skippedFeatureCount = ref(0); + +// ── Effective SRID (detected default + manual override) ───────────────────── +const detectedSrid = ref(null); +const selectedSrid = ref(4326); +const customSridInput = ref(""); +const showCustomSrid = ref(false); + +const sridOptions = computed(() => SHORTLIST_SRIDS.map((srid) => ({ srid, label: getSpatialReference(srid)?.label ?? `EPSG:${srid}` }))); +const effectiveSrid = computed(() => selectedSrid.value); +const spatialSourceLabel = computed(() => { + if (selectedSrid.value !== (detectedSrid.value ?? 4326)) return t("grid.layerPreviewCrsManual"); + if (detectedSrid.value != null) return t("grid.layerPreviewCrsDetected"); + return t("grid.layerPreviewCrsAssumed"); +}); // ── Resize / maximise ────────────────────────────────────────────────────── const customSize = ref<{ w: number; h: number } | null>(null); @@ -87,18 +107,34 @@ function onResizePointerUp() { // ── Leaflet ─────────────────────────────────────────────────────────────── let _L: typeof L | null = null; let map: L.Map | null = null; -let geoLayer: L.GeoJSON | null = null; +let geoLayer: L.FeatureGroup | null = null; let labelLayer: L.LayerGroup | null = null; let tileLayer: L.TileLayer | null = null; let mapResizeObserver: ResizeObserver | null = null; let _geojsonData: any = null; +let mapGeneration = 0; +let coordinateConverters = new globalThis.Map(); + +interface PreviewFeature { + type: "Feature"; + geometry: GeoJsonGeometry | null; + properties?: Record | null; +} + +/** + * SRID a feature should be projected with: a manual CRS override applies to + * every feature; otherwise each feature uses its own per-cell SRID, falling + * back to the detected/selected one when unknown. + */ +function featureSrid(feature: PreviewFeature): number { + const manualSrid = selectedSrid.value !== (detectedSrid.value ?? null) ? selectedSrid.value : null; + return manualSrid ?? (typeof feature.properties?._srid === "number" ? feature.properties._srid : effectiveSrid.value); +} async function loadLeaflet(): Promise { if (!_L) { - console.log("[LayerPreview] dynamic import leaflet…"); const mod = await import("leaflet"); _L = mod.default as unknown as typeof L; - console.log("[LayerPreview] leaflet loaded, L.version =", (_L as any).version); } return _L; } @@ -184,7 +220,7 @@ function updateLabels() { for (const f of features) { const value = f.properties?.[prop]; if (value == null) continue; - const coords = getLabelCoords(f.geometry); + const coords = getLabelCoords(f.geometry, featureSrid(f)); if (!coords) continue; const icon = L.divIcon({ className: "layer-preview-label", @@ -196,22 +232,44 @@ function updateLabels() { } } -function getLabelCoords(geom: any): [number, number] | null { - if (!geom) return null; - if (geom.type === "Point") return [geom.coordinates[1], geom.coordinates[0]]; - if (geom.type === "MultiPoint") return [geom.coordinates[0][1], geom.coordinates[0][0]]; - // For lines/polygons, label at the centroid (midpoint) - const coords = geom.coordinates?.flat(Infinity).filter((n: any) => typeof n === "number"); - if (!coords || coords.length < 2) return null; - // Collect all [lng, lat] pairs - const pts: [number, number][] = []; - const flat = geom.coordinates?.flat(Infinity) ?? []; - for (let i = 0; i + 1 < flat.length; i += 2) { - if (typeof flat[i] === "number" && typeof flat[i + 1] === "number") pts.push([flat[i], flat[i + 1]]); +function collectCoordinatePairs(geometry: any): [number, number][] { + const pairs: [number, number][] = []; + const visit = (value: any) => { + if (!Array.isArray(value)) return; + if (value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") { + pairs.push([value[0], value[1]]); + return; + } + value.forEach(visit); + }; + if (geometry?.type === "GeometryCollection") { + for (const child of geometry.geometries ?? []) pairs.push(...collectCoordinatePairs(child)); + } else { + visit(geometry?.coordinates); } + return pairs; +} + +function geometryCoordinatesAreValid(geometry: any, srid: number, converter: (coord: [number, number]) => L.LatLng): boolean { + const coordinates = collectCoordinatePairs(geometry); + if (coordinates.length === 0) return false; + return coordinates.every(([x, y]) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) return false; + if (srid === 4326 && (x < -180 || x > 180 || y < -90 || y > 90)) return false; + if (srid === 3857 && (Math.abs(x) > 20037508.342789244 || Math.abs(y) > 20037508.342789244)) return false; + const latlng = converter([x, y]); + return Number.isFinite(latlng.lat) && Number.isFinite(latlng.lng) && latlng.lat >= -90 && latlng.lat <= 90 && latlng.lng >= -180 && latlng.lng <= 180; + }); +} + +function getLabelCoords(geom: any, srid: number): [number, number] | null { + const converter = coordinateConverters.get(srid) ?? null; + if (!geom || !converter || !geometryCoordinatesAreValid(geom, srid, converter)) return null; + const pts = collectCoordinatePairs(geom); if (pts.length === 0) return null; - const avgLng = pts.reduce((s, p) => s + p[0], 0) / pts.length; - const avgLat = pts.reduce((s, p) => s + p[1], 0) / pts.length; + const projected = pts.map((coord) => converter(coord)); + const avgLng = projected.reduce((sum, point) => sum + point.lng, 0) / projected.length; + const avgLat = projected.reduce((sum, point) => sum + point.lat, 0) / projected.length; return [avgLat, avgLng]; } @@ -249,17 +307,13 @@ async function initMap() { return; } cleanupMap(); + const generation = mapGeneration; mapError.value = ""; - - // Remove the "Loading map…" placeholder so Leaflet can take over. - const placeholder = container.querySelector("[data-map-placeholder]"); - if (placeholder) placeholder.remove(); + isLoadingMap.value = true; try { - console.log("[LayerPreview] container offset:", container.offsetWidth, "x", container.offsetHeight); const L = await loadLeaflet(); - const rect = container.getBoundingClientRect(); - console.log("[LayerPreview] container getBoundingClientRect:", rect.width, "x", rect.height); + if (generation !== mapGeneration || !props.open) return; map = L.map(container, { zoomControl: true, @@ -267,7 +321,6 @@ async function initMap() { center: [35, 110], zoom: 4, }); - console.log("[LayerPreview] L.map created"); map.invalidateSize(); tileLayer = L.tileLayer(selectedBasemap.value.url, { @@ -277,9 +330,9 @@ async function initMap() { }).addTo(map); // Sync ID ref selectedBasemapId.value = selectedBasemap.value.id; - console.log("[LayerPreview] tileLayer added"); - addGeoJsonToMap(); - parseLabelProperties(_geojsonData); + await addGeoJsonToMap(generation); + if (generation !== mapGeneration || !map) return; + if (_geojsonData) parseLabelProperties(_geojsonData); mapResizeObserver = new ResizeObserver(() => { map?.invalidateSize(); @@ -289,20 +342,24 @@ async function initMap() { map?.invalidateSize(); }, 500); } catch (err) { - console.error("[LayerPreview] initMap error:", err); - mapError.value = String(err); + if (generation === mapGeneration) { + console.error("[LayerPreview] initMap error:", err); + mapError.value = String(err); + } + } finally { + if (generation === mapGeneration) isLoadingMap.value = false; } } -function addGeoJsonToMap() { +async function addGeoJsonToMap(generation: number) { if (!map || geoLayer) return; try { _geojsonData = JSON.parse(props.geojson); } catch { console.warn("[LayerPreview] invalid geojson JSON"); + mapError.value = t("grid.layerPreviewNoGeometryData"); return; } - console.log("[LayerPreview] GeoJSON type:", _geojsonData.type, "features:", _geojsonData.features?.length); let data = _geojsonData; if (data.type === "Feature") data = { type: "FeatureCollection", features: [data] }; @@ -313,33 +370,120 @@ function addGeoJsonToMap() { }; if (!data.features?.length) { console.warn("[LayerPreview] no features"); + mapError.value = t("grid.layerPreviewNoGeometryData"); return; } - geoLayer = _L! - .geoJSON(data, { - style: { - color: "#ff6600", - weight: 3, - opacity: 0.95, - fillColor: "#ff6600", - fillOpacity: 0.25, - }, - pointToLayer: pointToCircleFeature, - onEachFeature: (feature, layer) => { - layer.bindPopup(popupContent(feature)); - }, - }) - .addTo(map); + detectedSrid.value = typeof data.detectedSrid === "number" ? data.detectedSrid : null; + selectedSrid.value = detectedSrid.value ?? 4326; + _geojsonData = data; - const bounds = geoLayer.getBounds(); - console.log("[LayerPreview] geoLayer added, bounds:", bounds.toBBoxString(), "valid:", bounds.isValid()); - if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [30, 30], maxZoom: 18 }); - console.log("[LayerPreview] fitBounds done, zoom:", map.getZoom()); + await renderLayer(generation); +} + +async function renderLayer(generation: number) { + if (!map || !_geojsonData || !_L) return; + skippedFeatureCount.value = 0; + const leaflet = _L; + const data = _geojsonData; + + try { + const features = (data.features ?? []) as PreviewFeature[]; + const sridsNeeded = new Set(); + for (const feature of features) { + const srid = featureSrid(feature); + if (Number.isInteger(srid) && srid > 0) sridsNeeded.add(srid); + } + const converters = new globalThis.Map(); + for (const srid of sridsNeeded) { + const converter = await prepareCoordsToLatLng(srid, leaflet); + if (converter) converters.set(srid, converter); + } + if (generation !== mapGeneration || !map) return; + if (converters.size === 0) { + mapError.value = t("grid.layerPreviewUnsupportedCrs", { srid: effectiveSrid.value }); + return; + } + coordinateConverters = converters; + + geoLayer = leaflet.featureGroup().addTo(map); + const summary = renderGeometryFeaturesIndependently( + features, + (feature) => { + const geometry = feature.geometry; + const srid = featureSrid(feature); + const converter = converters.get(srid) ?? null; + if (!geometry || !converter || !geometryCoordinatesAreValid(geometry, srid, converter)) { + return null; + } + const layer = leaflet.geoJSON(feature as unknown as Parameters<(typeof L)["geoJSON"]>[0], { + coordsToLatLng: (coords) => converter([coords[0], coords[1]]), + style: { + color: "#ff6600", + weight: 3, + opacity: 0.95, + fillColor: "#ff6600", + fillOpacity: 0.25, + }, + pointToLayer: pointToCircleFeature, + onEachFeature: (sourceFeature, layer) => layer.bindPopup(popupContent(sourceFeature)), + }); + return { layer, srid }; + }, + (layer) => layer.addTo(geoLayer!), + (error) => console.warn("[LayerPreview] skipped invalid feature", error), + ); + skippedFeatureCount.value = summary.skipped; + if (summary.rendered === 0) { + mapError.value = t("grid.layerPreviewNoSupportedCrs"); + return; + } + + const bounds = geoLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [30, 30], maxZoom: 18 }); + } + } catch (err) { + if (generation === mapGeneration) { + console.error("[LayerPreview] renderLayer error:", err); + mapError.value = String(err); + } } } +async function applySrid(next: number) { + selectedSrid.value = next; + mapError.value = ""; + if (!map) return; + mapGeneration++; + const generation = mapGeneration; + if (geoLayer) { + map.removeLayer(geoLayer); + geoLayer = null; + } + if (labelLayer) { + map.removeLayer(labelLayer); + labelLayer = null; + } + await renderLayer(generation); + if (generation === mapGeneration && labelProperty.value) updateLabels(); +} + +function onSridSelect(value: string) { + if (value === "custom") { + showCustomSrid.value = true; + return; + } + showCustomSrid.value = false; + void applySrid(Number(value)); +} + +function onCustomSridSubmit() { + const srid = Number.parseInt(customSridInput.value, 10); + if (!Number.isInteger(srid) || srid <= 0) return; + void applySrid(srid); +} + function switchBasemap(basemap: BasemapOption) { selectedBasemap.value = basemap; selectedBasemapId.value = basemap.id; @@ -360,6 +504,7 @@ watch(selectedBasemapId, (id) => { }); function cleanupMap() { + mapGeneration++; mapResizeObserver?.disconnect(); mapResizeObserver = null; labelLayer = null; @@ -370,7 +515,14 @@ function cleanupMap() { map = null; } _geojsonData = null; + coordinateConverters = new globalThis.Map(); + detectedSrid.value = null; + selectedSrid.value = 4326; + showCustomSrid.value = false; + customSridInput.value = ""; labelProperty.value = ""; + isLoadingMap.value = false; + skippedFeatureCount.value = 0; } function close() { @@ -418,11 +570,9 @@ async function saveAsImage() { watch( () => props.open, async (open) => { - console.log("[LayerPreview] open prop changed:", open); if (open) { await nextTick(); if (!mapContainer.value) await nextTick(); - console.log("[LayerPreview] after nextTick, mapContainer in DOM:", !!mapContainer.value); await initMap(); } else { cleanupMap(); @@ -438,31 +588,43 @@ onBeforeUnmount(() => cleanupMap()); -
-
+
+
{{ dialogTitle }}
+ + + + {{ spatialSourceLabel }} + + {{ t("grid.layerPreviewSkipped", { count: skippedFeatureCount }) }} + + - + -
- - - @@ -480,10 +642,10 @@ onBeforeUnmount(() => cleanupMap());
-
Loading map…
-
-
- {{ mapError }} +
{{ t("grid.layerPreviewLoading") }}
+
+ {{ mapError }} +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3a107a2740..af62d34d42 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1340,10 +1340,20 @@ export default { imageLoadFailed: "Image failed to load", geometryPreview: "Geometry Preview", layerPreview: "Layer Preview", - layerPreviewLabelNone: "— Label —", - layerPreviewExportImage: "Export image", + layerPreviewSkipped: "Skipped {count} feature(s)", + layerPreviewNoSupportedCrs: "Unable to preview: coordinate system missing or unsupported", + layerPreviewNoGeometryData: "Unable to preview: no valid geometry data", + layerPreviewLabel: "Label", + layerPreviewExport: "Export image", layerPreviewMaximize: "Maximize", layerPreviewRestore: "Restore", + layerPreviewLoading: "Loading map...", + layerPreviewCustomCrs: "Custom EPSG…", + layerPreviewCustomCrsPlaceholder: "EPSG code", + layerPreviewUnsupportedCrs: "Unsupported coordinate system: EPSG:{srid}", + layerPreviewCrsManual: "Coordinate system: manual", + layerPreviewCrsDetected: "Coordinate system: detected", + layerPreviewCrsAssumed: "Coordinate system: assumed WGS 84", insertRowsNotSupported: "The current save target does not support adding rows.", zoomIn: "Zoom In", zoomOut: "Zoom Out", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5c7e5cf9f1..fb9291511f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1224,10 +1224,20 @@ export default withEnglishFallback({ imageLoadFailed: "No se pudo cargar la imagen", geometryPreview: "Vista previa de geometría", layerPreview: "Vista previa de capa", - layerPreviewLabelNone: "— Etiqueta —", - layerPreviewExportImage: "Exportar imagen", + layerPreviewSkipped: "{count} elemento(s) omitido(s)", + layerPreviewNoSupportedCrs: "No se puede previsualizar: sistema de coordenadas ausente o no compatible", + layerPreviewNoGeometryData: "No se puede previsualizar: no hay datos geométricos válidos", + layerPreviewLabel: "Etiqueta", + layerPreviewExport: "Exportar imagen", layerPreviewMaximize: "Maximizar", layerPreviewRestore: "Restaurar", + layerPreviewLoading: "Cargando mapa...", + layerPreviewCustomCrs: "EPSG personalizado…", + layerPreviewCustomCrsPlaceholder: "Código EPSG", + layerPreviewUnsupportedCrs: "Sistema de coordenadas no compatible: EPSG:{srid}", + layerPreviewCrsManual: "Sistema de coordenadas: manual", + layerPreviewCrsDetected: "Sistema de coordenadas: detectado", + layerPreviewCrsAssumed: "Sistema de coordenadas: se asume WGS 84", insertRowsNotSupported: "El destino de guardado actual no admite añadir filas.", zoomIn: "Acercar", zoomOut: "Alejar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 821a019976..7aec9e8730 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1222,10 +1222,20 @@ export default withEnglishFallback({ imageLoadFailed: "Caricamento immagine non riuscito", geometryPreview: "Anteprima Geometria", layerPreview: "Anteprima Layer", - layerPreviewLabelNone: "— Etichetta —", - layerPreviewExportImage: "Esporta immagine", - layerPreviewMaximize: "Ingrandisci", + layerPreviewSkipped: "{count} elemento/i ignorato/i", + layerPreviewNoSupportedCrs: "Impossibile visualizzare: sistema di coordinate mancante o non supportato", + layerPreviewNoGeometryData: "Impossibile visualizzare: nessun dato geometrico valido", + layerPreviewLabel: "Etichetta", + layerPreviewExport: "Esporta immagine", + layerPreviewMaximize: "Massimizza", layerPreviewRestore: "Ripristina", + layerPreviewLoading: "Caricamento mappa...", + layerPreviewCustomCrs: "EPSG personalizzato…", + layerPreviewCustomCrsPlaceholder: "Codice EPSG", + layerPreviewUnsupportedCrs: "Sistema di coordinate non supportato: EPSG:{srid}", + layerPreviewCrsManual: "Sistema di coordinate: manuale", + layerPreviewCrsDetected: "Sistema di coordinate: rilevato", + layerPreviewCrsAssumed: "Sistema di coordinate: presunto WGS 84", insertRowsNotSupported: "La destinazione di salvataggio corrente non supporta l'aggiunta di righe.", zoomIn: "Ingrandisci", zoomOut: "Rimpicciolisci", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a21dcefa0d..ac5c982f21 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1240,10 +1240,20 @@ export default withEnglishFallback({ imageLoadFailed: "画像の読み込みに失敗しました", geometryPreview: "ジオメトリプレビュー", layerPreview: "レイヤープレビュー", - layerPreviewLabelNone: "— ラベル —", - layerPreviewExportImage: "画像をエクスポート", + layerPreviewSkipped: "{count} 件をスキップ", + layerPreviewNoSupportedCrs: "プレビューできません: 座標系がないか対応していません", + layerPreviewNoGeometryData: "プレビューできません: 有効なジオメトリデータがありません", + layerPreviewLabel: "ラベル", + layerPreviewExport: "画像を書き出す", layerPreviewMaximize: "最大化", - layerPreviewRestore: "元に戻す", + layerPreviewRestore: "復元", + layerPreviewLoading: "地図を読み込み中...", + layerPreviewCustomCrs: "カスタム EPSG…", + layerPreviewCustomCrsPlaceholder: "EPSG コード", + layerPreviewUnsupportedCrs: "サポートされていない座標系: EPSG:{srid}", + layerPreviewCrsManual: "座標系: 手動", + layerPreviewCrsDetected: "座標系: 検出済み", + layerPreviewCrsAssumed: "座標系: 既定の WGS 84", insertRowsNotSupported: "現在の保存先は行の追加に対応していません。", zoomIn: "拡大", zoomOut: "縮小", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 5fde23a4e9..301cf3401d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1224,10 +1224,20 @@ export default withEnglishFallback({ imageLoadFailed: "Falha ao carregar a imagem", geometryPreview: "Visualização de Geometria", layerPreview: "Visualização de Camada", - layerPreviewLabelNone: "— Rótulo —", - layerPreviewExportImage: "Exportar imagem", + layerPreviewSkipped: "{count} recurso(s) ignorado(s)", + layerPreviewNoSupportedCrs: "Não foi possível visualizar: sistema ausente ou não suportado", + layerPreviewNoGeometryData: "Não foi possível visualizar: não há dados geométricos válidos", + layerPreviewLabel: "Rótulo", + layerPreviewExport: "Exportar imagem", layerPreviewMaximize: "Maximizar", layerPreviewRestore: "Restaurar", + layerPreviewLoading: "Carregando mapa...", + layerPreviewCustomCrs: "EPSG personalizado…", + layerPreviewCustomCrsPlaceholder: "Código EPSG", + layerPreviewUnsupportedCrs: "Sistema de coordenadas não suportado: EPSG:{srid}", + layerPreviewCrsManual: "Sistema de coordenadas: manual", + layerPreviewCrsDetected: "Sistema de coordenadas: detectado", + layerPreviewCrsAssumed: "Sistema de coordenadas: presumido WGS 84", insertRowsNotSupported: "O destino de salvamento atual não suporta a adição de linhas.", zoomIn: "Aproximar", zoomOut: "Afastar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b80af87890..94fafae5e0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1341,10 +1341,20 @@ export default withEnglishFallback({ imageLoadFailed: "图片加载失败", geometryPreview: "图形预览", layerPreview: "图层预览", - layerPreviewLabelNone: "— 标签 —", - layerPreviewExportImage: "导出图片", + layerPreviewSkipped: "已跳过 {count} 个要素", + layerPreviewNoSupportedCrs: "无法预览:缺少或不支持坐标系", + layerPreviewNoGeometryData: "无法预览:没有有效的几何数据", + layerPreviewLabel: "标签", + layerPreviewExport: "导出图片", layerPreviewMaximize: "最大化", layerPreviewRestore: "还原", + layerPreviewLoading: "正在加载地图...", + layerPreviewCustomCrs: "自定义 EPSG…", + layerPreviewCustomCrsPlaceholder: "EPSG 代码", + layerPreviewUnsupportedCrs: "不支持的坐标系:EPSG:{srid}", + layerPreviewCrsManual: "坐标系:手动", + layerPreviewCrsDetected: "坐标系:已探测", + layerPreviewCrsAssumed: "坐标系:默认 WGS 84", insertRowsNotSupported: "当前保存目标不支持新增行。", zoomIn: "放大", zoomOut: "缩小", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1d08a54750..e7c74658da 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1223,10 +1223,20 @@ export default withEnglishFallback({ imageLoadFailed: "圖片載入失敗", geometryPreview: "圖形預覽", layerPreview: "圖層預覽", - layerPreviewLabelNone: "— 標籤 —", - layerPreviewExportImage: "匯出圖片", + layerPreviewSkipped: "已略過 {count} 個圖徵", + layerPreviewNoSupportedCrs: "無法預覽:缺少或不支援座標系", + layerPreviewNoGeometryData: "無法預覽:沒有有效的幾何資料", + layerPreviewLabel: "標籤", + layerPreviewExport: "匯出圖片", layerPreviewMaximize: "最大化", layerPreviewRestore: "還原", + layerPreviewLoading: "正在載入地圖...", + layerPreviewCustomCrs: "自訂 EPSG…", + layerPreviewCustomCrsPlaceholder: "EPSG 代碼", + layerPreviewUnsupportedCrs: "不支援的座標系:EPSG:{srid}", + layerPreviewCrsManual: "座標系:手動", + layerPreviewCrsDetected: "座標系:已偵測", + layerPreviewCrsAssumed: "座標系:預設 WGS 84", insertRowsNotSupported: "目前的儲存目標不支援新增資料列。", zoomIn: "放大", zoomOut: "縮小", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/geometryLayerPreview.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/geometryLayerPreview.spec.ts new file mode 100644 index 0000000000..e92d80d7da --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/geometryLayerPreview.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { renderGeometryFeaturesIndependently } from "@/lib/dataGrid/geometryLayerPreview"; + +describe("renderGeometryFeaturesIndependently", () => { + it("keeps valid mixed-SRID layers when other features are unsupported or throw", () => { + const added: string[] = []; + const errors: unknown[] = []; + const summary = renderGeometryFeaturesIndependently( + [ + { id: "wgs84", srid: 4326 }, + { id: "unknown", srid: null }, + { id: "broken", srid: 3857 }, + { id: "mercator", srid: 3857 }, + ], + (feature) => { + if (feature.srid === null) return null; + if (feature.id === "broken") throw new Error("invalid geometry"); + return { layer: feature.id, srid: feature.srid }; + }, + (layer) => added.push(layer), + (error) => errors.push(error), + ); + + expect(added).toEqual(["wgs84", "mercator"]); + expect(summary).toEqual({ rendered: 2, skipped: 2, srids: [3857, 4326] }); + expect(errors).toHaveLength(1); + }); + + it("reports an entirely unsupported result without adding layers", () => { + const summary = renderGeometryFeaturesIndependently( + [null, 4490], + () => null, + () => { + throw new Error("must not add a layer"); + }, + ); + + expect(summary).toEqual({ rendered: 0, skipped: 2, srids: [] }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/geometryMapPreview.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/geometryMapPreview.spec.ts new file mode 100644 index 0000000000..52d18c8176 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/geometryMapPreview.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { buildGeometryMapFeatureCollection } from "@/lib/dataGrid/geometryMapPreview"; +import type { PreviewActionContext } from "@/lib/dataGrid/resultPreviewRegistry"; + +function context(): PreviewActionContext { + return { + result: { + columns: ["shape", "name"], + column_types: ["geometry", "text"], + column_sortables: [], + spatial_columns: [{ column_index: 0, srid: 4326 }], + rows: [ + ["POINT(116.397 39.908)", "wgs84"], + ["POINT(12957254.77 4852618.61)", "mercator"], + ["POINT(1 2)", "unknown"], + ], + affected_rows: 0, + execution_time_ms: 1, + truncated: false, + has_more: false, + }, + selectedRowIds: [], + displayRowRefs: [ + { id: 20, sourceIndex: 1, isNew: false }, + { id: 10, sourceIndex: 0, isNew: false }, + { id: 30, sourceIndex: 2, isNew: false }, + ], + }; +} + +describe("buildGeometryMapFeatureCollection", () => { + it("exposes the detected layer SRID and binds row properties by sourceIndex", () => { + const collection = buildGeometryMapFeatureCollection(context()); + + expect(collection?.detectedSrid).toBe(4326); + expect(collection?.features.map((feature) => feature.properties.name)).toEqual(["mercator", "wgs84", "unknown"]); + }); + + it("reports detectedSrid null when spatial metadata is absent", () => { + const ctx = context(); + ctx.result.spatial_columns = undefined; + + const collection = buildGeometryMapFeatureCollection(ctx); + expect(collection?.detectedSrid).toBeNull(); + }); + + it("binds each feature to its own per-cell SRID when the column mixes SRIDs", () => { + const ctx = context(); + ctx.result.spatial_columns = [{ column_index: 0, srid: 4326 }]; + // Per-cell SRIDs, parallel to rows: wgs84=4326, mercator=3857, unknown=null. + ctx.result.spatial_values = [ + [4326, null], + [3857, null], + [null, null], + ]; + + const collection = buildGeometryMapFeatureCollection(ctx); + expect(collection?.features.map((feature) => feature.properties._srid)).toEqual([3857, 4326, null]); + }); + + it("prefers per-cell SRIDs over column-level hints", () => { + const ctx = context(); + ctx.result.spatial_columns = [{ column_index: 0, srid: 3857 }]; + ctx.result.spatial_values = [ + [4326, null], + [null, null], + [null, null], + ]; + + const collection = buildGeometryMapFeatureCollection(ctx); + // wgs84 row carries its own 4326 even though the column hint says 3857. + expect(collection?.features.find((f) => f.properties.name === "wgs84")?.properties._srid).toBe(4326); + }); + + it("uses the column-level hint only for legacy results without per-cell metadata", () => { + const ctx = context(); + ctx.result.spatial_columns = [{ column_index: 0, srid: 3857 }]; + ctx.result.spatial_values = undefined; + + const collection = buildGeometryMapFeatureCollection(ctx); + expect(collection?.features.map((feature) => feature.properties._srid)).toEqual([3857, 3857, 3857]); + }); + + it("deduplicates geometry by SRID and WKT", () => { + const ctx = context(); + ctx.result.rows = [ + ["POINT(1 2)", "wgs84"], + ["POINT(1 2)", "mercator"], + ["POINT(1 2)", "duplicate wgs84"], + ]; + ctx.result.spatial_values = [ + [4326, null], + [3857, null], + [4326, null], + ]; + + const collection = buildGeometryMapFeatureCollection(ctx); + expect(collection?.features.map((feature) => feature.properties._srid)).toEqual([3857, 4326]); + expect(collection?.features.map((feature) => feature.properties.name)).toEqual(["mercator", "wgs84"]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/geometryPreview.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/geometryPreview.spec.ts index 9741469795..4b61061abc 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/geometryPreview.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/geometryPreview.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { wktToGeoJson } from "@/lib/dataGrid/geometryPreview"; +import { parseWktGeometry, renderGeometryOnCanvas, wktToGeoJson } from "@/lib/dataGrid/geometryPreview"; describe("wktToGeoJson", () => { it("preserves polygon interior rings", () => { @@ -51,4 +51,45 @@ describe("wktToGeoJson", () => { ], }); }); + + it("defensively strips an old EWKT prefix", () => { + expect(wktToGeoJson("SRID=4326;POINT(116.397 39.908)")).toEqual({ + type: "Point", + coordinates: [116.397, 39.908], + }); + expect(parseWktGeometry("SRID=3857;POINT(1 2)")?.type).toBe("POINT"); + }); + + it("rejects a geometry collection when any member cannot be parsed", () => { + expect(wktToGeoJson("GEOMETRYCOLLECTION(POINT(1 2),NOT_A_GEOMETRY(3 4))")).toBeNull(); + }); + + it("fills polygon holes with the even-odd rule", () => { + const fillRules: string[] = []; + const context = { + clearRect() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + closePath() {}, + stroke() {}, + arc() {}, + fill(rule?: string) { + if (rule) fillRules.push(rule); + }, + fillStyle: "", + strokeStyle: "", + font: "", + textAlign: "", + lineWidth: 0, + lineJoin: "", + lineCap: "", + fillText() {}, + } as unknown as CanvasRenderingContext2D; + const canvas = { width: 400, height: 280, getContext: () => context } as unknown as HTMLCanvasElement; + const geometry = parseWktGeometry("POLYGON((0 0,10 0,10 10,0 10,0 0),(2 2,8 2,8 8,2 8,2 2))"); + expect(geometry).not.toBeNull(); + renderGeometryOnCanvas(canvas, geometry!); + expect(fillRules).toContain("evenodd"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/geometryProjection.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/geometryProjection.spec.ts new file mode 100644 index 0000000000..0d12957bc6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/geometryProjection.spec.ts @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import L from "leaflet"; +import { createBuiltInCoordsToLatLng, prepareCoordsToLatLng } from "@/lib/dataGrid/geometryProjection"; + +describe("geometry projection", () => { + it("maps equivalent EPSG:4326 and EPSG:3857 points to the same location", () => { + const latLng = L.latLng(39.908, 116.397); + const projected = L.CRS.EPSG3857.project(latLng); + const from4326 = createBuiltInCoordsToLatLng(4326, L)!([116.397, 39.908]); + const from3857 = createBuiltInCoordsToLatLng(3857, L)!([projected.x, projected.y]); + + expect(from3857.lat).toBeCloseTo(from4326.lat, 8); + expect(from3857.lng).toBeCloseTo(from4326.lng, 8); + }); + + it("projects a CGCS2000 zone-40 (EPSG:4528) easting near 120E", async () => { + const toLatLng = await prepareCoordsToLatLng(4528, L); + expect(toLatLng).not.toBeNull(); + const ll = toLatLng!([40_500_000, 2_600_000]); // zone-40 false easting, ~lon 120 + + expect(ll.lng).toBeCloseTo(120, 1); + expect(ll.lat).toBeGreaterThan(0); + }); + + it("returns no converter for missing and unsupported spatial references", async () => { + expect(createBuiltInCoordsToLatLng(null, L)).toBeNull(); + await expect(prepareCoordsToLatLng(2154, L)).resolves.toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/spatialReferenceCatalog.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/spatialReferenceCatalog.spec.ts new file mode 100644 index 0000000000..52ad42fe9b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/spatialReferenceCatalog.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { getSpatialReference, SHORTLIST_SRIDS } from "@/lib/dataGrid/spatialReferenceCatalog"; + +describe("spatialReferenceCatalog", () => { + it("resolves WGS84 and Web Mercator natively", () => { + expect(getSpatialReference(4326)?.kind).toBe("lnglat"); + expect(getSpatialReference(3857)?.kind).toBe("webmercator"); + }); + + it("resolves CGCS2000 3-degree zone 40 (4528) with false easting 40500000", () => { + const entry = getSpatialReference(4528); + expect(entry?.kind).toBe("proj4"); + expect(entry?.proj4).toContain("+lon_0=120"); + expect(entry?.proj4).toContain("+x_0=40500000 "); + }); + + it("resolves CGCS2000 3-degree CM 120E (4549) with false easting 500000", () => { + const entry = getSpatialReference(4549); + expect(entry?.proj4).toContain("+lon_0=120"); + expect(entry?.proj4).toContain("+x_0=500000 "); + }); + + it("resolves CGCS2000 3-degree CM 114E (4547) with false easting 500000", () => { + const entry = getSpatialReference(4547); + expect(entry?.proj4).toContain("+lon_0=114"); + expect(entry?.proj4).toContain("+x_0=500000 "); + }); + + it("treats CGCS2000 geographic (4490) as lng/lat", () => { + expect(getSpatialReference(4490)?.kind).toBe("lnglat"); + }); + + it("returns null for codes outside the offline subset", () => { + expect(getSpatialReference(2154)).toBeNull(); + }); + + it("shortlist starts with 4326 and 3857", () => { + expect(SHORTLIST_SRIDS.slice(0, 2)).toEqual([4326, 3857]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts index 84598a8c3f..9a77c978c5 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts @@ -144,6 +144,25 @@ describe("tab result cache statement execution metadata", () => { }); }); + it("preserves spatial metadata", () => { + const encoded = encodeTabResultSnapshot({ + result: { + columns: ["geom"], + column_types: ["geometry"], + spatial_columns: [{ column_index: 0, srid: 4326 }], + spatial_values: [[4326], [3857], [null]], + rows: [["POINT(116.397 39.908)"], ["POINT(12957255 4852583)"], [null]], + affected_rows: 0, + execution_time_ms: 1, + }, + cachedAt: 1, + }); + + const result = decodeTabResultSnapshot(encoded).result; + expect(result?.spatial_columns).toEqual([{ column_index: 0, srid: 4326 }]); + expect(result?.spatial_values).toEqual([[4326], [3857], [null]]); + }); + it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => { const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot())); diff --git a/apps/desktop/src/lib/dataGrid/geometryLayerPreview.ts b/apps/desktop/src/lib/dataGrid/geometryLayerPreview.ts new file mode 100644 index 0000000000..edce9cf077 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/geometryLayerPreview.ts @@ -0,0 +1,35 @@ +export interface PreparedGeometryLayer { + layer: TLayer; + srid: number; +} + +export interface GeometryLayerRenderSummary { + rendered: number; + skipped: number; + srids: number[]; +} + +/** Render features independently so one malformed geometry cannot hide valid layers. */ +export function renderGeometryFeaturesIndependently(features: readonly TFeature[], prepareLayer: (feature: TFeature) => PreparedGeometryLayer | null, addLayer: (layer: TLayer) => void, onError?: (error: unknown) => void): GeometryLayerRenderSummary { + let rendered = 0; + let skipped = 0; + const srids = new Set(); + + for (const feature of features) { + try { + const prepared = prepareLayer(feature); + if (!prepared) { + skipped++; + continue; + } + addLayer(prepared.layer); + rendered++; + srids.add(prepared.srid); + } catch (error) { + skipped++; + onError?.(error); + } + } + + return { rendered, skipped, srids: Array.from(srids).sort((a, b) => a - b) }; +} diff --git a/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts b/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts index 8991b1118c..889186c4a4 100644 --- a/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts +++ b/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts @@ -1,8 +1,21 @@ import { Map as MapIcon } from "@lucide/vue"; -import { registerPreviewAction } from "@/lib/dataGrid/resultPreviewRegistry"; -import { wktToGeoJson } from "@/lib/dataGrid/geometryPreview"; +import { registerPreviewAction, type PreviewActionContext } from "@/lib/dataGrid/resultPreviewRegistry"; +import { wktToGeoJson, type GeoJsonGeometry } from "@/lib/dataGrid/geometryPreview"; import LayerPreviewDialog from "@/components/grid/LayerPreviewDialog.vue"; +export interface GeometryMapFeature { + type: "Feature"; + geometry: GeoJsonGeometry; + properties: Record; +} + +export interface GeometryMapFeatureCollection { + type: "FeatureCollection"; + features: GeometryMapFeature[]; + /** First non-null SRID among rendered features' columns, or null if unknown. */ + detectedSrid: number | null; +} + registerPreviewAction({ id: "geometry-map-preview", label: "grid.layerPreview", @@ -17,59 +30,63 @@ registerPreviewAction({ }); }, execute(ctx) { - const geomIndices = geometryColumnIndices(ctx.result.column_types); - if (geomIndices.length === 0) return null; - - const features: Record[] = []; - const seen = new Set(); - - for (const ref of ctx.displayRowRefs) { - if (ref.isNew) continue; - // Only include selected rows if there's an active selection - if (ctx.selectedRowIds.length > 0 && !ctx.selectedRowIds.includes(ref.id)) continue; - const row = ctx.result.rows[ref.sourceIndex]; - if (!row) continue; - for (const colIdx of geomIndices) { - const raw = row[colIdx]; - if (raw === null || raw === undefined) continue; - const wkt = String(raw); - if (wkt.startsWith("0x") || seen.has(wkt)) continue; - seen.add(wkt); - const geom = wktToGeoJson(wkt); - if (geom) { - // Include all non-geometry column values as properties for labelling. - const props: Record = { - _column: ctx.result.columns[colIdx], - _row: ref.sourceIndex, - }; - for (let c = 0; c < ctx.result.columns.length; c++) { - if (geomIndices.includes(c)) continue; // skip geometry columns - const colName = ctx.result.columns[c] ?? `col_${c}`; - const val = row[c]; - props[colName] = val ?? null; - } - features.push({ - type: "Feature", - geometry: geom, - properties: props, - }); - } - } - } - - if (features.length === 0) return null; - - const fc = { type: "FeatureCollection", features }; + const featureCollection = buildGeometryMapFeatureCollection(ctx); + if (!featureCollection) return null; return { component: LayerPreviewDialog, props: { - geojson: JSON.stringify(fc), + geojson: JSON.stringify(featureCollection), }, }; }, }); +export function buildGeometryMapFeatureCollection(ctx: PreviewActionContext): GeometryMapFeatureCollection | null { + const geomIndices = geometryColumnIndices(ctx.result.column_types); + if (geomIndices.length === 0) return null; + + const features: GeometryMapFeature[] = []; + const seen = new Set(); + const sridByColumn = new Map((ctx.result.spatial_columns ?? []).map((entry) => [entry.column_index, entry.srid])); + const spatialValues = ctx.result.spatial_values; + let detectedSrid: number | null = null; + + for (const ref of ctx.displayRowRefs) { + if (ref.isNew) continue; + if (ctx.selectedRowIds.length > 0 && !ctx.selectedRowIds.includes(ref.id)) continue; + const row = ctx.result.rows[ref.sourceIndex]; + if (!row) continue; + for (const colIdx of geomIndices) { + const raw = row[colIdx]; + if (raw === null || raw === undefined) continue; + const wkt = String(raw); + if (wkt.startsWith("0x")) continue; + const srid = spatialValues === undefined ? (sridByColumn.get(colIdx) ?? null) : (spatialValues[ref.sourceIndex]?.[colIdx] ?? null); + const geometryKey = JSON.stringify([srid, wkt]); + if (seen.has(geometryKey)) continue; + seen.add(geometryKey); + const geometry = wktToGeoJson(wkt); + if (!geometry) continue; + if (detectedSrid == null && srid != null) detectedSrid = srid; + + const properties: Record = { + _column: ctx.result.columns[colIdx], + _row: ref.sourceIndex, + _srid: srid, + }; + for (let c = 0; c < ctx.result.columns.length; c++) { + if (geomIndices.includes(c)) continue; + const columnName = ctx.result.columns[c] ?? `col_${c}`; + properties[columnName] = row[c] ?? null; + } + features.push({ type: "Feature", geometry, properties }); + } + } + + return features.length > 0 ? { type: "FeatureCollection", features, detectedSrid } : null; +} + function geometryColumnIndices(columnTypes: readonly string[] | undefined): number[] { if (!columnTypes) return []; const indices: number[] = []; diff --git a/apps/desktop/src/lib/dataGrid/geometryPreview.ts b/apps/desktop/src/lib/dataGrid/geometryPreview.ts index b5daad163c..b24795292c 100644 --- a/apps/desktop/src/lib/dataGrid/geometryPreview.ts +++ b/apps/desktop/src/lib/dataGrid/geometryPreview.ts @@ -1,6 +1,12 @@ -type Coord = [number, number]; +export type Coord = [number, number]; -interface GeometryGroup { +export interface ParsedWktGeometry { + type: string; + groups: GeometryGroup[]; + coordinates: Coord[]; +} + +export interface GeometryGroup { coords: Coord[]; children: GeometryGroup[]; } @@ -112,20 +118,18 @@ function extractCoords(g: GeometryGroup): Coord[] { function drawPolygonRings(ctx: CanvasRenderingContext2D, group: GeometryGroup, mapX: (v: number) => number, mapY: (v: number) => number) { // group.children contains ring groups const rings = group.children.length > 0 ? group.children : [group]; - let ringIndex = 0; + ctx.beginPath(); for (const ringGroup of rings) { const coords = extractCoords(ringGroup); if (coords.length < 2) continue; - ctx.beginPath(); ctx.moveTo(mapX(coords[0][0]), mapY(coords[0][1])); for (let j = 1; j < coords.length; j++) { ctx.lineTo(mapX(coords[j][0]), mapY(coords[j][1])); } ctx.closePath(); - if (ringIndex === 0) ctx.fill(); - ctx.stroke(); - ringIndex++; } + ctx.fill("evenodd"); + ctx.stroke(); } function drawGroups(ctx: CanvasRenderingContext2D, groups: GeometryGroup[], type: string, mapX: (v: number) => number, mapY: (v: number) => number) { @@ -201,12 +205,72 @@ function detectWktType(wkt: string): string { return m ? m[1].toUpperCase() : "GEOMETRYCOLLECTION"; } +function cleanWktInput(wkt: string): string { + const trimmed = wkt.trim(); + const ewkt = trimmed.match(/^SRID=\d+\s*;\s*/i); + return ewkt ? trimmed.slice(ewkt[0].length).trim() : trimmed; +} + // EWKB parsing on the backend can fall back to hex for unsupported geometry types // (e.g. TIN, Triangle, CircularString). Don't attempt to render hex as WKT. export function isHexGeometry(value: string): boolean { return value.startsWith("0x"); } +export function parseWktGeometry(wkt: string): ParsedWktGeometry | null { + const cleaned = cleanWktInput(wkt); + if (!cleaned || isHexGeometry(cleaned)) return null; + const type = detectWktType(cleaned); + const tokens = tokenize(cleaned); + const { groups } = parseGroups(tokens, 0); + const coordinates = flattenCoords(groups).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)); + return groups.length > 0 || /\bEMPTY\b/i.test(cleaned) ? { type, groups, coordinates } : null; +} + +export function renderGeometryOnCanvas(canvas: HTMLCanvasElement, geometry: ParsedWktGeometry): void { + const allCoords = geometry.coordinates; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const W = canvas.width; + const H = canvas.height; + const pad = 20; + ctx.clearRect(0, 0, W, H); + if (allCoords.length === 0) { + ctx.fillStyle = "#888"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Empty geometry", W / 2, H / 2); + return; + } + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const [x, y] of allCoords) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + if (minX === maxX && minY === maxY) { + minX -= 1; + maxX += 1; + minY -= 1; + maxY += 1; + } + const scale = Math.min((W - pad * 2) / (maxX - minX || 1), (H - pad * 2) / (maxY - minY || 1)); + const midX = (minX + maxX) / 2; + const midY = (minY + maxY) / 2; + const mapX = (x: number) => (x - midX) * scale + W / 2; + const mapY = (y: number) => -(y - midY) * scale + H / 2; + ctx.strokeStyle = "#3b82f6"; + ctx.fillStyle = "rgba(59, 130, 246, 0.15)"; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + drawGroups(ctx, geometry.groups, geometry.type, mapX, mapY); +} + export function renderWktOnCanvas(canvas: HTMLCanvasElement, wkt: string): void { if (isHexGeometry(wkt)) { const ctx = canvas.getContext("2d"); @@ -219,70 +283,15 @@ export function renderWktOnCanvas(canvas: HTMLCanvasElement, wkt: string): void return; } - const type = detectWktType(wkt); - const tokens = tokenize(wkt); - const { groups } = parseGroups(tokens, 0); - const allCoords = flattenCoords(groups); - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const W = canvas.width; - const H = canvas.height; - const pad = 20; - - ctx.clearRect(0, 0, W, H); - - if (allCoords.length === 0) { - ctx.fillStyle = "#888"; - ctx.font = "12px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText("Empty geometry", W / 2, H / 2); - return; - } - - let minX = Infinity, - maxX = -Infinity, - minY = Infinity, - maxY = -Infinity; - for (const [x, y] of allCoords) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - - if (minX === maxX && minY === maxY) { - minX -= 1; - maxX += 1; - minY -= 1; - maxY += 1; - } - - const rangeX = maxX - minX || 1; - const rangeY = maxY - minY || 1; - const scale = Math.min((W - pad * 2) / rangeX, (H - pad * 2) / rangeY); - - const midX = (minX + maxX) / 2; - const midY = (minY + maxY) / 2; - - const mapX = (x: number) => (x - midX) * scale + W / 2; - const mapY = (y: number) => -(y - midY) * scale + H / 2; - - ctx.strokeStyle = "#3b82f6"; - ctx.fillStyle = "rgba(59, 130, 246, 0.15)"; - ctx.lineWidth = 1.5; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - - drawGroups(ctx, groups, type, mapX, mapY); + const geometry = parseWktGeometry(wkt); + if (geometry) renderGeometryOnCanvas(canvas, geometry); } /** GeoJSON geometry type. */ export interface GeoJsonGeometry { type: string; coordinates?: any; - geometries?: (GeoJsonGeometry | null)[]; + geometries?: GeoJsonGeometry[]; } /** @@ -290,10 +299,11 @@ export interface GeoJsonGeometry { * Returns `null` for binary (hex) geometries or parse failures. */ export function wktToGeoJson(wkt: string): GeoJsonGeometry | null { - if (isHexGeometry(wkt)) return null; - const type = detectWktType(wkt); - if (type === "GEOMETRYCOLLECTION") return geometryCollectionToGeoJson(wkt); - const tokens = tokenize(wkt); + const cleaned = cleanWktInput(wkt); + if (isHexGeometry(cleaned)) return null; + const type = detectWktType(cleaned); + if (type === "GEOMETRYCOLLECTION") return geometryCollectionToGeoJson(cleaned); + const tokens = tokenize(cleaned); const { groups } = parseGroups(tokens, 0); if (groups.length === 0) return null; try { @@ -369,9 +379,11 @@ function geometryCollectionToGeoJson(wkt: string): GeoJsonGeometry | null { const content = inner.slice(1, -1).trim(); if (!content) return null; const parts = splitGeomCollectionMembers(content); + const geometries = parts.map((part) => wktToGeoJson(part)); + if (geometries.some((geometry) => geometry === null)) return null; return { type: "GeometryCollection", - geometries: parts.map((p) => wktToGeoJson(p)), + geometries: geometries as GeoJsonGeometry[], }; } diff --git a/apps/desktop/src/lib/dataGrid/geometryProjection.ts b/apps/desktop/src/lib/dataGrid/geometryProjection.ts new file mode 100644 index 0000000000..659ca51a6b --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/geometryProjection.ts @@ -0,0 +1,48 @@ +import type L from "leaflet"; +import type { Coord } from "@/lib/dataGrid/geometryPreview"; +import { getSpatialReference } from "@/lib/dataGrid/spatialReferenceCatalog"; + +export type CoordinateToLatLng = (coord: Coord) => L.LatLng; + +type CoordinateTransform = (coord: Coord) => Coord; + +const transformPromises = new Map>(); + +export function createBuiltInCoordsToLatLng(srid: number | null | undefined, leaflet: typeof L): CoordinateToLatLng | null { + if (srid == null) return null; + const entry = getSpatialReference(srid); + if (entry?.kind === "lnglat") return (coord) => leaflet.latLng(coord[1], coord[0]); + if (entry?.kind === "webmercator") return (coord) => leaflet.CRS.EPSG3857.unproject(leaflet.point(coord[0], coord[1])); + return null; +} + +export async function prepareCoordsToLatLng(srid: number | null | undefined, leaflet: typeof L): Promise { + const builtIn = createBuiltInCoordsToLatLng(srid, leaflet); + if (builtIn) return builtIn; + if (srid == null) return null; + const entry = getSpatialReference(srid); + if (!entry || entry.kind !== "proj4" || !entry.proj4) return null; + + let transformPromise = transformPromises.get(srid); + if (!transformPromise) { + transformPromise = loadCoordinateTransform(srid, entry.proj4); + transformPromises.set(srid, transformPromise); + transformPromise.catch(() => transformPromises.delete(srid)); + } + const transform = await transformPromise; + return (coord) => { + const [longitude, latitude] = transform(coord); + return leaflet.latLng(latitude, longitude); + }; +} + +async function loadCoordinateTransform(srid: number, definition: string): Promise { + const { default: proj4 } = await import("proj4"); + const source = `EPSG:${srid}`; + proj4.defs(source, definition); + const converter = proj4(source, "EPSG:4326"); + return ([x, y]) => { + const [longitude, latitude] = converter.forward([x, y]); + return [longitude, latitude]; + }; +} diff --git a/apps/desktop/src/lib/dataGrid/spatialReferenceCatalog.ts b/apps/desktop/src/lib/dataGrid/spatialReferenceCatalog.ts new file mode 100644 index 0000000000..050de69d1d --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/spatialReferenceCatalog.ts @@ -0,0 +1,57 @@ +export interface SpatialReferenceEntry { + srid: number; + label: string; + kind: "lnglat" | "webmercator" | "proj4"; + /** proj4 definition string for kind === "proj4". */ + proj4?: string; +} + +const GRS80 = "+ellps=GRS80 +towgs84=0,0,0 +units=m +no_defs"; + +/** CGCS2000 Gauss-Kruger definitions follow the official EPSG parameters. */ +function gaussKruger(lon0: number, falseEasting: number): string { + return `+proj=tmerc +lat_0=0 +lon_0=${lon0} +k=1 +x_0=${falseEasting} +y_0=0 ${GRS80}`; +} + +const entries = new Map(); + +function add(entry: SpatialReferenceEntry) { + entries.set(entry.srid, entry); +} + +// Geographic / native +add({ srid: 4326, label: "WGS 84 (EPSG:4326)", kind: "lnglat" }); +add({ srid: 4490, label: "CGCS2000 geographic (EPSG:4490)", kind: "lnglat" }); +add({ srid: 3857, label: "Web Mercator (EPSG:3857)", kind: "webmercator" }); +add({ srid: 3395, label: "World Mercator (EPSG:3395)", kind: "proj4", proj4: `+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 ${GRS80}` }); + +// CGCS2000 3-degree Gauss-Kruger, ZONE-numbered series: EPSG 4513..4533, zones 25..45. +// x_0 = zone*1e6 + 500000 — the leading digits encode the zone (e.g. 4528 = zone 40, x_0=40500000). +for (let code = 4513; code <= 4533; code++) { + const zone = 25 + (code - 4513); + add({ srid: code, label: `CGCS2000 3-degree GK zone ${zone} · CM ${zone * 3}E (EPSG:${code})`, kind: "proj4", proj4: gaussKruger(zone * 3, zone * 1_000_000 + 500_000) }); +} +// CGCS2000 3-degree Gauss-Kruger, CM (no zone prefix) series: EPSG 4534..4554, CM 75E..135E, x_0=500000 +// (e.g. 4547 = CM 114E, 4549 = CM 120E). +for (let code = 4534; code <= 4554; code++) { + const lon0 = 75 + (code - 4534) * 3; + add({ srid: code, label: `CGCS2000 3-degree GK CM ${lon0}E (EPSG:${code})`, kind: "proj4", proj4: gaussKruger(lon0, 500000) }); +} +// CGCS2000 6-degree Gauss-Kruger, ZONE-numbered series: EPSG 4491..4501, zones 13..23. +// x_0 = zone*1e6 + 500000. +for (let code = 4491; code <= 4501; code++) { + const zone = 13 + (code - 4491); + add({ srid: code, label: `CGCS2000 6-degree GK zone ${zone} · CM ${zone * 6 - 3}E (EPSG:${code})`, kind: "proj4", proj4: gaussKruger(zone * 6 - 3, zone * 1_000_000 + 500_000) }); +} +// CGCS2000 6-degree Gauss-Kruger, CM (no zone prefix) series: EPSG 4502..4512, CM 75E..135E, x_0=500000 +for (let code = 4502; code <= 4512; code++) { + const lon0 = 75 + (code - 4502) * 6; + add({ srid: code, label: `CGCS2000 6-degree GK CM ${lon0}E (EPSG:${code})`, kind: "proj4", proj4: gaussKruger(lon0, 500000) }); +} + +export function getSpatialReference(srid: number): SpatialReferenceEntry | null { + return entries.get(srid) ?? null; +} + +/** Curated dropdown order: WGS84, Web Mercator, then common CGCS2000 zones. */ +export const SHORTLIST_SRIDS: number[] = [4326, 3857, 4490, 4528, 4549, 4526, 4547]; diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index caf98f7d4f..e2d3f93eaf 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -46,6 +46,8 @@ export interface TabResultSnapshot { interface ColumnarQueryResult { columns: string[]; + spatial_columns?: QueryResult["spatial_columns"]; + spatial_values?: QueryResult["spatial_values"]; execution_error?: true; statement_index?: number; column_types?: string[]; @@ -324,6 +326,8 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin execution_error: result.execution_error, statement_index: result.statement_index, column_types: result.column_types ? [...result.column_types] : undefined, + spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })), + spatial_values: result.spatial_values?.map((row) => [...row]), rows: result.rows.map((row) => [...row]), mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined, @@ -363,6 +367,8 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult execution_error: result.execution_error, statement_index: result.statement_index, column_types: result.column_types ? [...result.column_types] : undefined, + spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })), + spatial_values: result.spatial_values?.map((row) => [...row]), columnValues, rowCount: result.rows.length, mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, @@ -386,6 +392,8 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul execution_error: result.execution_error, statement_index: result.statement_index, column_types: result.column_types ? [...result.column_types] : undefined, + spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })), + spatial_values: result.spatial_values?.map((row) => [...row]), rows, mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined, diff --git a/apps/desktop/src/stores/__tests__/queryStore.spatialMetadata.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.spatialMetadata.spec.ts new file mode 100644 index 0000000000..e2645e6671 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.spatialMetadata.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { appendQueryResultSegment } from "@/stores/queryStore"; +import type { QueryResult } from "@/types/database"; + +function make(rows: number, spatial?: QueryResult["spatial_columns"]): QueryResult { + return { + columns: ["geom"], + column_types: ["geometry"], + spatial_columns: spatial, + rows: Array.from({ length: rows }, (_, i) => [`POINT(${i} ${i})`]), + affected_rows: 0, + execution_time_ms: 0, + } as unknown as QueryResult; +} + +describe("appendQueryResultSegment spatial merge", () => { + it("keeps the first non-null SRID per column across pages", () => { + const previous = make(2, [{ column_index: 0, srid: 4326 }]); + const segment = make(2, [{ column_index: 0, srid: 3857 }]); + const merged = appendQueryResultSegment(previous, segment, 100); + expect(merged.spatial_columns).toEqual([{ column_index: 0, srid: 4326 }]); + expect(merged.rows).toHaveLength(4); + }); + + it("adopts the segment SRID when the previous page was unknown", () => { + const previous = make(1, [{ column_index: 0, srid: null }]); + const segment = make(1, [{ column_index: 0, srid: 4490 }]); + const merged = appendQueryResultSegment(previous, segment, 100); + expect(merged.spatial_columns).toEqual([{ column_index: 0, srid: 4490 }]); + }); + + it("concatenates per-cell SRIDs across pages without collapsing", () => { + const previous = make(2, [{ column_index: 0, srid: 4326 }]); + previous.spatial_values = [[4326], [3857]]; + const segment = make(2, [{ column_index: 0, srid: null }]); + segment.spatial_values = [[3857], [4490]]; + + const merged = appendQueryResultSegment(previous, segment, 100); + // Every row keeps its own SRID; a later page does not overwrite earlier cells. + expect(merged.spatial_values).toEqual([[4326], [3857], [3857], [4490]]); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8d5ce700d6..ebc8ab7bbe 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -157,7 +157,7 @@ function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | u return (pageOffset ?? 0) + result.rows.length; } -function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult { +export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult { if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment")); if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) { throw new Error("Result columns changed while loading the next segment"); @@ -169,12 +169,25 @@ function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, m if (existing.length !== previous.rows.length || next.length !== segment.rows.length) return undefined; return [...existing, ...next.slice(0, appendedRowCount)]; }; + const sridByColumn = new Map(); + for (const column of previous.spatial_columns ?? []) { + sridByColumn.set(column.column_index, column.srid); + } + for (const column of segment.spatial_columns ?? []) { + const existing = sridByColumn.get(column.column_index) ?? null; + sridByColumn.set(column.column_index, existing ?? column.srid); + } + const spatial_columns = Array.from(sridByColumn.entries()) + .map(([column_index, srid]) => ({ column_index, srid })) + .sort((a, b) => a.column_index - b.column_index); // Keep prior row objects intact so source-index based dirty/new/deleted state // remains valid, while bounding the in-memory result by the configured cap. return markQueryResultRowsRaw({ ...segment, appended_from_row_count: previous.rows.length, rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)], + spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined, + spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values), mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents), mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents), execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0), diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 568078edbb..8cb4f7e601 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -562,6 +562,14 @@ export interface OwnerInfo { export interface QueryResult { columns: string[]; + /** One SRID per geometry/geography column (first non-null observed). */ + spatial_columns?: SpatialColumn[]; + /** + * Per-cell SRID metadata, parallel to `rows`: spatial_values[row][column] is + * that cell's geometry SRID, or null for non-spatial cells / unknown SRIDs. + * Every geometry value keeps its own SRID so mixed-SRID results stay correct. + */ + spatial_values?: (number | null)[][]; /** Internal marker for a result built by appending a page to existing rows. */ appended_from_row_count?: number; /** Set for synthesized query execution failures. */ @@ -636,6 +644,11 @@ export interface BatchSqlExecution { items: BatchStatementExecutionItem[]; } +export interface SpatialColumn { + column_index: number; + srid: number | null; +} + export interface QueryResultRun { id: string; title: string; diff --git a/crates/dbx-cli/src/main.rs b/crates/dbx-cli/src/main.rs index 788c9bec49..eed169680d 100644 --- a/crates/dbx-cli/src/main.rs +++ b/crates/dbx-cli/src/main.rs @@ -903,6 +903,8 @@ mod tests { columns: Vec::new(), column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: Vec::new(), affected_rows: 2, execution_time_ms: 0, diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 4e24971324..c9e42a0b53 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -4427,6 +4427,8 @@ mod tests { columns: vec!["datcompatibility".to_string()], column_types: vec!["text".to_string()], column_sortables: vec![true], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!("M")]], affected_rows: 0, execution_time_ms: 1, diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index fe409fe265..2053250d28 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -880,7 +880,7 @@ fn is_export_insert_column( ) -> bool { !is_internal_export_column(database_type, column) && !is_postgres_tsvector_export_column(database_type, column_type) - && !(database_type == Some(DatabaseType::Mysql) && is_mysql_generated_column_extra(column_extra)) + && (database_type != Some(DatabaseType::Mysql) || !is_mysql_generated_column_extra(column_extra)) } fn is_postgres_json_export_column(database_type: Option, column_type: Option<&str>) -> bool { diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 77311c089a..64be1c2cfa 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -487,6 +487,8 @@ fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows: columns, column_types, column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows: 0, execution_time_ms, @@ -656,6 +658,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), diff --git a/crates/dbx-core/src/db/cloudflare_d1/mod.rs b/crates/dbx-core/src/db/cloudflare_d1/mod.rs index c4350398ae..c91c69c9b2 100644 --- a/crates/dbx-core/src/db/cloudflare_d1/mod.rs +++ b/crates/dbx-core/src/db/cloudflare_d1/mod.rs @@ -349,6 +349,8 @@ fn query_result( columns: result.columns, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: result.rows, affected_rows, execution_time_ms, diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index 4a91f8cf9c..48693f73fa 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -1293,6 +1293,8 @@ fn parse_elasticsearch_response_with_sql_parser( columns, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows: row_count, execution_time_ms: start.elapsed().as_millis(), @@ -1316,6 +1318,8 @@ fn parse_elasticsearch_response_with_sql_parser( columns, column_types, column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows: row_count, execution_time_ms: start.elapsed().as_millis(), @@ -1485,6 +1489,8 @@ fn raw_json_response_result( columns: vec!["status".to_string(), "response".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::Value::Number(status.into()), serde_json::Value::String(body_text.into())]], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -1549,6 +1555,8 @@ fn parse_elasticsearch_rest_response_with_sql_parser( columns: vec!["response".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows, execution_time_ms: start.elapsed().as_millis(), @@ -1980,6 +1988,8 @@ pub(crate) fn parse_tabular_sql_response( columns: column_names, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: result_rows, affected_rows: total_key .and_then(|key| body.get(key)) diff --git a/crates/dbx-core/src/db/influxdb_driver.rs b/crates/dbx-core/src/db/influxdb_driver.rs index 2009ef36d2..eee9dab4df 100644 --- a/crates/dbx-core/src/db/influxdb_driver.rs +++ b/crates/dbx-core/src/db/influxdb_driver.rs @@ -476,6 +476,8 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) - columns: s.columns.clone(), column_types: vec![], column_sortables: s.columns.iter().map(|_| false).collect(), + spatial_columns: vec![], + spatial_values: vec![], rows: s.values.clone(), affected_rows: s.values.len() as u64, execution_time_ms: start.elapsed().as_millis(), @@ -488,6 +490,8 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) - columns: vec![], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -630,6 +634,8 @@ fn parse_flux_csv(text: &str, start: Instant) -> Result { let columns = headers.unwrap_or_default(); Ok(QueryResult { column_sortables: columns.iter().map(|_| false).collect(), + spatial_columns: vec![], + spatial_values: vec![], columns, column_types: vec![], affected_rows: rows.len() as u64, diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 9cfc1aea61..01bf8be643 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -18,7 +18,7 @@ use crate::sql::{starts_with_executable_sql_keyword, starts_with_executable_sql_ use crate::types::{ ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo, - ForeignKeyInfo, IndexInfo, ObjectInfo, ObjectStatistics, QueryResult, TableInfo, TriggerInfo, + ForeignKeyInfo, IndexInfo, ObjectInfo, ObjectStatistics, QueryResult, SpatialColumnBuilder, TableInfo, TriggerInfo, }; use super::file_validator::validate_file_path; @@ -456,10 +456,8 @@ pub(crate) fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_j return row_get::, _>(row, idx) .map(|bytes| { if matches!(column.column_type(), ColumnType::MYSQL_TYPE_GEOMETRY) { - // MySQL prefixes geometry WKB with a 4-byte SRID. - // Strip it before passing to the WKB parser. - let wkb = if bytes.len() >= 4 { &bytes[4..] } else { &bytes }; - super::wkb::wkb_to_wkt(wkb) + decode_mysql_geometry(&bytes) + .map(|geometry| geometry.wkt) .map(serde_json::Value::String) .unwrap_or_else(|| super::binary_value_to_json(&bytes)) } else { @@ -504,6 +502,63 @@ pub(crate) fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_j .unwrap_or(serde_json::Value::Null) } +fn decode_mysql_geometry(bytes: &[u8]) -> Option { + if bytes.len() >= 5 && matches!(bytes[4], 0 | 1) { + let prefix: [u8; 4] = bytes[..4].try_into().ok()?; + if let Some(mut geometry) = super::wkb::decode_wkb_geometry(&bytes[4..]) { + if geometry.srid.is_none() { + let srid = u32::from_le_bytes(prefix); + geometry.srid = (srid != 0).then_some(srid); + } + return Some(geometry); + } + } + super::wkb::decode_wkb_geometry(bytes) +} + +fn mysql_spatial_column_builder(columns: &[mysql_async::Column]) -> SpatialColumnBuilder { + SpatialColumnBuilder::new( + columns + .iter() + .enumerate() + .filter_map(|(index, column)| (column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY).then_some(index)), + ) +} + +fn mysql_row_to_json_with_srids( + row: &mysql_async::Row, + spatial_columns: &mut SpatialColumnBuilder, +) -> (Vec, Vec>) { + let mut srids = vec![None; row.len()]; + let values = (0..row.len()) + .map(|idx| { + let is_geometry = row + .columns_ref() + .get(idx) + .is_some_and(|column| column.column_type() == ColumnType::MYSQL_TYPE_GEOMETRY); + if !is_geometry { + return mysql_value_to_json(row, idx); + } + let Some(bytes) = row_get::, _>(row, idx) else { + spatial_columns.observe(idx, None); + return serde_json::Value::Null; + }; + match decode_mysql_geometry(&bytes) { + Some(geometry) => { + spatial_columns.observe(idx, geometry.srid); + srids[idx] = geometry.srid; + serde_json::Value::String(geometry.wkt) + } + None => { + spatial_columns.observe(idx, None); + super::binary_value_to_json(&bytes) + } + } + }) + .collect(); + (values, srids) +} + fn mysql_temporal_value_to_json( column_type: ColumnType, datetime: Option, @@ -3540,6 +3595,8 @@ async fn execute_result_set_with_text_protocol_on_conn( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: result.affected_rows(), execution_time_ms: start.elapsed().as_millis(), @@ -3551,20 +3608,28 @@ async fn execute_result_set_with_text_protocol_on_conn( } let columns: Vec = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect(); let column_types: Vec = result.columns_ref().iter().map(mysql_column_type_name).collect(); + let mut spatial_columns = mysql_spatial_column_builder(result.columns_ref()); if should_collect_text_result_set(sql, row_limit, max_rows) { let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; let truncated = rows.len() > row_limit; + let mut spatial_values = Vec::new(); let result_rows = rows .iter() .take(row_limit) - .map(|row| (0..row.len()).map(|i| mysql_value_to_json(row, i)).collect()) + .map(|row| { + let (values, srids) = mysql_row_to_json_with_srids(row, &mut spatial_columns); + spatial_values.push(srids); + values + }) .collect(); return Ok(QueryResult { columns, column_types, column_sortables: vec![], + spatial_columns: spatial_columns.finish(), + spatial_values, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -3576,6 +3641,8 @@ async fn execute_result_set_with_text_protocol_on_conn( } let mut result_rows: Vec> = Vec::new(); + let mut spatial_values: Vec>> = Vec::new(); + let mut truncated = false; let mut stream = result .stream::() .await @@ -3584,22 +3651,21 @@ async fn execute_result_set_with_text_protocol_on_conn( while let Some(row) = stream.next().await { let row = row.map_err(|e| e.to_string())?; - let values: Vec = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect(); - result_rows.push(values); - if result_rows.len() > row_limit { + if result_rows.len() >= row_limit { + truncated = true; break; } - } - - let truncated = result_rows.len() > row_limit; - if truncated { - result_rows.truncate(row_limit); + let (values, srids) = mysql_row_to_json_with_srids(&row, &mut spatial_columns); + result_rows.push(values); + spatial_values.push(srids); } Ok(QueryResult { columns, column_types, column_sortables: vec![], + spatial_columns: spatial_columns.finish(), + spatial_values, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -3631,8 +3697,11 @@ async fn execute_result_set_with_prepared_protocol_on_conn( let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?; let columns: Vec = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect(); let column_types: Vec = result.columns_ref().iter().map(mysql_column_type_name).collect(); + let mut spatial_columns = mysql_spatial_column_builder(result.columns_ref()); let mut result_rows: Vec> = Vec::new(); + let mut spatial_values: Vec>> = Vec::new(); + let mut truncated = false; let mut stream = result .stream::() .await @@ -3641,22 +3710,21 @@ async fn execute_result_set_with_prepared_protocol_on_conn( while let Some(row) = stream.next().await { let row = row.map_err(|e| e.to_string())?; - let values: Vec = (0..row.len()).map(|i| mysql_value_to_json(&row, i)).collect(); - result_rows.push(values); - if result_rows.len() > row_limit { + if result_rows.len() >= row_limit { + truncated = true; break; } - } - - let truncated = result_rows.len() > row_limit; - if truncated { - result_rows.truncate(row_limit); + let (values, srids) = mysql_row_to_json_with_srids(&row, &mut spatial_columns); + result_rows.push(values); + spatial_values.push(srids); } Ok(QueryResult { columns, column_types, column_sortables: vec![], + spatial_columns: spatial_columns.finish(), + spatial_values, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -3916,6 +3984,8 @@ pub async fn execute_query_on_conn_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows, execution_time_ms: start.elapsed().as_millis(), @@ -4246,6 +4316,39 @@ mod tests { } } + #[test] + fn mysql_geometry_decoder_retains_srid_prefix() { + let mut raw = 3857_u32.to_le_bytes().to_vec(); + raw.extend_from_slice(&[ + 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, + ]); + let decoded = decode_mysql_geometry(&raw).unwrap(); + assert_eq!(decoded.wkt, "POINT(1 2)"); + assert_eq!(decoded.srid, Some(3857)); + } + + #[test] + fn mysql_geometry_decoder_accepts_unprefixed_wkb() { + let raw = [ + 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, + ]; + let decoded = decode_mysql_geometry(&raw).unwrap(); + assert_eq!(decoded.wkt, "POINT(1 2)"); + assert_eq!(decoded.srid, None); + } + + #[test] + fn mysql_geometry_srid_zero_is_unknown() { + let mut raw = 0_u32.to_le_bytes().to_vec(); + raw.extend_from_slice(&[ + 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, + ]); + assert_eq!(decode_mysql_geometry(&raw).unwrap().srid, None); + } + #[test] fn bytes_to_string_reuses_valid_utf8_and_falls_back_lossy() { assert_eq!(super::bytes_to_string_lossy("héllo 世界".as_bytes().to_vec()), "héllo 世界"); diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 8eb213ae34..503089b46c 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -27,7 +27,7 @@ use crate::types::{ ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo, DatabaseStorageInfo, ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, - OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo, + OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, SpatialColumnBuilder, TableInfo, TriggerInfo, }; pub(crate) const GAUSSDB_COMPATIBILITY_SQL: &str = @@ -605,6 +605,86 @@ pub(crate) fn pg_value_to_json_classified(row: &Row, idx: usize, col_type: PgCol } } +fn pg_value_to_json_with_srid(row: &Row, idx: usize, col_type: PgColType) -> (serde_json::Value, Option) { + if col_type != PgColType::Geometry { + return (pg_value_to_json_classified(row, idx, col_type), None); + } + if let Ok(PgRawBytes(raw)) = row.try_get::<_, PgRawBytes>(idx) { + return match super::wkb::decode_wkb_geometry(&raw) { + Some(geometry) => (serde_json::Value::String(geometry.wkt), geometry.srid), + None => (super::binary_value_to_json(&raw), None), + }; + } + (serde_json::Value::Null, None) +} + +fn pg_text_fallback_value(value: &str, col_type: Option) -> (serde_json::Value, Option) { + let (value, srid, _) = pg_text_fallback_value_with_spatial(value, col_type); + (value, srid) +} + +fn pg_text_fallback_value_with_spatial( + value: &str, + col_type: Option, +) -> (serde_json::Value, Option, bool) { + match col_type { + Some(PgColType::Geometry) => decode_pg_text_wkb(value) + .map(|geometry| (serde_json::Value::String(geometry.wkt), geometry.srid, true)) + .or_else(|| split_pg_ewkt(value, false).map(|(value, srid)| (value, srid, true))) + .unwrap_or_else(|| (serde_json::Value::String(value.to_string()), None, true)), + Some(_) => (serde_json::Value::String(value.to_string()), None, false), + None => decode_pg_text_wkb(value) + .map(|geometry| (serde_json::Value::String(geometry.wkt), geometry.srid, true)) + .or_else(|| split_pg_ewkt(value, true).map(|(value, srid)| (value, srid, true))) + .unwrap_or_else(|| (serde_json::Value::String(value.to_string()), None, false)), + } +} + +fn decode_pg_text_wkb(value: &str) -> Option { + let trimmed = value.trim(); + let hex = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .or_else(|| trimmed.strip_prefix("\\x")) + .or_else(|| trimmed.strip_prefix("\\X")) + .unwrap_or(trimmed); + if hex.len() < 10 || !hex.len().is_multiple_of(2) || !matches!(&hex[..2], "00" | "01") || !hex.is_ascii() { + return None; + } + let bytes = hex + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = std::str::from_utf8(pair).ok()?; + u8::from_str_radix(text, 16).ok() + }) + .collect::>>()?; + super::wkb::decode_wkb_geometry(&bytes) +} + +fn split_pg_ewkt(value: &str, require_recognizable_wkt: bool) -> Option<(serde_json::Value, Option)> { + let rest = value.strip_prefix("SRID=")?; + let (srid, wkt) = rest.split_once(';')?; + let srid = srid.parse::().ok().and_then(|value| u32::try_from(value).ok())?; + if require_recognizable_wkt && !is_recognizable_wkt(wkt) { + return None; + } + Some((serde_json::Value::String(wkt.to_string()), (srid != 0).then_some(srid))) +} + +fn is_recognizable_wkt(value: &str) -> bool { + let trimmed = value.trim_start(); + const TYPES: [&str; 7] = + ["POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION"]; + TYPES.iter().any(|geometry_type| { + trimmed + .get(..geometry_type.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(geometry_type)) + .and_then(|_| trimmed.as_bytes().get(geometry_type.len())) + .is_some_and(|next| next.is_ascii_whitespace() || *next == b'(') + }) +} + /// Serialize a pgvector `vector` component with f32 shortest round-trip decimal text. /// /// Casting through `f64` (or fixed fractional rounding) either expands binary noise or @@ -883,6 +963,13 @@ async fn execute_select_prepared( ); tokio::pin!(stream); let mut result_rows: Vec> = Vec::new(); + let mut spatial_values: Vec>> = Vec::new(); + let mut spatial_columns = SpatialColumnBuilder::new( + column_classes + .iter() + .enumerate() + .filter_map(|(index, col_type)| (*col_type == PgColType::Geometry).then_some(index)), + ); let mut truncated = false; let rows_start = Instant::now(); @@ -892,13 +979,19 @@ async fn execute_select_prepared( break; } let row = row_result?; - result_rows.push( - (0..row.columns().len()) - .map(|i| { - pg_value_to_json_classified(&row, i, column_classes.get(i).copied().unwrap_or(PgColType::Other)) - }) - .collect(), - ); + let mut values = Vec::with_capacity(row.columns().len()); + let mut row_srids = vec![None; row.columns().len()]; + for (i, row_srid) in row_srids.iter_mut().enumerate() { + let col_type = column_classes.get(i).copied().unwrap_or(PgColType::Other); + let (value, srid) = pg_value_to_json_with_srid(&row, i, col_type); + if col_type == PgColType::Geometry { + spatial_columns.observe(i, srid); + *row_srid = srid; + } + values.push(value); + } + result_rows.push(values); + spatial_values.push(row_srids); } log::info!( "[postgres][select:rows:done] elapsed_ms={} total_ms={} row_count={} truncated={}", @@ -908,10 +1001,13 @@ async fn execute_select_prepared( truncated ); + let (spatial_columns, spatial_values) = spatial_columns.finish_with_values(spatial_values); Ok(PreparedSelectOutcome::Complete(QueryResult { columns, column_types, column_sortables: Vec::new(), + spatial_columns, + spatial_values, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -937,6 +1033,15 @@ async fn execute_select_text( tokio::pin!(stream); let mut columns: Vec = Vec::new(); let mut result_rows: Vec> = Vec::new(); + let mut spatial_values: Vec>> = Vec::new(); + let column_classes = + prepared_column_types.as_ref().map(|types| classify_pg_column_types(types)).unwrap_or_default(); + let mut spatial_columns = SpatialColumnBuilder::new( + column_classes + .iter() + .enumerate() + .filter_map(|(index, col_type)| (*col_type == PgColType::Geometry).then_some(index)), + ); let mut truncated = false; while let Some(message) = stream.next().await { @@ -953,13 +1058,25 @@ async fn execute_select_text( break; } let mut values = Vec::with_capacity(row.len()); - for i in 0..row.len() { - values.push(match row.try_get(i).map_err(pg_error_to_string)? { - Some(value) => serde_json::Value::String(value.to_string()), - None => serde_json::Value::Null, - }); + let mut row_srids = vec![None; row.len()]; + for (i, row_srid) in row_srids.iter_mut().enumerate() { + match row.try_get(i).map_err(pg_error_to_string)? { + Some(value) => { + let (decoded, srid, is_spatial) = + pg_text_fallback_value_with_spatial(value, column_classes.get(i).copied()); + values.push(decoded); + if is_spatial { + spatial_columns.observe(i, srid); + *row_srid = srid; + } + } + None => { + values.push(serde_json::Value::Null); + } + } } result_rows.push(values); + spatial_values.push(row_srids); } Err(_) if result_rows.len() >= row_limit => { truncated = true; @@ -971,10 +1088,13 @@ async fn execute_select_text( } } + let (spatial_columns, spatial_values) = spatial_columns.finish_with_values(spatial_values); Ok(QueryResult { column_types: matching_pg_text_column_types(&columns, prepared_column_types), columns, column_sortables: Vec::new(), + spatial_columns, + spatial_values, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -1112,6 +1232,8 @@ async fn stream_select_query_text( let stream = client.simple_query_raw(sql).await.map_err(pg_error_to_string)?; tokio::pin!(stream); let mut columns: Vec = Vec::new(); + let column_classes = + prepared_column_types.as_ref().map(|types| classify_pg_column_types(types)).unwrap_or_default(); let mut rows_streamed = 0_u64; while let Some(message) = stream.next().await { match message.map_err(pg_error_to_string)? { @@ -1132,7 +1254,7 @@ async fn stream_select_query_text( let mut values = Vec::with_capacity(row.len()); for i in 0..row.len() { values.push(match row.try_get(i).map_err(pg_error_to_string)? { - Some(value) => serde_json::Value::String(value.to_string()), + Some(value) => pg_text_fallback_value(value, column_classes.get(i).copied()).0, None => serde_json::Value::Null, }); } @@ -1201,7 +1323,7 @@ pub async fn stream_query_rows( match stream_query_rows_on_client(&client, sql, max_rows, cancelled, &mut on_row).await { Ok(rows) => Ok(rows), Err(error) if should_retry_postgres_text_query_message(&error.to_ascii_lowercase()) => { - stream_query_rows_text_on_client(&client, sql, max_rows, cancelled, &mut on_row).await + stream_query_rows_text_on_client(&client, sql, max_rows, cancelled, None, &mut on_row).await } Err(error) => Err(error), } @@ -1221,7 +1343,7 @@ async fn stream_query_rows_on_client( "[postgres][row_stream:text_fallback] unsupported_type={} switching_to=simple_query", unsupported_type ); - return stream_query_rows_text_on_client(client, sql, max_rows, cancelled, on_row).await; + return stream_query_rows_text_on_client(client, sql, max_rows, cancelled, Some(&column_classes), on_row).await; } let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new(); let stream = client.query_raw(&stmt, params).await.map_err(pg_error_to_string)?; @@ -1252,6 +1374,7 @@ async fn stream_query_rows_text_on_client( sql: &str, max_rows: Option, cancelled: &AtomicBool, + column_classes: Option<&[PgColType]>, on_row: &mut impl FnMut(&[serde_json::Value]) -> Result<(), String>, ) -> Result { let stream = client.simple_query_raw(sql).await.map_err(pg_error_to_string)?; @@ -1271,7 +1394,9 @@ async fn stream_query_rows_text_on_client( let mut values = Vec::with_capacity(row.len()); for i in 0..row.len() { values.push(match row.try_get(i).map_err(pg_error_to_string)? { - Some(value) => serde_json::Value::String(value.to_string()), + Some(value) => { + pg_text_fallback_value(value, column_classes.and_then(|classes| classes.get(i)).copied()).0 + } None => serde_json::Value::Null, }); } @@ -2871,6 +2996,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), @@ -3379,6 +3506,8 @@ async fn execute_query_with_max_rows_inner( columns: vec![], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), @@ -4347,6 +4476,45 @@ mod tests { assert_eq!(classify_pg_type(""), PgColType::Other); } + #[test] + fn postgres_text_spatial_value_separates_srid_from_wkt() { + assert_eq!( + pg_text_fallback_value("SRID=4326;POINT(1 2)", Some(PgColType::Geometry)), + (serde_json::json!("POINT(1 2)"), Some(4326)) + ); + assert_eq!( + pg_text_fallback_value("SRID=0;POINT(1 2)", Some(PgColType::Geometry)), + (serde_json::json!("POINT(1 2)"), None) + ); + assert_eq!( + pg_text_fallback_value("POINT(1 2)", Some(PgColType::Geometry)), + (serde_json::json!("POINT(1 2)"), None) + ); + } + + #[test] + fn postgres_text_spatial_value_decodes_hex_ewkb() { + let ewkb = "0101000020E6100000000000000000F03F0000000000000040"; + for value in [ewkb.to_string(), format!("0x{ewkb}"), format!("\\x{ewkb}")] { + assert_eq!( + pg_text_fallback_value(&value, Some(PgColType::Geometry)), + (serde_json::json!("POINT(1 2)"), Some(4326)) + ); + assert_eq!(pg_text_fallback_value(&value, None), (serde_json::json!("POINT(1 2)"), Some(4326))); + } + + let srid_zero = "010100002000000000000000000000F03F0000000000000040"; + assert_eq!(pg_text_fallback_value(srid_zero, None), (serde_json::json!("POINT(1 2)"), None)); + } + + #[test] + fn postgres_text_fallback_does_not_reinterpret_ordinary_text() { + for value in ["SRID=abc;POINT(1 2)", "SRID=4326;not geometry", "0101-not-hex", "POINTLESS"] { + assert_eq!(pg_text_fallback_value(value, None), (serde_json::json!(value), None)); + } + assert_eq!(pg_text_fallback_value("SRID=4326;point(1 2)", None), (serde_json::json!("point(1 2)"), Some(4326))); + } + struct DockerPostgres { name: String, port: u16, diff --git a/crates/dbx-core/src/db/rqlite_driver.rs b/crates/dbx-core/src/db/rqlite_driver.rs index c276845755..174ed952b4 100644 --- a/crates/dbx-core/src/db/rqlite_driver.rs +++ b/crates/dbx-core/src/db/rqlite_driver.rs @@ -297,6 +297,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows, execution_time_ms: start.elapsed().as_millis(), @@ -347,6 +349,8 @@ fn query_result_from_rqlite_result( columns: result.columns, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: result.values, affected_rows: 0, execution_time_ms, diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index bc354a4494..b17445e981 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -2457,6 +2457,8 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option Vec { row.cells().map(|(_, cell)| sqlserver_cell_to_json(cell)).collect() } +fn sqlserver_spatial_marker(value: serde_json::Value) -> (serde_json::Value, Option) { + let serde_json::Value::String(text) = value else { + return (value, None); + }; + let Some(rest) = text.strip_prefix("SRID=") else { + return (serde_json::Value::String(text), None); + }; + let Some((srid, wkt)) = rest.split_once(';') else { + return (serde_json::Value::String(text), None); + }; + let Ok(srid) = srid.parse::() else { + return (serde_json::Value::String(wkt.to_string()), None); + }; + let srid = u32::try_from(srid).ok().filter(|value| *value != 0); + (serde_json::Value::String(wkt.to_string()), srid) +} + +fn row_to_json_with_spatial_metadata( + row: &tiberius::Row, + spatial_columns: &[SqlServerSpatialColumn], + on_srid: impl FnMut(usize, Option), +) -> (Vec, Vec>) { + let mut values = row_to_json(row); + let srids = decode_sqlserver_spatial_values(&mut values, spatial_columns, on_srid); + (values, srids) +} + +/// Decode `SRID=n;WKT` markers into plain WKT, returning the per-cell SRIDs +/// (`None` for non-spatial cells or unknown SRIDs). Every geometry value keeps +/// its own SRID so mixed-SRID columns stay correct. +fn decode_sqlserver_spatial_values( + values: &mut [serde_json::Value], + spatial_columns: &[SqlServerSpatialColumn], + mut on_srid: impl FnMut(usize, Option), +) -> Vec> { + let mut srids = vec![None; values.len()]; + for spatial_column in spatial_columns { + let Some(value) = values.get_mut(spatial_column.column_index) else { + continue; + }; + let (wkt, srid) = sqlserver_spatial_marker(std::mem::take(value)); + *value = wkt; + srids[spatial_column.column_index] = srid; + on_srid(spatial_column.column_index, srid); + } + srids +} + +fn restore_sqlserver_spatial_column_types(column_types: &mut [String], spatial_columns: &[SqlServerSpatialColumn]) { + for spatial_column in spatial_columns { + if let Some(column_type) = column_types.get_mut(spatial_column.column_index) { + column_type.clone_from(&spatial_column.column_type); + } + } +} + fn columns_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec { metadata.columns().iter().map(|c| c.name().to_string()).collect() } @@ -397,6 +453,8 @@ fn server_messages_query_result(messages: Vec, start: Instant) -> Option columns: vec![], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -413,11 +471,15 @@ async fn collect_first_result_limited( mut stream: QueryStream<'_>, start: Instant, max_rows: Option, + spatial_columns: &[SqlServerSpatialColumn], ) -> Result { let row_limit = query_result_row_limit(max_rows); let mut columns: Vec = vec![]; let mut column_types: Vec = vec![]; let mut rows: Vec> = Vec::new(); + let mut spatial_values: Vec>> = Vec::new(); + let mut spatial_values_builder = + SpatialColumnBuilder::new(spatial_columns.iter().map(|column| column.column_index)); let mut truncated = false; while let Some(item) = stream.try_next().await.map_err(|e| e.to_string())? { @@ -425,11 +487,17 @@ async fn collect_first_result_limited( QueryItem::Metadata(metadata) if metadata.result_index() == 0 => { columns = columns_from_metadata(&metadata); column_types = column_types_from_metadata(&metadata); + restore_sqlserver_spatial_column_types(&mut column_types, spatial_columns); } QueryItem::Metadata(_) => {} QueryItem::Row(row) if row.result_index() == 0 => { if rows.len() < row_limit { - rows.push(row_to_json(&row)); + let (values, srids) = + row_to_json_with_spatial_metadata(&row, spatial_columns, |column_index, srid| { + spatial_values_builder.observe(column_index, srid); + }); + rows.push(values); + spatial_values.push(srids); } else { truncated = true; } @@ -438,10 +506,14 @@ async fn collect_first_result_limited( } } + restore_sqlserver_spatial_column_types(&mut column_types, spatial_columns); + Ok(QueryResult { columns, column_types, column_sortables: vec![], + spatial_columns: spatial_values_builder.finish(), + spatial_values, rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -477,6 +549,24 @@ struct SqlServerDescribedColumn { user_type_name: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SqlServerSpatialColumn { + column_index: usize, + column_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SqlServerUnsafeTypeQuery { + sql: String, + spatial_columns: Vec, +} + +impl SqlServerUnsafeTypeQuery { + fn plain(sql: &str) -> Self { + Self { sql: sql.to_string(), spatial_columns: Vec::new() } + } +} + #[derive(Debug, PartialEq, Eq)] struct SqlServerLegacyProbe { source_sql: String, @@ -609,7 +699,10 @@ fn is_blocking_sqlserver_unsafe_probe_error(error: &str) -> bool { error.starts_with(SQLSERVER_UNSAFE_PROBE_BLOCK_ERROR_PREFIX) } -async fn sqlserver_unsafe_type_query(client: &mut SqlServerClient, sql: &str) -> Result, String> { +async fn sqlserver_unsafe_type_query( + client: &mut SqlServerClient, + sql: &str, +) -> Result, String> { if !is_single_sqlserver_select(sql) { return Ok(None); } @@ -627,7 +720,7 @@ fn sqlserver_legacy_probe(sql: &str) -> Option { fn sqlserver_legacy_probe_with_nonce(sql: &str, nonce: &str) -> Option { let statement = normalized_sqlserver_select_statement(sql)?; - let output_names = sqlserver_projection_output_names(&statement); + let output_names = sqlserver_projection_output_names(&statement.inner); let source_alias = quote_sqlserver_identifier("dbx_probe_source"); let (source_sql, output_name_overrides) = if let Some(names) = &output_names { let aliases = (0..names.len()) @@ -635,11 +728,11 @@ fn sqlserver_legacy_probe_with_nonce(sql: &str, nonce: &str) -> Option>() .join(", "); - (format!("({statement}) AS {source_alias}({aliases})"), Vec::new()) - } else if let Some(wildcard_probe) = sqlserver_wildcard_projection_probe(&statement, nonce) { + (format!("({}) AS {source_alias}({aliases})", statement.inner), Vec::new()) + } else if let Some(wildcard_probe) = sqlserver_wildcard_projection_probe(&statement.inner, nonce) { (format!("({}) AS {source_alias}", wildcard_probe.statement), wildcard_probe.output_name_overrides) } else { - (format!("({statement}) AS {source_alias}"), Vec::new()) + (format!("({}) AS {source_alias}", statement.inner), Vec::new()) }; Some(SqlServerLegacyProbe { source_sql, output_names, output_name_overrides }) } @@ -706,7 +799,10 @@ fn sqlserver_wildcard_projection_probe(statement: &str, nonce: &str) -> Option Option { +fn build_sqlserver_unsafe_type_query( + sql: &str, + columns: &[SqlServerDescribedColumn], +) -> Option { if columns.is_empty() || !columns.iter().any(is_sqlserver_unsafe_column) { return None; } @@ -724,7 +820,9 @@ fn build_sqlserver_unsafe_type_query(sql: &str, columns: &[SqlServerDescribedCol let source_column = quote_sqlserver_identifier(&source_columns[index]); let value_ref = format!("{source_alias}.{source_column}"); if is_sqlserver_spatial_column(column) { - format!("{quoted_output} = CASE WHEN {value_ref} IS NULL THEN NULL ELSE {value_ref}.STAsText() END") + format!( + "{quoted_output} = CASE WHEN {value_ref} IS NULL THEN NULL ELSE N'SRID=' + CONVERT(nvarchar(20), {value_ref}.STSrid) + N';' + {value_ref}.AsTextZM() END" + ) } else if is_sqlserver_variant_column(column) { format!("{quoted_output} = CAST({value_ref} AS NVARCHAR(MAX))") } else { @@ -734,7 +832,34 @@ fn build_sqlserver_unsafe_type_query(sql: &str, columns: &[SqlServerDescribedCol .collect::>() .join(", "); - Some(format!("SELECT {select_list} FROM ({statement}) AS {source_alias}({source_alias_list})")) + let spatial_columns = columns + .iter() + .enumerate() + .filter_map(|(column_index, column)| { + sqlserver_spatial_column_type(column) + .map(|column_type| SqlServerSpatialColumn { column_index, column_type: column_type.to_string() }) + }) + .collect(); + + // Re-apply the original ORDER BY / OFFSET / FETCH on the outer query so + // ordering and pagination survive the derived-table rewrite. When the sort + // keys cannot be safely migrated, refuse the rewrite rather than silently + // dropping order semantics (callers fall back to the plain statement). + let order_by = match &statement.order_by { + Some(order_by) => { + let outer_order_by = sqlserver_outer_order_by(order_by, columns)?; + format!(" {outer_order_by}") + } + None => String::new(), + }; + + Some(SqlServerUnsafeTypeQuery { + sql: format!( + "SELECT {select_list} FROM ({}) AS {source_alias}({source_alias_list}){order_by}", + statement.inner + ), + spatial_columns, + }) } fn is_sqlserver_unsafe_column(column: &SqlServerDescribedColumn) -> bool { @@ -742,12 +867,19 @@ fn is_sqlserver_unsafe_column(column: &SqlServerDescribedColumn) -> bool { } fn is_sqlserver_spatial_column(column: &SqlServerDescribedColumn) -> bool { - [&column.system_type_name, &column.user_type_name].into_iter().flatten().any(|name| { + sqlserver_spatial_column_type(column).is_some() +} + +fn sqlserver_spatial_column_type(column: &SqlServerDescribedColumn) -> Option<&'static str> { + [&column.system_type_name, &column.user_type_name].into_iter().flatten().find_map(|name| { let normalized = name.trim().trim_matches(['[', ']']).to_ascii_lowercase(); - normalized == "geometry" - || normalized == "geography" - || normalized.ends_with(".geometry") - || normalized.ends_with(".geography") + if normalized == "geometry" || normalized.ends_with(".geometry") { + Some("geometry") + } else if normalized == "geography" || normalized.ends_with(".geography") { + Some("geography") + } else { + None + } }) } @@ -758,7 +890,16 @@ fn is_sqlserver_variant_column(column: &SqlServerDescribedColumn) -> bool { }) } -fn normalized_sqlserver_select_statement(sql: &str) -> Option { +struct SqlServerNormalizedStatement { + /// Statement with the trailing ORDER BY (and any OFFSET/FETCH) removed so it + /// can be used as a derived table subquery. + inner: String, + /// The removed trailing ORDER BY clause verbatim, including any + /// `OFFSET ... ROWS [FETCH NEXT ... ROWS ONLY]` tail, if the query had one. + order_by: Option, +} + +fn normalized_sqlserver_select_statement(sql: &str) -> Option { let statement = trim_sqlserver_statement(sql); let trimmed = statement.trim_start(); if trimmed.is_empty() || !trimmed.get(..6).is_some_and(|prefix| prefix.eq_ignore_ascii_case("SELECT")) { @@ -768,19 +909,110 @@ fn normalized_sqlserver_select_statement(sql: &str) -> Option { return None; } - // Strip trailing ORDER BY so the statement can be used as a derived table - // subquery. SQL Server requires TOP / OFFSET / FOR XML alongside ORDER BY - // in subqueries, none of which are version-safe across 2008–2022. - let mut statement = trimmed.to_string(); - let tokens = top_level_sqlserver_tokens(&statement); - for index in (0..tokens.len().saturating_sub(1)).rev() { - if tokens[index].text == "ORDER" && tokens.get(index + 1).is_some_and(|token| token.text == "BY") { - statement.truncate(tokens[index].start); - statement = statement.trim_end().to_string(); - break; - } + // SQL Server rejects ORDER BY inside a derived table subquery (it requires + // TOP / OFFSET / FOR XML alongside it, none of which are version-safe across + // 2008–2022). Move the trailing ORDER BY out of the inner statement so the + // outer rewrite can re-apply it (see `sqlserver_outer_order_by`). When a + // top-level TOP is present the ORDER BY also drives row selection, so it must + // stay inside the derived table too (TOP makes ORDER BY legal in subqueries + // on every SQL Server version) while the outer rewrite re-applies it for the + // final result order. + let tokens = top_level_sqlserver_tokens(trimmed); + let order_index = (0..tokens.len().saturating_sub(1)) + .rev() + .find(|index| tokens[*index].text == "ORDER" && tokens.get(index + 1).is_some_and(|token| token.text == "BY")); + let Some(order_index) = order_index else { + return Some(SqlServerNormalizedStatement { inner: trimmed.to_string(), order_by: None }); + }; + let order_by = trimmed[tokens[order_index].start..].trim().to_string(); + if has_top_level_top(trimmed) { + return Some(SqlServerNormalizedStatement { inner: trimmed.to_string(), order_by: Some(order_by) }); } - Some(statement) + let inner = trimmed[..tokens[order_index].start].trim_end().to_string(); + Some(SqlServerNormalizedStatement { inner, order_by: Some(order_by) }) +} + +/// Translate the original trailing ORDER BY clause (with optional OFFSET/FETCH) +/// into an equivalent clause for the outer rewrite, or return `None` when the +/// ordering cannot be guaranteed on the outer query. `None` covers ORDER BY keys +/// that reference columns absent from the projection, non-trivial expressions, +/// and keys targeting columns the rewrite transforms (spatial/variant); callers +/// must then fall back to executing the plain statement rather than silently +/// dropping order and pagination semantics. +fn sqlserver_outer_order_by(order_by: &str, columns: &[SqlServerDescribedColumn]) -> Option { + // OFFSET/FETCH carry no column references, so keep that tail verbatim and + // rebuild only the sort-key list against the outer projection. + let tokens = top_level_sqlserver_tokens(order_by); + let offset_start = tokens.iter().find(|token| token.text == "OFFSET").map(|token| token.start); + let (order_exprs_text, offset_tail) = match offset_start { + Some(start) => (&order_by[..start], order_by[start..].trim()), + None => (order_by, ""), + }; + + let wrapped = format!("SELECT 1 {order_exprs_text}"); + let statements = Parser::parse_sql(&MsSqlDialect {}, &wrapped).ok()?; + let [Statement::Query(query)] = statements.as_slice() else { + return None; + }; + let order_by = query.order_by.as_ref()?; + let OrderByKind::Expressions(order_exprs) = &order_by.kind else { + return None; + }; + if order_exprs.is_empty() { + return None; + } + + let mut parts = Vec::with_capacity(order_exprs.len()); + for order_expr in order_exprs { + let output_name = sqlserver_order_by_output_name(columns, &order_expr.expr)?; + let mut part = quote_sqlserver_identifier(&output_name); + if order_expr.options.asc == Some(false) { + part.push_str(" DESC"); + } + if let Some(nulls_first) = order_expr.options.nulls_first { + part.push_str(if nulls_first { " NULLS FIRST" } else { " NULLS LAST" }); + } + parts.push(part); + } + + let mut outer = format!("ORDER BY {}", parts.join(", ")); + if !offset_tail.is_empty() { + outer.push(' '); + outer.push_str(offset_tail); + } + Some(outer) +} + +/// Map an ORDER BY sort key to the outer rewrite's output column name. Returns +/// `None` for keys that do not resolve to a projection column, keys targeting a +/// column the rewrite transforms (spatial/variant), and any non-trivial +/// expression, none of which can be safely re-applied on the outer query. +fn sqlserver_order_by_output_name(columns: &[SqlServerDescribedColumn], expr: &Expr) -> Option { + let index = match expr { + Expr::Identifier(identifier) => sqlserver_projection_column_index(columns, &identifier.value)?, + Expr::CompoundIdentifier(identifiers) => { + sqlserver_projection_column_index(columns, identifiers.last()?.value.as_str())? + } + Expr::Value(value_with_span) => { + if let Value::Number(ordinal, _) = &value_with_span.value { + ordinal.parse::().ok()?.checked_sub(1)? + } else { + return None; + } + } + _ => return None, + }; + let column = columns.get(index)?; + if is_sqlserver_unsafe_column(column) { + return None; + } + Some(sqlserver_output_column_name(column, index)) +} + +fn sqlserver_projection_column_index(columns: &[SqlServerDescribedColumn], name: &str) -> Option { + columns.iter().position(|column| { + column.name.as_deref().map(str::trim).is_some_and(|output_name| output_name.eq_ignore_ascii_case(name)) + }) } fn trim_sqlserver_statement(sql: &str) -> String { @@ -900,6 +1132,20 @@ fn has_top_level_select_into(sql: &str) -> bool { tokens[select_index + 1..from_index].iter().any(|token| token.text == "INTO") } +fn has_top_level_top(sql: &str) -> bool { + let tokens = top_level_sqlserver_tokens(sql); + let Some(select_index) = tokens.iter().position(|token| token.text == "SELECT") else { + return false; + }; + let from_index = tokens + .iter() + .enumerate() + .find(|(index, token)| *index > select_index && token.text == "FROM") + .map(|(index, _)| index) + .unwrap_or(tokens.len()); + tokens[select_index + 1..from_index].iter().any(|token| token.text == "TOP") +} + fn skip_sqlserver_quoted(sql: &str, pos: usize, quote: char) -> usize { let mut i = pos + quote.len_utf8(); while i < sql.len() { @@ -963,6 +1209,8 @@ fn push_sqlserver_result_set(results: &mut Vec, result: Option, mut on_item: impl for<'a> FnMut(SqlServerStreamItem<'a>) -> Result<(), String>, ) -> Result { - let query_sql = match sqlserver_unsafe_type_query(client, sql).await { - Ok(Some(sql)) => sql, - Ok(None) => sql.to_string(), + let query = match sqlserver_unsafe_type_query(client, sql).await { + Ok(Some(query)) => query, + Ok(None) => SqlServerUnsafeTypeQuery::plain(sql), Err(error) if is_blocking_sqlserver_unsafe_probe_error(&error) => return Err(error), - Err(_) => sql.to_string(), + Err(_) => SqlServerUnsafeTypeQuery::plain(sql), }; - let mut stream = sqlserver_driver_result(client.query(query_sql.as_str(), &[])).await?; + let mut stream = sqlserver_driver_result(client.query(query.sql.as_str(), &[])).await?; let mut active_result_index: Option = None; let mut columns: Vec = Vec::new(); let mut column_types: Vec = Vec::new(); @@ -1057,6 +1305,7 @@ pub async fn stream_first_result_set( active_result_index = Some(metadata.result_index()); columns = columns_from_metadata(&metadata); column_types = column_types_from_metadata(&metadata); + restore_sqlserver_spatial_column_types(&mut column_types, &query.spatial_columns); on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?; columns_emitted = true; } @@ -1066,6 +1315,7 @@ pub async fn stream_first_result_set( active_result_index = Some(row.result_index()); columns = row.columns().iter().map(|c| c.name().to_string()).collect(); column_types = row.columns().iter().map(sqlserver_column_type_name).collect(); + restore_sqlserver_spatial_column_types(&mut column_types, &query.spatial_columns); on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?; columns_emitted = true; } @@ -1075,7 +1325,7 @@ pub async fn stream_first_result_set( if row_limit.is_some_and(|limit| rows_exported as usize >= limit) { break; } - let values = row_to_json(&row); + let (values, _) = row_to_json_with_spatial_metadata(&row, &query.spatial_columns, |_, _| {}); on_item(SqlServerStreamItem::Row(&values))?; rows_exported += 1; } @@ -2230,15 +2480,15 @@ pub async fn execute_query_with_max_rows( let start = Instant::now(); if starts_with_executable_sql_keyword(sql, &["SELECT", "EXEC", "WITH", "TABLE"]) { - let query_sql = match sqlserver_unsafe_type_query(client, sql).await { - Ok(Some(sql)) => sql, - Ok(None) => sql.to_string(), + let query = match sqlserver_unsafe_type_query(client, sql).await { + Ok(Some(query)) => query, + Ok(None) => SqlServerUnsafeTypeQuery::plain(sql), Err(error) if is_blocking_sqlserver_unsafe_probe_error(&error) => return Err(error), - Err(_) => sql.to_string(), + Err(_) => SqlServerUnsafeTypeQuery::plain(sql), }; let (result, messages) = capture_sqlserver_messages(async { - let stream = sqlserver_driver_result(client.query(query_sql.as_str(), &[])).await?; - sqlserver_driver_result(collect_first_result_limited(stream, start, max_rows)).await + let stream = sqlserver_driver_result(client.query(query.sql.as_str(), &[])).await?; + sqlserver_driver_result(collect_first_result_limited(stream, start, max_rows, &query.spatial_columns)).await }) .await; let mut result = query_result_with_server_messages(result?, messages); @@ -2256,6 +2506,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -2274,6 +2526,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: result.rows_affected().iter().sum::(), execution_time_ms: start.elapsed().as_millis(), @@ -2305,6 +2559,8 @@ pub async fn execute_batch_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: result.rows_affected().iter().sum::(), execution_time_ms: start.elapsed().as_millis(), @@ -2319,10 +2575,16 @@ pub async fn execute_batch_with_max_rows( if is_single_sqlserver_select(sql) { match sqlserver_unsafe_type_query(client, sql).await { - Ok(Some(query_sql)) => { + Ok(Some(query)) => { let (result, messages) = capture_sqlserver_messages(async { - let stream = sqlserver_driver_result(client.query(query_sql.as_str(), &[])).await?; - sqlserver_driver_result(collect_first_result_limited(stream, start, max_rows)).await + let stream = sqlserver_driver_result(client.query(query.sql.as_str(), &[])).await?; + sqlserver_driver_result(collect_first_result_limited( + stream, + start, + max_rows, + &query.spatial_columns, + )) + .await }) .await; return result.map(|result| { @@ -2366,6 +2628,8 @@ pub async fn execute_simple_batch_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -2563,20 +2827,23 @@ fn first_sql_tokens(sql: &str, limit: usize) -> Vec { mod tests { use super::{ build_sqlserver_unsafe_type_query, capture_sqlserver_messages, completion_context_from_query_result, - format_sqlserver_numeric, is_blocking_sqlserver_unsafe_probe_error, is_sqlserver_spatial_column, - is_sqlserver_variant_column, query_result_with_server_messages, requires_simple_query_batch, - restore_sqlserver_legacy_probe_output_names, sqlserver_batch_can_use_execute, sqlserver_bulk_token_row, + decode_sqlserver_spatial_values, format_sqlserver_numeric, is_blocking_sqlserver_unsafe_probe_error, + is_sqlserver_spatial_column, is_sqlserver_variant_column, query_result_with_server_messages, + requires_simple_query_batch, restore_sqlserver_legacy_probe_output_names, + restore_sqlserver_spatial_column_types, sqlserver_batch_can_use_execute, sqlserver_bulk_token_row, sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_completion_assistant_sql, sqlserver_dml_output_returns_rows, sqlserver_filter_definition_error, sqlserver_hidden_schema_names, sqlserver_indexes_sql, sqlserver_legacy_indexes_sql, sqlserver_legacy_probe, sqlserver_legacy_probe_with_nonce, sqlserver_list_objects_sql, sqlserver_list_schemas_sql, sqlserver_list_tables_sql, - sqlserver_probe_explicit_alias, sqlserver_schema_name_predicate, sqlserver_supports_session_database_switch, - sqlserver_table_comment_sql, sqlserver_triggers_sql, sqlserver_visible_object_predicate, - strip_dbx_sqlserver_row_number_column, SqlServerDescribedColumn, SqlServerProbeOutputNameOverride, - SqlServerResultSet, SQLSERVER_COMPLETION_CONTEXT_SQL, SQLSERVER_RESULT_TYPE_PROBE_SQL, + sqlserver_probe_explicit_alias, sqlserver_schema_name_predicate, sqlserver_spatial_marker, + sqlserver_supports_session_database_switch, sqlserver_table_comment_sql, sqlserver_triggers_sql, + sqlserver_visible_object_predicate, strip_dbx_sqlserver_row_number_column, SqlServerDescribedColumn, + SqlServerProbeOutputNameOverride, SqlServerResultSet, SqlServerSpatialColumn, SQLSERVER_COMPLETION_CONTEXT_SQL, + SQLSERVER_RESULT_TYPE_PROBE_SQL, }; use crate::types::{ CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, QueryResult, + SpatialColumn, }; use chrono::NaiveDate; use std::{borrow::Cow, time::Instant}; @@ -2608,6 +2875,8 @@ mod tests { columns: vec![], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 1, @@ -2624,6 +2893,8 @@ mod tests { columns: vec!["id".to_string()], column_types: vec!["int".to_string()], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!(1)]], affected_rows: 0, execution_time_ms: 1, @@ -3051,6 +3322,8 @@ mod tests { columns: vec!["default_schema".to_string(), "engine_edition".to_string()], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!("app_user"), serde_json::json!("8")]], affected_rows: 0, execution_time_ms: 0, @@ -3391,6 +3664,8 @@ mod tests { columns: vec!["id".to_string(), "__dbx_row_num".to_string()], column_types: vec!["int".to_string(), "bigint".to_string()], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!(42), serde_json::json!(101)]], affected_rows: 0, execution_time_ms: 1, @@ -3451,9 +3726,118 @@ mod tests { .unwrap(); assert_eq!( - rewritten, - "SELECT [landId] = [dbx_unsafe_source].[dbx_col_1], [polygon] = CASE WHEN [dbx_unsafe_source].[dbx_col_2] IS NULL THEN NULL ELSE [dbx_unsafe_source].[dbx_col_2].STAsText() END FROM (SELECT * FROM dbo.tLandPolygon) AS [dbx_unsafe_source]([dbx_col_1], [dbx_col_2])" + rewritten.sql, + "SELECT [landId] = [dbx_unsafe_source].[dbx_col_1], [polygon] = CASE WHEN [dbx_unsafe_source].[dbx_col_2] IS NULL THEN NULL ELSE N'SRID=' + CONVERT(nvarchar(20), [dbx_unsafe_source].[dbx_col_2].STSrid) + N';' + [dbx_unsafe_source].[dbx_col_2].AsTextZM() END FROM (SELECT * FROM dbo.tLandPolygon) AS [dbx_unsafe_source]([dbx_col_1], [dbx_col_2])" ); + assert_eq!( + rewritten.spatial_columns, + vec![SqlServerSpatialColumn { column_index: 1, column_type: "geometry".to_string() }] + ); + } + + #[test] + fn sqlserver_spatial_marker_keeps_wkt_and_extracts_srid() { + let (value, srid) = sqlserver_spatial_marker(serde_json::json!("SRID=3857;POINT(1 2)")); + assert_eq!(value, serde_json::json!("POINT(1 2)")); + assert_eq!(srid, Some(3857)); + } + + #[test] + fn sqlserver_spatial_marker_treats_non_positive_srid_as_unknown_without_leaking_ewkt() { + for marker in ["SRID=0;POINT(1 2)", "SRID=-1;POINT(1 2)"] { + let (value, srid) = sqlserver_spatial_marker(serde_json::json!(marker)); + assert_eq!(value, serde_json::json!("POINT(1 2)")); + assert_eq!(srid, None); + } + } + + #[test] + fn sqlserver_only_decodes_described_spatial_columns() { + let mut values = vec![serde_json::json!("SRID=4326;POINT(1 2)"), serde_json::json!("SRID=3857;POINT(3 4)")]; + let mut srids = Vec::new(); + let per_cell = decode_sqlserver_spatial_values( + &mut values, + &[SqlServerSpatialColumn { column_index: 1, column_type: "geography".to_string() }], + |column_index, srid| srids.push((column_index, srid)), + ); + + assert_eq!(values[0], serde_json::json!("SRID=4326;POINT(1 2)")); + assert_eq!(values[1], serde_json::json!("POINT(3 4)")); + assert_eq!(srids, vec![(1, Some(3857))]); + assert_eq!(per_cell, vec![None, Some(3857)]); + } + + #[test] + fn sqlserver_spatial_values_keep_per_cell_srids_for_multiple_geometry_columns() { + // Two geometry columns with different SRIDs in the same row must each + // keep their own SRID instead of collapsing to a single column-level one. + let mut values = vec![ + serde_json::json!(1), + serde_json::json!("SRID=4326;POINT(1 2)"), + serde_json::json!("SRID=3857;POINT(3 4)"), + ]; + let spatial_columns = [ + SqlServerSpatialColumn { column_index: 1, column_type: "geometry".to_string() }, + SqlServerSpatialColumn { column_index: 2, column_type: "geometry".to_string() }, + ]; + let per_cell = decode_sqlserver_spatial_values(&mut values, &spatial_columns, |_, _| {}); + + assert_eq!(values[1], serde_json::json!("POINT(1 2)")); + assert_eq!(values[2], serde_json::json!("POINT(3 4)")); + assert_eq!(per_cell, vec![None, Some(4326), Some(3857)]); + } + + #[test] + fn sqlserver_marker_preserves_zm_wkt_and_extracts_srid() { + let (value, srid) = sqlserver_spatial_marker(serde_json::json!("SRID=4326;POINT ZM (1 2 3 4)")); + assert_eq!(value, serde_json::json!("POINT ZM (1 2 3 4)")); + assert_eq!(srid, Some(4326)); + + let (line_value, line_srid) = + sqlserver_spatial_marker(serde_json::json!("SRID=4326;LINESTRING ZM (1 2 3 4, 5 6 7 8)")); + assert_eq!(line_value, serde_json::json!("LINESTRING ZM (1 2 3 4, 5 6 7 8)")); + assert_eq!(line_srid, Some(4326)); + } + + #[test] + fn sqlserver_decodes_zm_geometry_for_query_and_export_paths() { + // `decode_sqlserver_spatial_values` backs both `collect_first_result_limited` + // (query results) and `stream_first_result_set` (export): Z/M values must + // survive decoding once the rewrite uses AsTextZM() instead of STAsText(). + let mut values = vec![ + serde_json::json!(1), + serde_json::json!("SRID=4326;POINT ZM (1 2 3 4)"), + serde_json::json!("SRID=4326;LINESTRING ZM (1 2 3 4, 5 6 7 8)"), + ]; + let mut srids = Vec::new(); + decode_sqlserver_spatial_values( + &mut values, + &[ + SqlServerSpatialColumn { column_index: 1, column_type: "geometry".to_string() }, + SqlServerSpatialColumn { column_index: 2, column_type: "geometry".to_string() }, + ], + |column_index, srid| srids.push((column_index, srid)), + ); + + assert_eq!(values[1], serde_json::json!("POINT ZM (1 2 3 4)")); + assert_eq!(values[2], serde_json::json!("LINESTRING ZM (1 2 3 4, 5 6 7 8)")); + assert_eq!(srids, vec![(1, Some(4326)), (2, Some(4326))]); + } + + #[test] + fn sqlserver_restores_geography_type_even_when_all_values_are_null() { + let spatial_columns = vec![SqlServerSpatialColumn { column_index: 1, column_type: "geography".to_string() }]; + let mut column_types = vec!["int".to_string(), "nvarchar".to_string()]; + restore_sqlserver_spatial_column_types(&mut column_types, &spatial_columns); + + assert_eq!(column_types, vec!["int", "geography"]); + let mut values = vec![serde_json::json!(1), serde_json::Value::Null]; + let mut srids = Vec::new(); + decode_sqlserver_spatial_values(&mut values, &spatial_columns, |column_index, srid| { + srids.push((column_index, srid)); + }); + assert_eq!(srids, vec![(1, None)]); + assert_eq!(values[1], serde_json::Value::Null); } #[test] @@ -3493,11 +3877,161 @@ mod tests { ) .unwrap(); - // ORDER BY is stripped from the inner query so it can be used as a - // derived table subquery across all SQL Server versions (2008–2022). - assert!(!rewritten.contains("ORDER BY")); - assert!(rewritten.contains("FROM dbo.tLandPolygon")); - assert!(rewritten.contains(".STAsText()")); + // SQL Server rejects ORDER BY inside the derived table subquery, so the + // inner statement must stay bare while the outer rewrite re-applies the + // original sort so row order survives the wrap. + assert!(rewritten.sql.contains("FROM (SELECT landId, polygon FROM dbo.tLandPolygon) AS [dbx_unsafe_source]")); + assert!(rewritten.sql.ends_with("ORDER BY [landId] DESC")); + assert!(rewritten.sql.contains(".AsTextZM()")); + } + + #[test] + fn sqlserver_migrates_order_by_ordinals_and_offset_fetch_to_outer_query() { + let rewritten = build_sqlserver_unsafe_type_query( + "SELECT id, landId, polygon FROM dbo.t ORDER BY 2 DESC, id OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY", + &[ + SqlServerDescribedColumn { + name: Some("id".to_string()), + system_type_name: Some("int".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("landId".to_string()), + system_type_name: Some("varchar(30)".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }, + ], + ) + .unwrap(); + + assert!(rewritten.sql.contains("FROM (SELECT id, landId, polygon FROM dbo.t) AS [dbx_unsafe_source]")); + assert!(rewritten.sql.ends_with(" ORDER BY [landId] DESC, [id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY")); + } + + #[test] + fn sqlserver_migrates_qualified_and_bracketed_order_by_columns() { + let rewritten = build_sqlserver_unsafe_type_query( + "SELECT [id], polygon FROM dbo.t ORDER BY dbo.t.[id] ASC", + &[ + SqlServerDescribedColumn { + name: Some("id".to_string()), + system_type_name: Some("int".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }, + ], + ) + .unwrap(); + + assert!(rewritten.sql.ends_with(" ORDER BY [id]")); + } + + #[test] + fn sqlserver_keeps_order_by_inside_for_top_queries_and_reapplies_outside() { + // With TOP the ORDER BY also selects which rows TOP returns, so it must + // stay inside the derived table (legal there once TOP is present) while + // the outer rewrite re-applies it for the guaranteed final order. + let rewritten = build_sqlserver_unsafe_type_query( + "SELECT TOP 10 id, polygon FROM dbo.t ORDER BY id", + &[ + SqlServerDescribedColumn { + name: Some("id".to_string()), + system_type_name: Some("int".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }, + ], + ) + .unwrap(); + + assert!(rewritten + .sql + .contains("FROM (SELECT TOP 10 id, polygon FROM dbo.t ORDER BY id) AS [dbx_unsafe_source]")); + assert!(rewritten.sql.ends_with(" ORDER BY [id]")); + } + + #[test] + fn sqlserver_falls_back_when_order_by_cannot_be_migrated() { + // ORDER BY references a column absent from the projection: cannot be + // re-applied on the outer query, so the rewrite must be refused rather + // than silently dropping order semantics. + assert_eq!( + build_sqlserver_unsafe_type_query( + "SELECT polygon FROM dbo.t ORDER BY landId", + &[SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }], + ), + None + ); + + // Non-trivial ORDER BY expression: cannot be re-applied safely. + assert_eq!( + build_sqlserver_unsafe_type_query( + "SELECT id, polygon FROM dbo.t ORDER BY UPPER(id)", + &[ + SqlServerDescribedColumn { + name: Some("id".to_string()), + system_type_name: Some("int".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }, + ], + ), + None + ); + + // ORDER BY targeting a rewritten geometry column would order by the WKT + // string instead of the geometry value: refuse rather than change semantics. + assert_eq!( + build_sqlserver_unsafe_type_query( + "SELECT id, polygon FROM dbo.t ORDER BY polygon", + &[ + SqlServerDescribedColumn { + name: Some("id".to_string()), + system_type_name: Some("int".to_string()), + user_type_schema: None, + user_type_name: None, + }, + SqlServerDescribedColumn { + name: Some("polygon".to_string()), + system_type_name: Some("geometry".to_string()), + user_type_schema: Some("sys".to_string()), + user_type_name: Some("geometry".to_string()), + }, + ], + ), + None + ); } #[test] @@ -3727,11 +4261,11 @@ mod tests { ) .unwrap(); - assert!(rewritten.contains("CAST(")); - assert!(rewritten.contains("AS NVARCHAR(MAX))")); - assert!(rewritten.contains("FROM sys.extended_properties")); + assert!(rewritten.sql.contains("CAST(")); + assert!(rewritten.sql.contains("AS NVARCHAR(MAX))")); + assert!(rewritten.sql.contains("FROM sys.extended_properties")); // The name column should not be cast - assert_eq!(rewritten.matches("CAST(").count(), 1); + assert_eq!(rewritten.sql.matches("CAST(").count(), 1); } #[test] @@ -3777,10 +4311,10 @@ mod tests { ) .unwrap(); - assert!(rewritten.contains(".STAsText()")); - assert!(rewritten.contains("CAST(")); - assert!(rewritten.contains("AS NVARCHAR(MAX))")); - assert!(rewritten.contains("FROM dbo.t")); + assert!(rewritten.sql.contains(".AsTextZM()")); + assert!(rewritten.sql.contains("CAST(")); + assert!(rewritten.sql.contains("AS NVARCHAR(MAX))")); + assert!(rewritten.sql.contains("FROM dbo.t")); } #[tokio::test] @@ -3822,7 +4356,7 @@ mod tests { assert!(is_sqlserver_variant_column(&legacy_columns[1])); let rewritten = build_sqlserver_unsafe_type_query(sql, &legacy_columns).unwrap(); - let legacy_rows = client.query(rewritten, &[]).await.unwrap().into_first_result().await.unwrap(); + let legacy_rows = client.query(rewritten.sql.as_str(), &[]).await.unwrap().into_first_result().await.unwrap(); assert_eq!(legacy_rows[0].get::(0), Some(1)); assert_eq!(legacy_rows[0].get::<&str, _>(1), Some("legacy")); @@ -3843,7 +4377,8 @@ mod tests { assert!(is_sqlserver_variant_column(&duplicate_columns[1])); let duplicate_rewritten = build_sqlserver_unsafe_type_query(duplicate_sql, &duplicate_columns).unwrap(); - let duplicate_rows = client.query(duplicate_rewritten, &[]).await.unwrap().into_first_result().await.unwrap(); + let duplicate_rows = + client.query(duplicate_rewritten.sql.as_str(), &[]).await.unwrap().into_first_result().await.unwrap(); assert_eq!(duplicate_rows[0].columns()[0].name(), "HJRQ"); assert_eq!(duplicate_rows[0].columns()[1].name(), "HJRQ"); assert_eq!(duplicate_rows[0].get::(0), Some(1)); @@ -3876,7 +4411,8 @@ mod tests { assert!(is_sqlserver_variant_column(&wildcard_columns[7])); let wildcard_rewritten = build_sqlserver_unsafe_type_query(wildcard_sql, &wildcard_columns).unwrap(); - let wildcard_rows = client.query(wildcard_rewritten, &[]).await.unwrap().into_first_result().await.unwrap(); + let wildcard_rows = + client.query(wildcard_rewritten.sql.as_str(), &[]).await.unwrap().into_first_result().await.unwrap(); assert_eq!(wildcard_rows.len(), 1); assert_eq!(wildcard_rows[0].columns()[0].name(), "ybbz"); assert_eq!(wildcard_rows[0].columns()[4].name(), "ybbz"); @@ -3887,4 +4423,188 @@ mod tests { client.simple_query("DROP TABLE #dbx_issue_4002").await.unwrap().into_results().await.unwrap(); } + + #[tokio::test] + #[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD"] + async fn live_sqlserver_point_zm_query_and_export_preserve_z_and_m() { + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST"); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT") + .expect("DBX_LIVE_SQLSERVER_PORT") + .parse::() + .expect("valid DBX_LIVE_SQLSERVER_PORT"); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER"); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + + let mut client = + super::connect(&host, port, &user, &password, Some("tempdb"), None, std::time::Duration::from_secs(10)) + .await + .unwrap(); + + let setup = "\ + IF OBJECT_ID('tempdb..#dbx_point_zm') IS NOT NULL DROP TABLE #dbx_point_zm; \ + CREATE TABLE #dbx_point_zm (id int NOT NULL, geom geometry NOT NULL); \ + INSERT INTO #dbx_point_zm (id, geom) VALUES \ + (1, geometry::STGeomFromText('POINT (1 2 3 4)', 4326)), \ + (2, geometry::STGeomFromText('LINESTRING ZM (1 2 3 4, 5 6 7 8)', 4326))"; + client.simple_query(setup).await.unwrap().into_results().await.unwrap(); + + // Query path: the rewrite must use AsTextZM() so Z/M survive STAsText's 2D-only output. + let sql = "SELECT id, geom FROM #dbx_point_zm ORDER BY id"; + let result = super::execute_query(&mut client, sql).await.unwrap(); + assert_eq!(result.rows.len(), 2); + let mut point_wkt: Option<&str> = None; + let mut line_wkt: Option<&str> = None; + for row in &result.rows { + let wkt = row[1].as_str().expect("geometry cell decodes to WKT string"); + if wkt.starts_with("POINT") { + point_wkt = Some(wkt); + } else if wkt.starts_with("LINESTRING") { + line_wkt = Some(wkt); + } + } + let point_wkt = point_wkt.expect("POINT row present"); + assert!(point_wkt.contains("1 2 3 4"), "POINT must retain Z/M via AsTextZM, got: {point_wkt}"); + let line_wkt = line_wkt.expect("LINESTRING row present"); + assert!( + line_wkt.contains("1 2 3 4") && line_wkt.contains("5 6 7 8"), + "LINESTRING must retain Z/M via AsTextZM, got: {line_wkt}" + ); + assert_eq!(result.spatial_columns, vec![SpatialColumn { column_index: 1, srid: Some(4326) }]); + + // Export path: stream_first_result_set decodes the same AsTextZM() markers. + let mut exported = Vec::new(); + let summary = super::stream_first_result_set(&mut client, sql, Some(10), None, |item| { + if let super::SqlServerStreamItem::Row(values) = item { + exported.push(values.to_vec()); + } + Ok(()) + }) + .await + .unwrap(); + assert_eq!(summary.rows_exported, 2); + let exported_point = exported.iter().find_map(|row| row[1].as_str().filter(|wkt| wkt.starts_with("POINT"))); + let exported_point = exported_point.expect("exported POINT row present"); + assert!( + exported_point.contains("1 2 3 4"), + "exported POINT must retain Z/M via AsTextZM, got: {exported_point}" + ); + + client.simple_query("DROP TABLE #dbx_point_zm").await.unwrap().into_results().await.unwrap(); + } + + #[tokio::test] + #[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD"] + async fn live_sqlserver_geometry_order_and_pagination_are_preserved() { + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST"); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT") + .expect("DBX_LIVE_SQLSERVER_PORT") + .parse::() + .expect("valid DBX_LIVE_SQLSERVER_PORT"); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER"); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + + let mut client = + super::connect(&host, port, &user, &password, Some("tempdb"), None, std::time::Duration::from_secs(10)) + .await + .unwrap(); + + let setup = "\ + IF OBJECT_ID('tempdb..#dbx_geometry_order') IS NOT NULL DROP TABLE #dbx_geometry_order; \ + CREATE TABLE #dbx_geometry_order (id int NOT NULL, name varchar(20) NOT NULL, geom geometry NOT NULL); \ + INSERT INTO #dbx_geometry_order (id, name, geom) VALUES \ + (3, 'c', geometry::STGeomFromText('POINT (3 3)', 4326)), \ + (1, 'a', geometry::STGeomFromText('POINT (1 1)', 4326)), \ + (2, 'b', geometry::STGeomFromText('POINT (2 2)', 4326))"; + client.simple_query(setup).await.unwrap().into_results().await.unwrap(); + + let ids = |result: &crate::types::QueryResult| -> Vec { + result.rows.iter().map(|row| row[0].as_i64().expect("id cell is an integer")).collect() + }; + + // The derived-table rewrite must not lose ORDER BY: rows come back in id + // order even though the table was inserted out of order. + let ordered = super::execute_query(&mut client, "SELECT id, name, geom FROM #dbx_geometry_order ORDER BY id") + .await + .unwrap(); + assert_eq!(ids(&ordered), vec![1, 2, 3]); + + // OFFSET/FETCH pagination must survive the rewrite: second page = id 2. + let page = super::execute_query( + &mut client, + "SELECT id, name, geom FROM #dbx_geometry_order ORDER BY id OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY", + ) + .await + .unwrap(); + assert_eq!(ids(&page), vec![2]); + + // A page past the end returns no rows, not the whole table. + let beyond = super::execute_query( + &mut client, + "SELECT id, name, geom FROM #dbx_geometry_order ORDER BY id OFFSET 10 ROWS", + ) + .await + .unwrap(); + assert!(beyond.rows.is_empty()); + + // DESC ordering is preserved too. + let descending = + super::execute_query(&mut client, "SELECT id, name, geom FROM #dbx_geometry_order ORDER BY id DESC") + .await + .unwrap(); + assert_eq!(ids(&descending), vec![3, 2, 1]); + + // TOP + ORDER BY keeps the sort inside the derived table (it drives row + // selection) and re-applies it outside for the guaranteed final order. + let top = super::execute_query(&mut client, "SELECT TOP 2 id, name, geom FROM #dbx_geometry_order ORDER BY id") + .await + .unwrap(); + assert_eq!(ids(&top), vec![1, 2]); + + client.simple_query("DROP TABLE #dbx_geometry_order").await.unwrap().into_results().await.unwrap(); + } + + #[tokio::test] + #[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD"] + async fn live_sqlserver_mixed_srid_columns_keep_per_cell_srids() { + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST"); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT") + .expect("DBX_LIVE_SQLSERVER_PORT") + .parse::() + .expect("valid DBX_LIVE_SQLSERVER_PORT"); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER"); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + + let mut client = + super::connect(&host, port, &user, &password, Some("tempdb"), None, std::time::Duration::from_secs(10)) + .await + .unwrap(); + + let setup = "\ + IF OBJECT_ID('tempdb..#dbx_mixed_srid') IS NOT NULL DROP TABLE #dbx_mixed_srid; \ + CREATE TABLE #dbx_mixed_srid (id int NOT NULL, geom_a geometry NOT NULL, geom_b geometry NOT NULL); \ + INSERT INTO #dbx_mixed_srid (id, geom_a, geom_b) VALUES \ + (1, geometry::STGeomFromText('POINT (1 1)', 4326), geometry::STGeomFromText('POINT (11 11)', 3857)), \ + (2, geometry::STGeomFromText('POINT (2 2)', 3857), geometry::STGeomFromText('POINT (12 12)', 4326))"; + client.simple_query(setup).await.unwrap().into_results().await.unwrap(); + + let result = super::execute_query(&mut client, "SELECT id, geom_a, geom_b FROM #dbx_mixed_srid ORDER BY id") + .await + .unwrap(); + // Every cell keeps its own SRID: row 1 is (4326, 3857), row 2 is (3857, + // 4326) — the same column mixes SRIDs across rows without collapsing. + assert_eq!( + result.spatial_values, + vec![vec![None, Some(4326), Some(3857)], vec![None, Some(3857), Some(4326)],] + ); + // The column-level hint still reports the first non-null SRID per column. + assert_eq!( + result.spatial_columns, + vec![ + SpatialColumn { column_index: 1, srid: Some(4326) }, + SpatialColumn { column_index: 2, srid: Some(3857) }, + ] + ); + + client.simple_query("DROP TABLE #dbx_mixed_srid").await.unwrap().into_results().await.unwrap(); + } } diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index d59edc11de..9b815dff98 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -343,6 +343,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -362,6 +364,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows, execution_time_ms: start.elapsed().as_millis(), @@ -378,6 +382,8 @@ pub async fn execute_query_with_max_rows( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), @@ -561,6 +567,8 @@ fn query_result_from_turso_result( columns: result.columns, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: result.values, affected_rows: 0, execution_time_ms, diff --git a/crates/dbx-core/src/db/vector_driver.rs b/crates/dbx-core/src/db/vector_driver.rs index 8330271cbb..58b377ad07 100644 --- a/crates/dbx-core/src/db/vector_driver.rs +++ b/crates/dbx-core/src/db/vector_driver.rs @@ -794,6 +794,8 @@ fn json_to_query_result(status: u16, body: Value, start: Instant) -> QueryResult columns: vec!["status".to_string(), "response".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![ Value::Number(status.into()), Value::String(serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string())), @@ -828,6 +830,8 @@ fn values_to_query_result(items: Vec, start: Instant) -> QueryResult { columns, column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], affected_rows: rows.len() as u64, rows, execution_time_ms: start.elapsed().as_millis(), diff --git a/crates/dbx-core/src/db/wkb.rs b/crates/dbx-core/src/db/wkb.rs index 41bc5d6c82..7936d05dc3 100644 --- a/crates/dbx-core/src/db/wkb.rs +++ b/crates/dbx-core/src/db/wkb.rs @@ -1,5 +1,6 @@ /// Shared WKB (Well-Known Binary) geometry parsing shared by PostgreSQL (EWKB) -/// and MySQL (standard WKB). Parses WKB bytes into WKT (Well-Known Text) strings. +/// and MySQL (standard WKB). Parses WKB bytes into WKT while retaining the +/// top-level EWKB SRID when present. /// /// Supports: POINT, LINESTRING, POLYGON, MULTIPOINT, MULTILINESTRING, /// MULTIPOLYGON, GEOMETRYCOLLECTION, and their Z/M/ZM variants. @@ -36,6 +37,12 @@ enum WkbGeometry { GeometryCollection { dims: WkbDimensions, geometries: Vec }, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DecodedGeometry { + pub wkt: String, + pub srid: Option, +} + impl WkbGeometry { fn to_wkt(&self) -> String { match self { @@ -211,7 +218,7 @@ fn parse_wkb_dimensions(type_word: u32) -> (u32, WkbDimensions, bool) { (base_type, dims, has_srid) } -fn read_wkb_point_coords(reader: &mut WkbReader<'_>, dims: WkbDimensions, allow_empty_point: bool) -> Option> { +fn read_wkb_point_coords(reader: &mut WkbReader<'_>, dims: WkbDimensions) -> Option>> { let endian = match reader.read_u8()? { 0 => WkbEndian::Big, 1 => WkbEndian::Little, @@ -226,10 +233,7 @@ fn read_wkb_point_coords(reader: &mut WkbReader<'_>, dims: WkbDimensions, allow_ reader.read_u32(endian)?; } let coords = (0..parsed_dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; - if allow_empty_point && coords.iter().all(|value| value.is_nan()) { - return None; - } - Some(coords) + Some((!coords.iter().all(|value| value.is_nan())).then_some(coords)) } fn parse_wkb_points(reader: &mut WkbReader<'_>, endian: WkbEndian, dims: WkbDimensions) -> Option>> { @@ -239,7 +243,7 @@ fn parse_wkb_points(reader: &mut WkbReader<'_>, endian: WkbEndian, dims: WkbDime .collect::>>() } -fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { +fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option<(WkbGeometry, Option)> { let endian = match reader.read_u8()? { 0 => WkbEndian::Big, 1 => WkbEndian::Little, @@ -247,11 +251,9 @@ fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { }; let type_word = reader.read_u32(endian)?; let (base_type, dims, has_srid) = parse_wkb_dimensions(type_word); - if has_srid { - reader.read_u32(endian)?; - } + let srid = has_srid.then(|| reader.read_u32(endian)).flatten().filter(|value| *value != 0); - match base_type { + let geometry = match base_type { 1 => { let coords = (0..dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; let coords = if coords.iter().all(|value| value.is_nan()) { None } else { Some(coords) }; @@ -265,14 +267,13 @@ fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { } 4 => { let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let points = - (0..count).map(|_| Some(read_wkb_point_coords(reader, dims, true))).collect::>>()?; + let points = (0..count).map(|_| read_wkb_point_coords(reader, dims)).collect::>>()?; Some(WkbGeometry::MultiPoint { dims, points }) } 5 => { let count = usize::try_from(reader.read_u32(endian)?).ok()?; let lines = (0..count) - .map(|_| match parse_wkb_geometry(reader)? { + .map(|_| match parse_wkb_geometry(reader)?.0 { WkbGeometry::LineString { points, .. } => Some(points), _ => None, }) @@ -282,7 +283,7 @@ fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { 6 => { let count = usize::try_from(reader.read_u32(endian)?).ok()?; let polygons = (0..count) - .map(|_| match parse_wkb_geometry(reader)? { + .map(|_| match parse_wkb_geometry(reader)?.0 { WkbGeometry::Polygon { rings, .. } => Some(rings), _ => None, }) @@ -291,11 +292,22 @@ fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { } 7 => { let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let geometries = (0..count).map(|_| parse_wkb_geometry(reader)).collect::>>()?; + let geometries = + (0..count).map(|_| parse_wkb_geometry(reader).map(|parsed| parsed.0)).collect::>>()?; Some(WkbGeometry::GeometryCollection { dims, geometries }) } _ => None, + }?; + Some((geometry, srid)) +} + +pub(crate) fn decode_wkb_geometry(raw: &[u8]) -> Option { + let mut reader = WkbReader::new(raw); + let (geometry, srid) = parse_wkb_geometry(&mut reader)?; + if reader.pos != raw.len() { + return None; } + Some(DecodedGeometry { wkt: geometry.to_wkt(), srid }) } /// Parse WKB/EWKB bytes into a WKT (Well-Known Text) string. @@ -303,10 +315,61 @@ fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { /// Supports both standard WKB (MySQL) and Extended WKB (PostgreSQL/PostGIS). /// Returns `None` if the bytes cannot be parsed as valid WKB. pub(crate) fn wkb_to_wkt(raw: &[u8]) -> Option { - let mut reader = WkbReader::new(raw); - let geometry = parse_wkb_geometry(&mut reader)?; - if reader.pos != raw.len() { - return None; + decode_wkb_geometry(raw).map(|geometry| geometry.wkt) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_hex(value: &str) -> Vec { + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(text, 16).unwrap() + }) + .collect() } - Some(geometry.to_wkt()) + + #[test] + fn ewkb_decoder_retains_top_level_srid() { + let decoded = decode_wkb_geometry(&decode_hex("0101000020E6100000C520B07268195D404E62105839F44340")).unwrap(); + assert_eq!(decoded.wkt, "POINT(116.397 39.908)"); + assert_eq!(decoded.srid, Some(4326)); + } + + #[test] + fn standard_wkb_has_no_srid() { + let decoded = decode_wkb_geometry(&decode_hex("0101000000000000000000F03F0000000000000040")).unwrap(); + assert_eq!(decoded.wkt, "POINT(1 2)"); + assert_eq!(decoded.srid, None); + } + + #[test] + fn big_endian_ewkb_retains_srid() { + let decoded = decode_wkb_geometry(&decode_hex("002000000100000F113FF00000000000004000000000000000")).unwrap(); + assert_eq!(decoded.wkt, "POINT(1 2)"); + assert_eq!(decoded.srid, Some(3857)); + } + + #[test] + fn ewkb_srid_zero_is_unknown() { + let decoded = decode_wkb_geometry(&decode_hex("010100002000000000000000000000F03F0000000000000040")).unwrap(); + assert_eq!(decoded.wkt, "POINT(1 2)"); + assert_eq!(decoded.srid, None); + } + + #[test] + fn multipoint_preserves_valid_empty_points() { + let decoded = + decode_wkb_geometry(&decode_hex("0104000000010000000101000000000000000000F87F000000000000F87F")).unwrap(); + assert_eq!(decoded.wkt, "MULTIPOINT(EMPTY)"); + } + + #[test] + fn multipoint_rejects_truncated_child_points() { + assert!(decode_wkb_geometry(&decode_hex("0104000000010000000101000000")).is_none()); + } } diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 5086f06548..7792cd22bb 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1984,6 +1984,8 @@ fn error_query_result(message: String) -> db::QueryResult { columns: vec!["Error".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::Value::String(message)]], affected_rows: 0, execution_time_ms: 0, @@ -1999,6 +2001,8 @@ fn empty_query_result(execution_time_ms: u128) -> db::QueryResult { columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms, @@ -2031,6 +2035,8 @@ async fn execute_multi_sqlserver( columns: vec!["Error".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::Value::String(canceled_error())]], affected_rows: 0, execution_time_ms: 0, @@ -2083,6 +2089,8 @@ async fn execute_multi_sqlserver( columns: vec!["Error".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::Value::String(e)]], affected_rows: 0, execution_time_ms: 0, @@ -2106,6 +2114,8 @@ async fn execute_multi_sqlserver( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 0, @@ -2232,6 +2242,8 @@ pub async fn execute_statements( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -2670,6 +2682,8 @@ async fn exec_tx_pg_inner( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -2754,6 +2768,8 @@ async fn exec_tx_mysql_inner( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -2818,6 +2834,8 @@ async fn exec_tx_sqlite_inner( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -2908,6 +2926,8 @@ async fn exec_tx_explicit_inner( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -2951,6 +2971,8 @@ async fn exec_tx_none_inner( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), @@ -3416,6 +3438,8 @@ async fn execute_manual_txn_postgres_statement( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: affected, execution_time_ms: 0, @@ -3456,6 +3480,8 @@ async fn execute_manual_txn_mysql_statement( columns, column_types, column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: data, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), @@ -3472,6 +3498,8 @@ async fn execute_manual_txn_mysql_statement( columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows, execution_time_ms: 0, @@ -3505,6 +3533,8 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) - columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 0, @@ -3530,6 +3560,8 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str) columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 0, @@ -4322,6 +4354,8 @@ for line in sys.stdin: columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 0, @@ -4344,6 +4378,8 @@ for line in sys.stdin: columns: vec![], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![], affected_rows: 0, execution_time_ms: 0, @@ -4992,6 +5028,8 @@ for line in sys.stdin: columns: vec!["id".to_string(), "nested".to_string()], column_types: Vec::new(), column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![ serde_json::json!(2_041_797_190_226_354_178_i64), serde_json::json!([1, 2_041_797_190_226_354_178_i64]), diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index b96ff8ea45..5d68d6c3f9 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -2773,6 +2773,8 @@ mod tests { columns: vec!["Table".to_string(), "Extra".to_string(), "CREATE TABLE".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![ serde_json::json!("users"), serde_json::json!("ignored"), @@ -2795,6 +2797,8 @@ mod tests { columns: vec!["name".to_string(), "definition".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!("users"), serde_json::json!("CREATE TABLE `users` (`id` bigint);\n")]], affected_rows: 0, execution_time_ms: 0, @@ -3288,6 +3292,8 @@ mod tests { columns: vec!["table_name".to_string(), "table_type".to_string()], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![ vec![serde_json::json!("daily_revenue"), serde_json::json!("BASE TABLE")], vec![serde_json::json!("revenue_view"), serde_json::json!("VIEW")], @@ -3321,6 +3327,8 @@ mod tests { ], column_types: vec![], column_sortables: vec![], + spatial_columns: vec![], + spatial_values: vec![], rows: vec![ vec![ serde_json::json!("amount"), @@ -3505,6 +3513,8 @@ mod tests { columns: vec!["COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!("Customer table")]], affected_rows: 0, execution_time_ms: 0, @@ -3520,6 +3530,8 @@ mod tests { columns: vec!["COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![serde_json::json!(" ")]], affected_rows: 0, execution_time_ms: 0, @@ -3550,6 +3562,8 @@ mod tests { columns: vec!["TABLE_NAME".to_string(), "COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![ vec![serde_json::json!("ORDERS"), serde_json::json!("Orders table")], vec![serde_json::json!("PRODUCTS"), serde_json::json!(" ")], @@ -3648,6 +3662,8 @@ mod tests { ], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![ vec![ serde_json::json!("id"), @@ -3762,6 +3778,8 @@ mod tests { ], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![ vec![ serde_json::json!("ORDERS"), diff --git a/crates/dbx-core/src/schema/kingbase.rs b/crates/dbx-core/src/schema/kingbase.rs index 2719bd7af0..f016207a56 100644 --- a/crates/dbx-core/src/schema/kingbase.rs +++ b/crates/dbx-core/src/schema/kingbase.rs @@ -195,6 +195,8 @@ mod tests { ], column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: vec![vec![ serde_json::json!("kdb_utils"), serde_json::json!("1.0"), diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index 5cbfcfe43c..095ff5f6db 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -418,6 +418,8 @@ async fn fetch_table_export_batch( columns: col_names.to_vec(), column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: Vec::new(), affected_rows: 0, execution_time_ms: 0, diff --git a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs index 3933c91824..412907f8bd 100644 --- a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs +++ b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs @@ -679,6 +679,8 @@ fn execute_change_transaction( columns: Vec::new(), column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows: Vec::new(), affected_rows: 0, execution_time_ms: started_at.elapsed().as_millis(), diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 4c1a1a3be3..24f1329ad5 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -5323,6 +5323,8 @@ mod tests { columns: Vec::new(), column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows: 0, execution_time_ms: 0, diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index dcee7a9519..afa2fabe49 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -226,6 +226,55 @@ pub struct CompletionAssistantResponse { pub fallback_used: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpatialColumn { + /// Zero-based index into `QueryResult.columns`. + pub column_index: usize, + /// SRID shared by the column's geometry cells. `None` when unknown/absent + /// (or SRID 0). A column reports the first non-null SRID it observes. + pub srid: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct SpatialColumnBuilder { + // column_index -> first non-null srid seen (sticky once set) + columns: std::collections::BTreeMap>, +} + +impl SpatialColumnBuilder { + pub(crate) fn new(column_indices: impl IntoIterator) -> Self { + let mut builder = Self::default(); + for column_index in column_indices { + builder.columns.entry(column_index).or_insert(None); + } + builder + } + + /// Record a geometry cell's SRID. The first non-null (and non-zero) value + /// wins; later observations for the same column are ignored. + pub(crate) fn observe(&mut self, column_index: usize, srid: Option) { + let entry = self.columns.entry(column_index).or_insert(None); + if entry.is_none() { + if let Some(value) = srid.filter(|value| *value != 0) { + *entry = Some(value); + } + } + } + + pub(crate) fn finish(self) -> Vec { + self.columns.into_iter().map(|(column_index, srid)| SpatialColumn { column_index, srid }).collect() + } + + pub(crate) fn finish_with_values( + self, + spatial_values: Vec>>, + ) -> (Vec, Vec>>) { + let spatial_columns = self.finish(); + let spatial_values = if spatial_columns.is_empty() { Vec::new() } else { spatial_values }; + (spatial_columns, spatial_values) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryResult { pub columns: Vec, @@ -238,6 +287,16 @@ pub struct QueryResult { /// be shorter/empty when a driver cannot supply sortable information. #[serde(default)] pub column_sortables: Vec, + /// Spatial reference metadata for geometry/geography cells. Kept outside + /// `rows` so displayed, copied, exported, and edited values remain WKT. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub spatial_columns: Vec, + /// Per-cell SRID metadata, parallel to `rows`: `spatial_values[row][column]` + /// is the SRID of that cell's geometry value (`None` for non-spatial cells + /// or unknown SRID). Unlike `spatial_columns` (a column-level hint), every + /// geometry value keeps its own SRID so mixed-SRID results stay correct. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub spatial_values: Vec>>, pub rows: Vec>, pub affected_rows: u64, pub execution_time_ms: u128, @@ -384,7 +443,7 @@ pub struct OwnerInfo { #[cfg(test)] mod tests { - use super::{ObjectInfo, ObjectSourceKind}; + use super::{ObjectInfo, ObjectSourceKind, SpatialColumn, SpatialColumnBuilder}; #[test] fn list_objects_payload_preserves_optional_validity() { @@ -396,6 +455,45 @@ mod tests { assert_eq!(objects[0].object_type, "TRIGGER"); } + #[test] + fn spatial_builder_reports_first_non_null_srid_per_column() { + let mut builder = SpatialColumnBuilder::new([3]); + builder.observe(3, None); + builder.observe(1, Some(4326)); + builder.observe(3, Some(3857)); + builder.observe(3, Some(4490)); // ignored: column 3 already set + builder.observe(1, None); // ignored: column 1 already set + + assert_eq!( + builder.finish(), + vec![ + SpatialColumn { column_index: 1, srid: Some(4326) }, + SpatialColumn { column_index: 3, srid: Some(3857) }, + ] + ); + } + + #[test] + fn spatial_builder_normalizes_zero_and_all_null() { + let mut builder = SpatialColumnBuilder::new([0]); + builder.observe(0, Some(0)); // SRID 0 -> unknown + assert_eq!(builder.finish(), vec![SpatialColumn { column_index: 0, srid: None }]); + assert!(SpatialColumnBuilder::default().finish().is_empty()); + } + + #[test] + fn spatial_builder_omits_values_without_spatial_columns() { + let values = vec![vec![None, None]]; + let (columns, values) = SpatialColumnBuilder::default().finish_with_values(values); + assert!(columns.is_empty()); + assert!(values.is_empty()); + + let expected_values = vec![vec![None, None]]; + let (columns, values) = SpatialColumnBuilder::new([0]).finish_with_values(expected_values.clone()); + assert_eq!(columns, vec![SpatialColumn { column_index: 0, srid: None }]); + assert_eq!(values, expected_values); + } + #[test] fn object_source_kind_accepts_synonym_wire_value() { let kind: ObjectSourceKind = serde_json::from_str("\"SYNONYM\"").unwrap(); diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 789fb0e7c2..b11fc4d1f8 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -1314,6 +1314,8 @@ fn query_result(columns: Vec, rows: Vec>, affected_rows: u64) columns, column_types: Vec::new(), column_sortables: Vec::new(), + spatial_columns: vec![], + spatial_values: vec![], rows, affected_rows, execution_time_ms: 0, diff --git a/flake.nix b/flake.nix index 479fae9d42..9517c47a85 100644 --- a/flake.nix +++ b/flake.nix @@ -197,7 +197,7 @@ fetcherVersion = 4; # Update with the hash reported by a failed fixed-output build: # nix build .#dbx-pnpm-deps 2>&1 | grep 'got:' - hash = "sha256-Bt3AwBVAdA96B4UWBVDvwNBy/JX2eNn8adPpWueAjcs="; + hash = "sha256-pPLJxLCIkwOZF1zxBZZ5mAftP0wiTEBpRnkpGES68yA="; }; # ── Step 2: vendor Cargo dependencies ───────────────────────────── # diff --git a/package.json b/package.json index 31084c3d9b..cc78a607ee 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "leaflet": "^1.9.4", "marked": "^18.0.4", "pinia": "^3.0.0", + "proj4": "^2.20.9", "reka-ui": "^2.10.1", "shadcn-vue": "^2.7.4", "shiki": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3a9700c24..475fa6e8f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: pinia: specifier: ^3.0.0 version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)) + proj4: + specifier: ^2.20.9 + version: 2.20.9 reka-ui: specifier: ^2.10.1 version: 2.10.1(vue@3.5.35(typescript@6.0.3)) @@ -502,74 +505,78 @@ packages: '@codemirror/view@6.43.0': resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} - '@dbx-app/cli-darwin-arm64@0.4.36': - resolution: {integrity: sha512-9IfZgHdA76x6D2znEMmQeZYalnVYgkgsgMDmZHBwpJsYrL/NEi7kMxfRhM9MhUoOxrNULR+K1a1oREQQqnULmA==} + '@dbx-app/cli-darwin-arm64@0.4.42': + resolution: {integrity: sha512-phyUBRT/E5pYemgoKJOC/Io8fGK/sw4thbovYW7Z2kSn4RTm+ymMbzITf4p+1Bw0wIN5Xtce6qsp6L+rIxruVA==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/cli-darwin-x64@0.4.36': - resolution: {integrity: sha512-SGIEMUPrySA9jh8uBx1AYl8wuA14f14ioynjpc3F75KbhPGHJ4kebGOjh8sVgjf4yWf1Rh9tsmffY9zLWeZQWQ==} + '@dbx-app/cli-darwin-x64@0.4.42': + resolution: {integrity: sha512-khDuRLH3ojiYAGsE7nkupRebZcBTQRfymIKKLeif4REdYjASGD6XoKD8aaxsiNMCTkR0T5YDzo2k6+yXMswrzw==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/cli-linux-arm64-gnu@0.4.36': - resolution: {integrity: sha512-zPHHluDmdqbb37kkNBwnAWhaVaA+mw8IhryeDElahJpYrX6fVU4hAZqzR6j1T3LrCVQcSCojkoChtoJ4MFcvVw==} + '@dbx-app/cli-linux-arm64-gnu@0.4.42': + resolution: {integrity: sha512-KAg0gSzpAk7KetKipcXKJNkIKAH8RKsT9MabdzHucl3juFvLVNaR1c5FE+IBHSRr8atk1WLEXL8U05Rxk+zbEw==} cpu: [arm64] os: [linux] + libc: [glibc] hasBin: true - '@dbx-app/cli-linux-x64-gnu@0.4.36': - resolution: {integrity: sha512-aOn97qTOb67fLOCD1duWLsofncvGsMHFr3bKN1D0D8izfLTtzpUDwmdcbJelPwh0Xc3eu/SXq5Zc55v8AiH/Vg==} + '@dbx-app/cli-linux-x64-gnu@0.4.42': + resolution: {integrity: sha512-ZNol747E+f2GK8ooeK7ow+nDlJRxxDA1L03bKTIpruS3nU5bx4oxxgJL1PiHDcJe1rUXr5+nuqVdm46AwQ3fSQ==} cpu: [x64] os: [linux] + libc: [glibc] hasBin: true - '@dbx-app/cli-win32-arm64@0.4.36': - resolution: {integrity: sha512-j3NGer4CcO6lBtC41MjnRIaYcwr+Q+SP7otL9jX1IW05Kj+zX3y5URL6n9VX2dI4yXSCAaf04D01PviYHUK5uw==} + '@dbx-app/cli-win32-arm64@0.4.42': + resolution: {integrity: sha512-WfpKaISwkpTfb4s3ZYah1E7aD20AEPcg4DkHfBOUQyaCqx5LSQhN7tt4XlOEaAEb+t1Bogp6WFW0B/EA+ypYVQ==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/cli-win32-x64@0.4.36': - resolution: {integrity: sha512-vgq3tWciNPi5yH4c4teQsUDV+r6XzsAOxMDz5dEpQzkQJbvUutqSvXz24hpGNX0F/jCI9EyExXsFab5g3k2qRg==} + '@dbx-app/cli-win32-x64@0.4.42': + resolution: {integrity: sha512-2Hc5+7hxDHQ1tkTrrffqPsWvxNRsEKSppviSTM0i5Xh5LaqHMMbboR5gcfkBb0Vw/DdzKLZB7ORufr+FNuY+nA==} cpu: [x64] os: [win32] hasBin: true - '@dbx-app/mcp-darwin-arm64@0.4.36': - resolution: {integrity: sha512-/B1b6DYv9eSTgMZx5qBF7Zsm8vRhba/s2tM+3jFNJanWgn/y+xJVnIZZIB5Qm3x0yhU7ONqgmeLl7HMu8y8v/w==} + '@dbx-app/mcp-darwin-arm64@0.4.42': + resolution: {integrity: sha512-E3UEBp+DwJYYxcIp60SCKTJSF3qP25xC2Jl/mW5bsERSsy58J6JiydDSwgMjtNDJ+Mj/ZVPA6Mbw1Rc40yK66A==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/mcp-darwin-x64@0.4.36': - resolution: {integrity: sha512-ED11PzL7zMLCKfk/zA1gBDLf3qy/v764jmBHut/Cy9JQvMPlZlwTmnTkuW6sJ8SdvG5WRIFDHFtEP1vzezmx3w==} + '@dbx-app/mcp-darwin-x64@0.4.42': + resolution: {integrity: sha512-UeeCBx7QWe3vmZ3eK3ChZRAXPAL+z4MtOLBFvIAaffhftZck/p3iXL8d6u3Ki/+5pgQjQ/E1MBrK1JJ1fz2zvA==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.36': - resolution: {integrity: sha512-jjJTH5e5Ru8o+RsUsF9PzgN+SQ4xGemBCnaDtbupPKxgOib1iX4wwgjbVt8ycK0QiNLk0ezv3Pj4RhABbv0wBw==} + '@dbx-app/mcp-linux-arm64-gnu@0.4.42': + resolution: {integrity: sha512-qLY5mmeCRn+JquH5C5PuZxKvjDq8oPnM9lgocN/HR4Ta1sOoWoqQuLOOmiiKY1SOAuqZWAwBOcBbVi37TFX7FA==} cpu: [arm64] os: [linux] + libc: [glibc] hasBin: true - '@dbx-app/mcp-linux-x64-gnu@0.4.36': - resolution: {integrity: sha512-qy9EyXVUSne00U4oAvRFwxn7Ed9wCj54WHZX+g+6m1uEEXAcbMC7F3lwczIdAOvQHU/qvLjeAP0rsjanVBVRmA==} + '@dbx-app/mcp-linux-x64-gnu@0.4.42': + resolution: {integrity: sha512-c5VneyXTHLZK1B7znI7y4VZbM77D942jMnMR1K9/d2uGTiFtpqm/Tj8mF+oWhW+we32LOhUAWdWVR4fOuf8jBQ==} cpu: [x64] os: [linux] + libc: [glibc] hasBin: true - '@dbx-app/mcp-win32-arm64@0.4.36': - resolution: {integrity: sha512-drAtzSUUaU7qF2BpTxw6czFc4bciM8Q3fNLgjM/uy7ehFw1ZsS1J8qBOCOiKnZXKJHzMnUYaPAl9hIqdsxINPQ==} + '@dbx-app/mcp-win32-arm64@0.4.42': + resolution: {integrity: sha512-sTaX2qBOZqF4wgH34RIlP95wH1YXWfj6LhGtPuOIsQC74Knq8kJGteg7oZCdXIhC8dmS+y8uhXqW+1UfMJfsdA==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/mcp-win32-x64@0.4.36': - resolution: {integrity: sha512-6sPpFJKCGpVJgujPeQrtF+D679/5RUCMuNxU29FTvyswynoqF4KHY07k14bK+JZGHvh1UGGWvy1CfaiC35HkKA==} + '@dbx-app/mcp-win32-x64@0.4.42': + resolution: {integrity: sha512-KSYa1LKV2nEndrD2x7GnxV8e6KiA1zQXYYe90TmkEAZRbtSMPRTroEBVVBMQTbT8f2A7ObzfkgX1ScGyiwX0/w==} cpu: [x64] os: [win32] hasBin: true @@ -973,48 +980,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.53.0': resolution: {integrity: sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.53.0': resolution: {integrity: sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.53.0': resolution: {integrity: sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.53.0': resolution: {integrity: sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.53.0': resolution: {integrity: sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.53.0': resolution: {integrity: sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.53.0': resolution: {integrity: sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.53.0': resolution: {integrity: sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==} @@ -1087,48 +1102,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.68.0': resolution: {integrity: sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.68.0': resolution: {integrity: sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.68.0': resolution: {integrity: sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.68.0': resolution: {integrity: sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.68.0': resolution: {integrity: sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.68.0': resolution: {integrity: sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.68.0': resolution: {integrity: sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.68.0': resolution: {integrity: sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==} @@ -1198,36 +1221,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -1330,24 +1359,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1416,30 +1449,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.2': resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.2': resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.2': resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} @@ -2424,7 +2462,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported gonzales-pe@4.3.0: resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==} @@ -2693,24 +2731,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2803,6 +2845,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mgrs@1.0.0: + resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -3143,6 +3188,14 @@ packages: engines: {node: '>=14'} hasBin: true + proj4@2.20.9: + resolution: {integrity: sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==} + peerDependencies: + geotiff: '*' + peerDependenciesMeta: + geotiff: + optional: true + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -3756,6 +3809,9 @@ packages: engines: {node: '>=8'} hasBin: true + wkt-parser@1.5.6: + resolution: {integrity: sha512-cqHU3lzGt/gt2OqIORP0uVy5yeOX43WABmgFkmJsmcqH3HhQo9PiJG6ftytwGKmpQUbegeX7+pQc2BuNCRfcrw==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4129,40 +4185,40 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@dbx-app/cli-darwin-arm64@0.4.36': + '@dbx-app/cli-darwin-arm64@0.4.42': optional: true - '@dbx-app/cli-darwin-x64@0.4.36': + '@dbx-app/cli-darwin-x64@0.4.42': optional: true - '@dbx-app/cli-linux-arm64-gnu@0.4.36': + '@dbx-app/cli-linux-arm64-gnu@0.4.42': optional: true - '@dbx-app/cli-linux-x64-gnu@0.4.36': + '@dbx-app/cli-linux-x64-gnu@0.4.42': optional: true - '@dbx-app/cli-win32-arm64@0.4.36': + '@dbx-app/cli-win32-arm64@0.4.42': optional: true - '@dbx-app/cli-win32-x64@0.4.36': + '@dbx-app/cli-win32-x64@0.4.42': optional: true - '@dbx-app/mcp-darwin-arm64@0.4.36': + '@dbx-app/mcp-darwin-arm64@0.4.42': optional: true - '@dbx-app/mcp-darwin-x64@0.4.36': + '@dbx-app/mcp-darwin-x64@0.4.42': optional: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.36': + '@dbx-app/mcp-linux-arm64-gnu@0.4.42': optional: true - '@dbx-app/mcp-linux-x64-gnu@0.4.36': + '@dbx-app/mcp-linux-x64-gnu@0.4.42': optional: true - '@dbx-app/mcp-win32-arm64@0.4.36': + '@dbx-app/mcp-win32-arm64@0.4.42': optional: true - '@dbx-app/mcp-win32-x64@0.4.36': + '@dbx-app/mcp-win32-x64@0.4.42': optional: true '@dotenvx/dotenvx@1.75.1': @@ -6253,6 +6309,8 @@ snapshots: merge2@1.4.1: {} + mgrs@1.0.0: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -6587,6 +6645,11 @@ snapshots: prettier@3.9.4: {} + proj4@2.20.9: + dependencies: + mgrs: 1.0.0 + wkt-parser: 1.5.6 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -7291,6 +7354,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wkt-parser@1.5.6: {} + word-wrap@1.2.5: {} wrap-ansi@9.0.2: