Skip to content

Commit c4ad376

Browse files
authored
Merge pull request #23 from developmentseed/geom_return_options
Support tables without geometries. Add options for returning geometries.
2 parents edf811b + 99b69ee commit c4ad376

File tree

5 files changed

+147
-34
lines changed

5 files changed

+147
-34
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Release Notes
22

3+
## Unreleased
4+
Add options to reduce the bandwidth required for returning record geometries.
5+
- bbox-only=[bool] only return the bounding box in the return geometry
6+
- geom-column=none don't return geometry as part of the return
7+
- simplify=[float] Use ST_SnapToGrid(ST_Simplify(geom, [simplify]),[simplify]) to simplify and reduce precision of output geometry.
8+
39
## 0.1.0
410

511
Initial release

tests/routes/test_items.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ def test_items_properties_filter(app):
194194

195195
def test_items_filter_cql_ids(app):
196196
"""Test /items endpoint with ids options."""
197-
filter = {"op": "=", "args": [{"property": "ogc_fid"}, 1]}
197+
filter_query = {"op": "=", "args": [{"property": "ogc_fid"}, 1]}
198198
response = app.get(
199-
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter)}"
199+
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter_query)}"
200200
)
201201
assert response.status_code == 200
202202
assert response.headers["content-type"] == "application/geo+json"
@@ -222,7 +222,7 @@ def test_items_filter_cql_ids(app):
222222
response = app.get(
223223
"/collections/public.landsat_wrs/items?filter-lang=cql2-text&filter=ogc_fid IN (1,2)"
224224
)
225-
print(response.content)
225+
226226
assert response.status_code == 200
227227
assert response.headers["content-type"] == "application/geo+json"
228228
body = response.json()
@@ -237,9 +237,9 @@ def test_items_filter_cql_ids(app):
237237

238238
def test_items_properties_filter_cql2(app):
239239
"""Test /items endpoint with properties filter options."""
240-
filter = {"op": "=", "args": [{"property": "path"}, 13]}
240+
filter_query = {"op": "=", "args": [{"property": "path"}, 13]}
241241
response = app.get(
242-
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter)}"
242+
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter_query)}"
243243
)
244244
assert response.status_code == 200
245245
assert response.headers["content-type"] == "application/geo+json"
@@ -250,22 +250,22 @@ def test_items_properties_filter_cql2(app):
250250
assert body["features"][0]["properties"]["path"] == 13
251251

252252
# invalid type (str instead of int)
253-
filter = {"op": "=", "args": [{"property": "path"}, "d"]}
253+
filter_query = {"op": "=", "args": [{"property": "path"}, "d"]}
254254
response = app.get(
255-
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter)}"
255+
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter_query)}"
256256
)
257257
assert response.status_code == 500
258258
assert "integer is required" in response.json()["detail"]
259259

260-
filter = {
260+
filter_query = {
261261
"op": "and",
262262
"args": [
263263
{"op": "=", "args": [{"property": "path"}, 13]},
264264
{"op": "=", "args": [{"property": "row"}, 10]},
265265
],
266266
}
267267
response = app.get(
268-
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter)}"
268+
f"/collections/public.landsat_wrs/items?filter-lang=cql2-json&filter={json.dumps(filter_query)}"
269269
)
270270
assert response.status_code == 200
271271
assert response.headers["content-type"] == "application/geo+json"
@@ -423,3 +423,63 @@ def test_items_datetime(app):
423423
)
424424
assert response.status_code == 422
425425
assert response.headers["content-type"] == "application/json"
426+
427+
428+
def test_items_geometry_return_options(app):
429+
"""Test /items endpoint with geometry return options."""
430+
response = app.get("/collections/public.landsat_wrs/items?ids=1&geom-column=none")
431+
assert response.status_code == 200
432+
assert response.headers["content-type"] == "application/geo+json"
433+
body = response.json()
434+
assert len(body["features"]) == 1
435+
assert body["numberMatched"] == 1
436+
assert body["numberReturned"] == 1
437+
assert body["features"][0]["id"] == "1"
438+
assert body["features"][0]["properties"]["ogc_fid"] == 1
439+
assert "geometry" not in body["features"][0]
440+
441+
response = app.get("/collections/public.landsat_wrs/items?ids=1&bbox-only=true")
442+
assert response.status_code == 200
443+
assert response.headers["content-type"] == "application/geo+json"
444+
body = response.json()
445+
assert len(body["features"]) == 1
446+
assert body["numberMatched"] == 1
447+
assert body["numberReturned"] == 1
448+
assert body["features"][0]["id"] == "1"
449+
assert body["features"][0]["properties"]["ogc_fid"] == 1
450+
assert body["features"][0]["geometry"] == {
451+
"coordinates": [
452+
[
453+
[-22.2153, 79.6888],
454+
[-22.2153, 81.8555],
455+
[-8.97407, 81.8555],
456+
[-8.97407, 79.6888],
457+
[-22.2153, 79.6888],
458+
]
459+
],
460+
"type": "Polygon",
461+
}
462+
463+
response = app.get("/collections/public.landsat_wrs/items?ids=1&simplify=.001")
464+
assert response.status_code == 200
465+
assert response.headers["content-type"] == "application/geo+json"
466+
body = response.json()
467+
assert len(body["features"]) == 1
468+
assert body["numberMatched"] == 1
469+
assert body["numberReturned"] == 1
470+
assert body["features"][0]["id"] == "1"
471+
assert body["features"][0]["properties"]["ogc_fid"] == 1
472+
print(body["features"][0]["geometry"])
473+
assert body["features"][0]["geometry"] == {
474+
"coordinates": [
475+
[
476+
[-10.803, 80.989],
477+
[-8.974, 80.342],
478+
[-16.985, 79.689],
479+
[-22.215, 81.092],
480+
[-13.255, 81.856],
481+
[-10.803, 80.989],
482+
]
483+
],
484+
"type": "Polygon",
485+
}

tifeatures/dbmodel.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ def datetime_column(self, name: Optional[str] = None) -> Optional[Column]:
7474

7575
def geometry_column(self, name: Optional[str] = None) -> Optional[GeometryColumn]:
7676
"""Return the name of the first geometry column."""
77-
if self.geometry_columns:
78-
for col in self.geometry_columns:
79-
if name is None or col.name == name:
80-
return col
77+
if name and name.lower() == "none":
78+
return None
79+
80+
for col in self.geometry_columns:
81+
if name is None or col.name == name:
82+
return col
8183

8284
return None
8385

tifeatures/factory.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,10 @@ def collections(
351351
],
352352
}
353353
)
354-
for collection in [*tables, *list(function_catalog.values())]
354+
for collection in [
355+
*tables,
356+
*list(function_catalog.values()),
357+
]
355358
],
356359
)
357360

@@ -398,7 +401,9 @@ def collection(
398401
),
399402
model.Link(
400403
href=self.url_for(
401-
request, "collection", collectionId=collection.id
404+
request,
405+
"collection",
406+
collectionId=collection.id,
402407
)
403408
+ "?f=html",
404409
rel="alternate",
@@ -521,6 +526,15 @@ async def items(
521526
description="Starts the response at an offset.",
522527
),
523528
output_type: Optional[ResponseType] = Depends(OutputType),
529+
bbox_only: Optional[bool] = Query(
530+
None,
531+
description="Only return the bounding box of the feature.",
532+
alias="bbox-only",
533+
),
534+
simplify: Optional[float] = Query(
535+
None,
536+
description="Simplify the output geometry to given threshold in decimal degrees.",
537+
),
524538
):
525539
offset = offset or 0
526540

@@ -537,6 +551,8 @@ async def items(
537551
"datetime-column",
538552
"limit",
539553
"offset",
554+
"bbox-only",
555+
"simplify",
540556
]
541557
properties_filter = [
542558
(key, value)
@@ -556,6 +572,8 @@ async def items(
556572
offset=offset,
557573
geom=geom_column,
558574
dt=datetime_column,
575+
bbox_only=bbox_only,
576+
simplify=simplify,
559577
)
560578

561579
qs = "?" + str(request.query_params) if request.query_params else ""

tifeatures/layer.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
from pydantic import BaseModel, root_validator
1313
from pygeofilter.ast import AstType
1414

15+
from tifeatures.dbmodel import GeometryColumn
1516
from tifeatures.dbmodel import Table as DBTable
1617
from tifeatures.errors import (
1718
InvalidDatetime,
1819
InvalidDatetimeColumnName,
1920
InvalidGeometryColumnName,
2021
InvalidPropertyName,
2122
MissingDatetimeColumn,
22-
MissingGeometryColumn,
2323
)
2424
from tifeatures.filter.evaluate import to_filter
2525
from tifeatures.filter.filters import bbox_to_wkt
@@ -121,6 +121,39 @@ def _select_count(self):
121121
def _from(self):
122122
return clauses.From(self.id)
123123

124+
def _geom(
125+
self,
126+
geometry_column: Optional[GeometryColumn],
127+
bbox_only: Optional[bool],
128+
simplify: Optional[float],
129+
):
130+
if geometry_column is None:
131+
return pg_funcs.cast(None, "json")
132+
133+
g = logic.V(geometry_column.name)
134+
135+
if bbox_only:
136+
g = logic.Func("ST_Envelope", g)
137+
138+
elif simplify:
139+
g = logic.Func(
140+
"ST_SnapToGrid",
141+
logic.Func("ST_Simplify", g, simplify),
142+
simplify,
143+
)
144+
145+
if geometry_column.srid == 4326:
146+
g = logic.Func("ST_AsGeoJson", g)
147+
148+
else:
149+
g = logic.Func(
150+
"ST_Transform",
151+
logic.Func("ST_AsGeoJson", g),
152+
4326,
153+
)
154+
155+
return g
156+
124157
def _where(
125158
self,
126159
ids: Optional[List[str]] = None,
@@ -304,13 +337,11 @@ async def query(
304337
dt: str = None,
305338
limit: Optional[int] = None,
306339
offset: Optional[int] = None,
340+
bbox_only: Optional[bool] = None,
341+
simplify: Optional[float] = None,
307342
) -> Tuple[FeatureCollection, int]:
308343
"""Build and run Pg query."""
309-
if not self.geometry_columns:
310-
raise MissingGeometryColumn("Must have geometry column for geojson output.")
311-
312-
geometry_column = self.geometry_column(geom)
313-
if not geometry_column:
344+
if geom and geom.lower() != "none" and not self.geometry_column(geom):
314345
raise InvalidGeometryColumnName(f"Invalid Geometry Column: {geom}.")
315346

316347
sql_query = """
@@ -327,13 +358,8 @@ async def query(
327358
json_build_object(
328359
'type', 'Feature',
329360
'id', :id_column,
330-
'geometry', ST_AsGeoJSON(
331-
CASE
332-
WHEN :srid = 4326 THEN :geometry_column
333-
ELSE ST_Transform(:geometry_column, 4326)
334-
END
335-
)::json,
336-
'properties', to_jsonb( features.* ) - :geom_columns
361+
'geometry', :geometry_q,
362+
'properties', to_jsonb( features.* ) - :geom_columns::text[]
337363
)
338364
),
339365
'total_count', total_count.count
@@ -365,16 +391,17 @@ async def query(
365391
dt=dt,
366392
),
367393
id_column=logic.V(self.id_column),
368-
srid=geometry_column.srid,
369-
geometry_column=logic.V(geometry_column.name),
370-
geom_columns=geometry_column.name,
394+
geometry_q=self._geom(
395+
geometry_column=self.geometry_column(geom),
396+
bbox_only=bbox_only,
397+
simplify=simplify,
398+
),
399+
geom_columns=[g.name for g in self.geometry_columns],
371400
)
401+
372402
async with pool.acquire() as conn:
373403
items = await conn.fetchval(q, *p)
374404

375-
# TODO:
376-
# - make sure we always return features (even empty)
377-
# - make sure we always return total_count
378405
if items:
379406
return (
380407
FeatureCollection(features=items["features"]),

0 commit comments

Comments
 (0)