Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,11 @@ 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):
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)
Expand Down Expand Up @@ -781,7 +784,9 @@ let zoom = point.intForeignMember(for: "zoom")
let extra = try point.foreignMembers(as: Extra.self)
```

`JSONValue` itself is a general-purpose JSON model with typed accessors, subscript traversal, string/number/bool/array/dictionary literals, and a JSON string representation (`description`):
`JSONValue` itself is a general-purpose JSON model with typed accessors,
subscript traversal, string/number/bool/array/dictionary literals, and a JSON
string representation (`description`):
```swift
let value: JSONValue = ["name": "Zürich", "tags": [1, 2.5, true]]

Expand All @@ -800,8 +805,13 @@ if case .object(let object) = value { ... }
print(feature.jsonValue(for: "name") ?? .null)
```

Note that `JSONValue` uses custom `==` and `hash(into:)` implementations: an integer and a number compare equal when the number is exactly that integer, so `.int(3) == .number(3.0)` is `true` (and both hash the same, deduplicating in sets).
Comparisons within the same case remain exact — `.number(0.1 + 0.2) != .number(0.3)`. This mirrors the number normalization when parsing (`3.0`becomes `.int(3)`), but values constructed directly can mix cases, so dictionary/set keys behave on numeric value, not on the enum case.
Note that `JSONValue` uses custom `==` and `hash(into:)` implementations: an
integer and a number compare equal when the number is exactly that integer,
so `.int(3) == .number(3.0)` is `true` (and both hash the same, deduplicating
in sets). Comparisons within the same case remain exact — `.number(0.1 + 0.2)
!= .number(0.3)`. This mirrors the number normalization when parsing (`3.0`
becomes `.int(3)`), but values constructed directly can mix cases, so
dictionary/set keys behave on numeric value, not on the enum case.

## FeatureCollection
[Implementation][38] / [FeatureCollection test cases][39]
Expand Down
48 changes: 41 additions & 7 deletions Sources/GISTools/GeoJson/Feature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,50 @@ 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
) -> Bool {
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
}

}
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand Down
63 changes: 59 additions & 4 deletions Tests/GISToolsTests/GeoJson/FeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,18 +401,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<Feature> = [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<Feature> = [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])
let 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
Expand Down
4 changes: 2 additions & 2 deletions Tests/GISToolsTests/GeoJson/PolygonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,8 @@ struct PolygonTests {
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]))
let polygonA = Polygon(unchecked: [outerRing, holeRing])
let polygonB = Polygon(unchecked: [holeRing, outerRing])

#expect(polygonA != polygonB)
#expect(polygonA.hashValue != polygonB.hashValue)
Expand Down
Loading