Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
675fe1d
deps: replace JSON3/StructTypes with JSON/StructUtils
asinghvi17 Dec 7, 2025
313febd
refactor: update imports from JSON3/StructTypes to JSON/StructUtils
asinghvi17 Dec 7, 2025
8f45b5a
refactor: convert read functions from JSON3.read to JSON.parse
asinghvi17 Dec 7, 2025
1e98ab7
refactor: update LazyFeatureCollection indexing to use JSON.parse
asinghvi17 Dec 7, 2025
50fb754
refactor: convert write functions from JSON3.write to JSON.json
asinghvi17 Dec 7, 2025
784edfe
refactor: adapt LazyFeature for StructUtils
asinghvi17 Dec 7, 2025
1f338d4
refactor: convert StructTypes to StructUtils/JSON.jl patterns
asinghvi17 Dec 7, 2025
d8be1db
test: update imports from JSON3 to JSON
asinghvi17 Dec 7, 2025
15830e2
docs: update README to reference JSON.jl
asinghvi17 Dec 7, 2025
8477f6f
refactor: update precompile statements for JSON/StructUtils
asinghvi17 Dec 7, 2025
dda1c26
refactor: add omit_null to write functions for optional field handling
asinghvi17 Dec 7, 2025
8e6bced
feat: complete JSON3 to JSON.jl migration with custom type choosers
asinghvi17 Dec 7, 2025
846d439
Restore lazy feature parsing on JSON.jl
simsurace Sep 1, 2026
efc9e18
Drop leftover precompile backup from migration
simsurace Sep 1, 2026
1b6db48
Resolve lift ambiguity without breaking geometry parsing
simsurace Sep 1, 2026
918ddfb
Stop leaking non-spec fields when writing GeoJSON
simsurace Sep 1, 2026
69df16a
Detect coordinate dimension instead of truncating it
simsurace Sep 1, 2026
391d782
Drop redundant GeoJSONT type chooser
simsurace Sep 1, 2026
cb439af
Bump to 0.9.0 for the JSON.jl backend switch
simsurace Sep 1, 2026
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
26 changes: 13 additions & 13 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
name = "GeoJSON"
uuid = "61d90e0f-e114-555e-ac52-39dfb47a3ef9"
authors = ["Martijn Visser <mgvisser@gmail.com>", "Rafael Schouten <rafaelschouten@gmail.com>", "Maarten Pronk <git@evetion.nl>"]
version = "0.8.4"
version = "0.9.0"

[deps]
Extents = "411431e0-e8b7-467b-b5e0-f676ba4f2910"
GeoFormatTypes = "68eda718-8dee-11e9-39e7-89f7f65f511f"
GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f"
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"

[weakdeps]
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"

[extensions]
GeoJSONMakieExt = "Makie"
GeoJSONRecipesBaseExt = "RecipesBase"

[compat]
Aqua = "0.8"
DataFrames = "1.6"
Extents = "0.1"
GeoFormatTypes = "0.4"
GeoInterface = "1.5"
JSON3 = "1.13"
JSON = "1"
Makie = "0.23, 0.24"
Plots = "1.39"
StructTypes = "1"
RecipesBase = "1"
StructUtils = "2"
Tables = "1"
Test = "<0.0.1,1"
julia = "1.9"

[weakdeps]
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"

[extensions]
GeoJSONMakieExt = "Makie"
GeoJSONRecipesBaseExt = "RecipesBase"

[extras]
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![CI](https://github.com/JuliaGeo/GeoJSON.jl/workflows/CI/badge.svg)](https://github.com/JuliaGeo/GeoJSON.jl/actions?query=workflow%3ACI)
[![codecov](https://codecov.io/gh/JuliaGeo/GeoJSON.jl/branch/main/graph/badge.svg?token=ccpOaPSi08)](https://codecov.io/gh/JuliaGeo/GeoJSON.jl)

Read [GeoJSON](https://geojson.org/) files using [JSON3.jl](https://github.com/quinnj/JSON3.jl), and provide the [Tables.jl](https://github.com/JuliaData/Tables.jl) interface.
Read [GeoJSON](https://geojson.org/) files using [JSON.jl](https://github.com/JuliaIO/JSON.jl), and provide the [Tables.jl](https://github.com/JuliaData/Tables.jl) interface.

This package is heavily inspired by, and borrows code from, [JSONTables.jl](https://github.com/JuliaData/JSONTables.jl), which
does the same thing for the general JSON format. GeoJSON puts the geometry in a `geometry` column, and adds all
Expand Down
3 changes: 2 additions & 1 deletion src/GeoJSON.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ module GeoJSON
Base.read(path, String)
end GeoJSON

import Extents, GeoFormatTypes, JSON3, Tables, StructTypes
import Extents, GeoFormatTypes, JSON, Tables
import StructUtils
import GeoInterface as GI

include("geojson_types.jl")
Expand Down
155 changes: 115 additions & 40 deletions src/geojson_types.jl
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,24 @@ function Base.iterate(f::Feature, state=collect(propertynames(f)))
end


# This is a non-public type used to lazily construct a Feature from a JSON3.RawValue
# This is a non-public type used to lazily construct a Feature from JSON bytes
# It can be written again as String, which can also be used to parse to a Feature
struct LazyFeature{D,T} <: GeoJSONT{D,T}
bytes::Any
pos::Int
len::Int
json::String
end
@inline StructTypes.construct(::Type{LazyFeature{D,T}}, x::JSON3.RawValue) where {D,T} = LazyFeature{D,T}(x.bytes, x.pos, x.len)
@inline Base.codeunits(x::LazyFeature) = unsafe_string(pointer(x.bytes, x.pos), x.len)
@inline JSON3.rawbytes(x::LazyFeature) = codeunits(x)

@inline Base.codeunits(x::LazyFeature) = x.json

# Capture each feature's raw JSON without parsing it, by delegating to JSON's own
# JSONText machinery (which correctly finds the value's byte span). The stored string
# is re-parsed to a Feature on demand in `getindex`.
function StructUtils.make(st::StructUtils.StructStyle, ::Type{LazyFeature{D,T}}, source) where {D,T}
raw, pos = StructUtils.make(st, JSON.JSONText, source)
return LazyFeature{D,T}(raw.value), pos
end

# Write a lazily-held feature back out verbatim.
@inline StructUtils.lower(::JSON.JSONStyle, x::LazyFeature) = JSON.JSONText(x.json)


"""
Expand All @@ -228,9 +236,10 @@ struct FeatureCollection{D,T} <: AbstractFeatureCollection{D,T}
bbox::Union{Nothing,Vector{T}}
features::Vector{Feature{D,T}}
crs::Union{Nothing,CRS}
names::Vector{Symbol}
types::Dict{Symbol,Type}
function FeatureCollection{D,T}(bbox, features, crs, n=nothing, t=nothing) where {D,T} # n, t = nothing required for StructTypes
names::Any # Computed field - can be Vector{Symbol} or parsed as Any from JSON
types::Any # Computed field - can be Dict{Symbol,Type} or parsed as Any from JSON
function FeatureCollection{D,T}(bbox, features, crs, n=nothing, t=nothing) where {D,T} # n, t = nothing required for StructUtils/StructTypes
# Always recompute names and types from features, ignoring n and t parameters
names, types = property_schema(features)
return new{D,T}(bbox, features, crs, names, types)
end
Expand All @@ -242,7 +251,14 @@ end


features(fc::FeatureCollection) = getfield(fc, :features)
Base.propertynames(fc::FeatureCollection) = getfield(fc, :names)
function Base.propertynames(fc::FeatureCollection)
names_field = getfield(fc, :names)
if names_field isa Vector{Symbol}
return names_field
else
return Symbol[]
end
end

function Base.getproperty(fc::FeatureCollection, name::Symbol)
if hasfield(typeof(fc), name)
Expand Down Expand Up @@ -288,14 +304,16 @@ end
features(fc::LazyFeatureCollection) = collect(fc.features)

Base.show(io::IO, x::LazyFeatureCollection) = print(io, "LazyFeatureCollection with $(length(x.features)) features")
Base.getindex(x::LazyFeatureCollection{D,T}, i::Int) where {D,T} = JSON3.read(codeunits(x.features[i]), Feature{D,T})::Feature{D,T}
Base.getindex(x::LazyFeatureCollection{D,T}, i::Int) where {D,T} = JSON.parse(codeunits(x.features[i]), Feature{D,T})::Feature{D,T}

struct GeoJSONWrapper{D,T,X<:GeoJSONT{D,T}}
obj::X
# Write a lazy collection back out as a normal FeatureCollection, emitting each
# held feature verbatim (see the LazyFeature lower above).
@inline function StructUtils.lower(::JSON.JSONStyle, x::LazyFeatureCollection)
return (; type="FeatureCollection", bbox=x.bbox, features=x.features, crs=x.crs)
end
GeoJSONWrapper{D,T}(obj::X) where {D,T,X<:GeoJSONT{D,T}} = GeoJSONWrapper{D,T,X}(obj)

# symbol (from json string type) to struct mapping
# NOTE: These must be defined BEFORE GeoJSONWrapper to be available in the choosetype lambda
@inline function geom_mapping(D, T)
(;
Point=Point{D,T},
Expand All @@ -313,10 +331,34 @@ end
FeatureCollection=FeatureCollection{D,T}
)
end
@inline StructTypes.StructType(::Type{<:GeoJSONWrapper}) = StructTypes.CustomStruct()
@inline StructTypes.lower(x::GeoJSONWrapper) = x.obj
@inline StructTypes.lowertype(::Type{<:GeoJSONWrapper{D,T}}) where {D,T} = GeoJSONT{D,T}

struct GeoJSONWrapper{D,T,X<:GeoJSONT{D,T}}
obj::X
end
GeoJSONWrapper{D,T}(obj::X) where {D,T,X<:GeoJSONT{D,T}} = GeoJSONWrapper{D,T,X}(obj)

# Type chooser for GeoJSONWrapper. `@choosetype` can't be used here: its chooser sees
# only the JSON source, but the concrete type we need depends on both the JSON "type"
# field AND the caller-supplied dimension D and number type T, which aren't in the JSON.
# A `make` method with `where {D,TT}` is the only way to capture those type parameters.
function StructUtils.make(st::StructUtils.StructStyle, T::Type{<:GeoJSONWrapper{D,TT}}, source) where {D,TT}
# Check if T is a UnionAll (i.e., GeoJSONWrapper{D,TT,X} where X)
if T isa UnionAll || (T isa DataType && !isconcretetype(T))
type_str = source.type[]
mapping = merge(geom_mapping(D, TT), obj_mapping(D, TT))
concrete_obj_type = get(mapping, Symbol(type_str), nothing)
concrete_obj_type === nothing && error("Unknown GeoJSON type: $type_str")
concrete_wrapper_type = GeoJSONWrapper{D,TT,concrete_obj_type}
# Call make again with the concrete type
return StructUtils.make(st, concrete_wrapper_type, source)
else
# T is already concrete (GeoJSONWrapper{D,TT,SomeConcreteType})
# Parse the source as the wrapped type and then wrap it
X = T.parameters[3] # Extract the wrapped type
obj, pos = StructUtils.make(st, X, source)
return T(obj), pos
end
end
typestring(::Type{<:Point}) = "Point"
typestring(::Type{<:MultiPoint}) = "MultiPoint"
typestring(::Type{<:LineString}) = "LineString"
Expand All @@ -329,25 +371,58 @@ typestring(::Type{<:FeatureCollection}) = "FeatureCollection"
typestring(::Type{Nothing}) = "null"
typestring(::Type{Missing}) = "null"

@inline StructTypes.StructType(::Type{<:GeoJSONT}) = StructTypes.AbstractType()
@inline StructTypes.StructType(::Type{<:AbstractGeometry}) = StructTypes.AbstractType()
@inline StructTypes.StructType(::Type{<:Point}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:LineString}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:Polygon}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:MultiPoint}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:MultiLineString}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:MultiPolygon}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:GeometryCollection}) = StructTypes.Struct()
@inline StructTypes.subtypekey(::Type{<:AbstractGeometry}) = :type
@inline StructTypes.subtypes(::Type{<:AbstractGeometry{D,T}}) where {D,T} = geom_mapping(D, T)
@inline StructTypes.subtypekey(::Type{<:GeoJSONT}) = :type
@inline StructTypes.subtypes(::Type{<:GeoJSONT{D,T}}) where {D,T} = merge(geom_mapping(D, T), obj_mapping(D, T))

@inline StructTypes.StructType(::Type{<:Feature}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{<:LazyFeature}) = JSON3.RawType()
@inline StructTypes.StructType(::Type{<:FeatureCollection}) = StructTypes.Struct()
@inline StructTypes.excludes(::Type{<:FeatureCollection}) = (:names, :types,)
@inline StructTypes.StructType(::Type{<:LazyFeatureCollection}) = StructTypes.Struct()
@inline StructTypes.StructType(::Type{CRS}) = StructTypes.Struct()

@inline StructTypes.omitempties(::Type{<:GeoJSONT}) = (:id, :crs, :bbox,)
# Geometries need a custom chooser because the concrete type comes from the "type"
# field while the dimension D and number type T come from the caller (they are not in
# the JSON). Both parsing entry points funnel through `_build_geometry`:
# - `make` is hit for a top-level geometry and for elements of a GeometryCollection's
# `geometries` vector; it gets a lazy `source` and must return the end position.
# - `lift` is hit for a Feature's `Union{Nothing,AbstractGeometry}` field; it gets an
# already-materialized `JSON.Object`.
# Materializing the lazy source to a JSON.Object lets a single builder serve both.
function StructUtils.make(st::StructUtils.StructStyle, ::Type{<:AbstractGeometry{D,TT}}, source) where {D,TT}
obj, pos = StructUtils.make(st, JSON.Object, source)
return _build_geometry(AbstractGeometry{D,TT}, obj), pos
end

StructUtils.lift(::StructUtils.DefaultStyle, ::Type{<:AbstractGeometry{D,TT}}, x::JSON.Object) where {D,TT} =
_build_geometry(AbstractGeometry{D,TT}, x)

function _build_geometry(::Type{<:AbstractGeometry{D,TT}}, x::JSON.Object) where {D,TT}
type_str = get(x, "type", nothing)
type_str === nothing && throw(ArgumentError("Missing 'type' field in geometry object: keys=$(keys(x))"))
concrete_type = get(geom_mapping(D, TT), Symbol(type_str), nothing)
concrete_type === nothing && error("Unknown geometry type: $type_str")

bbox_val = get(x, "bbox", nothing)
bbox = bbox_val === nothing ? nothing : Vector{TT}(bbox_val)

if concrete_type <: GeometryCollection
geoms_val = get(x, "geometries", nothing)
geometries = geoms_val === nothing ? AbstractGeometry{D,TT}[] :
AbstractGeometry{D,TT}[_build_geometry(AbstractGeometry{D,TT}, g) for g in geoms_val]
return GeometryCollection{D,TT}(bbox, geometries)
else
coords_val = get(x, "coordinates", nothing)
coords = coords_val === nothing ? nothing : _convert_coordinates(concrete_type, coords_val, TT)
return concrete_type(bbox, coords)
end
end

# Build an NTuple coordinate, rejecting the wrong dimension. A GeoJSON reader parses at a
# fixed D, so a mismatch here (e.g. a 3D coordinate read as 2D) must error rather than
# silently truncate — `read` relies on the ArgumentError to retry one dimension higher.
@inline function _coord(::Type{T}, ::Val{D}, c) where {T,D}
length(c) == D || throw(ArgumentError("expected a $(D)-dimensional coordinate, got $(length(c))"))
return NTuple{D,T}(c)
end

_convert_coordinates(::Type{<:Point{D,T}}, coords, ::Type{T}) where {D,T} = _coord(T, Val(D), coords)
_convert_coordinates(::Type{<:LineString{D,T}}, coords, ::Type{T}) where {D,T} = [_coord(T, Val(D), c) for c in coords]
_convert_coordinates(::Type{<:MultiPoint{D,T}}, coords, ::Type{T}) where {D,T} = [_coord(T, Val(D), c) for c in coords]
_convert_coordinates(::Type{<:Polygon{D,T}}, coords, ::Type{T}) where {D,T} = [[_coord(T, Val(D), c) for c in ring] for ring in coords]
_convert_coordinates(::Type{<:MultiLineString{D,T}}, coords, ::Type{T}) where {D,T} = [[_coord(T, Val(D), c) for c in line] for line in coords]
_convert_coordinates(::Type{<:MultiPolygon{D,T}}, coords, ::Type{T}) where {D,T} = [[[_coord(T, Val(D), c) for c in ring] for ring in poly] for poly in coords]

# No chooser is needed for the abstract GeoJSONT level: at the top level GeoJSONWrapper's
# chooser picks the concrete Feature/FeatureCollection/geometry, and concrete Feature and
# FeatureCollection are ordinary structs that StructUtils parses by default.
30 changes: 22 additions & 8 deletions src/io.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,29 @@ Read GeoJSON to a GeoInterface.jl compatible object.
- `numbertype::DataType=Float32`: Use Float64 when the precision is required.
"""
function read(io; lazyfc=false, ndim=2, numbertype=Float32)
# Check if io is a file path (string that represents an existing file)
# Use try-catch because isfile() can error on invalid paths
is_filepath = false
if io isa AbstractString
try
is_filepath = isfile(io)
catch
# If isfile() errors (e.g., path too long), treat as JSON string
is_filepath = false
end
end

parse_fn = is_filepath ? JSON.parsefile : JSON.parse

if lazyfc
obj = JSON3.read(io, LazyFeatureCollection{ndim,numbertype})
obj = parse_fn(io, LazyFeatureCollection{ndim,numbertype})
else
try
obj = JSON3.read(io, GeoJSONWrapper{ndim,numbertype}).obj
obj = parse_fn(io, GeoJSONWrapper{ndim,numbertype}).obj
catch e
if e isa ArgumentError
@warn "Failed to parse GeoJSON as 2D, trying 3D. Set `ndim` to 3 to avoid this warning."
obj = JSON3.read(io, GeoJSONWrapper{ndim + 1,numbertype}).obj
obj = parse_fn(io, GeoJSONWrapper{ndim + 1,numbertype}).obj
else
rethrow(e)
end
Expand All @@ -31,7 +45,7 @@ read(source::GeoFormatTypes.GeoJSON) = read(GeoFormatTypes.val(source))

function read(source::GeoFormatTypes.GeoJSON{<:AbstractDict})
dict = GeoFormatTypes.val(source)
str = JSON3.write(dict)
str = JSON.json(dict)
return read(str)
end

Expand All @@ -47,12 +61,12 @@ If `geometry` is a `Tables.Table`, you may pass a `Symbol` to the `geometrycolum
to indicate which column of the table holds the geometries. Note that this will not keep the name,
the geometry must be written to the `:geometry` column of the GeoJSON according to the spec.
"""
write(io, obj::GeoJSONT) = JSON3.write(io, obj)
write(obj::GeoJSONT) = JSON3.write(obj)
write(io, obj::GeoJSONT) = (JSON.json(io, obj); nothing)
write(obj::GeoJSONT) = JSON.json(obj)

# GeoInterface supported objects
write(io, obj; geometrycolumn = first(GI.geometrycolumns(obj))) = JSON3.write(io, _lower(obj; geometrycolumn))
write(obj; geometrycolumn = first(GI.geometrycolumns(obj))) = JSON3.write(_lower(obj; geometrycolumn))
write(io, obj; geometrycolumn = first(GI.geometrycolumns(obj))) = (JSON.json(io, _lower(obj; geometrycolumn)); nothing)
write(obj; geometrycolumn = first(GI.geometrycolumns(obj))) = JSON.json(_lower(obj; geometrycolumn))

function _lower(obj; geometrycolumn = first(GI.geometrycolumns(obj)))
if GI.isfeaturecollection(obj)
Expand Down
Loading
Loading