Skip to content

Commit

Permalink
Implement OGC API Features Part 4 transactions for the postgres provi…
Browse files Browse the repository at this point in the history
…der (#1891)

* Implement OGC API Features Part 4 for the postgres provider

* Mark transactions as supported in PostgreSQL
  • Loading branch information
totycro authored Dec 30, 2024
1 parent 6aa67b4 commit 5628529
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 5 deletions.
2 changes: 1 addition & 1 deletion docs/source/data-publishing/ogcapi-features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ parameters.
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,,✅
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,,✅
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
`Socrata`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
Expand Down
89 changes: 86 additions & 3 deletions pygeoapi/provider/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
# John A Stevenson <[email protected]>
# Colin Blackburn <[email protected]>
# Francesco Bartoli <[email protected]>
# Bernhard Mallinger <[email protected]>
#
# Copyright (c) 2018 Jorge Samuel Mendes de Jesus
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Francesco Bartoli
# Copyright (c) 2024 Bernhard Mallinger
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
Expand Down Expand Up @@ -56,11 +58,12 @@

from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is needed to process Geometry columns
from geoalchemy2.functions import ST_MakeEnvelope
from geoalchemy2.shape import to_shape
from geoalchemy2.shape import to_shape, from_shape
from pygeofilter.backends.sqlalchemy.evaluate import to_filter
import pyproj
import shapely
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, \
desc, delete
from sqlalchemy.engine import URL
from sqlalchemy.exc import ConstraintColumnNotFoundError, \
InvalidRequestError, OperationalError
Expand All @@ -69,7 +72,8 @@
from sqlalchemy.sql.expression import and_

from pygeoapi.provider.base import BaseProvider, \
ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError
ProviderConnectionError, ProviderInvalidDataError, ProviderQueryError, \
ProviderItemNotFoundError
from pygeoapi.util import get_transform_from_crs


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

return feature

def create(self, item):
"""
Create a new item
:param item: `dict` of new item
:returns: identifier of created item
"""

identifier, json_data = self._load_and_prepare_item(
item, accept_missing_identifier=True)

new_instance = self._feature_to_sqlalchemy(json_data, identifier)
with Session(self._engine) as session:
session.add(new_instance)
session.commit()
result_id = getattr(new_instance, self.id_field)

# NOTE: need to use id from instance in case it's generated
return result_id

def update(self, identifier, item):
"""
Updates an existing item
:param identifier: feature id
:param item: `dict` of partial or full item
:returns: `bool` of update result
"""

identifier, json_data = self._load_and_prepare_item(
item, raise_if_exists=False)

new_instance = self._feature_to_sqlalchemy(json_data, identifier)
with Session(self._engine) as session:
session.merge(new_instance)
session.commit()

return True

def delete(self, identifier):
"""
Deletes an existing item
:param identifier: item id
:returns: `bool` of deletion result
"""
with Session(self._engine) as session:
id_column = getattr(self.table_model, self.id_field)
result = session.execute(
delete(self.table_model)
.where(id_column == identifier)
)
session.commit()

return result.rowcount > 0

def _store_db_parameters(self, parameters, options):
self.db_user = parameters.get('user')
self.db_host = parameters.get('host')
Expand Down Expand Up @@ -343,6 +406,26 @@ def _sqlalchemy_to_feature(self, item, crs_transform_out=None):

return feature

def _feature_to_sqlalchemy(self, json_data, identifier=None):
attributes = {**json_data['properties']}
# 'identifier' key maybe be present in geojson properties, but might
# not be a valid db field
attributes.pop('identifier', None)
attributes[self.geom] = from_shape(
shapely.geometry.shape(json_data['geometry']),
# NOTE: for some reason, postgis in the github action requires
# explicit crs information. i think it's valid to assume 4326:
# https://portal.ogc.org/files/108198#feature-crs
srid=4326
)
attributes[self.id_field] = identifier

try:
return self.table_model(**attributes)
except Exception as e:
LOGGER.exception('Failed to create db model')
raise ProviderInvalidDataError(str(e))

def _get_order_by_clauses(self, sort_by, table_model):
# Build sort_by clauses if provided
clauses = []
Expand Down
1 change: 1 addition & 0 deletions tests/pygeoapi-test-config-postgresql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ resources:
user: postgres
password: postgres
search_path: [osm, public]
editable: true
options:
# Maximum time to wait while connecting, in seconds.
connect_timeout: 10
Expand Down
65 changes: 64 additions & 1 deletion tests/test_postgresql_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
# John A Stevenson <[email protected]>
# Colin Blackburn <[email protected]>
# Francesco Bartoli <[email protected]>
# Bernhard Mallinger <[email protected]>
#
# Copyright (c) 2019 Just van den Broecke
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Francesco Bartoli
# Copyright (c) 2024 Bernhard Mallinger
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
Expand Down Expand Up @@ -48,7 +50,8 @@

from pygeoapi.api import API
from pygeoapi.api.itemtypes import (
get_collection_items, get_collection_item, post_collection_items
get_collection_items, get_collection_item, manage_collection_item,
post_collection_items
)
from pygeoapi.provider.base import (
ProviderConnectionError,
Expand Down Expand Up @@ -107,6 +110,25 @@ def config_types():
}


@pytest.fixture()
def data():
return json.dumps({
'type': 'Feature',
'geometry': {
'type': 'MultiLineString',
'coordinates': [
[[100.0, 0.0], [101.0, 0.0]],
[[101.0, 0.0], [100.0, 1.0]],
]
},
'properties': {
'identifier': 123,
'name': 'Flowy McFlow',
'waterway': 'river'
}
})


@pytest.fixture()
def openapi():
with open(get_test_file_path('pygeoapi-test-openapi.yml')) as fh:
Expand Down Expand Up @@ -795,3 +817,44 @@ def test_get_collection_items_postgresql_automap_naming_conflicts(pg_api_):
assert code == HTTPStatus.OK
features = json.loads(response).get('features')
assert len(features) == 0


def test_transaction_basic_workflow(pg_api_, data):
# create
req = mock_api_request(data=data)
headers, code, content = manage_collection_item(
pg_api_, req, action='create', dataset='hot_osm_waterways')
assert code == HTTPStatus.CREATED

# update
data_parsed = json.loads(data)
new_name = data_parsed['properties']['name'] + ' Flow'
data_parsed['properties']['name'] = new_name
req = mock_api_request(data=json.dumps(data_parsed))
headers, code, content = manage_collection_item(
pg_api_, req, action='update', dataset='hot_osm_waterways',
identifier=123)
assert code == HTTPStatus.NO_CONTENT

# verify update
req = mock_api_request()
headers, code, content = get_collection_item(
pg_api_, req, 'hot_osm_waterways', 123)
assert json.loads(content)['properties']['name'] == new_name

# delete
req = mock_api_request(data=data)
headers, code, content = manage_collection_item(
pg_api_, req, action='delete', dataset='hot_osm_waterways',
identifier=123)
assert code == HTTPStatus.OK


def test_transaction_create_handles_invalid_input_data(pg_api_, data):
data_parsed = json.loads(data)
data_parsed['properties']['invalid-column'] = 'foo'

req = mock_api_request(data=json.dumps(data_parsed))
headers, code, content = manage_collection_item(
pg_api_, req, action='create', dataset='hot_osm_waterways')
assert 'generic error' in content

0 comments on commit 5628529

Please sign in to comment.