diff --git a/docs/source/cql.rst b/docs/source/cql.rst index e462fc2..4ba1c6c 100644 --- a/docs/source/cql.rst +++ b/docs/source/cql.rst @@ -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))``. diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 846f887..edd7135 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -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}' diff --git a/pygeoapi/provider/postgresql.py b/pygeoapi/provider/postgresql.py index fb5b4ec..47d9b7e 100644 --- a/pygeoapi/provider/postgresql.py +++ b/pygeoapi/provider/postgresql.py @@ -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) diff --git a/pygeoapi/util.py b/pygeoapi/util.py index d4fbabd..6945462 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -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{'|'.join(CRS_AUTHORITY)})/" - rf"[\d|\.]+?/(?P\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=;` - 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) diff --git a/tests/test_postgresql_provider.py b/tests/test_postgresql_provider.py index 1d8512c..650a609 100644 --- a/tests/test_postgresql_provider.py +++ b/tests/test_postgresql_provider.py @@ -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 diff --git a/tests/test_util.py b/tests/test_util.py index 6a758ea..98097f5 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -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