diff --git a/pygeofilter/backends/cql2_json/evaluate.py b/pygeofilter/backends/cql2_json/evaluate.py index b896bab..39aa167 100644 --- a/pygeofilter/backends/cql2_json/evaluate.py +++ b/pygeofilter/backends/cql2_json/evaluate.py @@ -31,8 +31,8 @@ from typing import Dict, Optional from ... import ast, values -from ...cql2 import get_op -from ..evaluator import Evaluator, handle +from ...frontends.cql2 import get_op +from ...evaluator import Evaluator, handle def json_serializer(obj): diff --git a/pygeofilter/backends/django/evaluate.py b/pygeofilter/backends/django/evaluate.py index 3498641..2659cad 100644 --- a/pygeofilter/backends/django/evaluate.py +++ b/pygeofilter/backends/django/evaluate.py @@ -30,7 +30,7 @@ from django.contrib.gis.geos import GEOSGeometry, Polygon from ... import ast, values -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle from . import filters diff --git a/pygeofilter/backends/elasticsearch/evaluate.py b/pygeofilter/backends/elasticsearch/evaluate.py index 2ae26e6..b1429b4 100644 --- a/pygeofilter/backends/elasticsearch/evaluate.py +++ b/pygeofilter/backends/elasticsearch/evaluate.py @@ -41,7 +41,7 @@ from packaging.version import Version from ... import ast, values -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle from .util import like_to_wildcard VERSION_7_10_0 = Version("7.10.0") diff --git a/pygeofilter/backends/geopandas/evaluate.py b/pygeofilter/backends/geopandas/evaluate.py index 98b33ab..d2836da 100644 --- a/pygeofilter/backends/geopandas/evaluate.py +++ b/pygeofilter/backends/geopandas/evaluate.py @@ -30,7 +30,7 @@ from shapely import geometry from ... import ast, values -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle from . import filters LITERALS = (str, float, int, bool, datetime, date, time, timedelta) diff --git a/pygeofilter/backends/native/evaluate.py b/pygeofilter/backends/native/evaluate.py index 691869e..47d97ae 100644 --- a/pygeofilter/backends/native/evaluate.py +++ b/pygeofilter/backends/native/evaluate.py @@ -32,7 +32,7 @@ from ... import ast, values from ...util import like_pattern_to_re, parse_datetime -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle COMPARISON_MAP = { ast.ComparisonOp.EQ: "==", diff --git a/pygeofilter/backends/optimize.py b/pygeofilter/backends/optimize.py index 313afb9..c588fb4 100644 --- a/pygeofilter/backends/optimize.py +++ b/pygeofilter/backends/optimize.py @@ -33,7 +33,7 @@ from .. import ast, values from ..util import like_pattern_to_re -from .evaluator import Evaluator, handle +from ..evaluator import Evaluator, handle COMPARISON_MAP = { "=": operator.eq, diff --git a/pygeofilter/backends/sql/evaluate.py b/pygeofilter/backends/sql/evaluate.py index 952b1a7..8ad9926 100644 --- a/pygeofilter/backends/sql/evaluate.py +++ b/pygeofilter/backends/sql/evaluate.py @@ -30,7 +30,7 @@ import shapely.geometry from ... import ast, values -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle COMPARISON_OP_MAP = { ast.ComparisonOp.EQ: "=", diff --git a/pygeofilter/backends/sqlalchemy/evaluate.py b/pygeofilter/backends/sqlalchemy/evaluate.py index 334b82a..16f3163 100644 --- a/pygeofilter/backends/sqlalchemy/evaluate.py +++ b/pygeofilter/backends/sqlalchemy/evaluate.py @@ -1,7 +1,7 @@ from datetime import date, datetime, time, timedelta from ... import ast, values -from ..evaluator import Evaluator, handle +from ...evaluator import Evaluator, handle from . import filters LITERALS = (str, float, int, bool, datetime, date, time, timedelta) diff --git a/pygeofilter/backends/evaluator.py b/pygeofilter/evaluator.py similarity index 99% rename from pygeofilter/backends/evaluator.py rename to pygeofilter/evaluator.py index d0f7970..744885d 100644 --- a/pygeofilter/backends/evaluator.py +++ b/pygeofilter/evaluator.py @@ -28,7 +28,7 @@ from functools import wraps from typing import Any, Callable, Dict, List, Type, cast -from .. import ast +from . import ast def get_all_subclasses(*classes: Type) -> List[Type]: diff --git a/pygeofilter/parsers/__init__.py b/pygeofilter/frontends/__init__.py similarity index 100% rename from pygeofilter/parsers/__init__.py rename to pygeofilter/frontends/__init__.py diff --git a/pygeofilter/frontends/abc.py b/pygeofilter/frontends/abc.py new file mode 100644 index 0000000..d5820ef --- /dev/null +++ b/pygeofilter/frontends/abc.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from ..ast import Node + + +class Frontend(ABC): + + @abstractmethod + def parse(self, raw: str) -> Node: + ... + + @abstractmethod + def encode(self, root: Node) -> str: + ... diff --git a/pygeofilter/cql2.py b/pygeofilter/frontends/cql2.py similarity index 99% rename from pygeofilter/cql2.py rename to pygeofilter/frontends/cql2.py index 53cf0ec..3520a2a 100644 --- a/pygeofilter/cql2.py +++ b/pygeofilter/frontends/cql2.py @@ -1,7 +1,7 @@ # Common configurations for cql2 parsers and evaluators. from typing import Dict, Type, Union -from . import ast +from .. import ast # https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 diff --git a/pygeofilter/parsers/cql2_json/__init__.py b/pygeofilter/frontends/cql2_json/__init__.py similarity index 100% rename from pygeofilter/parsers/cql2_json/__init__.py rename to pygeofilter/frontends/cql2_json/__init__.py diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/frontends/cql2_json/parser.py similarity index 99% rename from pygeofilter/parsers/cql2_json/parser.py rename to pygeofilter/frontends/cql2_json/parser.py index 74b41da..1f24a92 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/frontends/cql2_json/parser.py @@ -31,7 +31,7 @@ from typing import List, Union, cast from ... import ast, values -from ...cql2 import BINARY_OP_PREDICATES_MAP +from ..cql2 import BINARY_OP_PREDICATES_MAP from ...util import parse_date, parse_datetime, parse_duration # https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 diff --git a/pygeofilter/frontends/cql2_text/__init__.py b/pygeofilter/frontends/cql2_text/__init__.py new file mode 100644 index 0000000..66f547c --- /dev/null +++ b/pygeofilter/frontends/cql2_text/__init__.py @@ -0,0 +1,11 @@ +from ...ast import Node +from ..abc import Frontend +from .parser import parse + + +__all__ = ["parse"] + + +class CQL2TextFrontend(Frontend): + def parse(self, raw: str) -> Node: + return super().parse(raw) \ No newline at end of file diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/frontends/cql2_text/grammar.lark similarity index 100% rename from pygeofilter/parsers/cql2_text/grammar.lark rename to pygeofilter/frontends/cql2_text/grammar.lark diff --git a/pygeofilter/parsers/cql2_text/parser.py b/pygeofilter/frontends/cql2_text/parser.py similarity index 98% rename from pygeofilter/parsers/cql2_text/parser.py rename to pygeofilter/frontends/cql2_text/parser.py index b71ea96..cf529ba 100644 --- a/pygeofilter/parsers/cql2_text/parser.py +++ b/pygeofilter/frontends/cql2_text/parser.py @@ -31,7 +31,7 @@ from lark import Lark, logger, v_args from ... import ast, values -from ...cql2 import SPATIAL_PREDICATES_MAP, TEMPORAL_PREDICATES_MAP +from ..cql2 import SPATIAL_PREDICATES_MAP, TEMPORAL_PREDICATES_MAP from ..iso8601 import ISO8601Transformer from ..wkt import WKTTransformer diff --git a/pygeofilter/parsers/cql2_text/__init__.py b/pygeofilter/frontends/cql_json/__init__.py similarity index 100% rename from pygeofilter/parsers/cql2_text/__init__.py rename to pygeofilter/frontends/cql_json/__init__.py diff --git a/pygeofilter/parsers/cql_json/parser.py b/pygeofilter/frontends/cql_json/parser.py similarity index 100% rename from pygeofilter/parsers/cql_json/parser.py rename to pygeofilter/frontends/cql_json/parser.py diff --git a/pygeofilter/parsers/cql_json/__init__.py b/pygeofilter/frontends/ecql/__init__.py similarity index 100% rename from pygeofilter/parsers/cql_json/__init__.py rename to pygeofilter/frontends/ecql/__init__.py diff --git a/pygeofilter/frontends/ecql/encoder.py b/pygeofilter/frontends/ecql/encoder.py new file mode 100644 index 0000000..e497cb4 --- /dev/null +++ b/pygeofilter/frontends/ecql/encoder.py @@ -0,0 +1,174 @@ +from datetime import date, datetime, timedelta +import re + +import shapely.geometry + +from ... import ast +from ... import values +from ...evaluator import Evaluator, handle +from ...util import encode_duration + + +COMPARISON_OP_MAP = { + ast.ComparisonOp.EQ: "=", + ast.ComparisonOp.NE: "<>", + ast.ComparisonOp.LT: "<", + ast.ComparisonOp.LE: "<=", + ast.ComparisonOp.GT: ">", + ast.ComparisonOp.GE: ">=", +} + +ARITHMETIC_OP_MAP = { + ast.ArithmeticOp.ADD: "+", + ast.ArithmeticOp.SUB: "-", + ast.ArithmeticOp.MUL: "*", + ast.ArithmeticOp.DIV: "/", +} + + +def maybe_bracket(node: ast.Node, encoded: str) -> str: + if isinstance(node, (ast.Not, ast.Combination, ast.Comparison, ast.Arithmetic)): + return f"({encoded})" + return encoded + + +class ECQLEvaluator(Evaluator): + @handle(ast.Not) + def not_(self, node: ast.Not, sub): + return f"NOT {sub}" + + @handle(ast.And, ast.Or) + def combination(self, node: ast.Combination, lhs, rhs): + if isinstance(node.lhs, ast.Combination): + lhs = f"({lhs})" + if isinstance(node.rhs, ast.Combination): + rhs = f"({rhs})" + return f"{lhs} {node.op.value} {rhs}" + + @handle(ast.Comparison, subclasses=True) + def comparison(self, node: ast.Comparison, lhs, rhs): + return f"{lhs} {COMPARISON_OP_MAP[node.op]} {rhs}" + + @handle(ast.Between) + def between(self, node, lhs, low, high): + return f"{lhs} {'NOT ' if node.not_ else ''}BETWEEN {low} AND {high}" + + @handle(ast.Like) + def like(self, node: ast.Like, lhs): + pattern = node.pattern + if node.wildcard != "%": + # TODO: not preceded by escapechar + pattern = pattern.replace(node.wildcard, "%") + if node.singlechar != "_": + # TODO: not preceded by escapechar + pattern = pattern.replace(node.singlechar, "_") + + return f"{lhs} {'NOT ' if node.not_ else ''}{'I' if node.nocase else ''}LIKE '{pattern}'" + + @handle(ast.In) + def in_(self, node: ast.In, lhs, *options): + return f"{lhs} {'NOT ' if node.not_ else ''}IN ({', '.join(options)})" + + @handle(ast.IsNull) + def null(self, node: ast.IsNull, lhs): + return f"{lhs} IS {'NOT ' if node.not_ else ''}NULL" + + @handle(ast.Exists) + def exists(self, node: ast.Exists, lhs): + return f"{lhs} {'DOES-NOT-EXIST' if node.not_ else 'EXISTS'}" + + @handle(ast.Include) + def include(self, node: ast.Include): + return "EXCLUDE" if node.not_ else "INCLUDE" + + @handle(ast.TemporalPredicate, subclasses=True) + def temporal(self, node: ast.TemporalPredicate, lhs, rhs): + if isinstance(node, ast.TimeBefore): + return f"{lhs} BEFORE {rhs}" + elif isinstance(node, ast.TimeBeforeOrDuring): + return f"{lhs} BEFORE OR DURING {rhs}" + elif isinstance(node, ast.TimeDuring): + return f"{lhs} DURING {rhs}" + elif isinstance(node, ast.TimeDuringOrAfter): + return f"{lhs} DURING OR AFTER {rhs}" + elif isinstance(node, ast.TimeAfter): + return f"{lhs} AFTER {rhs}" + else: + raise NotImplementedError(f"{node.op} is not implemented") + + @handle(ast.SpatialComparisonPredicate, subclasses=True) + def spatial_operation(self, node: ast.SpatialComparisonPredicate, lhs, rhs): + return f"{node.op.value}({lhs}, {rhs})" + + @handle(ast.BBox) + def bbox(self, node: ast.BBox, lhs): + if not node.crs: + return f"BBOX({lhs}, {node.minx}, {node.miny}, {node.maxx}, {node.maxy})" + else: + return f"BBOX({lhs}, {node.minx}, {node.miny}, {node.maxx}, {node.maxy}, '{node.crs}')" + + @handle(ast.Relate) + def relate(self, node: ast.Relate, lhs, rhs): + return f"RELATE({lhs}, {rhs}, '{node.pattern}')" + + @handle(ast.SpatialDistancePredicate, subclasses=True) + def spatial_distance_predicate(self, node: ast.SpatialDistancePredicate, lhs, rhs): + return f"{node.op.value}({lhs}, {rhs}, {node.distance}, {node.units})" + + @handle(ast.Attribute) + def attribute(self, node: ast.Attribute): + is_cname = re.match(r"[a-zA-Z_][a-zA-Z0-9_]*", node.name) is not None + return node.name if is_cname else f'"{node.name}"' + + @handle(ast.Arithmetic, subclasses=True) + def arithmetic(self, node: ast.Arithmetic, lhs, rhs): + def arity(node): + if isinstance(node, (ast.Sub, ast.Add)): + return 1 + elif isinstance(node, (ast.Div, ast.Mul)): + return 2 + + node_arity = arity(node) + lhs_arity = arity(node.lhs) + rhs_arity = arity(node.rhs) + if lhs_arity and node_arity > lhs_arity: + lhs = f"({lhs})" + if rhs_arity and node_arity > rhs_arity: + rhs = f"({rhs})" + + return f"{lhs} {node.op.value} {rhs}" + + @handle(ast.Function) + def function(self, node: ast.Function, *arguments): + return f"{node.name}({', '.join(arguments)})" + + @handle(*values.LITERALS) + def literal(self, node): + if isinstance(node, str): + return f"'{node}'" + elif isinstance(node, (datetime, date)): + return node.isoformat().replace("+00:00", "Z") + elif isinstance(node, timedelta): + return encode_duration(node) + elif isinstance(node, bool): + return str(node).upper() + elif isinstance(node, float): + return str(int(node) if node.is_integer() else node) + else: + return str(node) + + @handle(values.Interval) + def interval(self, node: values.Interval, start, end): + return f"{self.literal(node.start)} / {self.literal(node.end)}" + + @handle(values.Geometry) + def geometry(self, node: values.Geometry): + return shapely.geometry.shape(node).wkt + + @handle(values.Envelope) + def envelope(self, node: values.Envelope): + return f"ENVELOPE ({node.x1} {node.y1} {node.x2} {node.y2})" + + +def encode(root: ast.Node) -> str: + return ECQLEvaluator().evaluate(root) diff --git a/pygeofilter/frontends/ecql/frontend.py b/pygeofilter/frontends/ecql/frontend.py new file mode 100644 index 0000000..64e1dbd --- /dev/null +++ b/pygeofilter/frontends/ecql/frontend.py @@ -0,0 +1,13 @@ +from ..abc import Frontend +from ...ast import Node + +from .parser import parse +from .encoder import encode + + +class ECQLFrontend(Frontend): + def parse(self, raw: str) -> Node: + return parse(raw) + + def encode(self, root: Node) -> str: + return encode(root) diff --git a/pygeofilter/parsers/ecql/grammar.lark b/pygeofilter/frontends/ecql/grammar.lark similarity index 100% rename from pygeofilter/parsers/ecql/grammar.lark rename to pygeofilter/frontends/ecql/grammar.lark diff --git a/pygeofilter/parsers/ecql/parser.py b/pygeofilter/frontends/ecql/parser.py similarity index 100% rename from pygeofilter/parsers/ecql/parser.py rename to pygeofilter/frontends/ecql/parser.py diff --git a/pygeofilter/parsers/fes/__init__.py b/pygeofilter/frontends/fes/__init__.py similarity index 100% rename from pygeofilter/parsers/fes/__init__.py rename to pygeofilter/frontends/fes/__init__.py diff --git a/pygeofilter/parsers/fes/base.py b/pygeofilter/frontends/fes/base.py similarity index 100% rename from pygeofilter/parsers/fes/base.py rename to pygeofilter/frontends/fes/base.py diff --git a/pygeofilter/parsers/fes/gml.py b/pygeofilter/frontends/fes/gml.py similarity index 100% rename from pygeofilter/parsers/fes/gml.py rename to pygeofilter/frontends/fes/gml.py diff --git a/pygeofilter/parsers/fes/parser.py b/pygeofilter/frontends/fes/parser.py similarity index 100% rename from pygeofilter/parsers/fes/parser.py rename to pygeofilter/frontends/fes/parser.py diff --git a/pygeofilter/parsers/fes/util.py b/pygeofilter/frontends/fes/util.py similarity index 100% rename from pygeofilter/parsers/fes/util.py rename to pygeofilter/frontends/fes/util.py diff --git a/pygeofilter/parsers/fes/v11.py b/pygeofilter/frontends/fes/v11.py similarity index 100% rename from pygeofilter/parsers/fes/v11.py rename to pygeofilter/frontends/fes/v11.py diff --git a/pygeofilter/parsers/fes/v20.py b/pygeofilter/frontends/fes/v20.py similarity index 100% rename from pygeofilter/parsers/fes/v20.py rename to pygeofilter/frontends/fes/v20.py diff --git a/pygeofilter/parsers/iso8601.lark b/pygeofilter/frontends/iso8601.lark similarity index 100% rename from pygeofilter/parsers/iso8601.lark rename to pygeofilter/frontends/iso8601.lark diff --git a/pygeofilter/parsers/iso8601.py b/pygeofilter/frontends/iso8601.py similarity index 100% rename from pygeofilter/parsers/iso8601.py rename to pygeofilter/frontends/iso8601.py diff --git a/pygeofilter/parsers/ecql/__init__.py b/pygeofilter/frontends/jfe/__init__.py similarity index 100% rename from pygeofilter/parsers/ecql/__init__.py rename to pygeofilter/frontends/jfe/__init__.py diff --git a/pygeofilter/parsers/jfe/parser.py b/pygeofilter/frontends/jfe/parser.py similarity index 100% rename from pygeofilter/parsers/jfe/parser.py rename to pygeofilter/frontends/jfe/parser.py diff --git a/pygeofilter/parsers/wkt.lark b/pygeofilter/frontends/wkt.lark similarity index 100% rename from pygeofilter/parsers/wkt.lark rename to pygeofilter/frontends/wkt.lark diff --git a/pygeofilter/parsers/wkt.py b/pygeofilter/frontends/wkt.py similarity index 100% rename from pygeofilter/parsers/wkt.py rename to pygeofilter/frontends/wkt.py diff --git a/pygeofilter/parsers/jfe/__init__.py b/pygeofilter/parsers/jfe/__init__.py deleted file mode 100644 index efcccb2..0000000 --- a/pygeofilter/parsers/jfe/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .parser import parse - -__all__ = ["parse"] diff --git a/pygeofilter/util.py b/pygeofilter/util.py index 06cd2a0..d7ed22c 100644 --- a/pygeofilter/util.py +++ b/pygeofilter/util.py @@ -29,14 +29,17 @@ from datetime import date, datetime, timedelta from dateparser import parse as _parse_datetime +import isodate -__all__ = [ - "parse_datetime", - "RE_ISO_8601", - "parse_duration", - "like_pattern_to_re_pattern", - "like_pattern_to_re", -] + +# __all__ = [ +# "parse_datetime", +# "encode_duration", +# "RE_ISO_8601", +# "parse_duration", +# "like_pattern_to_re_pattern", +# "like_pattern_to_re", +# ] RE_ISO_8601 = re.compile( r"^(?P[+-])?P" @@ -87,6 +90,18 @@ def parse_datetime(value: str) -> datetime: return parsed +def encode_duration(value: timedelta) -> str: + return isodate.duration_isoformat(value) + + +def encode_date(value: date) -> str: + return value.isoformat() + + +def encode_datetime(value: datetime) -> str: + return datetime.isoformat().replace("+00:00", "Z") + + def like_pattern_to_re_pattern(like, wildcard, single_char, escape_char): x_wildcard = re.escape(wildcard) x_single_char = re.escape(single_char) diff --git a/requirements-test.txt b/requirements-test.txt index 13f35b9..ca35698 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,7 +7,7 @@ pyproj rtree pygml dateparser -pygeoif==0.7 +pygeoif==1.0 lark elasticsearch elasticsearch-dsl diff --git a/setup.py b/setup.py index 47ac4ce..89db849 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ "lark<1.0", "pygeoif>=1.0.0", "dataclasses;python_version<'3.7'", + "isodate", ] if not on_rtd else [], diff --git a/tests/backends/django/test_django_evaluate.py b/tests/backends/django/test_django_evaluate.py index 5df0dda..ad0bb2f 100644 --- a/tests/backends/django/test_django_evaluate.py +++ b/tests/backends/django/test_django_evaluate.py @@ -29,7 +29,7 @@ from testapp import models from pygeofilter.backends.django.evaluate import to_filter -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse def evaluate(cql_expr, expected_ids, model_type=None): diff --git a/tests/backends/django/testapp/tests.py b/tests/backends/django/testapp/tests.py index 251e178..daeb267 100644 --- a/tests/backends/django/testapp/tests.py +++ b/tests/backends/django/testapp/tests.py @@ -28,7 +28,7 @@ from django.test import TransactionTestCase from pygeofilter.backends.django.evaluate import to_filter -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse from . import models diff --git a/tests/backends/elasticsearch/test_evaluate.py b/tests/backends/elasticsearch/test_evaluate.py index e3e8904..abe1134 100644 --- a/tests/backends/elasticsearch/test_evaluate.py +++ b/tests/backends/elasticsearch/test_evaluate.py @@ -20,7 +20,7 @@ from pygeofilter import ast from pygeofilter.backends.elasticsearch import to_filter -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse from pygeofilter.util import parse_datetime diff --git a/tests/backends/sqlalchemy/test_evaluate.py b/tests/backends/sqlalchemy/test_evaluate.py index 705f3af..71ef596 100644 --- a/tests/backends/sqlalchemy/test_evaluate.py +++ b/tests/backends/sqlalchemy/test_evaluate.py @@ -18,7 +18,7 @@ from sqlalchemy.sql import func, select from pygeofilter.backends.sqlalchemy.evaluate import to_filter -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse Base = declarative_base() diff --git a/tests/execute-tests.sh b/tests/execute-tests.sh new file mode 100644 index 0000000..55b033e --- /dev/null +++ b/tests/execute-tests.sh @@ -0,0 +1 @@ +pytest \ No newline at end of file diff --git a/tests/parsers/__init__.py b/tests/frontends/__init__.py similarity index 100% rename from tests/parsers/__init__.py rename to tests/frontends/__init__.py diff --git a/tests/parsers/cql2_json/__init__.py b/tests/frontends/cql2_json/__init__.py similarity index 100% rename from tests/parsers/cql2_json/__init__.py rename to tests/frontends/cql2_json/__init__.py diff --git a/tests/parsers/cql2_json/fixtures.json b/tests/frontends/cql2_json/fixtures.json similarity index 100% rename from tests/parsers/cql2_json/fixtures.json rename to tests/frontends/cql2_json/fixtures.json diff --git a/tests/parsers/cql2_json/get_fixtures.py b/tests/frontends/cql2_json/get_fixtures.py similarity index 100% rename from tests/parsers/cql2_json/get_fixtures.py rename to tests/frontends/cql2_json/get_fixtures.py diff --git a/tests/parsers/cql2_json/test_cql2_spec_fixtures.py b/tests/frontends/cql2_json/test_cql2_spec_fixtures.py similarity index 85% rename from tests/parsers/cql2_json/test_cql2_spec_fixtures.py rename to tests/frontends/cql2_json/test_cql2_spec_fixtures.py index 6857489..68cb85d 100644 --- a/tests/parsers/cql2_json/test_cql2_spec_fixtures.py +++ b/tests/frontends/cql2_json/test_cql2_spec_fixtures.py @@ -2,8 +2,8 @@ import pathlib from pygeofilter.backends.cql2_json import to_cql2 -from pygeofilter.parsers.cql2_json import parse as json_parse -from pygeofilter.parsers.cql2_text import parse as text_parse +from pygeofilter.frontends.cql2_json import parse as json_parse +from pygeofilter.frontends.cql2_text import parse as text_parse dir = pathlib.Path(__file__).parent.resolve() fixtures = pathlib.Path(dir, "fixtures.json") diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/frontends/cql2_json/test_parser.py similarity index 99% rename from tests/parsers/cql2_json/test_parser.py rename to tests/frontends/cql2_json/test_parser.py index b78d440..ec4b1a8 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/frontends/cql2_json/test_parser.py @@ -32,7 +32,7 @@ from pygeoif import geometry from pygeofilter import ast, values -from pygeofilter.parsers.cql2_json import parse +from pygeofilter.frontends.cql2_json import parse def normalize_geom(geometry): diff --git a/tests/parsers/cql_json/__init__.py b/tests/frontends/cql_json/__init__.py similarity index 100% rename from tests/parsers/cql_json/__init__.py rename to tests/frontends/cql_json/__init__.py diff --git a/tests/parsers/cql_json/test_parser.py b/tests/frontends/cql_json/test_parser.py similarity index 99% rename from tests/parsers/cql_json/test_parser.py rename to tests/frontends/cql_json/test_parser.py index 7712a88..d493861 100644 --- a/tests/parsers/cql_json/test_parser.py +++ b/tests/frontends/cql_json/test_parser.py @@ -32,7 +32,7 @@ from pygeoif import geometry from pygeofilter import ast, values -from pygeofilter.parsers.cql_json import parse +from pygeofilter.frontends.cql_json import parse def normalize_geom(geometry): diff --git a/tests/parsers/ecql/__init__.py b/tests/frontends/ecql/__init__.py similarity index 100% rename from tests/parsers/ecql/__init__.py rename to tests/frontends/ecql/__init__.py diff --git a/tests/frontends/ecql/test_encoder.py b/tests/frontends/ecql/test_encoder.py new file mode 100644 index 0000000..fd499e5 --- /dev/null +++ b/tests/frontends/ecql/test_encoder.py @@ -0,0 +1,618 @@ +# ------------------------------------------------------------------------------ +# +# Project: pygeofilter +# Authors: Fabian Schindler +# +# ------------------------------------------------------------------------------ +# Copyright (C) 2019 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 datetime import datetime, timedelta + +from dateparser.timezone_parser import StaticTzInfo +from pygeoif import geometry + +from pygeofilter import ast, values +from pygeofilter.frontends.ecql.encoder import encode + + +def test_attribute_eq_literal(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + "A", + ) + ) == "attr = 'A'" + + +def test_attribute_lt_literal(): + assert encode( + ast.LessThan( + ast.Attribute("attr"), + 5.0, + ) + ) == "attr < 5" + + +def test_attribute_lte_literal(): + assert encode( + ast.LessEqual( + ast.Attribute("attr"), + 5.0, + ) + ) == "attr <= 5" + + +def test_attribute_gt_literal(): + assert encode( + ast.GreaterThan( + ast.Attribute("attr"), + 5.0, + ) + ) == "attr > 5" + + +def test_attribute_gte_literal(): + assert encode( + ast.GreaterEqual( + ast.Attribute("attr"), + 5.0, + ) + ) == "attr >= 5" + + +def test_attribute_ne_literal(): + assert encode( + ast.NotEqual( + ast.Attribute("attr"), + 5, + ) + ) == "attr <> 5" + + +def test_attribute_between(): + assert encode( + ast.Between( + ast.Attribute("attr"), + 2, + 5, + False, + ) + ) == "attr BETWEEN 2 AND 5" + + +def test_attribute_not_between(): + assert encode( + ast.Between( + ast.Attribute("attr"), + 2, + 5, + True, + ) + ) == "attr NOT BETWEEN 2 AND 5" + + +def test_attribute_between_negative_positive(): + assert encode( + ast.Between( + ast.Attribute("attr"), + -1, + 1, + False, + ) + ) == "attr BETWEEN -1 AND 1" + + +def test_string_like(): + assert encode( + ast.Like( + ast.Attribute("attr"), + "some%", + nocase=False, + not_=False, + wildcard="%", + singlechar=".", + escapechar="\\", + ) + ) == "attr LIKE 'some%'" + + +def test_string_ilike(): + assert encode( + ast.Like( + ast.Attribute("attr"), + "some%", + nocase=True, + not_=False, + wildcard="%", + singlechar=".", + escapechar="\\", + ) + ) == "attr ILIKE 'some%'" + + +def test_string_not_like(): + assert encode( + ast.Like( + ast.Attribute("attr"), + "some%", + nocase=False, + not_=True, + wildcard="%", + singlechar=".", + escapechar="\\", + ) + ) == "attr NOT LIKE 'some%'" + + +def test_string_not_ilike(): + assert encode( + ast.Like( + ast.Attribute("attr"), + "some%", + nocase=True, + not_=True, + wildcard="%", + singlechar=".", + escapechar="\\", + ) + ) == "attr NOT ILIKE 'some%'" + + +def test_attribute_in_list(): + assert encode( + ast.In( + ast.Attribute("attr"), + [ + 1, + 2, + 3, + 4, + ], + False, + ) + ) == "attr IN (1, 2, 3, 4)" + + +def test_attribute_not_in_list(): + assert encode( + ast.In( + ast.Attribute("attr"), + [ + "A", + "B", + "C", + "D", + ], + True, + ) + ) == "attr NOT IN ('A', 'B', 'C', 'D')" + + +def test_attribute_is_null(): + assert encode(ast.IsNull(ast.Attribute("attr"), False)) == "attr IS NULL" + + +def test_attribute_is_not_null(): + assert encode(ast.IsNull(ast.Attribute("attr"), True)) == "attr IS NOT NULL" + + +def test_attribute_exists(): + assert encode(ast.Exists(ast.Attribute("attr"), False)) == "attr EXISTS" + + +def test_attribute_does_not_exist(): + assert encode(ast.Exists(ast.Attribute("attr"), True)) == "attr DOES-NOT-EXIST" + + +def test_include(): + assert encode(ast.Include(False)) == "INCLUDE" + + +def test_exclude(): + assert encode(ast.Include(True)) == "EXCLUDE" + + +# Temporal predicate + + +def test_attribute_before(): + assert encode( + ast.TimeBefore( + ast.Attribute("attr"), + datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) == "attr BEFORE 2000-01-01T00:00:01Z" + + +def test_attribute_before_or_during_dt_dt(): + assert encode( + ast.TimeBeforeOrDuring( + ast.Attribute("attr"), + values.Interval( + datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + ), + ) + ) == "attr BEFORE OR DURING 2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z" + + +def test_attribute_before_or_during_dt_dr(): + assert encode( + ast.TimeBeforeOrDuring( + ast.Attribute("attr"), + values.Interval( + datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + timedelta(seconds=4), + ), + ) + ) == "attr BEFORE OR DURING 2000-01-01T00:00:00Z / PT4S" + + +def test_attribute_before_or_during_dr_dt(): + assert encode( + ast.TimeBeforeOrDuring( + ast.Attribute("attr"), + values.Interval( + timedelta(seconds=4), + datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + ), + ) + ) == "attr BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z" + + +# Spatial predicate + + +def test_intersects_attr_point(): + assert encode( + ast.GeometryIntersects( + ast.Attribute("geometry"), + values.Geometry(geometry.Point(1, 1).__geo_interface__), + ) + ) == "INTERSECTS(geometry, POINT (1 1))" + + +def test_disjoint_linestring_attr(): + assert encode( + ast.GeometryDisjoint( + values.Geometry( + geometry.LineString([(1, 1), (2, 2)]).__geo_interface__, + ), + ast.Attribute("geometry"), + ) + ) == "DISJOINT(LINESTRING (1 1, 2 2), geometry)" + + +def test_contains_attr_polygon(): + assert encode( + ast.GeometryContains( + ast.Attribute("geometry"), + values.Geometry( + geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + ), + ) + ) == "CONTAINS(geometry, POLYGON ((1 1, 2 2, 0 3, 1 1)))" + + +def test_within_multipolygon_attr(): + assert encode( + ast.GeometryWithin( + values.Geometry( + geometry.MultiPolygon([ + [[(1, 1), (2, 2), (0, 3), (1, 1)]] + ]).__geo_interface__, + ), + ast.Attribute("geometry"), + ) + ) == "WITHIN(MULTIPOLYGON (((1 1, 2 2, 0 3, 1 1))), geometry)" + + +def test_touches_attr_multilinestring(): + assert encode( + ast.GeometryTouches( + ast.Attribute("geometry"), + values.Geometry( + geometry.MultiLineString([ + [(1, 1), (2, 2)], + [(0, 3), (1, 1)], + ]).__geo_interface__, + ), + ) + ) == "TOUCHES(geometry, MULTILINESTRING ((1 1, 2 2), (0 3, 1 1)))" + + +def test_crosses_attr_multilinestring(): + assert encode( + ast.GeometryCrosses( + ast.Attribute("geometry"), + values.Geometry( + geometry.MultiLineString([ + [(1, 1), (2, 2)], + [(0, 3), (1, 1)], + ]).__geo_interface__, + ), + ) + ) == "CROSSES(geometry, MULTILINESTRING ((1 1, 2 2), (0 3, 1 1)))" + + +def test_overlaps_attr_multilinestring(): + assert encode( + ast.GeometryOverlaps( + ast.Attribute("geometry"), + values.Geometry( + geometry.MultiLineString([ + [(1, 1), (2, 2)], + [(0, 3), (1, 1)], + ]).__geo_interface__, + ), + ) + ) == "OVERLAPS(geometry, MULTILINESTRING ((1 1, 2 2), (0 3, 1 1)))" + + +# def test_intersects_attr_point_ewkt(): +# assert encode( +# ast.GeometryIntersects( +# ast.Attribute("geometry"), +# values.Geometry(geometry.Point(1, 1).__geo_interface__), +# ) +# ) == "INTERSECTS(geometry, SRID=4326;POINT (1 1))" + + +def test_intersects_attr_geometrycollection(): + assert encode( + ast.GeometryIntersects( + ast.Attribute("geometry"), + values.Geometry( + geometry.GeometryCollection( + [ + geometry.Point(1, 1), + geometry.LineString([(1, 1), (2, 2)]), + geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]), + ] + ).__geo_interface__ + ), + ) + ) == ( + "INTERSECTS(geometry, GEOMETRYCOLLECTION (POINT (1 1), " + "LINESTRING (1 1, 2 2), " + "POLYGON ((1 1, 2 2, 0 3, 1 1))" + "))" + ) + + +# relate + + +def test_relate_attr_polygon(): + assert encode( + ast.Relate( + ast.Attribute("geometry"), + values.Geometry( + geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + ), + pattern="1*T***T**", + ) + ) == "RELATE(geometry, POLYGON ((1 1, 2 2, 0 3, 1 1)), '1*T***T**')" + + +# dwithin/beyond + + +def test_dwithin_attr_polygon(): + assert encode( + ast.DistanceWithin( + ast.Attribute("geometry"), + values.Geometry( + geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + ), + distance=5, + units="feet", + ) + ) == "DWITHIN(geometry, POLYGON ((1 1, 2 2, 0 3, 1 1)), 5, feet)" + + +def test_beyond_attr_polygon(): + assert encode( + ast.DistanceBeyond( + ast.Attribute("geometry"), + values.Geometry( + geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + ), + distance=5, + units="nautical miles", + ) + ) == "BEYOND(geometry, POLYGON ((1 1, 2 2, 0 3, 1 1)), 5, nautical miles)" + + +# BBox prediacte + + +def test_bbox_simple(): + assert encode( + ast.BBox( + ast.Attribute("geometry"), + 1, + 2, + 3, + 4, + ) + ) == "BBOX(geometry, 1, 2, 3, 4)" + + +def test_bbox_crs(): + assert encode( + ast.BBox( + ast.Attribute("geometry"), + 1, + 2, + 3, + 4, + "EPSG:3875", + ) + ) == "BBOX(geometry, 1, 2, 3, 4, 'EPSG:3875')" + + +def test_bbox_negative(): + assert encode( + ast.BBox( + ast.Attribute("geometry"), + -3, + -4, + -1, + -2, + "EPSG:3875", + ) + ) == "BBOX(geometry, -3, -4, -1, -2, 'EPSG:3875')" + + +def test_attribute_arithmetic_add(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Add( + 5, + 2, + ), + ) + ) == "attr = 5 + 2" + + +def test_attribute_arithmetic_sub(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Sub( + 5, + 2, + ), + ) + ) == "attr = 5 - 2" + + +def test_attribute_arithmetic_mul(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Mul( + 5, + 2, + ), + ) + ) == "attr = 5 * 2" + + +def test_attribute_arithmetic_div(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Div( + 5, + 2, + ), + ) + ) == "attr = 5 / 2" + + +def test_attribute_arithmetic_add_mul(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Add( + 3, + ast.Mul( + 5, + 2, + ), + ), + ) + ) == "attr = 3 + 5 * 2" + + +def test_attribute_arithmetic_div_sub(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Sub( + ast.Div( + 3, + 5, + ), + 2, + ), + ) + ) == "attr = 3 / 5 - 2" + + +def test_attribute_arithmetic_div_sub_bracketted(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Div( + 3, + ast.Sub( + 5, + 2, + ), + ), + ) + ) == "attr = 3 / (5 - 2)" + + +# test function expression parsing + + +def test_function_no_arg(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Function("myfunc", []), + ) + ) == "attr = myfunc()" + + +def test_function_single_arg(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Function( + "myfunc", + [ + 1, + ], + ), + ) + ) == "attr = myfunc(1)" + + +def test_function_attr_string_arg(): + assert encode( + ast.Equal( + ast.Attribute("attr"), + ast.Function( + "myfunc", + [ + ast.Attribute("other_attr"), + "abc", + ], + ), + ) + ) == "attr = myfunc(other_attr, 'abc')" diff --git a/tests/parsers/ecql/test_parser.py b/tests/frontends/ecql/test_parser.py similarity index 99% rename from tests/parsers/ecql/test_parser.py rename to tests/frontends/ecql/test_parser.py index 59b41a4..e55b276 100644 --- a/tests/parsers/ecql/test_parser.py +++ b/tests/frontends/ecql/test_parser.py @@ -31,7 +31,7 @@ from pygeoif import geometry from pygeofilter import ast, values -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse def test_attribute_eq_literal(): diff --git a/tests/parsers/fes/__init__.py b/tests/frontends/fes/__init__.py similarity index 100% rename from tests/parsers/fes/__init__.py rename to tests/frontends/fes/__init__.py diff --git a/tests/parsers/fes/test_v11.py b/tests/frontends/fes/test_v11.py similarity index 99% rename from tests/parsers/fes/test_v11.py rename to tests/frontends/fes/test_v11.py index c54b3cd..db97f5e 100644 --- a/tests/parsers/fes/test_v11.py +++ b/tests/frontends/fes/test_v11.py @@ -1,5 +1,5 @@ from pygeofilter import ast, values -from pygeofilter.parsers.fes.v11 import parse +from pygeofilter.frontends.fes.v11 import parse def test_and(): diff --git a/tests/parsers/fes/test_v20.py b/tests/frontends/fes/test_v20.py similarity index 99% rename from tests/parsers/fes/test_v20.py rename to tests/frontends/fes/test_v20.py index ee06314..0d22443 100644 --- a/tests/parsers/fes/test_v20.py +++ b/tests/frontends/fes/test_v20.py @@ -3,7 +3,7 @@ from dateparser.timezone_parser import StaticTzInfo from pygeofilter import ast, values -from pygeofilter.parsers.fes.v20 import parse +from pygeofilter.frontends.fes.v20 import parse def test_and(): diff --git a/tests/parsers/jfe/__init__.py b/tests/frontends/jfe/__init__.py similarity index 100% rename from tests/parsers/jfe/__init__.py rename to tests/frontends/jfe/__init__.py diff --git a/tests/parsers/jfe/test_parser.py b/tests/frontends/jfe/test_parser.py similarity index 99% rename from tests/parsers/jfe/test_parser.py rename to tests/frontends/jfe/test_parser.py index 21a4c65..f2f4eb3 100644 --- a/tests/parsers/jfe/test_parser.py +++ b/tests/frontends/jfe/test_parser.py @@ -32,7 +32,7 @@ from pygeoif import geometry from pygeofilter import ast, values -from pygeofilter.parsers.jfe import parse +from pygeofilter.frontends.jfe import parse def normalize_geom(geometry): diff --git a/tests/native/test_evaluate.py b/tests/native/test_evaluate.py index c9867b9..5f365ef 100644 --- a/tests/native/test_evaluate.py +++ b/tests/native/test_evaluate.py @@ -8,7 +8,7 @@ from pygeofilter import ast from pygeofilter.backends.native.evaluate import NativeEvaluator -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse @dataclass diff --git a/tests/test_geopandas/test_evaluate.py b/tests/test_geopandas/test_evaluate.py index 70b8dff..d1b52f6 100644 --- a/tests/test_geopandas/test_evaluate.py +++ b/tests/test_geopandas/test_evaluate.py @@ -6,7 +6,7 @@ from shapely.geometry import Point from pygeofilter.backends.geopandas.evaluate import to_filter -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse @pytest.fixture diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 1a73b2d..7d388fd 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -27,7 +27,7 @@ from pygeofilter import ast from pygeofilter.backends.optimize import optimize -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse def test_not(): diff --git a/tests/test_sql/test_evaluate.py b/tests/test_sql/test_evaluate.py index ee7fe3c..e50d7f5 100644 --- a/tests/test_sql/test_evaluate.py +++ b/tests/test_sql/test_evaluate.py @@ -29,7 +29,7 @@ from osgeo import ogr from pygeofilter.backends.sql import to_sql_where -from pygeofilter.parsers.ecql import parse +from pygeofilter.frontends.ecql import parse ogr.UseExceptions()