From daf9f314eb4560ab69641113c965e8f5bd4f45e9 Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Mon, 7 Sep 2026 13:37:56 +0000 Subject: [PATCH 1/3] Add Hashable conformances to Ring, Polygon, and MultiPolygon The hash is based on a rotation-canonical, quantized representation of the ring vertices, consistent with the shift-insensitive equality of these types (compareShifted). Purely additive, no behavior changes. --- Sources/GISTools/GeoJson/MultiPolygon.swift | 16 ++ Sources/GISTools/GeoJson/Polygon.swift | 16 ++ Sources/GISTools/GeoJson/Ring.swift | 91 +++++++++++ .../GeoJson/MultiPolygonTests.swift | 61 +++++++ .../GISToolsTests/GeoJson/PolygonTests.swift | 104 ++++++++++++ Tests/GISToolsTests/GeoJson/RingTests.swift | 152 ++++++++++++++++++ 6 files changed, 440 insertions(+) diff --git a/Sources/GISTools/GeoJson/MultiPolygon.swift b/Sources/GISTools/GeoJson/MultiPolygon.swift index 30ca046a..ffc3b2c7 100644 --- a/Sources/GISTools/GeoJson/MultiPolygon.swift +++ b/Sources/GISTools/GeoJson/MultiPolygon.swift @@ -267,6 +267,22 @@ extension MultiPolygon: Equatable { } +// MARK: - Hashable + +extension MultiPolygon: Hashable { + + /// The hash is based on the projection and the hashes of the polygons, + /// consistent with `==` (which regards rings with shifted start vertices + /// as equal). + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + for polygon in polygons { + hasher.combine(polygon) + } + } + +} + // MARK: - Polygons extension MultiPolygon { diff --git a/Sources/GISTools/GeoJson/Polygon.swift b/Sources/GISTools/GeoJson/Polygon.swift index c3d9942e..622589bf 100644 --- a/Sources/GISTools/GeoJson/Polygon.swift +++ b/Sources/GISTools/GeoJson/Polygon.swift @@ -276,3 +276,19 @@ extension Polygon: Equatable { } } + +// MARK: - Hashable + +extension Polygon: Hashable { + + /// The hash is based on the projection and rotation-canonical, quantized + /// representations of the rings, consistent with `==` (which regards rings + /// with shifted start vertices as equal). + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + for ring in coordinates { + ring.hashAsCanonicalRing(into: &hasher) + } + } + +} diff --git a/Sources/GISTools/GeoJson/Ring.swift b/Sources/GISTools/GeoJson/Ring.swift index 76c8e73d..04237648 100644 --- a/Sources/GISTools/GeoJson/Ring.swift +++ b/Sources/GISTools/GeoJson/Ring.swift @@ -116,6 +116,97 @@ extension [Coordinate3D] { } +// MARK: - Hashable + +/// A quantized, sortable representation of a coordinate, used for +/// epsilon-consistent hashing of rings. +/// +/// Coordinates are quantized like `Coordinate3D.hash(into:)` so that +/// coordinates within `GISTool.equalityDelta` of each other usually produce +/// the same key (the same boundary caveat applies). +private struct QuantizedVertex: Comparable, Hashable { + + let latitude: Double + let longitude: Double + let altitudeState: Int + let altitude: Double + + init(_ coordinate: Coordinate3D) { + self.latitude = (coordinate.latitude / GISTool.equalityDelta).rounded() + self.longitude = (coordinate.longitude / GISTool.equalityDelta).rounded() + self.altitudeState = coordinate.altitude == nil ? 0 : 1 + self.altitude = coordinate.altitude ?? 0.0 + } + + static func <(lhs: QuantizedVertex, rhs: QuantizedVertex) -> Bool { + if lhs.latitude != rhs.latitude { return lhs.latitude < rhs.latitude } + if lhs.longitude != rhs.longitude { return lhs.longitude < rhs.longitude } + if lhs.altitudeState != rhs.altitudeState { return lhs.altitudeState < rhs.altitudeState } + return lhs.altitude < rhs.altitude + } + +} + +extension [Coordinate3D] { + + /// Combine a rotation-canonical, quantized representation of the receiver, + /// treated as a closed ring, into the hasher. + /// + /// This mirrors `compareShifted(_:)`: the closing vertex is dropped and all + /// rotations of the remaining vertices are regarded as equal, so the + /// rotation that sorts lexicographically smallest is used as the canonical + /// form. Two rings that `compareShifted(_:)` regards as equal therefore + /// always produce the same hash. + func hashAsCanonicalRing(into hasher: inout Hasher) { + let vertices = Array(dropLast()).map({ QuantizedVertex($0) }) + let count = vertices.count + guard count > 0 else { return } + + var canonicalStart = 0 + if count > 1 { + for start in 1.. Bool { + for offset in indices { + let lhs = self[(start + offset) % count] + let rhs = self[(other + offset) % count] + if lhs != rhs { + return lhs < rhs + } + } + return false + } + +} + +extension Ring: Hashable { + + /// The hash is based on the projection and a rotation-canonical, quantized + /// representation of the ring's vertices, consistent with `==` (which + /// regards rings with shifted start vertices as equal). + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + coordinates.hashAsCanonicalRing(into: &hasher) + } + +} + // MARK: - Projection extension Ring: Projectable { diff --git a/Tests/GISToolsTests/GeoJson/MultiPolygonTests.swift b/Tests/GISToolsTests/GeoJson/MultiPolygonTests.swift index b4249875..2ed5b2c8 100644 --- a/Tests/GISToolsTests/GeoJson/MultiPolygonTests.swift +++ b/Tests/GISToolsTests/GeoJson/MultiPolygonTests.swift @@ -393,4 +393,65 @@ struct MultiPolygonTests { #expect(multiPolygon.polygons.count == 2) } + // MARK: - Hashable + + // Builds a simple square polygon for the hash tests. + private func squarePolygon(_ offset: Double, rotated: Bool = false) throws -> Polygon { + var coordinates: [Coordinate3D] = [ + Coordinate3D(latitude: offset, longitude: offset), + Coordinate3D(latitude: offset, longitude: offset + 1.0), + Coordinate3D(latitude: offset + 1.0, longitude: offset + 1.0), + Coordinate3D(latitude: offset + 1.0, longitude: offset), + Coordinate3D(latitude: offset, longitude: offset), + ] + if rotated { + coordinates = [ + Coordinate3D(latitude: offset, longitude: offset + 1.0), + Coordinate3D(latitude: offset + 1.0, longitude: offset + 1.0), + Coordinate3D(latitude: offset + 1.0, longitude: offset), + Coordinate3D(latitude: offset, longitude: offset), + Coordinate3D(latitude: offset, longitude: offset + 1.0), + ] + } + return try #require(Polygon([coordinates])) + } + + // Validates that identical MultiPolygons have equal hashes. + @Test + func hashableIdentical() async throws { + let multiPolygonA = try #require(MultiPolygon([squarePolygon(0.0), squarePolygon(2.0)])) + let multiPolygonB = try #require(MultiPolygon([squarePolygon(0.0), squarePolygon(2.0)])) + + #expect(multiPolygonA == multiPolygonB) + #expect(multiPolygonA.hashValue == multiPolygonB.hashValue) + + let set: Set = [multiPolygonA, multiPolygonB] + #expect(set.count == 1) + } + + // Validates that MultiPolygons with shifted ring start vertices have + // equal hashes. + @Test + func hashableShiftedRing() async throws { + let multiPolygon = try #require(MultiPolygon([squarePolygon(0.0)])) + let multiPolygonShifted = try #require(MultiPolygon([squarePolygon(0.0, rotated: true)])) + + #expect(multiPolygon == multiPolygonShifted) + #expect(multiPolygon.hashValue == multiPolygonShifted.hashValue) + + let set: Set = [multiPolygon, multiPolygonShifted] + #expect(set.count == 1) + } + + // Validates that MultiPolygons with swapped polygon order are not equal + // and have different hashes. + @Test + func hashablePolygonOrderSwapped() async throws { + let multiPolygonA = try #require(MultiPolygon([squarePolygon(0.0), squarePolygon(2.0)])) + let multiPolygonB = try #require(MultiPolygon([squarePolygon(2.0), squarePolygon(0.0)])) + + #expect(multiPolygonA != multiPolygonB) + #expect(multiPolygonA.hashValue != multiPolygonB.hashValue) + } + } diff --git a/Tests/GISToolsTests/GeoJson/PolygonTests.swift b/Tests/GISToolsTests/GeoJson/PolygonTests.swift index e79b1e4a..ecb3a9fe 100644 --- a/Tests/GISToolsTests/GeoJson/PolygonTests.swift +++ b/Tests/GISToolsTests/GeoJson/PolygonTests.swift @@ -394,4 +394,108 @@ struct PolygonTests { #expect(bbox.northEast.longitude == 1.0) } + // MARK: - Hashable + + private let polygonCoords: [[Coordinate3D]] = [[ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ]] + + // Validates that identical polygons have equal hashes. + @Test + func hashableIdentical() async throws { + let polygonA = try #require(Polygon(polygonCoords)) + let polygonB = try #require(Polygon(polygonCoords)) + + #expect(polygonA == polygonB) + #expect(polygonA.hashValue == polygonB.hashValue) + + let set: Set = [polygonA, polygonB] + #expect(set.count == 1) + } + + // Validates that polygons with shifted ring start vertices have equal hashes. + @Test + func hashableShiftedRing() async throws { + let polygon = try #require(Polygon(polygonCoords)) + let shiftedCoords: [[Coordinate3D]] = [[ + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + ]] + let polygonShifted = try #require(Polygon(shiftedCoords)) + + #expect(polygon == polygonShifted) + #expect(polygon.hashValue == polygonShifted.hashValue) + + let set: Set = [polygon, polygonShifted] + #expect(set.count == 1) + } + + // Validates that polygons with shifted inner rings have equal hashes. + @Test + func hashableShiftedInnerRing() async throws { + let polygonWithHole = try #require(Polygon(jsonString: PolygonTests.polygonJsonWithHoles)) + let shiftedCoords: [[Coordinate3D]] = [ + [ + Coordinate3D(latitude: 0.0, longitude: 100.0), + Coordinate3D(latitude: 0.0, longitude: 101.0), + Coordinate3D(latitude: 1.0, longitude: 101.0), + Coordinate3D(latitude: 1.0, longitude: 100.0), + Coordinate3D(latitude: 0.0, longitude: 100.0), + ], + [ + Coordinate3D(latitude: 2.0, longitude: 100.8), + Coordinate3D(latitude: 2.0, longitude: 100.2), + Coordinate3D(latitude: 1.0, longitude: 100.2), + Coordinate3D(latitude: 1.0, longitude: 100.8), + Coordinate3D(latitude: 2.0, longitude: 100.8), + ], + ] + let polygonWithHoleShifted = try #require(Polygon(shiftedCoords)) + + #expect(polygonWithHole == polygonWithHoleShifted) + #expect(polygonWithHole.hashValue == polygonWithHoleShifted.hashValue) + } + + // Validates that polygons with different ring order are not equal and + // have different hashes. + @Test + func hashableRingOrderSwapped() async throws { + let outerRing: [Coordinate3D] = [ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ] + let holeRing: [Coordinate3D] = [ + Coordinate3D(latitude: 2.0, longitude: 2.0), + Coordinate3D(latitude: 2.0, longitude: 4.0), + Coordinate3D(latitude: 4.0, longitude: 4.0), + Coordinate3D(latitude: 4.0, longitude: 2.0), + Coordinate3D(latitude: 2.0, longitude: 2.0), + ] + let polygonA = try #require(Polygon(unchecked: [outerRing, holeRing])) + let polygonB = try #require(Polygon(unchecked: [holeRing, outerRing])) + + #expect(polygonA != polygonB) + #expect(polygonA.hashValue != polygonB.hashValue) + } + + // Validates that projections participate in polygon equality and hashing. + @Test + func hashableProjections() async throws { + let polygon4326 = try #require(Polygon(polygonCoords)) + let polygon3857 = polygon4326.projected(to: .epsg3857) + + #expect(polygon4326 != polygon3857) + #expect(polygon4326.hashValue != polygon3857.hashValue) + } + } diff --git a/Tests/GISToolsTests/GeoJson/RingTests.swift b/Tests/GISToolsTests/GeoJson/RingTests.swift index b6cb1306..91714f81 100644 --- a/Tests/GISToolsTests/GeoJson/RingTests.swift +++ b/Tests/GISToolsTests/GeoJson/RingTests.swift @@ -165,4 +165,156 @@ struct RingTests { #expect(ring != otherRing) } + // MARK: - Hashable + + // Validates that identical rings have equal hashes. + @Test + func hashableIdentical() async throws { + let ringA = try #require(Ring(coords)) + let ringB = try #require(Ring(coords)) + + #expect(ringA == ringB) + #expect(ringA.hashValue == ringB.hashValue) + } + + // Validates that rings with shifted start vertices have equal hashes. + @Test + func hashableShiftedStart() async throws { + let ring = try #require(Ring(coords)) + let shiftedCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + ] + let ringShifted = try #require(Ring(shiftedCoords)) + + #expect(ring == ringShifted) + #expect(ring.hashValue == ringShifted.hashValue) + + let set: Set = [ring, ringShifted] + #expect(set.count == 1) + } + + // Validates that rings with different vertices have different hashes. + @Test + func hashableNotEqual() async throws { + let ring = try #require(Ring(coords)) + let otherCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 20.0), + Coordinate3D(latitude: 20.0, longitude: 20.0), + Coordinate3D(latitude: 20.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ] + let otherRing = try #require(Ring(otherCoords)) + + #expect(ring != otherRing) + #expect(ring.hashValue != otherRing.hashValue) + } + + // Validates that reversing a ring changes equality and hash (a reversal + // is not a rotation). + @Test + func hashableReversed() async throws { + let ring = try #require(Ring(coords)) + let reversedCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ] + let ringReversed = try #require(Ring(reversedCoords)) + + #expect(ring != ringReversed) + #expect(ring.hashValue != ringReversed.hashValue) + } + + // Validates that epsilon-equal rings (within `GISTool.equalityDelta`) are + // equal and have equal hashes. + @Test + func hashableEpsilonEqual() async throws { + let ring = try #require(Ring(coords)) + let epsilonCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 0.00000000003, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 10.0), + Coordinate3D(latitude: 10.0, longitude: 0.0), + Coordinate3D(latitude: 0.00000000003, longitude: 0.0), + ] + let ringEpsilon = try #require(Ring(epsilonCoords)) + + #expect(ring == ringEpsilon) + #expect(ring.hashValue == ringEpsilon.hashValue) + } + + // Validates that rings differing only in altitude are not equal and have + // different hashes. + @Test + func hashableAltitude() async throws { + let ring = try #require(Ring(coords)) + let altitudeCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 0.0, longitude: 0.0, altitude: 100.0), + Coordinate3D(latitude: 0.0, longitude: 10.0, altitude: 100.0), + Coordinate3D(latitude: 10.0, longitude: 10.0, altitude: 100.0), + Coordinate3D(latitude: 10.0, longitude: 0.0, altitude: 100.0), + Coordinate3D(latitude: 0.0, longitude: 0.0, altitude: 100.0), + ] + let ringAltitude = try #require(Ring(altitudeCoords)) + + #expect(ring != ringAltitude) + #expect(ring.hashValue != ringAltitude.hashValue) + } + + // Validates that projections participate in equality and hashing. + @Test + func hashableProjections() async throws { + let ring4326 = try #require(Ring(coords)) + let ring3857 = ring4326.projected(to: .epsg3857) + let ring3857Again = ring3857.projected(to: .epsg3857) + + #expect(ring3857 == ring3857Again) + #expect(ring3857.hashValue == ring3857Again.hashValue) + + #expect(ring4326 != ring3857) + #expect(ring4326.hashValue != ring3857.hashValue) + } + + // Validates that rings crossing the antimeridian hash consistently when + // their start vertex is shifted. + @Test + func hashableAntimeridian() async throws { + let antimeridianCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 10.0, longitude: 170.0), + Coordinate3D(latitude: 10.0, longitude: -170.0), + Coordinate3D(latitude: 0.0, longitude: -170.0), + Coordinate3D(latitude: 0.0, longitude: 170.0), + Coordinate3D(latitude: 10.0, longitude: 170.0), + ] + let ring = try #require(Ring(antimeridianCoords)) + let shiftedCoords: [Coordinate3D] = [ + Coordinate3D(latitude: 0.0, longitude: -170.0), + Coordinate3D(latitude: 0.0, longitude: 170.0), + Coordinate3D(latitude: 10.0, longitude: 170.0), + Coordinate3D(latitude: 10.0, longitude: -170.0), + Coordinate3D(latitude: 0.0, longitude: -170.0), + ] + let ringShifted = try #require(Ring(shiftedCoords)) + + #expect(ring == ringShifted) + #expect(ring.hashValue == ringShifted.hashValue) + } + + // Validates that empty (unchecked) rings are equal and hash equally. + @Test + func hashableEmpty() async throws { + let ringA = Ring(unchecked: []) + let ringB = Ring(unchecked: []) + + #expect(ringA == ringB) + #expect(ringA.hashValue == ringB.hashValue) + } + } From 5184540ddd06045e0cebb0378c7c50e3b7fb55bc Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Mon, 7 Sep 2026 14:03:45 +0000 Subject: [PATCH 2/3] Add typed access to Feature.properties and foreign members - JSONValue: a Hashable/Sendable/Codable JSON value enum with numeric normalization (3.0 == 3), created from property-style Sendable values - properties(as:)/foreignMembers(as:): decode into any Decodable type, with JSONDecoder strategy support and real DecodingErrors - jsonValue(for:)/jsonForeignMember(for:): pattern matching accessors - intValue/doubleValue/boolValue/stringValue(for:) and foreign member equivalents: coercing scalar accessors - Feature(encodedProperties:): create a Feature from Encodable properties (distinct label to keep dictionary-literal call sites resolving to the [String: Sendable] initializer) - Hashable conformances for Point, MultiPoint, LineString, MultiLineString, GeometryCollection, Feature, and FeatureCollection, consistent with their Equatable implementations - Existing accessors and Equatable behavior are unchanged --- README.md | 43 +++ Sources/GISTools/GeoJson/Feature.swift | 153 ++++++++++ .../GISTools/GeoJson/FeatureCollection.swift | 15 + Sources/GISTools/GeoJson/GeoJson.swift | 75 +++++ .../GISTools/GeoJson/GeometryCollection.swift | 17 ++ Sources/GISTools/GeoJson/JSONValue.swift | 228 +++++++++++++++ Sources/GISTools/GeoJson/JsonCoercion.swift | 93 +++++- Sources/GISTools/GeoJson/LineString.swift | 13 + .../GISTools/GeoJson/MultiLineString.swift | 13 + Sources/GISTools/GeoJson/MultiPoint.swift | 13 + Sources/GISTools/GeoJson/Point.swift | 13 + .../GeoJson/FeatureCollectionTests.swift | 22 ++ .../GISToolsTests/GeoJson/FeatureTests.swift | 265 ++++++++++++++++++ .../GeoJson/GeometryCollectionTests.swift | 25 ++ .../GeoJson/JSONValueTests.swift | 185 ++++++++++++ .../GeoJson/LineStringTests.swift | 26 ++ .../GeoJson/MultiLineStringTests.swift | 27 ++ .../GeoJson/MultiPointTests.swift | 27 ++ Tests/GISToolsTests/GeoJson/PointTests.swift | 56 ++++ 19 files changed, 1299 insertions(+), 10 deletions(-) create mode 100644 Sources/GISTools/GeoJson/JSONValue.swift create mode 100644 Tests/GISToolsTests/GeoJson/JSONValueTests.swift diff --git a/README.md b/README.md index 816a1652..35c4213b 100644 --- a/README.md +++ b/README.md @@ -738,6 +738,49 @@ init(_ geometry: GeoJsonGeometry, func projected(to newProjection: Projection) -> Feature ``` +### Typed property access + +Property reads (`property(for:)`, `subscript(_:)`) are unchecked casts that +return `nil` on any type mismatch — notably an `Int` read fails on a value +written as `3.0`. For typed access, `JSONValue` supports exhaustive pattern +matching, and properties can be decoded into any `Decodable` type (numeric +coercion handled, real errors instead of silent `nil`s): +```swift +enum JSONValue: Hashable, Sendable, Codable { + case string(String) + case number(Double) + case int(Int) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null +} + +struct RegionProperties: Codable { + let isoCode: String + let name: String + let priority: Int +} + +// Decode properties into a domain type (CodingKeys and JSONDecoder +// strategies like dateDecodingStrategy apply): +let props = try feature.properties(as: RegionProperties.self) + +// Pattern matching: +if case .int(let priority) = feature.jsonValue(for: "priority") { ... } + +// Coercing accessors: +let priority = feature.intValue(for: "priority") // 3.0 → 3 +let name = feature.stringValue(for: "name") + +// Create a Feature from Encodable properties: +let feature = try Feature(geometry, encodedProperties: RegionProperties(...)) + +// The same accessors exist for foreign members on all GeoJson types: +let zoom = point.intForeignMember(for: "zoom") +let extra = try point.foreignMembers(as: Extra.self) +``` + ## FeatureCollection [Implementation][38] / [FeatureCollection test cases][39] diff --git a/Sources/GISTools/GeoJson/Feature.swift b/Sources/GISTools/GeoJson/Feature.swift index e7093f22..4fb0aafa 100644 --- a/Sources/GISTools/GeoJson/Feature.swift +++ b/Sources/GISTools/GeoJson/Feature.swift @@ -257,6 +257,26 @@ extension Feature: Equatable { } +// MARK: - Hashable + +extension Feature: Hashable { + + /// The hash is consistent with `==`: projection, geometry, identifier, + /// and property keys. Property values are not compared (yet), so they are + /// not hashed either. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + if let hashableGeometry = geometry as? any Hashable { + hasher.combine(hashableGeometry) + } + hasher.combine(id) + for key in properties.keys.sorted() { + hasher.combine(key) + } + } + +} + // MARK: - Projection extension Feature { @@ -329,6 +349,139 @@ extension Feature { } +// MARK: - Typed properties + +extension Feature { + + /// Decode the receiver's properties into a `Decodable` type. + /// + /// The property values are converted to ``JSONValue`` (normalizing numbers + /// along the way, so a property written as `3.0` decodes into an `Int` + /// field), encoded to JSON, and decoded with the given decoder — meaning + /// `JSONDecoder` strategies (`dateDecodingStrategy`, + /// `keyDecodingStrategy`, …) apply and real `DecodingError`s are thrown + /// instead of failing silently. + /// + /// ```swift + /// struct RegionProperties: Codable { + /// let isoCode: String + /// let name: String + /// let priority: Int + /// } + /// + /// let props = try feature.properties(as: RegionProperties.self) + /// ``` + /// + /// - Parameters: + /// - type: The type to decode into + /// - decoder: The decoder to use (default `JSONDecoder()`) + /// - Returns: The decoded value + /// - Throws: A `DecodingError` if the properties cannot be decoded into + /// the given type, or if a property value is not JSON-compatible + public func properties( + as type: T.Type, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + try JSONValue.decode(properties, as: type, decoder: decoder) + } + + /// Create a `Feature` with properties from an `Encodable` value. + /// + /// The value is encoded to JSON and its properties are stored as native + /// `Sendable` values (`Int` fields stay `Int`, `String` fields stay + /// `String`), so they serialize and read back consistently. + /// + /// ```swift + /// let feature = try Feature( + /// geometry, + /// encodedProperties: RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + /// ``` + /// + /// - important: The argument label is `encodedProperties:` (not + /// `properties:`) so that dictionary-literal call sites keep + /// resolving to the `[String: Sendable]` initializer. + /// + /// - Parameters: + /// - geometry: The geometry object + /// - id: An optional identifier + /// - encodedProperties: The properties (must encode to a JSON object) + /// - calculateBoundingBox: When true, calculate the bounding box from the geometry + /// - Throws: An `EncodingError` if the value cannot be encoded to JSON, + /// or if it does not encode to a JSON object + public init( + _ geometry: GeoJsonGeometry, + id: Identifier? = nil, + encodedProperties: T, + calculateBoundingBox: Bool = false + ) throws { + let data = try JSONEncoder().encode(encodedProperties) + + guard let jsonProperties = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + throw EncodingError.invalidValue(encodedProperties, .init( + codingPath: [], + debugDescription: "The properties do not encode to a JSON object")) + } + + var sendableProperties: [String: Sendable] = [:] + sendableProperties.reserveCapacity(jsonProperties.count) + + for (key, jsonValue) in jsonProperties { + sendableProperties[key] = jsonValue.asSendable + } + + self.init(geometry, id: id, properties: sendableProperties, calculateBoundingBox: calculateBoundingBox) + } + + /// Returns a property as ``JSONValue``, e.g. for exhaustive pattern matching. + /// + /// - Parameter key: The property key + /// - Returns: The property value, or `nil` if the key doesn't exist or the + /// value is not JSON-compatible + public func jsonValue(for key: String) -> JSONValue? { + JSONValue(value: properties[key]) + } + + /// Returns a property coerced to `Int`. + /// + /// Numbers are converted when they can be represented exactly, so a + /// property written as `3.0` returns `3`. + /// + /// - Parameter key: The property key + /// - Returns: The property value, or `nil` if the key doesn't exist or the + /// value is not exactly representable as an integer + public func intValue(for key: String) -> Int? { + JsonCoercion.int(properties[key]) + } + + /// Returns a property coerced to `Double`. + /// + /// - Parameter key: The property key + /// - Returns: The property value, or `nil` if the key doesn't exist or the + /// value is not a number + public func doubleValue(for key: String) -> Double? { + JsonCoercion.double(properties[key]) + } + + /// Returns a property coerced to `Bool`. + /// + /// - Parameter key: The property key + /// - Returns: The property value, or `nil` if the key doesn't exist or the + /// value is not a boolean + public func boolValue(for key: String) -> Bool? { + JsonCoercion.bool(properties[key]) + } + + /// Returns a property coerced to `String`. + /// + /// - Parameter key: The property key + /// - Returns: The property value, or `nil` if the key doesn't exist or the + /// value is not a string + public func stringValue(for key: String) -> String? { + JsonCoercion.string(properties[key]) + } + +} + // MARK: - Convenience accessors extension Feature { diff --git a/Sources/GISTools/GeoJson/FeatureCollection.swift b/Sources/GISTools/GeoJson/FeatureCollection.swift index b186333f..200d3f46 100644 --- a/Sources/GISTools/GeoJson/FeatureCollection.swift +++ b/Sources/GISTools/GeoJson/FeatureCollection.swift @@ -229,6 +229,21 @@ extension FeatureCollection: Equatable { } +// MARK: - Hashable + +extension FeatureCollection: Hashable { + + /// The hash is based on the projection and the hashes of the features, + /// consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + for feature in features { + hasher.combine(feature) + } + } + +} + // MARK: - Projection extension FeatureCollection { diff --git a/Sources/GISTools/GeoJson/GeoJson.swift b/Sources/GISTools/GeoJson/GeoJson.swift index eee87569..7895b410 100644 --- a/Sources/GISTools/GeoJson/GeoJson.swift +++ b/Sources/GISTools/GeoJson/GeoJson.swift @@ -125,6 +125,81 @@ extension GeoJson { } +// MARK: - Typed foreign members + +extension GeoJson { + + /// Decode the receiver's foreign members into a `Decodable` type. + /// + /// The values are converted to ``JSONValue`` (normalizing numbers along + /// the way), encoded to JSON, and decoded with the given decoder — meaning + /// `JSONDecoder` strategies (`dateDecodingStrategy`, + /// `keyDecodingStrategy`, …) apply and real `DecodingError`s are thrown + /// instead of failing silently. + /// + /// - Parameters: + /// - type: The type to decode into + /// - decoder: The decoder to use (default `JSONDecoder()`) + /// - Returns: The decoded value + /// - Throws: A `DecodingError` if the foreign members cannot be decoded + /// into the given type, or if a value is not JSON-compatible + public func foreignMembers( + as type: T.Type, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + try JSONValue.decode(foreignMembers, as: type, decoder: decoder) + } + + /// Returns a foreign member as ``JSONValue``, e.g. for exhaustive pattern matching. + /// + /// - Parameter key: The foreign member key + /// - Returns: The foreign member value, or `nil` if the key doesn't exist + /// or the value is not JSON-compatible + public func jsonForeignMember(for key: String) -> JSONValue? { + JSONValue(value: foreignMembers[key]) + } + + /// Returns a foreign member coerced to `Int`. + /// + /// Numbers are converted when they can be represented exactly, so a + /// member written as `3.0` returns `3`. + /// + /// - Parameter key: The foreign member key + /// - Returns: The foreign member value, or `nil` if the key doesn't exist + /// or the value is not exactly representable as an integer + public func intForeignMember(for key: String) -> Int? { + JsonCoercion.int(foreignMembers[key]) + } + + /// Returns a foreign member coerced to `Double`. + /// + /// - Parameter key: The foreign member key + /// - Returns: The foreign member value, or `nil` if the key doesn't exist + /// or the value is not a number + public func doubleForeignMember(for key: String) -> Double? { + JsonCoercion.double(foreignMembers[key]) + } + + /// Returns a foreign member coerced to `Bool`. + /// + /// - Parameter key: The foreign member key + /// - Returns: The foreign member value, or `nil` if the key doesn't exist + /// or the value is not a boolean + public func boolForeignMember(for key: String) -> Bool? { + JsonCoercion.bool(foreignMembers[key]) + } + + /// Returns a foreign member coerced to `String`. + /// + /// - Parameter key: The foreign member key + /// - Returns: The foreign member value, or `nil` if the key doesn't exist + /// or the value is not a string + public func stringForeignMember(for key: String) -> String? { + JsonCoercion.string(foreignMembers[key]) + } + +} + // MARK: - EmptyCreatable /// GeoJSON objects that can be created empty, which might lead to diff --git a/Sources/GISTools/GeoJson/GeometryCollection.swift b/Sources/GISTools/GeoJson/GeometryCollection.swift index 0df7d53a..2e28c287 100644 --- a/Sources/GISTools/GeoJson/GeometryCollection.swift +++ b/Sources/GISTools/GeoJson/GeometryCollection.swift @@ -172,6 +172,23 @@ extension GeometryCollection: Equatable { } +// MARK: - Hashable + +extension GeometryCollection: Hashable { + + /// The hash is based on the projection and the hashes of the contained + /// geometries, consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + for geometry in geometries { + if let hashableGeometry = geometry as? any Hashable { + hasher.combine(hashableGeometry) + } + } + } + +} + // MARK: - Projection extension GeometryCollection { diff --git a/Sources/GISTools/GeoJson/JSONValue.swift b/Sources/GISTools/GeoJson/JSONValue.swift new file mode 100644 index 00000000..bec7e329 --- /dev/null +++ b/Sources/GISTools/GeoJson/JSONValue.swift @@ -0,0 +1,228 @@ +import Foundation + +/// A JSON value. +/// +/// ``Feature/properties`` and ``GeoJson/foreignMembers`` store JSON-compatible +/// values as `[String: Sendable]`. ``JSONValue`` is their typed counterpart: +/// it can be created from any of those values (`init?(value:)`), supports +/// exhaustive pattern matching, and round-trips through `Codable`. +/// +/// Numbers are normalized: an integral number (e.g. `3.0`) is represented as +/// `.int(3)`, so values compare and hash consistently regardless of how a +/// producer encoded them. Integers beyond `Int.max` fall back to `.number` +/// (with precision loss above 2^53, like the JavaScript ecosystem). +public enum JSONValue: Hashable, Sendable, Codable { + + /// A string. + case string(String) + /// A number. + case number(Double) + /// An integer. + case int(Int) + /// A boolean. + case bool(Bool) + /// An array. + case array([JSONValue]) + /// An object. + case object([String: JSONValue]) + /// JSON `null`. + case null + +} + +// MARK: - Conversion + +extension JSONValue { + + /// Try to initialize a `JSONValue` from any property-style value + /// (as stored in ``Feature/properties`` and ``GeoJson/foreignMembers``). + /// + /// Numbers are normalized to `.int` when they can be represented exactly. + /// Values that are not JSON-compatible (e.g. `Data` or custom types) fail. + /// + /// - Parameter value: The value to convert + public init?(value: Any?) { + guard let value else { return nil } + + if value is NSNull { + self = .null + return + } + + if let string = JsonCoercion.string(value) { + self = .string(string) + return + } + + if let bool = JsonCoercion.bool(value) { + self = .bool(bool) + return + } + + if let int = JsonCoercion.int(value) { + self = .int(int) + return + } + + if let double = JsonCoercion.double(value) { + self = .number(double) + return + } + + if let dictionary = JsonCoercion.dictionary(value) { + var result: [String: JSONValue] = [:] + result.reserveCapacity(dictionary.count) + + for (key, element) in dictionary { + guard let jsonValue = JSONValue(value: element) else { return nil } + result[key] = jsonValue + } + + self = .object(result) + return + } + + if let elements = JsonCoercion.array(value) { + var result: [JSONValue] = [] + result.reserveCapacity(elements.count) + + for element in elements { + guard let jsonValue = JSONValue(value: element) else { return nil } + result.append(jsonValue) + } + + self = .array(result) + return + } + + return nil + } + + /// The receiver as a `Sendable` JSON value (the representation used by + /// ``Feature/properties`` and ``GeoJson/foreignMembers``). + public var asSendable: Sendable { + switch self { + case .string(let string): string + case .number(let double): double + case .int(let int): int + case .bool(let bool): bool + case .array(let array): array.map({ $0.asSendable }) + case .object(let object): object.mapValues({ $0.asSendable }) + case .null: NSNull() + } + } + +} + +// MARK: - Decodable + +extension JSONValue { + + /// Decode a `[String: Sendable]` dictionary into a `Decodable` type. + /// + /// The values are converted to ``JSONValue`` (normalizing numbers along + /// the way), encoded to JSON, and decoded with the given decoder — so + /// `JSONDecoder` strategies apply and real `DecodingError`s are thrown + /// instead of failing silently. + /// + /// - Parameters: + /// - dictionary: The property-style dictionary + /// - type: The type to decode into + /// - decoder: The decoder to use + /// - Returns: The decoded value + /// - Throws: A `DecodingError` if the dictionary cannot be decoded into + /// the given type, or if a value is not JSON-compatible + static func decode( + _ dictionary: [String: Sendable], + as type: T.Type, + decoder: JSONDecoder + ) throws -> T { + var jsonDictionary: [String: JSONValue] = [:] + jsonDictionary.reserveCapacity(dictionary.count) + + for (key, value) in dictionary { + guard let jsonValue = JSONValue(value: value) else { + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "The value for key \"\(key)\" is not JSON-compatible")) + } + jsonDictionary[key] = jsonValue + } + + let data = try JSONEncoder().encode(jsonDictionary) + return try decoder.decode(T.self, from: data) + } + +} + +// MARK: - Codable + +extension JSONValue { + + /// Try to decode any JSON value. + /// + /// Integral numbers (including fractional input such as `3.0`) decode to + /// `.int`, keeping values comparable across producers. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + if let string = try? container.decode(String.self) { + self = .string(string) + return + } + + if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + return + } + + if let int = try? container.decode(Int.self) { + self = .int(int) + return + } + + if let double = try? container.decode(Double.self) { + if let int = Int(exactly: double) { + self = .int(int) + } + else { + self = .number(double) + } + return + } + + if let array = try? container.decode([JSONValue].self) { + self = .array(array) + return + } + + if let object = try? container.decode([String: JSONValue].self) { + self = .object(object) + return + } + + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported JSON value")) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let string): try container.encode(string) + case .number(let double): try container.encode(double) + case .int(let int): try container.encode(int) + case .bool(let bool): try container.encode(bool) + case .array(let array): try container.encode(array) + case .object(let object): try container.encode(object) + case .null: try container.encodeNil() + } + } + +} diff --git a/Sources/GISTools/GeoJson/JsonCoercion.swift b/Sources/GISTools/GeoJson/JsonCoercion.swift index bcdaf91d..f32baae5 100644 --- a/Sources/GISTools/GeoJson/JsonCoercion.swift +++ b/Sources/GISTools/GeoJson/JsonCoercion.swift @@ -116,24 +116,38 @@ enum JsonCoercion { return result } + /// Check if a value is a boolean. + /// + /// Booleans are `NSNumber`-backed when they come from `JSONSerialization` + /// (`__NSCFBoolean`) or when bridged from a native `Bool`. The `objCType` + /// `"c"` marker is the reliable discriminator on all supported platforms: + /// `is Bool` answers `true` for plain numbers on some Linux Foundation + /// versions, and conditional casts to `Bool` also succeed for + /// `NSNumber(value: 1)`. (An `Int8` would also report `"c"`, but JSON has + /// no `Int8` numbers and `Int8` values cannot be stored in property + /// dictionaries.) + /// + /// - Parameter value: A JSON value + /// - Returns: `true` if the value is a boolean + static func isBoolean(_ value: Any?) -> Bool { + guard let value else { return false } + + if let number = value as? NSNumber { + return String(cString: number.objCType) == "c" + } + + return value is Bool + } + /// Numeric value of a JSON number, tolerating both bridged `NSNumber` and /// native Swift numeric types (including `Float`). /// /// - Parameter value: A JSON value /// - Returns: The value as `Double`, or `nil` if the value is not a number static func double(_ value: Any?) -> Double? { - guard let value else { return nil } + guard let value, !isBoolean(value) else { return nil } if let number = value as? NSNumber { - // Reject booleans: they are `NSNumber`-backed (`__NSCFBoolean` on - // Darwin, and bridged native `Bool`). Note that `value is Bool` - // is *not* usable as a discriminator on Linux corelibs, where - // plain numbers like `1.0` also answer `true`; `objCType == "c"` - // is the reliable boolean marker on both platforms. (An `Int8` - // would also report `"c"`, but JSON has no `Int8` numbers.) - if String(cString: number.objCType) == "c" { - return nil - } return number.doubleValue } if let double = value as? Double { @@ -145,6 +159,65 @@ enum JsonCoercion { return nil } + /// Integer value of a JSON number, tolerating both bridged `NSNumber` and + /// native Swift numeric types. + /// + /// Numbers are converted when they can be represented exactly (`3.0` + /// becomes `3`); fractional numbers and values beyond the `Int` range + /// return `nil`. Booleans are never converted to numbers. + /// + /// - Parameter value: A JSON value + /// - Returns: The value as `Int`, or `nil` if the value is not exactly + /// representable as an integer + static func int(_ value: Any?) -> Int? { + guard let value, !isBoolean(value) else { return nil } + + if let int = value as? Int { + return int + } + if let double = value as? Double { + return Int(exactly: double) + } + if let int64 = value as? Int64 { + return Int(exactly: int64) + } + if let uint = value as? UInt { + return Int(exactly: uint) + } + if let uint64 = value as? UInt64 { + return Int(exactly: uint64) + } + if let float = value as? Float { + return Int(exactly: Double(float)) + } + return nil + } + + /// Boolean value of a JSON boolean, tolerating bridged `NSNumber` + /// booleans and native `Bool`. + /// + /// - Parameter value: A JSON value + /// - Returns: The value as `Bool`, or `nil` if the value is not a boolean + static func bool(_ value: Any?) -> Bool? { + guard let value, isBoolean(value) else { return nil } + + if let number = value as? NSNumber { + return number.boolValue + } + if let bool = value as? Bool { + return bool + } + return nil + } + + /// String value of a JSON string. + /// + /// - Parameter value: A JSON value + /// - Returns: The value as `String`, or `nil` if the value is not a string + static func string(_ value: Any?) -> String? { + value as? String + } + /// Coerce a JSON array of numbers into `[Double]`. /// /// - Parameter value: A JSON array of numbers diff --git a/Sources/GISTools/GeoJson/LineString.swift b/Sources/GISTools/GeoJson/LineString.swift index 9feda1de..5e864cc7 100644 --- a/Sources/GISTools/GeoJson/LineString.swift +++ b/Sources/GISTools/GeoJson/LineString.swift @@ -328,3 +328,16 @@ extension LineString: Equatable { } } + +// MARK: - Hashable + +extension LineString: Hashable { + + /// The hash is based on the projection and the (epsilon-quantized) + /// coordinates, consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + hasher.combine(coordinates) + } + +} diff --git a/Sources/GISTools/GeoJson/MultiLineString.swift b/Sources/GISTools/GeoJson/MultiLineString.swift index 6a6d38d5..b8169adc 100644 --- a/Sources/GISTools/GeoJson/MultiLineString.swift +++ b/Sources/GISTools/GeoJson/MultiLineString.swift @@ -310,6 +310,19 @@ extension MultiLineString: Equatable { } +// MARK: - Hashable + +extension MultiLineString: Hashable { + + /// The hash is based on the projection and the (epsilon-quantized) + /// coordinates, consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + hasher.combine(coordinates) + } + +} + // MARK: - LineStrings extension MultiLineString { diff --git a/Sources/GISTools/GeoJson/MultiPoint.swift b/Sources/GISTools/GeoJson/MultiPoint.swift index 2772c8ea..d406bd9c 100644 --- a/Sources/GISTools/GeoJson/MultiPoint.swift +++ b/Sources/GISTools/GeoJson/MultiPoint.swift @@ -264,6 +264,19 @@ extension MultiPoint: Equatable { } +// MARK: - Hashable + +extension MultiPoint: Hashable { + + /// The hash is based on the projection and the (epsilon-quantized) + /// coordinates, consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + hasher.combine(coordinates) + } + +} + // MARK: - Points extension MultiPoint { diff --git a/Sources/GISTools/GeoJson/Point.swift b/Sources/GISTools/GeoJson/Point.swift index 76f15db6..f62dd6ed 100644 --- a/Sources/GISTools/GeoJson/Point.swift +++ b/Sources/GISTools/GeoJson/Point.swift @@ -188,3 +188,16 @@ extension Point: Equatable { } } + +// MARK: - Hashable + +extension Point: Hashable { + + /// The hash is based on the projection and the (epsilon-quantized) + /// coordinate, consistent with `==`. + public func hash(into hasher: inout Hasher) { + hasher.combine(projection) + hasher.combine(coordinate) + } + +} diff --git a/Tests/GISToolsTests/GeoJson/FeatureCollectionTests.swift b/Tests/GISToolsTests/GeoJson/FeatureCollectionTests.swift index 4144e1c8..4dab5829 100644 --- a/Tests/GISToolsTests/GeoJson/FeatureCollectionTests.swift +++ b/Tests/GISToolsTests/GeoJson/FeatureCollectionTests.swift @@ -443,4 +443,26 @@ struct FeatureCollectionTests { #expect(!featureCollection.intersects(farAway)) } + // MARK: - Hashable + + // Validates that equal FeatureCollections have equal hashes and + // deduplicate in sets. + @Test + func hashable() async throws { + let features: [Feature] = [ + Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), id: .int(0)), + Feature(Point(Coordinate3D(latitude: 1.0, longitude: 1.0)), id: .int(1)), + ] + let featureCollectionA = FeatureCollection(features) + let featureCollectionB = FeatureCollection(features) + let featureCollectionC = FeatureCollection([features[0]]) + + #expect(featureCollectionA == featureCollectionB) + #expect(featureCollectionA.hashValue == featureCollectionB.hashValue) + #expect(featureCollectionA != featureCollectionC) + + let set: Set = [featureCollectionA, featureCollectionB, featureCollectionC] + #expect(set.count == 2) + } + } diff --git a/Tests/GISToolsTests/GeoJson/FeatureTests.swift b/Tests/GISToolsTests/GeoJson/FeatureTests.swift index 1916249c..6df1d9cd 100644 --- a/Tests/GISToolsTests/GeoJson/FeatureTests.swift +++ b/Tests/GISToolsTests/GeoJson/FeatureTests.swift @@ -143,4 +143,269 @@ struct FeatureTests { #expect(Feature.Identifier(value: UInt64(9223372036854775808))?.uint64Value == 9223372036854775808) } + // MARK: - Typed properties + + private struct RegionProperties: Codable, Equatable { + let isoCode: String + let name: String + let priority: Int + } + + // Validates decoding properties into a domain type. + @Test + func propertiesAs() async throws { + let json = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { "isoCode": "CH", "name": "Zurich", "priority": 3 } + } + """ + let feature = try #require(Feature(jsonString: json)) + + let properties = try feature.properties(as: RegionProperties.self) + + #expect(properties == RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + } + + // Validates that properties written as 3.0 decode into Int fields. + @Test + func propertiesAsNormalizesNumbers() async throws { + let json = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { "isoCode": "CH", "name": "Zurich", "priority": 3.0 } + } + """ + let feature = try #require(Feature(jsonString: json)) + + let properties = try feature.properties(as: RegionProperties.self) + + #expect(properties == RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + } + + // Validates that type mismatches and missing keys throw real errors + // instead of failing silently. + @Test + func propertiesAsErrors() async throws { + let mismatchJson = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { "isoCode": "CH", "name": "Zurich", "priority": "high" } + } + """ + let featureMismatch = try #require(Feature(jsonString: mismatchJson)) + + #expect(throws: DecodingError.self) { + try featureMismatch.properties(as: RegionProperties.self) + } + + let missingJson = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { "isoCode": "CH", "name": "Zurich" } + } + """ + let featureMissing = try #require(Feature(jsonString: missingJson)) + + #expect(throws: DecodingError.self) { + try featureMissing.properties(as: RegionProperties.self) + } + } + + // Validates that the optional decoder parameter is applied + // (e.g. the snake_case key decoding strategy). + @Test + func propertiesAsDecoderStrategy() async throws { + struct SnakeCaseProperties: Codable, Equatable { + let isoCode: String + } + + let json = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { "iso_code": "CH" } + } + """ + let feature = try #require(Feature(jsonString: json)) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let properties = try feature.properties(as: SnakeCaseProperties.self, decoder: decoder) + + #expect(properties == SnakeCaseProperties(isoCode: "CH")) + } + + // Validates that non-JSON-compatible property values throw. + @Test + func propertiesAsNotJsonCompatible() async throws { + var feature = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + feature.properties["data"] = Data([0x01]) + + #expect(throws: DecodingError.self) { + try feature.properties(as: [String: JSONValue].self) + } + } + + // Validates creating a Feature from Encodable properties and reading + // them back with properties(as:). + @Test + func initEncodedProperties() async throws { + let feature = try Feature( + Point(Coordinate3D(latitude: 47.3, longitude: 8.5)), + encodedProperties: RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + + #expect(feature.properties["isoCode"] as? String == "CH") + #expect(feature.properties["name"] as? String == "Zurich") + #expect(feature.properties["priority"] as? Int == 3) + + let properties = try feature.properties(as: RegionProperties.self) + #expect(properties == RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + + let jsonProperties = try #require(feature.asJson["properties"] as? [String: Sendable]) + #expect(jsonProperties["priority"] as? Int == 3) + } + + // Validates that non-object Encodable properties throw. + @Test + func initEncodedPropertiesNotAnObject() async throws { + #expect(throws: EncodingError.self) { + try Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5)), encodedProperties: [1, 2, 3]) + } + #expect(throws: EncodingError.self) { + try Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5)), encodedProperties: 42) + } + } + + // Validates the JSONValue and coercing accessors. + @Test + func typedAccessors() async throws { + let json = """ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5, 47.3] }, + "properties": { + "string": "text", + "int": 3, + "fractionalInt": 3.0, + "fractional": 3.5, + "bool": true, + "null": null + } + } + """ + let feature = try #require(Feature(jsonString: json)) + + #expect(feature.stringValue(for: "string") == "text") + #expect(feature.intValue(for: "int") == 3) + #expect(feature.intValue(for: "fractionalInt") == 3) + #expect(feature.intValue(for: "fractional") == nil) + #expect(feature.doubleValue(for: "int") == 3.0) + #expect(feature.doubleValue(for: "fractional") == 3.5) + #expect(feature.boolValue(for: "bool") == true) + #expect(feature.boolValue(for: "int") == nil) + #expect(feature.stringValue(for: "missing") == nil) + + #expect(feature.jsonValue(for: "int") == .int(3)) + #expect(feature.jsonValue(for: "fractional") == .number(3.5)) + #expect(feature.jsonValue(for: "null") == .null) + #expect(feature.jsonValue(for: "missing") == nil) + + // Exhaustive pattern matching over property values + if case .number(let double) = feature.jsonValue(for: "fractional") { + #expect(double == 3.5) + } + else { + Issue.record("Expected a .number value") + } + } + + // Validates that typed property access works in all projections and + // survives reprojection. + @Test(arguments: [Projection.epsg4326, .epsg3857, .epsg4978, .noSRID]) + func typedPropertiesAllProjections(_ projection: Projection) async throws { + let point = Point(Coordinate3D(latitude: 47.3, longitude: 8.5)).projected(to: projection) + let feature = try Feature( + point, + encodedProperties: RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + + #expect(feature.projection == projection) + let properties = try feature.properties(as: RegionProperties.self) + #expect(properties == RegionProperties(isoCode: "CH", name: "Zurich", priority: 3)) + + let targetProjection: Projection = projection == .epsg4326 ? .epsg3857 : .epsg4326 + let projected = feature.projected(to: targetProjection) + let projectedProperties = try projected.properties(as: RegionProperties.self) + + #expect(projectedProperties == properties) + } + + // MARK: - Hashable + + // Validates that equal features have equal hashes and deduplicate in sets. + @Test + func hashableEqual() async throws { + let featureA = try #require(Feature(jsonString: FeatureTests.featureJson)) + let featureB = try #require(Feature(jsonString: FeatureTests.featureJson)) + + #expect(featureA == featureB) + #expect(featureA.hashValue == featureB.hashValue) + + let set: Set = [featureA, featureB] + #expect(set.count == 1) + } + + // Validates that features with different property VALUES are still equal + // (property values are only compared by key) and hash equally. Deep value + // comparison is tracked separately. + @Test + func hashablePropertyValuesNotCompared() async throws { + var featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureA.properties["count"] = 3 + var featureB = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureB.properties["count"] = 4 + + #expect(featureA == featureB) + #expect(featureA.hashValue == featureB.hashValue) + } + + // Validates that features differing in geometry, id, or property keys + // are not equal and hash differently. + @Test + func hashableNotEqual() async throws { + let featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + let featureB = Feature(Point(Coordinate3D(latitude: 47.4, longitude: 8.5))) + + #expect(featureA != featureB) + #expect(featureA.hashValue != featureB.hashValue) + + var featureC = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureC.properties["count"] = 3 + + #expect(featureA != featureC) + #expect(featureA.hashValue != featureC.hashValue) + + var featureD = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureD.id = .int(5) + + #expect(featureA != featureD) + #expect(featureA.hashValue != featureD.hashValue) + } + + // Validates that features in different projections are not equal and + // hash differently. + @Test + func hashableProjections() async throws { + let feature4326 = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + let feature3857 = feature4326.projected(to: .epsg3857) + + #expect(feature4326 != feature3857) + #expect(feature4326.hashValue != feature3857.hashValue) + } + } diff --git a/Tests/GISToolsTests/GeoJson/GeometryCollectionTests.swift b/Tests/GISToolsTests/GeoJson/GeometryCollectionTests.swift index 017cec20..6e33135d 100644 --- a/Tests/GISToolsTests/GeoJson/GeometryCollectionTests.swift +++ b/Tests/GISToolsTests/GeoJson/GeometryCollectionTests.swift @@ -71,4 +71,29 @@ struct GeometryCollectionTests { #expect(geometryCollectionData == geometryCollection.asJsonData(prettyPrinted: true)) } + // MARK: - Hashable + + // Validates that equal GeometryCollections have equal hashes and + // deduplicate in sets. + @Test + func hashable() async throws { + let geometries: [GeoJsonGeometry] = [ + Point(Coordinate3D(latitude: 47.3, longitude: 8.5)), + try #require(LineString([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ])), + ] + let geometryCollectionA = GeometryCollection(geometries) + let geometryCollectionB = GeometryCollection(geometries) + let geometryCollectionC = GeometryCollection([geometries[0]]) + + #expect(geometryCollectionA == geometryCollectionB) + #expect(geometryCollectionA.hashValue == geometryCollectionB.hashValue) + #expect(geometryCollectionA != geometryCollectionC) + + let set: Set = [geometryCollectionA, geometryCollectionB, geometryCollectionC] + #expect(set.count == 2) + } + } diff --git a/Tests/GISToolsTests/GeoJson/JSONValueTests.swift b/Tests/GISToolsTests/GeoJson/JSONValueTests.swift new file mode 100644 index 00000000..aa73bce3 --- /dev/null +++ b/Tests/GISToolsTests/GeoJson/JSONValueTests.swift @@ -0,0 +1,185 @@ +import Foundation +@testable import GISTools +import Testing + +struct JSONValueTests { + + // MARK: - Conversion + + // Validates conversion of scalar values, including number normalization. + @Test + func conversionScalars() async throws { + #expect(JSONValue(value: "hello") == .string("hello")) + #expect(JSONValue(value: true) == .bool(true)) + #expect(JSONValue(value: false) == .bool(false)) + #expect(JSONValue(value: 3) == .int(3)) + #expect(JSONValue(value: Int64(-5)) == .int(-5)) + #expect(JSONValue(value: UInt(7)) == .int(7)) + #expect(JSONValue(value: 3.0) == .int(3)) + #expect(JSONValue(value: 3.5) == .number(3.5)) + #expect(JSONValue(value: Float(2.0)) == .int(2)) + #expect(JSONValue(value: Float(2.5)) == .number(2.5)) + #expect(JSONValue(value: NSNull()) == .null) + #expect(JSONValue(value: nil) == nil) + } + + // Validates that booleans are not misread as numbers and vice versa + // (both are NSNumber-backed after JSON parsing). + @Test + func conversionBoolDisambiguation() async throws { + #expect(JSONValue(value: NSNumber(value: true)) == .bool(true)) + #expect(JSONValue(value: NSNumber(value: false)) == .bool(false)) + #expect(JSONValue(value: NSNumber(value: 1)) == .int(1)) + #expect(JSONValue(value: NSNumber(value: 0)) == .int(0)) + #expect(JSONValue(value: NSNumber(value: 3.0)) == .int(3)) + #expect(JSONValue(value: NSNumber(value: 3.5)) == .number(3.5)) + } + + // Validates that integers beyond the Int range fall back to .number. + @Test + func conversionLargeNumbers() async throws { + // 9223372036854775807 is Int.max + #expect(JSONValue(value: UInt64(9_223_372_036_854_775_807)) == .int(9_223_372_036_854_775_807)) + #expect(JSONValue(value: NSNumber(value: UInt64(9_223_372_036_854_775_807))) == .int(9_223_372_036_854_775_807)) + + // 9223372036854775808 is Int.max + 1 + #expect(JSONValue(value: UInt64(9_223_372_036_854_775_808)) == .number(9_223_372_036_854_775_808.0)) + #expect(JSONValue(value: NSNumber(value: UInt64(9_223_372_036_854_775_808))) == .number(9_223_372_036_854_775_808.0)) + } + + // Validates conversion of nested structures. + @Test + func conversionNested() async throws { + let list: [Sendable] = [1, "two", 3.0] + let dictionary: [String: Sendable] = [ + "name": "test", + "count": 3, + "fractional": 3.5, + "flag": true, + "nothing": NSNull(), + "list": list, + "nested": ["inner": 1.0], + ] + + let jsonValue = try #require(JSONValue(value: dictionary)) + + #expect(jsonValue == .object([ + "name": .string("test"), + "count": .int(3), + "fractional": .number(3.5), + "flag": .bool(true), + "nothing": .null, + "list": .array([.int(1), .string("two"), .int(3)]), + "nested": .object(["inner": .int(1)]), + ])) + } + + // Validates that non-JSON values fail conversion. + @Test + func conversionNotJson() async throws { + #expect(JSONValue(value: Data([0x01])) == nil) + + struct Custom: Sendable {} + #expect(JSONValue(value: Custom()) == nil) + } + + // MARK: - asSendable + + // Validates the reverse conversion back into property-style values. + @Test + func asSendableRoundTrip() async throws { + let array: [Sendable] = [1, 2, 3] + let original: [String: Sendable] = [ + "string": "text", + "int": 42, + "fractional": 2.5, + "bool": true, + "null": NSNull(), + "array": array, + "object": ["inner": 3.5], + ] + + let jsonValue = try #require(JSONValue(value: original)) + let converted = try #require(jsonValue.asSendable as? [String: Sendable]) + + #expect(converted["string"] as? String == "text") + #expect(converted["int"] as? Int == 42) + #expect(converted["fractional"] as? Double == 2.5) + #expect(converted["bool"] as? Bool == true) + #expect(converted["null"] is NSNull) + #expect((converted["array"] as? [Sendable])?.compactMap({ $0 as? Int }) == [1, 2, 3]) + #expect((converted["object"] as? [String: Sendable])?["inner"] as? Double == 3.5) + + // The conversion is stable across a second round trip. + #expect(JSONValue(value: converted) == jsonValue) + } + + // MARK: - Decoding + + // Validates decoding a property-style dictionary into a domain type, + // including the 3.0 → Int normalization. + @Test + func decodeDictionary() async throws { + struct Payload: Codable, Equatable { + let name: String + let priority: Int + } + + let dictionary: [String: Sendable] = ["name": "test", "priority": 3.0] + let payload = try JSONValue.decode(dictionary, as: Payload.self, decoder: JSONDecoder()) + + #expect(payload == Payload(name: "test", priority: 3)) + } + + // MARK: - Codable + + // Validates encoding and decoding round trips. + @Test + func codableRoundTrip() async throws { + let values: [JSONValue] = [ + .string("text"), + .int(3), + .number(3.5), + .bool(true), + .null, + .array([.int(1), .string("two")]), + .object(["inner": .int(1)]), + ] + + let data = try JSONEncoder().encode(values) + let decoded = try JSONDecoder().decode([JSONValue].self, from: data) + + #expect(decoded == values) + } + + // Validates that fractional JSON numbers decode to .int when integral. + @Test + func codableNormalizesNumbers() async throws { + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("3".utf8)) == .int(3)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("3.0".utf8)) == .int(3)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("3.5".utf8)) == .number(3.5)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("true".utf8)) == .bool(true)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("false".utf8)) == .bool(false)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("null".utf8)) == .null) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("\"text\"".utf8)) == .string("text")) + } + + // Validates that invalid input throws a DecodingError. + @Test + func codableInvalid() async throws { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSONValue.self, from: Data("{\"x\":".utf8)) + } + } + + // MARK: - Hashable + + // Validates that equal values deduplicate in sets. + @Test + func hashable() async throws { + let set: Set = [.int(3), .int(3), .number(3.5), .string("3"), .bool(true)] + + #expect(set.count == 4) + } + +} diff --git a/Tests/GISToolsTests/GeoJson/LineStringTests.swift b/Tests/GISToolsTests/GeoJson/LineStringTests.swift index a0fd44d1..d8923f6b 100644 --- a/Tests/GISToolsTests/GeoJson/LineStringTests.swift +++ b/Tests/GISToolsTests/GeoJson/LineStringTests.swift @@ -309,4 +309,30 @@ struct LineStringTests { #expect(bbox.northEast.longitude == 1.0) } + // MARK: - Hashable + + // Validates that equal LineStrings have equal hashes and deduplicate in sets. + @Test + func hashable() async throws { + let lineStringA = try #require(LineString([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ])) + let lineStringB = try #require(LineString([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ])) + let lineStringC = try #require(LineString([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 2.0, longitude: 2.0), + ])) + + #expect(lineStringA == lineStringB) + #expect(lineStringA.hashValue == lineStringB.hashValue) + #expect(lineStringA != lineStringC) + + let set: Set = [lineStringA, lineStringB, lineStringC] + #expect(set.count == 2) + } + } diff --git a/Tests/GISToolsTests/GeoJson/MultiLineStringTests.swift b/Tests/GISToolsTests/GeoJson/MultiLineStringTests.swift index 6e77af33..c55fce4e 100644 --- a/Tests/GISToolsTests/GeoJson/MultiLineStringTests.swift +++ b/Tests/GISToolsTests/GeoJson/MultiLineStringTests.swift @@ -248,4 +248,31 @@ struct MultiLineStringTests { #expect(multiLineString.lineStrings.count == 2) } + // MARK: - Hashable + + // Validates that equal MultiLineStrings have equal hashes and deduplicate in sets. + @Test + func hashable() async throws { + let coordinates: [[Coordinate3D]] = [ + [ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ], + [ + Coordinate3D(latitude: 2.0, longitude: 2.0), + Coordinate3D(latitude: 3.0, longitude: 3.0), + ], + ] + let multiLineStringA = try #require(MultiLineString(coordinates)) + let multiLineStringB = try #require(MultiLineString(coordinates)) + let multiLineStringC = try #require(MultiLineString([coordinates[0]])) + + #expect(multiLineStringA == multiLineStringB) + #expect(multiLineStringA.hashValue == multiLineStringB.hashValue) + #expect(multiLineStringA != multiLineStringC) + + let set: Set = [multiLineStringA, multiLineStringB, multiLineStringC] + #expect(set.count == 2) + } + } diff --git a/Tests/GISToolsTests/GeoJson/MultiPointTests.swift b/Tests/GISToolsTests/GeoJson/MultiPointTests.swift index 87fa84dd..da543574 100644 --- a/Tests/GISToolsTests/GeoJson/MultiPointTests.swift +++ b/Tests/GISToolsTests/GeoJson/MultiPointTests.swift @@ -203,4 +203,31 @@ struct MultiPointTests { #expect(multiPoint.points.count == 2) } + // MARK: - Hashable + + // Validates that equal MultiPoints have equal hashes and deduplicate in sets. + @Test + func hashable() async throws { + let multiPointA = try #require(MultiPoint([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ])) + let multiPointB = try #require(MultiPoint([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + ])) + let multiPointC = try #require(MultiPoint([ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + Coordinate3D(latitude: 2.0, longitude: 2.0), + ])) + + #expect(multiPointA == multiPointB) + #expect(multiPointA.hashValue == multiPointB.hashValue) + #expect(multiPointA != multiPointC) + + let set: Set = [multiPointA, multiPointB, multiPointC] + #expect(set.count == 2) + } + } diff --git a/Tests/GISToolsTests/GeoJson/PointTests.swift b/Tests/GISToolsTests/GeoJson/PointTests.swift index 7cee3d46..d8cf9587 100644 --- a/Tests/GISToolsTests/GeoJson/PointTests.swift +++ b/Tests/GISToolsTests/GeoJson/PointTests.swift @@ -56,4 +56,60 @@ struct PointTests { #expect(pointData == point.asJsonData(prettyPrinted: true)) } + // MARK: - Typed foreign members + + // Validates the JSONValue and coercing foreign member accessors. + @Test + func typedForeignMembers() async throws { + let json = """ + { + "type": "Point", + "coordinates": [8.5, 47.3], + "string": "text", + "int": 3, + "fractionalInt": 3.0, + "fractional": 3.5, + "bool": true + } + """ + let point = try #require(Point(jsonString: json)) + + #expect(point.stringForeignMember(for: "string") == "text") + #expect(point.intForeignMember(for: "int") == 3) + #expect(point.intForeignMember(for: "fractionalInt") == 3) + #expect(point.intForeignMember(for: "fractional") == nil) + #expect(point.doubleForeignMember(for: "int") == 3.0) + #expect(point.doubleForeignMember(for: "fractional") == 3.5) + #expect(point.boolForeignMember(for: "bool") == true) + #expect(point.boolForeignMember(for: "int") == nil) + #expect(point.stringForeignMember(for: "missing") == nil) + + #expect(point.jsonForeignMember(for: "int") == .int(3)) + #expect(point.jsonForeignMember(for: "fractional") == .number(3.5)) + #expect(point.jsonForeignMember(for: "missing") == nil) + + struct Extra: Codable, Equatable { + let int: Int + } + let extra = try point.foreignMembers(as: Extra.self) + #expect(extra == Extra(int: 3)) + } + + // MARK: - Hashable + + // Validates that equal points have equal hashes and deduplicate in sets. + @Test + func hashable() async throws { + let pointA = Point(Coordinate3D(latitude: 47.3, longitude: 8.5)) + let pointB = Point(Coordinate3D(latitude: 47.3, longitude: 8.5)) + let pointC = Point(Coordinate3D(latitude: 47.4, longitude: 8.5)) + + #expect(pointA == pointB) + #expect(pointA.hashValue == pointB.hashValue) + #expect(pointA != pointC) + + let set: Set = [pointA, pointB, pointC] + #expect(set.count == 2) + } + } From 9f3f00c386d58a1456ee9f8a54e5d0d9493077b6 Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Mon, 7 Sep 2026 14:05:57 +0000 Subject: [PATCH 3/3] Compare Feature property values in equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two features are now equal when their property values match, compared after JSONValue normalization (a property stored as 3.0 equals one stored as 3). Values that are not JSON-compatible (e.g. Data) are regarded as equal when present on both sides, keeping == reflexive. The Feature hash is updated accordingly. Note: this is a behavior change — features with the same property keys but different values were previously regarded as equal. Resolves the keys-only equality TODO in Feature. --- Sources/GISTools/GeoJson/Feature.swift | 48 +++++++++++--- .../GISToolsTests/GeoJson/FeatureTests.swift | 63 +++++++++++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/Sources/GISTools/GeoJson/Feature.swift b/Sources/GISTools/GeoJson/Feature.swift index 4fb0aafa..1d8a8517 100644 --- a/Sources/GISTools/GeoJson/Feature.swift +++ b/Sources/GISTools/GeoJson/Feature.swift @@ -242,7 +242,13 @@ extension Feature { extension Feature: Equatable { - /// Two features are equal when their projection, geometry, identifier, and property keys match. + /// Two features are equal when their projection, geometry, identifier, and + /// properties match. + /// + /// Property values are compared after ``JSONValue`` normalization, so a + /// property stored as `3.0` equals one stored as `3`. Values that are not + /// JSON-compatible (e.g. `Data`) cannot be compared generically and are + /// regarded as equal when they are present on both sides. public static func ==( lhs: Feature, rhs: Feature @@ -250,9 +256,36 @@ extension Feature: Equatable { return lhs.projection == rhs.projection && lhs.geometry.isEqualTo(rhs.geometry) && lhs.id == rhs.id - && lhs.properties.keys == rhs.properties.keys - // TODO -// && lhs.properties == rhs.properties + && Feature.deepEqual(lhs.properties, rhs.properties) + } + + /// Compare two property dictionaries by their ``JSONValue``-normalized values. + /// + /// Values that cannot be converted to ``JSONValue`` (e.g. `Data`) are + /// regarded as equal when they are present on both sides, keeping `==` + /// reflexive. + fileprivate static func deepEqual( + _ lhs: [String: Sendable], + _ rhs: [String: Sendable] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + + for (key, lhsValue) in lhs { + guard let rhsValue = rhs[key] else { return false } + + switch (JSONValue(value: lhsValue), JSONValue(value: rhsValue)) { + case (.some(let lhsJson), .some(let rhsJson)): + if lhsJson != rhsJson { + return false + } + case (.none, .none): + continue + default: + return false + } + } + + return true } } @@ -261,9 +294,9 @@ extension Feature: Equatable { extension Feature: Hashable { - /// The hash is consistent with `==`: projection, geometry, identifier, - /// and property keys. Property values are not compared (yet), so they are - /// not hashed either. + /// The hash is consistent with `==`: projection, geometry, identifier, and + /// the ``JSONValue``-normalized property values. Values that are not + /// JSON-compatible hash to a fixed bucket, matching the `==` fallback. public func hash(into hasher: inout Hasher) { hasher.combine(projection) if let hashableGeometry = geometry as? any Hashable { @@ -272,6 +305,7 @@ extension Feature: Hashable { hasher.combine(id) for key in properties.keys.sorted() { hasher.combine(key) + hasher.combine(JSONValue(value: properties[key])?.hashValue ?? 0) } } diff --git a/Tests/GISToolsTests/GeoJson/FeatureTests.swift b/Tests/GISToolsTests/GeoJson/FeatureTests.swift index 6df1d9cd..19e5ca77 100644 --- a/Tests/GISToolsTests/GeoJson/FeatureTests.swift +++ b/Tests/GISToolsTests/GeoJson/FeatureTests.swift @@ -360,18 +360,73 @@ struct FeatureTests { #expect(set.count == 1) } - // Validates that features with different property VALUES are still equal - // (property values are only compared by key) and hash equally. Deep value - // comparison is tracked separately. + // Validates that features with different property VALUES are not equal + // and hash differently. @Test - func hashablePropertyValuesNotCompared() async throws { + func hashablePropertyValuesCompared() async throws { var featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) featureA.properties["count"] = 3 var featureB = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) featureB.properties["count"] = 4 + #expect(featureA != featureB) + #expect(featureA.hashValue != featureB.hashValue) + + let set: Set = [featureA, featureB] + #expect(set.count == 2) + } + + // Validates that numerically equal property values (3 vs 3.0) compare + // and hash equally. + @Test + func equalityNormalizedNumbers() async throws { + var featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureA.properties["count"] = 3 + var featureB = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureB.properties["count"] = 3.0 + + #expect(featureA == featureB) + #expect(featureA.hashValue == featureB.hashValue) + + let set: Set = [featureA, featureB] + #expect(set.count == 1) + } + + // Validates that nested property values compare deeply (after normalization). + @Test + func equalityNestedValues() async throws { + let nestedA: [String: Sendable] = ["a": 1, "b": [Sendable]([1, 2])] + let nestedB: [String: Sendable] = ["a": 1.0, "b": [Sendable]([1.0, 2.0])] + let nestedC: [String: Sendable] = ["a": 2] + + var featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureA.properties["nested"] = nestedA + var featureB = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureB.properties["nested"] = nestedB + var featureC = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureC.properties["nested"] = nestedC + + #expect(featureA == featureB) + #expect(featureA.hashValue == featureB.hashValue) + #expect(featureA != featureC) + } + + // Validates that non-JSON-compatible property values are regarded as + // equal when present on both sides, keeping == reflexive. + @Test + func equalityNonJsonFallback() async throws { + var featureA = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureA.properties["data"] = Data([0x01]) + var featureB = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureB.properties["data"] = Data([0x02]) + var featureC = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + featureC.properties["data"] = Data([0x03]) + var featureD = Feature(Point(Coordinate3D(latitude: 47.3, longitude: 8.5))) + #expect(featureA == featureB) #expect(featureA.hashValue == featureB.hashValue) + #expect(featureA == featureA) + #expect(featureA != featureD) } // Validates that features differing in geometry, id, or property keys