Add cql-json support for ES (#723)

Fix starlette event loop


Fix starlette event loop


Fix starlette event loop


Fix starlette event loop


Fix provider regression


Make method public


Make method public


Move function to the helpers utility


Add the CQL lifecycle for development


Add CQL docs


Fix flake8


Isolate import for starlette codepath
This commit is contained in:
Francesco Bartoli
2021-07-22 03:00:14 +02:00
committed by GitHub
parent 0f38c764d6
commit bb4cd0bf69
18 changed files with 1431 additions and 35 deletions
+1
View File
@@ -50,6 +50,7 @@ jobs:
- name: Install requirements 📦
run: |
pip3 install -r requirements.txt
pip3 install -r requirements-starlette.txt
pip3 install -r requirements-dev.txt
pip3 install -r requirements-provider.txt
python3 setup.py install
+50
View File
@@ -0,0 +1,50 @@
.. _cql:
CQL support
===========
Limitations
-----------
The support to CQL is limited to `Simple CQL filter <https://portal.ogc.org/files/96288#cql-core>`_ and thus it allows to query with the
following predicates:
- comparison predicates
- spatial predicates
- temporal predicates
Formats
-------
At the moment pygeoapi supports only the CQL dialect with the JSON encoding `CQL-JSON <https://portal.ogc.org/files/96288#simple-cql-JSON>`_.
Providers
---------
As of now the available providers supported for CQL filtering are limited to only :ref:`Elasticsearch <Elasticsearch>`.
Queries
^^^^^^^
The following type of queries are supported right now:
- ``between`` predicate query
- Logical ``and`` query with ``between`` and ``eq`` expression
- Spatial query with ``bbox``
Examples
^^^^^^^^
A ``between`` example for a specific property through an HTTP POST request:
.. code-block:: bash
curl --location --request POST 'http://localhost:5000/collections/nhsl_hazard_threat_all_indicators_s_bc/items?f=json&limit=50&filter-lang=cql-json' \
--header 'Content-Type: application/query-cql-json' \
--data-raw '{
"between": {
"value": { "property": "properties.MHn_Intensity" },
"lower": 0.59,
"upper": 0.60
}
}'
@@ -15,17 +15,17 @@ pygeoapi core feature providers are listed below, along with a matrix of support
parameters.
.. csv-table::
:header: Provider, properties (filters), resulttype, bbox, datetime, sortby, properties (display)
:header: Provider, properties (filters), resulttype, bbox, datetime, sortby, properties (display), CQL
:align: left
CSV,❌,results/hits,❌,❌,❌,✅
Elasticsearch,✅,results/hits,✅,✅,✅,✅
GeoJSON,❌,results/hits,❌,❌,❌,❌
MongoDB,✅,results,✅,✅,✅,❌
OGR,✅,results/hits,✅,❌,❌,❌
PostgreSQL,✅,results/hits,✅,❌,❌,❌
SQLiteGPKG,✅,results/hits,✅,❌,❌,❌
SensorThingsAPI,✅,results/hits,✅,✅,✅,✅
CSV,❌,results/hits,❌,❌,❌,✅,❌
Elasticsearch,✅,results/hits,✅,✅,✅,✅,✅
GeoJSON,❌,results/hits,❌,❌,❌,❌,❌
MongoDB,✅,results,✅,✅,✅,❌,❌
OGR,✅,results/hits,✅,❌,❌,❌,❌
PostgreSQL,✅,results/hits,✅,❌,❌,❌,❌
SQLiteGPKG,✅,results/hits,✅,❌,❌,❌,❌
SensorThingsAPI,✅,results/hits,✅,✅,✅,✅,❌
Below are specific connection examples based on supported providers.
@@ -65,6 +65,7 @@ To publish a GeoJSON file, the file must be a valid GeoJSON FeatureCollection.
data: tests/data/file.json
id_field: id
.. _Elasticsearch:
Elasticsearch
^^^^^^^^^^^^^
@@ -87,6 +88,11 @@ To publish an Elasticsearch index, the following are required in your index:
id_field: geonameid
time_field: datetimefield
This provider has the support for the CQL queries as indicated in the table above.
.. seealso::
:ref:`cql` for more details on how to use the Common Query Language to filter the collection with specific queries.
OGR
^^^
+33
View File
@@ -23,6 +23,39 @@ To run all tests, simply run ``pytest`` in the repository. To run a specific te
run ``pytest tests/test_api.py``, for example.
CQL extension lifecycle
-----------------------
Limitations
^^^^^^^^^^^
This workflow is valid only for the `CQL-JSON` format.
Schema
^^^^^^
The Common Query Language (CQL) is the part 3 of the standard OGC API - Features. This extension has its specification available at
`OGC API - Features - Part 3: Filtering and the Common Query Language (CQL) <https://portal.ogc.org/files/96288>`_ and the schema exists in development at
`cql.json <https://github.com/opengeospatial/ogcapi-features/blob/master/extensions/cql/standard/schema/cql.json>`_.
Model generation
^^^^^^^^^^^^^^^^
pygeoapi uses a class-based python model interface to translate the schema into python objects defined by `pydantic <https://pydantic-docs.helpmanual.io/>`_ models.
The model is generated with the pre-processing of the schema through the utility ``datamodel-codegen``:
.. code-block:: bash
# Generate from local downloaded json schema file
datamodel-codegen --input ~/Download/cql-schema.json --input-file-type jsonschema --output ./pygeoapi/models/cql_update.py --class-name CQLModel
How to merge
^^^^^^^^^^^^
Once the new pydantic models have been generated then the content of the python file ``cql_update.py`` can be used to replace the old classes within the ``cql.py`` file.
Update everything above the function ``get_next_node`` and then verify if the tests for the CQL are still passing, for example ``test_post_cql_json_between_query``
in ``tests/test_elasticsearch__provider.py``.
Working with Spatialite on OSX
------------------------------
+1
View File
@@ -29,6 +29,7 @@ pygeoapi |release| documentation
data-publishing/index
plugins
html-templating
cql
language
development
ogc-compliance
+286 -17
View File
@@ -66,7 +66,7 @@ from pygeoapi.provider.base import (
from pygeoapi.provider.tile import (ProviderTileNotFoundError,
ProviderTileQueryError,
ProviderTilesetIdNotFoundError)
from pygeoapi.models.cql import CQLModel
from pygeoapi.util import (dategetter, DATETIME_FORMAT,
filter_dict_by_key_value, get_provider_by_type,
get_provider_default, get_typed_value, JobStatus,
@@ -219,7 +219,10 @@ class APIRequest:
self._args = self._get_params(request)
# Get path info
self._path_info = request.headers.environ['PATH_INFO'].strip('/')
if hasattr(request, 'scope'):
self._path_info = request.scope['path'].strip('/')
elif hasattr(request.headers, 'environ'):
self._path_info = request.headers.environ['PATH_INFO'].strip('/')
# Extract locale from params or headers
self._raw_locale, self._locale = self._get_locale(request.headers,
@@ -228,6 +231,9 @@ class APIRequest:
# Determine format
self._format = self._get_format(request.headers)
# Get received headers
self._headers = self.get_request_headers(request.headers)
@classmethod
def with_data(cls, request, supported_locales) -> 'APIRequest':
"""
@@ -250,11 +256,17 @@ class APIRequest:
# Set data from Flask request
api_req._data = request.data
elif hasattr(request, 'body'):
# Set data from Starlette request after async coroutine completion
# TODO: this now blocks, but once Flask v2 with async support
# has been implemented, with_data() can become async too
loop = asyncio.get_event_loop()
api_req._data = loop.run_until_complete(request.body())
try:
import nest_asyncio
nest_asyncio.apply()
# Set data from Starlette request after async
# coroutine completion
# TODO: this now blocks, but once Flask v2 with async support
# has been implemented, with_data() can become async too
loop = asyncio.get_event_loop()
api_req._data = loop.run_until_complete(request.body())
except ModuleNotFoundError:
LOGGER.error("Module nest-asyncio not found")
return api_req
@staticmethod
@@ -399,6 +411,17 @@ class APIRequest:
return self._format
@property
def headers(self) -> dict:
"""
Returns the dictionary of the headers from
the request.
:returns: Request headers dictionary
"""
return self._headers
def get_linkrel(self, format_: str) -> str:
"""
Returns the hyperlink relationship (rel) attribute value for
@@ -477,6 +500,19 @@ class APIRequest:
headers['Content-Type'] = FORMAT_TYPES[self._format]
return headers
def get_request_headers(self, headers) -> dict:
"""
Obtains and returns a dictionary with Request object headers.
This method adds the headers of the original request and
makes them available to the API object.
:returns: A header dict
"""
headers_ = {item[0]: item[1] for item in headers.items()}
return headers_
class API:
"""API object"""
@@ -1104,13 +1140,12 @@ class API:
@pre_process
def get_collection_items(
self, request: Union[APIRequest, Any],
dataset, pathinfo=None) -> Tuple[dict, int, str]:
dataset) -> Tuple[dict, int, str]:
"""
Queries collection
:param request: A request object
:param dataset: dataset name
:param pathinfo: path location
:returns: tuple of headers, status code, content
"""
@@ -1393,14 +1428,9 @@ class API:
if request.format == F_HTML: # render
# For constructing proper URIs to items
if pathinfo:
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
pathinfo.strip('/')])
else:
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
request.path_info])
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
request.path_info])
content['items_path'] = path_info
content['dataset_path'] = '/'.join(path_info.split('/')[:-1])
@@ -1451,6 +1481,245 @@ class API:
return headers, 200, to_json(content, self.pretty_print)
@pre_process
def post_collection_items(
self, request: Union[APIRequest, Any],
dataset) -> Tuple[dict, int, str]:
"""
Queries collection or filter an item
:param request: A request object
:param dataset: dataset name
:returns: tuple of headers, status code, content
"""
request_headers = request.headers
if not request.is_valid(PLUGINS['formatter'].keys()):
return self.get_format_exception(request)
# Set Content-Language to system locale until provider locale
# has been determined
headers = request.get_response_headers(SYSTEM_LOCALE)
properties = []
reserved_fieldnames = ['bbox', 'f', 'limit', 'startindex',
'resulttype', 'datetime', 'sortby',
'properties', 'skipGeometry', 'q',
'filter-lang']
collections = filter_dict_by_key_value(self.config['resources'],
'type', 'collection')
if dataset not in collections.keys():
msg = 'Invalid collection'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing query parameters')
LOGGER.debug('Processing startindex parameter')
try:
startindex = int(request.params.get('startindex'))
if startindex < 0:
msg = 'startindex value should be positive or zero'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
except TypeError as err:
LOGGER.warning(err)
startindex = 0
except ValueError:
msg = 'startindex value should be an integer'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing limit parameter')
try:
limit = int(request.params.get('limit'))
# TODO: We should do more validation, against the min and max
# allowed by the server configuration
if limit <= 0:
msg = 'limit value should be strictly positive'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
except TypeError as err:
LOGGER.warning(err)
limit = int(self.config['server']['limit'])
except ValueError:
msg = 'limit value should be an integer'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
resulttype = request.params.get('resulttype') or 'results'
LOGGER.debug('Processing bbox parameter')
bbox = request.params.get('bbox')
if bbox is None:
bbox = []
else:
try:
bbox = validate_bbox(bbox)
except ValueError as err:
msg = str(err)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing datetime parameter')
datetime_ = request.params.get('datetime')
try:
datetime_ = validate_datetime(collections[dataset]['extents'],
datetime_)
except ValueError as err:
msg = str(err)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('processing q parameter')
val = request.params.get('q')
q = None
if val is not None:
q = val
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'feature'))
except ProviderTypeError:
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'record'))
except ProviderTypeError:
msg = 'Invalid provider type'
return self.get_exception(
400, headers, request.format, 'NoApplicableCode', msg)
except ProviderConnectionError:
msg = 'connection error (check logs)'
return self.get_exception(
500, headers, request.format, 'NoApplicableCode', msg)
except ProviderQueryError:
msg = 'query error (check logs)'
return self.get_exception(
500, headers, request.format, 'NoApplicableCode', msg)
LOGGER.debug('processing property parameters')
for k, v in request.params.items():
if k not in reserved_fieldnames and k not in p.fields.keys():
msg = 'unknown query parameter: {}'.format(k)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
elif k not in reserved_fieldnames and k in p.fields.keys():
LOGGER.debug('Add property filter {}={}'.format(k, v))
properties.append((k, v))
LOGGER.debug('processing sort parameter')
val = request.params.get('sortby')
if val is not None:
sortby = []
sorts = val.split(',')
for s in sorts:
prop = s
order = '+'
if s[0] in ['+', '-']:
order = s[0]
prop = s[1:]
if prop not in p.fields.keys():
msg = 'bad sort property'
return self.get_exception(
400, headers, request.format,
'InvalidParameterValue', msg)
sortby.append({'property': prop, 'order': order})
else:
sortby = []
LOGGER.debug('processing properties parameter')
val = request.params.get('properties')
if val is not None:
select_properties = val.split(',')
properties_to_check = set(p.properties) | set(p.fields.keys())
if (len(list(set(select_properties) -
set(properties_to_check))) > 0):
msg = 'unknown properties specified'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
else:
select_properties = []
LOGGER.debug('processing skipGeometry parameter')
val = request.params.get('skipGeometry')
if val is not None:
skip_geometry = str2bool(val)
else:
skip_geometry = False
LOGGER.debug('Processing filter-lang parameter')
filter_lang = request.params.get('filter-lang')
if filter_lang == 'cql-json': # @TODO add check from the configuration
val = filter_lang
else:
msg = 'Invalid filter language'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Querying provider')
LOGGER.debug('startindex: {}'.format(startindex))
LOGGER.debug('limit: {}'.format(limit))
LOGGER.debug('resulttype: {}'.format(resulttype))
LOGGER.debug('sortby: {}'.format(sortby))
LOGGER.debug('bbox: {}'.format(bbox))
LOGGER.debug('datetime: {}'.format(datetime_))
LOGGER.debug('properties: {}'.format(select_properties))
LOGGER.debug('skipGeometry: {}'.format(skip_geometry))
LOGGER.debug('q: {}'.format(q))
LOGGER.debug('filter-lang: {}'.format(filter_lang))
LOGGER.debug('Processing headers')
LOGGER.debug('Processing request content-type header')
if (request_headers.get(
'Content-Type') or request_headers.get(
'content-type')) != 'application/query-cql-json':
msg = ('Invalid body content-type')
return self.get_exception(
400, headers, request.format, 'InvalidHeaderValue', msg)
LOGGER.debug('Processing body')
if not request.data:
msg = 'missing request data'
return self.get_exception(
400, headers, request.format, 'MissingParameterValue', msg)
try:
# Parse bytes data, if applicable
data = request.data.decode()
LOGGER.debug(data)
# @TODO validation function
filter_ = None
if val:
filter_ = CQLModel.parse_raw(data)
content = p.query(startindex=startindex, limit=limit,
resulttype=resulttype, bbox=bbox,
datetime_=datetime_, properties=properties,
sortby=sortby,
select_properties=select_properties,
skip_geometry=skip_geometry,
q=q,
filterq=filter_)
except (UnicodeDecodeError, AttributeError):
pass
return headers, 200, to_json(content, self.pretty_print)
@pre_process
def get_collection_item(self, request: Union[APIRequest, Any],
dataset, identifier) -> Tuple[dict, int, str]:
+11 -4
View File
@@ -175,7 +175,7 @@ def collection_queryables(collection_id=None):
return get_response(api_.get_collection_queryables(request, collection_id))
@BLUEPRINT.route('/collections/<collection_id>/items')
@BLUEPRINT.route('/collections/<collection_id>/items', methods=['GET', 'POST'])
@BLUEPRINT.route('/collections/<collection_id>/items/<item_id>')
def collection_items(collection_id, item_id=None):
"""
@@ -187,9 +187,16 @@ def collection_items(collection_id, item_id=None):
:returns: HTTP response
"""
if item_id is None:
return get_response(api_.get_collection_items(request, collection_id))
return get_response(
api_.get_collection_item(request, collection_id, item_id))
if request.method == 'GET': # list items
return get_response(
api_.get_collection_items(request, collection_id))
elif request.method == 'POST': # filter items
return get_response(
api_.post_collection_items(request, collection_id))
else:
return get_response(
api_.get_collection_item(request, collection_id, item_id))
@BLUEPRINT.route('/collections/<collection_id>/coverage')
+30
View File
@@ -0,0 +1,30 @@
# =================================================================
#
# Authors: Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2021 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
"""Interface module containing the models for the openapi/json schema"""
+527
View File
@@ -0,0 +1,527 @@
# ****************************** -*-
# flake8: noqa
# generated by datamodel-codegen:
# filename: cql-schema.json
# timestamp: 2021-03-13T21:05:20+00:00
# =================================================================
#
# Authors: Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2021 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
from datetime import date, datetime
from enum import Enum
from typing import Any, List, Optional, Union
from pydantic import BaseModel, Field
class CQLModel(BaseModel):
__root__: 'Union[\n ComparisonPredicate,\n SpatialPredicate,\n TemporalPredicate,\n AndExpression\n ]'
class AndExpression(BaseModel):
and_: 'List[ComparisonPredicate]' = Field(..., alias='and')
class NotExpression(BaseModel):
not_: 'List[Any]' = Field(..., alias='not')
class OrExpression(BaseModel):
or_: 'List[Any]' = Field(..., alias='or')
class PropertyRef(BaseModel):
property: 'Optional[str]' = None
class ScalarLiteral(BaseModel):
__root__: 'Union[str, float, bool]'
class Bbox(BaseModel):
__root__: 'List[float]'
class LineStringType(Enum):
LineString = 'LineString'
class LinestringCoordinate(BaseModel):
__root__: 'List[Any]'
class Linestring(BaseModel):
type: 'LineStringType'
coordinates: 'List[LinestringCoordinate]' = Field(...)
bbox: 'Optional[List[float]]' = Field(None)
class MultiLineStringType(Enum):
MultiLineString = 'MultiLineString'
class MultilineStringCoordinate(BaseModel):
__root__: 'List[Any]'
class Multilinestring(BaseModel):
type: 'MultiLineStringType'
coordinates: 'List[List[MultilineStringCoordinate]]'
bbox: 'Optional[List[float]]' = Field(None)
class MultiPointType(Enum):
MultiPoint = 'MultiPoint'
class Multipoint(BaseModel):
type: 'MultiPointType'
coordinates: 'List[List[float]]'
bbox: 'Optional[List[float]]' = Field(None)
class MultiPolygonType(Enum):
MultiPolygon = 'MultiPolygon'
class MultipolygonCoordinateItem(BaseModel):
__root__: 'List[Any]'
class Multipolygon(BaseModel):
type: 'MultiPolygonType'
coordinates: 'List[List[List[MultipolygonCoordinateItem]]]'
bbox: 'Optional[List[float]]' = Field(None)
class PointType(Enum):
Point = 'Point'
class Point(BaseModel):
type: 'PointType'
coordinates: 'List[float]' = Field(...)
bbox: 'Optional[List[float]]' = Field(None)
class PolygonType(Enum):
Polygon = 'Polygon'
class PolygonCoordinatesItem(BaseModel):
__root__: 'List[Any]'
class Polygon(BaseModel):
type: 'PolygonType'
coordinates: 'List[List[PolygonCoordinatesItem]]'
bbox: 'Optional[List[float]]' = Field(None)
class TimeString(BaseModel):
__root__: 'Union[date, datetime]'
class EnvelopeLiteral(BaseModel):
bbox: 'Bbox'
class GeometryLiteral(BaseModel):
__root__: 'Union[\n Point, Linestring, Polygon, Multipoint, Multilinestring, Multipolygon\n ]'
class TypedTimeString(BaseModel):
datetime: 'TimeString'
class PeriodString(BaseModel):
__root__: 'List[Union[TimeString, str]]' = Field(...)
class SpatialLiteral(BaseModel):
__root__: 'Union[GeometryLiteral, EnvelopeLiteral]'
class TemporalLiteral(BaseModel):
__root__: 'Union[TimeString, PeriodString]'
class TypedPeriodString(BaseModel):
datetime: 'PeriodString'
class TypedTemporalLiteral(BaseModel):
__root__: 'Union[TypedTimeString, TypedPeriodString]'
class ArrayPredicate(BaseModel):
__root__: 'Union[\n AequalsExpression,\n AcontainsExpression,\n AcontainedByExpression,\n AoverlapsExpression,\n ]'
class ComparisonPredicate(BaseModel):
__root__: 'Union[\n BinaryComparisonPredicate,\n IsLikePredicate,\n IsBetweenPredicate,\n IsInListPredicate,\n IsNullPredicate,\n ]'
class SpatialPredicate(BaseModel):
__root__: 'Union[\n IntersectsExpression,\n EqualsExpression,\n DisjointExpression,\n TouchesExpression,\n WithinExpression,\n OverlapsExpression,\n CrossesExpression,\n ContainsExpression,\n ]'
class TemporalPredicate(BaseModel):
__root__: 'Union[\n BeforeExpression,\n AfterExpression,\n MeetsExpression,\n MetbyExpression,\n ToverlapsExpression,\n OverlappedbyExpression,\n BeginsExpression,\n BegunbyExpression,\n DuringExpression,\n TcontainsExpression,\n EndsExpression,\n EndedbyExpression,\n TequalsExpression,\n AnyinteractsExpression,\n ]'
class AcontainedByExpression(BaseModel):
acontainedBy: 'ArrayExpression'
class AcontainsExpression(BaseModel):
acontains: 'ArrayExpression'
class AequalsExpression(BaseModel):
aequals: 'ArrayExpression'
class AfterExpression(BaseModel):
after: 'TemporalOperands'
class AnyinteractsExpression(BaseModel):
anyinteracts: 'TemporalOperands'
class AoverlapsExpression(BaseModel):
aoverlaps: 'ArrayExpression'
class BeforeExpression(BaseModel):
before: 'TemporalOperands'
class BeginsExpression(BaseModel):
begins: 'TemporalOperands'
class BegunbyExpression(BaseModel):
begunby: 'TemporalOperands'
class BinaryComparisonPredicate(BaseModel):
__root__: 'Union[\n EqExpression, LtExpression, GtExpression, LteExpression, GteExpression\n ]'
class ContainsExpression(BaseModel):
contains: 'SpatialOperands'
class CrossesExpression(BaseModel):
crosses: 'SpatialOperands'
class DisjointExpression(BaseModel):
disjoint: 'SpatialOperands'
class DuringExpression(BaseModel):
during: 'TemporalOperands'
class EndedbyExpression(BaseModel):
endedby: 'TemporalOperands'
class EndsExpression(BaseModel):
ends: 'TemporalOperands'
class EqualsExpression(BaseModel):
equals: 'SpatialOperands'
class IntersectsExpression(BaseModel):
intersects: 'SpatialOperands'
class Between(BaseModel):
value: 'ValueExpression'
lower: 'ScalarExpression' = Field(None)
upper: 'ScalarExpression' = Field(None)
class IsBetweenPredicate(BaseModel):
between: 'Between'
class In(BaseModel):
value: 'ValueExpression'
list: 'List[ValueExpression]'
nocase: 'Optional[bool]' = True
class IsInListPredicate(BaseModel):
in_: 'In' = Field(..., alias='in')
class IsLikePredicate(BaseModel):
like: 'ScalarOperands'
wildcard: 'Optional[str]' = '%'
singleChar: 'Optional[str]' = '.'
escapeChar: 'Optional[str]' = '\\'
nocase: 'Optional[bool]' = True
class IsNullPredicate(BaseModel):
isNull: 'ScalarExpression'
class MeetsExpression(BaseModel):
meets: 'TemporalOperands'
class MetbyExpression(BaseModel):
metby: 'TemporalOperands'
class OverlappedbyExpression(BaseModel):
overlappedby: 'TemporalOperands'
class OverlapsExpression(BaseModel):
overlaps: 'SpatialOperands'
class TcontainsExpression(BaseModel):
tcontains: 'TemporalOperands'
class TequalsExpression(BaseModel):
tequals: 'TemporalOperands'
class TouchesExpression(BaseModel):
touches: 'SpatialOperands'
class ToverlapsExpression(BaseModel):
toverlaps: 'TemporalOperands'
class WithinExpression(BaseModel):
within: 'SpatialOperands'
class ArrayExpression(BaseModel):
__root__: 'List[Union[PropertyRef, FunctionRef, ArrayLiteral]]' = Field(
... # , max_items=2, min_items=2
)
class EqExpression(BaseModel):
eq: 'ScalarOperands'
class GtExpression(BaseModel):
gt: 'ScalarOperands'
class GteExpression(BaseModel):
gte: 'ScalarOperands'
class LtExpression(BaseModel):
lt: 'ScalarOperands'
class LteExpression(BaseModel):
lte: 'ScalarOperands'
class ScalarExpression(BaseModel):
__root__: 'Union[ScalarLiteral, PropertyRef,\n FunctionRef, ArithmeticExpression]'
class ScalarOperands(BaseModel):
__root__: 'List[ScalarExpression]' = Field(...)
class SpatialOperands(BaseModel):
__root__: 'List[GeomExpression]' = Field(...)
class TemporalOperands(BaseModel):
__root__: 'List[TemporalExpression]' = Field(...)
# , max_items=2, min_items=2)
class ValueExpression(BaseModel):
__root__: 'Union[ScalarExpression, SpatialLiteral, TypedTemporalLiteral]'
class ArithmeticExpression(BaseModel):
__root__: 'Union[AddExpression, SubExpression, MulExpression, DivExpression]'
class ArrayLiteral(BaseModel):
__root__: 'List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]'
class FunctionRef(BaseModel):
function: 'Function'
class GeomExpression(BaseModel):
__root__: 'Union[SpatialLiteral, PropertyRef, FunctionRef]'
class TemporalExpression(BaseModel):
__root__: 'Union[TemporalLiteral, PropertyRef, FunctionRef]'
class AddExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='+')
class DivExpression(BaseModel):
_: 'Optional[ArithmeticOperands]' = Field(None, alias='/')
class Function(BaseModel):
name: 'str'
arguments: 'Optional[\n List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]\n ]' = None
class MulExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='*')
class SubExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='-')
class ArithmeticOperands(BaseModel):
__root__: 'List[\n Union[ArithmeticExpression, PropertyRef, FunctionRef, float]\n ]' = Field(...)
CQLModel.update_forward_refs()
AndExpression.update_forward_refs()
ArrayPredicate.update_forward_refs()
ComparisonPredicate.update_forward_refs()
SpatialPredicate.update_forward_refs()
TemporalPredicate.update_forward_refs()
AcontainedByExpression.update_forward_refs()
AcontainsExpression.update_forward_refs()
AequalsExpression.update_forward_refs()
AfterExpression.update_forward_refs()
AnyinteractsExpression.update_forward_refs()
AoverlapsExpression.update_forward_refs()
BeforeExpression.update_forward_refs()
BeginsExpression.update_forward_refs()
BegunbyExpression.update_forward_refs()
BinaryComparisonPredicate.update_forward_refs()
ContainsExpression.update_forward_refs()
CrossesExpression.update_forward_refs()
DisjointExpression.update_forward_refs()
DuringExpression.update_forward_refs()
EndedbyExpression.update_forward_refs()
EndsExpression.update_forward_refs()
EqualsExpression.update_forward_refs()
IntersectsExpression.update_forward_refs()
Between.update_forward_refs()
In.update_forward_refs()
IsBetweenPredicate.update_forward_refs()
IsLikePredicate.update_forward_refs()
IsNullPredicate.update_forward_refs()
ValueExpression.update_forward_refs()
MeetsExpression.update_forward_refs()
MetbyExpression.update_forward_refs()
OverlappedbyExpression.update_forward_refs()
OverlapsExpression.update_forward_refs()
TcontainsExpression.update_forward_refs()
TequalsExpression.update_forward_refs()
TouchesExpression.update_forward_refs()
ToverlapsExpression.update_forward_refs()
WithinExpression.update_forward_refs()
ArrayExpression.update_forward_refs()
EqExpression.update_forward_refs()
GtExpression.update_forward_refs()
GteExpression.update_forward_refs()
LtExpression.update_forward_refs()
LteExpression.update_forward_refs()
ScalarExpression.update_forward_refs()
ScalarOperands.update_forward_refs()
SpatialOperands.update_forward_refs()
TemporalOperands.update_forward_refs()
ArithmeticExpression.update_forward_refs()
ArrayLiteral.update_forward_refs()
ScalarLiteral.update_forward_refs()
PropertyRef.update_forward_refs()
FunctionRef.update_forward_refs()
AddExpression.update_forward_refs()
DivExpression.update_forward_refs()
MulExpression.update_forward_refs()
SubExpression.update_forward_refs()
def get_next_node(obj):
logical_op = None
if obj.__repr_name__() == 'AndExpression':
next_node = obj.and_
logical_op = 'and'
elif obj.__repr_name__() == 'OrExpression':
next_node = obj.or_
logical_op = 'or'
elif obj.__repr_name__() == 'NotExpression':
next_node = obj.not_
logical_op = 'not'
elif obj.__repr_name__() == 'ComparisonPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'SpatialPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'TemporalPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'IsBetweenPredicate':
next_node = obj.between
elif obj.__repr_name__() == 'Between':
next_node = obj.value
elif obj.__repr_name__() == 'ValueExpression':
next_node = obj.__root__ or obj.lower or obj.upper
elif obj.__repr_name__() == 'ScalarExpression':
next_node = obj.__root__
elif obj.__repr_name__() == 'ScalarLiteral':
next_node = obj.__root__
elif obj.__repr_name__() == 'PropertyRef':
next_node = obj.property
elif obj.__repr_name__() == 'BinaryComparisonPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'EqExpression':
next_node = obj.eq
logical_op = 'eq'
else:
raise ValueError("Object not valid")
return (logical_op, next_node)
+196 -1
View File
@@ -3,6 +3,7 @@
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2021 Tom Kralidis
# Copyright (c) 2021 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -27,6 +28,7 @@
#
# =================================================================
from typing import Dict
from collections import OrderedDict
import json
import logging
@@ -34,10 +36,14 @@ from urllib.parse import urlparse
from elasticsearch import Elasticsearch, exceptions, helpers
from elasticsearch.client.indices import IndicesClient
from elasticsearch_dsl import Search, Q
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
ProviderQueryError,
ProviderItemNotFoundError)
from pygeoapi.models.cql import CQLModel, get_next_node
from pygeoapi.util import get_envelope
LOGGER = logging.getLogger(__name__)
@@ -144,7 +150,8 @@ class ElasticsearchProvider(BaseProvider):
def query(self, startindex=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None, **kwargs):
select_properties=[], skip_geometry=False, q=None,
filterq=None, **kwargs):
"""
query Elasticsearch index
@@ -158,6 +165,7 @@ class ElasticsearchProvider(BaseProvider):
:param select_properties: list of property names
:param skip_geometry: bool of whether to skip geometry (default False)
:param q: full-text search term(s)
:param filterq: filter object
:returns: dict of 0..n GeoJSON features
"""
@@ -291,6 +299,9 @@ class ElasticsearchProvider(BaseProvider):
query['_source'] = {'excludes': ['geometry']}
try:
LOGGER.debug('querying Elasticsearch')
if filterq:
LOGGER.debug('adding cql object: {}'.format(filterq.json()))
query = update_query(input_query=query, cql=filterq)
LOGGER.debug(json.dumps(query, indent=4))
LOGGER.debug('Setting ES paging zero-based')
@@ -495,3 +506,187 @@ class ElasticsearchCatalogueProvider(ElasticsearchProvider):
def __repr__(self):
return '<ElasticsearchCatalogueProvider> {}'.format(self.data)
class ESQueryBuilder:
def __init__(self):
self._operation = None
self.must_value = {}
self.should_value = {}
self.mustnot_value = {}
self.filter_value = {}
def must(self, must_value):
self.must_value = must_value
return self
def should(self, should_value):
self.should_value = should_value
return self
def must_not(self, mustnot_value):
self.mustnot_value = mustnot_value
return self
def filter(self, filter_value):
self.filter_value = filter_value
return self
@property
def operation(self):
return self._operation
@operation.setter
def operation(self, value):
self._operation = value
def build(self):
if self.must_value:
must_clause = self.must_value or {}
if self.should_value:
should_clause = self.should_value or {}
if self.mustnot_value:
mustnot_clause = self.mustnot_value or {}
if self.filter_value:
filter_clause = self.filter_value or {}
else:
filter_clause = {}
# to figure out how to deal with logical operations
# return match_clause & range_clause
clauses = must_clause or should_clause or mustnot_clause
filters = filter_clause
if self.operation == 'and':
res = Q(
'bool',
must=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'or':
res = Q(
'bool',
should=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'not':
res = Q(
'bool',
must_not=[clause for clause in clauses],
filter=[filter for filter in filters])
else:
if filters:
res = Q(
'bool',
must=[clauses],
filter=[filters])
else:
res = Q(
'bool',
must=[clauses])
return res
def _build_query(q, cql):
# this would be handled by the AST with the traverse of CQL model
op, node = get_next_node(cql.__root__)
q.operation = op
if isinstance(node, list):
query_list = []
for elem in node:
op, next_node = get_next_node(elem)
if not getattr(next_node, 'between', 0) == 0:
property = next_node.between.value.__root__.__root__.property
lower = next_node.between.lower.__root__.__root__
upper = next_node.between.upper.__root__.__root__
query_list.append(Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
))
if not getattr(next_node, '__root__', 0) == 0:
scalars = tuple(next_node.__root__.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query_list.append(Q(
{'match': {f'{property}': f'{value}'}}
))
q.must(query_list)
elif not getattr(node, 'between', 0) == 0:
property = node.between.value.__root__.__root__.property
lower = None
if not getattr(node.between.lower,
'__root__', 0) == 0:
lower = node.between.lower.__root__.__root__
upper = None
if not getattr(node.between.upper,
'__root__', 0) == 0:
upper = node.between.upper.__root__.__root__
query = Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
)
q.must(query)
elif not getattr(node, '__root__', 0) == 0:
next_op, next_node = get_next_node(node)
if not getattr(next_node, 'eq', 0) == 0:
scalars = tuple(next_node.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query = Q(
{'match': {f'{property}': f'{value}'}}
)
q.must(query)
elif not getattr(node, 'intersects', 0) == 0:
property = node.intersects.__root__[0].__root__.property
if property == 'geometry':
geom_type = node.intersects.__root__[
1].__root__.__root__.__root__.type
if geom_type.value == 'Polygon':
coordinates = node.intersects.__root__[
1].__root__.__root__.__root__.coordinates
coords_list = [
poly_coords.__root__ for poly_coords in coordinates[0]
]
filter_ = Q(
{
'geo_shape': {
'geometry': {
'shape': {
'type': 'envelope',
'coordinates': get_envelope(
coords_list)
},
'relation': 'intersects'
}
}
}
)
query_all = Q(
{'match_all': {}}
)
q.must(query_all)
q.filter(filter_)
return q.build()
def update_query(input_query: Dict, cql: CQLModel):
s = Search.from_dict(input_query)
query = ESQueryBuilder()
output_query = _build_query(query, cql)
s = s.query(output_query)
LOGGER.debug('Enhanced query: {}'.format(
json.dumps(s.to_dict())
))
return s.to_dict()
+10 -4
View File
@@ -220,8 +220,8 @@ async def get_collection_items_tiles(request: Request, name=None,
request, name, tileMatrixSetId, tile_matrix, tileRow, tileCol))
@app.route('/collections/{collection_id}/items')
@app.route('/collections/{collection_id}/items/')
@app.route('/collections/{collection_id}/items', methods=['GET', 'POST'])
@app.route('/collections/{collection_id}/items/', methods=['GET', 'POST'])
@app.route('/collections/{collection_id}/items/{item_id}')
@app.route('/collections/{collection_id}/items/{item_id}/')
async def collection_items(request: Request, collection_id=None, item_id=None):
@@ -239,8 +239,14 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
if 'item_id' in request.path_params:
item_id = request.path_params['item_id']
if item_id is None:
return get_response(api_.get_collection_items(
request, collection_id, pathinfo=request.scope['path']))
if request.method == 'GET': # list items
return get_response(
api_.get_collection_items(
request, collection_id))
elif request.method == 'POST': # filter items
return get_response(
api_.post_collection_items(
request, collection_id))
else:
return get_response(api_.get_collection_item(
request, collection_id, item_id))
+19
View File
@@ -30,6 +30,7 @@
"""Generic util functions used in the code"""
import base64
from typing import List
from datetime import date, datetime, time
from decimal import Decimal
from enum import Enum
@@ -41,6 +42,7 @@ import os
import re
from urllib.request import urlopen
from urllib.parse import urlparse
from shapely.geometry import Polygon
import dateutil.parser
# from babel.support import Translations
@@ -511,3 +513,20 @@ def url_join(*parts):
"""
return '/'.join([p.strip().strip('/') for p in parts])
def get_envelope(coords_list: List[List[float]]):
"""
helper function to get the envelope for a given coordinates
list through the Shapely API.
:param coords_list: list of coordinates
:returns: list of the envelope's coordinates
"""
coords = [tuple(item) for item in coords_list]
polygon = Polygon(coords)
bounds = polygon.bounds
return [[bounds[0], bounds[3]],
[bounds[2], bounds[1]]]
+3
View File
@@ -1,6 +1,9 @@
# Flask-based CORS setup
flask_cors
# Generate pydantic models from json schema
datamodel-code-generator
# testing
pytest
pytest-cov
+1
View File
@@ -10,3 +10,4 @@ pymongo==3.10.1
scipy
xarray
zarr
elasticsearch-dsl
+1
View File
@@ -1,3 +1,4 @@
aiofiles
starlette
uvicorn
nest-asyncio
+2
View File
@@ -10,3 +10,5 @@ rasterio
shapely
tinydb
unicodecsv
future-annotations
pydantic
File diff suppressed because one or more lines are too long
+170
View File
@@ -3,6 +3,7 @@
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2020 Tom Kralidis
# Copyright (c) 2021 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -31,6 +32,7 @@ import pytest
from pygeoapi.provider.base import ProviderItemNotFoundError
from pygeoapi.provider.elasticsearch_ import ElasticsearchProvider
from pygeoapi.models.cql import CQLModel
@pytest.fixture()
@@ -43,6 +45,105 @@ def config():
}
@pytest.fixture()
def config_cql():
return {
'name': 'Elasticsearch',
'type': 'feature',
'data': 'http://localhost:9200/nhsl_hazard_threat_all_indicators_s_bc', # noqa
'id_field': 'Sauid'
}
@pytest.fixture()
def between():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"lower": 10000,
"upper": 100000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def between_upper():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"upper": 100000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def between_lower():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"lower": 10000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def eq():
eq_ = {
"eq": [
{"property": "properties.featurecla"},
"Admin-0 capital"
]
}
return CQLModel.parse_obj(eq_)
@pytest.fixture()
def _and(eq, between):
and_ = {
"and": [
{
"between": {
"value": {
"property": "properties.pop_max"
},
"lower": 100000,
"upper": 1000000
}
},
{
"eq": [
{"property": "properties.featurecla"},
"Admin-0 capital"
]
}
]
}
return CQLModel.parse_obj(and_)
@pytest.fixture()
def intersects():
intersects = {"intersects": [
{"property": "geometry"},
{
"type": "Polygon",
"coordinates": [
[
[10.497565, 41.520355],
[10.497565, 43.308645],
[15.111823, 43.308645],
[15.111823, 41.520355],
[10.497565, 41.520355]
]
]
}
]}
return CQLModel.parse_obj(intersects)
def test_query(config):
p = ElasticsearchProvider(config)
@@ -121,3 +222,72 @@ def test_get_not_existing_item_raise_exception(config):
p = ElasticsearchProvider(config)
with pytest.raises(ProviderItemNotFoundError):
p.get('404')
def test_post_cql_json_between_query(config, between):
"""Testing cql json query for a between object"""
p = ElasticsearchProvider(config)
results = p.query(limit=100, filterq=between)
assert len(results['features']) == 23
assert results['numberMatched'] == 23
assert results['numberReturned'] == 23
for item in results['features']:
assert 10000 <= item["properties"]["pop_max"] <= 100000
def test_post_cql_json_between_lte_query(config, between_upper):
"""Testing cql json query for a between object"""
p = ElasticsearchProvider(config)
results = p.query(limit=100, filterq=between_upper)
assert len(results['features']) == 28
assert results['numberMatched'] == 28
assert results['numberReturned'] == 28
for item in results['features']:
assert item["properties"]["pop_max"] <= 100000
def test_post_cql_json_between_gte_query(config, between_lower):
"""Testing cql json query for a between object"""
p = ElasticsearchProvider(config)
results = p.query(limit=500, filterq=between_lower)
assert len(results['features']) == 237
assert results['numberMatched'] == 237
assert results['numberReturned'] == 237
for item in results['features']:
assert 10000 <= item["properties"]["pop_max"]
def test_post_cql_json_eq_query(config, eq):
"""Testing cql json query for an eq object"""
p = ElasticsearchProvider(config)
results = p.query(limit=500, filterq=eq)
assert len(results['features']) == 235
def test_post_cql_json_and_query(config, _and):
"""Testing cql json query for an and object"""
p = ElasticsearchProvider(config)
results = p.query(limit=1000, filterq=_and)
assert len(results['features']) == 77
def test_post_cql_json_intersects_query(config, intersects):
"""Testing cql json query for an intersects object"""
p = ElasticsearchProvider(config)
results = p.query(limit=100, filterq=intersects)
assert len(results['features']) == 2