From d7adff54b77d263d08fe0fcf3cd29355989287a6 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:31:48 +0800 Subject: [PATCH] feat(xugu): expose spatial query values as WKT --- agents/drivers/xugu/main.go | 89 +++-- agents/drivers/xugu/spatial.go | 484 ++++++++++++++++++++++++++++ agents/drivers/xugu/spatial_test.go | 109 +++++++ 3 files changed, 654 insertions(+), 28 deletions(-) create mode 100644 agents/drivers/xugu/spatial.go create mode 100644 agents/drivers/xugu/spatial_test.go diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 411dc8789d..a1ddb93d56 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -238,6 +238,12 @@ var xuguDataTypes = []string{ "DOUBLE[]", "FLOAT", "GUID", + "GEOMETRY", + "GEOGRAPHY", + "BOX2D", + "BOX3D", + "SPHEROID", + "RASTER", "INT", "INTEGER", "INTEGER[]", @@ -311,12 +317,14 @@ type queryOptions struct { } type queryResult struct { - Columns []string `json:"columns"` - ColumnTypes []string `json:"column_types"` - Rows [][]any `json:"rows"` - AffectedRows int64 `json:"affected_rows"` - ExecutionTimeMS int64 `json:"execution_time_ms"` - Truncated bool `json:"truncated"` + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + SpatialColumns []spatialColumn `json:"spatial_columns,omitempty"` + SpatialValues [][]*uint32 `json:"spatial_values,omitempty"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` } func (r queryResult) MarshalJSON() ([]byte, error) { @@ -335,14 +343,16 @@ func (r queryResult) MarshalJSON() ([]byte, error) { } type queryPageResult struct { - Columns []string `json:"columns"` - ColumnTypes []string `json:"column_types"` - Rows [][]any `json:"rows"` - AffectedRows int64 `json:"affected_rows"` - ExecutionTimeMS int64 `json:"execution_time_ms"` - Truncated bool `json:"truncated"` - SessionID *string `json:"session_id"` - HasMore bool `json:"has_more"` + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + SpatialColumns []spatialColumn `json:"spatial_columns,omitempty"` + SpatialValues [][]*uint32 `json:"spatial_values,omitempty"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` + SessionID *string `json:"session_id"` + HasMore bool `json:"has_more"` } func (r queryPageResult) MarshalJSON() ([]byte, error) { @@ -361,11 +371,13 @@ func (r queryPageResult) MarshalJSON() ([]byte, error) { } type querySession struct { - rows *sql.Rows - columns []string - columnTypes []string - pending []any - remaining int + rows *sql.Rows + columns []string + columnTypes []string + scanner *xuguRowScanner + pending []any + pendingSpatial []*uint32 + remaining int } type databaseInfo struct { @@ -3535,6 +3547,8 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes return queryPageResult{ Columns: result.Columns, ColumnTypes: result.ColumnTypes, + SpatialColumns: result.SpatialColumns, + SpatialValues: result.SpatialValues, Rows: result.Rows, AffectedRows: result.AffectedRows, ExecutionTimeMS: result.ExecutionTimeMS, @@ -3553,11 +3567,19 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes return queryPageResult{}, err } columnTypes := columnTypeNames(rows) + rowScanner := newXuguRowScanner(len(columns), columnTypes) + columnTypes = rowScanner.spatial.columnTypes maxRows := opts.MaxRows if maxRows <= 0 { maxRows = defaultMaxRows } - session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows} + session := &querySession{ + rows: rows, + columns: columns, + columnTypes: columnTypes, + scanner: rowScanner, + remaining: maxRows, + } result, err := readQuerySessionPage(session, pageSize) result.ExecutionTimeMS = time.Since(start).Milliseconds() if err != nil { @@ -3619,37 +3641,42 @@ func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, pageSize = defaultMaxRows } result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false} + spatialValues := make([][]*uint32, 0, pageSize) for len(result.Rows) < pageSize && session.remaining > 0 { if session.pending != nil { result.Rows = append(result.Rows, session.pending) + spatialValues = append(spatialValues, session.pendingSpatial) session.pending = nil + session.pendingSpatial = nil session.remaining-- continue } if !session.rows.Next() { - return result, session.rows.Err() + return finishXuguSpatialPage(result, session.scanner, spatialValues), session.rows.Err() } - row, err := scanRow(session.rows, len(session.columns)) + row, rowSpatial, err := session.scanner.scan(session.rows) if err != nil { return queryPageResult{}, err } result.Rows = append(result.Rows, row) + spatialValues = append(spatialValues, rowSpatial) session.remaining-- } if session.remaining <= 0 { result.Truncated = true - return result, nil + return finishXuguSpatialPage(result, session.scanner, spatialValues), nil } if session.rows.Next() { - row, err := scanRow(session.rows, len(session.columns)) + row, rowSpatial, err := session.scanner.scan(session.rows) if err != nil { return queryPageResult{}, err } session.pending = row + session.pendingSpatial = rowSpatial result.HasMore = true - return result, nil + return finishXuguSpatialPage(result, session.scanner, spatialValues), nil } - return result, session.rows.Err() + return finishXuguSpatialPage(result, session.scanner, spatialValues), session.rows.Err() } func (s *server) executeQuery(opts queryOptions) (queryResult, error) { @@ -3696,18 +3723,24 @@ func (s *server) executeSelect(sqlText string, maxRows int, timeoutSecs int) (qu if err != nil { return queryResult{}, err } - result := queryResult{Columns: columns, ColumnTypes: columnTypeNames(rows), Rows: [][]any{}} + columnTypes := columnTypeNames(rows) + scanner := newXuguRowScanner(len(columns), columnTypes) + columnTypes = scanner.spatial.columnTypes + result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: [][]any{}} + spatialValues := make([][]*uint32, 0) for rows.Next() { if len(result.Rows) >= maxRows { result.Truncated = true break } - values, err := scanRow(rows, len(columns)) + values, rowSpatial, err := scanner.scan(rows) if err != nil { return queryResult{}, err } result.Rows = append(result.Rows, values) + spatialValues = append(spatialValues, rowSpatial) } + result.SpatialColumns, result.SpatialValues = xuguSpatialResultMetadata(scanner, spatialValues) return result, rows.Err() } diff --git a/agents/drivers/xugu/spatial.go b/agents/drivers/xugu/spatial.go new file mode 100644 index 0000000000..efc6982002 --- /dev/null +++ b/agents/drivers/xugu/spatial.go @@ -0,0 +1,484 @@ +package main + +import ( + "database/sql" + "encoding/binary" + "encoding/hex" + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" +) + +// spatialColumn and the corresponding per-cell SRID values are part of the +// common agent query-result protocol consumed by the desktop map preview. +type spatialColumn struct { + ColumnIndex int `json:"column_index"` + SRID *uint32 `json:"srid"` +} + +type xuguRowScanner struct { + values []any + targets []any + spatial *xuguSpatialDecoder +} + +func newXuguRowScanner(count int, columnTypes []string) *xuguRowScanner { + if len(columnTypes) < count { + expanded := make([]string, count) + copy(expanded, columnTypes) + columnTypes = expanded + } + scanner := &xuguRowScanner{ + values: make([]any, count), + targets: make([]any, count), + spatial: newXuguSpatialDecoder(columnTypes), + } + for index := range scanner.targets { + scanner.targets[index] = &scanner.values[index] + } + return scanner +} + +func (scanner *xuguRowScanner) scan(rows *sql.Rows) ([]any, []*uint32, error) { + for index := range scanner.values { + scanner.values[index] = nil + } + if err := rows.Scan(scanner.targets...); err != nil { + return nil, nil, err + } + result := make([]any, len(scanner.values)) + copy(result, scanner.values) + return scanner.spatial.normalizeRow(result) +} + +type xuguSpatialDecoder struct { + columnTypes []string + indices []int + candidates []int + spatial []bool + observed bool + sridByColumn map[int]uint32 +} + +func newXuguSpatialDecoder(columnTypes []string) *xuguSpatialDecoder { + decoder := &xuguSpatialDecoder{ + columnTypes: columnTypes, + spatial: make([]bool, len(columnTypes)), + sridByColumn: map[int]uint32{}, + } + for index, columnType := range columnTypes { + if isXuguSpatialColumnType(columnType) { + decoder.indices = append(decoder.indices, index) + decoder.spatial[index] = true + continue + } + // The current Xugu Go driver leaves DatabaseTypeName empty for direct + // GEOMETRY/GEOGRAPHY projections. Keep only those unknown columns as + // candidates; explicit BLOB/BINARY columns are never auto-classified. + if strings.TrimSpace(columnType) == "" { + decoder.candidates = append(decoder.candidates, index) + } + } + return decoder +} + +func isXuguSpatialColumnType(columnType string) bool { + normalized := strings.ToLower(strings.TrimSpace(columnType)) + if index := strings.LastIndexByte(normalized, '.'); index >= 0 { + normalized = normalized[index+1:] + } + if index := strings.IndexByte(normalized, '('); index >= 0 { + normalized = normalized[:index] + } + return normalized == "geometry" || normalized == "geography" +} + +func (decoder *xuguSpatialDecoder) normalizeRow(values []any) ([]any, []*uint32, error) { + rowSRIDs := make([]*uint32, len(values)) + for _, index := range decoder.candidates { + if index >= len(values) || decoder.spatial[index] { + continue + } + decoded, srid, recognized := decodeXuguSpatialValue(values[index]) + if !recognized { + continue + } + decoder.indices = append(decoder.indices, index) + decoder.spatial[index] = true + decoder.columnTypes[index] = "GEOMETRY" + values[index] = decoded + rowSRIDs[index] = srid + decoder.recordSRID(index, srid) + } + for _, index := range decoder.indices { + if index >= len(values) || rowSRIDs[index] != nil { + continue + } + decoded, srid, _ := decodeXuguSpatialValue(values[index]) + values[index] = decoded + rowSRIDs[index] = srid + decoder.recordSRID(index, srid) + } + for index, value := range values { + if !decoder.spatial[index] { + values[index] = normalizeValue(value) + } + } + if len(decoder.indices) > 0 { + decoder.observed = true + } + return values, rowSRIDs, nil +} + +func (decoder *xuguSpatialDecoder) recordSRID(index int, srid *uint32) { + if srid == nil { + return + } + if _, exists := decoder.sridByColumn[index]; !exists { + decoder.sridByColumn[index] = *srid + } +} + +func (decoder *xuguSpatialDecoder) columns() []spatialColumn { + if !decoder.observed { + return nil + } + columns := make([]spatialColumn, 0, len(decoder.indices)) + for _, index := range decoder.indices { + var srid *uint32 + if value, found := decoder.sridByColumn[index]; found { + srid = uint32Pointer(value) + } + columns = append(columns, spatialColumn{ColumnIndex: index, SRID: srid}) + } + return columns +} + +func xuguSpatialResultMetadata(scanner *xuguRowScanner, values [][]*uint32) ([]spatialColumn, [][]*uint32) { + columns := scanner.spatial.columns() + if len(columns) == 0 { + return nil, nil + } + return columns, values +} + +func finishXuguSpatialPage(result queryPageResult, scanner *xuguRowScanner, values [][]*uint32) queryPageResult { + result.SpatialColumns, result.SpatialValues = xuguSpatialResultMetadata(scanner, values) + return result +} + +func decodeXuguSpatialValue(value any) (any, *uint32, bool) { + if value == nil { + return nil, nil, false + } + switch typed := value.(type) { + case []byte: + if decoded, ok := decodeXuguWKB(typed); ok { + return decoded.WKT, decoded.SRID, true + } + if utf8.Valid(typed) { + return decodeXuguSpatialText(string(typed)) + } + return "0x" + hex.EncodeToString(typed), nil, false + case string: + return decodeXuguSpatialText(typed) + default: + return fmt.Sprint(normalizeValue(value)), nil, false + } +} + +func decodeXuguSpatialText(value string) (any, *uint32, bool) { + if strings.HasPrefix(strings.ToUpper(value), "SRID=") { + if separator := strings.IndexByte(value, ';'); separator > 5 { + if parsed, err := strconv.ParseUint(value[5:separator], 10, 32); err == nil { + srid := uint32(parsed) + wkt := strings.TrimSpace(value[separator+1:]) + if isXuguWKT(wkt) { + return wkt, uint32Pointer(srid), true + } + } + } + } + if raw, ok := parseXuguSpatialHex(value); ok { + if decoded, decodedOK := decodeXuguWKB(raw); decodedOK { + return decoded.WKT, decoded.SRID, true + } + } + // Plain WKT is valid for a declared spatial column. It is intentionally + // not used to promote an unknown column, because free-form text can look + // like WKT and must not change a non-spatial result's representation. + return value, nil, false +} + +func isXuguWKT(value string) bool { + upper := strings.ToUpper(strings.TrimSpace(value)) + for _, prefix := range []string{"POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION"} { + if strings.HasPrefix(upper, prefix+"(") || strings.HasPrefix(upper, prefix+" ") { + return true + } + } + return false +} + +func parseXuguSpatialHex(value string) ([]byte, bool) { + normalized := strings.TrimSpace(value) + if strings.HasPrefix(normalized, "0x") || strings.HasPrefix(normalized, "0X") || strings.HasPrefix(normalized, `\x`) || strings.HasPrefix(normalized, `\X`) { + normalized = normalized[2:] + } + if len(normalized) < 10 || len(normalized)%2 != 0 || (normalized[:2] != "00" && normalized[:2] != "01") { + return nil, false + } + raw, err := hex.DecodeString(normalized) + return raw, err == nil +} + +func uint32Pointer(value uint32) *uint32 { + result := value + return &result +} + +type decodedXuguWKB struct { + WKT string + SRID *uint32 +} + +type xuguWKBDimensions struct{ z, m bool } + +func (dimensions xuguWKBDimensions) size() int { + size := 2 + if dimensions.z { + size++ + } + if dimensions.m { + size++ + } + return size +} + +func (dimensions xuguWKBDimensions) suffix() string { + if dimensions.z && dimensions.m { + return " ZM" + } + if dimensions.z { + return " Z" + } + if dimensions.m { + return " M" + } + return "" +} + +type xuguWKBReader struct { + raw []byte + pos int +} + +func (reader *xuguWKBReader) uint32(order binary.ByteOrder) (uint32, bool) { + if reader.pos+4 > len(reader.raw) { + return 0, false + } + value := order.Uint32(reader.raw[reader.pos : reader.pos+4]) + reader.pos += 4 + return value, true +} + +func (reader *xuguWKBReader) float64(order binary.ByteOrder) (float64, bool) { + if reader.pos+8 > len(reader.raw) { + return 0, false + } + value := math.Float64frombits(order.Uint64(reader.raw[reader.pos : reader.pos+8])) + reader.pos += 8 + return value, true +} + +func (reader *xuguWKBReader) byteOrder() (binary.ByteOrder, bool) { + if reader.pos >= len(reader.raw) { + return nil, false + } + value := reader.raw[reader.pos] + reader.pos++ + if value == 0 { + return binary.BigEndian, true + } + if value == 1 { + return binary.LittleEndian, true + } + return nil, false +} + +func decodeXuguWKB(raw []byte) (decodedXuguWKB, bool) { + reader := &xuguWKBReader{raw: raw} + wkt, srid, ok := reader.geometry(0) + if !ok || reader.pos != len(raw) { + return decodedXuguWKB{}, false + } + return decodedXuguWKB{WKT: wkt, SRID: srid}, true +} + +func (reader *xuguWKBReader) geometry(depth int) (string, *uint32, bool) { + if depth > 64 { + return "", nil, false + } + order, ok := reader.byteOrder() + if !ok { + return "", nil, false + } + typeWord, ok := reader.uint32(order) + if !ok { + return "", nil, false + } + kind, dimensions, hasSRID := parseXuguWKBType(typeWord) + var srid *uint32 + if hasSRID { + value, valueOK := reader.uint32(order) + if !valueOK { + return "", nil, false + } + if value != 0 { + srid = uint32Pointer(value) + } + } + name := map[uint32]string{1: "POINT", 2: "LINESTRING", 3: "POLYGON", 4: "MULTIPOINT", 5: "MULTILINESTRING", 6: "MULTIPOLYGON", 7: "GEOMETRYCOLLECTION"}[kind] + if name == "" { + return "", nil, false + } + suffix := dimensions.suffix() + switch kind { + case 1: + point, empty, pointOK := reader.point(order, dimensions) + if !pointOK { + return "", nil, false + } + if empty { + return name + suffix + " EMPTY", srid, true + } + return name + suffix + "(" + point + ")", srid, true + case 2: + points, pointsOK := reader.points(order, dimensions) + if !pointsOK { + return "", nil, false + } + if len(points) == 0 { + return name + suffix + " EMPTY", srid, true + } + return name + suffix + "(" + strings.Join(points, ",") + ")", srid, true + case 3: + rings, ringsOK := reader.rings(order, dimensions) + if !ringsOK { + return "", nil, false + } + if len(rings) == 0 { + return name + suffix + " EMPTY", srid, true + } + return name + suffix + "(" + strings.Join(rings, ",") + ")", srid, true + case 4, 5, 6, 7: + count, countOK := reader.uint32(order) + if !countOK || uint64(count)*5 > uint64(len(reader.raw)-reader.pos) { + return "", nil, false + } + parts := make([]string, int(count)) + for index := range parts { + child, _, childOK := reader.geometry(depth + 1) + if !childOK { + return "", nil, false + } + switch kind { + case 4: + if !strings.HasPrefix(child, "POINT"+suffix) { + return "", nil, false + } + rest := strings.TrimPrefix(child, "POINT"+suffix) + if strings.HasPrefix(rest, " EMPTY") { + parts[index] = "EMPTY" + } else if strings.HasPrefix(rest, "(") && strings.HasSuffix(rest, ")") { + parts[index] = "(" + strings.TrimSuffix(strings.TrimPrefix(rest, "("), ")") + ")" + } + case 5: + parts[index] = trimXuguWKBChild(child, "LINESTRING", suffix) + case 6: + parts[index] = trimXuguWKBChild(child, "POLYGON", suffix) + default: + parts[index] = child + } + if parts[index] == "" { + return "", nil, false + } + } + if len(parts) == 0 { + return name + suffix + " EMPTY", srid, true + } + return name + suffix + "(" + strings.Join(parts, ",") + ")", srid, true + } + return "", nil, false +} + +func trimXuguWKBChild(value, name, suffix string) string { + prefix := name + suffix + if !strings.HasPrefix(value, prefix+"(") || !strings.HasSuffix(value, ")") { + return "" + } + return "(" + strings.TrimSuffix(strings.TrimPrefix(value, prefix+"("), ")") + ")" +} + +func parseXuguWKBType(typeWord uint32) (uint32, xuguWKBDimensions, bool) { + kind := typeWord & 0x1fffffff + dimensions := xuguWKBDimensions{z: typeWord&0x80000000 != 0, m: typeWord&0x40000000 != 0} + hasSRID := typeWord&0x20000000 != 0 + switch { + case kind >= 3000: + dimensions.z, dimensions.m, kind = true, true, kind-3000 + case kind >= 2000: + dimensions.m, kind = true, kind-2000 + case kind >= 1000: + dimensions.z, kind = true, kind-1000 + } + return kind, dimensions, hasSRID +} + +func (reader *xuguWKBReader) point(order binary.ByteOrder, dimensions xuguWKBDimensions) (string, bool, bool) { + values := make([]string, dimensions.size()) + empty := true + for index := range values { + value, ok := reader.float64(order) + if !ok { + return "", false, false + } + empty = empty && math.IsNaN(value) + values[index] = strconv.FormatFloat(value, 'g', -1, 64) + } + return strings.Join(values, " "), empty, true +} + +func (reader *xuguWKBReader) points(order binary.ByteOrder, dimensions xuguWKBDimensions) ([]string, bool) { + count, ok := reader.uint32(order) + if !ok || uint64(count)*uint64(dimensions.size())*8 > uint64(len(reader.raw)-reader.pos) { + return nil, false + } + points := make([]string, int(count)) + for index := range points { + point, empty, pointOK := reader.point(order, dimensions) + if !pointOK || empty { + return nil, false + } + points[index] = point + } + return points, true +} + +func (reader *xuguWKBReader) rings(order binary.ByteOrder, dimensions xuguWKBDimensions) ([]string, bool) { + count, ok := reader.uint32(order) + if !ok || uint64(count)*4 > uint64(len(reader.raw)-reader.pos) { + return nil, false + } + rings := make([]string, int(count)) + for index := range rings { + points, pointsOK := reader.points(order, dimensions) + if !pointsOK { + return nil, false + } + rings[index] = "(" + strings.Join(points, ",") + ")" + } + return rings, true +} diff --git a/agents/drivers/xugu/spatial_test.go b/agents/drivers/xugu/spatial_test.go new file mode 100644 index 0000000000..9c2bfda08f --- /dev/null +++ b/agents/drivers/xugu/spatial_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/hex" + "reflect" + "testing" +) + +func TestDecodeXuguSpatialValues(t *testing.T) { + point := mustDecodeXuguHex(t, "0101000020E6100000C520B07268195D404E62105839F44340") + tests := []struct { + name string + value any + wantValue any + wantSRID *uint32 + recognized bool + }{ + {"raw EWKB", point, "POINT(116.397 39.908)", uint32Pointer(4326), true}, + {"hex EWKB", "0x0101000020E6100000C520B07268195D404E62105839F44340", "POINT(116.397 39.908)", uint32Pointer(4326), true}, + {"EWKT", "SRID=3857;POINT(1 2)", "POINT(1 2)", uint32Pointer(3857), true}, + {"invalid EWKT is not promoted", "SRID=3857;not a geometry", "SRID=3857;not a geometry", nil, false}, + {"WKT remains text for unknown columns", "POINT(1 2)", "POINT(1 2)", nil, false}, + {"NULL", nil, nil, nil, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, srid, recognized := decodeXuguSpatialValue(test.value) + if !reflect.DeepEqual(value, test.wantValue) || !reflect.DeepEqual(srid, test.wantSRID) || recognized != test.recognized { + t.Fatalf("decodeXuguSpatialValue(%v) = (%v, %v, %v), want (%v, %v, %v)", test.value, value, srid, recognized, test.wantValue, test.wantSRID, test.recognized) + } + }) + } +} + +func TestXuguSpatialDecoderInfersBlankGeometryTypeWithoutPromotingText(t *testing.T) { + columnTypes := []string{"INTEGER", "", "VARCHAR"} + decoder := newXuguSpatialDecoder(columnTypes) + point := mustDecodeXuguHex(t, "0101000020E6100000000000000000F03F0000000000000040") + row, values, err := decoder.normalizeRow([]any{int64(1), point, "POINT(9 9)"}) + if err != nil { + t.Fatal(err) + } + if row[1] != "POINT(1 2)" || row[2] != "POINT(9 9)" { + t.Fatalf("unexpected normalized row: %v", row) + } + if columnTypes[1] != "GEOMETRY" || columnTypes[2] != "VARCHAR" { + t.Fatalf("unexpected inferred column types: %v", columnTypes) + } + if len(values) != 3 || values[1] == nil || *values[1] != 4326 || values[2] != nil { + t.Fatalf("unexpected spatial cell metadata: %v", values) + } + columns := decoder.columns() + if len(columns) != 1 || columns[0].ColumnIndex != 1 || columns[0].SRID == nil || *columns[0].SRID != 4326 { + t.Fatalf("unexpected spatial columns: %+v", columns) + } +} + +func TestXuguSpatialDecoderKeepsOrdinaryBinaryColumnUntouched(t *testing.T) { + columnTypes := []string{"BLOB", "BINARY"} + decoder := newXuguSpatialDecoder(columnTypes) + value := []byte{0x01, 0x02, 0x03} + row, values, err := decoder.normalizeRow([]any{value, value}) + if err != nil { + t.Fatal(err) + } + if len(decoder.indices) != 0 || len(decoder.columns()) != 0 || len(values) != 2 { + t.Fatalf("ordinary binary values were classified as spatial: columns=%+v values=%v", decoder.columns(), values) + } + if row[0] != string(value) || row[1] != string(value) { + t.Fatalf("ordinary binary values changed: %v", row) + } +} + +func TestDecodeXuguSpatialComplexEWKB(t *testing.T) { + encoded := "0107000020E61000000200000001010000000000000000005D4000000000000044400102000000020000000000000000405D4000000000008044400000000000805D400000000000004540" + decoded, ok := decodeXuguWKB(mustDecodeXuguHex(t, encoded)) + if !ok || decoded.WKT != "GEOMETRYCOLLECTION(POINT(116 40),LINESTRING(117 41,118 42))" || decoded.SRID == nil || *decoded.SRID != 4326 { + t.Fatalf("unexpected complex EWKB: %+v ok=%v", decoded, ok) + } +} + +func TestMalformedXuguSpatialBytesFallBack(t *testing.T) { + value, srid, recognized := decodeXuguSpatialValue(mustDecodeXuguHex(t, "0101000020E6100000C520B072")) + if value != "0x0101000020e6100000c520b072" || srid != nil || recognized { + t.Fatalf("unexpected malformed fallback: value=%v srid=%v recognized=%v", value, srid, recognized) + } +} + +func TestXuguDataTypesExposeSpatialDomainTypes(t *testing.T) { + want := []string{"GEOMETRY", "GEOGRAPHY", "BOX2D", "BOX3D"} + set := make(map[string]bool, len(xuguDataTypes)) + for _, value := range xuguDataTypes { + set[value] = true + } + for _, value := range want { + if !set[value] { + t.Fatalf("spatial data type %q is not exposed", value) + } + } +} + +func mustDecodeXuguHex(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return decoded +}