Skip to content

Commit 5628529

Browse files
authored
Implement OGC API Features Part 4 transactions for the postgres provider (#1891)
* Implement OGC API Features Part 4 for the postgres provider * Mark transactions as supported in PostgreSQL
1 parent 6aa67b4 commit 5628529

4 files changed

Lines changed: 152 additions & 5 deletions

File tree

docs/source/data-publishing/ogcapi-features.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ parameters.
2929
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
3030
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
3131
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
32-
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,,✅
32+
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,,✅
3333
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
3434
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
3535
`Socrata`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅

pygeoapi/provider/postgresql.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
# John A Stevenson <jostev@bgs.ac.uk>
77
# Colin Blackburn <colb@bgs.ac.uk>
88
# Francesco Bartoli <xbartolone@gmail.com>
9+
# Bernhard Mallinger <bernhard.mallinger@eox.at>
910
#
1011
# Copyright (c) 2018 Jorge Samuel Mendes de Jesus
1112
# Copyright (c) 2024 Tom Kralidis
1213
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
1314
# Copyright (c) 2023 Francesco Bartoli
15+
# Copyright (c) 2024 Bernhard Mallinger
1416
#
1517
# Permission is hereby granted, free of charge, to any person
1618
# obtaining a copy of this software and associated documentation
@@ -56,11 +58,12 @@
5658

5759
from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is needed to process Geometry columns
5860
from geoalchemy2.functions import ST_MakeEnvelope
59-
from geoalchemy2.shape import to_shape
61+
from geoalchemy2.shape import to_shape, from_shape
6062
from pygeofilter.backends.sqlalchemy.evaluate import to_filter
6163
import pyproj
6264
import shapely
63-
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
65+
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, \
66+
desc, delete
6467
from sqlalchemy.engine import URL
6568
from sqlalchemy.exc import ConstraintColumnNotFoundError, \
6669
InvalidRequestError, OperationalError
@@ -69,7 +72,8 @@
6972
from sqlalchemy.sql.expression import and_
7073

7174
from pygeoapi.provider.base import BaseProvider, \
72-
ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError
75+
ProviderConnectionError, ProviderInvalidDataError, ProviderQueryError, \
76+
ProviderItemNotFoundError
7377
from pygeoapi.util import get_transform_from_crs
7478

7579

@@ -307,6 +311,65 @@ def get(self, identifier, crs_transform_spec=None, **kwargs):
307311

308312
return feature
309313

314+
def create(self, item):
315+
"""
316+
Create a new item
317+
318+
:param item: `dict` of new item
319+
320+
:returns: identifier of created item
321+
"""
322+
323+
identifier, json_data = self._load_and_prepare_item(
324+
item, accept_missing_identifier=True)
325+
326+
new_instance = self._feature_to_sqlalchemy(json_data, identifier)
327+
with Session(self._engine) as session:
328+
session.add(new_instance)
329+
session.commit()
330+
result_id = getattr(new_instance, self.id_field)
331+
332+
# NOTE: need to use id from instance in case it's generated
333+
return result_id
334+
335+
def update(self, identifier, item):
336+
"""
337+
Updates an existing item
338+
339+
:param identifier: feature id
340+
:param item: `dict` of partial or full item
341+
342+
:returns: `bool` of update result
343+
"""
344+
345+
identifier, json_data = self._load_and_prepare_item(
346+
item, raise_if_exists=False)
347+
348+
new_instance = self._feature_to_sqlalchemy(json_data, identifier)
349+
with Session(self._engine) as session:
350+
session.merge(new_instance)
351+
session.commit()
352+
353+
return True
354+
355+
def delete(self, identifier):
356+
"""
357+
Deletes an existing item
358+
359+
:param identifier: item id
360+
361+
:returns: `bool` of deletion result
362+
"""
363+
with Session(self._engine) as session:
364+
id_column = getattr(self.table_model, self.id_field)
365+
result = session.execute(
366+
delete(self.table_model)
367+
.where(id_column == identifier)
368+
)
369+
session.commit()
370+
371+
return result.rowcount > 0
372+
310373
def _store_db_parameters(self, parameters, options):
311374
self.db_user = parameters.get('user')
312375
self.db_host = parameters.get('host')
@@ -343,6 +406,26 @@ def _sqlalchemy_to_feature(self, item, crs_transform_out=None):
343406

344407
return feature
345408

409+
def _feature_to_sqlalchemy(self, json_data, identifier=None):
410+
attributes = {**json_data['properties']}
411+
# 'identifier' key maybe be present in geojson properties, but might
412+
# not be a valid db field
413+
attributes.pop('identifier', None)
414+
attributes[self.geom] = from_shape(
415+
shapely.geometry.shape(json_data['geometry']),
416+
# NOTE: for some reason, postgis in the github action requires
417+
# explicit crs information. i think it's valid to assume 4326:
418+
# https://portal.ogc.org/files/108198#feature-crs
419+
srid=4326
420+
)
421+
attributes[self.id_field] = identifier
422+
423+
try:
424+
return self.table_model(**attributes)
425+
except Exception as e:
426+
LOGGER.exception('Failed to create db model')
427+
raise ProviderInvalidDataError(str(e))
428+
346429
def _get_order_by_clauses(self, sort_by, table_model):
347430
# Build sort_by clauses if provided
348431
clauses = []

tests/pygeoapi-test-config-postgresql.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ resources:
134134
user: postgres
135135
password: postgres
136136
search_path: [osm, public]
137+
editable: true
137138
options:
138139
# Maximum time to wait while connecting, in seconds.
139140
connect_timeout: 10

tests/test_postgresql_provider.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
# John A Stevenson <jostev@bgs.ac.uk>
66
# Colin Blackburn <colb@bgs.ac.uk>
77
# Francesco Bartoli <xbartolone@gmail.com>
8+
# Bernhard Mallinger <bernhard.mallinger@eox.at>
89
#
910
# Copyright (c) 2019 Just van den Broecke
1011
# Copyright (c) 2024 Tom Kralidis
1112
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
1213
# Copyright (c) 2023 Francesco Bartoli
14+
# Copyright (c) 2024 Bernhard Mallinger
1315
#
1416
# Permission is hereby granted, free of charge, to any person
1517
# obtaining a copy of this software and associated documentation
@@ -48,7 +50,8 @@
4850

4951
from pygeoapi.api import API
5052
from pygeoapi.api.itemtypes import (
51-
get_collection_items, get_collection_item, post_collection_items
53+
get_collection_items, get_collection_item, manage_collection_item,
54+
post_collection_items
5255
)
5356
from pygeoapi.provider.base import (
5457
ProviderConnectionError,
@@ -107,6 +110,25 @@ def config_types():
107110
}
108111

109112

113+
@pytest.fixture()
114+
def data():
115+
return json.dumps({
116+
'type': 'Feature',
117+
'geometry': {
118+
'type': 'MultiLineString',
119+
'coordinates': [
120+
[[100.0, 0.0], [101.0, 0.0]],
121+
[[101.0, 0.0], [100.0, 1.0]],
122+
]
123+
},
124+
'properties': {
125+
'identifier': 123,
126+
'name': 'Flowy McFlow',
127+
'waterway': 'river'
128+
}
129+
})
130+
131+
110132
@pytest.fixture()
111133
def openapi():
112134
with open(get_test_file_path('pygeoapi-test-openapi.yml')) as fh:
@@ -795,3 +817,44 @@ def test_get_collection_items_postgresql_automap_naming_conflicts(pg_api_):
795817
assert code == HTTPStatus.OK
796818
features = json.loads(response).get('features')
797819
assert len(features) == 0
820+
821+
822+
def test_transaction_basic_workflow(pg_api_, data):
823+
# create
824+
req = mock_api_request(data=data)
825+
headers, code, content = manage_collection_item(
826+
pg_api_, req, action='create', dataset='hot_osm_waterways')
827+
assert code == HTTPStatus.CREATED
828+
829+
# update
830+
data_parsed = json.loads(data)
831+
new_name = data_parsed['properties']['name'] + ' Flow'
832+
data_parsed['properties']['name'] = new_name
833+
req = mock_api_request(data=json.dumps(data_parsed))
834+
headers, code, content = manage_collection_item(
835+
pg_api_, req, action='update', dataset='hot_osm_waterways',
836+
identifier=123)
837+
assert code == HTTPStatus.NO_CONTENT
838+
839+
# verify update
840+
req = mock_api_request()
841+
headers, code, content = get_collection_item(
842+
pg_api_, req, 'hot_osm_waterways', 123)
843+
assert json.loads(content)['properties']['name'] == new_name
844+
845+
# delete
846+
req = mock_api_request(data=data)
847+
headers, code, content = manage_collection_item(
848+
pg_api_, req, action='delete', dataset='hot_osm_waterways',
849+
identifier=123)
850+
assert code == HTTPStatus.OK
851+
852+
853+
def test_transaction_create_handles_invalid_input_data(pg_api_, data):
854+
data_parsed = json.loads(data)
855+
data_parsed['properties']['invalid-column'] = 'foo'
856+
857+
req = mock_api_request(data=json.dumps(data_parsed))
858+
headers, code, content = manage_collection_item(
859+
pg_api_, req, action='create', dataset='hot_osm_waterways')
860+
assert 'generic error' in content

0 commit comments

Comments
 (0)