Convert coordinates to storage crs when filtering via cql (#1489)
* Allow either URL or URN CRS URIs * Implemented transformation of geometries in CQL filter * Fixed flake8 issue * Removed commented out code * Implemented support for the filter-crs query parameter * Remove unneeded print() calls
This commit is contained in:
committed by
GitHub
parent
de787b0f22
commit
2d0fc5df3e
@@ -40,6 +40,15 @@ Using Elasticsearch the following type of queries are supported right now:
|
||||
* Logical ``and`` query with ``between`` and ``eq`` expression
|
||||
* Spatial query with ``bbox``
|
||||
|
||||
Note that when using a spatial operator in your filter expression, geometries are by default interpreted as being
|
||||
in the OGC:CRS84 Coordinate Reference System. If you wish to provide geometries in other CRS, use the ``filter-crs``
|
||||
query parameter with a suitable value.
|
||||
|
||||
Alternatively, a geometry's CRS may also be included using Extended Well-Known Text, in which case it will override
|
||||
the value of ``filter-crs`` (if any) - this can be useful if your filtering expression is complex enough to
|
||||
need multiple geometries expressed in different CRSs. The standard way of providing ``filter-crs`` as an additional
|
||||
query parameter is preferable for most cases.
|
||||
|
||||
Examples
|
||||
^^^^^^^^
|
||||
|
||||
@@ -93,4 +102,20 @@ A ``CROSSES`` example via an HTTP GET request. The CQL text is passed via the `
|
||||
|
||||
curl "http://localhost:5000/collections/hot_osm_waterways/items?f=json&filter=CROSSES(foo_geom,%20LINESTRING(28%20-2,%2030%20-4))"
|
||||
|
||||
A ``DWITHIN`` example via HTTP GET and using a custom CRS for the filter geometry:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
curl "http://localhost:5000/collections/beni/items?filter=DWITHIN(geometry,POINT(1392921%205145517),100,meters)&filter-crs=http://www.opengis.net/def/crs/EPSG/0/3857"
|
||||
|
||||
|
||||
The same example, but this time providing a geometry in EWKT format:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
curl "http://localhost:5000/collections/beni/items?filter=DWITHIN(geometry,SRID=3857;POINT(1392921%205145517),100,meters)"
|
||||
|
||||
|
||||
|
||||
|
||||
Note that the CQL text has been URL encoded. This is required in curl commands but when entering in a browser, plain text can be used e.g. ``CROSSES(foo_geom, LINESTRING(28 -2, 30 -4))``.
|
||||
|
||||
+30
-9
@@ -85,7 +85,8 @@ from pygeoapi.util import (dategetter, RequestedProcessExecutionMode,
|
||||
json_serial, render_j2_template, str2bool,
|
||||
TEMPLATES, to_json, get_api_rules, get_base_url,
|
||||
get_crs_from_uri, get_supported_crs_list,
|
||||
CrsTransformSpec, transform_bbox)
|
||||
modify_pygeofilter, CrsTransformSpec,
|
||||
transform_bbox)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1501,7 +1502,7 @@ class API:
|
||||
reserved_fieldnames = ['bbox', 'bbox-crs', 'crs', 'f', 'lang', 'limit',
|
||||
'offset', 'resulttype', 'datetime', 'sortby',
|
||||
'properties', 'skipGeometry', 'q',
|
||||
'filter', 'filter-lang']
|
||||
'filter', 'filter-lang', 'filter-crs']
|
||||
|
||||
collections = filter_dict_by_key_value(self.config['resources'],
|
||||
'type', 'collection')
|
||||
@@ -1714,11 +1715,19 @@ class API:
|
||||
else:
|
||||
skip_geometry = False
|
||||
|
||||
LOGGER.debug('Processing filter-crs parameter')
|
||||
filter_crs_uri = request.params.get('filter-crs', DEFAULT_CRS)
|
||||
LOGGER.debug('processing filter parameter')
|
||||
cql_text = request.params.get('filter')
|
||||
if cql_text is not None:
|
||||
try:
|
||||
filter_ = parse_ecql_text(cql_text)
|
||||
filter_ = modify_pygeofilter(
|
||||
filter_,
|
||||
filter_crs_uri=filter_crs_uri,
|
||||
storage_crs_uri=provider_def.get('storage_crs'),
|
||||
geometry_column_name=provider_def.get('geom_field'),
|
||||
)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
msg = f'Bad CQL string : {cql_text}'
|
||||
@@ -1736,7 +1745,6 @@ class API:
|
||||
return self.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
# Get provider locale (if any)
|
||||
prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
|
||||
|
||||
@@ -1755,7 +1763,9 @@ class API:
|
||||
LOGGER.debug(f'language: {prv_locale}')
|
||||
LOGGER.debug(f'q: {q}')
|
||||
LOGGER.debug(f'cql_text: {cql_text}')
|
||||
LOGGER.debug(f'filter_: {filter_}')
|
||||
LOGGER.debug(f'filter-lang: {filter_lang}')
|
||||
LOGGER.debug(f'filter-crs: {filter_crs_uri}')
|
||||
|
||||
try:
|
||||
content = p.query(offset=offset, limit=limit,
|
||||
@@ -1925,7 +1935,7 @@ class API:
|
||||
reserved_fieldnames = ['bbox', 'f', 'limit', 'offset',
|
||||
'resulttype', 'datetime', 'sortby',
|
||||
'properties', 'skipGeometry', 'q',
|
||||
'filter-lang']
|
||||
'filter-lang', 'filter-crs']
|
||||
|
||||
collections = filter_dict_by_key_value(self.config['resources'],
|
||||
'type', 'collection')
|
||||
@@ -2012,19 +2022,21 @@ class API:
|
||||
LOGGER.debug('Loading provider')
|
||||
|
||||
try:
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
collections[dataset]['providers'], 'feature'))
|
||||
provider_def = get_provider_by_type(
|
||||
collections[dataset]['providers'], 'feature')
|
||||
except ProviderTypeError:
|
||||
try:
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
collections[dataset]['providers'], 'record'))
|
||||
provider_def = get_provider_by_type(
|
||||
collections[dataset]['providers'], 'record')
|
||||
except ProviderTypeError:
|
||||
msg = 'Invalid provider type'
|
||||
return self.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'NoApplicableCode', msg)
|
||||
|
||||
try:
|
||||
p = load_plugin('provider', provider_def)
|
||||
except ProviderGenericError as err:
|
||||
LOGGER.error(err)
|
||||
return self.get_exception(
|
||||
err.http_status_code, headers, request.format,
|
||||
err.ogc_exception_code, err.message)
|
||||
@@ -2086,6 +2098,8 @@ class API:
|
||||
else:
|
||||
skip_geometry = False
|
||||
|
||||
LOGGER.debug('Processing filter-crs parameter')
|
||||
filter_crs = request.params.get('filter-crs', DEFAULT_CRS)
|
||||
LOGGER.debug('Processing filter-lang parameter')
|
||||
filter_lang = request.params.get('filter-lang')
|
||||
if filter_lang != 'cql-json': # @TODO add check from the configuration
|
||||
@@ -2105,6 +2119,7 @@ class API:
|
||||
LOGGER.debug(f'skipGeometry: {skip_geometry}')
|
||||
LOGGER.debug(f'q: {q}')
|
||||
LOGGER.debug(f'filter-lang: {filter_lang}')
|
||||
LOGGER.debug(f'filter-crs: {filter_crs}')
|
||||
|
||||
LOGGER.debug('Processing headers')
|
||||
|
||||
@@ -2142,6 +2157,12 @@ class API:
|
||||
LOGGER.debug('processing PostgreSQL CQL_JSON data')
|
||||
try:
|
||||
filter_ = parse_cql_json(data)
|
||||
filter_ = modify_pygeofilter(
|
||||
filter_,
|
||||
filter_crs_uri=filter_crs,
|
||||
storage_crs_uri=provider_def.get('storage_crs'),
|
||||
geometry_column_name=provider_def.get('geom_field')
|
||||
)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
msg = f'Bad CQL string : {data}'
|
||||
|
||||
@@ -55,7 +55,6 @@ from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is nee
|
||||
from geoalchemy2.functions import ST_MakeEnvelope
|
||||
from geoalchemy2.shape import to_shape
|
||||
from pygeofilter.backends.sqlalchemy.evaluate import to_filter
|
||||
import pygeofilter.ast
|
||||
import pyproj
|
||||
import shapely
|
||||
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
|
||||
@@ -139,8 +138,7 @@ class PostgreSQLProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Preparing filters')
|
||||
property_filters = self._get_property_filters(properties)
|
||||
modified_filterq = self._modify_pygeofilter(filterq)
|
||||
cql_filters = self._get_cql_filters(modified_filterq)
|
||||
cql_filters = self._get_cql_filters(filterq)
|
||||
bbox_filter = self._get_bbox_filter(bbox)
|
||||
order_by_clauses = self._get_order_by_clauses(sortby, self.table_model)
|
||||
selected_properties = self._select_properties_clause(select_properties,
|
||||
@@ -497,40 +495,3 @@ class PostgreSQLProvider(BaseProvider):
|
||||
else:
|
||||
crs_transform = None
|
||||
return crs_transform
|
||||
|
||||
def _modify_pygeofilter(
|
||||
self,
|
||||
ast_tree: pygeofilter.ast.Node,
|
||||
) -> pygeofilter.ast.Node:
|
||||
"""
|
||||
Prepare the input pygeofilter for querying the database.
|
||||
|
||||
Returns a new ``pygeofilter.ast.Node`` object that can be used for
|
||||
querying the database.
|
||||
"""
|
||||
new_tree = deepcopy(ast_tree)
|
||||
_inplace_replace_geometry_filter_name(new_tree, self.geom)
|
||||
return new_tree
|
||||
|
||||
|
||||
def _inplace_replace_geometry_filter_name(
|
||||
node: pygeofilter.ast.Node,
|
||||
geometry_column_name: str
|
||||
):
|
||||
"""Recursively traverse node tree and rename nodes of type ``Attribute``.
|
||||
|
||||
Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of
|
||||
the ``geometry_column_name`` parameter.
|
||||
"""
|
||||
try:
|
||||
sub_nodes = node.get_sub_nodes()
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
for sub_node in sub_nodes:
|
||||
is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute)
|
||||
if is_attribute_node and sub_node.name == "geometry":
|
||||
sub_node.name = geometry_column_name
|
||||
else:
|
||||
_inplace_replace_geometry_filter_name(
|
||||
sub_node, geometry_column_name)
|
||||
|
||||
+148
-29
@@ -30,6 +30,7 @@
|
||||
"""Generic util functions used in the code"""
|
||||
|
||||
import base64
|
||||
from copy import deepcopy
|
||||
from filelock import FileLock
|
||||
import json
|
||||
import logging
|
||||
@@ -44,7 +45,7 @@ from decimal import Decimal
|
||||
from enum import Enum
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
from typing import Any, IO, Union, List, Callable
|
||||
from typing import Any, IO, Union, List, Optional, Callable
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
@@ -66,6 +67,8 @@ from shapely.geometry import (
|
||||
import yaml
|
||||
from babel.support import Translations
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
import pygeofilter.ast
|
||||
import pygeofilter.values
|
||||
import pyproj
|
||||
from pyproj.exceptions import CRSError
|
||||
from requests import Session
|
||||
@@ -84,21 +87,6 @@ DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||
THISDIR = Path(__file__).parent.resolve()
|
||||
TEMPLATES = THISDIR / 'templates'
|
||||
|
||||
CRS_AUTHORITY = [
|
||||
"AUTO",
|
||||
"EPSG",
|
||||
"OGC",
|
||||
]
|
||||
|
||||
# Global to compile only once
|
||||
CRS_URI_PATTERN = re.compile(
|
||||
(
|
||||
rf"^http://www.opengis\.net/def/crs/"
|
||||
rf"(?P<auth>{'|'.join(CRS_AUTHORITY)})/"
|
||||
rf"[\d|\.]+?/(?P<code>\w+?)$"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Type for Shapely geometrical objects.
|
||||
GeomObject = Union[
|
||||
@@ -686,7 +674,9 @@ def get_crs_from_uri(uri: str) -> pyproj.CRS:
|
||||
Author: @MTachon
|
||||
|
||||
:param uri: Uniform resource identifier of the coordinate
|
||||
reference system.
|
||||
reference system. In accordance with
|
||||
https://docs.ogc.org/pol/09-048r5.html#_naming_rule URIs can
|
||||
take either the form of a URL or a URN
|
||||
:type uri: str
|
||||
|
||||
:raises `CRSError`: Error raised if no CRS could be identified from the
|
||||
@@ -696,23 +686,26 @@ def get_crs_from_uri(uri: str) -> pyproj.CRS:
|
||||
:rtype: `pyproj.CRS`
|
||||
"""
|
||||
|
||||
# normalize the input `uri` to a URL first
|
||||
url = uri.replace(
|
||||
"urn:ogc:def:crs",
|
||||
"http://www.opengis.net/def/crs"
|
||||
).replace(":", "/")
|
||||
try:
|
||||
crs = pyproj.CRS.from_authority(*CRS_URI_PATTERN.search(uri).groups())
|
||||
except CRSError:
|
||||
authority, code = url.rsplit("/", maxsplit=3)[1::2]
|
||||
crs = pyproj.CRS.from_authority(authority, code)
|
||||
except ValueError:
|
||||
msg = (
|
||||
f"CRS could not be identified from URI {uri!r} "
|
||||
f"(Authority: {CRS_URI_PATTERN.search(uri).group('auth')!r}, "
|
||||
f"Code: {CRS_URI_PATTERN.search(uri).group('code')!r})."
|
||||
f"CRS could not be identified from URI {uri!r}. CRS URIs must "
|
||||
"follow one of two formats: "
|
||||
"'http://www.opengis.net/def/crs/{authority}/{version}/{code}' or "
|
||||
"'urn:ogc:def:crs:{authority}:{version}:{code}' "
|
||||
"(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa
|
||||
)
|
||||
LOGGER.error(msg)
|
||||
raise CRSError(msg)
|
||||
except AttributeError:
|
||||
msg = (
|
||||
f"CRS could not be identified from URI {uri!r}. CRS URIs must "
|
||||
"follow the format "
|
||||
"'http://www.opengis.net/def/crs/{authority}/{version}/{code}' "
|
||||
"(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa
|
||||
)
|
||||
except CRSError:
|
||||
msg = f"CRS could not be identified from URI {uri!r}"
|
||||
LOGGER.error(msg)
|
||||
raise CRSError(msg)
|
||||
else:
|
||||
@@ -886,3 +879,129 @@ def bbox2geojsongeometry(bbox: list) -> dict:
|
||||
|
||||
b = box(*bbox, ccw=False)
|
||||
return geom_to_geojson(b)
|
||||
|
||||
|
||||
def modify_pygeofilter(
|
||||
ast_tree: pygeofilter.ast.Node,
|
||||
*,
|
||||
filter_crs_uri: str,
|
||||
storage_crs_uri: Optional[str] = None,
|
||||
geometry_column_name: Optional[str] = None
|
||||
) -> pygeofilter.ast.Node:
|
||||
"""
|
||||
Modifies the input pygeofilter with information from the provider.
|
||||
|
||||
:param ast_tree: `pygeofilter.ast.Node` representing the
|
||||
already parsed pygeofilter expression
|
||||
:param filter_crs_uri: URI of the CRS being used in the filtering
|
||||
expression
|
||||
:param storage_crs_uri: An optional string containing the URI of
|
||||
the provider's storage CRS
|
||||
:param geometry_column_name: An optional string containing the
|
||||
actual name of the provider's geometry field
|
||||
:returns: A new pygeofilter.ast.Node, with the modified filter
|
||||
expression
|
||||
|
||||
This function modifies the parsed pygeofilter that contains the raw
|
||||
filter expression provided by an external client. It performs the
|
||||
following modifications:
|
||||
|
||||
- if the filter includes any spatial coordinates and they are being
|
||||
provided in a different CRS from the provider's storage CRS, the
|
||||
corresponding geometries are transformed into the storage CRS
|
||||
|
||||
- if the filter includes the generic 'geometry' name as a reference to
|
||||
the actual geometry of features, it is replaced by the actual name
|
||||
of the geometry field, as specified by the provider
|
||||
|
||||
"""
|
||||
new_tree = deepcopy(ast_tree)
|
||||
if storage_crs_uri:
|
||||
storage_crs = get_crs_from_uri(storage_crs_uri)
|
||||
filter_crs = get_crs_from_uri(filter_crs_uri)
|
||||
_inplace_transform_filter_geometries(new_tree, filter_crs, storage_crs)
|
||||
if geometry_column_name:
|
||||
_inplace_replace_geometry_filter_name(new_tree, geometry_column_name)
|
||||
return new_tree
|
||||
|
||||
|
||||
def _inplace_transform_filter_geometries(
|
||||
node: pygeofilter.ast.Node,
|
||||
filter_crs: pyproj.CRS,
|
||||
storage_crs: pyproj.CRS
|
||||
):
|
||||
"""
|
||||
Recursively traverse node tree and convert coordinates to the storage CRS.
|
||||
|
||||
This function modifies nodes in the already-parsed filter in order to find
|
||||
any geometry literals that may be used in the filter and, if necessary,
|
||||
proceeds to convert spatial coordinates to the CRS used by the provider.
|
||||
"""
|
||||
try:
|
||||
sub_nodes = node.get_sub_nodes()
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
for sub_node in sub_nodes:
|
||||
is_geometry_node = isinstance(
|
||||
sub_node, pygeofilter.values.Geometry)
|
||||
if is_geometry_node:
|
||||
# NOTE1: To be flexible, and since pygeofilter
|
||||
# already supports it, in addition to supporting
|
||||
# the `filter-crs` parameter, we also support having a
|
||||
# geometry defined in EWKT, meaning the CRS is provided
|
||||
# inline, like this `SRID=<CRS_CODE>;<WKT>` - If provided,
|
||||
# this overrides the value of `filter-crs`. This enables
|
||||
# supporting, for example, an exotic filter expression with
|
||||
# multiple geometries specified in different CRSs
|
||||
|
||||
# NOTE2: We specify a default CRS using a URI of type URN
|
||||
# because this is what pygeofilter uses internally too
|
||||
|
||||
crs_urn_provided_in_ewkt = sub_node.geometry.get(
|
||||
'crs', {}).get('properties', {}).get('name')
|
||||
if crs_urn_provided_in_ewkt is not None:
|
||||
crs = get_crs_from_uri(crs_urn_provided_in_ewkt)
|
||||
else:
|
||||
crs = filter_crs
|
||||
if crs != storage_crs:
|
||||
# convert geometry coordinates to storage crs
|
||||
geom = geojson_to_geom(sub_node.geometry)
|
||||
coord_transformer = pyproj.Transformer.from_crs(
|
||||
crs_from=crs, crs_to=storage_crs).transform
|
||||
transformed_geom = ops.transform(coord_transformer, geom)
|
||||
sub_node.geometry = geom_to_geojson(transformed_geom)
|
||||
# ensure the crs is encoded in the sub-node, otherwise
|
||||
# pygeofilter will assign it its own default CRS
|
||||
authority, code = storage_crs.to_authority()
|
||||
sub_node.geometry['crs'] = {
|
||||
'properties': {
|
||||
'name': f'urn:ogc:def:crs:{authority}::{code}'
|
||||
}
|
||||
}
|
||||
else:
|
||||
_inplace_transform_filter_geometries(
|
||||
sub_node, filter_crs, storage_crs)
|
||||
|
||||
|
||||
def _inplace_replace_geometry_filter_name(
|
||||
node: pygeofilter.ast.Node,
|
||||
geometry_column_name: str
|
||||
):
|
||||
"""Recursively traverse node tree and rename nodes of type ``Attribute``.
|
||||
|
||||
Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of
|
||||
the ``geometry_column_name`` parameter.
|
||||
"""
|
||||
try:
|
||||
sub_nodes = node.get_sub_nodes()
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
for sub_node in sub_nodes:
|
||||
is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute)
|
||||
if is_attribute_node and sub_node.name == "geometry":
|
||||
sub_node.name = geometry_column_name
|
||||
else:
|
||||
_inplace_replace_geometry_filter_name(
|
||||
sub_node, geometry_column_name)
|
||||
|
||||
@@ -44,9 +44,7 @@ import pytest
|
||||
import pyproj
|
||||
from http import HTTPStatus
|
||||
|
||||
import pygeofilter.ast
|
||||
from pygeofilter.parsers.ecql import parse
|
||||
from pygeofilter.values import Geometry
|
||||
|
||||
from pygeoapi.api import API
|
||||
|
||||
@@ -750,69 +748,3 @@ 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize('original_filter, expected', [
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, POINT(1 1))",
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
id='unnested-geometry'
|
||||
),
|
||||
pytest.param(
|
||||
"some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))",
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
),
|
||||
id='nested-geometry'
|
||||
),
|
||||
pytest.param(
|
||||
"(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR "
|
||||
"DWITHIN(geometry, POINT(2 2), 10, meters)",
|
||||
pygeofilter.ast.Or(
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
),
|
||||
pygeofilter.ast.DistanceWithin(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2, 2)}),
|
||||
distance=10,
|
||||
units='meters',
|
||||
)
|
||||
),
|
||||
id='complex-filter'
|
||||
),
|
||||
])
|
||||
def test_modify_pygeofilter(original_filter, expected):
|
||||
|
||||
class _CustomPostgreSqlProvider(PostgreSQLProvider):
|
||||
"""This is a subclass of the original PostgreSQLProvider.
|
||||
|
||||
The current test is only interested in verifying the correctness of
|
||||
the logic that modifies the parsed filter. As such, in order
|
||||
to simplify instantiating the postgresql pygeoapi provider, and
|
||||
in order to avoid dealing with mocking out the sqlalchemy table
|
||||
reflection mechanism, this class overrides the __init__() method
|
||||
and can be used to test the implementation of the base class'
|
||||
`self._modify_pygeofilter()` method, which is really all we want
|
||||
to test here.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.geom = 'custom_geom_name'
|
||||
|
||||
provider = _CustomPostgreSqlProvider()
|
||||
parsed_filter = parse(original_filter)
|
||||
result = provider._modify_pygeofilter(parsed_filter)
|
||||
assert result == expected
|
||||
|
||||
+200
-13
@@ -29,10 +29,14 @@
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from decimal import Decimal
|
||||
from contextlib import nullcontext as does_not_raise
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from pyproj.exceptions import CRSError
|
||||
import pygeofilter.ast
|
||||
from pygeofilter.parsers.ecql import parse
|
||||
from pygeofilter.values import Geometry
|
||||
from shapely.geometry import Point
|
||||
|
||||
from pygeoapi import util
|
||||
@@ -267,19 +271,28 @@ def test_get_supported_crs_list():
|
||||
assert DUTCH_CRS in crs_list
|
||||
|
||||
|
||||
def test_get_crs_from_uri():
|
||||
with pytest.raises(CRSError):
|
||||
util.get_crs_from_uri('http://www.opengis.net/not/a/valid/crs/uri')
|
||||
with pytest.raises(CRSError):
|
||||
util.get_crs_from_uri('http://www.opengis.net/def/crs/EPSG/0/0')
|
||||
CRS_DICT = {
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'OGC:CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/28992': 'EPSG:28992'
|
||||
}
|
||||
for key in CRS_DICT:
|
||||
crs_obj = util.get_crs_from_uri(key)
|
||||
assert crs_obj.srs == CRS_DICT[key]
|
||||
@pytest.mark.parametrize('uri, expected_raise, expected', [
|
||||
pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa
|
||||
pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa
|
||||
pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa
|
||||
pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa
|
||||
pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa
|
||||
pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa
|
||||
pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None),
|
||||
pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None),
|
||||
pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None),
|
||||
pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None),
|
||||
pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), "OGC:CRS84"),
|
||||
pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), "OGC:CRS84"),
|
||||
pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), "EPSG:4326"),
|
||||
pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), "EPSG:4326"),
|
||||
pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"),
|
||||
pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa
|
||||
])
|
||||
def test_get_crs_from_uri(uri, expected_raise, expected):
|
||||
with expected_raise:
|
||||
crs = util.get_crs_from_uri(uri)
|
||||
assert crs.srs.upper() == expected
|
||||
|
||||
|
||||
def test_transform_bbox():
|
||||
@@ -316,3 +329,177 @@ def test_prefetcher():
|
||||
headers = prefetcher.get_headers(headers['location'])
|
||||
assert int(headers.get('content-length', 0)) == length
|
||||
assert headers.get('content-type') == 'image/png'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa
|
||||
pytest.param(
|
||||
'INTERSECTS(geometry, POINT(1 1))',
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
None,
|
||||
None,
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
id='passthrough'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, POINT(1 1))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
None,
|
||||
'custom_geom_name',
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
id='unnested-geometry-name'
|
||||
),
|
||||
pytest.param(
|
||||
"some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
None,
|
||||
'custom_geom_name',
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
),
|
||||
id='nested-geometry-name'
|
||||
),
|
||||
pytest.param(
|
||||
"(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR "
|
||||
"DWITHIN(geometry, POINT(2 2), 10, meters)",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
None,
|
||||
'custom_geom_name',
|
||||
pygeofilter.ast.Or(
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (1, 1)})
|
||||
),
|
||||
),
|
||||
pygeofilter.ast.DistanceWithin(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2, 2)}),
|
||||
distance=10,
|
||||
units='meters',
|
||||
)
|
||||
),
|
||||
id='complex-filter-name'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, POINT(12.512829 41.896698))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa
|
||||
),
|
||||
id='unnested-geometry-transformed-coords'
|
||||
),
|
||||
pytest.param(
|
||||
"some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))", # noqa
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa
|
||||
),
|
||||
),
|
||||
id='nested-geometry-transformed-coords'
|
||||
),
|
||||
pytest.param(
|
||||
"(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR " # noqa
|
||||
"DWITHIN(geometry, POINT(12 41), 10, meters)",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.Or(
|
||||
pygeofilter.ast.And(
|
||||
pygeofilter.ast.Equal(
|
||||
pygeofilter.ast.Attribute(name='some_attribute'), 10),
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa
|
||||
),
|
||||
),
|
||||
pygeofilter.ast.DistanceWithin(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2267681.8892602, 4543101.513292163)}), # noqa
|
||||
distance=10,
|
||||
units='meters',
|
||||
)
|
||||
),
|
||||
id='complex-filter-transformed-coords'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313681.8086284213, 4641307.939955416)}) # noqa
|
||||
),
|
||||
id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, POINT(1392921 5145517))",
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3857',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313681.8086284213, 4641307.939955416)}) # noqa
|
||||
),
|
||||
id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
None,
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='geometry'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313681.8086284213, 4641307.939955416)}) # noqa
|
||||
),
|
||||
id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs'
|
||||
),
|
||||
pytest.param(
|
||||
"INTERSECTS(geometry, POINT(12.512829 41.896698))",
|
||||
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'http://www.opengis.net/def/crs/EPSG/0/3004',
|
||||
'custom_geom_name',
|
||||
pygeofilter.ast.GeometryIntersects(
|
||||
pygeofilter.ast.Attribute(name='custom_geom_name'),
|
||||
Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa
|
||||
),
|
||||
id='unnested-geometry-name-and-transformed-coords'
|
||||
),
|
||||
])
|
||||
def test_modify_pygeofilter(
|
||||
original_filter,
|
||||
filter_crs,
|
||||
storage_crs,
|
||||
geometry_colum_name,
|
||||
expected
|
||||
):
|
||||
parsed_filter = parse(original_filter)
|
||||
result = util.modify_pygeofilter(
|
||||
parsed_filter,
|
||||
filter_crs_uri=filter_crs,
|
||||
storage_crs_uri=storage_crs,
|
||||
geometry_column_name=geometry_colum_name
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
Reference in New Issue
Block a user