├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── docs ├── Makefile ├── api │ ├── ast.rst │ ├── index.rst │ ├── integrations │ │ └── django │ │ │ ├── evaluate.rst │ │ │ ├── filters.rst │ │ │ └── parser.rst │ ├── lexer.rst │ ├── main.rst │ ├── parser.rst │ ├── util.rst │ └── values.rst ├── conf.py ├── index.rst ├── installation.rst ├── introduction.rst ├── make.bat └── usage.rst ├── pycql ├── __init__.py ├── ast.py ├── integrations │ ├── __init__.py │ ├── django │ │ ├── __init__.py │ │ ├── evaluate.py │ │ ├── filters.py │ │ └── parser.py │ └── sqlalchemy │ │ ├── README.md │ │ ├── __init__.py │ │ ├── evaluate.py │ │ ├── filters.py │ │ └── parser.py ├── lexer.py ├── lextab.py ├── parser.out ├── parser.py ├── parsetab.py ├── util.py └── values.py ├── requirements-dev.txt ├── requirements-test.txt ├── requirements.txt ├── setup.py └── tests ├── django_test ├── django_test │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── testapp │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── sqlalchemy_test ├── __init__.py └── tests.py ├── test_lexer.py └── test_parser.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build ⚙️ 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | name: Setup Python ${{ matrix.python-version }} 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install requirements 📦 18 | run: | 19 | sudo apt-get install -y binutils libproj-dev gdal-bin libsqlite3-mod-spatialite spatialite-bin 20 | pip install -r requirements.txt 21 | pip install -r requirements-test.txt 22 | pip install -r requirements-dev.txt 23 | pip install . 24 | - name: Run unit tests ⚙️ 25 | run: | 26 | pytest 27 | cd tests/django_test 28 | python manage.py test testapp 29 | publish: 30 | runs-on: ubuntu-20.04 31 | needs: test 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions/setup-python@v2 35 | name: Setup Python 36 | with: 37 | python-version: "3.x" 38 | - name: Install build dependency 39 | run: pip install wheel 40 | - name: Build package 🏗️ 41 | run: python setup.py sdist bdist_wheel --universal 42 | - name: Publish package 43 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | with: 46 | user: __token__ 47 | password: ${{ secrets.PYPI_API_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .doctrees -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2019 EOX IT Services GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pycql *.py 2 | include README.md 3 | include LICENSE 4 | include requirements.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ------- 2 | ------- 3 | 4 | 5 | # DEPRECATED 6 | 7 | This project is superseded by [pygeofilter](https://github.com/geopython/pygeofilter). 8 | 9 | ------- 10 | ------- 11 | 12 | # pycql 13 | 14 | [![PyPI version](https://badge.fury.io/py/pycql.svg)](https://badge.fury.io/py/pycql) 15 | [![Build Status](https://github.com/geopython/pycql/workflows/build%20%E2%9A%99%EF%B8%8F/badge.svg)](https://github.com/geopython/pycql/actions) 16 | [![Documentation Status](https://readthedocs.org/projects/pycql/badge/?version=latest)](https://pycql.readthedocs.io/en/latest/?badge=latest) 17 | 18 | pycql is a pure Python parser implementation of the OGC CQL standard 19 | 20 | ## Installation 21 | 22 | ```bash 23 | pip install pycql 24 | ``` 25 | 26 | ## Usage 27 | 28 | The basic functionality parses the input string to an abstract syntax tree (AST) representation. 29 | This AST can then be used to build database filters or similar functionality. 30 | 31 | ```python 32 | >>> import pycql 33 | >>> ast = pycql.parse(filter_expression) 34 | ``` 35 | 36 | ### Inspection 37 | 38 | The easiest way to inspect the resulting AST is to use the `get_repr` function, which returns a 39 | nice string representation of what was parsed: 40 | 41 | ```python 42 | >>> ast = pycql.parse('id = 10') 43 | >>> print(pycql.get_repr(ast)) 44 | ATTRIBUTE id = LITERAL 10.0 45 | >>> 46 | >>> 47 | >>> filter_expr = '(number BETWEEN 5 AND 10 AND string NOT LIKE "%B") OR INTERSECTS(geometry, LINESTRING(0 0, 1 1))' 48 | >>> print(pycql.get_repr(pycql.parse(filter_expr))) 49 | ( 50 | ( 51 | ATTRIBUTE number BETWEEN LITERAL 5.0 AND LITERAL 10.0 52 | ) AND ( 53 | ATTRIBUTE string NOT ILIKE LITERAL '%B' 54 | ) 55 | ) OR ( 56 | INTERSECTS(ATTRIBUTE geometry, LITERAL GEOMETRY 'LINESTRING(0 0, 1 1)') 57 | ) 58 | ``` 59 | 60 | ### Evaluation 61 | 62 | In order to create useful filters from the resulting AST, it has to be evaluated. For the 63 | Django integration, this was done using a recursive descent into the AST, evaluating the 64 | subnodes first and constructing a `Q` object. Consider having a `filters` API (for an 65 | example look at the Django one) which creates the filter. Now the evaluator looks something 66 | like this: 67 | 68 | ```python 69 | 70 | from pycql.ast import * 71 | from myapi import filters # <- this is where the filters are created. 72 | # of course, this can also be done in the 73 | # evaluator itself 74 | class FilterEvaluator: 75 | def __init__(self, field_mapping=None, mapping_choices=None): 76 | self.field_mapping = field_mapping 77 | self.mapping_choices = mapping_choices 78 | 79 | def to_filter(self, node): 80 | to_filter = self.to_filter 81 | if isinstance(node, NotConditionNode): 82 | return filters.negate(to_filter(node.sub_node)) 83 | elif isinstance(node, CombinationConditionNode): 84 | return filters.combine( 85 | (to_filter(node.lhs), to_filter(node.rhs)), node.op 86 | ) 87 | elif isinstance(node, ComparisonPredicateNode): 88 | return filters.compare( 89 | to_filter(node.lhs), to_filter(node.rhs), node.op, 90 | self.mapping_choices 91 | ) 92 | elif isinstance(node, BetweenPredicateNode): 93 | return filters.between( 94 | to_filter(node.lhs), to_filter(node.low), 95 | to_filter(node.high), node.not_ 96 | ) 97 | elif isinstance(node, BetweenPredicateNode): 98 | return filters.between( 99 | to_filter(node.lhs), to_filter(node.low), 100 | to_filter(node.high), node.not_ 101 | ) 102 | 103 | # ... Some nodes are left out for brevity 104 | 105 | elif isinstance(node, AttributeExpression): 106 | return filters.attribute(node.name, self.field_mapping) 107 | 108 | elif isinstance(node, LiteralExpression): 109 | return node.value 110 | 111 | elif isinstance(node, ArithmeticExpressionNode): 112 | return filters.arithmetic( 113 | to_filter(node.lhs), to_filter(node.rhs), node.op 114 | ) 115 | 116 | return node 117 | ``` 118 | 119 | As mentionend, the `to_filter` method is the recursion. 120 | 121 | ## Testing 122 | 123 | The basic functionality can be tested using `pytest`. 124 | 125 | ```bash 126 | python -m pytest 127 | ``` 128 | 129 | There is a test project/app to test the Django integration. This is tested using the following 130 | command: 131 | 132 | ```bash 133 | python manage.py test testapp 134 | ``` 135 | 136 | 137 | ## Django integration 138 | 139 | For Django there is a default bridging implementation, where all the filters are translated to the 140 | Django ORM. In order to use this integration, we need two dictionaries, one mapping the available 141 | fields to the Django model fields, and one to map the fields that use `choices`. Consider the 142 | following example models: 143 | 144 | ```python 145 | from django.contrib.gis.db import models 146 | 147 | 148 | optional = dict(null=True, blank=True) 149 | 150 | class Record(models.Model): 151 | identifier = models.CharField(max_length=256, unique=True, null=False) 152 | geometry = models.GeometryField() 153 | 154 | float_attribute = models.FloatField(**optional) 155 | int_attribute = models.IntegerField(**optional) 156 | str_attribute = models.CharField(max_length=256, **optional) 157 | datetime_attribute = models.DateTimeField(**optional) 158 | choice_attribute = models.PositiveSmallIntegerField(choices=[ 159 | (1, 'ASCENDING'), 160 | (2, 'DESCENDING'),], 161 | **optional) 162 | 163 | 164 | class RecordMeta(models.Model): 165 | record = models.ForeignKey(Record, on_delete=models.CASCADE, related_name='record_metas') 166 | 167 | float_meta_attribute = models.FloatField(**optional) 168 | int_meta_attribute = models.IntegerField(**optional) 169 | str_meta_attribute = models.CharField(max_length=256, **optional) 170 | datetime_meta_attribute = models.DateTimeField(**optional) 171 | choice_meta_attribute = models.PositiveSmallIntegerField(choices=[ 172 | (1, 'X'), 173 | (2, 'Y'), 174 | (3, 'Z')], 175 | **optional) 176 | ``` 177 | 178 | Now we can specify the field mappings and mapping choices to be used when applying the filters: 179 | 180 | ```python 181 | FIELD_MAPPING = { 182 | 'identifier': 'identifier', 183 | 'geometry': 'geometry', 184 | 'floatAttribute': 'float_attribute', 185 | 'intAttribute': 'int_attribute', 186 | 'strAttribute': 'str_attribute', 187 | 'datetimeAttribute': 'datetime_attribute', 188 | 'choiceAttribute': 'choice_attribute', 189 | 190 | # meta fields 191 | 'floatMetaAttribute': 'record_metas__float_meta_attribute', 192 | 'intMetaAttribute': 'record_metas__int_meta_attribute', 193 | 'strMetaAttribute': 'record_metas__str_meta_attribute', 194 | 'datetimeMetaAttribute': 'record_metas__datetime_meta_attribute', 195 | 'choiceMetaAttribute': 'record_metas__choice_meta_attribute', 196 | } 197 | 198 | MAPPING_CHOICES = { 199 | 'choiceAttribute': dict(Record._meta.get_field('choice_attribute').choices), 200 | 'choiceMetaAttribute': dict(RecordMeta._meta.get_field('choice_meta_attribute').choices), 201 | } 202 | ``` 203 | 204 | Finally we are able to connect the CQL AST to the Django database models. We also provide factory 205 | functions to parse the timestamps, durations, geometries and envelopes, so that they can be used 206 | with the ORM layer: 207 | 208 | ```python 209 | from pycql.integrations.django import to_filter, parse 210 | 211 | cql_expr = 'strMetaAttribute LIKE "%parent%" AND datetimeAttribute BEFORE 2000-01-01T00:00:01Z' 212 | 213 | # NOTE: we are using the django integration `parse` wrapper here 214 | ast = parse(cql_expr) 215 | filters = to_filter(ast, mapping, mapping_choices) 216 | 217 | qs = Record.objects.filter(**filters) 218 | ``` 219 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pygeoapi (0.0.8) focal; urgency=medium 2 | 3 | * New upstream release 4 | 5 | -- Tom Kralidis Sun, 21 Mar 2021 14:44:12 +0000 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pycql 2 | Section: python 3 | Priority: optional 4 | Maintainer: Fabian Schindler 5 | Uploaders: Angelos Tzotsos 6 | Build-Depends: debhelper (>= 9), 7 | dh-python, 8 | python3-all, 9 | python3-setuptools 10 | Standards-Version: 4.3.0 11 | Vcs-Git: https://github.com/geopython/pycql.git 12 | Homepage: https://geopython.github.io/pycql 13 | 14 | Package: python3-pycql 15 | Architecture: all 16 | Depends: ${python3:Depends}, 17 | python3-dateparser, 18 | python3-ply, 19 | ${misc:Depends} 20 | Description: CQL parser for Python 21 | pycql is a pure Python parser implementation of the OGC CQL standard. 22 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Source: https://github.com/geopython/pycql 3 | 4 | Files: * 5 | Copyright: Copyright (C) 2019 EOX IT Services GmbH 6 | License: MIT 7 | Permission is hereby granted, free of charge, to any person 8 | obtaining a copy of this software and associated documentation 9 | files (the "Software"), to deal in the Software without 10 | restriction, including without limitation the rights to use, 11 | copy, modify, merge, publish, distribute, sublicense, and/or 12 | sell copies of the Software, and to permit persons to whom 13 | the Software is furnished to do so, subject to the following 14 | conditions: 15 | . 16 | The above copyright notice and this permission notice shall be 17 | included in all copies or substantial portions of the Software. 18 | . 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 21 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 23 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 24 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | OTHER DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | # Uncomment this to turn on verbose mode. 5 | #export DH_VERBOSE=1 6 | 7 | export PYBUILD_NAME=pycql 8 | 9 | %: 10 | dh $@ --with python3 --buildsystem pybuild 11 | 12 | override_dh_auto_test: 13 | @echo "nocheck set, not running tests" 14 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/api/ast.rst: -------------------------------------------------------------------------------- 1 | pycql.ast 2 | ========= 3 | 4 | .. automodule:: pycql.ast 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | main 9 | ast 10 | lexer 11 | parser 12 | util 13 | values 14 | integrations/django/evaluate 15 | integrations/django/filters 16 | integrations/django/parser 17 | -------------------------------------------------------------------------------- /docs/api/integrations/django/evaluate.rst: -------------------------------------------------------------------------------- 1 | pycql.integrations.django.evaluate 2 | ================================== 3 | 4 | .. automodule:: pycql.integrations.django.evaluate 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/integrations/django/filters.rst: -------------------------------------------------------------------------------- 1 | pycql.integrations.django.filters 2 | ================================= 3 | 4 | .. automodule:: pycql.integrations.django.filters 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/integrations/django/parser.rst: -------------------------------------------------------------------------------- 1 | pycql.integrations.django.parser 2 | ================================ 3 | 4 | .. automodule:: pycql.integrations.django.parser 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/lexer.rst: -------------------------------------------------------------------------------- 1 | pycql.lexer 2 | =========== 3 | 4 | .. automodule:: pycql.lexer 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/main.rst: -------------------------------------------------------------------------------- 1 | pycql 2 | ===== 3 | 4 | .. automodule:: pycql 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/parser.rst: -------------------------------------------------------------------------------- 1 | pycql.parser 2 | ============ 3 | 4 | .. automodule:: pycql.parser 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/util.rst: -------------------------------------------------------------------------------- 1 | pycql.util 2 | ========== 3 | 4 | .. automodule:: pycql.util 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/values.rst: -------------------------------------------------------------------------------- 1 | pycql.values 2 | ============ 3 | 4 | .. automodule:: pycql.values 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'pycql' 23 | copyright = '2019, Fabian Schindler' 24 | author = 'Fabian Schindler' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '0.0.3' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.intersphinx', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path. 68 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = None 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ['_static'] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = 'pycqldoc' 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'pycql.tex', 'pycql Documentation', 134 | 'Fabian Schindler', 'manual'), 135 | ] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'pycql', 'pycql Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'pycql', 'pycql Documentation', 155 | author, 'pycql', 'One line description of project.', 156 | 'Miscellaneous'), 157 | ] 158 | 159 | 160 | # -- Options for Epub output ------------------------------------------------- 161 | 162 | # Bibliographic Dublin Core info. 163 | epub_title = project 164 | 165 | # The unique identifier of the text. This can be a ISBN number 166 | # or the project homepage. 167 | # 168 | # epub_identifier = '' 169 | 170 | # A unique identification for the text. 171 | # 172 | # epub_uid = '' 173 | 174 | # A list of files that should not be packed into the epub file. 175 | epub_exclude_files = ['search.html'] 176 | 177 | 178 | # -- Extension configuration ------------------------------------------------- 179 | 180 | 181 | intersphinx_mapping = { 182 | 'python': ('https://python.readthedocs.org/en/latest/', None), 183 | 'django': ('https://django.readthedocs.org/en/latest/', None), 184 | } 185 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pycql's documentation! 2 | ================================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | introduction 9 | installation 10 | usage 11 | api/index 12 | 13 | Indices and tables 14 | ================== 15 | 16 | * :ref:`genindex` 17 | * :ref:`modindex` 18 | * :ref:`search` 19 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | pycql can be installed using ``pip`` from the Python Package Index (PyPI):: 5 | 6 | pip install pycql 7 | 8 | You can also install pycql from source:: 9 | 10 | cd path/to/pycql/ 11 | python setup.py install 12 | 13 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | pycql is a pure python parser of the Common Query Language (CQL) defined in the 5 | `OGC Catalogue specification `_. 6 | 7 | The basic bare-bone functionality is to parse the given CQL to an abstract 8 | syntax tree (AST) representation. This AST can then be used to create filters 9 | for databases or search engines. 10 | 11 | 12 | pycql license 13 | ------------- 14 | 15 | Copyright (C) 2019 EOX IT Services GmbH 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies of this Software or works derived from this Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 33 | THE SOFTWARE. 34 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | 5 | The basic functionality parses the input string to an abstract syntax tree 6 | (AST) representation. This AST can then be used to build database filters 7 | or similar functionality. 8 | 9 | .. code-block:: pycon 10 | 11 | >>> import pycql 12 | >>> ast = pycql.parse(filter_expression) 13 | 14 | What is returned by the :func:`pycql.parser.parse` is the root 15 | :class:`pycql.ast.Node` of the AST representation. 16 | 17 | Inspection 18 | ---------- 19 | 20 | The easiest way to inspect the resulting AST is to use the 21 | :func:`pycql.ast.get_repr` function, which returns a nice string 22 | representation of what was parsed: 23 | 24 | .. code-block:: pycon 25 | 26 | >>> ast = pycql.parse('id = 10') 27 | >>> print(pycql.get_repr(ast)) 28 | ATTRIBUTE id = LITERAL 10.0 29 | >>> 30 | >>> 31 | >>> filter_expr = '(number BETWEEN 5 AND 10 AND string NOT LIKE "%B") OR INTERSECTS(geometry, LINESTRING(0 0, 1 1))' 32 | >>> print(pycql.get_repr(pycql.parse(filter_expr))) 33 | ( 34 | ( 35 | ATTRIBUTE number BETWEEN LITERAL 5.0 AND LITERAL 10.0 36 | ) AND ( 37 | ATTRIBUTE string NOT ILIKE LITERAL '%B' 38 | ) 39 | ) OR ( 40 | INTERSECTS(ATTRIBUTE geometry, LITERAL GEOMETRY 'LINESTRING(0 0, 1 1)') 41 | ) 42 | 43 | Evaluation 44 | ---------- 45 | 46 | In order to create useful filters from the resulting AST, it has to be 47 | evaluated. For the Django integration, this was done using a recursive 48 | descent into the AST, evaluating the subnodes first and constructing a 49 | `Q` object. Consider having a `filters` API (for an example look at the 50 | Django one) which creates the filter. Now the evaluator looks something 51 | like this: 52 | 53 | .. code-block:: python 54 | 55 | from pycql.ast import * 56 | from myapi import filters # <- this is where the filters are created. 57 | # of course, this can also be done in the 58 | # evaluator itself 59 | class FilterEvaluator: 60 | def __init__(self, field_mapping=None, mapping_choices=None): 61 | self.field_mapping = field_mapping 62 | self.mapping_choices = mapping_choices 63 | 64 | def to_filter(self, node): 65 | to_filter = self.to_filter 66 | if isinstance(node, NotConditionNode): 67 | return filters.negate(to_filter(node.sub_node)) 68 | elif isinstance(node, CombinationConditionNode): 69 | return filters.combine( 70 | (to_filter(node.lhs), to_filter(node.rhs)), node.op 71 | ) 72 | elif isinstance(node, ComparisonPredicateNode): 73 | return filters.compare( 74 | to_filter(node.lhs), to_filter(node.rhs), node.op, 75 | self.mapping_choices 76 | ) 77 | elif isinstance(node, BetweenPredicateNode): 78 | return filters.between( 79 | to_filter(node.lhs), to_filter(node.low), 80 | to_filter(node.high), node.not_ 81 | ) 82 | elif isinstance(node, BetweenPredicateNode): 83 | return filters.between( 84 | to_filter(node.lhs), to_filter(node.low), 85 | to_filter(node.high), node.not_ 86 | ) 87 | 88 | # ... Some nodes are left out for brevity 89 | 90 | elif isinstance(node, AttributeExpression): 91 | return filters.attribute(node.name, self.field_mapping) 92 | 93 | elif isinstance(node, LiteralExpression): 94 | return node.value 95 | 96 | elif isinstance(node, ArithmeticExpressionNode): 97 | return filters.arithmetic( 98 | to_filter(node.lhs), to_filter(node.rhs), node.op 99 | ) 100 | 101 | return node 102 | 103 | As mentionend, the `to_filter` method is the recursion. 104 | 105 | 106 | Django integration 107 | ------------------ 108 | 109 | For Django there is a default bridging implementation, where all the filters 110 | are translated to the Django ORM. In order to use this integration, we need 111 | two dictionaries, one mapping the available fields to the Django model fields, 112 | and one to map the fields that use ``choices``. Consider the following example 113 | models: 114 | 115 | .. code-block:: python 116 | 117 | from django.contrib.gis.db import models 118 | 119 | 120 | optional = dict(null=True, blank=True) 121 | 122 | class Record(models.Model): 123 | identifier = models.CharField(max_length=256, unique=True, null=False) 124 | geometry = models.GeometryField() 125 | 126 | float_attribute = models.FloatField(**optional) 127 | int_attribute = models.IntegerField(**optional) 128 | str_attribute = models.CharField(max_length=256, **optional) 129 | datetime_attribute = models.DateTimeField(**optional) 130 | choice_attribute = models.PositiveSmallIntegerField(choices=[ 131 | (1, 'ASCENDING'), 132 | (2, 'DESCENDING'),], 133 | **optional) 134 | 135 | 136 | class RecordMeta(models.Model): 137 | record = models.ForeignKey(Record, on_delete=models.CASCADE, related_name='record_metas') 138 | 139 | float_meta_attribute = models.FloatField(**optional) 140 | int_meta_attribute = models.IntegerField(**optional) 141 | str_meta_attribute = models.CharField(max_length=256, **optional) 142 | datetime_meta_attribute = models.DateTimeField(**optional) 143 | choice_meta_attribute = models.PositiveSmallIntegerField(choices=[ 144 | (1, 'X'), 145 | (2, 'Y'), 146 | (3, 'Z')], 147 | **optional) 148 | 149 | 150 | Now we can specify the field mappings and mapping choices to be used when 151 | applying the filters: 152 | 153 | .. code-block:: python 154 | 155 | FIELD_MAPPING = { 156 | 'identifier': 'identifier', 157 | 'geometry': 'geometry', 158 | 'floatAttribute': 'float_attribute', 159 | 'intAttribute': 'int_attribute', 160 | 'strAttribute': 'str_attribute', 161 | 'datetimeAttribute': 'datetime_attribute', 162 | 'choiceAttribute': 'choice_attribute', 163 | 164 | # meta fields 165 | 'floatMetaAttribute': 'record_metas__float_meta_attribute', 166 | 'intMetaAttribute': 'record_metas__int_meta_attribute', 167 | 'strMetaAttribute': 'record_metas__str_meta_attribute', 168 | 'datetimeMetaAttribute': 'record_metas__datetime_meta_attribute', 169 | 'choiceMetaAttribute': 'record_metas__choice_meta_attribute', 170 | } 171 | 172 | MAPPING_CHOICES = { 173 | 'choiceAttribute': dict(Record._meta.get_field('choice_attribute').choices), 174 | 'choiceMetaAttribute': dict(RecordMeta._meta.get_field('choice_meta_attribute').choices), 175 | } 176 | 177 | 178 | Finally we are able to connect the CQL AST to the Django database models. We 179 | also provide factory functions to parse the timestamps, durations, geometries and 180 | envelopes, so that they can be used with the ORM layer: 181 | 182 | .. code-block:: python 183 | 184 | from pycql.integrations.django import to_filter, parse 185 | 186 | cql_expr = 'strMetaAttribute LIKE "%parent%" AND datetimeAttribute BEFORE 2000-01-01T00:00:01Z' 187 | 188 | # NOTE: we are using the django integration `parse` wrapper here 189 | ast = parse(cql_expr) 190 | filters = to_filter(ast, mapping, mapping_choices) 191 | 192 | qs = Record.objects.filter(**filters) 193 | 194 | -------------------------------------------------------------------------------- /pycql/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from .parser import parse 29 | from .ast import get_repr 30 | 31 | __version__ = '0.0.12' 32 | -------------------------------------------------------------------------------- /pycql/ast.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """ 29 | """ 30 | 31 | 32 | class Node: 33 | """ The base class for all other nodes to display the AST of CQL. 34 | """ 35 | inline = False 36 | 37 | def get_sub_nodes(self): 38 | """ Get a list of sub-node of this node. 39 | 40 | :return: a list of all sub-nodes 41 | :rtype: list[Node] 42 | """ 43 | raise NotImplementedError 44 | 45 | def get_template(self): 46 | """ Get a template string (using the ``%`` operator) 47 | to represent the current node and sub-nodes. The template string 48 | must provide a template replacement for each sub-node reported by 49 | :func:`~pycql.ast.Node.get_sub_nodes`. 50 | 51 | :return: the template to render 52 | """ 53 | raise NotImplementedError 54 | 55 | def __eq__(self, other): 56 | if type(self) != type(other): 57 | return False 58 | 59 | return self.__dict__ == other.__dict__ 60 | 61 | 62 | class ConditionNode(Node): 63 | """ The base class for all nodes representing a condition 64 | """ 65 | pass 66 | 67 | 68 | class NotConditionNode(ConditionNode): 69 | """ 70 | Node class to represent a negation condition. 71 | 72 | :ivar sub_node: the condition node to be negated 73 | :type sub_node: Node 74 | """ 75 | 76 | def __init__(self, sub_node): 77 | self.sub_node = sub_node 78 | 79 | def get_sub_nodes(self): 80 | """ Returns the sub-node for the negated condition. """ 81 | return [self.sub_node] 82 | 83 | def get_template(self): 84 | return "NOT %s" 85 | 86 | 87 | class CombinationConditionNode(ConditionNode): 88 | """ Node class to represent a condition to combine two other conditions 89 | using either AND or OR. 90 | 91 | :ivar lhs: the left hand side node of this combination 92 | :type lhs: Node 93 | :ivar rhs: the right hand side node of this combination 94 | :type rhs: Node 95 | :ivar op: the combination type. Either ``"AND"`` or ``"OR"`` 96 | :type op: str 97 | """ 98 | def __init__(self, lhs, rhs, op): 99 | self.lhs = lhs 100 | self.rhs = rhs 101 | self.op = op 102 | 103 | def get_sub_nodes(self): 104 | return [self.lhs, self.rhs] 105 | 106 | def get_template(self): 107 | return "%%s %s %%s" % self.op 108 | 109 | 110 | class PredicateNode(Node): 111 | """ The base class for all nodes representing a predicate 112 | """ 113 | pass 114 | 115 | 116 | class ComparisonPredicateNode(PredicateNode): 117 | """ Node class to represent a comparison predicate: to compare two 118 | expressions using a comparison operation. 119 | 120 | :ivar lhs: the left hand side node of this comparison 121 | :type lhs: Node 122 | :ivar rhs: the right hand side node of this comparison 123 | :type rhs: Node 124 | :ivar op: the comparison type. One of ``"="``, ``"<>"``, ``"<"``, 125 | ``">"``, ``"<="``, ``">="`` 126 | :type op: str 127 | """ 128 | def __init__(self, lhs, rhs, op): 129 | self.lhs = lhs 130 | self.rhs = rhs 131 | self.op = op 132 | 133 | def get_sub_nodes(self): 134 | return [self.lhs, self.rhs] 135 | 136 | def get_template(self): 137 | return "%%s %s %%s" % self.op 138 | 139 | 140 | class BetweenPredicateNode(PredicateNode): 141 | """ Node class to represent a BETWEEN predicate: to check whether an 142 | expression value within a range. 143 | 144 | :ivar lhs: the left hand side node of this comparison 145 | :type lhs: Node 146 | :ivar low: the lower bound of the clause 147 | :type low: Node 148 | :ivar high: the upper bound of the clause 149 | :type high: Node 150 | :ivar not_: whether the predicate shall be negated 151 | :type not_: bool 152 | """ 153 | def __init__(self, lhs, low, high, not_): 154 | self.lhs = lhs 155 | self.low = low 156 | self.high = high 157 | self.not_ = not_ 158 | 159 | def get_sub_nodes(self): 160 | return [self.lhs, self.low, self.high] 161 | 162 | def get_template(self): 163 | return "%%s %sBETWEEN %%s AND %%s" % ("NOT " if self.not_ else "") 164 | 165 | 166 | class LikePredicateNode(PredicateNode): 167 | """ Node class to represent a wildcard sting matching predicate. 168 | 169 | :ivar lhs: the left hand side node of this predicate 170 | :type lhs: Node 171 | :ivar rhs: the right hand side node of this predicate 172 | :type rhs: Node 173 | :ivar case: whether the comparison shall be case sensitive 174 | :type case: bool 175 | :ivar not_: whether the predicate shall be negated 176 | :type not_: bool 177 | """ 178 | def __init__(self, lhs, rhs, case, not_): 179 | self.lhs = lhs 180 | self.rhs = rhs 181 | self.case = case 182 | self.not_ = not_ 183 | 184 | def get_sub_nodes(self): 185 | return [self.lhs, self.rhs] 186 | 187 | def get_template(self): 188 | return "%%s %s%sLIKE %%s" % ( 189 | "NOT " if self.not_ else "", 190 | "I" if self.case else "" 191 | ) 192 | 193 | 194 | class InPredicateNode(PredicateNode): 195 | """ Node class to represent list checking predicate. 196 | 197 | :ivar lhs: the left hand side node of this predicate 198 | :type lhs: Node 199 | :ivar sub_nodes: the list of sub nodes to check the inclusion 200 | against 201 | :type sub_nodes: list[Node] 202 | :ivar not_: whether the predicate shall be negated 203 | :type not_: bool 204 | """ 205 | def __init__(self, lhs, sub_nodes, not_): 206 | self.lhs = lhs 207 | self.sub_nodes = sub_nodes 208 | self.not_ = not_ 209 | 210 | def get_sub_nodes(self): 211 | return [self.lhs] + list(self.sub_nodes) 212 | 213 | def get_template(self): 214 | return "%%s %sIN (%s)" % ( 215 | "NOT " if self.not_ else "", 216 | ", ".join(["%s"] * len(self.sub_nodes)) 217 | ) 218 | 219 | 220 | class NullPredicateNode(PredicateNode): 221 | """ Node class to represent null check predicate. 222 | 223 | :ivar lhs: the left hand side node of this predicate 224 | :type lhs: Node 225 | :ivar not_: whether the predicate shall be negated 226 | :type not_: bool 227 | """ 228 | def __init__(self, lhs, not_): 229 | self.lhs = lhs 230 | self.not_ = not_ 231 | 232 | def get_sub_nodes(self): 233 | return [self.lhs] 234 | 235 | def get_template(self): 236 | return "%%s IS %sNULL" % ("NOT " if self.not_ else "") 237 | 238 | 239 | # class ExistsPredicateNode(PredicateNode): 240 | # pass 241 | 242 | 243 | class TemporalPredicateNode(PredicateNode): 244 | """ Node class to represent temporal predicate. 245 | 246 | :ivar lhs: the left hand side node of this comparison 247 | :type lhs: Node 248 | :ivar rhs: the right hand side node of this comparison 249 | :type rhs: Node 250 | :ivar op: the comparison type. One of ``"BEFORE"``, 251 | ``"BEFORE OR DURING"``, ``"DURING"``, 252 | ``"DURING OR AFTER"``, ``"AFTER"`` 253 | :type op: str 254 | """ 255 | def __init__(self, lhs, rhs, op): 256 | self.lhs = lhs 257 | self.rhs = rhs 258 | self.op = op 259 | 260 | def get_sub_nodes(self): 261 | return [self.lhs, self.rhs] 262 | 263 | def get_template(self): 264 | return "%%s %s %%s" % self.op 265 | 266 | 267 | class SpatialPredicateNode(PredicateNode): 268 | """ Node class to represent spatial relation predicate. 269 | 270 | :ivar lhs: the left hand side node of this comparison 271 | :type lhs: Node 272 | :ivar rhs: the right hand side node of this comparison 273 | :type rhs: Node 274 | :ivar op: the comparison type. One of ``"INTERSECTS"``, 275 | ``"DISJOINT"``, ``"CONTAINS"``, ``"WITHIN"``, 276 | ``"TOUCHES"``, ``"CROSSES"``, ``"OVERLAPS"``, 277 | ``"EQUALS"``, ``"RELATE"``, ``"DWITHIN"``, ``"BEYOND"`` 278 | :type op: str 279 | :ivar pattern: the relationship patter for the ``"RELATE"`` operation 280 | :type pattern: str or None 281 | :ivar distance: the distance for distance related operations 282 | :type distance: Node or None 283 | :ivar units: the units for distance related operations 284 | :type units: str or None 285 | """ 286 | def __init__(self, lhs, rhs, op, pattern=None, distance=None, units=None): 287 | self.lhs = lhs 288 | self.rhs = rhs 289 | self.op = op 290 | self.pattern = pattern 291 | self.distance = distance 292 | self.units = units 293 | 294 | def get_sub_nodes(self): 295 | return [self.lhs, self.rhs] 296 | 297 | def get_template(self): 298 | if self.pattern: 299 | return "%s(%%s, %%s, %r)" % (self.op, self.pattern) 300 | elif self.distance or self.units: 301 | return "%s(%%s, %%s, %r, %r)" % (self.op, self.distance, self.units) 302 | else: 303 | return "%s(%%s, %%s)" % (self.op) 304 | 305 | 306 | class BBoxPredicateNode(PredicateNode): 307 | """ Node class to represent a bounding box predicate. 308 | 309 | :ivar lhs: the left hand side node of this predicate 310 | :type lhs: Node 311 | :ivar minx: the minimum X value of the bounding box 312 | :type minx: float 313 | :ivar miny: the minimum Y value of the bounding box 314 | :type miny: float 315 | :ivar maxx: the maximum X value of the bounding box 316 | :type maxx: float 317 | :ivar maxx: the maximum Y value of the bounding box 318 | :type maxx: float 319 | :ivar crs: the coordinate reference system identifier 320 | for the CRS the BBox is expressed in 321 | :type crs: str 322 | """ 323 | def __init__(self, lhs, minx, miny, maxx, maxy, crs=None): 324 | self.lhs = lhs 325 | self.minx = minx 326 | self.miny = miny 327 | self.maxx = maxx 328 | self.maxy = maxy 329 | self.crs = crs 330 | 331 | def get_sub_nodes(self): 332 | return [self.lhs] 333 | 334 | def get_template(self): 335 | return "BBOX(%%s, %r, %r, %r, %r, %r)" % ( 336 | self.minx, self.miny, self.maxx, self.maxy, self.crs 337 | ) 338 | 339 | 340 | class ExpressionNode(Node): 341 | """ The base class for all nodes representing expressions 342 | """ 343 | pass 344 | 345 | 346 | class AttributeExpression(ExpressionNode): 347 | """ Node class to represent attribute lookup expressions 348 | 349 | :ivar name: the name of the attribute to be accessed 350 | :type name: str 351 | """ 352 | inline = True 353 | 354 | def __init__(self, name): 355 | self.name = name 356 | 357 | def __repr__(self): 358 | return "ATTRIBUTE %s" % self.name 359 | 360 | 361 | class LiteralExpression(ExpressionNode): 362 | """ Node class to represent literal value expressions 363 | 364 | :ivar value: the value of the literal 365 | :type value: str, float, int, datetime, timedelta 366 | """ 367 | inline = True 368 | 369 | def __init__(self, value): 370 | self.value = value 371 | 372 | def __repr__(self): 373 | return "LITERAL %r" % self.value 374 | 375 | 376 | class ArithmeticExpressionNode(ExpressionNode): 377 | """ Node class to represent arithmetic operation expressions with two 378 | sub-expressions and an operator. 379 | 380 | :ivar lhs: the left hand side node of this arithmetic expression 381 | :type lhs: Node 382 | :ivar rhs: the right hand side node of this arithmetic expression 383 | :type rhs: Node 384 | :ivar op: the comparison type. One of ``"+"``, ``"-"``, ``"*"``, ``"/"`` 385 | :type op: str 386 | """ 387 | def __init__(self, lhs, rhs, op): 388 | self.lhs = lhs 389 | self.rhs = rhs 390 | self.op = op 391 | 392 | def get_sub_nodes(self): 393 | return [self.lhs, self.rhs] 394 | 395 | def get_template(self): 396 | return "%%s %s %%s" % self.op 397 | 398 | 399 | def indent(text, amount, ch=' '): 400 | padding = amount * ch 401 | return ''.join(padding+line for line in text.splitlines(True)) 402 | 403 | 404 | def get_repr(node, indent_amount=0, indent_incr=4): 405 | """ Get a debug representation of the given AST node. ``indent_amount`` 406 | and ``indent_incr`` are for the recursive call and don't need to be 407 | passed. 408 | 409 | :param Node node: the node to get the representation for 410 | :param int indent_amount: the current indentation level 411 | :param int indent_incr: the indentation incrementation per level 412 | :return: the represenation of the node 413 | :rtype: str 414 | """ 415 | sub_nodes = node.get_sub_nodes() 416 | template = node.get_template() 417 | 418 | args = [] 419 | for sub_node in sub_nodes: 420 | if isinstance(sub_node, Node) and not sub_node.inline: 421 | args.append("(\n%s\n)" % 422 | indent( 423 | get_repr(sub_node, indent_amount + indent_incr, indent_incr), 424 | indent_amount + indent_incr 425 | ) 426 | ) 427 | else: 428 | args.append(repr(sub_node)) 429 | 430 | return template % tuple(args) 431 | -------------------------------------------------------------------------------- /pycql/integrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/pycql/integrations/__init__.py -------------------------------------------------------------------------------- /pycql/integrations/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .evaluate import to_filter 2 | from .parser import parse -------------------------------------------------------------------------------- /pycql/integrations/django/evaluate.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | 29 | from . import filters 30 | from ...parser import parse 31 | from ...ast import ( 32 | NotConditionNode, CombinationConditionNode, ComparisonPredicateNode, 33 | BetweenPredicateNode, BetweenPredicateNode, LikePredicateNode, 34 | InPredicateNode, NullPredicateNode, TemporalPredicateNode, 35 | SpatialPredicateNode, BBoxPredicateNode, AttributeExpression, 36 | LiteralExpression, ArithmeticExpressionNode, 37 | ) 38 | 39 | 40 | class FilterEvaluator: 41 | def __init__(self, field_mapping=None, mapping_choices=None): 42 | self.field_mapping = field_mapping 43 | self.mapping_choices = mapping_choices 44 | 45 | def to_filter(self, node): 46 | to_filter = self.to_filter 47 | if isinstance(node, NotConditionNode): 48 | return filters.negate(to_filter(node.sub_node)) 49 | elif isinstance(node, CombinationConditionNode): 50 | return filters.combine( 51 | (to_filter(node.lhs), to_filter(node.rhs)), node.op 52 | ) 53 | elif isinstance(node, ComparisonPredicateNode): 54 | return filters.compare( 55 | to_filter(node.lhs), to_filter(node.rhs), node.op, 56 | self.mapping_choices 57 | ) 58 | elif isinstance(node, BetweenPredicateNode): 59 | return filters.between( 60 | to_filter(node.lhs), to_filter(node.low), to_filter(node.high), 61 | node.not_ 62 | ) 63 | elif isinstance(node, BetweenPredicateNode): 64 | return filters.between( 65 | to_filter(node.lhs), to_filter(node.low), to_filter(node.high), 66 | node.not_ 67 | ) 68 | elif isinstance(node, LikePredicateNode): 69 | return filters.like( 70 | to_filter(node.lhs), to_filter(node.rhs), node.case, node.not_, 71 | self.mapping_choices 72 | 73 | ) 74 | elif isinstance(node, InPredicateNode): 75 | return filters.contains( 76 | to_filter(node.lhs), [ 77 | to_filter(sub_node) for sub_node in node.sub_nodes 78 | ], node.not_, self.mapping_choices 79 | ) 80 | elif isinstance(node, NullPredicateNode): 81 | return filters.null( 82 | to_filter(node.lhs), node.not_ 83 | ) 84 | elif isinstance(node, TemporalPredicateNode): 85 | return filters.temporal( 86 | to_filter(node.lhs), node.rhs, node.op 87 | ) 88 | elif isinstance(node, SpatialPredicateNode): 89 | return filters.spatial( 90 | to_filter(node.lhs), to_filter(node.rhs), node.op, 91 | to_filter(node.pattern), 92 | to_filter(node.distance), 93 | to_filter(node.units) 94 | ) 95 | elif isinstance(node, BBoxPredicateNode): 96 | return filters.bbox( 97 | to_filter(node.lhs), 98 | to_filter(node.minx), 99 | to_filter(node.miny), 100 | to_filter(node.maxx), 101 | to_filter(node.maxy), 102 | to_filter(node.crs) 103 | ) 104 | elif isinstance(node, AttributeExpression): 105 | return filters.attribute(node.name, self.field_mapping) 106 | 107 | elif isinstance(node, LiteralExpression): 108 | return node.value 109 | 110 | elif isinstance(node, ArithmeticExpressionNode): 111 | return filters.arithmetic( 112 | to_filter(node.lhs), to_filter(node.rhs), node.op 113 | ) 114 | 115 | return node 116 | 117 | 118 | def to_filter(ast, field_mapping=None, mapping_choices=None): 119 | """ Helper function to translate ECQL AST to Django Query expressions. 120 | 121 | :param ast: the abstract syntax tree 122 | :param field_mapping: a dict mapping from the filter name to the Django 123 | field lookup. 124 | :param mapping_choices: a dict mapping field lookups to choices. 125 | :type ast: :class:`Node` 126 | :returns: a Django query object 127 | :rtype: :class:`django.db.models.Q` 128 | """ 129 | return FilterEvaluator(field_mapping, mapping_choices).to_filter(ast) 130 | -------------------------------------------------------------------------------- /pycql/integrations/django/filters.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | 29 | from operator import and_, or_, add, sub, mul, truediv 30 | from datetime import datetime, timedelta 31 | from functools import reduce 32 | 33 | try: 34 | from collections import OrderedDict 35 | except ImportError: 36 | from django.utils.datastructures import SortedDict as OrderedDict 37 | 38 | from django.db.models import Q, F, ForeignKey, Value 39 | from django.db.models.expressions import Expression 40 | 41 | from django.contrib.gis.gdal import SpatialReference 42 | from django.contrib.gis.geos import Polygon 43 | from django.contrib.gis.measure import D 44 | 45 | ARITHMETIC_TYPES = (Expression, F, Value, int, float) 46 | 47 | # ------------------------------------------------------------------------------ 48 | # Filters 49 | # ------------------------------------------------------------------------------ 50 | 51 | 52 | def combine(sub_filters, combinator="AND"): 53 | """ Combine filters using a logical combinator 54 | 55 | :param sub_filters: the filters to combine 56 | :param combinator: a string: "AND" / "OR" 57 | :type sub_filters: list[django.db.models.Q] 58 | :return: the combined filter 59 | :rtype: :class:`django.db.models.Q` 60 | """ 61 | for sub_filter in sub_filters: 62 | assert isinstance(sub_filter, Q) 63 | 64 | assert combinator in ("AND", "OR") 65 | op = and_ if combinator == "AND" else or_ 66 | return reduce(lambda acc, q: op(acc, q) if acc else q, sub_filters) 67 | 68 | 69 | def negate(sub_filter): 70 | """ Negate a filter, opposing its meaning. 71 | 72 | :param sub_filter: the filter to negate 73 | :type sub_filter: :class:`django.db.models.Q` 74 | :return: the negated filter 75 | :rtype: :class:`django.db.models.Q` 76 | """ 77 | assert isinstance(sub_filter, Q) 78 | return ~sub_filter 79 | 80 | OP_TO_COMP = { 81 | "<": "lt", 82 | "<=": "lte", 83 | ">": "gt", 84 | ">=": "gte", 85 | "<>": None, 86 | "=": "exact" 87 | } 88 | 89 | 90 | def compare(lhs, rhs, op, mapping_choices=None): 91 | """ Compare a filter with an expression using a comparison operation 92 | 93 | :param lhs: the field to compare 94 | :type lhs: :class:`django.db.models.F` 95 | :param rhs: the filter expression 96 | :type rhs: :class:`django.db.models.F` 97 | :param op: a string denoting the operation. one of ``"<"``, ``"<="``, 98 | ``">"``, ``">="``, ``"<>"``, ``"="`` 99 | :type op: str 100 | :param mapping_choices: a dict to lookup potential choices for a certain 101 | field. 102 | :type mapping_choices: dict[str, str] 103 | :return: a comparison expression object 104 | :rtype: :class:`django.db.models.Q` 105 | """ 106 | assert isinstance(lhs, F) 107 | # assert isinstance(rhs, Q) # TODO!! 108 | assert op in OP_TO_COMP 109 | comp = OP_TO_COMP[op] 110 | 111 | field_name = lhs.name 112 | 113 | if mapping_choices and field_name in mapping_choices: 114 | try: 115 | if isinstance(rhs, str): 116 | rhs = mapping_choices[field_name][rhs] 117 | elif hasattr(rhs, 'value'): 118 | rhs = Value(mapping_choices[field_name][rhs.value]) 119 | 120 | except KeyError as e: 121 | raise AssertionError("Invalid field value %s" % e) 122 | 123 | if comp: 124 | return Q(**{"%s__%s" % (lhs.name, comp): rhs}) 125 | return ~Q(**{field_name: rhs}) 126 | 127 | 128 | def between(lhs, low, high, not_=False): 129 | """ Create a filter to match elements that have a value within a certain 130 | range. 131 | 132 | :param lhs: the field to compare 133 | :type lhs: :class:`django.db.models.F` 134 | :param low: the lower value of the range 135 | :type low: 136 | :param high: the upper value of the range 137 | :type high: 138 | :param not_: whether the range shall be inclusive (the default) or 139 | exclusive 140 | :type not_: bool 141 | :return: a comparison expression object 142 | :rtype: :class:`django.db.models.Q` 143 | """ 144 | assert isinstance(lhs, F) 145 | # assert isinstance(low, BaseExpression) 146 | # assert isinstance(high, BaseExpression) # TODO 147 | 148 | q = Q(**{"%s__range" % lhs.name: (low, high)}) 149 | return ~q if not_ else q 150 | 151 | 152 | def like(lhs, rhs, case=False, not_=False, mapping_choices=None): 153 | """ Create a filter to filter elements according to a string attribute using 154 | wildcard expressions. 155 | 156 | :param lhs: the field to compare 157 | :type lhs: :class:`django.db.models.F` 158 | :param rhs: the wildcard pattern: a string containing any number of '%' 159 | characters as wildcards. 160 | :type rhs: str 161 | :param case: whether the lookup shall be done case sensitively or not 162 | :type case: bool 163 | :param not_: whether the range shall be inclusive (the default) or 164 | exclusive 165 | :type not_: bool 166 | :param mapping_choices: a dict to lookup potential choices for a certain 167 | field. 168 | :type mapping_choices: dict[str, str] 169 | :return: a comparison expression object 170 | :rtype: :class:`django.db.models.Q` 171 | """ 172 | assert isinstance(lhs, F) 173 | 174 | if isinstance(rhs, str): 175 | pattern = rhs 176 | elif hasattr(rhs, 'value'): 177 | pattern = rhs.value 178 | else: 179 | raise AssertionError('Invalid pattern specified') 180 | 181 | parts = pattern.split("%") 182 | length = len(parts) 183 | 184 | if mapping_choices and lhs.name in mapping_choices: 185 | # special case when choices are given for the field: 186 | # compare statically and use 'in' operator to check if contained 187 | cmp_av = [ 188 | (a, a if case else a.lower()) 189 | for a in mapping_choices[lhs.name].keys() 190 | ] 191 | 192 | for idx, part in enumerate(parts): 193 | if not part: 194 | continue 195 | 196 | cmp_p = part if case else part.lower() 197 | 198 | if idx == 0 and length > 1: # startswith 199 | cmp_av = [a for a in cmp_av if a[1].startswith(cmp_p)] 200 | elif idx == 0: # exact matching 201 | cmp_av = [a for a in cmp_av if a[1] == cmp_p] 202 | elif idx == length - 1: # endswith 203 | cmp_av = [a for a in cmp_av if a[1].endswith(cmp_p)] 204 | else: # middle 205 | cmp_av = [a for a in cmp_av if cmp_p in a[1]] 206 | 207 | q = Q(**{ 208 | "%s__in" % lhs.name: [ 209 | mapping_choices[lhs.name][a[0]] 210 | for a in cmp_av 211 | ] 212 | }) 213 | 214 | else: 215 | i = "" if case else "i" 216 | q = None 217 | 218 | for idx, part in enumerate(parts): 219 | if not part: 220 | continue 221 | 222 | if idx == 0 and length > 1: # startswith 223 | new_q = Q(**{ 224 | "%s__%s" % (lhs.name, "%sstartswith" % i): part 225 | }) 226 | elif idx == 0: # exact matching 227 | new_q = Q(**{ 228 | "%s__%s" % (lhs.name, "%sexact" % i): part 229 | }) 230 | elif idx == length - 1: # endswith 231 | new_q = Q(**{ 232 | "%s__%s" % (lhs.name, "%sendswith" % i): part 233 | }) 234 | else: # middle 235 | new_q = Q(**{ 236 | "%s__%s" % (lhs.name, "%scontains" % i): part 237 | }) 238 | 239 | q = q & new_q if q else new_q 240 | 241 | return ~q if not_ else q 242 | 243 | 244 | def contains(lhs, items, not_=False, mapping_choices=None): 245 | """ Create a filter to match elements attribute to be in a list of choices. 246 | 247 | :param lhs: the field to compare 248 | :type lhs: :class:`django.db.models.F` 249 | :param items: a list of choices 250 | :type items: list 251 | :param not_: whether the range shall be inclusive (the default) or 252 | exclusive 253 | :type not_: bool 254 | :param mapping_choices: a dict to lookup potential choices for a certain 255 | field. 256 | :type mapping_choices: dict[str, str] 257 | :return: a comparison expression object 258 | :rtype: :class:`django.db.models.Q` 259 | """ 260 | assert isinstance(lhs, F) 261 | # for item in items: 262 | # assert isinstance(item, BaseExpression) 263 | 264 | if mapping_choices and lhs.name in mapping_choices: 265 | def map_value(item): 266 | try: 267 | if isinstance(item, str): 268 | item = mapping_choices[lhs.name][item] 269 | elif hasattr(item, 'value'): 270 | item = Value(mapping_choices[lhs.name][item.value]) 271 | 272 | except KeyError as e: 273 | raise AssertionError("Invalid field value %s" % e) 274 | return item 275 | 276 | items = map(map_value, items) 277 | 278 | q = Q(**{"%s__in" % lhs.name: items}) 279 | return ~q if not_ else q 280 | 281 | 282 | def null(lhs, not_=False): 283 | """ Create a filter to match elements whose attribute is (not) null 284 | 285 | :param lhs: the field to compare 286 | :type lhs: :class:`django.db.models.F` 287 | :param not_: whether the range shall be inclusive (the default) or 288 | exclusive 289 | :type not_: bool 290 | :return: a comparison expression object 291 | :rtype: :class:`django.db.models.Q` 292 | """ 293 | assert isinstance(lhs, F) 294 | return Q(**{"%s__isnull" % lhs.name: not not_}) 295 | 296 | 297 | def temporal(lhs, time_or_period, op): 298 | """ Create a temporal filter for the given temporal attribute. 299 | 300 | :param lhs: the field to compare 301 | :type lhs: :class:`django.db.models.F` 302 | :param time_or_period: the time instant or time span to use as a filter 303 | :type time_or_period: :class:`datetime.datetime` or a tuple of two 304 | datetimes or a tuple of one datetime and one 305 | :class:`datetime.timedelta` 306 | :param op: the comparison operation. one of ``"BEFORE"``, 307 | ``"BEFORE OR DURING"``, ``"DURING"``, ``"DURING OR AFTER"``, 308 | ``"AFTER"``. 309 | :type op: str 310 | :return: a comparison expression object 311 | :rtype: :class:`django.db.models.Q` 312 | """ 313 | assert isinstance(lhs, F) 314 | assert op in ( 315 | "BEFORE", "BEFORE OR DURING", "DURING", "DURING OR AFTER", "AFTER" 316 | ) 317 | low = None 318 | high = None 319 | if op in ("BEFORE", "AFTER"): 320 | assert isinstance(time_or_period, datetime) 321 | if op == "BEFORE": 322 | high = time_or_period 323 | else: 324 | low = time_or_period 325 | else: 326 | low, high = time_or_period 327 | assert isinstance(low, datetime) or isinstance(high, datetime) 328 | 329 | if isinstance(low, timedelta): 330 | low = high - low 331 | if isinstance(high, timedelta): 332 | high = low + high 333 | 334 | if low and high: 335 | return Q(**{"%s__range" % lhs.name: (low, high)}) 336 | elif low: 337 | return Q(**{"%s__gte" % lhs.name: low}) 338 | else: 339 | return Q(**{"%s__lte" % lhs.name: high}) 340 | 341 | 342 | def time_interval(time_or_period, containment='overlaps', 343 | begin_time_field='begin_time', end_time_field='end_time'): 344 | """ 345 | """ 346 | 347 | gt_op = "__gte" 348 | lt_op = "__lte" 349 | 350 | is_slice = len(time_or_period) == 1 351 | if len(time_or_period) == 1: 352 | is_slice = True 353 | value = time_or_period[0] 354 | else: 355 | is_slice = False 356 | low, high = time_or_period 357 | 358 | if is_slice or (high == low and containment == "overlaps"): 359 | return Q(**{ 360 | begin_time_field + "__lte": time_or_period[0], 361 | end_time_field + "__gte": time_or_period[0] 362 | }) 363 | 364 | elif high == low: 365 | return Q(**{ 366 | begin_time_field + "__gte": value, 367 | end_time_field + "__lte": value 368 | }) 369 | 370 | else: 371 | q = Q() 372 | # check if the temporal bounds must be strictly contained 373 | if containment == "contains": 374 | if high is not None: 375 | q &= Q(**{ 376 | end_time_field + lt_op: high 377 | }) 378 | if low is not None: 379 | q &= Q(**{ 380 | begin_time_field + gt_op: low 381 | }) 382 | # or just overlapping 383 | else: 384 | if high is not None: 385 | q &= Q(**{ 386 | begin_time_field + lt_op: high 387 | }) 388 | if low is not None: 389 | q &= Q(**{ 390 | end_time_field + gt_op: low 391 | }) 392 | return q 393 | 394 | 395 | UNITS_LOOKUP = { 396 | "kilometers": "km", 397 | "meters": "m" 398 | } 399 | 400 | 401 | def spatial(lhs, rhs, op, pattern=None, distance=None, units=None): 402 | """ Create a spatial filter for the given spatial attribute. 403 | 404 | :param lhs: the field to compare 405 | :type lhs: :class:`django.db.models.F` 406 | :param rhs: the time instant or time span to use as a filter 407 | :type rhs: 408 | :param op: the comparison operation. one of ``"INTERSECTS"``, 409 | ``"DISJOINT"``, `"CONTAINS"``, ``"WITHIN"``, 410 | ``"TOUCHES"``, ``"CROSSES"``, ``"OVERLAPS"``, 411 | ``"EQUALS"``, ``"RELATE"``, ``"DWITHIN"``, ``"BEYOND"`` 412 | :type op: str 413 | :param pattern: the spatial relation pattern 414 | :type pattern: str 415 | :param distance: the distance value for distance based lookups: 416 | ``"DWITHIN"`` and ``"BEYOND"`` 417 | :type distance: float 418 | :param units: the units the distance is expressed in 419 | :type units: str 420 | :return: a comparison expression object 421 | :rtype: :class:`django.db.models.Q` 422 | """ 423 | assert isinstance(lhs, F) 424 | # assert isinstance(rhs, BaseExpression) # TODO 425 | 426 | assert op in ( 427 | "INTERSECTS", "DISJOINT", "CONTAINS", "WITHIN", "TOUCHES", "CROSSES", 428 | "OVERLAPS", "EQUALS", "RELATE", "DWITHIN", "BEYOND" 429 | ) 430 | if op == "RELATE": 431 | assert pattern 432 | elif op in ("DWITHIN", "BEYOND"): 433 | assert distance 434 | assert units 435 | 436 | if op in ( 437 | "INTERSECTS", "DISJOINT", "CONTAINS", "WITHIN", "TOUCHES", 438 | "CROSSES", "OVERLAPS", "EQUALS"): 439 | return Q(**{"%s__%s" % (lhs.name, op.lower()): rhs}) 440 | elif op == "RELATE": 441 | return Q(**{"%s__relate" % lhs.name: (rhs, pattern)}) 442 | elif op in ("DWITHIN", "BEYOND"): 443 | # TODO: maybe use D.unit_attname(units) 444 | d = D(**{UNITS_LOOKUP[units]: distance}) 445 | if op == "DWITHIN": 446 | return Q(**{"%s__distance_lte" % lhs.name: (rhs, d, 'spheroid')}) 447 | return Q(**{"%s__distance_gte" % lhs.name: (rhs, d, 'spheroid')}) 448 | 449 | 450 | def bbox(lhs, minx, miny, maxx, maxy, crs=None, bboverlaps=True): 451 | """ Create a bounding box filter for the given spatial attribute. 452 | 453 | :param lhs: the field to compare 454 | :param minx: the lower x part of the bbox 455 | :type minx: float 456 | :param miny: the lower y part of the bbox 457 | :type miny: float 458 | :param maxx: the upper x part of the bbox 459 | :type maxx: float 460 | :param maxy: the upper y part of the bbox 461 | :type maxy: float 462 | :param crs: the CRS the bbox is expressed in 463 | :type crs: str 464 | :type lhs: :class:`django.db.models.F` 465 | :return: a comparison expression object 466 | :rtype: :class:`django.db.models.Q` 467 | """ 468 | assert isinstance(lhs, F) 469 | box = Polygon.from_bbox((minx, miny, maxx, maxy)) 470 | 471 | if crs: 472 | box.srid = SpatialReference(crs).srid 473 | box.transform(4326) 474 | 475 | if bboverlaps: 476 | return Q(**{"%s__bboverlaps" % lhs.name: box}) 477 | return Q(**{"%s__intersects" % lhs.name: box}) 478 | 479 | 480 | def attribute(name, field_mapping=None): 481 | """ Create an attribute lookup expression using a field mapping dictionary. 482 | 483 | :param name: the field filter name 484 | :type name: str 485 | :param field_mapping: the dictionary to use as a lookup. 486 | :type mapping_choices: dict[str, str] 487 | :rtype: :class:`django.db.models.F` 488 | """ 489 | if field_mapping: 490 | field = field_mapping.get(name, name) 491 | else: 492 | field = name 493 | return F(field) 494 | 495 | 496 | def literal(value): 497 | return Value(value) 498 | 499 | 500 | OP_TO_FUNC = { 501 | "+": add, 502 | "-": sub, 503 | "*": mul, 504 | "/": truediv 505 | } 506 | 507 | 508 | def arithmetic(lhs, rhs, op): 509 | """ Create an arithmetic filter 510 | 511 | :param lhs: left hand side of the arithmetic expression. either a scalar 512 | or a field lookup or another type of expression 513 | :param rhs: same as `lhs` 514 | :param op: the arithmetic operation. one of ``"+"``, ``"-"``, ``"*"``, ``"/"`` 515 | :rtype: :class:`django.db.models.F` 516 | """ 517 | 518 | assert isinstance(lhs, ARITHMETIC_TYPES), '%r is not a compatible type' % lhs 519 | assert isinstance(rhs, ARITHMETIC_TYPES), '%r is not a compatible type' % rhs 520 | assert op in OP_TO_FUNC 521 | func = OP_TO_FUNC[op] 522 | return func(lhs, rhs) 523 | -------------------------------------------------------------------------------- /pycql/integrations/django/parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.contrib.gis.geos import Polygon, MultiPolygon, GEOSGeometry 29 | from django.utils.dateparse import parse_datetime 30 | 31 | from ...parser import parse as _plain_parse 32 | from ...util import parse_duration 33 | 34 | 35 | def parse(cql): 36 | """ Shorthand for the :func:`pycql.parser.parse` function with 37 | the required factories set up. 38 | 39 | :param cql: the CQL expression string to parse 40 | :type cql: str 41 | :return: the parsed CQL expression as an AST 42 | :rtype: ~pycql.ast.Node 43 | """ 44 | return _plain_parse( 45 | cql, GEOSGeometry, Polygon.from_bbox, parse_datetime, 46 | parse_duration 47 | ) 48 | -------------------------------------------------------------------------------- /pycql/integrations/sqlalchemy/README.md: -------------------------------------------------------------------------------- 1 | ## SQLAlchemy Integration 2 | 3 | The SQLAlchemy Integration translates the AST into a set of filters suitable for input into a filter of a SQLAlchemy Query. 4 | 5 | Given the following example model: 6 | 7 | ```python 8 | from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey 9 | from geoalchemy2 import Geometry 10 | Base = declarative_base() 11 | 12 | 13 | class Record(Base): 14 | __tablename__ = "record" 15 | identifier = Column(String, primary_key=True) 16 | geometry = Column( 17 | Geometry( 18 | geometry_type="MULTIPOLYGON", 19 | srid=4326, 20 | spatial_index=False, 21 | management=True, 22 | ) 23 | ) 24 | float_attribute = Column(Float) 25 | int_attribute = Column(Integer) 26 | str_attribute = Column(String) 27 | datetime_attribute = Column(DateTime) 28 | choice_attribute = Column(Integer) 29 | 30 | 31 | class RecordMeta(Base): 32 | __tablename__ = "record_meta" 33 | identifier = Column(Integer, primary_key=True) 34 | record = Column(String, ForeignKey("record.identifier")) 35 | float_meta_attribute = Column(Float) 36 | int_meta_attribute = Column(Integer) 37 | str_meta_attribute = Column(String) 38 | datetime_meta_attribute = Column(DateTime) 39 | choice_meta_attribute = Column(Integer) 40 | ``` 41 | 42 | Now we can specify the field mappings to be used when applying the filters: 43 | 44 | ```python 45 | FIELD_MAPPING = { 46 | "identifier": Record.identifier, 47 | "geometry": Record.geometry, 48 | "floatAttribute": Record.float_attribute, 49 | "intAttribute": Record.int_attribute, 50 | "strAttribute": Record.str_attribute, 51 | "datetimeAttribute": Record.datetime_attribute, 52 | "choiceAttribute": Record.choice_attribute, 53 | # meta fields 54 | "floatMetaAttribute": RecordMeta.float_meta_attribute, 55 | "intMetaAttribute": RecordMeta.int_meta_attribute, 56 | "strMetaAttribute": RecordMeta.str_meta_attribute, 57 | "datetimeMetaAttribute": RecordMeta.datetime_meta_attribute, 58 | "choiceMetaAttribute": RecordMeta.choice_meta_attribute, 59 | } 60 | ``` 61 | 62 | Finally we are able to connect the CQL AST to the SQLAlchemy database models. We also provide factory 63 | functions to parse the timestamps, durations, geometries and envelopes, so that they can be used 64 | with the ORM layer: 65 | 66 | ```python 67 | from pycql.integrations.sqlalchemy import to_filter, parse 68 | 69 | cql_expr = 'strMetaAttribute LIKE "%parent%" AND datetimeAttribute BEFORE 2000-01-01T00:00:01Z' 70 | 71 | # NOTE: we are using the sqlalchemy integration `parse` wrapper here 72 | ast = parse(cql_expr) 73 | print(ast) 74 | filters = to_filter(ast, FIELD_MAPPING) 75 | 76 | q = session.query(Record).join(RecordMeta).filter(filters) 77 | ``` 78 | 79 | ## Tests 80 | Tests for the sqlalchemy integration can be run as following: 81 | 82 | ```shell 83 | python -m unittest discover tests/sqlalchemy_test/ tests.py 84 | ``` 85 | -------------------------------------------------------------------------------- /pycql/integrations/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .evaluate import to_filter 2 | from .parser import parse 3 | -------------------------------------------------------------------------------- /pycql/integrations/sqlalchemy/evaluate.py: -------------------------------------------------------------------------------- 1 | from . import filters 2 | from ...ast import ( 3 | NotConditionNode, 4 | CombinationConditionNode, 5 | ComparisonPredicateNode, 6 | BetweenPredicateNode, 7 | LikePredicateNode, 8 | InPredicateNode, 9 | NullPredicateNode, 10 | TemporalPredicateNode, 11 | SpatialPredicateNode, 12 | BBoxPredicateNode, 13 | AttributeExpression, 14 | LiteralExpression, 15 | ArithmeticExpressionNode, 16 | ) 17 | 18 | 19 | class FilterEvaluator: 20 | def __init__(self, field_mapping=None): 21 | self.field_mapping = field_mapping 22 | 23 | def to_filter(self, node): 24 | to_filter = self.to_filter 25 | if isinstance(node, NotConditionNode): 26 | return filters.negate(to_filter(node.sub_node)) 27 | elif isinstance(node, CombinationConditionNode): 28 | return filters.combine( 29 | (to_filter(node.lhs), to_filter(node.rhs)), node.op 30 | ) 31 | elif isinstance(node, ComparisonPredicateNode): 32 | return filters.runop( 33 | to_filter(node.lhs), to_filter(node.rhs), node.op, 34 | ) 35 | elif isinstance(node, BetweenPredicateNode): 36 | return filters.between( 37 | to_filter(node.lhs), 38 | to_filter(node.low), 39 | to_filter(node.high), 40 | node.not_, 41 | ) 42 | elif isinstance(node, LikePredicateNode): 43 | return filters.like( 44 | to_filter(node.lhs), to_filter(node.rhs), node.case, node.not_, 45 | ) 46 | elif isinstance(node, InPredicateNode): 47 | return filters.runop( 48 | to_filter(node.lhs), 49 | [to_filter(sub_node) for sub_node in node.sub_nodes], 50 | "in", 51 | node.not_, 52 | ) 53 | elif isinstance(node, NullPredicateNode): 54 | return filters.runop( 55 | to_filter(node.lhs), None, "is_null", node.not_ 56 | ) 57 | elif isinstance(node, TemporalPredicateNode): 58 | return filters.temporal(to_filter(node.lhs), node.rhs, node.op) 59 | elif isinstance(node, SpatialPredicateNode): 60 | return filters.spatial( 61 | to_filter(node.lhs), 62 | to_filter(node.rhs), 63 | node.op, 64 | to_filter(node.pattern), 65 | to_filter(node.distance), 66 | to_filter(node.units), 67 | ) 68 | elif isinstance(node, BBoxPredicateNode): 69 | return filters.bbox( 70 | to_filter(node.lhs), 71 | to_filter(node.minx), 72 | to_filter(node.miny), 73 | to_filter(node.maxx), 74 | to_filter(node.maxy), 75 | to_filter(node.crs), 76 | ) 77 | elif isinstance(node, AttributeExpression): 78 | return filters.attribute(node.name, self.field_mapping) 79 | 80 | elif isinstance(node, LiteralExpression): 81 | return node.value 82 | 83 | elif isinstance(node, ArithmeticExpressionNode): 84 | return filters.runop( 85 | to_filter(node.lhs), to_filter(node.rhs), node.op 86 | ) 87 | 88 | return node 89 | 90 | 91 | def to_filter(ast, field_mapping=None): 92 | """ Helper function to translate ECQL AST to Django Query expressions. 93 | 94 | :param ast: the abstract syntax tree 95 | :param field_mapping: a dict mapping from the filter name to the Django 96 | field lookup. 97 | :param mapping_choices: a dict mapping field lookups to choices. 98 | :type ast: :class:`Node` 99 | :returns: a Django query object 100 | :rtype: :class:`django.db.models.Q` 101 | """ 102 | return FilterEvaluator(field_mapping).to_filter(ast) 103 | -------------------------------------------------------------------------------- /pycql/integrations/sqlalchemy/filters.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from functools import reduce 3 | from inspect import signature 4 | from sqlalchemy import and_, func, not_, or_ 5 | from .parser import parse_bbox 6 | 7 | 8 | # ------------------------------------------------------------------------------ 9 | # Filters 10 | # ------------------------------------------------------------------------------ 11 | class Operator: 12 | 13 | OPERATORS = { 14 | "is_null": lambda f, a=None: f.is_(None), 15 | "is_not_null": lambda f, a=None: f.isnot(None), 16 | "==": lambda f, a: f == a, 17 | "=": lambda f, a: f == a, 18 | "eq": lambda f, a: f == a, 19 | "!=": lambda f, a: f != a, 20 | "<>": lambda f, a: f != a, 21 | "ne": lambda f, a: f != a, 22 | ">": lambda f, a: f > a, 23 | "gt": lambda f, a: f > a, 24 | "<": lambda f, a: f < a, 25 | "lt": lambda f, a: f < a, 26 | ">=": lambda f, a: f >= a, 27 | "ge": lambda f, a: f >= a, 28 | "<=": lambda f, a: f <= a, 29 | "le": lambda f, a: f <= a, 30 | "like": lambda f, a: f.like(a), 31 | "ilike": lambda f, a: f.ilike(a), 32 | "not_ilike": lambda f, a: ~f.ilike(a), 33 | "in": lambda f, a: f.in_(a), 34 | "not_in": lambda f, a: ~f.in_(a), 35 | "any": lambda f, a: f.any(a), 36 | "not_any": lambda f, a: func.not_(f.any(a)), 37 | "INTERSECTS": lambda f, a: f.ST_Contains(a), 38 | "DISJOINT": lambda f, a: f.ST_Disjoint(a), 39 | "CONTAINS": lambda f, a: f.ST_Contains(a), 40 | "WITHIN": lambda f, a: f.ST_Within(a), 41 | "TOUCHES": lambda f, a: f.ST_Touches(a), 42 | "CROSSES": lambda f, a: f.ST_Crosses(a), 43 | "OVERLAPS": lambda f, a: f.ST_Overlaps(a), 44 | "EQUALS": lambda f, a: f.ST_Equals(a), 45 | "RELATE": lambda f, a, pattern: f.ST_Relate(a, pattern), 46 | "DWITHIN": lambda f, a, distance: f.ST_Dwithin(a, distance), 47 | "BEYOND": lambda f, a, distance: ~f.ST_Dwithin(a, distance), 48 | "+": lambda f, a: f + a, 49 | "-": lambda f, a: f - a, 50 | "*": lambda f, a: f * a, 51 | "/": lambda f, a: f / a, 52 | } 53 | 54 | def __init__(self, operator: str = None): 55 | if not operator: 56 | operator = "==" 57 | 58 | if operator not in self.OPERATORS: 59 | raise Exception("Operator `{}` not valid.".format(operator)) 60 | 61 | self.operator = operator 62 | self.function = self.OPERATORS[operator] 63 | self.arity = len(signature(self.function).parameters) 64 | 65 | 66 | def combine(sub_filters, combinator: str = "AND"): 67 | """ Combine filters using a logical combinator 68 | 69 | :param sub_filters: the filters to combine 70 | :param combinator: a string: "AND" / "OR" 71 | :return: the combined filter 72 | """ 73 | assert combinator in ("AND", "OR") 74 | _op = and_ if combinator == "AND" else or_ 75 | 76 | def test(acc, q): 77 | return _op(acc, q) 78 | 79 | return reduce(test, sub_filters) 80 | 81 | 82 | def negate(sub_filter): 83 | """ Negate a filter, opposing its meaning. 84 | 85 | :param sub_filter: the filter to negate 86 | :return: the negated filter 87 | """ 88 | return not_(sub_filter) 89 | 90 | 91 | def runop(lhs, rhs=None, op: str = "=", negate: bool = False): 92 | """ Compare a filter with an expression using a comparison operation 93 | 94 | :param lhs: the field to compare 95 | :param rhs: the filter expression 96 | :param op: a string denoting the operation. 97 | :return: a comparison expression object 98 | """ 99 | _op = Operator(op) 100 | 101 | if negate: 102 | return not_(_op.function(lhs, rhs)) 103 | return _op.function(lhs, rhs) 104 | 105 | 106 | def between(lhs, low, high, negate=False): 107 | """ Create a filter to match elements that have a value within a certain 108 | range. 109 | 110 | :param lhs: the field to compare 111 | :param low: the lower value of the range 112 | :param high: the upper value of the range 113 | :param not_: whether the range shall be inclusive (the default) or 114 | exclusive 115 | :return: a comparison expression object 116 | """ 117 | l_op = Operator("<=") 118 | g_op = Operator(">=") 119 | if negate: 120 | return not_(and_(g_op.function(lhs, low), l_op.function(lhs, high))) 121 | return and_(g_op.function(lhs, low), l_op.function(lhs, high)) 122 | 123 | 124 | def like(lhs, rhs, case=False, negate=False): 125 | """ Create a filter to filter elements according to a string attribute using 126 | wildcard expressions. 127 | 128 | :param lhs: the field to compare 129 | :param rhs: the wildcard pattern: a string containing any number of '%' 130 | characters as wildcards. 131 | :param case: whether the lookup shall be done case sensitively or not 132 | :param not_: whether the range shall be inclusive (the default) or 133 | exclusive 134 | :return: a comparison expression object 135 | """ 136 | if case: 137 | _op = Operator("like") 138 | else: 139 | _op = Operator("ilike") 140 | 141 | if negate: 142 | return not_(_op.function(lhs, rhs)) 143 | return _op.function(lhs, rhs) 144 | 145 | 146 | def temporal(lhs, time_or_period, op): 147 | """ Create a temporal filter for the given temporal attribute. 148 | 149 | :param lhs: the field to compare 150 | :type lhs: :class:`django.db.models.F` 151 | :param time_or_period: the time instant or time span to use as a filter 152 | :type time_or_period: :class:`datetime.datetime` or a tuple of two 153 | datetimes or a tuple of one datetime and one 154 | :class:`datetime.timedelta` 155 | :param op: the comparison operation. one of ``"BEFORE"``, 156 | ``"BEFORE OR DURING"``, ``"DURING"``, ``"DURING OR AFTER"``, 157 | ``"AFTER"``. 158 | :type op: str 159 | :return: a comparison expression object 160 | :rtype: :class:`django.db.models.Q` 161 | """ 162 | low = None 163 | high = None 164 | if op in ("BEFORE", "AFTER"): 165 | if op == "BEFORE": 166 | high = time_or_period 167 | else: 168 | low = time_or_period 169 | else: 170 | low, high = time_or_period 171 | 172 | if isinstance(low, timedelta): 173 | low = high - low 174 | if isinstance(high, timedelta): 175 | high = low + high 176 | if low and high: 177 | return between(lhs, low, high) 178 | elif low: 179 | return runop(lhs, low, ">=") 180 | else: 181 | return runop(lhs, high, "<=") 182 | 183 | 184 | UNITS_LOOKUP = {"kilometers": "km", "meters": "m"} 185 | 186 | 187 | def spatial(lhs, rhs, op, pattern=None, distance=None, units=None): 188 | """ Create a spatial filter for the given spatial attribute. 189 | 190 | :param lhs: the field to compare 191 | :param rhs: the time instant or time span to use as a filter 192 | :param op: the comparison operation. one of ``"INTERSECTS"``, 193 | ``"DISJOINT"``, `"CONTAINS"``, ``"WITHIN"``, 194 | ``"TOUCHES"``, ``"CROSSES"``, ``"OVERLAPS"``, 195 | ``"EQUALS"``, ``"RELATE"``, ``"DWITHIN"``, ``"BEYOND"`` 196 | :param pattern: the spatial relation pattern 197 | :param distance: the distance value for distance based lookups: 198 | ``"DWITHIN"`` and ``"BEYOND"`` 199 | :param units: the units the distance is expressed in 200 | :return: a comparison expression object 201 | """ 202 | 203 | _op = Operator(op) 204 | if op == "RELATE": 205 | return _op.function(lhs, rhs, pattern) 206 | elif op in ("DWITHIN", "BEYOND"): 207 | if units == "kilometers": 208 | distance = distance / 1000 209 | elif units == "miles": 210 | distance = distance / 1609 211 | return _op.function(lhs, rhs, distance) 212 | else: 213 | return _op.function(lhs, rhs) 214 | 215 | 216 | def bbox(lhs, minx, miny, maxx, maxy, crs=4326): 217 | """ Create a bounding box filter for the given spatial attribute. 218 | 219 | :param lhs: the field to compare 220 | :param minx: the lower x part of the bbox 221 | :param miny: the lower y part of the bbox 222 | :param maxx: the upper x part of the bbox 223 | :param maxy: the upper y part of the bbox 224 | :param crs: the CRS the bbox is expressed in 225 | :return: a comparison expression object 226 | """ 227 | 228 | return lhs.ST_Intersects(parse_bbox([minx, miny, maxx, maxy])) 229 | 230 | 231 | def attribute(name, field_mapping=None): 232 | """ Create an attribute lookup expression using a field mapping dictionary. 233 | 234 | :param name: the field filter name 235 | :param field_mapping: the dictionary to use as a lookup. 236 | """ 237 | field = field_mapping.get(name, name) 238 | 239 | return field 240 | 241 | 242 | def literal(value): 243 | return value 244 | -------------------------------------------------------------------------------- /pycql/integrations/sqlalchemy/parser.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import re 4 | 5 | from ...parser import parse as _plain_parse 6 | from ...util import parse_duration 7 | from dateparser import parse as parse_datetime 8 | from sqlalchemy import func 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | def parse_geometry(geom): 14 | LOGGER.debug(f"PARSE GEOM: {geom}") 15 | search = re.search(r"SRID=(\d+);", geom) 16 | 17 | sridtxt = "" if search else "SRID=4326;" 18 | LOGGER.debug(f"{sridtxt}{geom}") 19 | 20 | return func.ST_GeomFromEWKT(f"{sridtxt}{geom}") 21 | 22 | 23 | def parse_bbox(box, srid: int=4326): 24 | LOGGER.debug("PARSE BBOX: {type(box)}, {box}") 25 | minx, miny, maxx, maxy = box 26 | return func.ST_GeomFromEWKT( 27 | f"SRID={srid};POLYGON((" 28 | f"{minx} {miny}, {minx} {maxy}, " 29 | f"{maxx} {maxy}, {maxx} {miny}, " 30 | f"{minx} {miny}))" 31 | ) 32 | 33 | 34 | def parse(cql): 35 | """ Shorthand for the :func:`pycql.parser.parse` function with 36 | the required factories set up. 37 | 38 | :param cql: the CQL expression string to parse 39 | :type cql: str 40 | :return: the parsed CQL expression as an AST 41 | :rtype: ~pycql.ast.Node 42 | """ 43 | return _plain_parse( 44 | cql, 45 | geometry_factory=parse_geometry, 46 | bbox_factory=parse_bbox, 47 | time_factory=parse_datetime, 48 | duration_factory=parse_duration, 49 | ) 50 | -------------------------------------------------------------------------------- /pycql/lexer.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import logging 29 | 30 | from ply import lex 31 | from ply.lex import TOKEN 32 | 33 | from . import values 34 | 35 | LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | class CQLLexer: 39 | def __init__(self, geometry_factory=values.Geometry, bbox_factory=values.BBox, 40 | time_factory=values.Time, duration_factory=values.Duration, **kwargs): 41 | 42 | self.lexer = lex.lex(object=self, **kwargs) 43 | self.geometry_factory = geometry_factory 44 | self.bbox_factory = bbox_factory 45 | self.time_factory = time_factory 46 | self.duration_factory = duration_factory 47 | 48 | def build(self, **kwargs): 49 | pass 50 | # self.lexer.build() 51 | 52 | def input(self, *args): 53 | self.lexer.input(*args) 54 | 55 | def token(self): 56 | self.last_token = self.lexer.token() 57 | return self.last_token 58 | 59 | keywords = ( 60 | "NOT", "AND", "OR", 61 | "BETWEEN", "LIKE", "ILIKE", "IN", "IS", "NULL", 62 | "BEFORE", "AFTER", "DURING", "INTERSECTS", "DISJOINT", "CONTAINS", 63 | "WITHIN", "TOUCHES", "CROSSES", "OVERLAPS", "EQUALS", "RELATE", 64 | "DWITHIN", "BEYOND", "BBOX", 65 | "feet", "meters", "statute miles", "nautical miles", "kilometers" 66 | ) 67 | 68 | tokens = keywords + ( 69 | # Operators 70 | 'PLUS', 'MINUS', 'TIMES', 'DIVIDE', 71 | 'LT', 'LE', 'GT', 'GE', 'EQ', 'NE', 72 | 73 | 'LPAREN', 'RPAREN', 74 | 'LBRACKET', 'RBRACKET', 75 | 'COMMA', 76 | 77 | 'GEOMETRY', 78 | 'ENVELOPE', 79 | 80 | 'UNITS', 81 | 82 | 'ATTRIBUTE', 83 | 'TIME', 84 | 'DURATION', 85 | 'FLOAT', 86 | 'INTEGER', 87 | 'QUOTED', 88 | ) 89 | 90 | keyword_map = dict((keyword, keyword) for keyword in keywords) 91 | 92 | identifier_pattern = r'[a-zA-Z_$][0-9a-zA-Z_$]*' 93 | 94 | int_pattern = r'-?[0-9]+' 95 | # float_pattern = r'(?:[0-9]+[.][0-9]*|[.][0-9]+)(?:[Ee][-+]?[0-9]+)?' 96 | float_pattern = r'[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?' 97 | 98 | time_pattern = r"\d{4}-\d{2}-\d{2}T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z" 99 | duration_pattern = ( 100 | # "P(?=[YMDHMS])" # positive lookahead here... TODO: does not work 101 | # "((\d+Y)?(\d+M)?(\d+D)?)?(T(\d+H)?(\d+M)?(\d+S)?)?" 102 | r"P((\d+Y)?(\d+M)?(\d+D)?)?(T(\d+H)?(\d+M)?(\d+S)?)?" 103 | ) 104 | quoted_string_pattern = r'(\"[^"]*\")|(\'[^\']*\')' 105 | 106 | # for geometry parsing 107 | 108 | # a simple pattern that allows the simple float and integer notations (but 109 | # not the scientific ones). Maybe TODO 110 | number_pattern = r'-?[0-9]*\.?[0-9]+' 111 | 112 | coordinate_2d_pattern = r'%s\s+%s\s*' % (number_pattern, number_pattern) 113 | coordinate_3d_pattern = r'%s\s+%s\s*' % ( 114 | coordinate_2d_pattern, number_pattern 115 | ) 116 | coordinate_4d_pattern = r'%s\s+%s\s*' % ( 117 | coordinate_3d_pattern, number_pattern 118 | ) 119 | coordinate_pattern = r'((%s)|(%s)|(%s))' % ( 120 | coordinate_2d_pattern, coordinate_3d_pattern, coordinate_4d_pattern 121 | ) 122 | 123 | coordinates_pattern = r'%s(\s*,\s*%s)*' % ( 124 | coordinate_pattern, coordinate_pattern 125 | ) 126 | 127 | coordinate_group_pattern = r'\(\s*%s\s*\)' % coordinates_pattern 128 | coordinate_groups_pattern = r'%s(\s*,\s*%s)*' % ( 129 | coordinate_group_pattern, coordinate_group_pattern 130 | ) 131 | 132 | nested_coordinate_group_pattern = r'\(\s*%s\s*\)' % coordinate_groups_pattern 133 | nested_coordinate_groups_pattern = r'%s(\s*,\s*%s)*' % ( 134 | nested_coordinate_group_pattern, nested_coordinate_group_pattern 135 | ) 136 | 137 | geometry_pattern = ( 138 | r'(POINT\s*\(%s\))|' % coordinate_pattern + 139 | r'((MULTIPOINT|LINESTRING)\s*\(%s\))|' % coordinates_pattern + 140 | r'((MULTIPOINT|MULTILINESTRING|POLYGON)\s*\(%s\))|' % ( 141 | coordinate_groups_pattern 142 | ) + 143 | r'(MULTIPOLYGON\s*\(%s\))' % nested_coordinate_groups_pattern 144 | ) 145 | envelope_pattern = r'ENVELOPE\s*\((\s*%s\s*){4}\)' % number_pattern 146 | 147 | t_PLUS = r'\+' 148 | t_MINUS = r'-' 149 | t_TIMES = r'\*' 150 | t_DIVIDE = r'/' 151 | t_OR = r'OR' 152 | t_AND = r'AND' 153 | t_LT = r'<' 154 | t_GT = r'>' 155 | t_LE = r'<=' 156 | t_GE = r'>=' 157 | t_EQ = r'=' 158 | t_NE = r'<>' 159 | 160 | # Delimeters 161 | t_LPAREN = r'\(' 162 | t_RPAREN = r'\)' 163 | t_LBRACKET = r'\[' 164 | t_RBRACKET = r'\]' 165 | t_COMMA = r',' 166 | 167 | @TOKEN(geometry_pattern) 168 | def t_GEOMETRY(self, t): 169 | t.value = self.geometry_factory(t.value) 170 | return t 171 | 172 | @TOKEN(envelope_pattern) 173 | def t_ENVELOPE(self, t): 174 | bbox = [ 175 | float(number) for number in 176 | t.value.partition('(')[2].partition(')')[0].split() 177 | ] 178 | t.value = self.bbox_factory(bbox) 179 | return t 180 | 181 | @TOKEN(r'(feet)|(meters)|(statute miles)|(nautical miles)|(kilometers)') 182 | def t_UNITS(self, t): 183 | return t 184 | 185 | @TOKEN(time_pattern) 186 | def t_TIME(self, t): 187 | t.value = self.time_factory(t.value) 188 | return t 189 | 190 | @TOKEN(duration_pattern) 191 | def t_DURATION(self, t): 192 | t.value = self.duration_factory(t.value) 193 | return t 194 | 195 | @TOKEN(float_pattern) 196 | def t_FLOAT(self, t): 197 | t.value = float(t.value) 198 | return t 199 | 200 | @TOKEN(int_pattern) 201 | def t_INTEGER(self, t): 202 | t.value = int(t.value) 203 | return t 204 | 205 | @TOKEN(quoted_string_pattern) 206 | def t_QUOTED(self, t): 207 | t.value = t.value[1:-1] 208 | return t 209 | 210 | @TOKEN(identifier_pattern) 211 | def t_ATTRIBUTE(self, t): 212 | t.type = self.keyword_map.get(t.value, "ATTRIBUTE") 213 | return t 214 | 215 | def t_newline(self, t): 216 | r'\n+' 217 | t.lexer.lineno += len(t.value) 218 | 219 | # A string containing ignored characters (spaces and tabs) 220 | t_ignore = ' \t' 221 | 222 | def t_error(self, t): 223 | LOGGER.debug(t) 224 | -------------------------------------------------------------------------------- /pycql/lextab.py: -------------------------------------------------------------------------------- 1 | # lextab.py. This file automatically created by PLY (version 3.11). Don't edit! 2 | _tabversion = '3.10' 3 | _lextokens = set(('AFTER', 'AND', 'ATTRIBUTE', 'BBOX', 'BEFORE', 'BETWEEN', 'BEYOND', 'COMMA', 'CONTAINS', 'CROSSES', 'DISJOINT', 'DIVIDE', 'DURATION', 'DURING', 'DWITHIN', 'ENVELOPE', 'EQ', 'EQUALS', 'FLOAT', 'GE', 'GEOMETRY', 'GT', 'ILIKE', 'IN', 'INTEGER', 'INTERSECTS', 'IS', 'LBRACKET', 'LE', 'LIKE', 'LPAREN', 'LT', 'MINUS', 'NE', 'NOT', 'NULL', 'OR', 'OVERLAPS', 'PLUS', 'QUOTED', 'RBRACKET', 'RELATE', 'RPAREN', 'TIME', 'TIMES', 'TOUCHES', 'UNITS', 'WITHIN', 'feet', 'kilometers', 'meters', 'nautical miles', 'statute miles')) 4 | _lexreflags = 64 5 | _lexliterals = '' 6 | _lexstateinfo = {'INITIAL': 'inclusive'} 7 | _lexstatere = {'INITIAL': [('(?P(POINT\\s*\\(((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))\\))|((MULTIPOINT|LINESTRING)\\s*\\(((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\))|((MULTIPOINT|MULTILINESTRING|POLYGON)\\s*\\(\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\)(\\s*,\\s*\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\))*\\))|(MULTIPOLYGON\\s*\\(\\(\\s*\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\)(\\s*,\\s*\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\))*\\s*\\)(\\s*,\\s*\\(\\s*\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\)(\\s*,\\s*\\(\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*))(\\s*,\\s*((-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)|(-?[0-9]*\\.?[0-9]+\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*\\s+-?[0-9]*\\.?[0-9]+\\s*)))*\\s*\\))*\\s*\\))*\\)))|(?PENVELOPE\\s*\\((\\s*-?[0-9]*\\.?[0-9]+\\s*){4}\\))|(?P(feet)|(meters)|(statute miles)|(nautical miles)|(kilometers))|(?P\\d{4}-\\d{2}-\\d{2}T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z)|(?PP((\\d+Y)?(\\d+M)?(\\d+D)?)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?)|(?P[0-9]*\\.?[0-9]+([eE][-+]?[0-9]+)?)|(?P-?[0-9]+)|(?P(\\"[^"]*\\")|(\\\'[^\\\']*\\\'))|(?P[a-zA-Z_$][0-9a-zA-Z_$]*)|(?P\\n+)|(?PAND)|(?P>=)|(?P\\[)|(?P<=)|(?P\\()|(?P<>)|(?POR)|(?P\\+)|(?P\\])|(?P\\))|(?P\\*)|(?P,)|(?P/)|(?P=)|(?P>)|(?P<)|(?P-)', [None, ('t_GEOMETRY', 'GEOMETRY'), None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, ('t_ENVELOPE', 'ENVELOPE'), None, ('t_UNITS', 'UNITS'), None, None, None, None, None, ('t_TIME', 'TIME'), ('t_DURATION', 'DURATION'), None, None, None, None, None, None, None, None, ('t_FLOAT', 'FLOAT'), None, ('t_INTEGER', 'INTEGER'), ('t_QUOTED', 'QUOTED'), None, None, ('t_ATTRIBUTE', 'ATTRIBUTE'), ('t_newline', 'newline'), (None, 'AND'), (None, 'GE'), (None, 'LBRACKET'), (None, 'LE'), (None, 'LPAREN'), (None, 'NE'), (None, 'OR'), (None, 'PLUS'), (None, 'RBRACKET'), (None, 'RPAREN'), (None, 'TIMES'), (None, 'COMMA'), (None, 'DIVIDE'), (None, 'EQ'), (None, 'GT'), (None, 'LT'), (None, 'MINUS')])]} 8 | _lexstateignore = {'INITIAL': ' \t'} 9 | _lexstateerrorf = {'INITIAL': 't_error'} 10 | _lexstateeoff = {} 11 | -------------------------------------------------------------------------------- /pycql/parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import logging 29 | 30 | from ply import yacc 31 | 32 | from .lexer import CQLLexer 33 | from . import ast 34 | from . import values 35 | 36 | LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | class CQLParser: 40 | def __init__(self, geometry_factory=values.Geometry, bbox_factory=values.BBox, 41 | time_factory=values.Time, duration_factory=values.Duration): 42 | self.lexer = CQLLexer( 43 | # lextab='ecql.lextab', 44 | # outputdir="ecql" 45 | geometry_factory, 46 | bbox_factory, 47 | time_factory, 48 | duration_factory, 49 | optimize=True, 50 | ) 51 | 52 | self.lexer.build() 53 | self.tokens = self.lexer.tokens 54 | 55 | self.parser = yacc.yacc( 56 | module=self, 57 | # start='condition_or_empty', 58 | # debug=True, 59 | optimize=True, 60 | # tabmodule='ecql.yacctab', 61 | # outputdir="ecql" 62 | 63 | errorlog=yacc.NullLogger(), 64 | ) 65 | 66 | def parse(self, text): 67 | self.__query = text 68 | return self.parser.parse( 69 | input=text, 70 | lexer=self.lexer 71 | ) 72 | 73 | def restart(self, *args, **kwargs): 74 | return self.parser.restart(*args, **kwargs) 75 | 76 | precedence = ( 77 | ('left', 'EQ', 'NE'), 78 | ('left', 'GT', 'GE', 'LT', 'LE'), 79 | ('left', 'PLUS', 'MINUS'), 80 | ('left', 'TIMES', 'DIVIDE'), 81 | ) 82 | 83 | # 84 | # grammar 85 | # 86 | 87 | start = 'condition_or_empty' 88 | 89 | def p_condition_or_empty(self, p): 90 | """ condition_or_empty : condition 91 | | empty 92 | """ 93 | p[0] = p[1] 94 | 95 | def p_condition(self, p): 96 | """ condition : predicate 97 | | condition AND condition 98 | | condition OR condition 99 | | NOT condition 100 | | LPAREN condition RPAREN 101 | | LBRACKET condition RBRACKET 102 | """ 103 | 104 | if len(p) == 2: 105 | p[0] = p[1] 106 | elif p[2] in ("AND", "OR"): 107 | p[0] = ast.CombinationConditionNode(p[1], p[3], p[2]) 108 | elif p[1] == "NOT": 109 | p[0] = ast.NotConditionNode(p[2]) 110 | elif p[1] in ("(", "["): 111 | p[0] = p[2] 112 | 113 | def p_predicate(self, p): 114 | """ predicate : expression EQ expression 115 | | expression NE expression 116 | | expression LT expression 117 | | expression LE expression 118 | | expression GT expression 119 | | expression GE expression 120 | | expression NOT BETWEEN expression AND expression 121 | | expression BETWEEN expression AND expression 122 | | expression NOT LIKE QUOTED 123 | | expression LIKE QUOTED 124 | | expression NOT ILIKE QUOTED 125 | | expression ILIKE QUOTED 126 | | expression NOT IN LPAREN expression_list RPAREN 127 | | expression IN LPAREN expression_list RPAREN 128 | | expression IS NOT NULL 129 | | expression IS NULL 130 | | temporal_predicate 131 | | spatial_predicate 132 | """ 133 | if len(p) == 2: # hand over temporal and spatial predicates 134 | p[0] = p[1] 135 | 136 | elif p[2] in ("=", "<>", "<", "<=", ">", ">="): 137 | p[0] = ast.ComparisonPredicateNode(p[1], p[3], p[2]) 138 | else: 139 | not_ = False 140 | op = p[2] 141 | if op == 'NOT': 142 | not_ = True 143 | op = p[3] 144 | 145 | if op == "BETWEEN": 146 | p[0] = ast.BetweenPredicateNode( 147 | p[1], p[4 if not_ else 3], p[6 if not_ else 5], not_ 148 | ) 149 | elif op in ("LIKE", "ILIKE"): 150 | p[0] = ast.LikePredicateNode( 151 | p[1], ast.LiteralExpression(p[4 if not_ else 3]), 152 | op == "LIKE", not_ 153 | ) 154 | elif op == "IN": 155 | p[0] = ast.InPredicateNode(p[1], p[5 if not_ else 4], not_) 156 | 157 | elif op == "IS": 158 | p[0] = ast.NullPredicateNode(p[1], p[3] == "NOT") 159 | 160 | def p_temporal_predicate(self, p): 161 | """ temporal_predicate : expression BEFORE TIME 162 | | expression BEFORE OR DURING time_period 163 | | expression DURING time_period 164 | | expression DURING OR AFTER time_period 165 | | expression AFTER TIME 166 | """ 167 | 168 | if len(p) > 4: 169 | op = " ".join(p[2:-1]) 170 | else: 171 | op = p[2] 172 | 173 | p[0] = ast.TemporalPredicateNode(p[1], p[3 if len(p) == 4 else 5], op) 174 | 175 | def p_time_period(self, p): 176 | """ time_period : TIME DIVIDE TIME 177 | | TIME DIVIDE DURATION 178 | | DURATION DIVIDE TIME 179 | """ 180 | p[0] = (p[1], p[3]) 181 | 182 | def p_spatial_predicate(self, p): 183 | """ spatial_predicate : INTERSECTS LPAREN expression COMMA expression RPAREN 184 | | DISJOINT LPAREN expression COMMA expression RPAREN 185 | | CONTAINS LPAREN expression COMMA expression RPAREN 186 | | WITHIN LPAREN expression COMMA expression RPAREN 187 | | TOUCHES LPAREN expression COMMA expression RPAREN 188 | | CROSSES LPAREN expression COMMA expression RPAREN 189 | | OVERLAPS LPAREN expression COMMA expression RPAREN 190 | | EQUALS LPAREN expression COMMA expression RPAREN 191 | | RELATE LPAREN expression COMMA expression COMMA QUOTED RPAREN 192 | | DWITHIN LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN 193 | | BEYOND LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN 194 | | BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number RPAREN 195 | | BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number COMMA QUOTED RPAREN 196 | """ 197 | op = p[1] 198 | lhs = p[3] 199 | rhs = p[5] 200 | 201 | if op == "RELATE": 202 | p[0] = ast.SpatialPredicateNode(lhs, rhs, op, pattern=p[7]) 203 | elif op in ("DWITHIN", "BEYOND"): 204 | p[0] = ast.SpatialPredicateNode( 205 | lhs, rhs, op, distance=p[7], units=p[9] 206 | ) 207 | elif op == "BBOX": 208 | p[0] = ast.BBoxPredicateNode(lhs, *p[5::2]) 209 | else: 210 | p[0] = ast.SpatialPredicateNode(lhs, rhs, op) 211 | 212 | def p_expression_list(self, p): 213 | """ expression_list : expression_list COMMA expression 214 | | expression 215 | """ 216 | if len(p) == 2: 217 | p[0] = [p[1]] 218 | else: 219 | p[1].append(p[3]) 220 | p[0] = p[1] 221 | 222 | def p_expression(self, p): 223 | """ expression : expression PLUS expression 224 | | expression MINUS expression 225 | | expression TIMES expression 226 | | expression DIVIDE expression 227 | | LPAREN expression RPAREN 228 | | LBRACKET expression RBRACKET 229 | | GEOMETRY 230 | | ENVELOPE 231 | | attribute 232 | | QUOTED 233 | | INTEGER 234 | | FLOAT 235 | """ 236 | if len(p) == 2: 237 | if isinstance(p[1], ast.Node): 238 | p[0] = p[1] 239 | else: 240 | p[0] = ast.LiteralExpression(p[1]) 241 | else: 242 | if p[1] in ("(", "["): 243 | p[0] = p[2] 244 | else: 245 | op = p[2] 246 | lhs = p[1] 247 | rhs = p[3] 248 | p[0] = ast.ArithmeticExpressionNode(lhs, rhs, op) 249 | 250 | def p_number(self, p): 251 | """ number : INTEGER 252 | | FLOAT 253 | """ 254 | p[0] = ast.LiteralExpression(p[1]) 255 | 256 | def p_attribute(self, p): 257 | """ attribute : ATTRIBUTE 258 | """ 259 | p[0] = ast.AttributeExpression(p[1]) 260 | 261 | def p_empty(self, p): 262 | 'empty : ' 263 | p[0] = None 264 | 265 | def p_error(self, p): 266 | if p: 267 | LOGGER.debug(dir(p)) 268 | LOGGER.debug(f"Syntax error at token {p.type}, {p.value}, {p.lexpos}, {p.lineno}") 269 | 270 | LOGGER.debug(self.__query.split('\n')) 271 | line = self.__query.split('\n')[p.lineno - 1] 272 | LOGGER.debug(line) 273 | LOGGER.debug((' ' * p.lexpos) + '^') 274 | 275 | # Just discard the token and tell the parser it's okay. 276 | #p.parser.errok() 277 | else: 278 | LOGGER.debug("Syntax error at EOF") 279 | 280 | 281 | def parse(cql, geometry_factory=values.Geometry, bbox_factory=values.BBox, 282 | time_factory=values.Time, duration_factory=values.Duration): 283 | """ Parses the passed CQL to its AST interpretation. 284 | 285 | :param cql: the CQL expression string to parse 286 | :type cql: str 287 | :param geometry_factory: the geometry parsing function: it shall parse 288 | the given WKT geometry string the relevant type 289 | :param bbox_factory: the bbox parsing function: it shall parse 290 | the given BBox tuple the relevant type. 291 | :param time_factory: the timestamp parsing function: it shall parse 292 | the given ISO8601 timestamp string tuple the relevant 293 | type. 294 | :param duration_factory: the duration parsing function: it shall parse 295 | the given ISO8601 furation string tuple the relevant 296 | type. 297 | :return: the parsed CQL expression as an AST 298 | :rtype: ~pycql.ast.Node 299 | """ 300 | parser = CQLParser( 301 | geometry_factory, 302 | bbox_factory, 303 | time_factory, 304 | duration_factory 305 | ) 306 | return parser.parse(cql) 307 | -------------------------------------------------------------------------------- /pycql/parsetab.py: -------------------------------------------------------------------------------- 1 | 2 | # parsetab.py 3 | # This file is automatically generated. Do not edit. 4 | # pylint: disable=W,C,R 5 | _tabversion = '3.10' 6 | 7 | _lr_method = 'LALR' 8 | 9 | _lr_signature = 'condition_or_emptyleftEQNEleftGTGELTLEleftPLUSMINUSleftTIMESDIVIDEAFTER AND ATTRIBUTE BBOX BEFORE BETWEEN BEYOND COMMA CONTAINS CROSSES DISJOINT DIVIDE DURATION DURING DWITHIN ENVELOPE EQ EQUALS FLOAT GE GEOMETRY GT ILIKE IN INTEGER INTERSECTS IS LBRACKET LE LIKE LPAREN LT MINUS NE NOT NULL OR OVERLAPS PLUS QUOTED RBRACKET RELATE RPAREN TIME TIMES TOUCHES UNITS WITHIN feet kilometers meters nautical miles statute miles condition_or_empty : condition\n | empty\n condition : predicate\n | condition AND condition\n | condition OR condition\n | NOT condition\n | LPAREN condition RPAREN\n | LBRACKET condition RBRACKET\n predicate : expression EQ expression\n | expression NE expression\n | expression LT expression\n | expression LE expression\n | expression GT expression\n | expression GE expression\n | expression NOT BETWEEN expression AND expression\n | expression BETWEEN expression AND expression\n | expression NOT LIKE QUOTED\n | expression LIKE QUOTED\n | expression NOT ILIKE QUOTED\n | expression ILIKE QUOTED\n | expression NOT IN LPAREN expression_list RPAREN\n | expression IN LPAREN expression_list RPAREN\n | expression IS NOT NULL\n | expression IS NULL\n | temporal_predicate\n | spatial_predicate\n temporal_predicate : expression BEFORE TIME\n | expression BEFORE OR DURING time_period\n | expression DURING time_period\n | expression DURING OR AFTER time_period\n | expression AFTER TIME\n time_period : TIME DIVIDE TIME\n | TIME DIVIDE DURATION\n | DURATION DIVIDE TIME\n spatial_predicate : INTERSECTS LPAREN expression COMMA expression RPAREN\n | DISJOINT LPAREN expression COMMA expression RPAREN\n | CONTAINS LPAREN expression COMMA expression RPAREN\n | WITHIN LPAREN expression COMMA expression RPAREN\n | TOUCHES LPAREN expression COMMA expression RPAREN\n | CROSSES LPAREN expression COMMA expression RPAREN\n | OVERLAPS LPAREN expression COMMA expression RPAREN\n | EQUALS LPAREN expression COMMA expression RPAREN\n | RELATE LPAREN expression COMMA expression COMMA QUOTED RPAREN\n | DWITHIN LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN\n | BEYOND LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN\n | BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number RPAREN\n | BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number COMMA QUOTED RPAREN\n expression_list : expression_list COMMA expression\n | expression\n expression : expression PLUS expression\n | expression MINUS expression\n | expression TIMES expression\n | expression DIVIDE expression\n | LPAREN expression RPAREN\n | LBRACKET expression RBRACKET\n | GEOMETRY\n | ENVELOPE\n | attribute\n | QUOTED\n | INTEGER\n | FLOAT\n number : INTEGER\n | FLOAT\n attribute : ATTRIBUTE\n empty : ' 10 | 11 | _lr_action_items = {'NOT':([0,5,6,7,8,9,12,13,14,15,16,29,30,31,34,36,48,71,73,92,93,94,95,],[5,5,5,5,43,-59,-56,-57,-58,-60,-61,-64,5,5,43,43,90,-54,-55,-50,-51,-52,-53,]),'LPAREN':([0,5,6,7,17,18,19,20,21,22,23,24,25,26,27,28,30,31,37,38,39,40,41,42,44,47,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,85,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[6,6,6,6,56,57,58,59,60,61,62,63,64,65,66,67,6,6,75,75,75,75,75,75,75,89,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,120,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,75,]),'LBRACKET':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[7,7,7,7,7,7,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,76,]),'$end':([0,1,2,3,4,9,10,11,12,13,14,15,16,29,32,68,69,70,71,72,73,74,77,78,79,80,81,87,88,91,92,93,94,95,96,98,102,118,119,124,143,144,146,147,148,149,150,165,166,168,169,170,171,172,173,174,175,184,191,192,196,198,],[-65,0,-1,-2,-3,-59,-25,-26,-56,-57,-58,-60,-61,-64,-6,-4,-5,-7,-54,-8,-55,-9,-10,-11,-12,-13,-14,-18,-20,-24,-50,-51,-52,-53,-27,-29,-31,-17,-19,-23,-16,-22,-28,-30,-32,-33,-34,-15,-21,-35,-36,-37,-38,-39,-40,-41,-42,-43,-44,-45,-46,-47,]),'GEOMETRY':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,]),'ENVELOPE':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,]),'QUOTED':([0,5,6,7,30,31,37,38,39,40,41,42,44,45,46,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,83,84,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,176,195,],[9,9,9,9,9,9,9,9,9,9,9,9,9,87,88,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,118,119,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,180,197,]),'INTEGER':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,140,141,145,177,178,179,187,193,],[15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,163,15,15,163,163,163,163,163,]),'FLOAT':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,140,141,145,177,178,179,187,193,],[16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,164,16,16,164,164,164,164,164,]),'INTERSECTS':([0,5,6,7,30,31,],[17,17,17,17,17,17,]),'DISJOINT':([0,5,6,7,30,31,],[18,18,18,18,18,18,]),'CONTAINS':([0,5,6,7,30,31,],[19,19,19,19,19,19,]),'WITHIN':([0,5,6,7,30,31,],[20,20,20,20,20,20,]),'TOUCHES':([0,5,6,7,30,31,],[21,21,21,21,21,21,]),'CROSSES':([0,5,6,7,30,31,],[22,22,22,22,22,22,]),'OVERLAPS':([0,5,6,7,30,31,],[23,23,23,23,23,23,]),'EQUALS':([0,5,6,7,30,31,],[24,24,24,24,24,24,]),'RELATE':([0,5,6,7,30,31,],[25,25,25,25,25,25,]),'DWITHIN':([0,5,6,7,30,31,],[26,26,26,26,26,26,]),'BEYOND':([0,5,6,7,30,31,],[27,27,27,27,27,27,]),'BBOX':([0,5,6,7,30,31,],[28,28,28,28,28,28,]),'ATTRIBUTE':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,29,]),'AND':([2,4,9,10,11,12,13,14,15,16,29,32,33,35,68,69,70,71,72,73,74,77,78,79,80,81,86,87,88,91,92,93,94,95,96,98,102,117,118,119,124,143,144,146,147,148,149,150,165,166,168,169,170,171,172,173,174,175,184,191,192,196,198,],[30,-3,-59,-25,-26,-56,-57,-58,-60,-61,-64,30,30,30,30,30,-7,-54,-8,-55,-9,-10,-11,-12,-13,-14,121,-18,-20,-24,-50,-51,-52,-53,-27,-29,-31,141,-17,-19,-23,-16,-22,-28,-30,-32,-33,-34,-15,-21,-35,-36,-37,-38,-39,-40,-41,-42,-43,-44,-45,-46,-47,]),'OR':([2,4,9,10,11,12,13,14,15,16,29,32,33,35,53,54,68,69,70,71,72,73,74,77,78,79,80,81,87,88,91,92,93,94,95,96,98,102,118,119,124,143,144,146,147,148,149,150,165,166,168,169,170,171,172,173,174,175,184,191,192,196,198,],[31,-3,-59,-25,-26,-56,-57,-58,-60,-61,-64,31,31,31,97,99,31,31,-7,-54,-8,-55,-9,-10,-11,-12,-13,-14,-18,-20,-24,-50,-51,-52,-53,-27,-29,-31,-17,-19,-23,-16,-22,-28,-30,-32,-33,-34,-15,-21,-35,-36,-37,-38,-39,-40,-41,-42,-43,-44,-45,-46,-47,]),'RPAREN':([4,9,10,11,12,13,14,15,16,29,32,33,34,68,69,70,71,72,73,74,77,78,79,80,81,87,88,91,92,93,94,95,96,98,102,115,118,119,122,123,124,142,143,144,146,147,148,149,150,151,152,153,154,155,156,157,158,163,164,165,166,167,168,169,170,171,172,173,174,175,180,184,188,189,191,192,194,196,197,198,],[-3,-59,-25,-26,-56,-57,-58,-60,-61,-64,-6,70,71,-4,-5,-7,-54,-8,-55,-9,-10,-11,-12,-13,-14,-18,-20,-24,-50,-51,-52,-53,-27,-29,-31,71,-17,-19,-49,144,-23,166,-16,-22,-28,-30,-32,-33,-34,168,169,170,171,172,173,174,175,-62,-63,-15,-21,-48,-35,-36,-37,-38,-39,-40,-41,-42,184,-43,191,192,-44,-45,196,-46,198,-47,]),'RBRACKET':([4,9,10,11,12,13,14,15,16,29,32,35,36,68,69,70,71,72,73,74,77,78,79,80,81,87,88,91,92,93,94,95,96,98,102,116,118,119,124,143,144,146,147,148,149,150,165,166,168,169,170,171,172,173,174,175,184,191,192,196,198,],[-3,-59,-25,-26,-56,-57,-58,-60,-61,-64,-6,72,73,-4,-5,-7,-54,-8,-55,-9,-10,-11,-12,-13,-14,-18,-20,-24,-50,-51,-52,-53,-27,-29,-31,73,-17,-19,-23,-16,-22,-28,-30,-32,-33,-34,-15,-21,-35,-36,-37,-38,-39,-40,-41,-42,-43,-44,-45,-46,-47,]),'EQ':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[37,-59,-56,-57,-58,-60,-61,-64,37,37,-54,-55,-50,-51,-52,-53,]),'NE':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[38,-59,-56,-57,-58,-60,-61,-64,38,38,-54,-55,-50,-51,-52,-53,]),'LT':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[39,-59,-56,-57,-58,-60,-61,-64,39,39,-54,-55,-50,-51,-52,-53,]),'LE':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[40,-59,-56,-57,-58,-60,-61,-64,40,40,-54,-55,-50,-51,-52,-53,]),'GT':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[41,-59,-56,-57,-58,-60,-61,-64,41,41,-54,-55,-50,-51,-52,-53,]),'GE':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[42,-59,-56,-57,-58,-60,-61,-64,42,42,-54,-55,-50,-51,-52,-53,]),'BETWEEN':([8,9,12,13,14,15,16,29,34,36,43,71,73,92,93,94,95,],[44,-59,-56,-57,-58,-60,-61,-64,44,44,82,-54,-55,-50,-51,-52,-53,]),'LIKE':([8,9,12,13,14,15,16,29,34,36,43,71,73,92,93,94,95,],[45,-59,-56,-57,-58,-60,-61,-64,45,45,83,-54,-55,-50,-51,-52,-53,]),'ILIKE':([8,9,12,13,14,15,16,29,34,36,43,71,73,92,93,94,95,],[46,-59,-56,-57,-58,-60,-61,-64,46,46,84,-54,-55,-50,-51,-52,-53,]),'IN':([8,9,12,13,14,15,16,29,34,36,43,71,73,92,93,94,95,],[47,-59,-56,-57,-58,-60,-61,-64,47,47,85,-54,-55,-50,-51,-52,-53,]),'IS':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[48,-59,-56,-57,-58,-60,-61,-64,48,48,-54,-55,-50,-51,-52,-53,]),'PLUS':([8,9,12,13,14,15,16,29,34,36,71,73,74,77,78,79,80,81,86,92,93,94,95,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,122,143,151,152,153,154,155,156,157,158,159,160,161,165,167,],[49,-59,-56,-57,-58,-60,-61,-64,49,49,-54,-55,49,49,49,49,49,49,49,-50,-51,-52,-53,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,]),'MINUS':([8,9,12,13,14,15,16,29,34,36,71,73,74,77,78,79,80,81,86,92,93,94,95,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,122,143,151,152,153,154,155,156,157,158,159,160,161,165,167,],[50,-59,-56,-57,-58,-60,-61,-64,50,50,-54,-55,50,50,50,50,50,50,50,-50,-51,-52,-53,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,]),'TIMES':([8,9,12,13,14,15,16,29,34,36,71,73,74,77,78,79,80,81,86,92,93,94,95,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,122,143,151,152,153,154,155,156,157,158,159,160,161,165,167,],[51,-59,-56,-57,-58,-60,-61,-64,51,51,-54,-55,51,51,51,51,51,51,51,51,51,-52,-53,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,]),'DIVIDE':([8,9,12,13,14,15,16,29,34,36,71,73,74,77,78,79,80,81,86,92,93,94,95,100,101,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,122,143,151,152,153,154,155,156,157,158,159,160,161,165,167,],[52,-59,-56,-57,-58,-60,-61,-64,52,52,-54,-55,52,52,52,52,52,52,52,52,52,-52,-53,127,128,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,]),'BEFORE':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,],[53,-59,-56,-57,-58,-60,-61,-64,53,53,-54,-55,-50,-51,-52,-53,]),'DURING':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,97,],[54,-59,-56,-57,-58,-60,-61,-64,54,54,-54,-55,-50,-51,-52,-53,125,]),'AFTER':([8,9,12,13,14,15,16,29,34,36,71,73,92,93,94,95,99,],[55,-59,-56,-57,-58,-60,-61,-64,55,55,-54,-55,-50,-51,-52,-53,126,]),'COMMA':([9,12,13,14,15,16,29,71,73,92,93,94,95,103,104,105,106,107,108,109,110,111,112,113,114,122,123,142,159,160,161,162,163,164,167,181,182,183,190,194,],[-59,-56,-57,-58,-60,-61,-64,-54,-55,-50,-51,-52,-53,129,130,131,132,133,134,135,136,137,138,139,140,-49,145,145,176,177,178,179,-62,-63,-48,185,186,187,193,195,]),'NULL':([48,90,],[91,124,]),'TIME':([53,54,55,125,126,127,128,],[96,100,102,100,100,148,150,]),'DURATION':([54,125,126,127,],[101,101,101,149,]),'UNITS':([185,186,],[188,189,]),} 12 | 13 | _lr_action = {} 14 | for _k, _v in _lr_action_items.items(): 15 | for _x,_y in zip(_v[0],_v[1]): 16 | if not _x in _lr_action: _lr_action[_x] = {} 17 | _lr_action[_x][_k] = _y 18 | del _lr_action_items 19 | 20 | _lr_goto_items = {'condition_or_empty':([0,],[1,]),'condition':([0,5,6,7,30,31,],[2,32,33,35,68,69,]),'empty':([0,],[3,]),'predicate':([0,5,6,7,30,31,],[4,4,4,4,4,4,]),'expression':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[8,8,34,36,8,8,74,77,78,79,80,81,86,92,93,94,95,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,122,122,143,151,152,153,154,155,156,157,158,159,160,161,165,167,]),'temporal_predicate':([0,5,6,7,30,31,],[10,10,10,10,10,10,]),'spatial_predicate':([0,5,6,7,30,31,],[11,11,11,11,11,11,]),'attribute':([0,5,6,7,30,31,37,38,39,40,41,42,44,49,50,51,52,56,57,58,59,60,61,62,63,64,65,66,67,75,76,82,89,120,121,129,130,131,132,133,134,135,136,137,138,139,141,145,],[14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,14,]),'time_period':([54,125,126,],[98,146,147,]),'expression_list':([89,120,],[123,142,]),'number':([140,177,178,179,187,193,],[162,181,182,183,190,194,]),} 21 | 22 | _lr_goto = {} 23 | for _k, _v in _lr_goto_items.items(): 24 | for _x, _y in zip(_v[0], _v[1]): 25 | if not _x in _lr_goto: _lr_goto[_x] = {} 26 | _lr_goto[_x][_k] = _y 27 | del _lr_goto_items 28 | _lr_productions = [ 29 | ("S' -> condition_or_empty","S'",1,None,None,None), 30 | ('condition_or_empty -> condition','condition_or_empty',1,'p_condition_or_empty','parser.py',86), 31 | ('condition_or_empty -> empty','condition_or_empty',1,'p_condition_or_empty','parser.py',87), 32 | ('condition -> predicate','condition',1,'p_condition','parser.py',92), 33 | ('condition -> condition AND condition','condition',3,'p_condition','parser.py',93), 34 | ('condition -> condition OR condition','condition',3,'p_condition','parser.py',94), 35 | ('condition -> NOT condition','condition',2,'p_condition','parser.py',95), 36 | ('condition -> LPAREN condition RPAREN','condition',3,'p_condition','parser.py',96), 37 | ('condition -> LBRACKET condition RBRACKET','condition',3,'p_condition','parser.py',97), 38 | ('predicate -> expression EQ expression','predicate',3,'p_predicate','parser.py',110), 39 | ('predicate -> expression NE expression','predicate',3,'p_predicate','parser.py',111), 40 | ('predicate -> expression LT expression','predicate',3,'p_predicate','parser.py',112), 41 | ('predicate -> expression LE expression','predicate',3,'p_predicate','parser.py',113), 42 | ('predicate -> expression GT expression','predicate',3,'p_predicate','parser.py',114), 43 | ('predicate -> expression GE expression','predicate',3,'p_predicate','parser.py',115), 44 | ('predicate -> expression NOT BETWEEN expression AND expression','predicate',6,'p_predicate','parser.py',116), 45 | ('predicate -> expression BETWEEN expression AND expression','predicate',5,'p_predicate','parser.py',117), 46 | ('predicate -> expression NOT LIKE QUOTED','predicate',4,'p_predicate','parser.py',118), 47 | ('predicate -> expression LIKE QUOTED','predicate',3,'p_predicate','parser.py',119), 48 | ('predicate -> expression NOT ILIKE QUOTED','predicate',4,'p_predicate','parser.py',120), 49 | ('predicate -> expression ILIKE QUOTED','predicate',3,'p_predicate','parser.py',121), 50 | ('predicate -> expression NOT IN LPAREN expression_list RPAREN','predicate',6,'p_predicate','parser.py',122), 51 | ('predicate -> expression IN LPAREN expression_list RPAREN','predicate',5,'p_predicate','parser.py',123), 52 | ('predicate -> expression IS NOT NULL','predicate',4,'p_predicate','parser.py',124), 53 | ('predicate -> expression IS NULL','predicate',3,'p_predicate','parser.py',125), 54 | ('predicate -> temporal_predicate','predicate',1,'p_predicate','parser.py',126), 55 | ('predicate -> spatial_predicate','predicate',1,'p_predicate','parser.py',127), 56 | ('temporal_predicate -> expression BEFORE TIME','temporal_predicate',3,'p_temporal_predicate','parser.py',157), 57 | ('temporal_predicate -> expression BEFORE OR DURING time_period','temporal_predicate',5,'p_temporal_predicate','parser.py',158), 58 | ('temporal_predicate -> expression DURING time_period','temporal_predicate',3,'p_temporal_predicate','parser.py',159), 59 | ('temporal_predicate -> expression DURING OR AFTER time_period','temporal_predicate',5,'p_temporal_predicate','parser.py',160), 60 | ('temporal_predicate -> expression AFTER TIME','temporal_predicate',3,'p_temporal_predicate','parser.py',161), 61 | ('time_period -> TIME DIVIDE TIME','time_period',3,'p_time_period','parser.py',172), 62 | ('time_period -> TIME DIVIDE DURATION','time_period',3,'p_time_period','parser.py',173), 63 | ('time_period -> DURATION DIVIDE TIME','time_period',3,'p_time_period','parser.py',174), 64 | ('spatial_predicate -> INTERSECTS LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',179), 65 | ('spatial_predicate -> DISJOINT LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',180), 66 | ('spatial_predicate -> CONTAINS LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',181), 67 | ('spatial_predicate -> WITHIN LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',182), 68 | ('spatial_predicate -> TOUCHES LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',183), 69 | ('spatial_predicate -> CROSSES LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',184), 70 | ('spatial_predicate -> OVERLAPS LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',185), 71 | ('spatial_predicate -> EQUALS LPAREN expression COMMA expression RPAREN','spatial_predicate',6,'p_spatial_predicate','parser.py',186), 72 | ('spatial_predicate -> RELATE LPAREN expression COMMA expression COMMA QUOTED RPAREN','spatial_predicate',8,'p_spatial_predicate','parser.py',187), 73 | ('spatial_predicate -> DWITHIN LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN','spatial_predicate',10,'p_spatial_predicate','parser.py',188), 74 | ('spatial_predicate -> BEYOND LPAREN expression COMMA expression COMMA number COMMA UNITS RPAREN','spatial_predicate',10,'p_spatial_predicate','parser.py',189), 75 | ('spatial_predicate -> BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number RPAREN','spatial_predicate',12,'p_spatial_predicate','parser.py',190), 76 | ('spatial_predicate -> BBOX LPAREN expression COMMA number COMMA number COMMA number COMMA number COMMA QUOTED RPAREN','spatial_predicate',14,'p_spatial_predicate','parser.py',191), 77 | ('expression_list -> expression_list COMMA expression','expression_list',3,'p_expression_list','parser.py',210), 78 | ('expression_list -> expression','expression_list',1,'p_expression_list','parser.py',211), 79 | ('expression -> expression PLUS expression','expression',3,'p_expression','parser.py',220), 80 | ('expression -> expression MINUS expression','expression',3,'p_expression','parser.py',221), 81 | ('expression -> expression TIMES expression','expression',3,'p_expression','parser.py',222), 82 | ('expression -> expression DIVIDE expression','expression',3,'p_expression','parser.py',223), 83 | ('expression -> LPAREN expression RPAREN','expression',3,'p_expression','parser.py',224), 84 | ('expression -> LBRACKET expression RBRACKET','expression',3,'p_expression','parser.py',225), 85 | ('expression -> GEOMETRY','expression',1,'p_expression','parser.py',226), 86 | ('expression -> ENVELOPE','expression',1,'p_expression','parser.py',227), 87 | ('expression -> attribute','expression',1,'p_expression','parser.py',228), 88 | ('expression -> QUOTED','expression',1,'p_expression','parser.py',229), 89 | ('expression -> INTEGER','expression',1,'p_expression','parser.py',230), 90 | ('expression -> FLOAT','expression',1,'p_expression','parser.py',231), 91 | ('number -> INTEGER','number',1,'p_number','parser.py',248), 92 | ('number -> FLOAT','number',1,'p_number','parser.py',249), 93 | ('attribute -> ATTRIBUTE','attribute',1,'p_attribute','parser.py',254), 94 | ('empty -> ','empty',0,'p_empty','parser.py',259), 95 | ] 96 | -------------------------------------------------------------------------------- /pycql/util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import re 29 | from datetime import timedelta 30 | 31 | RE_ISO_8601 = re.compile( 32 | r"^(?P[+-])?P" 33 | r"(?:(?P\d+(\.\d+)?)Y)?" 34 | r"(?:(?P\d+(\.\d+)?)M)?" 35 | r"(?:(?P\d+(\.\d+)?)D)?" 36 | r"T?(?:(?P\d+(\.\d+)?)H)?" 37 | r"(?:(?P\d+(\.\d+)?)M)?" 38 | r"(?:(?P\d+(\.\d+)?)S)?$" 39 | ) 40 | 41 | 42 | def parse_duration(value): 43 | """ Parses an ISO 8601 duration string into a python timedelta object. 44 | Raises a ``ValueError`` if a conversion was not possible. 45 | 46 | :param value: the ISO8601 duration string to parse 47 | :type value: str 48 | :return: the parsed duration 49 | :rtype: datetime.timedelta 50 | """ 51 | 52 | match = RE_ISO_8601.match(value) 53 | if not match: 54 | raise ValueError( 55 | "Could not parse ISO 8601 duration from '%s'." % value 56 | ) 57 | match = match.groupdict() 58 | 59 | sign = -1 if "-" == match['sign'] else 1 60 | days = float(match['days'] or 0) 61 | days += float(match['months'] or 0) * 30 # ?! 62 | days += float(match['years'] or 0) * 365 # ?! 63 | fsec = float(match['seconds'] or 0) 64 | fsec += float(match['minutes'] or 0) * 60 65 | fsec += float(match['hours'] or 0) * 3600 66 | 67 | return sign * timedelta(days, fsec) 68 | -------------------------------------------------------------------------------- /pycql/values.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | class _Value: 29 | def __init__(self, value): 30 | self.value = value 31 | 32 | def __eq__(self, other): 33 | if type(self) != type(other): 34 | return False 35 | 36 | return self.value == other.value 37 | 38 | class Geometry(_Value): 39 | def __repr__(self): 40 | return "GEOMETRY '%s'" % self.value 41 | 42 | class Time(_Value): 43 | def __repr__(self): 44 | return "TIME '%s'" % self.value 45 | 46 | class Duration(_Value): 47 | def __repr__(self): 48 | return "DURATION '%s'" % self.value 49 | 50 | class BBox(_Value): 51 | def __repr__(self): 52 | return "BBOX '%s'" % self.value 53 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pytest 3 | wheel 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | django 2 | geoalchemy2 3 | sqlalchemy 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dateparser 2 | ply 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """Install pycql.""" 29 | 30 | from setuptools import find_packages, setup 31 | import os 32 | import os.path 33 | 34 | # don't install dependencies when building win readthedocs 35 | on_rtd = os.environ.get('READTHEDOCS') == 'True' 36 | 37 | # get version number 38 | # from https://github.com/mapbox/rasterio/blob/master/setup.py#L55 39 | with open(os.path.join(os.path.dirname(__file__), 'pycql/__init__.py')) as f: 40 | for line in f: 41 | if line.find("__version__") >= 0: 42 | version = line.split("=")[1].strip() 43 | version = version.strip('"') 44 | version = version.strip("'") 45 | break 46 | 47 | # use README.md for project long_description 48 | with open('README.md') as f: 49 | readme = f.read() 50 | 51 | 52 | def parse_requirements(file): 53 | return sorted(set( 54 | line.partition('#')[0].strip() 55 | for line in open(os.path.join(os.path.dirname(__file__), file)) 56 | ) - set('')) 57 | 58 | setup( 59 | name='pycql', 60 | version=version, 61 | description='pycql is a pure Python parser implementation of the OGC CQL standard', 62 | long_description=readme, 63 | long_description_content_type="text/markdown", 64 | author='Fabian Schindler', 65 | author_email='fabian.schindler@gmail.com', 66 | url='https://github.com/geopython/pycql', 67 | license='MIT', 68 | packages=find_packages(), 69 | package_dir={'static': 'static'}, 70 | install_requires=parse_requirements('requirements.txt') if not on_rtd else [], 71 | classifiers=[ 72 | 'Development Status :: 3 - Alpha', 73 | 'Intended Audience :: Developers', 74 | 'Topic :: Scientific/Engineering :: GIS', 75 | 'License :: OSI Approved :: MIT License', 76 | 'Programming Language :: Python :: 3.5', 77 | 'Programming Language :: Python :: 3.6', 78 | 'Programming Language :: Python :: 3.7', 79 | ], 80 | tests_require=['pytest'] 81 | ) 82 | -------------------------------------------------------------------------------- /tests/django_test/django_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/tests/django_test/django_test/__init__.py -------------------------------------------------------------------------------- /tests/django_test/django_test/settings.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """ 29 | Django settings for django_test project. 30 | 31 | Generated by 'django-admin startproject' using Django 2.2.5. 32 | 33 | For more information on this file, see 34 | https://docs.djangoproject.com/en/2.2/topics/settings/ 35 | 36 | For the full list of settings and their values, see 37 | https://docs.djangoproject.com/en/2.2/ref/settings/ 38 | """ 39 | 40 | import os 41 | 42 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 43 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 44 | 45 | 46 | # Quick-start development settings - unsuitable for production 47 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 48 | 49 | # SECURITY WARNING: keep the secret key used in production secret! 50 | SECRET_KEY = '$8$g&lh$b4ye0ec7$aq5dgm%xd-5^505_y8627rikrwkjo!)4v' 51 | 52 | # SECURITY WARNING: don't run with debug turned on in production! 53 | DEBUG = True 54 | 55 | ALLOWED_HOSTS = [] 56 | 57 | 58 | # Application definition 59 | 60 | INSTALLED_APPS = [ 61 | 'django.contrib.admin', 62 | 'django.contrib.auth', 63 | 'django.contrib.contenttypes', 64 | 'django.contrib.sessions', 65 | 'django.contrib.messages', 66 | 'django.contrib.staticfiles', 67 | 'django.contrib.gis', 68 | 'testapp', 69 | ] 70 | 71 | MIDDLEWARE = [ 72 | 'django.middleware.security.SecurityMiddleware', 73 | 'django.contrib.sessions.middleware.SessionMiddleware', 74 | 'django.middleware.common.CommonMiddleware', 75 | 'django.middleware.csrf.CsrfViewMiddleware', 76 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 77 | 'django.contrib.messages.middleware.MessageMiddleware', 78 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 79 | ] 80 | 81 | ROOT_URLCONF = 'django_test.urls' 82 | 83 | TEMPLATES = [ 84 | { 85 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 86 | 'DIRS': [], 87 | 'APP_DIRS': True, 88 | 'OPTIONS': { 89 | 'context_processors': [ 90 | 'django.template.context_processors.debug', 91 | 'django.template.context_processors.request', 92 | 'django.contrib.auth.context_processors.auth', 93 | 'django.contrib.messages.context_processors.messages', 94 | ], 95 | }, 96 | }, 97 | ] 98 | 99 | WSGI_APPLICATION = 'django_test.wsgi.application' 100 | 101 | 102 | # Database 103 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 104 | 105 | DATABASES = { 106 | 'default': { 107 | 'ENGINE': 'django.contrib.gis.db.backends.spatialite', 108 | 'NAME': ':memory:', # os.path.join(BASE_DIR, 'db.sqlite3'), 109 | 'TEST': { 110 | 'NAME': ':memory:', 111 | } 112 | } 113 | } 114 | 115 | 116 | # Password validation 117 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 118 | 119 | AUTH_PASSWORD_VALIDATORS = [ 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 122 | }, 123 | { 124 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 125 | }, 126 | { 127 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 128 | }, 129 | { 130 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 131 | }, 132 | ] 133 | 134 | 135 | # Internationalization 136 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 137 | 138 | LANGUAGE_CODE = 'en-us' 139 | 140 | TIME_ZONE = 'UTC' 141 | 142 | USE_I18N = True 143 | 144 | USE_L10N = True 145 | 146 | USE_TZ = True 147 | 148 | 149 | # Static files (CSS, JavaScript, Images) 150 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 151 | 152 | STATIC_URL = '/static/' 153 | -------------------------------------------------------------------------------- /tests/django_test/django_test/urls.py: -------------------------------------------------------------------------------- 1 | """django_test URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/django_test/django_test/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_test.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/django_test/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_test.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /tests/django_test/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/tests/django_test/testapp/__init__.py -------------------------------------------------------------------------------- /tests/django_test/testapp/admin.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.contrib import admin 29 | 30 | # Register your models here. 31 | -------------------------------------------------------------------------------- /tests/django_test/testapp/apps.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.apps import AppConfig 29 | 30 | 31 | class TestappConfig(AppConfig): 32 | name = 'testapp' 33 | -------------------------------------------------------------------------------- /tests/django_test/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-09 07:18 2 | 3 | import django.contrib.gis.db.models.fields 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Record', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('identifier', models.CharField(max_length=256, unique=True)), 21 | ('geometry', django.contrib.gis.db.models.fields.GeometryField(srid=4326)), 22 | ('float_attribute', models.FloatField(blank=True, null=True)), 23 | ('int_attribute', models.IntegerField(blank=True, null=True)), 24 | ('str_attribute', models.CharField(blank=True, max_length=256, null=True)), 25 | ('datetime_attribute', models.DateTimeField(blank=True, null=True)), 26 | ('choice_attribute', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'A'), (2, 'B'), (3, 'C')], null=True)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name='RecordMeta', 31 | fields=[ 32 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('float_meta_attribute', models.FloatField(blank=True, null=True)), 34 | ('int_meta_attribute', models.IntegerField(blank=True, null=True)), 35 | ('str_meta_attribute', models.CharField(blank=True, max_length=256, null=True)), 36 | ('datetime_meta_attribute', models.DateTimeField(blank=True, null=True)), 37 | ('choice_meta_attribute', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'X'), (2, 'Y'), (3, 'Z')], null=True)), 38 | ('record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='record_metas', to='testapp.Record')), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /tests/django_test/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/tests/django_test/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/django_test/testapp/models.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | 29 | from django.contrib.gis.db import models 30 | 31 | 32 | optional = dict(null=True, blank=True) 33 | 34 | class Record(models.Model): 35 | identifier = models.CharField(max_length=256, unique=True, null=False) 36 | geometry = models.GeometryField() 37 | 38 | float_attribute = models.FloatField(**optional) 39 | int_attribute = models.IntegerField(**optional) 40 | str_attribute = models.CharField(max_length=256, **optional) 41 | datetime_attribute = models.DateTimeField(**optional) 42 | choice_attribute = models.PositiveSmallIntegerField(choices=[(1, 'ASCENDING'), (2, 'DESCENDING'),], **optional) 43 | 44 | 45 | class RecordMeta(models.Model): 46 | record = models.ForeignKey(Record, on_delete=models.CASCADE, related_name='record_metas') 47 | 48 | float_meta_attribute = models.FloatField(**optional) 49 | int_meta_attribute = models.IntegerField(**optional) 50 | str_meta_attribute = models.CharField(max_length=256, **optional) 51 | datetime_meta_attribute = models.DateTimeField(**optional) 52 | choice_meta_attribute = models.PositiveSmallIntegerField(choices=[(1, 'X'), (2, 'Y'), (3, 'Z')], **optional) 53 | 54 | 55 | FIELD_MAPPING = { 56 | 'identifier': 'identifier', 57 | 'geometry': 'geometry', 58 | 'floatAttribute': 'float_attribute', 59 | 'intAttribute': 'int_attribute', 60 | 'strAttribute': 'str_attribute', 61 | 'datetimeAttribute': 'datetime_attribute', 62 | 'choiceAttribute': 'choice_attribute', 63 | 64 | # meta fields 65 | 'floatMetaAttribute': 'record_metas__float_meta_attribute', 66 | 'intMetaAttribute': 'record_metas__int_meta_attribute', 67 | 'strMetaAttribute': 'record_metas__str_meta_attribute', 68 | 'datetimeMetaAttribute': 'record_metas__datetime_meta_attribute', 69 | 'choiceMetaAttribute': 'record_metas__choice_meta_attribute', 70 | } 71 | 72 | MAPPING_CHOICES = { 73 | 'choiceAttribute': dict(Record._meta.get_field('choice_attribute').choices), 74 | 'choiceMetaAttribute': dict(RecordMeta._meta.get_field('choice_meta_attribute').choices), 75 | } 76 | 77 | -------------------------------------------------------------------------------- /tests/django_test/testapp/tests.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.test import TransactionTestCase 29 | from django.db.models import ForeignKey 30 | from django.contrib.gis.geos import Polygon, MultiPolygon, GEOSGeometry 31 | from django.utils.dateparse import parse_datetime 32 | 33 | from pycql import parse 34 | from pycql.util import parse_duration 35 | from pycql.integrations.django.evaluate import to_filter 36 | 37 | from . import models 38 | 39 | class CQLTestCase(TransactionTestCase): 40 | def setUp(self): 41 | self.create(dict( 42 | identifier="A", 43 | geometry=MultiPolygon(Polygon.from_bbox((0, 0, 5, 5))), 44 | float_attribute=0.0, 45 | int_attribute=10, 46 | str_attribute='AAA', 47 | datetime_attribute=parse_datetime("2000-01-01T00:00:00Z"), 48 | choice_attribute="ASCENDING", 49 | ), dict( 50 | float_meta_attribute=10.0, 51 | int_meta_attribute=20, 52 | str_meta_attribute="AparentA", 53 | datetime_meta_attribute=parse_datetime("2000-01-01T00:00:05Z"), 54 | choice_meta_attribute="X", 55 | )) 56 | 57 | self.create(dict( 58 | identifier="B", 59 | geometry=MultiPolygon(Polygon.from_bbox((5, 5, 10, 10))), 60 | float_attribute=30.0, 61 | int_attribute=None, 62 | str_attribute='BBB', 63 | datetime_attribute=parse_datetime("2000-01-01T00:00:05Z"), 64 | choice_attribute="DESCENDING", 65 | ), dict( 66 | float_meta_attribute=20.0, 67 | int_meta_attribute=30, 68 | str_meta_attribute="BparentB", 69 | datetime_meta_attribute=parse_datetime("2000-01-01T00:00:10Z"), 70 | choice_meta_attribute="Y", 71 | )) 72 | 73 | def convert(self, name, value, model_class): 74 | field = model_class._meta.get_field(name) 75 | if field.choices: 76 | return dict((v, k) for k, v in field.choices)[value] 77 | return value 78 | 79 | def create_meta(self, record, metadata): 80 | record_meta = models.RecordMeta(**dict( 81 | (name, self.convert(name, value, models.RecordMeta)) 82 | for name, value in metadata.items() 83 | )) 84 | record_meta.record = record 85 | record_meta.full_clean() 86 | record_meta.save() 87 | 88 | def create(self, record_params, metadata_params): 89 | record = models.Record.objects.create(**dict( 90 | (name, self.convert(name, value, models.Record)) 91 | for name, value in record_params.items() 92 | )) 93 | self.create_meta(record, metadata_params) 94 | return record 95 | 96 | def evaluate(self, cql_expr, expected_ids, model_type=None): 97 | model_type = model_type or models.Record 98 | mapping = models.FIELD_MAPPING 99 | mapping_choices = models.MAPPING_CHOICES 100 | 101 | ast = parse( 102 | cql_expr, GEOSGeometry, Polygon.from_bbox, parse_datetime, 103 | parse_duration 104 | ) 105 | filters = to_filter(ast, mapping, mapping_choices) 106 | 107 | qs = model_type.objects.filter(filters) 108 | 109 | self.assertEqual( 110 | expected_ids, 111 | type(expected_ids)(qs.values_list("identifier", flat=True)) 112 | ) 113 | 114 | # common comparisons 115 | 116 | def test_id_eq(self): 117 | self.evaluate( 118 | 'identifier = "A"', 119 | ('A',) 120 | ) 121 | 122 | def test_id_ne(self): 123 | self.evaluate( 124 | 'identifier <> "B"', 125 | ('A',) 126 | ) 127 | 128 | def test_float_lt(self): 129 | self.evaluate( 130 | 'floatAttribute < 30', 131 | ('A',) 132 | ) 133 | 134 | def test_float_le(self): 135 | self.evaluate( 136 | 'floatAttribute <= 20', 137 | ('A',) 138 | ) 139 | 140 | def test_float_gt(self): 141 | self.evaluate( 142 | 'floatAttribute > 20', 143 | ('B',) 144 | ) 145 | 146 | def test_float_ge(self): 147 | self.evaluate( 148 | 'floatAttribute >= 30', 149 | ('B',) 150 | ) 151 | 152 | def test_float_between(self): 153 | self.evaluate( 154 | 'floatAttribute BETWEEN -1 AND 1', 155 | ('A',) 156 | ) 157 | 158 | # test different field types 159 | 160 | def test_common_value_eq(self): 161 | self.evaluate( 162 | 'strAttribute = "AAA"', 163 | ('A',) 164 | ) 165 | 166 | def test_common_value_in(self): 167 | self.evaluate( 168 | 'strAttribute IN ("AAA", "XXX")', 169 | ('A',) 170 | ) 171 | 172 | def test_common_value_like(self): 173 | self.evaluate( 174 | 'strAttribute LIKE "AA%"', 175 | ('A',) 176 | ) 177 | 178 | def test_common_value_like_middle(self): 179 | self.evaluate( 180 | r'strAttribute LIKE "A%A"', 181 | ('A',) 182 | ) 183 | 184 | # TODO: resolve from choice? 185 | # def test_enum_value_eq(self): 186 | # self.evaluate( 187 | # 'choiceAttribute = "A"', 188 | # ('A',) 189 | # ) 190 | 191 | # def test_enum_value_in(self): 192 | # self.evaluate( 193 | # 'choiceAttribute IN ("ASCENDING")', 194 | # ('A',) 195 | # ) 196 | 197 | # def test_enum_value_like(self): 198 | # self.evaluate( 199 | # 'choiceAttribute LIKE "ASCEN%"', 200 | # ('A',) 201 | # ) 202 | 203 | # def test_enum_value_ilike(self): 204 | # self.evaluate( 205 | # 'choiceAttribute ILIKE "ascen%"', 206 | # ('A',) 207 | # ) 208 | 209 | # def test_enum_value_ilike_start_middle_end(self): 210 | # self.evaluate( 211 | # r'choiceAttribute ILIKE "a%en%ing"', 212 | # ('A',) 213 | # ) 214 | 215 | # (NOT) LIKE | ILIKE 216 | 217 | def test_like_beginswith(self): 218 | self.evaluate( 219 | 'strMetaAttribute LIKE "A%"', 220 | ('A',) 221 | ) 222 | 223 | def test_ilike_beginswith(self): 224 | self.evaluate( 225 | 'strMetaAttribute ILIKE "a%"', 226 | ('A',) 227 | ) 228 | 229 | def test_like_endswith(self): 230 | self.evaluate( 231 | r'strMetaAttribute LIKE "%A"', 232 | ('A',) 233 | ) 234 | 235 | def test_ilike_endswith(self): 236 | self.evaluate( 237 | r'strMetaAttribute ILIKE "%a"', 238 | ('A',) 239 | ) 240 | 241 | def test_like_middle(self): 242 | self.evaluate( 243 | r'strMetaAttribute LIKE "%parent%"', 244 | ('A', 'B') 245 | ) 246 | 247 | def test_like_startswith_middle(self): 248 | self.evaluate( 249 | r'strMetaAttribute LIKE "A%rent%"', 250 | ('A',) 251 | ) 252 | 253 | def test_like_middle_endswith(self): 254 | self.evaluate( 255 | r'strMetaAttribute LIKE "%ren%A"', 256 | ('A',) 257 | ) 258 | 259 | def test_like_startswith_middle_endswith(self): 260 | self.evaluate( 261 | r'strMetaAttribute LIKE "A%ren%A"', 262 | ('A',) 263 | ) 264 | 265 | def test_ilike_middle(self): 266 | self.evaluate( 267 | 'strMetaAttribute ILIKE "%PaReNT%"', 268 | ('A', 'B') 269 | ) 270 | 271 | def test_not_like_beginswith(self): 272 | self.evaluate( 273 | 'strMetaAttribute NOT LIKE "B%"', 274 | ('A',) 275 | ) 276 | 277 | def test_not_ilike_beginswith(self): 278 | self.evaluate( 279 | 'strMetaAttribute NOT ILIKE "b%"', 280 | ('A',) 281 | ) 282 | 283 | def test_not_like_endswith(self): 284 | self.evaluate( 285 | r'strMetaAttribute NOT LIKE "%B"', 286 | ('A',) 287 | ) 288 | 289 | def test_not_ilike_endswith(self): 290 | self.evaluate( 291 | r'strMetaAttribute NOT ILIKE "%b"', 292 | ('A',) 293 | ) 294 | 295 | # (NOT) IN 296 | 297 | def test_string_in(self): 298 | self.evaluate( 299 | 'identifier IN ("A", \'B\')', 300 | ('A', 'B') 301 | ) 302 | 303 | def test_string_not_in(self): 304 | self.evaluate( 305 | 'identifier NOT IN ("B", \'C\')', 306 | ('A',) 307 | ) 308 | 309 | # (NOT) NULL 310 | 311 | def test_string_null(self): 312 | self.evaluate( 313 | 'intAttribute IS NULL', 314 | ('B',) 315 | ) 316 | 317 | def test_string_not_null(self): 318 | self.evaluate( 319 | 'intAttribute IS NOT NULL', 320 | ('A',) 321 | ) 322 | 323 | # temporal predicates 324 | 325 | def test_before(self): 326 | self.evaluate( 327 | 'datetimeAttribute BEFORE 2000-01-01T00:00:01Z', 328 | ('A',) 329 | ) 330 | 331 | def test_before_or_during_dt_dt(self): 332 | self.evaluate( 333 | 'datetimeAttribute BEFORE OR DURING ' 334 | '2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z', 335 | ('A',) 336 | ) 337 | 338 | def test_before_or_during_dt_td(self): 339 | self.evaluate( 340 | 'datetimeAttribute BEFORE OR DURING ' 341 | '2000-01-01T00:00:00Z / PT4S', 342 | ('A',) 343 | ) 344 | 345 | def test_before_or_during_td_dt(self): 346 | self.evaluate( 347 | 'datetimeAttribute BEFORE OR DURING ' 348 | 'PT4S / 2000-01-01T00:00:03Z', 349 | ('A',) 350 | ) 351 | 352 | def test_during_td_dt(self): 353 | self.evaluate( 354 | 'datetimeAttribute BEFORE OR DURING ' 355 | 'PT4S / 2000-01-01T00:00:03Z', 356 | ('A',) 357 | ) 358 | 359 | # TODO: test DURING OR AFTER / AFTER 360 | 361 | # spatial predicates 362 | 363 | def test_intersects_point(self): 364 | self.evaluate( 365 | 'INTERSECTS(geometry, POINT(1 1.0))', 366 | ('A',) 367 | ) 368 | 369 | def test_intersects_mulitipoint_1(self): 370 | self.evaluate( 371 | 'INTERSECTS(geometry, MULTIPOINT(0 0, 1 1))', 372 | ('A',) 373 | ) 374 | 375 | def test_intersects_mulitipoint_2(self): 376 | self.evaluate( 377 | 'INTERSECTS(geometry, MULTIPOINT((0 0), (1 1)))', 378 | ('A',) 379 | ) 380 | 381 | def test_intersects_linestring(self): 382 | self.evaluate( 383 | 'INTERSECTS(geometry, LINESTRING(0 0, 1 1))', 384 | ('A',) 385 | ) 386 | 387 | def test_intersects_multilinestring(self): 388 | self.evaluate( 389 | 'INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))', 390 | ('A',) 391 | ) 392 | 393 | def test_intersects_polygon(self): 394 | self.evaluate( 395 | 'INTERSECTS(geometry, ' 396 | 'POLYGON((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 2 1, 2 2, 1 2, 1 1)))', 397 | ('A',) 398 | ) 399 | 400 | def test_intersects_multipolygon(self): 401 | self.evaluate( 402 | 'INTERSECTS(geometry, ' 403 | 'MULTIPOLYGON(((0 0, 3 0, 3 3, 0 3, 0 0), ' 404 | '(1 1, 2 1, 2 2, 1 2, 1 1))))', 405 | ('A',) 406 | ) 407 | 408 | def test_intersects_envelope(self): 409 | self.evaluate( 410 | 'INTERSECTS(geometry, ENVELOPE(0 0 1.0 1.0))', 411 | ('A',) 412 | ) 413 | 414 | def test_dwithin(self): 415 | self.evaluate( 416 | 'DWITHIN(geometry, POINT(0 0), 10, meters)', 417 | ('A',) 418 | ) 419 | 420 | def test_beyond(self): 421 | self.evaluate( 422 | 'BEYOND(geometry, POINT(0 0), 10, meters)', 423 | ('B',) 424 | ) 425 | 426 | def test_bbox(self): 427 | self.evaluate( 428 | 'BBOX(geometry, 0, 0, 1, 1, "EPSG:4326")', 429 | ('A',) 430 | ) 431 | 432 | # TODO: other relation methods 433 | 434 | # arithmethic expressions 435 | 436 | def test_arith_simple_plus(self): 437 | self.evaluate( 438 | 'intMetaAttribute = 10 + 10', 439 | ('A',) 440 | ) 441 | 442 | def test_arith_field_plus_1(self): 443 | self.evaluate( 444 | 'intMetaAttribute = floatMetaAttribute + 10', 445 | ('A', 'B') 446 | ) 447 | 448 | def test_arith_field_plus_2(self): 449 | self.evaluate( 450 | 'intMetaAttribute = 10 + floatMetaAttribute', 451 | ('A', 'B') 452 | ) 453 | 454 | def test_arith_field_plus_field(self): 455 | self.evaluate( 456 | 'intMetaAttribute = ' 457 | 'floatMetaAttribute + intAttribute', 458 | ('A',) 459 | ) 460 | 461 | def test_arith_field_plus_mul_1(self): 462 | self.evaluate( 463 | 'intMetaAttribute = intAttribute * 1.5 + 5', 464 | ('A',) 465 | ) 466 | 467 | def test_arith_field_plus_mul_2(self): 468 | self.evaluate( 469 | 'intMetaAttribute = 5 + intAttribute * 1.5', 470 | ('A',) 471 | ) 472 | -------------------------------------------------------------------------------- /tests/django_test/testapp/views.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.shortcuts import render 29 | 30 | # Create your views here. 31 | -------------------------------------------------------------------------------- /tests/sqlalchemy_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/tests/sqlalchemy_test/__init__.py -------------------------------------------------------------------------------- /tests/sqlalchemy_test/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pycql.integrations.sqlalchemy.parser import parse 4 | from pycql.integrations.sqlalchemy.evaluate import to_filter 5 | 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker 8 | from sqlalchemy.event import listen 9 | from sqlalchemy.sql import select, func 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey 12 | from geoalchemy2 import Geometry 13 | 14 | import dateparser 15 | 16 | 17 | def load_spatialite(dbapi_conn, connection_record): 18 | dbapi_conn.enable_load_extension(True) 19 | dbapi_conn.load_extension("/usr/lib/x86_64-linux-gnu/mod_spatialite.so") 20 | 21 | 22 | engine = create_engine("sqlite://", echo=True) 23 | listen(engine, "connect", load_spatialite) 24 | 25 | Base = declarative_base() 26 | 27 | 28 | class Record(Base): 29 | __tablename__ = "record" 30 | identifier = Column(String, primary_key=True) 31 | geometry = Column( 32 | Geometry( 33 | geometry_type="MULTIPOLYGON", 34 | srid=4326, 35 | spatial_index=False, 36 | management=True, 37 | ) 38 | ) 39 | float_attribute = Column(Float) 40 | int_attribute = Column(Integer) 41 | str_attribute = Column(String) 42 | datetime_attribute = Column(DateTime) 43 | choice_attribute = Column(Integer) 44 | 45 | 46 | class RecordMeta(Base): 47 | __tablename__ = "record_meta" 48 | identifier = Column(Integer, primary_key=True) 49 | record = Column(String, ForeignKey("record.identifier")) 50 | float_meta_attribute = Column(Float) 51 | int_meta_attribute = Column(Integer) 52 | str_meta_attribute = Column(String) 53 | datetime_meta_attribute = Column(DateTime) 54 | choice_meta_attribute = Column(Integer) 55 | 56 | 57 | FIELD_MAPPING = { 58 | "identifier": Record.identifier, 59 | "geometry": Record.geometry, 60 | "floatAttribute": Record.float_attribute, 61 | "intAttribute": Record.int_attribute, 62 | "strAttribute": Record.str_attribute, 63 | "datetimeAttribute": Record.datetime_attribute, 64 | "choiceAttribute": Record.choice_attribute, 65 | # meta fields 66 | "floatMetaAttribute": RecordMeta.float_meta_attribute, 67 | "intMetaAttribute": RecordMeta.int_meta_attribute, 68 | "strMetaAttribute": RecordMeta.str_meta_attribute, 69 | "datetimeMetaAttribute": RecordMeta.datetime_meta_attribute, 70 | "choiceMetaAttribute": RecordMeta.choice_meta_attribute, 71 | } 72 | 73 | 74 | class CQLTestCase(unittest.TestCase): 75 | @classmethod 76 | def setUpClass(self): 77 | self.conn = engine.connect() 78 | self.conn.execute(select([func.InitSpatialMetaData()])) 79 | 80 | Session = sessionmaker(bind=engine) 81 | self.session = Session() 82 | 83 | Base.metadata.create_all(engine) 84 | 85 | record = Record( 86 | identifier="A", 87 | geometry="SRID=4326;MULTIPOLYGON(((0 0, 0 5, 5 5,5 0,0 0)))", 88 | float_attribute=0.0, 89 | int_attribute=10, 90 | str_attribute="AAA", 91 | datetime_attribute=dateparser.parse("2000-01-01T00:00:00Z"), 92 | choice_attribute=1, 93 | ) 94 | self.session.add(record) 95 | 96 | record_meta = RecordMeta( 97 | float_meta_attribute=10.0, 98 | int_meta_attribute=20, 99 | str_meta_attribute="AparentA", 100 | datetime_meta_attribute=dateparser.parse("2000-01-01T00:00:05Z"), 101 | choice_meta_attribute=1, 102 | record=record.identifier, 103 | ) 104 | self.session.add(record_meta) 105 | 106 | record = Record( 107 | identifier="B", 108 | geometry="SRID=4326;MULTIPOLYGON(((5 5, 5 10, 10 10,10 5,5 5)))", 109 | float_attribute=30.0, 110 | int_attribute=None, 111 | str_attribute="BBB", 112 | datetime_attribute=dateparser.parse("2000-01-01T00:00:10Z"), 113 | choice_attribute=1, 114 | ) 115 | self.session.add(record) 116 | 117 | record_meta = RecordMeta( 118 | float_meta_attribute=20.0, 119 | int_meta_attribute=30, 120 | str_meta_attribute="BparentB", 121 | datetime_meta_attribute=dateparser.parse("2000-01-01T00:00:05Z"), 122 | choice_meta_attribute=1, 123 | record=record.identifier, 124 | ) 125 | self.session.add(record_meta) 126 | 127 | @classmethod 128 | def tearDownClass(self): 129 | self.conn.close() 130 | 131 | def evaluate(self, cql_expr, expected_ids): 132 | ast = parse(cql_expr) 133 | print(ast) 134 | filters = to_filter(ast, FIELD_MAPPING) 135 | 136 | q = self.session.query(Record).join(RecordMeta).filter(filters) 137 | print(str(q)) 138 | results = [row.identifier for row in q] 139 | 140 | self.assertEqual( 141 | expected_ids, type(expected_ids)(results), 142 | ) 143 | 144 | # common comparisons 145 | 146 | def test_id_eq(self): 147 | self.evaluate('identifier = "A"', ("A",)) 148 | 149 | def test_id_ne(self): 150 | self.evaluate('identifier <> "B"', ("A",)) 151 | 152 | def test_float_lt(self): 153 | self.evaluate("floatAttribute < 30", ("A",)) 154 | 155 | def test_float_le(self): 156 | self.evaluate("floatAttribute <= 20", ("A",)) 157 | 158 | def test_float_gt(self): 159 | self.evaluate("floatAttribute > 20", ("B",)) 160 | 161 | def test_float_ge(self): 162 | self.evaluate("floatAttribute >= 30", ("B",)) 163 | 164 | def test_float_between(self): 165 | self.evaluate("floatAttribute BETWEEN -1 AND 1", ("A",)) 166 | 167 | # test different field types 168 | 169 | def test_common_value_eq(self): 170 | self.evaluate('strAttribute = "AAA"', ("A",)) 171 | 172 | def test_common_value_in(self): 173 | self.evaluate('strAttribute IN ("AAA", "XXX")', ("A",)) 174 | 175 | def test_common_value_like(self): 176 | self.evaluate('strAttribute LIKE "AA%"', ("A",)) 177 | 178 | def test_common_value_like_middle(self): 179 | self.evaluate(r'strAttribute LIKE "A%A"', ("A",)) 180 | 181 | def test_like_beginswith(self): 182 | self.evaluate('strMetaAttribute LIKE "A%"', ("A",)) 183 | 184 | def test_ilike_beginswith(self): 185 | self.evaluate('strMetaAttribute ILIKE "a%"', ("A",)) 186 | 187 | def test_like_endswith(self): 188 | self.evaluate(r'strMetaAttribute LIKE "%A"', ("A",)) 189 | 190 | def test_ilike_endswith(self): 191 | self.evaluate(r'strMetaAttribute ILIKE "%a"', ("A",)) 192 | 193 | def test_like_middle(self): 194 | self.evaluate(r'strMetaAttribute LIKE "%parent%"', ("A", "B")) 195 | 196 | def test_like_startswith_middle(self): 197 | self.evaluate(r'strMetaAttribute LIKE "A%rent%"', ("A",)) 198 | 199 | def test_like_middle_endswith(self): 200 | self.evaluate(r'strMetaAttribute LIKE "%ren%A"', ("A",)) 201 | 202 | def test_like_startswith_middle_endswith(self): 203 | self.evaluate(r'strMetaAttribute LIKE "A%ren%A"', ("A",)) 204 | 205 | def test_ilike_middle(self): 206 | self.evaluate('strMetaAttribute ILIKE "%PaReNT%"', ("A", "B")) 207 | 208 | def test_not_like_beginswith(self): 209 | self.evaluate('strMetaAttribute NOT LIKE "B%"', ("A",)) 210 | 211 | def test_not_ilike_beginswith(self): 212 | self.evaluate('strMetaAttribute NOT ILIKE "b%"', ("A",)) 213 | 214 | def test_not_like_endswith(self): 215 | self.evaluate(r'strMetaAttribute NOT LIKE "%B"', ("A",)) 216 | 217 | def test_not_ilike_endswith(self): 218 | self.evaluate(r'strMetaAttribute NOT ILIKE "%b"', ("A",)) 219 | 220 | # (NOT) IN 221 | 222 | def test_string_in(self): 223 | self.evaluate("identifier IN (\"A\", 'B')", ("A", "B")) 224 | 225 | def test_string_not_in(self): 226 | self.evaluate("identifier NOT IN (\"B\", 'C')", ("A",)) 227 | 228 | # (NOT) NULL 229 | 230 | def test_string_null(self): 231 | self.evaluate("intAttribute IS NULL", ("B",)) 232 | 233 | def test_string_not_null(self): 234 | self.evaluate("intAttribute IS NOT NULL", ("A",)) 235 | 236 | # temporal predicates 237 | 238 | def test_before(self): 239 | self.evaluate("datetimeAttribute BEFORE 2000-01-01T00:00:01Z", ("A",)) 240 | 241 | def test_before_or_during_dt_dt(self): 242 | self.evaluate( 243 | "datetimeAttribute BEFORE OR DURING " 244 | "2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z", 245 | ("A",), 246 | ) 247 | 248 | def test_before_or_during_dt_td(self): 249 | self.evaluate( 250 | "datetimeAttribute BEFORE OR DURING " 251 | "2000-01-01T00:00:00Z / PT4S", 252 | ("A",), 253 | ) 254 | 255 | def test_before_or_during_td_dt(self): 256 | self.evaluate( 257 | "datetimeAttribute BEFORE OR DURING " 258 | "PT4S / 2000-01-01T00:00:03Z", 259 | ("A",), 260 | ) 261 | 262 | def test_during_td_dt(self): 263 | self.evaluate( 264 | "datetimeAttribute BEFORE OR DURING " 265 | "PT4S / 2000-01-01T00:00:03Z", 266 | ("A",), 267 | ) 268 | 269 | # spatial predicates 270 | 271 | def test_intersects_point(self): 272 | self.evaluate("INTERSECTS(geometry, POINT(1 1.0))", ("A",)) 273 | 274 | def test_intersects_mulitipoint_1(self): 275 | self.evaluate("INTERSECTS(geometry, MULTIPOINT(0 0, 1 1))", ("A",)) 276 | 277 | def test_intersects_mulitipoint_2(self): 278 | self.evaluate("INTERSECTS(geometry, MULTIPOINT((0 0), (1 1)))", ("A",)) 279 | 280 | def test_intersects_linestring(self): 281 | self.evaluate("INTERSECTS(geometry, LINESTRING(0 0, 1 1))", ("A",)) 282 | 283 | def test_intersects_multilinestring(self): 284 | self.evaluate( 285 | "INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))", 286 | ("A",), 287 | ) 288 | 289 | def test_intersects_polygon(self): 290 | self.evaluate( 291 | "INTERSECTS(geometry, " 292 | "POLYGON((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 2 1, 2 2, 1 2, 1 1)))", 293 | ("A",), 294 | ) 295 | 296 | def test_intersects_multipolygon(self): 297 | self.evaluate( 298 | "INTERSECTS(geometry, " 299 | "MULTIPOLYGON(((0 0, 3 0, 3 3, 0 3, 0 0), " 300 | "(1 1, 2 1, 2 2, 1 2, 1 1))))", 301 | ("A",), 302 | ) 303 | 304 | def test_intersects_envelope(self): 305 | self.evaluate("INTERSECTS(geometry, ENVELOPE(0 0 1.0 1.0))", ("A",)) 306 | 307 | # Commented out as not supported in spatialite for testing 308 | # def test_dwithin(self): 309 | # self.evaluate("DWITHIN(geometry, POINT(0 0), 10, meters)", ("A",)) 310 | 311 | # def test_beyond(self): 312 | # self.evaluate("BEYOND(geometry, POINT(0 0), 10, meters)", ("B",)) 313 | 314 | def test_bbox(self): 315 | self.evaluate('BBOX(geometry, 0, 0, 1, 1, "EPSG:4326")', ("A",)) 316 | 317 | # arithmethic expressions 318 | 319 | def test_arith_simple_plus(self): 320 | self.evaluate("intMetaAttribute = 10 + 10", ("A",)) 321 | 322 | def test_arith_field_plus_1(self): 323 | self.evaluate("intMetaAttribute = floatMetaAttribute + 10", ("A", "B")) 324 | 325 | def test_arith_field_plus_2(self): 326 | self.evaluate("intMetaAttribute = 10 + floatMetaAttribute", ("A", "B")) 327 | 328 | def test_arith_field_plus_field(self): 329 | self.evaluate( 330 | "intMetaAttribute = " "floatMetaAttribute + intAttribute", ("A",) 331 | ) 332 | 333 | def test_arith_field_plus_mul_1(self): 334 | self.evaluate("intMetaAttribute = intAttribute * 1.5 + 5", ("A",)) 335 | 336 | def test_arith_field_plus_mul_2(self): 337 | self.evaluate("intMetaAttribute = 5 + intAttribute * 1.5", ("A",)) 338 | -------------------------------------------------------------------------------- /tests/test_lexer.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pycql/b41a395752bc83684bb1d96006df4d5da4d7190a/tests/test_lexer.py -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pycql 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from pycql import parse, get_repr 29 | from pycql.ast import * 30 | 31 | 32 | def test_attribute_eq_literal(): 33 | ast = parse('attr = "A"') 34 | assert ast == ComparisonPredicateNode( 35 | AttributeExpression('attr'), 36 | LiteralExpression('A'), 37 | '=' 38 | ) 39 | 40 | def test_attribute_lt_literal(): 41 | ast = parse('attr < 5') 42 | assert ast == ComparisonPredicateNode( 43 | AttributeExpression('attr'), 44 | LiteralExpression(5.0), 45 | '<' 46 | ) 47 | 48 | def test_attribute_lte_literal(): 49 | ast = parse('attr <= 5') 50 | assert ast == ComparisonPredicateNode( 51 | AttributeExpression('attr'), 52 | LiteralExpression(5.0), 53 | '<=' 54 | ) 55 | 56 | def test_attribute_gt_literal(): 57 | ast = parse('attr > 5') 58 | assert ast == ComparisonPredicateNode( 59 | AttributeExpression('attr'), 60 | LiteralExpression(5.0), 61 | '>' 62 | ) 63 | 64 | def test_attribute_gte_literal(): 65 | ast = parse('attr >= 5') 66 | assert ast == ComparisonPredicateNode( 67 | AttributeExpression('attr'), 68 | LiteralExpression(5.0), 69 | '>=' 70 | ) 71 | 72 | def test_attribute_ne_literal(): 73 | ast = parse('attr <> 5') 74 | assert ast == ComparisonPredicateNode( 75 | AttributeExpression('attr'), 76 | LiteralExpression(5), 77 | '<>' 78 | ) 79 | 80 | def test_attribute_between(): 81 | ast = parse('attr BETWEEN 2 AND 5') 82 | assert ast == BetweenPredicateNode( 83 | AttributeExpression('attr'), 84 | LiteralExpression(2), 85 | LiteralExpression(5), 86 | False, 87 | ) 88 | 89 | def test_attribute_not_between(): 90 | ast = parse('attr NOT BETWEEN 2 AND 5') 91 | assert ast == BetweenPredicateNode( 92 | AttributeExpression('attr'), 93 | LiteralExpression(2), 94 | LiteralExpression(5), 95 | True, 96 | ) 97 | 98 | def test_string_like(): 99 | ast = parse('attr LIKE "some%"') 100 | assert ast == LikePredicateNode( 101 | AttributeExpression('attr'), 102 | LiteralExpression('some%'), 103 | True, 104 | False, 105 | ) 106 | 107 | def test_string_ilike(): 108 | ast = parse('attr ILIKE "some%"') 109 | assert ast == LikePredicateNode( 110 | AttributeExpression('attr'), 111 | LiteralExpression('some%'), 112 | False, 113 | False, 114 | ) 115 | 116 | def test_string_not_like(): 117 | ast = parse('attr NOT LIKE "some%"') 118 | assert ast == LikePredicateNode( 119 | AttributeExpression('attr'), 120 | LiteralExpression('some%'), 121 | True, 122 | True, 123 | ) 124 | 125 | def test_string_not_ilike(): 126 | ast = parse('attr NOT ILIKE "some%"') 127 | assert ast == LikePredicateNode( 128 | AttributeExpression('attr'), 129 | LiteralExpression('some%'), 130 | False, 131 | True, 132 | ) 133 | 134 | def test_attribute_in_list(): 135 | ast = parse('attr IN (1, 2, 3, 4)') 136 | assert ast == InPredicateNode( 137 | AttributeExpression('attr'), [ 138 | LiteralExpression(1), 139 | LiteralExpression(2), 140 | LiteralExpression(3), 141 | LiteralExpression(4), 142 | ], 143 | False 144 | ) 145 | 146 | def test_attribute_not_in_list(): 147 | ast = parse('attr NOT IN ("A", "B", \'C\', \'D\')') 148 | assert ast == InPredicateNode( 149 | AttributeExpression('attr'), [ 150 | LiteralExpression("A"), 151 | LiteralExpression("B"), 152 | LiteralExpression("C"), 153 | LiteralExpression("D"), 154 | ], 155 | True 156 | ) 157 | 158 | def test_attribute_is_null(): 159 | ast = parse('attr IS NULL') 160 | assert ast == NullPredicateNode( 161 | AttributeExpression('attr'), False 162 | ) 163 | 164 | def test_attribute_is_not_null(): 165 | ast = parse('attr IS NOT NULL') 166 | assert ast == NullPredicateNode( 167 | AttributeExpression('attr'), True 168 | ) 169 | 170 | # Temporal predicate 171 | 172 | # def test_attribute_before(): 173 | # ast = parse('attr BEFORE 2000-01-01T00:00:01Z') 174 | # assert ast == TemporalPredicateNode( 175 | # AttributeExpression('attr'), 176 | # None, 177 | # '', 178 | # ) 179 | 180 | # def test_attribute_before_or_during_dt_dt(): 181 | # ast = parse('attr BEFORE OR DURING 2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z') 182 | # assert ast == TemporalPredicateNode( 183 | # AttributeExpression('attr'), 184 | # None, 185 | # '', 186 | # ) 187 | 188 | # def test_attribute_before_or_during_dt_dr(): 189 | # ast = parse('attr BEFORE OR DURING 2000-01-01T00:00:00Z / PT4S') 190 | # assert ast == TemporalPredicateNode( 191 | # AttributeExpression('attr'), 192 | # None, 193 | # '', 194 | # ) 195 | 196 | # def test_attribute_before_or_during_dr_dt(): 197 | # ast = parse('attr BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z') 198 | # assert ast == TemporalPredicateNode( 199 | # AttributeExpression('attr'), 200 | # None, 201 | # '', 202 | # ) 203 | 204 | 205 | 206 | # Spatial predicate 207 | 208 | # BBox prediacte 209 | 210 | def test_bbox_simple(): 211 | ast = parse('BBOX(geometry, 1, 2, 3, 4)') 212 | assert ast == BBoxPredicateNode( 213 | AttributeExpression('geometry'), 214 | LiteralExpression(1), 215 | LiteralExpression(2), 216 | LiteralExpression(3), 217 | LiteralExpression(4), 218 | ) 219 | 220 | def test_bbox_crs(): 221 | ast = parse('BBOX(geometry, 1, 2, 3, 4, "EPSG:3875")') 222 | assert ast == BBoxPredicateNode( 223 | AttributeExpression('geometry'), 224 | LiteralExpression(1), 225 | LiteralExpression(2), 226 | LiteralExpression(3), 227 | LiteralExpression(4), 228 | 'EPSG:3875', 229 | ) 230 | 231 | def test_attribute_arithmetic_add(): 232 | ast = parse('attr = 5 + 2') 233 | assert ast == ComparisonPredicateNode( 234 | AttributeExpression('attr'), 235 | ArithmeticExpressionNode( 236 | LiteralExpression(5), 237 | LiteralExpression(2), 238 | '+', 239 | ), 240 | '=', 241 | ) 242 | 243 | def test_attribute_arithmetic_sub(): 244 | ast = parse('attr = 5 - 2') 245 | assert ast == ComparisonPredicateNode( 246 | AttributeExpression('attr'), 247 | ArithmeticExpressionNode( 248 | LiteralExpression(5), 249 | LiteralExpression(2), 250 | '-', 251 | ), 252 | '=', 253 | ) 254 | 255 | def test_attribute_arithmetic_mul(): 256 | ast = parse('attr = 5 * 2') 257 | assert ast == ComparisonPredicateNode( 258 | AttributeExpression('attr'), 259 | ArithmeticExpressionNode( 260 | LiteralExpression(5), 261 | LiteralExpression(2), 262 | '*', 263 | ), 264 | '=', 265 | ) 266 | 267 | def test_attribute_arithmetic_div(): 268 | ast = parse('attr = 5 / 2') 269 | assert ast == ComparisonPredicateNode( 270 | AttributeExpression('attr'), 271 | ArithmeticExpressionNode( 272 | LiteralExpression(5), 273 | LiteralExpression(2), 274 | '/', 275 | ), 276 | '=', 277 | ) 278 | 279 | 280 | def test_attribute_arithmetic_add_mul(): 281 | ast = parse('attr = 3 + 5 * 2') 282 | assert ast == ComparisonPredicateNode( 283 | AttributeExpression('attr'), 284 | ArithmeticExpressionNode( 285 | LiteralExpression(3), 286 | ArithmeticExpressionNode( 287 | LiteralExpression(5), 288 | LiteralExpression(2), 289 | '*', 290 | ), 291 | '+', 292 | ), 293 | '=', 294 | ) 295 | 296 | def test_attribute_arithmetic_div_sub(): 297 | ast = parse('attr = 3 / 5 - 2') 298 | assert ast == ComparisonPredicateNode( 299 | AttributeExpression('attr'), 300 | ArithmeticExpressionNode( 301 | ArithmeticExpressionNode( 302 | LiteralExpression(3), 303 | LiteralExpression(5), 304 | '/', 305 | ), 306 | LiteralExpression(2), 307 | '-', 308 | ), 309 | '=', 310 | ) 311 | 312 | def test_attribute_arithmetic_div_sub_bracketted(): 313 | ast = parse('attr = 3 / (5 - 2)') 314 | assert ast == ComparisonPredicateNode( 315 | AttributeExpression('attr'), 316 | ArithmeticExpressionNode( 317 | LiteralExpression(3), 318 | ArithmeticExpressionNode( 319 | LiteralExpression(5), 320 | LiteralExpression(2), 321 | '-', 322 | ), 323 | '/', 324 | ), 325 | '=', 326 | ) 327 | --------------------------------------------------------------------------------