diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e60406e78..072448b204 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -130,6 +130,7 @@ jobs: pip3 install -r requirements-pubsub.txt pip3 install . pip3 install GDAL==`gdal-config --version` + pip3 install --force-reinstall https://github.com/geopython/pygeofilter/archive/main.zip - name: setup test data ⚙️ run: | pybabel compile -d locale -l es diff --git a/docs/source/cql.rst b/docs/source/cql2.rst similarity index 70% rename from docs/source/cql.rst rename to docs/source/cql2.rst index 66a6d63649..f1ceabdb53 100644 --- a/docs/source/cql.rst +++ b/docs/source/cql2.rst @@ -1,9 +1,9 @@ -.. _cql: +.. _cql2: -CQL support -=========== +CQL2 support +============ -OGC Common Query Language (`CQL2`_) is a generic language designed to provide enhanced query and subset/filtering to (primarily) feature and record data. +`OGC Common Query Language`_ (CQL2) is a generic language designed to provide enhanced query and subset/filtering to (primarily) feature and record data. Providers --------- @@ -14,7 +14,7 @@ for current provider support. Limitations ----------- -Support of CQL is limited to `Basic CQL2 `_ and thus it allows to query with the +Support is limited to `Basic CQL2 `_ and thus it allows to query with the following predicates: * comparison predicates @@ -24,9 +24,9 @@ following predicates: Formats ------- -Supported providers leverage the CQL2 dialect with the JSON encoding `CQL-JSON `_. +Supported providers leverage the CQL2 dialect with the JSON encoding `CQL JSON `_. -PostgreSQL supports both `CQL2 JSON `_ and `CQL text `_ dialects. +PostgreSQL supports both `CQL JSON `_ and `CQL Text `_ dialects. Queries ^^^^^^^ @@ -83,7 +83,7 @@ Or ] }' -The same ``BETWEEN`` query using HTTP GET request formatted as CQL text and URL encoded as below: +The same ``BETWEEN`` query using HTTP GET request formatted as CQL2 text and URL encoded as below: .. code-block:: bash @@ -103,25 +103,10 @@ An ``EQUALS`` example for a specific property: ] }' -A ``CROSSES`` example via an HTTP GET request. The CQL text is passed via the ``filter`` parameter. +A ``S_CROSSES`` example via an HTTP GET request. The CQL2 text is passed via the ``filter`` parameter. .. code-block:: bash - curl "http://localhost:5000/collections/hot_osm_waterways/items?f=json&filter=CROSSES(foo_geom,%20LINESTRING(28%20-2,%2030%20-4))" + curl "http://localhost:5000/collections/hot_osm_waterways/items?f=json&filter=S_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))``. - -.. _`CQL2`: https://docs.ogc.org/is/21-065r2/21-065r2.html +.. _`OGC Common Query Language`: https://docs.ogc.org/is/21-065r2/21-065r2.html diff --git a/docs/source/index.rst b/docs/source/index.rst index 4579a27b31..1430b06dca 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,7 +45,7 @@ reference documentation on all aspects of the project. plugins html-templating crs - cql + cql2 language development ogc-compliance diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 162f1921f9..ef6b06d0aa 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -115,7 +115,7 @@ To publish an Elasticsearch index, the following are required in your index: The ES provider also has the support for the CQL queries as indicated in the table above. .. seealso:: - :ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. + :ref:`cql2` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. .. _ERDDAP Tabledap Service: @@ -292,7 +292,7 @@ These are optional and if not specified, the default from the engine will be use This provider has support for the CQL queries as indicated in the Provider table above. .. seealso:: - :ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. + :ref:`cql2` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. OGR @@ -432,7 +432,7 @@ To publish an OpenSearch index, the following are required in your index: The OpenSearch provider also has the support for the CQL queries as indicated in the table above. .. seealso:: - :ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. + :ref:`cql2` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. .. _Oracle: @@ -730,7 +730,7 @@ block contains the necessary socket connection information. This provider has support for the CQL queries as indicated in the Provider table above. .. seealso:: - :ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. + :ref:`cql2` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. SQLiteGPKG ^^^^^^^^^^ diff --git a/docs/source/publishing/ogcapi-records.rst b/docs/source/publishing/ogcapi-records.rst index fda95bdbd9..f21533de70 100644 --- a/docs/source/publishing/ogcapi-records.rst +++ b/docs/source/publishing/ogcapi-records.rst @@ -54,7 +54,7 @@ To publish an Elasticsearch index, the following are required in your index: The ES provider also has the support for the CQL queries as indicated in the table above. .. seealso:: - :ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. + :ref:`cql2` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries. TinyDBCatalogue ^^^^^^^^^^^^^^^ diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 27f1c58c91..0329b87f02 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -43,7 +43,7 @@ from typing import Any, Tuple, Union import urllib.parse -from pygeofilter.parsers.ecql import parse as parse_ecql_text +from pygeofilter.parsers.cql2_text import parse as parse_cql2_text from pygeofilter.parsers.cql2_json import parse as parse_cql2_json from pyproj.exceptions import CRSError @@ -84,7 +84,10 @@ 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables-query-parameters', # noqa 'http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete', # noqa 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/schemas', - 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/core-roles-features' # noqa + 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/core-roles-features', # noqa + 'http://www.opengis.net/spec/cql2/1.0/conf/cql2-text', + 'http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2' + ] CONFORMANCE_CLASSES_RECORDS = [ @@ -488,7 +491,7 @@ def get_collection_items( if cql_text is not None: try: - filter_ = parse_ecql_text(cql_text) + filter_ = parse_cql2_text(cql_text) filter_ = modify_pygeofilter( filter_, filter_crs_uri=filter_crs_uri, @@ -522,8 +525,8 @@ def get_collection_items( LOGGER.debug('Processing filter-lang parameter') filter_lang = request.params.get('filter-lang') - # Currently only cql-text is handled, but it is optional - if filter_lang not in [None, 'cql-json', 'cql-text']: + filter_langs = [None, 'cql-json', 'cql-text', 'cql2-text', 'cql2-json'] + if filter_lang not in filter_langs: msg = 'Invalid filter language' return api.get_exception( HTTPStatus.BAD_REQUEST, headers, request.format, diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 2c3752d596..8700a5f7f1 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -66,12 +66,12 @@ def write(self, options: dict = {}, data: dict = None) -> str: :returns: string representation of format """ - type = data.get('type', '') - LOGGER.debug(f'Formatting CSV from data type: {type}') + type_ = data.get('type', '') + LOGGER.debug(f'Formatting CSV from data type: {type_}') - if 'Feature' in type or 'features' in data: + if 'Feature' in type_ or 'features' in data: return self._write_from_geojson(options, data) - elif 'Coverage' in type or 'coverages' in data: + elif 'Coverage' in type_ or 'coverages' in data: return self._write_from_covjson(options, data) def _write_from_geojson( diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 26df3d5784..e0dc1e547a 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -589,7 +589,7 @@ def test_conformance(config, api_): assert isinstance(root, dict) assert 'conformsTo' in root - assert len(root['conformsTo']) == 42 + assert len(root['conformsTo']) == 44 assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \ in root['conformsTo'] diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py index 70ec0a46b2..b84a01ea94 100644 --- a/tests/other/test_crs.py +++ b/tests/other/test_crs.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -33,7 +33,7 @@ import pytest from pyproj.exceptions import CRSError import pygeofilter.ast -from pygeofilter.parsers.ecql import parse +from pygeofilter.parsers.cql2_text import parse from pygeofilter.values import Geometry from shapely.geometry import Point @@ -201,7 +201,7 @@ def test_transform_bbox(): @pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa pytest.param( - 'INTERSECTS(geometry, POINT(1 1))', + 'S_INTERSECTS(geometry, POINT(1 1))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, None, @@ -212,7 +212,7 @@ def test_transform_bbox(): id='passthrough' ), pytest.param( - 'INTERSECTS(geometry, POINT(1 1))', + 'S_INTERSECTS(geometry, POINT(1 1))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, 'custom_geom_name', @@ -223,7 +223,7 @@ def test_transform_bbox(): id='unnested-geometry-name' ), pytest.param( - 'some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))', + 'some_attribute = 10 AND S_INTERSECTS(geometry, POINT(1 1))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, 'custom_geom_name', @@ -238,31 +238,7 @@ def test_transform_bbox(): 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))', + 'S_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, @@ -273,7 +249,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords' ), pytest.param( - 'some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))', # noqa + 'some_attribute = 10 AND S_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, @@ -288,31 +264,7 @@ def test_transform_bbox(): 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))', + 'S_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, @@ -323,7 +275,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' ), pytest.param( - 'INTERSECTS(geometry, POINT(1392921 5145517))', + 'S_INTERSECTS(geometry, POINT(1392921 5145517))', 'http://www.opengis.net/def/crs/EPSG/0/3857', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -334,7 +286,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' ), pytest.param( - 'INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))', + 'S_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, @@ -345,7 +297,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' ), pytest.param( - 'INTERSECTS(geometry, POINT(12.512829 41.896698))', + 'S_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', diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index b725999058..06c88ce1d8 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -57,7 +57,7 @@ import pyproj from shapely.geometry import shape as geojson_to_geom -from pygeofilter.parsers.ecql import parse +from pygeofilter.parsers.cql2_text import parse from pygeoapi.api import API from pygeoapi.api.itemtypes import ( @@ -410,7 +410,7 @@ def test_get_not_existing_item_raise_exception(config): 80835475, 80835478, 80835483, 80835486]), ("osm_id BETWEEN 80800000 AND 80900000 AND waterway = 'stream'", [80835470]), - ("osm_id BETWEEN 80800000 AND 80900000 AND waterway ILIKE 'sTrEam'", + ("osm_id BETWEEN 80800000 AND 80900000 AND CASEI(waterway) LIKE CASEI('sTrEam')", # noqa [80835470]), ("osm_id BETWEEN 80800000 AND 80900000 AND waterway LIKE 's%'", [80835470]), @@ -421,7 +421,7 @@ def test_get_not_existing_item_raise_exception(config): ("osm_id BETWEEN 80800000 AND 80900000 AND BBOX(foo_geom, 29, -2.8, 29.2, -2.9)", # noqa [80827793, 80835470, 80835472, 80835483, 80835489]), ("osm_id BETWEEN 80800000 AND 80900000 AND " - "CROSSES(foo_geom, LINESTRING(29.091 -2.731, 29.253 -2.845))", + "S_CROSSES(foo_geom, LINESTRING(29.091 -2.731, 29.253 -2.845))", [80835470, 80835472, 80835489]) ]) def test_query_cql(config, cql, expected_ids):