Skip to content

Commit

Permalink
Merge pull request #41 from bitner/cqlbackend
Browse files Browse the repository at this point in the history
CQL 2 Support
  • Loading branch information
constantinius authored Jan 21, 2022
2 parents 3607f7e + a5ace72 commit f4e5b5f
Show file tree
Hide file tree
Showing 18 changed files with 1,371 additions and 694 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
Expand All @@ -31,4 +31,3 @@ jobs:
- name: Run type checking
run: |
mypy pygeofilter
5 changes: 1 addition & 4 deletions pygeofilter/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@
# THE SOFTWARE.
# ------------------------------------------------------------------------------

"""
"""

from enum import Enum
from dataclasses import dataclass
from typing import List, Optional, ClassVar, Union
Expand Down Expand Up @@ -693,7 +690,7 @@ def get_template(self) -> str:

def indent(text: str, amount: int, ch: str = ' ') -> str:
padding = amount * ch
return ''.join(padding+line for line in text.splitlines(True))
return ''.join(padding + line for line in text.splitlines(True))


def get_repr(node: Node, indent_amount: int = 0, indent_incr: int = 4) -> str:
Expand Down
3 changes: 3 additions & 0 deletions pygeofilter/backends/cql2_json/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .evaluate import to_cql2

__all__ = ["to_cql2"]
119 changes: 119 additions & 0 deletions pygeofilter/backends/cql2_json/evaluate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# ------------------------------------------------------------------------------
#
# Project: pygeofilter <https://github.com/geopython/pygeofilter>
# Authors: Fabian Schindler <[email protected]>,
# David Bitner <[email protected]>
#
# ------------------------------------------------------------------------------
# Copyright (C) 2021 EOX IT Services GmbH
#
# 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 of this Software or works derived from this 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 typing import Dict, Optional
from datetime import datetime

import shapely.geometry

from ..evaluator import Evaluator, handle
from ... import ast
from ...cql2 import get_op
from ... import values


class CQL2Evaluator(Evaluator):
def __init__(
self,
attribute_map: Optional[Dict[str, str]],
function_map: Optional[Dict[str, str]],
):
self.attribute_map = attribute_map
self.function_map = function_map

@handle(
ast.Condition,
ast.Comparison,
ast.TemporalPredicate,
ast.SpatialComparisonPredicate,
ast.Arithmetic,
ast.ArrayPredicate,
subclasses=True,
)
def comparison(self, node, *args):
op = get_op(node)
return {"op": op, "args": [*args]}

@handle(ast.Between)
def between(self, node, lhs, low, high):
return {"op": "between", "args": [lhs, [low, high]]}

@handle(ast.Like)
def like(self, node, *subargs):
return {"op": "like", "args": [node.lhs, node.pattern]}

@handle(ast.IsNull)
def isnull(self, node, arg):
return {"op": "isNull", "args": arg}

@handle(ast.Function)
def function(self, node, *args):
name = node.name.lower()
if name == "lower":
ret = {"lower": args[0]}
else:
ret = {"function": name, "args": [*args]}
return ret

@handle(ast.In)
def in_(self, node, lhs, *options):
return {"in": {"value": lhs, "list": options}}

@handle(ast.Attribute)
def attribute(self, node: ast.Attribute):
return {"property": node.name}

@handle(values.Interval)
def interval(self, node: values.Interval):
return {"interval": [node.start, node.end]}

@handle(datetime)
def datetime(self, node: ast.Attribute):
return {"timestamp": node.name}

@handle(*values.LITERALS)
def literal(self, node):
return node

@handle(values.Geometry)
def geometry(self, node: values.Geometry):
return shapely.geometry.shape(node).__geo_interface__

@handle(values.Envelope)
def envelope(self, node: values.Envelope):
return shapely.geometry.box(
node.x1, node.y1, node.x2, node.y2
).__geo_interface__


def to_cql2(
root: ast.Node,
field_mapping: Optional[Dict[str, str]] = None,
function_map: Optional[Dict[str, str]] = None,
) -> str:
return CQL2Evaluator(field_mapping, function_map).evaluate(root)
65 changes: 33 additions & 32 deletions pygeofilter/backends/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,15 @@


def get_all_subclasses(*classes: Type) -> List[Type]:
""" Utility function to get all the leaf-classes (classes that don't
have any further sub-classes) from a given list of classes.
"""Utility function to get all the leaf-classes (classes that don't
have any further sub-classes) from a given list of classes.
"""
all_subclasses = []

for cls in classes:
subclasses = cls.__subclasses__()
if subclasses:
all_subclasses.extend(
get_all_subclasses(*subclasses)
)
all_subclasses.extend(get_all_subclasses(*subclasses))
else:
# directly insert classes that do not have any sub-classes
all_subclasses.append(cls)
Expand All @@ -51,8 +49,8 @@ def get_all_subclasses(*classes: Type) -> List[Type]:


def handle(*node_classes: Type, subclasses: bool = False) -> Callable:
""" Function-decorator to mark a class function as a handler for a
given node type.
"""Function-decorator to mark a class function as a handler for a
given node type.
"""
assert node_classes

Expand All @@ -68,42 +66,45 @@ def inner(func):


class EvaluatorMeta(type):
""" Metaclass for the ``Evaluator`` class to create a static map for
all handler methods by their respective handled types.
"""Metaclass for the ``Evaluator`` class to create a static map for
all handler methods by their respective handled types.
"""

def __init__(cls, name, bases, dct):
cls.handler_map = {}
for base in bases:
cls.handler_map.update(getattr(base, 'handler_map'))
cls.handler_map.update(getattr(base, "handler_map"))

for value in dct.values():
if hasattr(value, 'handles_classes'):
if hasattr(value, "handles_classes"):
for handled_class in value.handles_classes:
cls.handler_map[handled_class] = value


class Evaluator(metaclass=EvaluatorMeta):
""" Base class for AST evaluators.
"""
"""Base class for AST evaluators."""

handler_map: Dict[Type, Callable]

def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any:
""" Recursive function to evaluate an abstract syntax tree.
For every node in the walked syntax tree, its registered handler
is called with the node as first parameter and all pre-evaluated
child nodes as star-arguments.
When no handler was found for a given node, the ``adopt`` function
is called with the node and its arguments, which by default raises
an ``NotImplementedError``.
"""Recursive function to evaluate an abstract syntax tree.
For every node in the walked syntax tree, its registered handler
is called with the node as first parameter and all pre-evaluated
child nodes as star-arguments.
When no handler was found for a given node, the ``adopt`` function
is called with the node and its arguments, which by default raises
an ``NotImplementedError``.
"""
if hasattr(node, 'get_sub_nodes'):
sub_args = [
self.evaluate(sub_node, False)
for sub_node in cast(ast.Node, node).get_sub_nodes()
]
else:
sub_args = []
sub_args = []
if hasattr(node, "get_sub_nodes"):
subnodes = cast(ast.Node, node).get_sub_nodes()
if subnodes:
if isinstance(subnodes, list):
sub_args = [
self.evaluate(sub_node, False) for sub_node in subnodes
]
else:
sub_args = [self.evaluate(subnodes, False)]

handler = self.handler_map.get(type(node))
if handler is not None:
Expand All @@ -117,15 +118,15 @@ def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any:
return result

def adopt(self, node, *sub_args):
""" Interface function for a last resort when trying to evaluate a node
and no handler was found.
"""Interface function for a last resort when trying to evaluate a node
and no handler was found.
"""
raise NotImplementedError(
f'Failed to evaluate node of type {type(node)}'
f"Failed to evaluate node of type {type(node)}"
)

def adopt_result(self, result: Any) -> Any:
""" Interface function for adopting the final evaluation result if necessary.
Default is no-op.
"""Interface function for adopting the final evaluation result if necessary.
Default is no-op.
"""
return result
100 changes: 100 additions & 0 deletions pygeofilter/cql2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Common configurations for cql2 parsers and evaluators.
from typing import Dict, Type, Union
from . import ast

# https://github.com/opengeospatial/ogcapi-features/tree/master/cql2


COMPARISON_MAP: Dict[str, Type[ast.Node]] = {
"=": ast.Equal,
"eq": ast.Equal,
"<>": ast.NotEqual,
"!=": ast.NotEqual,
"ne": ast.NotEqual,
"<": ast.LessThan,
"lt": ast.LessThan,
"<=": ast.LessEqual,
"lte": ast.LessEqual,
">": ast.GreaterThan,
"gt": ast.GreaterThan,
">=": ast.GreaterEqual,
"gte": ast.GreaterEqual,
"like": ast.Like,
}

SPATIAL_PREDICATES_MAP: Dict[str, Type[ast.SpatialComparisonPredicate]] = {
"s_intersects": ast.GeometryIntersects,
"s_equals": ast.GeometryEquals,
"s_disjoint": ast.GeometryDisjoint,
"s_touches": ast.GeometryTouches,
"s_within": ast.GeometryWithin,
"s_overlaps": ast.GeometryOverlaps,
"s_crosses": ast.GeometryCrosses,
"s_contains": ast.GeometryContains,
}

TEMPORAL_PREDICATES_MAP: Dict[str, Type[ast.TemporalPredicate]] = {
"t_before": ast.TimeBefore,
"t_after": ast.TimeAfter,
"t_meets": ast.TimeMeets,
"t_metby": ast.TimeMetBy,
"t_overlaps": ast.TimeOverlaps,
"t_overlappedby": ast.TimeOverlappedBy,
"t_begins": ast.TimeBegins,
"t_begunby": ast.TimeBegunBy,
"t_during": ast.TimeDuring,
"t_contains": ast.TimeContains,
"t_ends": ast.TimeEnds,
"t_endedby": ast.TimeEndedBy,
"t_equals": ast.TimeEquals,
"t_intersects": ast.TimeOverlaps,
}


ARRAY_PREDICATES_MAP: Dict[str, Type[ast.ArrayPredicate]] = {
"a_equals": ast.ArrayEquals,
"a_contains": ast.ArrayContains,
"a_containedby": ast.ArrayContainedBy,
"a_overlaps": ast.ArrayOverlaps,
}

ARITHMETIC_MAP: Dict[str, Type[ast.Arithmetic]] = {
"+": ast.Add,
"-": ast.Sub,
"*": ast.Mul,
"/": ast.Div,
}

CONDITION_MAP: Dict[str, Type[ast.Node]] = {
"and": ast.And,
"or": ast.Or,
"not": ast.Not,
"isNull": ast.IsNull,
}

BINARY_OP_PREDICATES_MAP: Dict[
str,
Union[
Type[ast.Node],
Type[ast.Comparison],
Type[ast.SpatialComparisonPredicate],
Type[ast.TemporalPredicate],
Type[ast.ArrayPredicate],
Type[ast.Arithmetic],
],
] = {
**COMPARISON_MAP,
**SPATIAL_PREDICATES_MAP,
**TEMPORAL_PREDICATES_MAP,
**ARRAY_PREDICATES_MAP,
**ARITHMETIC_MAP,
**CONDITION_MAP,
}


def get_op(node: ast.Node) -> Union[str, None]:
# Get the cql2 operator string from a node.
for k, v in BINARY_OP_PREDICATES_MAP.items():
if isinstance(node, v):
return k
return None
Loading

0 comments on commit f4e5b5f

Please sign in to comment.