Skip to content

Commit 9610ef0

Browse files
authored
Merge pull request #176 from paulmach/pm/generic-geojson
geojson: add generic property support to features and collections
2 parents c2aa315 + e76ee9d commit 9610ef0

File tree

7 files changed

+291
-54
lines changed

7 files changed

+291
-54
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,19 @@ Features are defined as:
8989

9090
```go
9191
type Feature struct {
92-
ID interface{} `json:"id,omitempty"`
92+
ID any `json:"id,omitempty"`
9393
Type string `json:"type"`
9494
Geometry orb.Geometry `json:"geometry"`
9595
Properties Properties `json:"properties"`
9696
}
97+
98+
// or a generic version with user defined properties type:
99+
type FeatureOf[P] struct {
100+
ID any `json:"id,omitempty"`
101+
Type string `json:"type"`
102+
Geometry orb.Geometry `json:"geometry"`
103+
Properties P `json:"properties"`
104+
}
97105
```
98106

99107
Defining the geometry as an `orb.Geometry` interface along with sub-package functions
@@ -107,6 +115,20 @@ for _, f := range fc {
107115
}
108116
```
109117

118+
An example using generic properties:
119+
120+
```go
121+
type MyProperties struct {
122+
Name string `json:"name"`
123+
Age int `json:"age"`
124+
}
125+
126+
fc := geojson.FeatureCollectionOf[MyProperties]{}
127+
err := json.Unmarshal(rawJSON, &fc)
128+
129+
fc.Features[0].Properties.Name // == "Alice"
130+
```
131+
110132
The library supports third party "encoding/json" replacements
111133
such [github.com/json-iterator/go](https://github.com/json-iterator/go).
112134
See the [geojson](geojson) readme for more details.

geojson/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
This package **encodes and decodes** [GeoJSON](http://geojson.org/) into Go structs
44
using the geometries in the [orb](https://github.com/paulmach/orb) package.
55

6+
Generics are supported for Feature Properties, but the default is `map[string]any`.
7+
See the [Generic Properties](#generic-properties) section below for more information.
8+
69
Supports both the [json.Marshaler](https://pkg.go.dev/encoding/json#Marshaler) and
710
[json.Unmarshaler](https://pkg.go.dev/encoding/json#Unmarshaler) interfaces.
811
The package also provides helper functions such as `UnmarshalFeatureCollection` and `UnmarshalFeature`.
@@ -131,3 +134,28 @@ f.Properties.MustFloat64(key string, def ...float64) float64
131134
f.Properties.MustInt(key string, def ...int) int
132135
f.Properties.MustString(key string, def ...string) string
133136
```
137+
138+
## Generic Properties
139+
140+
Go 1.18 introduced generics, and with it the ability to have a custom type for feature properties.
141+
142+
```go
143+
type MyProperties struct {
144+
Name string `json:"name"`
145+
Age int `json:"age"`
146+
}
147+
148+
fc := geojson.FeatureCollectionOf[MyProperties]{}
149+
fc.Append(
150+
&geojson.FeatureOf[MyProperties]{
151+
Geometry: orb.Point{1, 2},
152+
Properties: MyProperties{Name: "Alice", Age: 30},
153+
},
154+
)
155+
156+
// unmarshalling can be even easier
157+
fc2 := geojson.FeatureCollectionOf[MyProperties]{}
158+
err := json.Unmarshal(rawJSON, &fc2)
159+
160+
fc2.Features[0].Properties.Name // == "Alice"
161+
```

geojson/feature.go

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@ package geojson
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"fmt"
67

78
"github.com/paulmach/orb"
89
"go.mongodb.org/mongo-driver/v2/bson"
910
)
1011

11-
// A Feature corresponds to GeoJSON feature object
12-
type Feature struct {
12+
// A FeatureOf corresponds to GeoJSON feature object but allows for a generic type for the properties.
13+
// This allows users to unmarshal into a struct instead of a map if they choose.
14+
//
15+
// The code assumes type of P is a struct, or map as the GeoJSON spec requires it
16+
// marshal into the a json object.
17+
type FeatureOf[P any] struct {
1318
ID any `json:"id,omitempty"`
1419
Type string `json:"type"`
1520
BBox BBox `json:"bbox,omitempty"`
1621
Geometry orb.Geometry `json:"geometry"`
17-
Properties Properties `json:"properties"`
22+
Properties P `json:"properties"`
1823

1924
// ExtraMembers can be used to encoded/decode extra key/members in
2025
// the base of the feature object. Note that keys of "id", "type", "bbox"
@@ -23,6 +28,9 @@ type Feature struct {
2328
ExtraMembers Properties `json:"-"`
2429
}
2530

31+
// A Feature corresponds to GeoJSON feature object.
32+
type Feature = FeatureOf[Properties]
33+
2634
// NewFeature creates and initializes a GeoJSON feature given the required attributes.
2735
func NewFeature(geometry orb.Geometry) *Feature {
2836
return &Feature{
@@ -35,45 +43,75 @@ func NewFeature(geometry orb.Geometry) *Feature {
3543
// Point implements the orb.Pointer interface so that Features can be used
3644
// with quadtrees. The point returned is the center of the Bound of the geometry.
3745
// To represent the geometry with another point you must create a wrapper type.
38-
func (f *Feature) Point() orb.Point {
46+
func (f *FeatureOf[P]) Point() orb.Point {
3947
return f.Geometry.Bound().Center()
4048
}
4149

42-
var _ orb.Pointer = &Feature{}
50+
var _ orb.Pointer = &FeatureOf[any]{}
4351

4452
// MarshalJSON converts the feature object into the proper JSON.
4553
// It will handle the encoding of all the child geometries.
4654
// Alternately one can call json.Marshal(f) directly for the same result.
4755
// Items in the ExtraMembers map will be included in the base of the
4856
// feature object.
49-
func (f Feature) MarshalJSON() ([]byte, error) {
50-
return marshalJSON(newFeatureDoc(&f))
57+
func (f FeatureOf[P]) MarshalJSON() ([]byte, error) {
58+
jProperties, err := f.jsonProperties()
59+
if err != nil {
60+
return nil, err
61+
}
62+
return marshalJSON(f.newFeatureDoc(jProperties))
5163
}
5264

5365
// MarshalBSON converts the feature object into the proper JSON.
5466
// It will handle the encoding of all the child geometries.
5567
// Alternately one can call json.Marshal(f) directly for the same result.
5668
// Items in the ExtraMembers map will be included in the base of the
5769
// feature object.
58-
func (f Feature) MarshalBSON() ([]byte, error) {
59-
return bson.Marshal(newFeatureDoc(&f))
70+
func (f FeatureOf[P]) MarshalBSON() ([]byte, error) {
71+
properties, err := f.bsonProperties()
72+
if err != nil {
73+
return nil, err
74+
}
75+
return bson.Marshal(f.newFeatureDoc(properties))
76+
}
77+
78+
func (f FeatureOf[P]) jsonProperties() (json.RawMessage, error) {
79+
jProperties, err := json.Marshal(f.Properties)
80+
if err != nil {
81+
return nil, err
82+
}
83+
84+
if len(jProperties) <= 2 { // empty
85+
// we assume it's an object so an empty {} is 2 bytes
86+
// in that case the properties should be nil according to the geojson spec
87+
jProperties = nil
88+
}
89+
90+
return jProperties, nil
6091
}
6192

62-
func newFeatureDoc(f *Feature) any {
93+
func (f FeatureOf[P]) bsonProperties() (any, error) {
94+
t, value, err := bson.MarshalValue(f.Properties)
95+
if err != nil {
96+
return nil, err
97+
}
98+
99+
if t == bson.TypeEmbeddedDocument && bytes.Equal(value, []byte{5, 0, 0, 0, 0}) {
100+
return nil, nil
101+
}
102+
103+
return bson.RawValue{Type: t, Value: value}, nil
104+
}
105+
106+
func (f FeatureOf[P]) newFeatureDoc(properties any) any {
63107
if len(f.ExtraMembers) == 0 {
64-
doc := &featureDoc{
108+
return &featureDoc[any]{
65109
ID: f.ID,
66110
Type: "Feature",
67-
Properties: f.Properties,
68111
BBox: f.BBox,
69112
Geometry: NewGeometry(f.Geometry),
113+
Properties: properties,
70114
}
71-
72-
if len(doc.Properties) == 0 {
73-
doc.Properties = nil
74-
}
75-
76-
return doc
77115
}
78116

79117
var tmp map[string]any
@@ -95,12 +133,7 @@ func newFeatureDoc(f *Feature) any {
95133
}
96134

97135
tmp["geometry"] = NewGeometry(f.Geometry)
98-
99-
if len(f.Properties) == 0 {
100-
tmp["properties"] = nil
101-
} else {
102-
tmp["properties"] = f.Properties
103-
}
136+
tmp["properties"] = properties
104137

105138
return tmp
106139
}
@@ -119,9 +152,9 @@ func UnmarshalFeature(data []byte) (*Feature, error) {
119152

120153
// UnmarshalJSON handles the correct unmarshalling of the data
121154
// into the orb.Geometry types.
122-
func (f *Feature) UnmarshalJSON(data []byte) error {
155+
func (f *FeatureOf[P]) UnmarshalJSON(data []byte) error {
123156
if bytes.Equal(data, []byte(`null`)) {
124-
*f = Feature{}
157+
*f = FeatureOf[P]{}
125158
return nil
126159
}
127160

@@ -132,7 +165,7 @@ func (f *Feature) UnmarshalJSON(data []byte) error {
132165
return err
133166
}
134167

135-
*f = Feature{}
168+
*f = FeatureOf[P]{}
136169
for key, value := range tmp {
137170
switch key {
138171
case "id":
@@ -187,15 +220,15 @@ func (f *Feature) UnmarshalJSON(data []byte) error {
187220
}
188221

189222
// UnmarshalBSON will unmarshal a BSON document created with bson.Marshal.
190-
func (f *Feature) UnmarshalBSON(data []byte) error {
223+
func (f *FeatureOf[P]) UnmarshalBSON(data []byte) error {
191224
tmp := make(map[string]bson.RawValue, 4)
192225

193226
err := bson.Unmarshal(data, &tmp)
194227
if err != nil {
195228
return err
196229
}
197230

198-
*f = Feature{}
231+
*f = FeatureOf[P]{}
199232
for key, value := range tmp {
200233
switch key {
201234
case "id":
@@ -246,10 +279,10 @@ func (f *Feature) UnmarshalBSON(data []byte) error {
246279
return nil
247280
}
248281

249-
type featureDoc struct {
250-
ID any `json:"id,omitempty" bson:"id"`
251-
Type string `json:"type" bson:"type"`
252-
BBox BBox `json:"bbox,omitempty" bson:"bbox,omitempty"`
253-
Geometry *Geometry `json:"geometry" bson:"geometry"`
254-
Properties Properties `json:"properties" bson:"properties"`
282+
type featureDoc[P any] struct {
283+
ID any `json:"id,omitempty" bson:"id"`
284+
Type string `json:"type" bson:"type"`
285+
BBox BBox `json:"bbox,omitempty" bson:"bbox,omitempty"`
286+
Geometry *Geometry `json:"geometry" bson:"geometry"`
287+
Properties P `json:"properties" bson:"properties"`
255288
}

geojson/feature_collection.go

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,25 @@ import (
1515

1616
const featureCollection = "FeatureCollection"
1717

18-
// A FeatureCollection correlates to a GeoJSON feature collection.
19-
type FeatureCollection struct {
20-
Type string `json:"type"`
21-
BBox BBox `json:"bbox,omitempty"`
22-
Features []*Feature `json:"features"`
18+
// A FeatureCollectionOf correlates to a GeoJSON feature collection but allows for a generic type
19+
// for the properties of the features.
20+
//
21+
// The code assumes type of P is a struct, or map as the GeoJSON spec requires it
22+
// marshal into the a json object.
23+
type FeatureCollectionOf[P any] struct {
24+
Type string `json:"type"`
25+
BBox BBox `json:"bbox,omitempty"`
26+
Features []*FeatureOf[P] `json:"features"`
2327

2428
// ExtraMembers can be used to encoded/decode extra key/members in
2529
// the base of the feature collection. Note that keys of "type", "bbox"
2630
// and "features" will not work as those are reserved by the GeoJSON spec.
2731
ExtraMembers Properties `json:"-"`
2832
}
2933

34+
// A FeatureCollection correlates to a GeoJSON feature collection.
35+
type FeatureCollection = FeatureCollectionOf[Properties]
36+
3037
// NewFeatureCollection creates and initializes a new feature collection.
3138
func NewFeatureCollection() *FeatureCollection {
3239
return &FeatureCollection{
@@ -36,7 +43,7 @@ func NewFeatureCollection() *FeatureCollection {
3643
}
3744

3845
// Append appends a feature to the collection.
39-
func (fc *FeatureCollection) Append(feature *Feature) *FeatureCollection {
46+
func (fc *FeatureCollectionOf[P]) Append(feature *FeatureOf[P]) *FeatureCollectionOf[P] {
4047
fc.Features = append(fc.Features, feature)
4148
return fc
4249
}
@@ -46,7 +53,7 @@ func (fc *FeatureCollection) Append(feature *Feature) *FeatureCollection {
4653
// Alternately one can call json.Marshal(fc) directly for the same result.
4754
// Items in the ExtraMembers map will be included in the base of the
4855
// feature collection object.
49-
func (fc FeatureCollection) MarshalJSON() ([]byte, error) {
56+
func (fc FeatureCollectionOf[P]) MarshalJSON() ([]byte, error) {
5057
m := newFeatureCollectionDoc(fc)
5158
return marshalJSON(m)
5259
}
@@ -56,13 +63,13 @@ func (fc FeatureCollection) MarshalJSON() ([]byte, error) {
5663
// and geometries.
5764
// Items in the ExtraMembers map will be included in the base of the
5865
// feature collection object.
59-
func (fc FeatureCollection) MarshalBSON() ([]byte, error) {
66+
func (fc FeatureCollectionOf[P]) MarshalBSON() ([]byte, error) {
6067
m := newFeatureCollectionDoc(fc)
6168
return bson.Marshal(m)
6269
}
6370

64-
func newFeatureCollectionDoc(fc FeatureCollection) map[string]any {
65-
var tmp map[string]any
71+
func newFeatureCollectionDoc[P any](fc FeatureCollectionOf[P]) map[string]any {
72+
var tmp map[string]interface{}
6673
if fc.ExtraMembers != nil {
6774
tmp = fc.ExtraMembers.Clone()
6875
} else {
@@ -75,7 +82,7 @@ func newFeatureCollectionDoc(fc FeatureCollection) map[string]any {
7582
tmp["bbox"] = fc.BBox
7683
}
7784
if fc.Features == nil {
78-
tmp["features"] = []*Feature{}
85+
tmp["features"] = []*FeatureOf[P]{}
7986
} else {
8087
tmp["features"] = fc.Features
8188
}
@@ -85,9 +92,9 @@ func newFeatureCollectionDoc(fc FeatureCollection) map[string]any {
8592

8693
// UnmarshalJSON decodes the data into a GeoJSON feature collection.
8794
// Extra/foreign members will be put into the `ExtraMembers` attribute.
88-
func (fc *FeatureCollection) UnmarshalJSON(data []byte) error {
95+
func (fc *FeatureCollectionOf[P]) UnmarshalJSON(data []byte) error {
8996
if bytes.Equal(data, []byte(`null`)) {
90-
*fc = FeatureCollection{}
97+
*fc = FeatureCollectionOf[P]{}
9198
return nil
9299
}
93100

@@ -98,7 +105,7 @@ func (fc *FeatureCollection) UnmarshalJSON(data []byte) error {
98105
return err
99106
}
100107

101-
*fc = FeatureCollection{}
108+
*fc = FeatureCollectionOf[P]{}
102109
for key, value := range tmp {
103110
switch key {
104111
case "type":
@@ -139,15 +146,15 @@ func (fc *FeatureCollection) UnmarshalJSON(data []byte) error {
139146

140147
// UnmarshalBSON will unmarshal a BSON document created with bson.Marshal.
141148
// Extra/foreign members will be put into the `ExtraMembers` attribute.
142-
func (fc *FeatureCollection) UnmarshalBSON(data []byte) error {
149+
func (fc *FeatureCollectionOf[P]) UnmarshalBSON(data []byte) error {
143150
tmp := make(map[string]bson.RawValue, 4)
144151

145152
err := bson.Unmarshal(data, &tmp)
146153
if err != nil {
147154
return err
148155
}
149156

150-
*fc = FeatureCollection{}
157+
*fc = FeatureCollectionOf[P]{}
151158
for key, value := range tmp {
152159
switch key {
153160
case "type":

0 commit comments

Comments
 (0)