Skip to content

Commit 67ed88c

Browse files
authored
Merge pull request #50 from JuliaGeo/better_columnnames
adapt name search from JSONTables.jl
2 parents a5f4a77 + 84c803a commit 67ed88c

File tree

6 files changed

+174
-53
lines changed

6 files changed

+174
-53
lines changed

Project.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ julia = "1.6"
2323

2424
[extras]
2525
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
26+
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
2627
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
2728
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
2829

2930
[targets]
30-
test = ["Aqua", "Plots", "Test"]
31+
test = ["Aqua", "DataFrames", "Plots", "Test"]

src/GeoJSON.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module GeoJSON
66
Base.read(path, String)
77
end GeoJSON
88

9-
import JSON3, Tables, GeoFormatTypes, Extents, GeoInterfaceRecipes
9+
import Extents, GeoFormatTypes, GeoInterfaceRecipes, JSON3, Tables
1010
import GeoInterface as GI
1111

1212
include("geometries.jl")

src/features.jl

Lines changed: 124 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,40 @@ A feature wrapping the JSON object.
55
"""
66
struct Feature{T}
77
object::T
8+
parent_properties::Vector{Symbol}
89
end
910

10-
Feature{T}(f::Feature{T}) where {T} = f
11-
Feature(; geometry::Geometry, kwargs...) =
11+
Feature(f::Feature) = f
12+
Feature(f::Feature, names::Vector{Symbol}) = Feature(object(f), names)
13+
Feature{T}(f::Feature, names::Vector{Symbol}) where T = Feature(object(f), names)
14+
Feature{T}(f::Feature) where {T} = f
15+
Feature(object) = Feature(object, Symbol[])
16+
Feature(; geometry::Union{Geometry,Missing,Nothing}, kwargs...) =
1217
Feature(merge((type = "Feature", geometry), kwargs))
13-
Feature(geometry::Geometry; kwargs...) =
18+
Feature(geometry::Union{Geometry,Missing,Nothing}; kwargs...) =
1419
Feature(merge((type = "Feature", geometry), kwargs))
1520

1621
"""
1722
properties(f::Union{Feature,FeatureCollection})
1823
1924
Access the properties JSON object of a Feature
2025
"""
26+
properties(obj::JSON3.Object) = obj.properties
2127
properties(f::Feature) = object(f).properties
2228

2329
"""
2430
geometry(f::Feature)
2531
2632
Access the JSON object that represents the Feature's geometry
2733
"""
28-
geometry(f::Feature) = geometry(object(f).geometry)
34+
function geometry(f::Feature{<:JSON3.Object})
35+
geom = geometry(object(f).geometry)
36+
return ismissing(geom) ? nothing : geom
37+
end
38+
function geometry(f::Feature{<:NamedTuple})
39+
geom = object(f).geometry
40+
return ismissing(geom) ? nothing : geom
41+
end
2942

3043
coordinates(f::Feature) = coordinates(geometry(object(f).geometry))
3144

@@ -43,13 +56,23 @@ function Base.getproperty(f::Feature, nm::Symbol)
4356
geometry(f)
4457
else
4558
props = properties(f)
46-
getproperty(props, nm)
59+
if hasproperty(props, nm)
60+
getproperty(props, nm)
61+
else
62+
if nm in getfield(f, :parent_properties)
63+
missing
64+
else
65+
error("property `$nm` is not part of Feature or parent FeatureCollection")
66+
end
67+
end
4768
end
4869
return ifelse(x === nothing, missing, x)
4970
end
5071

5172
Base.:(==)(f1::Feature, f2::Feature) = object(f1) == object(f2)
5273

74+
75+
5376
"""
5477
FeatureCollection <: AbstractVector{Feature}
5578
@@ -61,25 +84,39 @@ and similarly the GeoInterface.jl interface.
6184
struct FeatureCollection{T,O,A} <: AbstractVector{T}
6285
object::O
6386
features::A
87+
names::Vector{Symbol}
88+
types::Dict{Symbol,Type}
6489
end
65-
66-
function FeatureCollection(object::O) where {O}
90+
function FeatureCollection(object::O) where O
6791
features = object.features
68-
T = isempty(features) ? Feature{Any} : typeof(Feature(first(features)))
69-
return FeatureCollection{T,O,typeof(features)}(object, features)
92+
if isempty(features)
93+
names = Symbol[:geometry]
94+
types = Dict{Symbol,Type}(:geometry => Union{Missing,Geometry})
95+
T = Feature{Any}
96+
else
97+
names, types = property_schema(features)
98+
insert!(names, 1, :geometry)
99+
types[:geometry] = Union{Missing,Geometry}
100+
f1 = first(features)
101+
T = if f1 isa JSON3.Object
102+
typeof(Feature(f1, names))
103+
elseif f1 isa NamedTuple && isconcretetype(eltype(features))
104+
typeof(Feature(f1, names))
105+
else
106+
T = Feature{Any}
107+
end
108+
end
109+
return FeatureCollection{T,O,typeof(features)}(object, features, names, types)
70110
end
71-
72111
function FeatureCollection(; features::AbstractVector{T}, kwargs...) where {T}
73-
FT = ifelse(T <: Feature, T, Feature{T})
74112
object = merge((type = "FeatureCollection", features), kwargs)
75-
return FeatureCollection{FT,typeof(object),typeof(features)}(object, features)
113+
return FeatureCollection(object)
76114
end
115+
FeatureCollection(features::AbstractVector; kwargs...) =
116+
FeatureCollection(; features, kwargs...)
77117

78-
function FeatureCollection(features::AbstractVector{T}; kwargs...) where {T}
79-
FT = ifelse(T <: Feature, T, Feature{T})
80-
object = merge((type = "FeatureCollection", features), kwargs)
81-
return FeatureCollection{FT,typeof(object),typeof(features)}(object, features)
82-
end
118+
names(fc::FeatureCollection) = getfield(fc, :names)
119+
types(fc::FeatureCollection) = getfield(fc, :types)
83120

84121
"""
85122
features(fc::FeatureCollection)
@@ -90,16 +127,7 @@ features(fc::FeatureCollection) = getfield(fc, :features)
90127

91128
# Base methods
92129

93-
function Base.propertynames(fc::FeatureCollection)
94-
# get the propertynames from the first feature, if it exists
95-
return if isempty(fc)
96-
(:geometry,)
97-
else
98-
f = first(fc)
99-
propertynames(f)
100-
end
101-
end
102-
130+
Base.propertynames(fc::FeatureCollection) = getfield(fc, :names)
103131
Base.getproperty(fc::FeatureCollection, nm::Symbol) = getproperty.(fc, nm)
104132

105133
Base.IteratorSize(::Type{<:FeatureCollection}) = Base.HasLength()
@@ -109,31 +137,38 @@ Base.IteratorEltype(::Type{<:FeatureCollection}) = Base.HasEltype()
109137
# read only AbstractVector
110138
Base.size(fc::FeatureCollection) = size(features(fc))
111139
Base.eltype(::FeatureCollection{T}) where {T<:Feature} = T
112-
Base.getindex(fc::FeatureCollection{T}, i) where {T<:Feature} = T(features(fc)[i])
113140
Base.IndexStyle(::Type{<:FeatureCollection}) = IndexLinear()
141+
function Base.getindex(fc::FeatureCollection{T}, i) where {T<:Feature}
142+
f = features(fc)[i]
143+
if f isa Feature
144+
return f
145+
else
146+
return T(f, names(fc))
147+
end
148+
end
114149

115150
function Base.iterate(fc::FeatureCollection{T}) where {T<:Feature}
116151
x = iterate(features(fc))
117152
x === nothing && return nothing
118153
val, state = x
119-
return T(val), state
154+
return T(val, names(fc)), state
120155
end
121156

122157
function Base.iterate(fc::FeatureCollection{T}, state) where {T<:Feature}
123158
x = iterate(features(fc), state)
124159
x === nothing && return nothing
125160
val, state = x
126-
return T(val), state
161+
return T(val, names(fc)), state
127162
end
128163

129-
Base.show(io::IO, fc::FeatureCollection) =
164+
Base.show(io::IO, ::MIME"text/plain", fc::FeatureCollection) =
130165
println(io, "FeatureCollection with $(length(fc)) Features")
131166

132-
function Base.show(io::IO, f::Feature)
167+
function Base.show(io::IO, ::MIME"text/plain", f::Feature)
133168
geom = geometry(f)
134169
propnames = propertynames(f)
135170
n = length(propnames)
136-
if geom === nothing
171+
if ismissing(geom) || isnothing(geom)
137172
print(io, "Feature with null geometry")
138173
else
139174
print(io, "Feature with a ", type(geom))
@@ -145,6 +180,8 @@ end
145180
Tables.istable(::Type{<:FeatureCollection}) = true
146181
Tables.rowaccess(::Type{<:FeatureCollection}) = true
147182
Tables.rows(fc::FeatureCollection) = fc
183+
Tables.schema(fc::FeatureCollection) =
184+
Tables.Schema(getfield(fc, :names), [getfield(fc, :types)[nm] for nm in getfield(fc, :names)])
148185

149186
# methods that apply to all GeoJSON Objects
150187
const GeoJSONObject = Union{Geometry,Feature,FeatureCollection}
@@ -164,3 +201,57 @@ type(x) = String(x.type)
164201
bbox(x::GeoJSONObject) = get(object(x), :bbox, nothing)
165202

166203
Base.show(io::IO, ::MIME"text/plain", x::GeoJSONObject) = show(io, x)
204+
205+
# Adapted from JSONTables.jl jsontable method
206+
# We cannot simply use their method as we need the key/value pairs
207+
# of the properties field, rather than the main object
208+
function property_schema(features)
209+
# Short cut for concrete eltypes of NamedTuple
210+
if features isa AbstractArray{<:NamedTuple} && isconcretetype(eltype(features))
211+
f1 = first(features)
212+
props = properties(f1)
213+
names = [keys(props)...]
214+
types = Dict{Symbol,Type}(map((k, v) -> k => typeof(v), keys(props), props)...)
215+
return names, types
216+
end
217+
# Otherwise find the shared names
218+
names = Symbol[]
219+
seen = Set{Symbol}()
220+
types = Dict{Symbol, Type}()
221+
for feature in features
222+
props = properties(feature)
223+
isnothing(props) && continue
224+
if isempty(names)
225+
for k in propertynames(props)
226+
k === :geometry && continue
227+
push!(names, k)
228+
types[k] = missT(typeof(props[k]))
229+
end
230+
seen = Set(names)
231+
else
232+
for nm in names
233+
if hasproperty(props, nm)
234+
T = types[nm]
235+
v = props[nm]
236+
if !(missT(typeof(v)) <: T)
237+
types[nm] = Union{T, missT(typeof(v))}
238+
end
239+
else
240+
types[nm] = Union{Missing, types[nm]}
241+
end
242+
end
243+
for (k, v) in pairs(props)
244+
k === :geometry && continue
245+
if !(k in seen)
246+
push!(seen, k)
247+
push!(names, k)
248+
types[k] = Union{Missing, missT(typeof(v))}
249+
end
250+
end
251+
end
252+
end
253+
return names, types
254+
end
255+
256+
missT(::Type{Nothing}) = Missing
257+
missT(::Type{T}) where {T} = T

src/json.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ end
9191
Convert a GeoJSON geometry from JSON style object to a struct specific
9292
to that geometry type.
9393
"""
94-
function geometry(g)
94+
function geometry(g::JSON3.Object)
9595
t = type(g)
9696
if t == "Point"
9797
Point(g)

test/geojson_samples.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ table_not_present = """{
348348
"type":"FeatureCollection",
349349
"features":[
350350
{"type":"Feature","properties":{"a":1,"b":null,"c":"only-here"},"geometry":null},
351-
{"type":"Feature","properties":{"a":null,"b":null},"geometry":null},
351+
{"type":"Feature","properties":{"a":null,"b":null,"d":"appears-later"},"geometry":null},
352352
{"type":"Feature","properties":{"a":3,"b":null},"geometry":null}
353353
]}"""
354354

test/runtests.jl

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ using JSON3
77
using Tables
88
using Test
99
using Plots
10+
using DataFrames
1011

1112
# samples and collections thereof defined under module T
1213
include("geojson_samples.jl")
@@ -113,18 +114,22 @@ include("geojson_samples.jl")
113114
# FeatureCollection
114115
features = [f]
115116
fc = GeoJSON.FeatureCollection(features)
116-
@test GeoJSON.features(fc) === features
117-
@test propertynames(fc) === Tables.columnnames(fc) === (:geometry, :a, :b)
117+
@test GeoJSON.features(fc) == features
118+
@test propertynames(fc) == Tables.columnnames(fc) == [:geometry, :a, :b]
118119
@test GeoJSON.geometry.(fc) == [p]
119120
@test iterate(p) === (1.1, 2)
120121
@test iterate(p, 2) === (2.2, 3)
121122
@test iterate(p, 3) === nothing
122123

123124
# other constructors
124-
GeoJSON.Feature(geometry = p, properties = (a = 1, geometry = "g", b = 2))
125-
GeoJSON.Feature((geometry = p, properties = (a = 1, geometry = "g", b = 2)))
126-
GeoJSON.FeatureCollection(; features)
127-
GeoJSON.FeatureCollection((type = "FeatureCollection", features = [f]))
125+
@test DataFrame([GeoJSON.Feature(geometry = p, properties = (a = 1, geometry = "g", b = 2))]) ==
126+
DataFrame([GeoJSON.Feature((geometry = p, properties = (a = 1, geometry = "g", b = 2)))]) ==
127+
DataFrame(GeoJSON.FeatureCollection((type="FeatureCollection", features=[f]))) ==
128+
DataFrame(GeoJSON.FeatureCollection(; features))
129+
130+
# Mixed name vector
131+
f2 = GeoJSON.Feature(p; properties = (a = 1, geometry = "g", b = 2, c = 3))
132+
GeoJSON.FeatureCollection((type = "FeatureCollection", features = [f, f2]))
128133
end
129134

130135
@testset "extent" begin
@@ -178,7 +183,7 @@ include("geojson_samples.jl")
178183
<:JSON3.Object,
179184
<:JSON3.Array,
180185
}
181-
@test propertynames(t) == (:geometry, :park, :cartodb_id, :addr1, :addr2)
186+
@test propertynames(t) == [:geometry, :cartodb_id, :addr1, :addr2, :park]
182187
@test Tables.rowtable(t) isa Vector{<:NamedTuple}
183188
@test Tables.columntable(t) isa NamedTuple
184189
@inferred first(t)
@@ -204,6 +209,16 @@ include("geojson_samples.jl")
204209
@test geom[1][1][3] == [-117.912919, 33.96445]
205210
@test geom[1][1][4] == [-117.913883, 33.96657]
206211

212+
@testset "With NamedTuple feature" begin
213+
nt_feature = GeoJSON.Feature(;
214+
geometry=t[1].geometry,
215+
properties=(cartodb_id=t[1].cartodb_id, addr1=t[1].addr1, addr2=t[1].addr2, park=t[1].park),
216+
)
217+
fc = GeoJSON.FeatureCollection([nt_feature])
218+
@test fc isa GeoJSON.FeatureCollection
219+
@test occursin("(:geometry, :cartodb_id, :addr1, :addr2, :park)", sprint(show, MIME"text/plain"(), fc[1]))
220+
end
221+
207222
@testset "write to disk" begin
208223
fc = t
209224
GeoJSON.write("test.json", fc)
@@ -232,6 +247,9 @@ include("geojson_samples.jl")
232247
@testset "Tables with missings" begin
233248
t = GeoJSON.read(T.tablenull)
234249
@test t[1] isa GeoJSON.Feature
250+
@test occursin("(:geometry, :a, :b)", sprint(show, MIME"text/plain"(), t[1]))
251+
@test ismissing(t[1].geometry)
252+
GeoJSON.geometry(t[1])
235253
@test t.geometry isa Vector{Union{T,Missing}} where {T<:GeoJSON.Point}
236254
@test ismissing(t.geometry[3])
237255
@test t.a isa Vector{Union{Float64,Missing}}
@@ -240,15 +258,26 @@ include("geojson_samples.jl")
240258
@test Tables.columntable(t) isa NamedTuple
241259

242260
t = GeoJSON.read(T.table_not_present)
243-
@test propertynames(t) === propertynames(t[1]) === (:geometry, :a, :b, :c)
244-
@test propertynames(t[2]) === (:geometry, :a, :b)
245-
# "c" is only present in the properyies of the first row
246-
# We don't support automatically setting these to missing in the tables interface.
247-
# They have to be explicitly set to null.
248-
# We could support it by having getproperty(f::Feature, :not_present) return missing
249-
# if needed, but then you always get missing instead of KeyError.
250-
@test_throws KeyError t.c
251-
@test_throws KeyError Tables.columntable(t)
261+
@test occursin("(:geometry, :a, :b, :c)", sprint(show, MIME"text/plain"(), t[1]))
262+
@test propertynames(t) == [:geometry, :a, :b, :c, :d]
263+
@test propertynames(t[1]) == (:geometry, :a, :b, :c)
264+
@test propertynames(t[2]) == (:geometry, :a, :b, :d)
265+
# "c" and "d" are only present in the properties of a single row
266+
@test all(t.c .=== ["only-here", missing, missing])
267+
@test all(t.d .=== [missing, "appears-later", missing])
268+
@testset "With NamedTuple feature" begin
269+
nt_props = [
270+
(a=t[1].a, b=t[1].b, c=t[1].c),
271+
(a=t[2].a, b=t[2].b, d=t[2].d),
272+
(a=t[3].a, b=t[3].b),
273+
]
274+
features = map(t.geometry, nt_props) do geometry, properties
275+
GeoJSON.Feature(; geometry, properties)
276+
end
277+
fc = GeoJSON.FeatureCollection(features)
278+
@test fc isa GeoJSON.FeatureCollection
279+
@test occursin("(:geometry, :a, :b, :c)", sprint(show, MIME"text/plain"(), fc[1]))
280+
end
252281
end
253282

254283
@testset "FeatureCollection of one GeometryCollection" begin

0 commit comments

Comments
 (0)