Skip to content

Commit 0c52ef1

Browse files
authored
feat(datagrid): add a Map view for results holding a geometry column (#2532) (#2806)
* feat(plugins): add TableProGeometry, a shared reader for WKT, WKB and GeoJSON geometry Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * feat(datagrid): add a Map view for results holding a geometry column (#2532) Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(datagrid): make a click on a map shape select its row (#2532) Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(datagrid): take map clicks from the map view and report the selection they make (#2532) Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * docs(datagrid): hold the Map mode screenshot until a light and dark pair exists (#2532) Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * build(ios): link TableProGeometry into the iOS app target Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(plugins): read ClickHouse bracketed arrays and every Elasticsearch geo_point form Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(plugin-elasticsearch): keep a geo_shape value whole instead of cutting it at 10,000 characters Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * perf(datagrid): draw the map selection as one overlay per geometry kind Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(datagrid): offer Map for the columns whose values the map can read Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * build(ios): compile the shared MySQL maintenance and PostgreSQL quoting the iOS drivers call Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * fix(ios): keep the shared MySQL server flavor free of the plugin's maintenance statements Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 * test(coordinator): let the pooled driver produce the drop statement the trigger test asserts it runs Claude-Session: https://claude.ai/code/session_01GdkP9Kj2oVdzM5acwU69A3 --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent c180dbc commit 0c52ef1

54 files changed

Lines changed: 4778 additions & 284 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- **Show DDL** and **Copy DDL** for views and materialized views. (#2726)
2929
- **Edit Comment…** for PostgreSQL tables, views, materialized views and foreign tables. (#2726)
3030
- UTF-16 LE, UTF-16 BE and Windows-1252 in the SQL import encoding menu.
31+
- Map view for a result holding a geometry column, drawn with MapKit over Apple's own tiles. (#2532)
3132
- **Max INSERT size** for SQL export, 1 MB by default. (#2533)
3233
- Largest INSERT written, in the SQL export summary. (#2533)
3334

@@ -78,6 +79,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7879
- Snowflake foreign keys into another database opening the current database's same-named table.
7980
- Wrong database read, and written, by a connection whose startup commands select one of their own.
8081
- **None** in the foreign key picker's Label menu forgotten on reopen.
82+
- Elasticsearch `geo_point` columns classified as integers.
83+
- MySQL and MariaDB geometry values losing their SRID.
84+
- MySQL `GEOMETRYCOLLECTION` columns classified as text.
85+
- Empty `GEOMETRYCOLLECTION` from MySQL rendered as invalid WKT.
86+
- MySQL geometry with a Z or M ordinate read from the wrong offset.
87+
- Elasticsearch `geo_shape` values cut off at 10,000 characters.
88+
- A tab stranded in JSON or Chart mode, with no way back to Data, after a statement that returns no columns.
8189
- **Prompt for password** lost when importing a TablePlus connection set to **Ask everytime**.
8290
- TablePlus import saving a password for a connection TablePlus was set never to store one for.
8391
- TablePlus import reading the CA certificate and client key paths the wrong way round.

Packages/TableProCore/Package.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ let package = Package(
1010
],
1111
products: [
1212
.library(name: "TableProCoreTypes", targets: ["TableProCoreTypes"]),
13+
.library(name: "TableProGeometry", targets: ["TableProGeometry"]),
1314
.library(name: "TableProPluginKit", targets: ["TableProPluginKit"]),
1415
.library(name: "TableProModels", targets: ["TableProModels"]),
1516
.library(name: "TableProImport", targets: ["TableProImport"]),
@@ -38,6 +39,11 @@ let package = Package(
3839
dependencies: [],
3940
path: "Sources/TableProCoreTypes"
4041
),
42+
.target(
43+
name: "TableProGeometry",
44+
dependencies: [],
45+
path: "Sources/TableProGeometry"
46+
),
4147
.target(
4248
name: "TableProPluginKit",
4349
dependencies: [],
@@ -113,6 +119,11 @@ let package = Package(
113119
dependencies: [],
114120
path: "Sources/TableProR2SQLCore"
115121
),
122+
.testTarget(
123+
name: "TableProGeometryTests",
124+
dependencies: ["TableProGeometry"],
125+
path: "Tests/TableProGeometryTests"
126+
),
116127
.testTarget(
117128
name: "TableProNumberFormattingTests",
118129
dependencies: ["TableProNumberFormatting"],
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Foundation
2+
3+
/// Reads the GeoJSON that MongoDB, Snowflake and Elasticsearch hand over.
4+
///
5+
/// RFC 7946 fixes two things this reader relies on: coordinates are `[longitude, latitude]`, and
6+
/// the coordinate reference system is always WGS84, so a decoded value reports SRID 4326 rather
7+
/// than nil. A third position is altitude and is dropped, exactly as `MKGeoJSONDecoder` does.
8+
///
9+
/// `MKGeoJSONDecoder` covers this format natively and is used in preference where a whole document
10+
/// is being read, but it rejects an entire document when one coordinate is out of range
11+
/// (`MKErrorDomain` code 6, measured), which would blank a result over a single bad row. Reading
12+
/// per value keeps one bad row to itself.
13+
public enum GeoJSONGeometryReader {
14+
public static func read(_ text: String) -> Result<SpatialValue, SpatialReadFailure> {
15+
guard let data = text.data(using: .utf8) else { return .failure(.notGeometry) }
16+
return read(data)
17+
}
18+
19+
public static func read(_ data: Data) -> Result<SpatialValue, SpatialReadFailure> {
20+
guard let root = try? JSONSerialization.jsonObject(with: data),
21+
let object = root as? [String: Any]
22+
else {
23+
return .failure(.notGeometry)
24+
}
25+
guard let geometry = geometry(from: object, depth: 1) else { return .failure(.notGeometry) }
26+
return .success(SpatialValue(srid: 4326, geometry: geometry))
27+
}
28+
29+
public static func looksLikeGeoJSON(_ text: String) -> Bool {
30+
guard let first = text.first(where: { !$0.isWhitespace }), first == "{" else { return false }
31+
return text.contains("\"type\"") && (text.contains("\"coordinates\"")
32+
|| text.contains("\"geometry\"")
33+
|| text.contains("\"geometries\"")
34+
|| text.contains("\"features\""))
35+
}
36+
37+
private static func geometry(from object: [String: Any], depth: Int) -> SpatialGeometry? {
38+
guard depth <= SpatialLimits.maximumNestingDepth else { return nil }
39+
guard let type = object["type"] as? String else { return nil }
40+
switch type {
41+
case "Feature":
42+
guard let nested = object["geometry"] as? [String: Any] else { return nil }
43+
return geometry(from: nested, depth: depth + 1)
44+
case "FeatureCollection":
45+
guard let features = object["features"] as? [[String: Any]] else { return nil }
46+
let children = features.compactMap { geometry(from: $0, depth: depth + 1) }
47+
return children.count == 1 ? children[0] : .collection(children)
48+
case "GeometryCollection":
49+
guard let members = object["geometries"] as? [[String: Any]] else { return nil }
50+
return .collection(members.compactMap { geometry(from: $0, depth: depth + 1) })
51+
case "Point":
52+
guard let point = position(object["coordinates"]) else { return nil }
53+
return .point(point)
54+
case "MultiPoint":
55+
guard let points = positions(object["coordinates"]) else { return nil }
56+
return .multiPoint(points)
57+
case "LineString":
58+
guard let points = positions(object["coordinates"]) else { return nil }
59+
return .lineString(points)
60+
case "MultiLineString":
61+
guard let lines = positionRings(object["coordinates"]) else { return nil }
62+
return .multiLineString(lines)
63+
case "Polygon":
64+
guard let rings = positionRings(object["coordinates"]) else { return nil }
65+
return .polygon(rings: rings)
66+
case "MultiPolygon":
67+
guard let raw = object["coordinates"] as? [Any] else { return nil }
68+
var polygons: [[[SpatialPoint]]] = []
69+
polygons.reserveCapacity(raw.count)
70+
for entry in raw {
71+
guard let rings = positionRings(entry) else { return nil }
72+
polygons.append(rings)
73+
}
74+
return .multiPolygon(polygons)
75+
default:
76+
return nil
77+
}
78+
}
79+
80+
private static func position(_ raw: Any?) -> SpatialPoint? {
81+
guard let values = raw as? [Any], values.count >= 2,
82+
let x = double(values[0]), let y = double(values[1])
83+
else {
84+
return nil
85+
}
86+
return SpatialPoint(x: x, y: y)
87+
}
88+
89+
private static func positions(_ raw: Any?) -> [SpatialPoint]? {
90+
guard let entries = raw as? [Any] else { return nil }
91+
var points: [SpatialPoint] = []
92+
points.reserveCapacity(entries.count)
93+
for entry in entries {
94+
guard let point = position(entry) else { return nil }
95+
points.append(point)
96+
}
97+
return points
98+
}
99+
100+
private static func positionRings(_ raw: Any?) -> [[SpatialPoint]]? {
101+
guard let entries = raw as? [Any] else { return nil }
102+
var rings: [[SpatialPoint]] = []
103+
rings.reserveCapacity(entries.count)
104+
for entry in entries {
105+
guard let ring = positions(entry) else { return nil }
106+
rings.append(ring)
107+
}
108+
return rings
109+
}
110+
111+
private static func double(_ raw: Any) -> Double? {
112+
if let value = raw as? Double { return value }
113+
if let value = raw as? Int { return Double(value) }
114+
if let value = raw as? NSNumber { return value.doubleValue }
115+
return nil
116+
}
117+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import Foundation
2+
3+
public struct SpatialPoint: Equatable, Sendable {
4+
public let x: Double
5+
public let y: Double
6+
7+
public init(x: Double, y: Double) {
8+
self.x = x
9+
self.y = y
10+
}
11+
}
12+
13+
/// A geometry as the database describes it, in the coordinate system its SRID names.
14+
///
15+
/// Deliberately not MapKit types: the same value has to survive projection, an extent fold and a
16+
/// refusal, and only the drawing layer knows about `CLLocationCoordinate2D`. Z and M ordinates are
17+
/// read so the cursor stays in step with the wire format and then dropped, because MapKit draws on
18+
/// a sphere and has nowhere to put them.
19+
public indirect enum SpatialGeometry: Equatable, Sendable {
20+
case point(SpatialPoint)
21+
case lineString([SpatialPoint])
22+
/// The first ring is the exterior; every later ring is a hole.
23+
case polygon(rings: [[SpatialPoint]])
24+
case multiPoint([SpatialPoint])
25+
case multiLineString([[SpatialPoint]])
26+
case multiPolygon([[[SpatialPoint]]])
27+
case collection([SpatialGeometry])
28+
case empty
29+
}
30+
31+
/// What the engine handed over, with the coordinate system it named.
32+
///
33+
/// `srid` is nil when the value carried none, which is not the same as zero. PostGIS writes no
34+
/// `SRID=` prefix for 0 and MySQL's wire format writes a literal 0, and both mean "unknown" rather
35+
/// than "a coordinate system numbered zero".
36+
public struct SpatialValue: Equatable, Sendable {
37+
public let srid: Int32?
38+
public let geometry: SpatialGeometry
39+
40+
public init(srid: Int32?, geometry: SpatialGeometry) {
41+
self.srid = srid
42+
self.geometry = geometry
43+
}
44+
}
45+
46+
/// Why a cell could not be read as a geometry.
47+
///
48+
/// `unsupportedGeometryType` carries the keyword so the pane can name it. PostGIS hands out
49+
/// `CIRCULARSTRING`, `CURVEPOLYGON` and `TIN` values that no amount of parsing turns into line
50+
/// segments, and a user told "3 shapes could not be drawn" learns nothing; told
51+
/// "CIRCULARSTRING is not supported" they know what to do.
52+
public enum SpatialReadFailure: Error, Equatable, Sendable {
53+
case notGeometry
54+
case unsupportedGeometryType(String)
55+
case malformed
56+
}
57+
58+
public extension SpatialGeometry {
59+
var isEmpty: Bool {
60+
switch self {
61+
case .empty:
62+
return true
63+
case .point:
64+
return false
65+
case .lineString(let points), .multiPoint(let points):
66+
return points.isEmpty
67+
case .polygon(let rings):
68+
return rings.allSatisfy(\.isEmpty)
69+
case .multiLineString(let lines):
70+
return lines.allSatisfy(\.isEmpty)
71+
case .multiPolygon(let polygons):
72+
return polygons.allSatisfy { $0.allSatisfy(\.isEmpty) }
73+
case .collection(let children):
74+
return children.allSatisfy(\.isEmpty)
75+
}
76+
}
77+
78+
/// Every coordinate in the geometry, in reading order.
79+
///
80+
/// Used for the projectability envelope test and the vertex budget, both of which need the
81+
/// count and the extremes rather than the structure.
82+
var allPoints: [SpatialPoint] {
83+
var out: [SpatialPoint] = []
84+
collectPoints(into: &out)
85+
return out
86+
}
87+
88+
var pointCount: Int {
89+
switch self {
90+
case .empty:
91+
return 0
92+
case .point:
93+
return 1
94+
case .lineString(let points), .multiPoint(let points):
95+
return points.count
96+
case .polygon(let rings):
97+
return rings.reduce(0) { $0 + $1.count }
98+
case .multiLineString(let lines):
99+
return lines.reduce(0) { $0 + $1.count }
100+
case .multiPolygon(let polygons):
101+
return polygons.reduce(0) { $0 + $1.reduce(0) { $0 + $1.count } }
102+
case .collection(let children):
103+
return children.reduce(0) { $0 + $1.pointCount }
104+
}
105+
}
106+
107+
private func collectPoints(into out: inout [SpatialPoint]) {
108+
switch self {
109+
case .empty:
110+
return
111+
case .point(let point):
112+
out.append(point)
113+
case .lineString(let points), .multiPoint(let points):
114+
out.append(contentsOf: points)
115+
case .polygon(let rings):
116+
for ring in rings { out.append(contentsOf: ring) }
117+
case .multiLineString(let lines):
118+
for line in lines { out.append(contentsOf: line) }
119+
case .multiPolygon(let polygons):
120+
for polygon in polygons {
121+
for ring in polygon { out.append(contentsOf: ring) }
122+
}
123+
case .collection(let children):
124+
for child in children { child.collectPoints(into: &out) }
125+
}
126+
}
127+
}
128+
129+
/// Bounds every reader shares, so one crafted value cannot cost more than a page of rows does.
130+
public enum SpatialLimits {
131+
/// How deeply a geometry collection may nest before a value is refused.
132+
///
133+
/// Each level of `GEOMETRYCOLLECTION(...)` is one more frame on the reader's stack, and the
134+
/// wire formats put no limit on it, so a few hundred bytes of nothing but nested collection
135+
/// headers would exhaust the stack rather than fail. Thirty-two is far past anything a database
136+
/// writes: PostGIS, MySQL and MapKit's own GeoJSON decoder all produce at most two.
137+
public static let maximumNestingDepth = 32
138+
}

0 commit comments

Comments
 (0)