Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert coordinates to storage crs when filtering via cql #1489

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/source/cql.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^

Expand Down Expand Up @@ -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))``.
39 changes: 30 additions & 9 deletions pygeoapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
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)

from pygeoapi.models.provider.base import TilesMetadataFormat

Expand Down Expand Up @@ -1375,7 +1376,7 @@ def get_collection_items(
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')
Expand Down Expand Up @@ -1588,11 +1589,19 @@ def get_collection_items(
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}'
Expand All @@ -1610,7 +1619,6 @@ def get_collection_items(
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)

Expand All @@ -1629,7 +1637,9 @@ def get_collection_items(
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,
Expand Down Expand Up @@ -1799,7 +1809,7 @@ def post_collection_items(
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')
Expand Down Expand Up @@ -1886,19 +1896,21 @@ def post_collection_items(
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)
Expand Down Expand Up @@ -1960,6 +1972,8 @@ def post_collection_items(
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
Expand All @@ -1979,6 +1993,7 @@ def post_collection_items(
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')

Expand Down Expand Up @@ -2016,6 +2031,12 @@ def post_collection_items(
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}'
Expand Down
41 changes: 1 addition & 40 deletions pygeoapi/provider/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
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
Expand Down Expand Up @@ -139,8 +138,7 @@ def query(self, offset=0, limit=10, resulttype='results',

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,
Expand Down Expand Up @@ -497,40 +495,3 @@ def _get_crs_transform(self, crs_transform_spec=None):
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)
Loading
Loading