├── .babelrc ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── assets ├── 15-five.svg ├── police1.svg ├── redhat.svg └── teamplify.svg ├── completion-widget └── index.js ├── djangoql ├── __init__.py ├── admin.py ├── ast.py ├── compat.py ├── exceptions.py ├── lexer.py ├── parser.py ├── parsetab.py ├── queryset.py ├── schema.py ├── serializers.py ├── static │ └── djangoql │ │ ├── css │ │ ├── completion.css │ │ ├── completion.css.map │ │ ├── completion_admin.css │ │ └── syntax_help.css │ │ ├── img │ │ ├── completion_example.png │ │ └── completion_example_scaled.png │ │ └── js │ │ ├── completion.js │ │ ├── completion.js.map │ │ ├── completion_admin.js │ │ ├── completion_admin_toggle.js │ │ └── completion_admin_toggle_off.js ├── templates │ └── djangoql │ │ ├── error_message.html │ │ └── syntax_help.html └── views.py ├── package.json ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── test_project ├── core │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_book_genre.py │ │ ├── 0003_book_similar_books.py │ │ ├── 0004_book_similar_books_related_name.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── completion_demo.html │ ├── tests │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_ast.py │ │ ├── test_lexer.py │ │ ├── test_parser.py │ │ ├── test_queryset.py │ │ └── test_schema.py │ └── views.py ├── manage.py └── test_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | workflow_dispatch: 8 | jobs: 9 | notify-build-start: 10 | # Secrets are not available for forks for security reasons, so pull 11 | # request checks will fail when trying to send the Slack notification. 12 | # Unfortunately, there's no way to explicitly check that a secret is 13 | # available, so we check for event_name instead: 14 | # https://github.com/actions/runner/issues/520 15 | if: ${{ github.event_name == 'push' }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | # Send build notifications to Slack 19 | - uses: ivelum/github-action-slack-notify-build@v1.7.2 20 | id: slack 21 | with: 22 | channel_id: C0PT3267R 23 | status: STARTED 24 | color: '#ee9b00' 25 | env: 26 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 27 | outputs: 28 | status_message_id: ${{ steps.slack.outputs.message_id }} 29 | 30 | lint: 31 | needs: [notify-build-start] 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-python@v5 36 | with: 37 | python-version: 3.9 38 | - name: get pip cache dir 39 | id: pip-cache 40 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 41 | - name: cache pip 42 | uses: actions/cache@v4 43 | with: 44 | path: ${{ steps.pip-cache.outputs.dir }} 45 | key: pip|lint 46 | - run: pip install -r requirements-dev.txt 47 | - run: flake8 48 | - run: isort --diff -c . 49 | 50 | # Send notification on build failure 51 | - name: Notify slack fail 52 | uses: ivelum/github-action-slack-notify-build@v1.7.2 53 | if: failure() 54 | env: 55 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 56 | with: 57 | message_id: ${{ needs.notify-build-start.outputs.status_message_id }} 58 | channel_id: C0PT3267R 59 | status: FAILED 60 | color: '#d7263d' 61 | 62 | tests: 63 | needs: [notify-build-start, lint] 64 | runs-on: ubuntu-${{ matrix.ubuntu }} 65 | strategy: 66 | fail-fast: false 67 | matrix: 68 | include: 69 | - {django: '1.8.*', python: '2.7', ubuntu: '20.04'} 70 | 71 | - {django: '1.9.*', python: '2.7', ubuntu: '20.04'} 72 | 73 | - {django: '1.10.*', python: '2.7', ubuntu: '20.04'} 74 | 75 | - {django: '1.11.*', python: '2.7', ubuntu: '20.04'} 76 | - {django: '1.11.*', python: '3.6', ubuntu: '20.04'} 77 | - {django: '1.11.*', python: '3.7', ubuntu: '22.04'} 78 | 79 | - {django: '2.0.*', python: '3.6', ubuntu: '20.04'} 80 | - {django: '2.0.*', python: '3.7', ubuntu: '22.04'} 81 | 82 | - {django: '2.1.*', python: '3.6', ubuntu: '20.04'} 83 | - {django: '2.1.*', python: '3.7', ubuntu: '22.04'} 84 | 85 | - {django: '2.2.*', python: '3.6', ubuntu: '20.04'} 86 | - {django: '2.2.*', python: '3.7', ubuntu: '22.04'} 87 | - {django: '2.2.*', python: '3.8', ubuntu: '24.04'} 88 | - {django: '2.2.*', python: '3.9', ubuntu: '24.04'} 89 | 90 | - {django: '3.0.*', python: '3.6', ubuntu: '20.04'} 91 | - {django: '3.0.*', python: '3.7', ubuntu: '22.04'} 92 | - {django: '3.0.*', python: '3.8', ubuntu: '24.04'} 93 | - {django: '3.0.*', python: '3.9', ubuntu: '24.04'} 94 | 95 | - {django: '3.1.*', python: '3.6', ubuntu: '20.04'} 96 | - {django: '3.1.*', python: '3.7', ubuntu: '22.04'} 97 | - {django: '3.1.*', python: '3.8', ubuntu: '24.04'} 98 | - {django: '3.1.*', python: '3.9', ubuntu: '24.04'} 99 | 100 | - {django: '3.2.*', python: '3.6', ubuntu: '20.04'} 101 | - {django: '3.2.*', python: '3.7', ubuntu: '22.04'} 102 | - {django: '3.2.*', python: '3.8', ubuntu: '24.04'} 103 | - {django: '3.2.*', python: '3.9', ubuntu: '24.04'} 104 | - {django: '3.2.*', python: '3.10', ubuntu: '24.04'} 105 | 106 | - {django: '4.0.*', python: '3.8', ubuntu: '24.04'} 107 | - {django: '4.0.*', python: '3.9', ubuntu: '24.04'} 108 | - {django: '4.0.*', python: '3.10', ubuntu: '24.04'} 109 | 110 | - {django: '4.1.*', python: '3.8', ubuntu: '24.04'} 111 | - {django: '4.1.*', python: '3.9', ubuntu: '24.04'} 112 | - {django: '4.1.*', python: '3.10', ubuntu: '24.04'} 113 | - {django: '4.1.*', python: '3.11', ubuntu: '24.04'} 114 | 115 | - {django: '4.2.*', python: '3.8', ubuntu: '24.04'} 116 | - {django: '4.2.*', python: '3.9', ubuntu: '24.04'} 117 | - {django: '4.2.*', python: '3.10', ubuntu: '24.04'} 118 | - {django: '4.2.*', python: '3.11', ubuntu: '24.04'} 119 | - {django: '4.2.*', python: '3.12', ubuntu: '24.04'} 120 | 121 | - {django: '5.0.*', python: '3.10', ubuntu: '24.04'} 122 | - {django: '5.0.*', python: '3.11', ubuntu: '24.04'} 123 | - {django: '5.0.*', python: '3.12', ubuntu: '24.04'} 124 | 125 | - {django: '5.1.*', python: '3.10', ubuntu: '24.04'} 126 | - {django: '5.1.*', python: '3.11', ubuntu: '24.04'} 127 | - {django: '5.1.*', python: '3.12', ubuntu: '24.04'} 128 | - {django: '5.1.*', python: '3.13', ubuntu: '24.04'} 129 | 130 | steps: 131 | - uses: actions/checkout@v4 132 | - name: Setup Python v. >2.7 133 | if: ${{ matrix.python != '2.7' }} 134 | uses: actions/setup-python@v5 135 | with: 136 | python-version: ${{ matrix.python }} 137 | - name: Setup Python v2.7 138 | if: ${{ matrix.python == '2.7' }} 139 | uses: MatteoH2O1999/setup-python@v2 140 | with: 141 | python-version: ${{ matrix.python }} 142 | allow-build: info 143 | cache-build: true 144 | - name: update pip 145 | run: | 146 | pip install -U wheel 147 | pip install -U setuptools 148 | python -m pip install -U pip 149 | - name: get pip cache dir 150 | id: pip-cache 151 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 152 | - name: cache pip 153 | uses: actions/cache@v4 154 | with: 155 | path: ${{ steps.pip-cache.outputs.dir }} 156 | key: pip|${{ matrix.python }}|${{ matrix.django }} 157 | - run: pip install PLY 158 | - run: pip install Django==${{ matrix.django }} 159 | - run: pip install -e . 160 | - run: python test_project/manage.py test core.tests 161 | 162 | # Send notification on build failure 163 | - name: Notify slack fail 164 | uses: ivelum/github-action-slack-notify-build@v1.7.2 165 | if: failure() 166 | env: 167 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 168 | with: 169 | message_id: ${{ needs.notify-build-start.outputs.status_message_id }} 170 | channel_id: C0PT3267R 171 | status: FAILED 172 | color: '#d7263d' 173 | 174 | notify-build-success: 175 | if: ${{ github.event_name == 'push' }} 176 | needs: [notify-build-start, tests] 177 | runs-on: ubuntu-latest 178 | steps: 179 | # Send notification on build success 180 | - name: Notify slack success 181 | uses: ivelum/github-action-slack-notify-build@v1.7.2 182 | env: 183 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 184 | with: 185 | message_id: ${{ needs.notify-build-start.outputs.status_message_id }} 186 | channel_id: C0PT3267R 187 | status: SUCCESS 188 | color: '#16db65' 189 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.py[co] 4 | 5 | node_modules 6 | test_project/db.sqlite3 7 | 8 | build 9 | dist 10 | djangoql.egg-info 11 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.18.1 2 | ------ 3 | 4 | * Fixed serialization for `options` element (`#112`_) 5 | 6 | .. _#112: https://github.com/ivelum/djangoql/pull/112 7 | 8 | 0.18.0 9 | ------ 10 | 11 | * Add support for multiple django admin sites (`#110`_) 12 | * Add support for Django 5.0 and Python 3.12 13 | 14 | .. _#110: https://github.com/ivelum/djangoql/pull/110 15 | 16 | 0.17.1 17 | ------ 18 | 19 | * Added the ``completion.js.map`` file into the distribution to fix 20 | compatibility with Django 4.0 collectstatic (thanks to @magdapoppins); 21 | 22 | 0.17.0 23 | ------ 24 | 25 | * Django 4.0 compatibility (thanks to @Lotfull); 26 | 27 | 0.16.0 28 | ------ 29 | 30 | * added support for new string-specific comparison operators: ``startswith``, 31 | ``not startswith``, ``endswith``, ``not endswith``; 32 | 33 | 0.15.4 34 | ------ 35 | 36 | * fixed a deprecation warning for Django 3.1 (thanks to @sainAk); 37 | 38 | 0.15.3 39 | ------ 40 | 41 | * fixed ``django-completion`` bug related to removed chained models from 42 | suggestions; 43 | * fixed ``django-completion`` bug related to fixed circular dependencies. 44 | 45 | Related pull requests: 46 | 47 | * `https://github.com/ivelum/djangoql-completion/pull/2 `_ 48 | * `https://github.com/ivelum/djangoql/pull/77 `_ 49 | 50 | 0.15.2 51 | ------ 52 | 53 | * fixed regression for Django < 2.1 (thanks to @derekenos for reporting the 54 | issue); 55 | 56 | 0.15.1 57 | ------ 58 | 59 | * fixed ``url()`` deprecation warnings for Django 3.1+ (thanks to @ecilveks); 60 | 61 | 0.15.0 62 | ------ 63 | 64 | * the completion JavaScript widget has been moved to 65 | `its own repo `_ and is now 66 | available as a standalone 67 | `package on npm `_. It 68 | still ships with the Python package, though, so if you don't need to embed 69 | the completion widget in your custom JavaScript application, no additional 70 | installation steps are required; 71 | * added support for GenericIPAddressField (thanks to @HannaShalamitskaya for 72 | reporting the issue); 73 | * the source code is now linted with flake8 and isort; 74 | 75 | 0.14.5 76 | ------ 77 | 78 | * added a help text to some operators; 79 | * fixed the background color in the dark mode (django 3.2+); 80 | 81 | 0.14.4 82 | ------ 83 | 84 | * add ``~`` operator for date/datetime fields; 85 | 86 | 0.14.3 87 | ------ 88 | 89 | * ``write_tables`` argument for PLY parser is now disabled by default. This 90 | change prevents an error that may arise if DjangoQL is installed into 91 | un-writeable location (#63, #53. Thanks to @sochotnicky for the PR); 92 | * fixed quotes handling in completion widget (#62, thanks to @nicolazilio for 93 | reporting this); 94 | 95 | 0.14.2 96 | ------ 97 | 98 | * add basic support for models.BinaryField (thanks to @Akay7); 99 | 100 | 0.14.1 101 | ------ 102 | 103 | * fixed inconsistency in search by fields with choices (#58, thanks to 104 | @pandichef for reporting this); 105 | * Officially compatible with Python 3.9 (no changes in the code, just added it 106 | to the test matrix); 107 | 108 | 0.14.0 109 | ------ 110 | 111 | * New feature: field suggestion options are now loaded asynchronously via 112 | Suggestions API; 113 | * **Breaking**: ``DjangoQLField.get_options()`` now accepts mandatory ``search`` 114 | parameter. If you've implemented custom suggestion options for your schema, 115 | please add handling of this parameter (you should only return results that 116 | match ``search`` criteria); 117 | * **Breaking**: when using in the admin together with the standard Django 118 | search, DjangoQL checkbox is now on by default. If you don't want this 119 | behavior, you can turn it off with ``djangoql_completion_enabled_by_default`` 120 | option. Thanks to @nicolazilio for the idea; 121 | * Deprecated: if you've used ``DjangoQLSchema.as_dict()`` somewhere in your 122 | code, please switch to new schema serializers instead (see in 123 | ``serializers.py``); 124 | * Improved field customization examples in the docs (#55, thanks to 125 | @joeydebreuk); 126 | * Added support for Django 3.1.x (#57, thanks to @jleclanche) 127 | 128 | 0.13.1 129 | ------ 130 | 131 | * Fixed compatibility with upcoming Django 3.0 (thanks to @vkrizan for the 132 | reminder); 133 | 134 | 0.13.0 135 | ------ 136 | 137 | * Added "DjangoQL syntax help" link to the error messages in Django admin 138 | (thanks to @AngellusMortis for the idea); 139 | 140 | 0.12.6 141 | ------ 142 | 143 | * Fixed: DateField and DateTimeField lookups no longer crash on comparison with 144 | None (thanks to @st8st8); 145 | * Officially compatible with Django 2.2 (no changes in the code, just added it 146 | to the test matrix); 147 | 148 | 0.12.5 149 | ------ 150 | 151 | * Added convenience method DjangoQLSearchMixin.djangoql_search_enabled() 152 | (thanks to @MilovanovM); 153 | 154 | 0.12.4 155 | ------ 156 | 157 | * DjangoQL syntax help page in admin now requires users to be logged-in (thanks 158 | to @OndrejIT); 159 | 160 | 0.12.3 161 | ------ 162 | 163 | * Fixed removal/override of related fields, when the referenced model is 164 | linked from more parent models on multiple levels (thanks to @vkrizan); 165 | 166 | 0.12.2 167 | ------ 168 | 169 | * fixed weird completion widget behavior for unknown field types (thanks to 170 | @vkrizan); 171 | 172 | 0.12.0 173 | ------ 174 | 175 | * completion widget now supports passing either CSS selector or HTMLElement 176 | instance (thanks to @vkrizan); 177 | 178 | 0.11.0 179 | ------ 180 | 181 | * completion widget converted to a constructable JS object to improve its 182 | compatibility with JS frameworks (thanks to @vkrizan); 183 | 184 | 0.10.3 185 | ------ 186 | 187 | * DjangoQL no longer depends on ContentType. Fixes use cases when the package 188 | is used without Django admin and ContentType is not used; 189 | 190 | 0.10.2 191 | ------ 192 | 193 | * Removed .DS_Store from the distribution (thanks to @vkrizan); 194 | 195 | 0.10.1 196 | ------ 197 | 198 | * Added Python 3.7 and Django 2.1 to the test matrix; 199 | * removed PYTHONDONTWRITEBYTECODE from the setup.py and added test_project to 200 | the distribution (thanks to @vkrizan); 201 | 202 | 0.10.0 203 | ------ 204 | 205 | * Introducing Search Modes in the admin: now users can switch between Advanced 206 | Search mode (DjangoQL) and a standard Django search that you define with 207 | ``search_fields`` in your ModelAdmin; 208 | 209 | 210 | 0.9.1 211 | ----- 212 | 213 | * Improved schema auto-generation. Now it avoids adding fields that may cause 214 | circular references, like ``author.book.author.book...``; 215 | 216 | 217 | 0.9.0 218 | ----- 219 | 220 | * Fixed compatibility with Django 2.0, added Django 2.0 to the test matrix; 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ivelum 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 djangoql/static *.css *.png *.gif *.js *.map 2 | recursive-include djangoql/templates *.html 3 | recursive-include test_project *.py *.html 4 | include README.rst LICENSE 5 | 6 | global-exclude .DS_Store __pycache__ 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | DjangoQL 2 | ======== 3 | 4 | .. image:: https://github.com/ivelum/djangoql/workflows/Tests/badge.svg 5 | :target: https://github.com/ivelum/djangoql/actions?query=workflow%3ATests 6 | 7 | Advanced search language for Django, with auto-completion. Supports logical 8 | operators, parenthesis, table joins, and works with any Django model. Tested on 9 | Python 2.7, 3.6–3.13, Django 1.8–5.1. The auto-completion feature has been 10 | tested in Chrome, Firefox, Safari, IE9+. 11 | 12 | See a video: `DjangoQL demo `_ 13 | 14 | .. image:: https://raw.githubusercontent.com/ivelum/djangoql/master/djangoql/static/djangoql/img/completion_example_scaled.png 15 | 16 | DjangoQL is used by: 17 | 18 | |logo1| |logo2| |logo3| |logo4| 19 | 20 | .. |logo1| image:: https://raw.githubusercontent.com/ivelum/djangoql/master/assets/redhat.svg 21 | :width: 22% 22 | :target: https://www.redhat.com 23 | 24 | .. |logo2| image:: https://raw.githubusercontent.com/ivelum/djangoql/master/assets/teamplify.svg 25 | :width: 22% 26 | :target: https://teamplify.com 27 | 28 | .. |logo3| image:: https://raw.githubusercontent.com/ivelum/djangoql/master/assets/police1.svg 29 | :width: 22% 30 | :target: https://www.police1.com 31 | 32 | .. |logo4| image:: https://raw.githubusercontent.com/ivelum/djangoql/master/assets/15-five.svg 33 | :width: 22% 34 | :target: https://www.15five.com 35 | 36 | Is your project using DjangoQL? Please submit a PR and let us know! 37 | 38 | Contents 39 | -------- 40 | 41 | * `Installation`_ 42 | * `Add it to your Django admin`_ 43 | * `Using DjangoQL with the standard Django admin search`_ 44 | * `Language reference`_ 45 | * `DjangoQL Schema`_ 46 | * `Custom search fields`_ 47 | * `Can I use it outside of Django admin?`_ 48 | * `Using completion widget outside of Django admin`_ 49 | 50 | Installation 51 | ------------ 52 | 53 | .. code:: shell 54 | 55 | $ pip install djangoql 56 | 57 | Add ``'djangoql'`` to ``INSTALLED_APPS`` in your ``settings.py``: 58 | 59 | .. code:: python 60 | 61 | INSTALLED_APPS = [ 62 | ... 63 | 'djangoql', 64 | ... 65 | ] 66 | 67 | 68 | Add it to your Django admin 69 | --------------------------- 70 | 71 | Adding ``DjangoQLSearchMixin`` to your model admin will replace the standard 72 | Django search functionality with DjangoQL search. Example: 73 | 74 | .. code:: python 75 | 76 | from django.contrib import admin 77 | 78 | from djangoql.admin import DjangoQLSearchMixin 79 | 80 | from .models import Book 81 | 82 | 83 | @admin.register(Book) 84 | class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin): 85 | pass 86 | 87 | 88 | Using DjangoQL with the standard Django admin search 89 | ---------------------------------------------------- 90 | 91 | DjangoQL will recognize if you have defined ``search_fields`` in your ModelAdmin 92 | class, and doing so will allow you to choose between an advanced search with 93 | DjangoQL and a standard Django search (as specified by search fields). Example: 94 | 95 | .. code:: python 96 | 97 | @admin.register(Book) 98 | class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin): 99 | search_fields = ('title', 'author__name') 100 | 101 | For the example above, a checkbox that controls search mode will appear near 102 | the search input. If the checkbox is on, then DjanqoQL search is used. There is 103 | also an option that controls if that checkbox is enabled by default - 104 | ``djangoql_completion_enabled_by_default`` (set to ``True`` by default): 105 | 106 | .. code:: python 107 | 108 | @admin.register(Book) 109 | class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin): 110 | search_fields = ('title', 'author__name') 111 | djangoql_completion_enabled_by_default = False 112 | 113 | If you don't want two search modes, simply remove ``search_fields`` from your 114 | ModelAdmin class. 115 | 116 | Language reference 117 | ------------------ 118 | 119 | DjangoQL is shipped with comprehensive Syntax Help, which can be found in Django 120 | admin (see the Syntax Help link in auto-completion popup). Here's a quick 121 | summary: 122 | 123 | DjangoQL's syntax resembles Python's, with some minor 124 | differences. Basically you just reference model fields as you would 125 | in Python code, then apply comparison and logical operators and 126 | parenthesis. DjangoQL is case-sensitive. 127 | 128 | - model fields: exactly as they are defined in Python code. Access 129 | nested properties via ``.``, for example ``author.last_name``; 130 | - strings must be double-quoted. Single quotes are not supported. 131 | To escape a double quote use ``\"``; 132 | - boolean and null values: ``True``, ``False``, ``None``. Please note 133 | that they can be combined only with equality operators, so you can 134 | write ``published = False or date_published = None``, but 135 | ``published > False`` will cause an error; 136 | - logical operators: ``and``, ``or``; 137 | - comparison operators: ``=``, ``!=``, ``<``, ``<=``, ``>``, ``>=`` 138 | - work as you expect; 139 | - string-specific comparison operators: ``startswith``, ``not startswith``, 140 | ``endswith``, ``not endswith`` - work as you expect. Test whether or not a 141 | string contains a substring: ``~`` and ``!~`` (translated into 142 | ``__icontains`` under the hood). 143 | Example: ``name endswith "peace" or author.last_name ~ "tolstoy"``; 144 | - date-specific comparison operators, compare by date part: ``~`` and ``!~``. 145 | Example: ``date_published ~ "2021-11"`` - find books published in Nov, 2021; 146 | - test a value vs. list: ``in``, ``not in``. Example: 147 | ``pk in (2, 3)``. 148 | 149 | 150 | DjangoQL Schema 151 | --------------- 152 | 153 | Schema defines limitations - what you can do with a DjangoQL query. 154 | If you don't specify any schema, DjangoQL will provide a default 155 | schema for you. This will walk recursively through all model fields and 156 | relations and include everything it finds in the schema, so 157 | users would be able to search through everything. Sometimes 158 | this is not what you want, either due to DB performance or security 159 | concerns. If you'd like to limit search models or fields, you should 160 | define a schema. Here's an example: 161 | 162 | .. code:: python 163 | 164 | class UserQLSchema(DjangoQLSchema): 165 | exclude = (Book,) 166 | suggest_options = { 167 | Group: ['name'], 168 | } 169 | 170 | def get_fields(self, model): 171 | if model == Group: 172 | return ['name'] 173 | return super(UserQLSchema, self).get_fields(model) 174 | 175 | 176 | @admin.register(User) 177 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 178 | djangoql_schema = UserQLSchema 179 | 180 | In the example above we created a schema that does 3 things: 181 | 182 | - excludes the Book model from search via ``exclude`` option. Instead of 183 | ``exclude`` you may also use ``include``, which limits a search to 184 | listed models only; 185 | - limits available search fields for Group model to only the ``name`` field 186 | , in the ``.get_fields()`` method; 187 | - enables completion options for Group names via ``suggest_options``. 188 | 189 | An important note about ``suggest_options``: it looks for the ``choices`` model 190 | field parameter first, and if it's not specified - it will synchronously pull 191 | all values for given model fields, so you should avoid large querysets there. 192 | If you'd like to define custom suggestion options, see below. 193 | 194 | Custom search fields 195 | -------------------- 196 | 197 | Deeper search customization can be achieved with custom search fields. Custom 198 | search fields can be used to search by annotations, define custom suggestion 199 | options, or define fully custom search logic. In ``djangoql.schema``, DjangoQL 200 | defines the following base field classes that you may 201 | subclass to define your own behavior: 202 | 203 | * ``IntField`` 204 | * ``FloatField`` 205 | * ``StrField`` 206 | * ``BoolField`` 207 | * ``DateField`` 208 | * ``DateTimeField`` 209 | * ``RelationField`` 210 | 211 | Here are examples for common use cases: 212 | 213 | **Search by queryset annotations:** 214 | 215 | .. code:: python 216 | 217 | from djangoql.schema import DjangoQLSchema, IntField 218 | 219 | 220 | class UserQLSchema(DjangoQLSchema): 221 | def get_fields(self, model): 222 | fields = super(UserQLSchema, self).get_fields(model) 223 | if model == User: 224 | fields += [IntField(name='groups_count')] 225 | return fields 226 | 227 | 228 | @admin.register(User) 229 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 230 | djangoql_schema = UserQLSchema 231 | 232 | def get_queryset(self, request): 233 | qs = super(CustomUserAdmin, self).get_queryset(request) 234 | return qs.annotate(groups_count=Count('groups')) 235 | 236 | Let's take a closer look at what's happening in the example above. First, we 237 | add ``groups_count`` annotation to the queryset that is used by Django admin 238 | in the ``CustomUserAdmin.get_queryset()`` method. It would contain the number 239 | of groups a user belongs to. As our queryset now pulls this column, we can 240 | filter by it. It just needs to be included in the schema. In 241 | ``UserQLSchema.get_fields()`` we define a custom integer search field for the 242 | ``User`` model. Its name should match the name of the column in our queryset. 243 | 244 | **Custom suggestion options** 245 | 246 | .. code:: python 247 | 248 | from djangoql.schema import DjangoQLSchema, StrField 249 | 250 | 251 | class GroupNameField(StrField): 252 | model = Group 253 | name = 'name' 254 | suggest_options = True 255 | 256 | def get_options(self, search): 257 | return super(GroupNameField, self)\ 258 | .get_options(search)\ 259 | .annotate(users_count=Count('user'))\ 260 | .order_by('-users_count') 261 | 262 | 263 | class UserQLSchema(DjangoQLSchema): 264 | def get_fields(self, model): 265 | if model == Group: 266 | return ['id', GroupNameField()] 267 | return super(UserQLSchema, self).get_fields(model) 268 | 269 | 270 | @admin.register(User) 271 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 272 | djangoql_schema = UserQLSchema 273 | 274 | In this example we've defined a custom GroupNameField that sorts suggestions 275 | for group names by popularity (no. of users in a group) instead of default 276 | alphabetical sorting. 277 | 278 | **Custom search lookup** 279 | 280 | DjangoQL base fields provide two basic methods that you can override to 281 | substitute either search column, search value, or both - 282 | ``.get_lookup_name()`` and ``.get_lookup_value(value)``: 283 | 284 | .. code:: python 285 | 286 | class UserDateJoinedYear(IntField): 287 | name = 'date_joined_year' 288 | 289 | def get_lookup_name(self): 290 | return 'date_joined__year' 291 | 292 | 293 | class UserQLSchema(DjangoQLSchema): 294 | def get_fields(self, model): 295 | fields = super(UserQLSchema, self).get_fields(model) 296 | if model == User: 297 | fields += [UserDateJoinedYear()] 298 | return fields 299 | 300 | 301 | @admin.register(User) 302 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 303 | djangoql_schema = UserQLSchema 304 | 305 | In this example we've defined the custom ``date_joined_year`` search field for 306 | users, and used the built-in Django ``__year`` filter option in 307 | ``.get_lookup_name()`` to filter by date year only. Similarly you can use 308 | ``.get_lookup_value(value)`` hook to modify a search value before it's used in 309 | the filter. 310 | 311 | **Fully custom search lookup** 312 | 313 | ``.get_lookup_name()`` and ``.get_lookup_value(value)`` hooks cover many 314 | simple use cases, but sometimes they're not enough and you want a fully custom 315 | search logic. In such cases you can override main ``.get_lookup()`` method of 316 | a field. Example below demonstrates User ``age`` search: 317 | 318 | .. code:: python 319 | 320 | from djangoql.schema import DjangoQLSchema, IntField 321 | 322 | 323 | class UserAgeField(IntField): 324 | """ 325 | Search by given number of full years 326 | """ 327 | model = User 328 | name = 'age' 329 | 330 | def get_lookup_name(self): 331 | """ 332 | We'll be doing comparisons vs. this model field 333 | """ 334 | return 'date_joined' 335 | 336 | def get_lookup(self, path, operator, value): 337 | """ 338 | The lookup should support with all operators compatible with IntField 339 | """ 340 | if operator == 'in': 341 | result = None 342 | for year in value: 343 | condition = self.get_lookup(path, '=', year) 344 | result = condition if result is None else result | condition 345 | return result 346 | elif operator == 'not in': 347 | result = None 348 | for year in value: 349 | condition = self.get_lookup(path, '!=', year) 350 | result = condition if result is None else result & condition 351 | return result 352 | 353 | value = self.get_lookup_value(value) 354 | search_field = '__'.join(path + [self.get_lookup_name()]) 355 | year_start = self.years_ago(value + 1) 356 | year_end = self.years_ago(value) 357 | if operator == '=': 358 | return ( 359 | Q(**{'%s__gt' % search_field: year_start}) & 360 | Q(**{'%s__lte' % search_field: year_end}) 361 | ) 362 | elif operator == '!=': 363 | return ( 364 | Q(**{'%s__lte' % search_field: year_start}) | 365 | Q(**{'%s__gt' % search_field: year_end}) 366 | ) 367 | elif operator == '>': 368 | return Q(**{'%s__lt' % search_field: year_start}) 369 | elif operator == '>=': 370 | return Q(**{'%s__lte' % search_field: year_end}) 371 | elif operator == '<': 372 | return Q(**{'%s__gt' % search_field: year_end}) 373 | elif operator == '<=': 374 | return Q(**{'%s__gte' % search_field: year_start}) 375 | 376 | def years_ago(self, n): 377 | timestamp = now() 378 | try: 379 | return timestamp.replace(year=timestamp.year - n) 380 | except ValueError: 381 | # February 29 382 | return timestamp.replace(month=2, day=28, year=timestamp.year - n) 383 | 384 | 385 | class UserQLSchema(DjangoQLSchema): 386 | def get_fields(self, model): 387 | fields = super(UserQLSchema, self).get_fields(model) 388 | if model == User: 389 | fields += [UserAgeField()] 390 | return fields 391 | 392 | 393 | @admin.register(User) 394 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 395 | djangoql_schema = UserQLSchema 396 | 397 | 398 | Can I use it outside of Django admin? 399 | ------------------------------------- 400 | 401 | Sure. You can add DjangoQL search functionality to any Django model using 402 | ``DjangoQLQuerySet``: 403 | 404 | .. code:: python 405 | 406 | from django.db import models 407 | 408 | from djangoql.queryset import DjangoQLQuerySet 409 | 410 | 411 | class Book(models.Model): 412 | name = models.CharField(max_length=255) 413 | author = models.ForeignKey('auth.User') 414 | 415 | objects = DjangoQLQuerySet.as_manager() 416 | 417 | With the example above you can perform a search like this: 418 | 419 | .. code:: python 420 | 421 | qs = Book.objects.djangoql( 422 | 'name ~ "war" and author.last_name = "Tolstoy"' 423 | ) 424 | 425 | It returns a normal queryset, so you can extend it and reuse if 426 | necessary. The following code works fine: 427 | 428 | .. code:: python 429 | 430 | print(qs.count()) 431 | 432 | Alternatively you can add DjangoQL search to any existing queryset, 433 | even if it's not an instance of DjangoQLQuerySet: 434 | 435 | .. code:: python 436 | 437 | from django.contrib.auth.models import User 438 | 439 | from djangoql.queryset import apply_search 440 | 441 | qs = User.objects.all() 442 | qs = apply_search(qs, 'groups = None') 443 | print(qs.exists()) 444 | 445 | Schemas can be specified either as a queryset option, or passed 446 | to ``.djangoql()`` queryset method directly: 447 | 448 | .. code:: python 449 | 450 | class BookQuerySet(DjangoQLQuerySet): 451 | djangoql_schema = BookSchema 452 | 453 | 454 | class Book(models.Model): 455 | ... 456 | 457 | objects = BookQuerySet.as_manager() 458 | 459 | # Now, Book.objects.djangoql() will use BookSchema by default: 460 | Book.objects.djangoql('name ~ "Peace") # uses BookSchema 461 | 462 | # Overriding default queryset schema with AnotherSchema: 463 | Book.objects.djangoql('name ~ "Peace", schema=AnotherSchema) 464 | 465 | You can also provide schema as an option for ``apply_search()`` 466 | 467 | .. code:: python 468 | 469 | qs = User.objects.all() 470 | qs = apply_search(qs, 'groups = None', schema=CustomSchema) 471 | 472 | 473 | Using completion widget outside of Django admin 474 | ----------------------------------------------- 475 | 476 | The completion widget is not tightly coupled to Django admin, so you can easily 477 | use it outside of the admin if you want. The widget is 478 | `available on npm `_ as a 479 | standalone package. 480 | See the source code and the docs in the 481 | `djangoql-completion `_ 482 | repo on GitHub. 483 | 484 | The completion widget is also bundled with the 485 | `djangoql `_ Python package on PyPI. If 486 | you're not using Webpack or another JavaScript bundler, you can use the 487 | pre-built version that ships with the Python package. Here is an example: 488 | 489 | Template code, ``completion_demo.html``: 490 | 491 | .. code:: html 492 | 493 | {% load static %} 494 | 495 | 496 | 497 | 498 | DjangoQL completion demo 499 | 500 | 501 | 502 | 503 | 504 |
505 |

{{ error }}

506 | 507 |
508 | 509 |
    510 | {% for item in search_results %} 511 |
  • {{ item }}
  • 512 | {% endfor %} 513 |
514 | 515 | 539 | 540 | 541 | 542 | And in your ``views.py``: 543 | 544 | .. code:: python 545 | 546 | import json 547 | 548 | from django.contrib.auth.models import Group, User 549 | from django.shortcuts import render_to_response 550 | from django.views.decorators.http import require_GET 551 | 552 | from djangoql.exceptions import DjangoQLError 553 | from djangoql.queryset import apply_search 554 | from djangoql.schema import DjangoQLSchema 555 | from djangoql.serializers import DjangoQLSchemaSerializer 556 | 557 | 558 | class UserQLSchema(DjangoQLSchema): 559 | include = (User, Group) 560 | suggest_options = { 561 | Group: ['name'], 562 | } 563 | 564 | 565 | @require_GET 566 | def completion_demo(request): 567 | q = request.GET.get('q', '') 568 | error = '' 569 | query = User.objects.all().order_by('username') 570 | if q: 571 | try: 572 | query = apply_search(query, q, schema=UserQLSchema) 573 | except DjangoQLError as e: 574 | query = query.none() 575 | error = str(e) 576 | # You may want to use SuggestionsAPISerializer and an additional API 577 | # endpoint (see in djangoql.views) for asynchronous suggestions loading 578 | introspections = DjangoQLSchemaSerializer().serialize( 579 | UserQLSchema(query.model), 580 | ) 581 | return render_to_response('completion_demo.html', { 582 | 'q': q, 583 | 'error': error, 584 | 'search_results': query, 585 | 'introspections': json.dumps(introspections), 586 | }) 587 | 588 | 589 | License 590 | ------- 591 | 592 | MIT 593 | -------------------------------------------------------------------------------- /assets/15-five.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /assets/police1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/redhat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/teamplify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /completion-widget/index.js: -------------------------------------------------------------------------------- 1 | import DjangoQL from 'djangoql-completion'; 2 | 3 | import 'djangoql-completion/dist/completion.css'; 4 | 5 | window.DjangoQL = DjangoQL; 6 | -------------------------------------------------------------------------------- /djangoql/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.18.1' 2 | -------------------------------------------------------------------------------- /djangoql/admin.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib import messages 4 | from django.contrib.admin.views.main import ChangeList 5 | from django.core.exceptions import FieldError, ValidationError 6 | from django.db import DataError, NotSupportedError 7 | from django.forms import Media 8 | from django.http import HttpResponse 9 | from django.template.loader import render_to_string 10 | from django.views.generic import TemplateView 11 | 12 | from .compat import text_type 13 | from .exceptions import DjangoQLError 14 | from .queryset import apply_search 15 | from .schema import DjangoQLSchema 16 | from .serializers import SuggestionsAPISerializer 17 | from .views import SuggestionsAPIView 18 | 19 | 20 | try: 21 | from django.core.urlresolvers import reverse 22 | except ImportError: # Django 2.0 23 | from django.urls import reverse 24 | 25 | try: 26 | from django.urls import re_path # Django >= 4.0 27 | except ImportError: 28 | try: 29 | from django.conf.urls import re_path # Django < 4.0 30 | except ImportError: # Django < 2.0 31 | from django.conf.urls import url as re_path 32 | 33 | DJANGOQL_SEARCH_MARKER = 'q-l' 34 | 35 | 36 | class DjangoQLChangeList(ChangeList): 37 | def get_filters_params(self, *args, **kwargs): 38 | params = super(DjangoQLChangeList, self).get_filters_params( 39 | *args, 40 | **kwargs 41 | ) 42 | if DJANGOQL_SEARCH_MARKER in params: 43 | del params[DJANGOQL_SEARCH_MARKER] 44 | return params 45 | 46 | 47 | class DjangoQLSearchMixin(object): 48 | search_fields = ('_djangoql',) # just a stub to have search input displayed 49 | djangoql_completion = True 50 | djangoql_completion_enabled_by_default = True 51 | djangoql_schema = DjangoQLSchema 52 | djangoql_syntax_help_template = 'djangoql/syntax_help.html' 53 | 54 | def search_mode_toggle_enabled(self): 55 | # If search fields were defined on a child ModelAdmin instance, 56 | # we suppose that the developer wants two search modes and therefore 57 | # enable search mode toggle 58 | return self.search_fields != DjangoQLSearchMixin.search_fields 59 | 60 | def djangoql_search_enabled(self, request): 61 | return request.GET.get(DJANGOQL_SEARCH_MARKER, '').lower() == 'on' 62 | 63 | def get_changelist(self, *args, **kwargs): 64 | return DjangoQLChangeList 65 | 66 | def get_search_results(self, request, queryset, search_term): 67 | if ( 68 | self.search_mode_toggle_enabled() and 69 | not self.djangoql_search_enabled(request) 70 | ): 71 | return super(DjangoQLSearchMixin, self).get_search_results( 72 | request=request, 73 | queryset=queryset, 74 | search_term=search_term, 75 | ) 76 | use_distinct = False 77 | if not search_term: 78 | return queryset, use_distinct 79 | 80 | try: 81 | qs = apply_search(queryset, search_term, self.djangoql_schema) 82 | except (DjangoQLError, ValueError, FieldError, ValidationError) as e: 83 | msg = self.djangoql_error_message(e) 84 | messages.add_message(request, messages.WARNING, msg) 85 | qs = queryset.none() 86 | else: 87 | # Hack to handle 'inet' comparison errors in Postgres. If you 88 | # know a better way to check for such an error, please submit a PR. 89 | try: 90 | # Django >= 2.1 has built-in .explain() method 91 | explain = getattr(qs, 'explain', None) 92 | if callable(explain): 93 | try: 94 | explain() 95 | except NotSupportedError: 96 | list(qs[:1]) 97 | else: 98 | list(qs[:1]) 99 | except DataError as e: 100 | if 'inet' not in str(e): 101 | raise 102 | msg = self.djangoql_error_message(e) 103 | messages.add_message(request, messages.WARNING, msg) 104 | qs = queryset.none() 105 | 106 | return qs, use_distinct 107 | 108 | def djangoql_error_message(self, exception): 109 | if isinstance(exception, ValidationError): 110 | msg = exception.messages[0] 111 | else: 112 | msg = text_type(exception) 113 | return render_to_string('djangoql/error_message.html', context={ 114 | 'error_message': msg, 115 | }) 116 | 117 | @property 118 | def media(self): 119 | media = super(DjangoQLSearchMixin, self).media 120 | if self.djangoql_completion: 121 | js = [ 122 | 'djangoql/js/completion.js', 123 | ] 124 | if self.search_mode_toggle_enabled(): 125 | js.append('djangoql/js/completion_admin_toggle.js') 126 | if not self.djangoql_completion_enabled_by_default: 127 | js.append('djangoql/js/completion_admin_toggle_off.js') 128 | js.append('djangoql/js/completion_admin.js') 129 | media += Media( 130 | css={'': ( 131 | 'djangoql/css/completion.css', 132 | 'djangoql/css/completion_admin.css', 133 | )}, 134 | js=js, 135 | ) 136 | return media 137 | 138 | def get_urls(self): 139 | custom_urls = [] 140 | if self.djangoql_completion: 141 | custom_urls += [ 142 | re_path( 143 | r'^introspect/$', 144 | self.admin_site.admin_view(self.introspect), 145 | name='%s_%s_djangoql_introspect' % ( 146 | self.model._meta.app_label, 147 | self.model._meta.model_name, 148 | ), 149 | ), 150 | re_path( 151 | r'^suggestions/$', 152 | self.admin_site.admin_view(self.suggestions), 153 | name='%s_%s_djangoql_suggestions' % ( 154 | self.model._meta.app_label, 155 | self.model._meta.model_name, 156 | ), 157 | ), 158 | re_path( 159 | r'^djangoql-syntax/$', 160 | self.admin_site.admin_view(TemplateView.as_view( 161 | template_name=self.djangoql_syntax_help_template, 162 | )), 163 | name='djangoql_syntax_help', 164 | ), 165 | ] 166 | return custom_urls + super(DjangoQLSearchMixin, self).get_urls() 167 | 168 | def introspect(self, request): 169 | suggestions_url = reverse('%s:%s_%s_djangoql_suggestions' % ( 170 | self.admin_site.name, 171 | self.model._meta.app_label, 172 | self.model._meta.model_name, 173 | )) 174 | serializer = SuggestionsAPISerializer(suggestions_url) 175 | response = serializer.serialize(self.djangoql_schema(self.model)) 176 | return HttpResponse( 177 | content=json.dumps(response, indent=2), 178 | content_type='application/json; charset=utf-8', 179 | ) 180 | 181 | def suggestions(self, request): 182 | view = SuggestionsAPIView.as_view( 183 | schema=self.djangoql_schema(self.model), 184 | ) 185 | return view(request) 186 | -------------------------------------------------------------------------------- /djangoql/ast.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from .compat import text_type 4 | 5 | 6 | class Node(object): 7 | def __str__(self): 8 | children = [] 9 | for k, v in self.__dict__.items(): 10 | if isinstance(v, (list, tuple)): 11 | v = '[%s]' % ', '.join([text_type(v) for v in v if v]) 12 | children.append('%s=%s' % (k, v)) 13 | return '<%s%s%s>' % ( 14 | self.__class__.__name__, 15 | ': ' if children else '', 16 | ', '.join(children), 17 | ) 18 | 19 | __repr__ = __str__ 20 | 21 | def __eq__(self, other): 22 | if not isinstance(other, self.__class__): 23 | return False 24 | for k, v in self.__dict__.items(): 25 | if getattr(other, k) != v: 26 | return False 27 | return True 28 | 29 | def __ne__(self, other): 30 | return not self.__eq__(other) 31 | 32 | 33 | class Expression(Node): 34 | def __init__(self, left, operator, right): 35 | self.left = left 36 | self.operator = operator 37 | self.right = right 38 | 39 | 40 | class Name(Node): 41 | def __init__(self, parts): 42 | if isinstance(parts, list): 43 | self.parts = parts 44 | elif isinstance(parts, tuple): 45 | self.parts = list(parts) 46 | else: 47 | self.parts = [parts] 48 | 49 | @property 50 | def value(self): 51 | return '.'.join(self.parts) 52 | 53 | 54 | class Const(Node): 55 | def __init__(self, value): 56 | self.value = value 57 | 58 | 59 | class List(Node): 60 | def __init__(self, items): 61 | self.items = items 62 | 63 | @property 64 | def value(self): 65 | return [i.value for i in self.items] 66 | 67 | 68 | class Operator(Node): 69 | def __init__(self, operator): 70 | self.operator = operator 71 | 72 | 73 | class Logical(Operator): 74 | pass 75 | 76 | 77 | class Comparison(Operator): 78 | pass 79 | -------------------------------------------------------------------------------- /djangoql/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | PY2 = sys.version_info.major == 2 5 | 6 | if PY2: 7 | binary_type = str 8 | text_type = unicode # noqa: F821 9 | else: 10 | binary_type = bytes 11 | text_type = str 12 | -------------------------------------------------------------------------------- /djangoql/exceptions.py: -------------------------------------------------------------------------------- 1 | class DjangoQLError(Exception): 2 | def __init__(self, message=None, value=None, line=None, column=None): 3 | self.value = value 4 | self.line = line 5 | self.column = column 6 | super(DjangoQLError, self).__init__(message) 7 | 8 | def __str__(self): 9 | message = super(DjangoQLError, self).__str__() 10 | if self.line: 11 | position_info = 'Line %s' % self.line 12 | if self.column: 13 | position_info += ', col %s' % self.column 14 | return '%s: %s' % (position_info, message) 15 | else: 16 | return message 17 | 18 | 19 | class DjangoQLSyntaxError(DjangoQLError): 20 | pass 21 | 22 | 23 | class DjangoQLLexerError(DjangoQLSyntaxError): 24 | pass 25 | 26 | 27 | class DjangoQLParserError(DjangoQLSyntaxError): 28 | pass 29 | 30 | 31 | class DjangoQLSchemaError(DjangoQLError): 32 | pass 33 | -------------------------------------------------------------------------------- /djangoql/lexer.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import ply.lex as lex 4 | from ply.lex import TOKEN 5 | 6 | from .exceptions import DjangoQLLexerError 7 | 8 | 9 | class DjangoQLLexer(object): 10 | def __init__(self, **kwargs): 11 | self._lexer = lex.lex(module=self, **kwargs) 12 | self.reset() 13 | 14 | def reset(self): 15 | self.text = '' 16 | self._lexer.lineno = 1 17 | return self 18 | 19 | def input(self, s): 20 | self.reset() 21 | self.text = s 22 | self._lexer.input(s) 23 | return self 24 | 25 | def token(self): 26 | return self._lexer.token() 27 | 28 | # Iterator interface 29 | def __iter__(self): 30 | return self 31 | 32 | def next(self): 33 | t = self.token() 34 | if t is None: 35 | raise StopIteration 36 | return t 37 | 38 | __next__ = next 39 | 40 | def find_column(self, t): 41 | """ 42 | Returns token position in current text, starting from 1 43 | """ 44 | cr = max( 45 | self.text.rfind(lt, 0, t.lexpos) for lt in self.line_terminators 46 | ) 47 | if cr == -1: 48 | return t.lexpos + 1 49 | return t.lexpos - cr 50 | 51 | whitespace = ' \t\v\f\u00A0' 52 | line_terminators = '\n\r\u2028\u2029' 53 | 54 | re_line_terminators = r'\n\r\u2028\u2029' 55 | 56 | re_escaped_char = r'\\[\"\\/bfnrt]' 57 | re_escaped_unicode = r'\\u[0-9A-Fa-f]{4}' 58 | re_string_char = r'[^\"\\' + re_line_terminators + u']' 59 | 60 | re_int_value = r'(-?0|-?[1-9][0-9]*)' 61 | re_fraction_part = r'\.[0-9]+' 62 | re_exponent_part = r'[eE][\+-]?[0-9]+' 63 | 64 | tokens = [ 65 | 'COMMA', 66 | 'OR', 67 | 'AND', 68 | 'NOT', 69 | 'IN', 70 | 'TRUE', 71 | 'FALSE', 72 | 'NONE', 73 | 'NAME', 74 | 'STRING_VALUE', 75 | 'FLOAT_VALUE', 76 | 'INT_VALUE', 77 | 'PAREN_L', 78 | 'PAREN_R', 79 | 'EQUALS', 80 | 'NOT_EQUALS', 81 | 'GREATER', 82 | 'GREATER_EQUAL', 83 | 'LESS', 84 | 'LESS_EQUAL', 85 | 'CONTAINS', 86 | 'NOT_CONTAINS', 87 | 'STARTSWITH', 88 | 'ENDSWITH', 89 | ] 90 | 91 | t_COMMA = ',' 92 | t_PAREN_L = r'\(' 93 | t_PAREN_R = r'\)' 94 | t_EQUALS = '=' 95 | t_NOT_EQUALS = '!=' 96 | t_GREATER = '>' 97 | t_GREATER_EQUAL = '>=' 98 | t_LESS = '<' 99 | t_LESS_EQUAL = '<=' 100 | t_CONTAINS = '~' 101 | t_NOT_CONTAINS = '!~' 102 | 103 | t_NAME = r'[_A-Za-z][_0-9A-Za-z]*(\.[_A-Za-z][_0-9A-Za-z]*)*' 104 | 105 | t_ignore = whitespace 106 | 107 | @TOKEN(r'\"(' + re_escaped_char + 108 | '|' + re_escaped_unicode + 109 | '|' + re_string_char + r')*\"') 110 | def t_STRING_VALUE(self, t): 111 | t.value = t.value[1:-1] # cut leading and trailing quotes "" 112 | return t 113 | 114 | @TOKEN(re_int_value + re_fraction_part + re_exponent_part + '|' + 115 | re_int_value + re_fraction_part + '|' + 116 | re_int_value + re_exponent_part) 117 | def t_FLOAT_VALUE(self, t): 118 | return t 119 | 120 | @TOKEN(re_int_value) 121 | def t_INT_VALUE(self, t): 122 | return t 123 | 124 | not_followed_by_name = '(?![_0-9A-Za-z])' 125 | 126 | @TOKEN('or' + not_followed_by_name) 127 | def t_OR(self, t): 128 | return t 129 | 130 | @TOKEN('and' + not_followed_by_name) 131 | def t_AND(self, t): 132 | return t 133 | 134 | @TOKEN('not' + not_followed_by_name) 135 | def t_NOT(self, t): 136 | return t 137 | 138 | @TOKEN('in' + not_followed_by_name) 139 | def t_IN(self, t): 140 | return t 141 | 142 | @TOKEN('startswith' + not_followed_by_name) 143 | def t_STARTSWITH(self, t): 144 | return t 145 | 146 | @TOKEN('endswith' + not_followed_by_name) 147 | def t_ENDSWITH(self, t): 148 | return t 149 | 150 | @TOKEN('True' + not_followed_by_name) 151 | def t_TRUE(self, t): 152 | return t 153 | 154 | @TOKEN('False' + not_followed_by_name) 155 | def t_FALSE(self, t): 156 | return t 157 | 158 | @TOKEN('None' + not_followed_by_name) 159 | def t_NONE(self, t): 160 | return t 161 | 162 | def t_error(self, t): 163 | raise DjangoQLLexerError( 164 | message='Illegal character %s' % repr(t.value[0]), 165 | value=t.value, 166 | line=t.lineno, 167 | column=self.find_column(t), 168 | ) 169 | 170 | @TOKEN('[' + re_line_terminators + ']+') 171 | def t_newline(self, t): 172 | t.lexer.lineno += len(t.value) 173 | return 174 | -------------------------------------------------------------------------------- /djangoql/parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | from decimal import Decimal 5 | 6 | import ply.yacc as yacc 7 | 8 | from .ast import Comparison, Const, Expression, List, Logical, Name 9 | from .compat import binary_type, text_type 10 | from .exceptions import DjangoQLParserError 11 | from .lexer import DjangoQLLexer 12 | 13 | 14 | unescape_pattern = re.compile( 15 | '(' + DjangoQLLexer.re_escaped_char + '|' + 16 | DjangoQLLexer.re_escaped_unicode + ')', 17 | ) 18 | 19 | 20 | def unescape_repl(m): 21 | contents = m.group(1) 22 | if len(contents) == 2: 23 | return contents[1] 24 | else: 25 | return contents.encode('utf8').decode('unicode_escape') 26 | 27 | 28 | def unescape(value): 29 | if isinstance(value, binary_type): 30 | value = value.decode('utf8') 31 | return re.sub(unescape_pattern, unescape_repl, value) 32 | 33 | 34 | class DjangoQLParser(object): 35 | def __init__(self, debug=False, **kwargs): 36 | self.default_lexer = DjangoQLLexer() 37 | self.tokens = self.default_lexer.tokens 38 | kwargs['debug'] = debug 39 | if 'write_tables' not in kwargs: 40 | kwargs['write_tables'] = False 41 | self.yacc = yacc.yacc(module=self, **kwargs) 42 | 43 | def parse(self, input=None, lexer=None, **kwargs): # noqa: A002 44 | lexer = lexer or self.default_lexer 45 | return self.yacc.parse(input=input, lexer=lexer, **kwargs) 46 | 47 | start = 'expression' 48 | 49 | def p_expression_parens(self, p): 50 | """ 51 | expression : PAREN_L expression PAREN_R 52 | """ 53 | p[0] = p[2] 54 | 55 | def p_expression_logical(self, p): 56 | """ 57 | expression : expression logical expression 58 | """ 59 | p[0] = Expression(left=p[1], operator=p[2], right=p[3]) 60 | 61 | def p_expression_comparison(self, p): 62 | """ 63 | expression : name comparison_number number 64 | | name comparison_string string 65 | | name comparison_equality boolean_value 66 | | name comparison_equality none 67 | | name comparison_in_list const_list_value 68 | """ 69 | p[0] = Expression(left=p[1], operator=p[2], right=p[3]) 70 | 71 | def p_name(self, p): 72 | """ 73 | name : NAME 74 | """ 75 | p[0] = Name(parts=p[1].split('.')) 76 | 77 | def p_logical(self, p): 78 | """ 79 | logical : AND 80 | | OR 81 | """ 82 | p[0] = Logical(operator=p[1]) 83 | 84 | def p_comparison_number(self, p): 85 | """ 86 | comparison_number : comparison_equality 87 | | comparison_greater_less 88 | """ 89 | p[0] = p[1] 90 | 91 | def p_comparison_string(self, p): 92 | """ 93 | comparison_string : comparison_equality 94 | | comparison_greater_less 95 | | comparison_string_specific 96 | """ 97 | p[0] = p[1] 98 | 99 | def p_comparison_equality(self, p): 100 | """ 101 | comparison_equality : EQUALS 102 | | NOT_EQUALS 103 | """ 104 | p[0] = Comparison(operator=p[1]) 105 | 106 | def p_comparison_greater_less(self, p): 107 | """ 108 | comparison_greater_less : GREATER 109 | | GREATER_EQUAL 110 | | LESS 111 | | LESS_EQUAL 112 | """ 113 | p[0] = Comparison(operator=p[1]) 114 | 115 | def p_comparison_string_specific(self, p): 116 | """ 117 | comparison_string_specific : CONTAINS 118 | | NOT_CONTAINS 119 | | STARTSWITH 120 | | NOT STARTSWITH 121 | | ENDSWITH 122 | | NOT ENDSWITH 123 | """ 124 | if len(p) == 2: 125 | p[0] = Comparison(operator=p[1]) 126 | else: 127 | p[0] = Comparison(operator='%s %s' % (p[1], p[2])) 128 | 129 | def p_comparison_in_list(self, p): 130 | """ 131 | comparison_in_list : IN 132 | | NOT IN 133 | """ 134 | if len(p) == 2: 135 | p[0] = Comparison(operator=p[1]) 136 | else: 137 | p[0] = Comparison(operator='%s %s' % (p[1], p[2])) 138 | 139 | def p_const_value(self, p): 140 | """ 141 | const_value : number 142 | | string 143 | | none 144 | | boolean_value 145 | """ 146 | p[0] = p[1] 147 | 148 | def p_number_int(self, p): 149 | """ 150 | number : INT_VALUE 151 | """ 152 | p[0] = Const(value=int(p[1])) 153 | 154 | def p_number_float(self, p): 155 | """ 156 | number : FLOAT_VALUE 157 | """ 158 | p[0] = Const(value=Decimal(p[1])) 159 | 160 | def p_string(self, p): 161 | """ 162 | string : STRING_VALUE 163 | """ 164 | p[0] = Const(value=unescape(p[1])) 165 | 166 | def p_none(self, p): 167 | """ 168 | none : NONE 169 | """ 170 | p[0] = Const(value=None) 171 | 172 | def p_boolean_value(self, p): 173 | """ 174 | boolean_value : true 175 | | false 176 | """ 177 | p[0] = p[1] 178 | 179 | def p_true(self, p): 180 | """ 181 | true : TRUE 182 | """ 183 | p[0] = Const(value=True) 184 | 185 | def p_false(self, p): 186 | """ 187 | false : FALSE 188 | """ 189 | p[0] = Const(value=False) 190 | 191 | def p_const_list_value(self, p): 192 | """ 193 | const_list_value : PAREN_L const_value_list PAREN_R 194 | """ 195 | p[0] = List(items=p[2]) 196 | 197 | def p_const_value_list(self, p): 198 | """ 199 | const_value_list : const_value_list COMMA const_value 200 | """ 201 | p[0] = p[1] + [p[3]] 202 | 203 | def p_const_value_list_single(self, p): 204 | """ 205 | const_value_list : const_value 206 | """ 207 | p[0] = [p[1]] 208 | 209 | def p_error(self, token): 210 | if token is None: 211 | self.raise_syntax_error('Unexpected end of input') 212 | else: 213 | fragment = text_type(token.value) 214 | if len(fragment) > 20: 215 | fragment = fragment[:17] + '...' 216 | self.raise_syntax_error( 217 | 'Syntax error at %s' % repr(fragment), 218 | token=token, 219 | ) 220 | 221 | def raise_syntax_error(self, message, token=None): 222 | if token is None: 223 | raise DjangoQLParserError(message) 224 | lexer = token.lexer 225 | if callable(getattr(lexer, 'find_column', None)): 226 | column = lexer.find_column(token) 227 | else: 228 | column = None 229 | raise DjangoQLParserError( 230 | message=message, 231 | value=token.value, 232 | line=token.lineno, 233 | column=column, 234 | ) 235 | -------------------------------------------------------------------------------- /djangoql/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 = 'expressionAND COMMA CONTAINS EQUALS FALSE FLOAT_VALUE GREATER GREATER_EQUAL IN INT_VALUE LESS LESS_EQUAL NAME NONE NOT NOT_CONTAINS NOT_EQUALS OR PAREN_L PAREN_R STRING_VALUE TRUE\n expression : PAREN_L expression PAREN_R\n \n expression : expression logical expression\n \n expression : name comparison_number number\n | name comparison_string string\n | name comparison_equality boolean_value\n | name comparison_equality none\n | name comparison_in_list const_list_value\n \n name : NAME\n \n logical : AND\n | OR\n \n comparison_number : comparison_equality\n | comparison_greater_less\n \n comparison_string : comparison_equality\n | comparison_greater_less\n | comparison_contains\n \n comparison_equality : EQUALS\n | NOT_EQUALS\n \n comparison_greater_less : GREATER\n | GREATER_EQUAL\n | LESS\n | LESS_EQUAL\n \n comparison_contains : CONTAINS\n | NOT_CONTAINS\n \n comparison_in_list : IN\n | NOT IN\n \n const_value : number\n | string\n | none\n | boolean_value\n \n number : INT_VALUE\n \n number : FLOAT_VALUE\n \n string : STRING_VALUE\n \n none : NONE\n \n boolean_value : true\n | false\n \n true : TRUE\n \n false : FALSE\n \n const_list_value : PAREN_L const_value_list PAREN_R\n \n const_value_list : const_value_list COMMA const_value\n \n const_value_list : const_value\n ' 10 | 11 | _lr_action_items = {'PAREN_L':([0,2,5,6,7,12,17,41,],[2,2,2,-9,-10,40,-24,-25,]),'NAME':([0,2,5,6,7,],[4,4,4,-9,-10,]),'$end':([1,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,48,],[0,-2,-1,-3,-30,-31,-4,-32,-5,-6,-34,-35,-33,-36,-37,-7,-38,]),'AND':([1,8,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,48,],[6,6,6,-1,-3,-30,-31,-4,-32,-5,-6,-34,-35,-33,-36,-37,-7,-38,]),'OR':([1,8,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,48,],[7,7,7,-1,-3,-30,-31,-4,-32,-5,-6,-34,-35,-33,-36,-37,-7,-38,]),'EQUALS':([3,4,],[15,-8,]),'NOT_EQUALS':([3,4,],[16,-8,]),'IN':([3,4,18,],[17,-8,41,]),'NOT':([3,4,],[18,-8,]),'GREATER':([3,4,],[19,-8,]),'GREATER_EQUAL':([3,4,],[20,-8,]),'LESS':([3,4,],[21,-8,]),'LESS_EQUAL':([3,4,],[22,-8,]),'CONTAINS':([3,4,],[23,-8,]),'NOT_CONTAINS':([3,4,],[24,-8,]),'PAREN_R':([8,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,42,43,44,45,46,47,48,50,],[26,-2,-1,-3,-30,-31,-4,-32,-5,-6,-34,-35,-33,-36,-37,-7,48,-40,-26,-27,-28,-29,-38,-39,]),'INT_VALUE':([9,11,13,15,16,19,20,21,22,40,49,],[28,-11,-12,-16,-17,-18,-19,-20,-21,28,28,]),'FLOAT_VALUE':([9,11,13,15,16,19,20,21,22,40,49,],[29,-11,-12,-16,-17,-18,-19,-20,-21,29,29,]),'STRING_VALUE':([10,11,13,14,15,16,19,20,21,22,23,24,40,49,],[31,-13,-14,-15,-16,-17,-18,-19,-20,-21,-22,-23,31,31,]),'NONE':([11,15,16,40,49,],[36,-16,-17,36,36,]),'TRUE':([11,15,16,40,49,],[37,-16,-17,37,37,]),'FALSE':([11,15,16,40,49,],[38,-16,-17,38,38,]),'COMMA':([28,29,31,34,35,36,37,38,42,43,44,45,46,47,50,],[-30,-31,-32,-34,-35,-33,-36,-37,49,-40,-26,-27,-28,-29,-39,]),} 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 = {'expression':([0,2,5,],[1,8,25,]),'name':([0,2,5,],[3,3,3,]),'logical':([1,8,25,],[5,5,5,]),'comparison_number':([3,],[9,]),'comparison_string':([3,],[10,]),'comparison_equality':([3,],[11,]),'comparison_in_list':([3,],[12,]),'comparison_greater_less':([3,],[13,]),'comparison_contains':([3,],[14,]),'number':([9,40,49,],[27,44,44,]),'string':([10,40,49,],[30,45,45,]),'boolean_value':([11,40,49,],[32,47,47,]),'none':([11,40,49,],[33,46,46,]),'true':([11,40,49,],[34,34,34,]),'false':([11,40,49,],[35,35,35,]),'const_list_value':([12,],[39,]),'const_value_list':([40,],[42,]),'const_value':([40,49,],[43,50,]),} 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' -> expression","S'",1,None,None,None), 30 | ('expression -> PAREN_L expression PAREN_R','expression',3,'p_expression_parens','parser.py',49), 31 | ('expression -> expression logical expression','expression',3,'p_expression_logical','parser.py',55), 32 | ('expression -> name comparison_number number','expression',3,'p_expression_comparison','parser.py',61), 33 | ('expression -> name comparison_string string','expression',3,'p_expression_comparison','parser.py',62), 34 | ('expression -> name comparison_equality boolean_value','expression',3,'p_expression_comparison','parser.py',63), 35 | ('expression -> name comparison_equality none','expression',3,'p_expression_comparison','parser.py',64), 36 | ('expression -> name comparison_in_list const_list_value','expression',3,'p_expression_comparison','parser.py',65), 37 | ('name -> NAME','name',1,'p_name','parser.py',71), 38 | ('logical -> AND','logical',1,'p_logical','parser.py',77), 39 | ('logical -> OR','logical',1,'p_logical','parser.py',78), 40 | ('comparison_number -> comparison_equality','comparison_number',1,'p_comparison_number','parser.py',84), 41 | ('comparison_number -> comparison_greater_less','comparison_number',1,'p_comparison_number','parser.py',85), 42 | ('comparison_string -> comparison_equality','comparison_string',1,'p_comparison_string','parser.py',91), 43 | ('comparison_string -> comparison_greater_less','comparison_string',1,'p_comparison_string','parser.py',92), 44 | ('comparison_string -> comparison_contains','comparison_string',1,'p_comparison_string','parser.py',93), 45 | ('comparison_equality -> EQUALS','comparison_equality',1,'p_comparison_equality','parser.py',99), 46 | ('comparison_equality -> NOT_EQUALS','comparison_equality',1,'p_comparison_equality','parser.py',100), 47 | ('comparison_greater_less -> GREATER','comparison_greater_less',1,'p_comparison_greater_less','parser.py',106), 48 | ('comparison_greater_less -> GREATER_EQUAL','comparison_greater_less',1,'p_comparison_greater_less','parser.py',107), 49 | ('comparison_greater_less -> LESS','comparison_greater_less',1,'p_comparison_greater_less','parser.py',108), 50 | ('comparison_greater_less -> LESS_EQUAL','comparison_greater_less',1,'p_comparison_greater_less','parser.py',109), 51 | ('comparison_contains -> CONTAINS','comparison_contains',1,'p_comparison_contains','parser.py',115), 52 | ('comparison_contains -> NOT_CONTAINS','comparison_contains',1,'p_comparison_contains','parser.py',116), 53 | ('comparison_in_list -> IN','comparison_in_list',1,'p_comparison_in_list','parser.py',122), 54 | ('comparison_in_list -> NOT IN','comparison_in_list',2,'p_comparison_in_list','parser.py',123), 55 | ('const_value -> number','const_value',1,'p_const_value','parser.py',132), 56 | ('const_value -> string','const_value',1,'p_const_value','parser.py',133), 57 | ('const_value -> none','const_value',1,'p_const_value','parser.py',134), 58 | ('const_value -> boolean_value','const_value',1,'p_const_value','parser.py',135), 59 | ('number -> INT_VALUE','number',1,'p_number_int','parser.py',141), 60 | ('number -> FLOAT_VALUE','number',1,'p_number_float','parser.py',147), 61 | ('string -> STRING_VALUE','string',1,'p_string','parser.py',153), 62 | ('none -> NONE','none',1,'p_none','parser.py',159), 63 | ('boolean_value -> true','boolean_value',1,'p_boolean_value','parser.py',165), 64 | ('boolean_value -> false','boolean_value',1,'p_boolean_value','parser.py',166), 65 | ('true -> TRUE','true',1,'p_true','parser.py',172), 66 | ('false -> FALSE','false',1,'p_false','parser.py',178), 67 | ('const_list_value -> PAREN_L const_value_list PAREN_R','const_list_value',3,'p_const_list_value','parser.py',184), 68 | ('const_value_list -> const_value_list COMMA const_value','const_value_list',3,'p_const_value_list','parser.py',190), 69 | ('const_value_list -> const_value','const_value_list',1,'p_const_value_list_single','parser.py',196), 70 | ] 71 | -------------------------------------------------------------------------------- /djangoql/queryset.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from .ast import Logical 4 | from .parser import DjangoQLParser 5 | from .schema import DjangoQLField, DjangoQLSchema 6 | 7 | 8 | def build_filter(expr, schema_instance): 9 | if isinstance(expr.operator, Logical): 10 | left = build_filter(expr.left, schema_instance) 11 | right = build_filter(expr.right, schema_instance) 12 | if expr.operator.operator == 'or': 13 | return left | right 14 | else: 15 | return left & right 16 | 17 | field = schema_instance.resolve_name(expr.left) 18 | if not field: 19 | # That must be a reference to a model without specifying a field. 20 | # Let's construct an abstract lookup field for it 21 | field = DjangoQLField( 22 | name=expr.left.parts[-1], 23 | nullable=True, 24 | ) 25 | return field.get_lookup( 26 | path=expr.left.parts[:-1], 27 | operator=expr.operator.operator, 28 | value=expr.right.value, 29 | ) 30 | 31 | 32 | def apply_search(queryset, search, schema=None): 33 | """ 34 | Applies search written in DjangoQL mini-language to given queryset 35 | """ 36 | ast = DjangoQLParser().parse(search) 37 | schema = schema or DjangoQLSchema 38 | schema_instance = schema(queryset.model) 39 | schema_instance.validate(ast) 40 | return queryset.filter(build_filter(ast, schema_instance)) 41 | 42 | 43 | class DjangoQLQuerySet(QuerySet): 44 | djangoql_schema = None 45 | 46 | def djangoql(self, search, schema=None): 47 | return apply_search(self, search, schema=schema or self.djangoql_schema) 48 | -------------------------------------------------------------------------------- /djangoql/schema.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from collections import OrderedDict, deque 4 | from datetime import datetime 5 | from decimal import Decimal 6 | 7 | from django.conf import settings 8 | from django.core.exceptions import FieldDoesNotExist 9 | from django.db import models 10 | from django.db.models import ManyToManyRel, ManyToOneRel 11 | from django.db.models.fields.related import ForeignObjectRel 12 | from django.utils.timezone import get_current_timezone 13 | 14 | from .ast import Comparison, Const, List, Logical, Name, Node 15 | from .compat import text_type 16 | from .exceptions import DjangoQLSchemaError 17 | 18 | 19 | class DjangoQLField(object): 20 | """ 21 | Abstract searchable field 22 | """ 23 | model = None 24 | name = None 25 | nullable = False 26 | suggest_options = False 27 | type = 'unknown' 28 | value_types = [] 29 | value_types_description = '' 30 | 31 | def __init__(self, model=None, name=None, nullable=None, 32 | suggest_options=None): 33 | if model is not None: 34 | self.model = model 35 | if name is not None: 36 | self.name = name 37 | if nullable is not None: 38 | self.nullable = nullable 39 | if suggest_options is not None: 40 | self.suggest_options = suggest_options 41 | 42 | def _field_choices(self): 43 | if self.model: 44 | try: 45 | return self.model._meta.get_field(self.name).choices 46 | except (AttributeError, FieldDoesNotExist): 47 | pass 48 | return [] 49 | 50 | @property 51 | def async_options(self): 52 | return not self._field_choices() 53 | 54 | def get_options(self, search): 55 | """ 56 | Override this method to provide custom suggestion options 57 | """ 58 | result = [] 59 | choices = self._field_choices() 60 | if choices: 61 | search = search.lower() 62 | for c in choices: 63 | choice = text_type(c[1]) 64 | if search in choice.lower(): 65 | result.append(choice) 66 | return result 67 | 68 | def get_lookup_name(self): 69 | """ 70 | Override this method to provide custom lookup name 71 | """ 72 | return self.name 73 | 74 | def get_lookup_value(self, value): 75 | """ 76 | Override this method to convert displayed values to lookup values 77 | """ 78 | choices = self._field_choices() 79 | if choices: 80 | if isinstance(value, list): 81 | return [c[0] for c in choices if c[0] in value or c[1] in value] 82 | else: 83 | for c in choices: 84 | if value in c: 85 | return c[0] 86 | return value 87 | 88 | def get_operator(self, operator): 89 | """ 90 | Get a comparison suffix to be used in Django ORM & inversion flag for it 91 | 92 | :param operator: string, DjangoQL comparison operator 93 | :return: (suffix, invert) - a tuple with 2 values: 94 | suffix - suffix to be used in ORM query, for example '__gt' for '>' 95 | invert - boolean, True if this comparison needs to be inverted 96 | """ 97 | op = { 98 | '=': '', 99 | '>': '__gt', 100 | '>=': '__gte', 101 | '<': '__lt', 102 | '<=': '__lte', 103 | '~': '__icontains', 104 | 'in': '__in', 105 | 'startswith': '__istartswith', 106 | 'endswith': '__iendswith', 107 | }.get(operator) 108 | if op is not None: 109 | return op, False 110 | op = { 111 | '!=': '', 112 | '!~': '__icontains', 113 | 'not in': '__in', 114 | 'not startswith': '__istartswith', 115 | 'not endswith': '__iendswith', 116 | }[operator] 117 | return op, True 118 | 119 | def get_lookup(self, path, operator, value): 120 | """ 121 | Performs a lookup for this field with given path, operator and value. 122 | 123 | Override this if you'd like to implement a fully custom lookup. It 124 | should support all comparison operators compatible with the field type. 125 | 126 | :param path: a list of names preceding current lookup. For example, 127 | if expression looks like 'author.groups.name = "Foo"' path would 128 | be ['author', 'groups']. 'name' is not included, because it's the 129 | current field instance itself. 130 | :param operator: a string with comparison operator. It could be one of 131 | the following: '=', '!=', '>', '>=', '<', '<=', '~', '!~', 'in', 132 | 'not in'. Depending on the field type, some operators may be 133 | excluded. '~' and '!~' can be applied to StrField only and aren't 134 | allowed for any other fields. BoolField can't be used with less or 135 | greater operators, '>', '>=', '<' and '<=' are excluded for it. 136 | :param value: value passed for comparison 137 | :return: Q-object 138 | """ 139 | search = '__'.join(path + [self.get_lookup_name()]) 140 | op, invert = self.get_operator(operator) 141 | q = models.Q(**{'%s%s' % (search, op): self.get_lookup_value(value)}) 142 | return ~q if invert else q 143 | 144 | def validate(self, value): 145 | if not self.nullable and value is None: 146 | raise DjangoQLSchemaError( 147 | 'Field %s is not nullable, ' 148 | "can't compare it to None" % self.name, 149 | ) 150 | if value is not None and type(value) not in self.value_types: 151 | if self.nullable: 152 | msg = ( 153 | 'Field "{field}" has "nullable {field_type}" type. ' 154 | 'It can be compared to {possible_values} or None, ' 155 | 'but not to {value}' 156 | ) 157 | else: 158 | msg = ( 159 | 'Field "{field}" has "{field_type}" type. It can ' 160 | 'be compared to {possible_values}, ' 161 | 'but not to {value}' 162 | ) 163 | raise DjangoQLSchemaError(msg.format( 164 | field=self.name, 165 | field_type=self.type, 166 | possible_values=self.value_types_description, 167 | value=repr(value), 168 | )) 169 | 170 | 171 | class IntField(DjangoQLField): 172 | type = 'int' 173 | value_types = [int] 174 | value_types_description = 'integer numbers' 175 | 176 | def validate(self, value): 177 | """ 178 | Support enum-like choices defined on an integer field 179 | """ 180 | return super(IntField, self).validate(self.get_lookup_value(value)) 181 | 182 | 183 | class FloatField(DjangoQLField): 184 | type = 'float' 185 | value_types = [int, float, Decimal] 186 | value_types_description = 'floating point numbers' 187 | 188 | 189 | class StrField(DjangoQLField): 190 | type = 'str' 191 | value_types = [text_type] 192 | value_types_description = 'strings' 193 | 194 | def get_options(self, search): 195 | choice_options = super(StrField, self).get_options(search) 196 | if choice_options: 197 | return choice_options 198 | lookup = {} 199 | if search: 200 | lookup['%s__icontains' % self.name] = search 201 | return self.model.objects\ 202 | .filter(**lookup)\ 203 | .order_by(self.name)\ 204 | .values_list(self.name, flat=True)\ 205 | .distinct() 206 | 207 | 208 | class BoolField(DjangoQLField): 209 | type = 'bool' 210 | value_types = [bool] 211 | value_types_description = 'True or False' 212 | 213 | 214 | class DateField(DjangoQLField): 215 | type = 'date' 216 | value_types = [text_type] 217 | value_types_description = 'dates in "YYYY-MM-DD" format' 218 | 219 | def validate(self, value): 220 | super(DateField, self).validate(value) 221 | try: 222 | self.get_lookup_value(value) 223 | except ValueError: 224 | raise DjangoQLSchemaError( 225 | 'Field "%s" can be compared to dates in ' 226 | '"YYYY-MM-DD" format, but not to %s' % ( 227 | self.name, 228 | repr(value), 229 | ), 230 | ) 231 | 232 | def get_lookup_value(self, value): 233 | if not value: 234 | return None 235 | return datetime.strptime(value, '%Y-%m-%d').date() 236 | 237 | 238 | class DateTimeField(DjangoQLField): 239 | type = 'datetime' 240 | value_types = [text_type] 241 | value_types_description = 'timestamps in "YYYY-MM-DD HH:MM" format' 242 | 243 | def validate(self, value): 244 | super(DateTimeField, self).validate(value) 245 | try: 246 | self.get_lookup_value(value) 247 | except ValueError: 248 | raise DjangoQLSchemaError( 249 | 'Field "%s" can be compared to timestamps in ' 250 | '"YYYY-MM-DD HH:MM" format, but not to %s' % ( 251 | self.name, 252 | repr(value), 253 | ), 254 | ) 255 | 256 | def get_lookup_value(self, value): 257 | if not value: 258 | return None 259 | mask = '%Y-%m-%d' 260 | if len(value) > 10: 261 | mask += ' %H:%M' 262 | if len(value) > 16: 263 | mask += ':%S' 264 | dt = datetime.strptime(value, mask) 265 | if settings.USE_TZ: 266 | dt = dt.replace(tzinfo=get_current_timezone()) 267 | return dt 268 | 269 | def get_lookup(self, path, operator, value): 270 | search = '__'.join(path + [self.get_lookup_name()]) 271 | op, invert = self.get_operator(operator) 272 | 273 | # Add LIKE operator support for datetime fields. For LIKE comparisons 274 | # we don't want to convert source value to datetime instance, because 275 | # it would effectively kill the idea. What we want is expressions like 276 | # 'created ~ "2017-01-30' 277 | # to be translated to 278 | # 'created LIKE %2017-01-30%', 279 | # but it would work only if we pass a string as a parameter. If we pass 280 | # a datetime instance, it would add time part in a form of 00:00:00, 281 | # and resulting comparison would look like 282 | # 'created LIKE %2017-01-30 00:00:00%' 283 | # which is not what we want for this case. 284 | val = value if operator in ('~', '!~') else self.get_lookup_value(value) 285 | 286 | q = models.Q(**{'%s%s' % (search, op): val}) 287 | return ~q if invert else q 288 | 289 | 290 | class RelationField(DjangoQLField): 291 | type = 'relation' 292 | 293 | def __init__(self, model, name, related_model, nullable=False, 294 | suggest_options=False): 295 | super(RelationField, self).__init__( 296 | model=model, 297 | name=name, 298 | nullable=nullable, 299 | suggest_options=suggest_options, 300 | ) 301 | self.related_model = related_model 302 | 303 | @property 304 | def relation(self): 305 | return DjangoQLSchema.model_label(self.related_model) 306 | 307 | 308 | class DjangoQLSchema(object): 309 | include = () # models to include into introspection 310 | exclude = () # models to exclude from introspection 311 | suggest_options = None 312 | 313 | def __init__(self, model): 314 | if not inspect.isclass(model) or not issubclass(model, models.Model): 315 | raise DjangoQLSchemaError( 316 | 'Schema must be initialized with a subclass of Django model', 317 | ) 318 | if self.include and self.exclude: 319 | raise DjangoQLSchemaError( 320 | 'Either include or exclude can be specified, but not both', 321 | ) 322 | if self.excluded(model): 323 | raise DjangoQLSchemaError( 324 | "%s can't be used with %s because it's excluded from it" % ( 325 | model, 326 | self.__class__, 327 | ), 328 | ) 329 | self.current_model = model 330 | self._models = None 331 | if self.suggest_options is None: 332 | self.suggest_options = {} 333 | 334 | def excluded(self, model): 335 | return model in self.exclude or ( 336 | self.include and model not in self.include 337 | ) 338 | 339 | @property 340 | def models(self): 341 | if not self._models: 342 | self._models = self.introspect( 343 | model=self.current_model, 344 | exclude=tuple(self.model_label(m) for m in self.exclude), 345 | ) 346 | return self._models 347 | 348 | @classmethod 349 | def model_label(self, model): 350 | return text_type(model._meta) 351 | 352 | def introspect(self, model, exclude=()): 353 | """ 354 | Start with given model and recursively walk through its relationships. 355 | 356 | Returns a dict with all model labels and their fields found. 357 | """ 358 | result = {} 359 | open_set = deque([model]) 360 | closed_set = set(exclude) 361 | 362 | while open_set: 363 | model = open_set.popleft() 364 | model_label = self.model_label(model) 365 | 366 | if model_label in closed_set: 367 | continue 368 | 369 | model_fields = OrderedDict() 370 | for field in self.get_fields(model): 371 | if not isinstance(field, DjangoQLField): 372 | field = self.get_field_instance(model, field) 373 | if not field: 374 | continue 375 | if isinstance(field, RelationField): 376 | open_set.append(field.related_model) 377 | model_fields[field.name] = field 378 | 379 | result[model_label] = model_fields 380 | closed_set.add(model_label) 381 | 382 | return result 383 | 384 | def get_fields(self, model): 385 | """ 386 | By default, returns all field names of a given model. 387 | 388 | Override this method to limit field options. You can either return a 389 | plain list of field names from it, like ['id', 'name'], or call 390 | .super() and exclude unwanted fields from its result. 391 | """ 392 | return sorted( 393 | [f.name for f in model._meta.get_fields() if f.name != 'password'], 394 | ) 395 | 396 | def get_field_instance(self, model, field_name): 397 | field = model._meta.get_field(field_name) 398 | field_kwargs = {'model': model, 'name': field.name} 399 | if field.is_relation: 400 | if not field.related_model: 401 | # GenericForeignKey 402 | return 403 | if self.excluded(field.related_model): 404 | return 405 | field_cls = RelationField 406 | field_kwargs['related_model'] = field.related_model 407 | else: 408 | field_cls = self.get_field_cls(field) 409 | if isinstance(field, (ManyToOneRel, ManyToManyRel, ForeignObjectRel)): 410 | # Django 1.8 doesn't have .null attribute for these fields 411 | field_kwargs['nullable'] = True 412 | else: 413 | field_kwargs['nullable'] = field.null 414 | field_kwargs['suggest_options'] = ( 415 | field.name in self.suggest_options.get(model, []) 416 | ) 417 | return field_cls(**field_kwargs) 418 | 419 | def get_field_cls(self, field): 420 | str_fields = ( 421 | models.CharField, 422 | models.TextField, 423 | models.UUIDField, 424 | models.BinaryField, 425 | models.GenericIPAddressField, 426 | ) 427 | if isinstance(field, str_fields): 428 | return StrField 429 | elif isinstance(field, (models.AutoField, models.IntegerField)): 430 | return IntField 431 | elif isinstance(field, (models.BooleanField, models.NullBooleanField)): 432 | return BoolField 433 | elif isinstance(field, (models.DecimalField, models.FloatField)): 434 | return FloatField 435 | elif isinstance(field, models.DateTimeField): 436 | return DateTimeField 437 | elif isinstance(field, models.DateField): 438 | return DateField 439 | return DjangoQLField 440 | 441 | def as_dict(self): 442 | from .serializers import DjangoQLSchemaSerializer 443 | warnings.warn( 444 | 'DjangoQLSchema.as_dict() is deprecated and will be removed in ' 445 | 'future releases. Please use DjangoQLSchemaSerializer instead.', 446 | ) 447 | return DjangoQLSchemaSerializer().serialize(self) 448 | 449 | def resolve_name(self, name): 450 | assert isinstance(name, Name) 451 | model = self.model_label(self.current_model) 452 | field = None 453 | for name_part in name.parts: 454 | field = self.models[model].get(name_part) 455 | if not field: 456 | raise DjangoQLSchemaError( 457 | 'Unknown field: %s. Possible choices are: %s' % ( 458 | name_part, 459 | ', '.join(sorted(self.models[model].keys())), 460 | ), 461 | ) 462 | if field.type == 'relation': 463 | model = field.relation 464 | field = None 465 | return field 466 | 467 | def validate(self, node): 468 | """ 469 | Validate DjangoQL AST tree vs. current schema 470 | """ 471 | assert isinstance(node, Node) 472 | if isinstance(node.operator, Logical): 473 | self.validate(node.left) 474 | self.validate(node.right) 475 | return 476 | assert isinstance(node.left, Name) 477 | assert isinstance(node.operator, Comparison) 478 | assert isinstance(node.right, (Const, List)) 479 | 480 | # Check that field and value types are compatible 481 | field = self.resolve_name(node.left) 482 | value = node.right.value 483 | if field is None: 484 | if value is not None: 485 | raise DjangoQLSchemaError( 486 | 'Related model %s can be compared to None only, but not to ' 487 | '%s' % (node.left.value, type(value).__name__), 488 | ) 489 | else: 490 | values = value if isinstance(node.right, List) else [value] 491 | for v in values: 492 | field.validate(v) 493 | -------------------------------------------------------------------------------- /djangoql/serializers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from .schema import RelationField 4 | 5 | 6 | class DjangoQLSchemaSerializer(object): 7 | def serialize(self, schema): 8 | models = {} 9 | for model_label, fields in schema.models.items(): 10 | models[model_label] = OrderedDict( 11 | [(name, self.serialize_field(f)) for name, f in fields.items()], 12 | ) 13 | return { 14 | 'current_model': schema.model_label(schema.current_model), 15 | 'models': models, 16 | } 17 | 18 | def serialize_field(self, field): 19 | result = { 20 | 'type': field.type, 21 | 'nullable': field.nullable, 22 | 'options': self.serialize_field_options(field), 23 | } 24 | if isinstance(field, RelationField): 25 | result['relation'] = field.relation 26 | return result 27 | 28 | def serialize_field_options(self, field): 29 | return list(field.get_options('')) if field.suggest_options else None 30 | 31 | 32 | class SuggestionsAPISerializer(DjangoQLSchemaSerializer): 33 | def __init__(self, suggestions_api_url): 34 | self.suggestions_api_url = suggestions_api_url 35 | 36 | def serialize(self, schema): 37 | result = super(SuggestionsAPISerializer, self).serialize(schema) 38 | result['suggestions_api_url'] = self.suggestions_api_url 39 | return result 40 | 41 | def serialize_field_options(self, field): 42 | if field.async_options: 43 | return field.suggest_options 44 | else: 45 | return field.get_options('') 46 | -------------------------------------------------------------------------------- /djangoql/static/djangoql/css/completion.css: -------------------------------------------------------------------------------- 1 | .djangoql-completion { 2 | position: absolute; 3 | display: none; 4 | border: solid 1px #ccc; 5 | border-radius: 4px; 6 | background: white; 7 | background: var(--body-bg, white); 8 | min-width: 183px; 9 | font-size: 13px; 10 | } 11 | 12 | .djangoql-completion .active { 13 | background-color: #79aec8; 14 | color: white; 15 | } 16 | 17 | .djangoql-completion ul { 18 | padding: 2px 0; 19 | margin: 0; 20 | max-height: 295px; 21 | overflow: auto; 22 | } 23 | 24 | .djangoql-completion li { 25 | list-style: none; 26 | padding: 4px 10px; 27 | cursor: pointer; 28 | } 29 | 30 | .djangoql-completion li:hover { 31 | background-color: #c4e9fa; 32 | color: black; 33 | } 34 | 35 | .djangoql-completion li i { 36 | font-size: 0.9em; 37 | color: #ccc; 38 | float: right; 39 | font-style: normal; 40 | } 41 | 42 | .djangoql-completion .syntax-help { 43 | padding: 4px 10px 6px 10px; 44 | margin: 0; 45 | border-top: solid 1px #ccc; 46 | font-size: inherit; 47 | } 48 | 49 | /* 50 | Pure CSS loading icon. Credit: https://loading.io/css/ 51 | */ 52 | .djangoql-loading { 53 | display: block; 54 | position: relative; 55 | } 56 | 57 | .djangoql-loading:after { 58 | -moz-animation: djangoql-loading 1.2s linear infinite; 59 | -ms-animation: djangoql-loading 1.2s linear infinite; 60 | -webkit-animation: djangoql-loading 1.2s linear infinite; 61 | animation: djangoql-loading 1.2s linear infinite; 62 | border: 2px solid #aaa; 63 | border-color: #aaa transparent #aaa transparent; 64 | border-radius: 50%; 65 | content: " "; 66 | display: block; 67 | height: 12px; 68 | left: 50%; 69 | margin: -8px; 70 | position: absolute; 71 | top: 50%; 72 | width: 12px; 73 | } 74 | 75 | @keyframes djangoql-loading { 76 | 0% { 77 | transform: rotate(0deg); 78 | } 79 | 100% { 80 | transform: rotate(360deg); 81 | } 82 | } 83 | 84 | 85 | /*# sourceMappingURL=completion.css.map*/ -------------------------------------------------------------------------------- /djangoql/static/djangoql/css/completion.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./node_modules/djangoql-completion/dist/completion.css"],"names":[],"mappings":"AAAA;EACE,kBAAkB;EAClB,aAAa;EACb,sBAAsB;EACtB,kBAAkB;EAClB,iBAAiB;EACjB,iCAAiC;EACjC,gBAAgB;EAChB,eAAe;AACjB;;AAEA;EACE,yBAAyB;EACzB,YAAY;AACd;;AAEA;EACE,cAAc;EACd,SAAS;EACT,iBAAiB;EACjB,cAAc;AAChB;;AAEA;EACE,gBAAgB;EAChB,iBAAiB;EACjB,eAAe;AACjB;;AAEA;EACE,yBAAyB;EACzB,YAAY;AACd;;AAEA;EACE,gBAAgB;EAChB,WAAW;EACX,YAAY;EACZ,kBAAkB;AACpB;;AAEA;EACE,0BAA0B;EAC1B,SAAS;EACT,0BAA0B;EAC1B,kBAAkB;AACpB;;AAEA;;CAEC;AACD;EACE,cAAc;EACd,kBAAkB;AACpB;;AAEA;EACE,qDAAqD;EACrD,oDAAoD;EACpD,wDAAwD;EACxD,gDAAgD;EAChD,sBAAsB;EACtB,+CAA+C;EAC/C,kBAAkB;EAClB,YAAY;EACZ,cAAc;EACd,YAAY;EACZ,SAAS;EACT,YAAY;EACZ,kBAAkB;EAClB,QAAQ;EACR,WAAW;AACb;;AAEA;EACE;IACE,uBAAuB;EACzB;EACA;IACE,yBAAyB;EAC3B;AACF","file":"css/completion.css","sourcesContent":[".djangoql-completion {\n position: absolute;\n display: none;\n border: solid 1px #ccc;\n border-radius: 4px;\n background: white;\n background: var(--body-bg, white);\n min-width: 183px;\n font-size: 13px;\n}\n\n.djangoql-completion .active {\n background-color: #79aec8;\n color: white;\n}\n\n.djangoql-completion ul {\n padding: 2px 0;\n margin: 0;\n max-height: 295px;\n overflow: auto;\n}\n\n.djangoql-completion li {\n list-style: none;\n padding: 4px 10px;\n cursor: pointer;\n}\n\n.djangoql-completion li:hover {\n background-color: #c4e9fa;\n color: black;\n}\n\n.djangoql-completion li i {\n font-size: 0.9em;\n color: #ccc;\n float: right;\n font-style: normal;\n}\n\n.djangoql-completion .syntax-help {\n padding: 4px 10px 6px 10px;\n margin: 0;\n border-top: solid 1px #ccc;\n font-size: inherit;\n}\n\n/*\nPure CSS loading icon. Credit: https://loading.io/css/\n*/\n.djangoql-loading {\n display: block;\n position: relative;\n}\n\n.djangoql-loading:after {\n -moz-animation: djangoql-loading 1.2s linear infinite;\n -ms-animation: djangoql-loading 1.2s linear infinite;\n -webkit-animation: djangoql-loading 1.2s linear infinite;\n animation: djangoql-loading 1.2s linear infinite;\n border: 2px solid #aaa;\n border-color: #aaa transparent #aaa transparent;\n border-radius: 50%;\n content: \" \";\n display: block;\n height: 12px;\n left: 50%;\n margin: -8px;\n position: absolute;\n top: 50%;\n width: 12px;\n}\n\n@keyframes djangoql-loading {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /djangoql/static/djangoql/css/completion_admin.css: -------------------------------------------------------------------------------- 1 | #changelist #toolbar form textarea#searchbar { 2 | width: 80%; 3 | } 4 | 5 | .djangoql-toggle { 6 | margin-right: 10px; 7 | } 8 | 9 | .djangoql-help { 10 | display: inline-block; 11 | float: right; 12 | margin-right: 30px; 13 | } 14 | 15 | @media (max-width: 1024px) { 16 | .djangoql-help { 17 | margin-right: 20px; 18 | } 19 | } 20 | 21 | @media (max-width: 767px) { 22 | .djangoql-help { 23 | margin-right: 5px; 24 | } 25 | } 26 | 27 | .clearfix::after { 28 | content: ""; 29 | clear: both; 30 | display: table; 31 | } 32 | -------------------------------------------------------------------------------- /djangoql/static/djangoql/css/syntax_help.css: -------------------------------------------------------------------------------- 1 | code { 2 | background-color: rgba(27, 31, 35, 0.05); 3 | padding: 0.2em; 4 | } 5 | 6 | pre { 7 | background-color: rgba(27, 31, 35, 0.05); 8 | padding: 5px 40px; 9 | margin: 0 0 10px 0; 10 | } 11 | 12 | table { 13 | margin: 10px 0; 14 | } 15 | -------------------------------------------------------------------------------- /djangoql/static/djangoql/img/completion_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/djangoql/static/djangoql/img/completion_example.png -------------------------------------------------------------------------------- /djangoql/static/djangoql/img/completion_example_scaled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/djangoql/static/djangoql/img/completion_example_scaled.png -------------------------------------------------------------------------------- /djangoql/static/djangoql/js/completion_admin.js: -------------------------------------------------------------------------------- 1 | (function (DjangoQL) { 2 | 'use strict'; 3 | 4 | function parseQueryString() { 5 | var qs = window.location.search.substring(1); 6 | var result = {}; 7 | var vars = qs.split('&'); 8 | var i; 9 | var l = vars.length; 10 | var pair; 11 | var key; 12 | for (i = 0; i < l; i++) { 13 | pair = vars[i].split('='); 14 | key = decodeURIComponent(pair[0]); 15 | if (key) { 16 | if (typeof result[key] !== 'undefined') { 17 | if (({}).toString.call(result[key]) !== '[object Array]') { 18 | result[key] = [result[key]]; 19 | } 20 | result[key].push(decodeURIComponent(pair[1])); 21 | } else { 22 | result[key] = decodeURIComponent(pair[1]); 23 | } 24 | } 25 | } 26 | return result; 27 | } 28 | 29 | // Replace standard search input with textarea and add completion toggle 30 | DjangoQL.DOMReady(function () { 31 | // use '-' in the param name to prevent conflicts with any model field name 32 | var QLParamName = 'q-l'; 33 | var QLEnabled; 34 | var QLEnabledInURL; 35 | var QLInput; 36 | var QLToggle; 37 | var QLPlaceholder = 'Advanced search with Query Language'; 38 | var originalPlaceholder; 39 | var textarea; 40 | var input = document.querySelector('input[name=q]'); 41 | var djangoQL; 42 | 43 | if (!input) { 44 | return; 45 | } 46 | originalPlaceholder = input.placeholder; 47 | 48 | QLEnabledInURL = parseQueryString()[QLParamName]; 49 | if (QLEnabledInURL === 'on') { 50 | QLEnabled = true; 51 | } else if (QLEnabledInURL === 'off') { 52 | QLEnabled = false; 53 | } else { 54 | QLEnabled = Boolean(DjangoQL._toggleOnByDefault); 55 | } 56 | 57 | function onCompletionToggle(e) { 58 | if (e.target.checked) { 59 | djangoQL.enableCompletion(); 60 | QLInput.value = 'on'; 61 | textarea.placeholder = QLPlaceholder; 62 | textarea.focus(); 63 | djangoQL.popupCompletion(); 64 | } else { 65 | djangoQL.disableCompletion(); 66 | QLInput.value = 'off'; 67 | textarea.placeholder = originalPlaceholder; 68 | textarea.focus(); 69 | } 70 | } 71 | 72 | if (DjangoQL._enableToggle) { 73 | QLInput = document.querySelector('input[name=' + QLParamName + ']'); 74 | if (!QLInput) { 75 | QLInput = document.createElement('input'); 76 | QLInput.type = 'hidden'; 77 | input.parentNode.insertBefore(QLInput, input); 78 | } 79 | QLInput.name = QLParamName; 80 | QLInput.value = QLEnabled ? 'on' : 'off'; 81 | 82 | QLToggle = document.createElement('input'); 83 | QLToggle.type = 'checkbox'; 84 | QLToggle.checked = QLEnabled; 85 | QLToggle.className = 'djangoql-toggle'; 86 | QLToggle.title = QLPlaceholder; 87 | QLToggle.onchange = onCompletionToggle; 88 | input.parentNode.insertBefore(QLToggle, input); 89 | } else { 90 | QLEnabled = true; 91 | } 92 | 93 | textarea = document.createElement('textarea'); 94 | textarea.value = input.value; 95 | textarea.id = input.id; 96 | textarea.name = input.name; 97 | textarea.rows = 1; 98 | textarea.placeholder = QLEnabled ? QLPlaceholder : originalPlaceholder; 99 | textarea.setAttribute('maxlength', 2000); 100 | input.parentNode.insertBefore(textarea, input); 101 | 102 | input.parentNode.removeChild(input); 103 | textarea.focus(); 104 | 105 | djangoQL = new DjangoQL({ 106 | completionEnabled: QLEnabled, 107 | introspections: 'introspect/', 108 | syntaxHelp: 'djangoql-syntax/', 109 | selector: 'textarea[name=q]', 110 | autoResize: true 111 | }); 112 | }); 113 | }(window.DjangoQL)); 114 | -------------------------------------------------------------------------------- /djangoql/static/djangoql/js/completion_admin_toggle.js: -------------------------------------------------------------------------------- 1 | (function (DjangoQL) { 2 | 'use strict'; 3 | 4 | DjangoQL._enableToggle = true; 5 | DjangoQL._toggleOnByDefault = true; 6 | }(window.DjangoQL)); 7 | -------------------------------------------------------------------------------- /djangoql/static/djangoql/js/completion_admin_toggle_off.js: -------------------------------------------------------------------------------- 1 | (function (DjangoQL) { 2 | 'use strict'; 3 | 4 | DjangoQL._toggleOnByDefault = false; 5 | }(window.DjangoQL)); 6 | -------------------------------------------------------------------------------- /djangoql/templates/djangoql/error_message.html: -------------------------------------------------------------------------------- 1 |
2 | {{ error_message }} 3 | 4 | DjangoQL syntax help 5 | 6 |
7 | -------------------------------------------------------------------------------- /djangoql/templates/djangoql/syntax_help.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load static %} 3 | 4 | {% block title %}DjangoQL search syntax{% endblock %} 5 | 6 | {% block extrahead %} 7 | 8 | {% endblock %} 9 | 10 | {% block coltype %}colSM{% endblock %} 11 | 12 | {% block content_title %}

DjangoQL search syntax

{% endblock %} 13 | 14 | {% block sidebar %} 15 | 30 | {% endblock %} 31 | 32 | {% block content %} 33 |
34 | 35 | {% block search_conditions %} 36 |
37 |

Search conditions

38 |

39 | A search condition is a basic search query building block. It always 40 | consists of 3 elements: field, 41 | comparison operator and value, placed exactly 42 | in this order from left to right. 43 |

44 | {% block search_condition_examples %} 45 |

46 | Here's an example - looking for users with first name "John". In the 47 | example below first_name is a field, 48 | = is a comparison operator and 49 | "John" is a value: 50 |

51 |
first_name = "John"
52 |

53 | Another example, looking for users who registered in 2017 or later: 54 |

55 |
date_joined >= "2017-01-01"
56 |

One more example, looking for super-users:

57 |
is_superuser = True
58 |

And one more - finding all users whose names are in a given list:

59 |
first_name in ("John", "Jack", "Jason")
60 | {% endblock %} 61 |
62 | {% endblock %} 63 | 64 | {% block multiple_search_conditions %} 65 |
66 |

Multiple search conditions

67 | 68 |

69 | You can combine multiple search conditions together using the logical 70 | operators and (both conditions must be true) and 71 | or (at least one of the conditions must be true, no matter 72 | which one). Important - logical operators must be written in lowercase: 73 | and and or is correct, and AND or 74 | OR is incorrect and will cause an error. 75 |

76 | 77 | {% block multiple_search_condition_examples %} 78 |

79 | Example: looking for users with first name "John" and 80 | registered in 2017 or later. Please note that we have 2 search 81 | conditions here, joined with and: 82 |

83 |
first_name = "John" and date_joined >= "2017-01-01"
84 |

85 | One more example, looking for users who are either super-users 86 | or marked with "Staff" flag: 87 |

88 |
is_superuser = True or is_staff = True
89 |

90 | Logical operators can be quite powerful, as they let you to build 91 | complex search queries. If you're building a complex query there's an 92 | important tip to keep in mind: if your query contains both 93 | and and or operators, we strongly encourage 94 | you to use parenthesis to specify the precedence of operators. Here's 95 | an example to illustrate why this is important. Let's assume that you 96 | want to pull users who are either super-users or marked 97 | with Staff flag, and registered in 2017 or later. It 98 | might be tempting to write a query like this: 99 |

100 |
is_superuser = True or is_staff = True and date_joined > "2017-01-01"
101 |

102 | The problem with the query above is that it won't do what you expect, 103 | because the and operator is evaluated first. In fact it pulls 104 | users who are either super-users (no matter when they registered) 105 | or users who are both Staff and registered 106 | after 2017. This problem can be fixed with parentheses, just put them 107 | around the search conditions that must be evaluated first, like this: 108 |

109 |
(is_superuser = True or is_staff = True) and date_joined > "2017-01-01"
110 |

111 | Using parenthesis is recommended only when your query mixes both 112 | and and or operators. If your query contains 113 | multiple logical operators of only one kind (either and 114 | or or) you can safely omit parenthesis and it will work 115 | as expected. 116 |

117 | {% endblock %} 118 |
119 | {% endblock %} 120 | 121 | {% block fields %} 122 |
123 |

Fields

124 | 125 |

126 | In a search query, you should reference the current model's fields 127 | exactly as they're defined in Python code for that particular Django 128 | model. Search query input has an auto-completion feature that pops up 129 | automatically and suggests all available options. If you're not sure 130 | what the field name is, then pick one of the options displayed 131 | (example): 132 |

133 | 134 | {% block field_completion_example %} 135 | DjangoQL completion example 139 | {% endblock %} 140 | 141 | {% block field_naming %} 142 |

143 | In most cases, internal Django model fields look similar to what you see 144 | in Django admin interface, just in lowercase and with _ 145 | instead of spaces. For example, in the standard Users admin interface, 146 | the internal first_name field is displayed as 147 | First name, email field is displayed as 148 | Email address and so on. However there could be exceptions 149 | to this, if developers have defined custom display names that look 150 | very different from their internal representation. In such cases it 151 | might be a good idea to ask developers to override this help template 152 | and provide an "internal name -> display name" fields mapping right 153 | here. 154 |

155 | {% endblock %} 156 | 157 |

158 | Note that some fields that you see in Django admin may not be 159 | searchable. This includes computed fields, i.e. fields which are not 160 | stored in the database as a plain value, but rather calculated from 161 | other values in the code. 162 |

163 |
164 | {% endblock %} 165 | 166 | {% block related_models %} 167 |
168 | 169 | 170 |

171 | DjangoQL allows you to search by related models as well (it 172 | automatically converts relations to SQL joins under the hood). Use the 173 | . dot separator to designate related models and their 174 | fields. For example: 175 |

176 | 177 |
groups.name in ("Marketing", "Support")
178 | 179 |

180 | See the . in the example above? It means that 181 | groups is a related model and name is a field 182 | of that model. As usual, DjangoQL auto-completion provides suggestions 183 | for all available related models and their fields. For complex data 184 | structures you can use multiple levels of relation, i.e. specifying a 185 | related model, then its related model, and so on. 186 |

187 | 188 |

189 | In most cases the search condition with a related model must specify the 190 | exact field of that model, but not a related model itself. For example, 191 | groups in ("Marketing", "Support") won't work, because 192 | groups is a model and not a field. Models can have many 193 | fields, and the server doesn't know against which field you would like 194 | to perform a comparison. However there's one notable exception to 195 | this - when you'd like to find records that are linked (or not linked) 196 | to any related models of that kind. In such a case, you should compare 197 | the related model to a special None value, like this: 198 |

199 | 200 |
groups = None
201 | 202 |

203 | The example above would search for users that don't belong to any 204 | groups. If you'd like to find all users that belong to at least any 205 | group instead, use != None: 206 |

207 | 208 |
groups != None
209 |
210 | {% endblock %} 211 | 212 | {% block comparison_operators %} 213 |
214 |

Comparison operators

215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 |
OperatorMeaningExample
=equalsfirst_name = "John"
!=does not equalid != 42
~contains a substringemail ~ "@gmail.com"
!~does not contain a substringusername !~ "test"
startswithstarts with a substringlast_name startswith "do"
not startswithdoes not start with a substringlast_name not startswith "do"
endswithends with a substringlast_name endswith "oe"
not endswithdoes not end with a substringlast_name not endswith "oe"
>greaterdate_joined > "2017-02-28"
>=greater or equalid >= 9000
<lessid < 9000
<=less or equallast_login <= "2017-02-28 14:53"
invalue is in the listfirst_name in ("John", "Jack", "Jason")
not invalue is not in the listid not in (42, 9000)
297 | 298 |

Notes:

299 |
    300 |
  1. 301 | ~ and !~ operators can be applied only to 302 | string and date/datetime fields. A date/datetime field will be handled 303 | as a string one (ex., payment_date ~ "2020-12-01") 304 |
  2. 305 |
  3. 306 | startswith, not startswith, 307 | endswith, and not endswith can be applied 308 | to string fields only; 309 |
  4. 310 |
  5. 311 | True, False and None values can 312 | be combined only with = and !=; 313 |
  6. 314 |
  7. 315 | in and not in operators must be written in 316 | lowercase. IN or NOT IN is incorrect and 317 | will cause an error. 318 |
  8. 319 |
320 |
321 | {% endblock %} 322 | 323 | {% block values %} 324 |
325 |

Values

326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 345 | 346 | 347 | 348 | 349 | 354 | 355 | 356 | 357 | 360 | 366 | 367 | 368 | 369 | 372 | 379 | 380 | 381 | 382 | 385 | 389 | 390 | 391 | 392 | 396 | 403 | 404 | 405 | 406 | 409 | 419 | 420 | 421 |
TypeExamplesComments
string"this is a string" 340 | Strings must be enclosed in double quotes, like 341 | "this". If your string contains double quote 342 | symbols in it, you should escape them with a backslash, 343 | like this: "this is a string with \"quoted\" text". 344 |
int42, 0, -9000 350 | Integer numbers are just digits with optional unary minus. If 351 | you're typing big numbers please don't use thousand separators, 352 | DjangoQL doesn't understand them. 353 |
float 358 | 3.14, -0.5, 5.972e24 359 | 361 | Floating point numbers look like integer numbers with optional 362 | fractional part separated with dot. You can also use 363 | e notation to specify power of ten. For example, 364 | 5.972e24 means 5.972 * 1024. 365 |
bool 370 | True, False 371 | 373 | Boolean is a special type that accepts only two values: 374 | True or False. These values are 375 | case-sensitive, you should write True or 376 | False exactly like this, with the first letter in 377 | uppercase and others in lowercase, without quotes. 378 |
date 383 | "2017-02-28" 384 | 386 | Dates are represented as strings in "YYYY-MM-DD" 387 | format. 388 |
datetime 393 | "2017-02-28 14:53"
394 | "2017-02-28 14:53:07" 395 |
397 | Date and time can be represented as a string in 398 | "YYYY-MM-DD HH:MM" format, or optionally with seconds 399 | in "YYYY-MM-DD HH:MM:SS" format (24-hour clock). 400 | Please note that comparisons with date and time are performed in 401 | the server's timezone, which is usually UTC. 402 |
null 407 | None 408 | 410 | This is a special value that represents an absence of any value: 411 | None. It should be written exactly like this, with 412 | the first letter in uppercase and others in lowercase, without 413 | quotes. Use it when some field in the database is 414 | nullable (i.e. can contain NULL in SQL terms) and you'd like to 415 | search for records which either have no value 416 | (some_field = None) or have some value 417 | (some_field != None). 418 |
422 |
423 | {% endblock %} 424 | 425 |
426 | 427 | {% endblock %} 428 | -------------------------------------------------------------------------------- /djangoql/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.paginator import EmptyPage, Paginator 4 | from django.http import HttpResponse 5 | from django.views.generic.base import View 6 | 7 | 8 | class SuggestionsAPIView(View): 9 | http_method_names = ['get'] 10 | schema = None 11 | items_per_page = 100 12 | 13 | def get(self, request, *args, **kwargs): 14 | search = request.GET.get('search', '') 15 | 16 | try: 17 | field_name = request.GET.get('field', '') 18 | field = self.get_field(field_name) 19 | page_number = int(request.GET.get('page', 1)) 20 | if page_number < 1: 21 | raise ValueError('page must be an integer starting from 1') 22 | suggestions = self.get_suggestions(field=field, search=search) 23 | except ValueError as e: 24 | error = str(e) or e.__class__.__name__ 25 | return HttpResponse( 26 | content=json.dumps({'error': error}, indent=2), 27 | content_type='application/json; charset=utf-8', 28 | status=400, 29 | ) 30 | 31 | paginator = Paginator(suggestions, self.items_per_page) 32 | try: 33 | page = paginator.page(page_number) 34 | except EmptyPage: 35 | items = [] 36 | has_next = False 37 | else: 38 | items = list(page.object_list) 39 | has_next = page.has_next() 40 | 41 | response = { 42 | 'items': items, 43 | 'page': page_number, 44 | 'has_next': has_next, 45 | } 46 | return HttpResponse( 47 | content=json.dumps(response, indent=2), 48 | content_type='application/json; charset=utf-8', 49 | ) 50 | 51 | def get_field(self, field_name): 52 | if not self.schema: 53 | raise ValueError('DjangoQL schema is undefined') 54 | if not field_name: 55 | raise ValueError('"field" parameter is required') 56 | parts = field_name.split('.') 57 | field_name = parts.pop() 58 | if parts: 59 | model_name = parts[-1] 60 | app_label = '.'.join(parts[:-1]) 61 | if not app_label: 62 | app_label = self.schema.current_model._meta.app_label 63 | model_label = '.'.join([app_label, model_name]) 64 | else: 65 | model_label = self.schema.model_label(self.schema.current_model) 66 | schema_model = self.schema.models.get(model_label) 67 | if not schema_model: 68 | raise ValueError('Unknown model: %s' % model_label) 69 | field_instance = schema_model.get(field_name) 70 | if not field_instance: 71 | raise ValueError('Unknown field: %s' % field_name) 72 | return field_instance 73 | 74 | def get_suggestions(self, field, search): 75 | if not field.suggest_options: 76 | raise ValueError("%s.%s doesn't support suggestions" % ( 77 | field.model._meta.object_name, 78 | field.name, 79 | )) 80 | return field.get_options(search) 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "webpack --mode production" 5 | }, 6 | "dependencies": { 7 | "djangoql-completion": "0.5.0" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "7.14.3", 11 | "@babel/preset-env": "7.14.2", 12 | "babel-loader": "8.2.2", 13 | "css-loader": "5.2.6", 14 | "mini-css-extract-plugin": "1.6.0", 15 | "webpack": "5.37.0", 16 | "webpack-cli": "4.7.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8==3.9.2 2 | flake8-builtins==1.5.3 3 | flake8-commas==2.0.0 4 | flake8-print==4.0.0 5 | flake8-quotes==3.2.0 6 | isort==5.8.0 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length = 80 6 | exclude = .git,.github,build,dist,djangoql/parsetab.py,*/migrations/*,test_project/manage.py 7 | ignore = A003,C815,W504 8 | 9 | [isort] 10 | known_django = django 11 | known_first_party = core,test_project 12 | line_length = 80 13 | lines_after_imports = 2 14 | multi_line_output = 2 15 | sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 16 | default_section = THIRDPARTY 17 | skip_glob = */migrations/* 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | import djangoql 6 | 7 | 8 | packages = ['djangoql'] 9 | requires = ['ply>=3.8'] 10 | 11 | setup( 12 | name='djangoql', 13 | version=djangoql.__version__, 14 | description='DjangoQL: Advanced search language for Django', 15 | long_description=open('README.rst').read(), 16 | long_description_content_type='text/x-rst', 17 | author='Denis Stebunov', 18 | author_email='support@ivelum.com', 19 | url='https://github.com/ivelum/djangoql/', 20 | packages=packages, 21 | include_package_data=True, 22 | install_requires=requires, 23 | license=open('LICENSE').readline().strip(), 24 | zip_safe=False, 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'Natural Language :: English', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Programming Language :: Python :: 3.6', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | 'Programming Language :: Python :: 3.11', 39 | 'Programming Language :: Python :: 3.12', 40 | 'Programming Language :: Python :: 3.13', 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /test_project/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/test_project/core/__init__.py -------------------------------------------------------------------------------- /test_project/core/admin.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth.admin import UserAdmin 5 | from django.contrib.auth.models import Group, User 6 | from django.db.models import Count, Q 7 | from django.utils.timezone import now 8 | 9 | from djangoql.admin import DjangoQLSearchMixin 10 | from djangoql.schema import DjangoQLSchema, IntField, StrField 11 | 12 | from .models import Book 13 | 14 | 15 | admin.site.unregister(User) 16 | 17 | 18 | class ZaibatsuAdminSite(admin.AdminSite): 19 | site_header = 'Zaibatsu Admin' 20 | site_title = 'Zaibatsu Admin Portal' 21 | index_title = 'Welcome to Zaibatsu Admin Portal' 22 | 23 | 24 | zaibatsu_admin_site = ZaibatsuAdminSite(name='zaibatsu') 25 | 26 | 27 | class AuthorField(StrField): 28 | name = 'author' 29 | model = Book 30 | suggest_options = True 31 | 32 | def get_lookup_name(self): 33 | return 'author__username' 34 | 35 | def get_options(self, search): 36 | return Book.objects\ 37 | .filter(author__username__icontains=search)\ 38 | .values_list('author__username', flat=True)\ 39 | .order_by('author__username')\ 40 | .distinct() 41 | 42 | 43 | class BookQLSchema(DjangoQLSchema): 44 | suggest_options = { 45 | Book: ['genre'], 46 | } 47 | 48 | def get_fields(self, model): 49 | fields = super(BookQLSchema, self).get_fields(model) 50 | if model == Book: 51 | fields += [AuthorField()] 52 | return fields 53 | 54 | 55 | @admin.register(Book) 56 | class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin): 57 | djangoql_schema = BookQLSchema 58 | list_display = ('name', 'author', 'genre', 'written', 'is_published') 59 | list_filter = ('is_published',) 60 | filter_horizontal = ('similar_books',) 61 | 62 | 63 | class UserAgeField(IntField): 64 | """ 65 | Search by given number of full years 66 | """ 67 | model = User 68 | name = 'age' 69 | 70 | def get_lookup_name(self): 71 | """ 72 | We'll be doing comparisons vs. this model field 73 | """ 74 | return 'date_joined' 75 | 76 | def get_lookup(self, path, operator, value): 77 | if operator == 'in': 78 | result = None 79 | for year in value: 80 | condition = self.get_lookup(path, '=', year) 81 | result = condition if result is None else result | condition 82 | return result 83 | elif operator == 'not in': 84 | result = None 85 | for year in value: 86 | condition = self.get_lookup(path, '!=', year) 87 | result = condition if result is None else result & condition 88 | return result 89 | 90 | value = self.get_lookup_value(value) 91 | search_field = '__'.join(path + [self.get_lookup_name()]) 92 | year_start = self.years_ago(value + 1) 93 | year_end = self.years_ago(value) 94 | if operator == '=': 95 | return ( 96 | Q(**{'%s__gt' % search_field: year_start}) & 97 | Q(**{'%s__lte' % search_field: year_end}) 98 | ) 99 | elif operator == '!=': 100 | return ( 101 | Q(**{'%s__lte' % search_field: year_start}) | 102 | Q(**{'%s__gt' % search_field: year_end}) 103 | ) 104 | elif operator == '>': 105 | return Q(**{'%s__lt' % search_field: year_start}) 106 | elif operator == '>=': 107 | return Q(**{'%s__lt' % search_field: year_end}) 108 | elif operator == '<': 109 | return Q(**{'%s__gt' % search_field: year_end}) 110 | elif operator == '<=': 111 | return Q(**{'%s__gte' % search_field: year_start}) 112 | 113 | def years_ago(self, n): 114 | timestamp = now() 115 | try: 116 | return timestamp.replace(year=timestamp.year - n) 117 | except ValueError: 118 | # February 29 119 | return timestamp.replace(month=2, day=28, year=timestamp.year - n) 120 | 121 | 122 | class BookGenreField(StrField): 123 | model = Book 124 | name = 'name' 125 | suggest_options = True 126 | 127 | def get_options(self, search): 128 | time.sleep(1) 129 | return Book.objects.filter( 130 | name__icontains=search, 131 | ).values_list('name', flat=True) 132 | 133 | 134 | class UserQLSchema(DjangoQLSchema): 135 | suggest_options = { 136 | Book: ['genre'], 137 | Group: ['name'], 138 | } 139 | 140 | def get_fields(self, model): 141 | fields = super(UserQLSchema, self).get_fields(model) 142 | if model == User: 143 | fields += [UserAgeField(), IntField(name='groups_count')] 144 | elif model == Book: 145 | fields += [BookGenreField()] 146 | return fields 147 | 148 | 149 | class CustomUserAdmin(DjangoQLSearchMixin, UserAdmin): 150 | djangoql_schema = UserQLSchema 151 | search_fields = ('username', 'first_name', 'last_name') 152 | 153 | list_display = ('username', 'first_name', 'last_name', 'is_staff', 'group') 154 | 155 | def group(self, obj): 156 | return ', '.join([g.name for g in obj.groups.all()]) 157 | group.short_description = 'Groups' 158 | 159 | def get_queryset(self, request): 160 | qs = super(CustomUserAdmin, self).get_queryset(request) 161 | return qs.\ 162 | annotate(groups_count=Count('groups')).\ 163 | prefetch_related('groups') 164 | 165 | 166 | admin.site.register(User, CustomUserAdmin) 167 | zaibatsu_admin_site.register(User, CustomUserAdmin) 168 | -------------------------------------------------------------------------------- /test_project/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-31 20:46 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ('contenttypes', '0002_remove_content_type_name'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Book', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.CharField(max_length=10)), 26 | ('written', models.DateTimeField(default=django.utils.timezone.now)), 27 | ('is_published', models.BooleanField(default=False)), 28 | ('rating', models.FloatField(null=True)), 29 | ('price', models.DecimalField(decimal_places=2, max_digits=7, null=True)), 30 | ('object_id', models.PositiveIntegerField(null=True)), 31 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 32 | ('content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /test_project/core/migrations/0002_book_genre.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-06-03 08:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('core', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='book', 17 | name='genre', 18 | field=models.PositiveIntegerField(choices=[(1, 'Drama'), (2, 'Comics'), (3, 'Other')], null=True, blank=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /test_project/core/migrations/0003_book_similar_books.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-11-11 19:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('core', '0002_book_genre'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='book', 15 | name='similar_books', 16 | field=models.ManyToManyField(to='core.Book', blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /test_project/core/migrations/0004_book_similar_books_related_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-11-20 20:58 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('core', '0003_book_similar_books'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='book', 16 | name='content_type', 17 | field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), 18 | ), 19 | migrations.AlterField( 20 | model_name='book', 21 | name='object_id', 22 | field=models.PositiveIntegerField(editable=False, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='book', 26 | name='similar_books', 27 | field=models.ManyToManyField(blank=True, related_name='_book_similar_books_+', to='core.Book'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /test_project/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/test_project/core/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/core/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.utils.timezone import now 7 | 8 | from djangoql.queryset import DjangoQLQuerySet 9 | 10 | 11 | class Book(models.Model): 12 | GENRES = { 13 | 1: 'Drama', 14 | 2: 'Comics', 15 | 3: 'Other', 16 | } 17 | 18 | name = models.CharField(max_length=10) # lol, we're minimalists 19 | author = models.ForeignKey('auth.User', on_delete=models.CASCADE) 20 | genre = models.PositiveIntegerField( 21 | null=True, 22 | blank=True, 23 | choices=GENRES.items(), 24 | ) 25 | written = models.DateTimeField(default=now) 26 | is_published = models.BooleanField(default=False) 27 | rating = models.FloatField(null=True) 28 | price = models.DecimalField(max_digits=7, decimal_places=2, null=True) 29 | content_type = models.ForeignKey( 30 | ContentType, 31 | null=True, 32 | on_delete=models.CASCADE, 33 | editable=False, 34 | ) 35 | object_id = models.PositiveIntegerField(null=True, editable=False) 36 | content_object = GenericForeignKey('content_type', 'object_id') 37 | 38 | similar_books = models.ManyToManyField('Book', blank=True, related_name='+') 39 | 40 | objects = DjangoQLQuerySet.as_manager() 41 | 42 | def __str__(self): 43 | return self.name 44 | -------------------------------------------------------------------------------- /test_project/core/templates/completion_demo.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | DjangoQL completion demo 7 | 8 | 9 | 10 | 11 | 12 |
13 |

{{ error }}

14 | 15 |
16 | 17 |
    18 | {% for item in search_results %} 19 |
  • {{ item }}
  • 20 | {% endfor %} 21 |
22 | 23 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /test_project/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/test_project/core/tests/__init__.py -------------------------------------------------------------------------------- /test_project/core/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | 6 | 7 | try: 8 | from django.core.urlresolvers import reverse 9 | except ImportError: # Django 2.0 10 | from django.urls import reverse 11 | 12 | 13 | class DjangoQLAdminTest(TestCase): 14 | def setUp(self): 15 | self.credentials = {'username': 'test', 'password': 'lol'} 16 | User.objects.create_superuser(email='herp@derp.rr', **self.credentials) 17 | 18 | def get_json(self, url, status=200, **kwargs): 19 | response = self.client.get(url, **kwargs) 20 | self.assertEqual(status, response.status_code) 21 | try: 22 | return json.loads(response.content.decode('utf8')) 23 | except ValueError: 24 | self.fail('Not a valid json') 25 | 26 | def test_introspections(self): 27 | url = reverse('admin:core_book_djangoql_introspect') 28 | # unauthorized request should be redirected 29 | response = self.client.get(url) 30 | self.assertEqual(302, response.status_code) 31 | self.assertTrue(self.client.login(**self.credentials)) 32 | # authorized request should be served 33 | introspections = self.get_json(url) 34 | self.assertEqual('core.book', introspections['current_model']) 35 | for model in ('core.book', 'auth.user', 'auth.group'): 36 | self.assertIn(model, introspections['models']) 37 | 38 | def test_introspection_suggestion_api_url(self): 39 | self.assertTrue(self.client.login(**self.credentials)) 40 | for app in ['admin', 'zaibatsu']: 41 | url = reverse('%s:auth_user_djangoql_introspect' % app) 42 | introspections = self.get_json(url) 43 | self.assertEqual( 44 | reverse('%s:auth_user_djangoql_suggestions' % app), 45 | introspections['suggestions_api_url'], 46 | ) 47 | 48 | def test_djangoql_syntax_help(self): 49 | url = reverse('admin:djangoql_syntax_help') 50 | # unauthorized request should be redirected 51 | response = self.client.get(url) 52 | self.assertEqual(302, response.status_code) 53 | self.assertTrue(self.client.login(**self.credentials)) 54 | # authorized request should be served 55 | response = self.client.get(url) 56 | self.assertEqual(200, response.status_code) 57 | 58 | def test_suggestions(self): 59 | url = reverse('admin:core_book_djangoql_suggestions') 60 | # unauthorized request should be redirected 61 | response = self.client.get(url) 62 | self.assertEqual(302, response.status_code) 63 | # authorize for the next checks 64 | self.assertTrue(self.client.login(**self.credentials)) 65 | 66 | # field parameter is mandatory 67 | r = self.get_json(url, status=400) 68 | self.assertEqual(r.get('error'), '"field" parameter is required') 69 | 70 | # check for unknown fields 71 | r = self.get_json(url, status=400, data={'field': 'gav'}) 72 | self.assertEqual(r.get('error'), 'Unknown field: gav') 73 | r = self.get_json(url, status=400, data={'field': 'x.y'}) 74 | self.assertEqual(r.get('error'), 'Unknown model: core.x') 75 | r = self.get_json(url, status=400, data={'field': 'auth.user.lol'}) 76 | self.assertEqual(r.get('error'), 'Unknown field: lol') 77 | 78 | # field with choices 79 | r = self.get_json(url, data={'field': 'genre'}) 80 | self.assertEqual(r, { 81 | 'page': 1, 82 | 'has_next': False, 83 | 'items': ['Drama', 'Comics', 'Other'], 84 | }) 85 | 86 | # test that search is working 87 | r = self.get_json(url, data={'field': 'genre', 'search': 'o'}) 88 | self.assertEqual(r, { 89 | 'page': 1, 90 | 'has_next': False, 91 | 'items': ['Comics', 'Other'], 92 | }) 93 | 94 | # ensure that page parameter is checked correctly 95 | r = self.get_json(url, status=400, data={'field': 'genre', 'page': 'x'}) 96 | self.assertEqual( 97 | r.get('error'), 98 | "invalid literal for int() with base 10: 'x'", 99 | ) 100 | r = self.get_json(url, status=400, data={'field': 'genre', 'page': '0'}) 101 | self.assertEqual( 102 | r.get('error'), 103 | 'page must be an integer starting from 1', 104 | ) 105 | 106 | # check that paging after results end works correctly 107 | r = self.get_json(url, data={'field': 'genre', 'page': 2}) 108 | self.assertEqual(r, { 109 | 'page': 2, 110 | 'has_next': False, 111 | 'items': [], 112 | }) 113 | 114 | def test_query(self): 115 | url = reverse('admin:core_book_changelist') + '?q=price=0' 116 | self.assertTrue(self.client.login(**self.credentials)) 117 | response = self.client.get(url) 118 | # There should be no error at least 119 | self.assertEqual(200, response.status_code) 120 | -------------------------------------------------------------------------------- /test_project/core/tests/test_ast.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from djangoql.ast import Comparison, Const, Expression, Name 4 | 5 | 6 | class DjangoQLASTTest(TestCase): 7 | def test_equality(self): 8 | self.assertEqual( 9 | Expression(Name('age'), Comparison('='), Const(18)), 10 | Expression(Name('age'), Comparison('='), Const(18)), 11 | ) 12 | self.assertNotEqual( 13 | Expression(Name('age'), Comparison('='), Const(42)), 14 | Expression(Name('age'), Comparison('='), Const(18)), 15 | ) 16 | -------------------------------------------------------------------------------- /test_project/core/tests/test_lexer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from djangoql.exceptions import DjangoQLLexerError 4 | from djangoql.lexer import DjangoQLLexer 5 | 6 | 7 | class DjangoQLLexerTest(TestCase): 8 | lexer = DjangoQLLexer() 9 | 10 | def assert_output(self, lexer, expected): 11 | actual = list(lexer) 12 | len_actual = len(actual) 13 | len_expected = len(expected) 14 | self.assertEqual( 15 | len_actual, 16 | len_expected, 17 | 'Actual output length %s does not match expected length %s\n' 18 | 'Actual: %s\n' 19 | 'Expected: %s' % (len_actual, len_expected, actual, expected), 20 | ) 21 | for i, token in enumerate(actual): 22 | self.assertEqual(token.type, expected[i][0]) 23 | self.assertEqual(token.value, expected[i][1]) 24 | 25 | def test_punctuator(self): 26 | self.assert_output(self.lexer.input('('), [('PAREN_L', '(')]) 27 | self.assert_output(self.lexer.input(')'), [('PAREN_R', ')')]) 28 | self.assert_output(self.lexer.input(','), [('COMMA', ',')]) 29 | self.assert_output(self.lexer.input('='), [('EQUALS', '=')]) 30 | self.assert_output(self.lexer.input('!='), [('NOT_EQUALS', '!=')]) 31 | self.assert_output(self.lexer.input('>'), [('GREATER', '>')]) 32 | self.assert_output(self.lexer.input('>='), [('GREATER_EQUAL', '>=')]) 33 | self.assert_output(self.lexer.input('<'), [('LESS', '<')]) 34 | self.assert_output(self.lexer.input('<='), [('LESS_EQUAL', '<=')]) 35 | self.assert_output(self.lexer.input('~'), [('CONTAINS', '~')]) 36 | self.assert_output(self.lexer.input('!~'), [('NOT_CONTAINS', '!~')]) 37 | 38 | def test_name(self): 39 | for name in ('a', 'myVar_42', '__LOL__', '_', '_0'): 40 | self.assert_output(self.lexer.input(name), [('NAME', name)]) 41 | 42 | def test_entity_props(self): 43 | self.assert_output(self.lexer.input('a.b.c'), [('NAME', 'a.b.c')]) 44 | try: 45 | list(self.lexer.input('user . group . id')) 46 | self.fail('Whitespace around dots must raise an exception') 47 | except DjangoQLLexerError: 48 | pass 49 | try: 50 | list(self.lexer.input('user..id')) 51 | self.fail('Two dots in a row must raise an exception') 52 | except DjangoQLLexerError: 53 | pass 54 | 55 | def test_reserved_words(self): 56 | reserved = ('True', 'False', 'None', 'or', 'and', 'in', 'not', 57 | 'startswith', 'endswith') 58 | for word in reserved: 59 | self.assert_output(self.lexer.input(word), [(word.upper(), word)]) 60 | # A word made of reserved words should be treated as a name 61 | for word in ('True_story', 'not_None', 'inspect', 'startswith_in'): 62 | self.assert_output(self.lexer.input(word), [('NAME', word)]) 63 | 64 | def test_int(self): 65 | for val in ('0', '-0', '42', '-42'): 66 | self.assert_output(self.lexer.input(val), [('INT_VALUE', val)]) 67 | 68 | def test_float(self): 69 | for val in ('-0.5e+42', '42.0', '2E64', '2.71e-0002'): 70 | self.assert_output(self.lexer.input(val), [('FLOAT_VALUE', val)]) 71 | 72 | def test_string(self): 73 | for s in ('""', u'""', '"42"', r'"\t\n\u0042 ^"'): 74 | self.assert_output( 75 | self.lexer.input(s), 76 | [('STRING_VALUE', s.strip('"'))], 77 | ) 78 | 79 | def test_illegal_chars(self): 80 | for s in ('"', '^'): 81 | try: 82 | list(self.lexer.input(s)) 83 | self.fail('Illegal char exception not raised for %s' % repr(s)) 84 | except DjangoQLLexerError as e: 85 | self.assertEqual(1, e.line) 86 | self.assertEqual(1, e.column) 87 | self.assertTrue( 88 | str(e).startswith('Line 1, col 1: Illegal character'), 89 | ) 90 | self.assertEqual(s, e.value) 91 | 92 | def test_positional_info(self): 93 | for i, t in enumerate(self.lexer.input('1\n 3\n 5\n')): 94 | self.assertEqual(i + 1, t.lineno) 95 | self.assertEqual(i * 2 + 1, self.lexer.find_column(t)) 96 | -------------------------------------------------------------------------------- /test_project/core/tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest.util 3 | from unittest import TestCase 4 | 5 | from djangoql.ast import Comparison, Const, Expression, List, Logical, Name 6 | from djangoql.exceptions import DjangoQLParserError 7 | from djangoql.parser import DjangoQLParser 8 | 9 | 10 | # Show full contents in assertions when comparing long text strings 11 | unittest.util._MAX_LENGTH = 2000 12 | 13 | 14 | class DjangoQLParseTest(TestCase): 15 | parser = DjangoQLParser() 16 | 17 | def test_comparisons(self): 18 | self.assertEqual( 19 | Expression(Name('age'), Comparison('>='), Const(18)), 20 | self.parser.parse('age >= 18'), 21 | ) 22 | self.assertEqual( 23 | Expression(Name('gender'), Comparison('='), Const('female')), 24 | self.parser.parse('gender = "female"'), 25 | ) 26 | self.assertEqual( 27 | Expression(Name('name'), Comparison('!='), Const('Gennady')), 28 | self.parser.parse('name != "Gennady"'), 29 | ) 30 | self.assertEqual( 31 | Expression(Name('married'), Comparison('in'), 32 | List([Const(True), Const(False)])), 33 | self.parser.parse('married in (True, False)'), 34 | ) 35 | self.assertEqual( 36 | Expression(Name('smile'), Comparison('!='), Const(None)), 37 | self.parser.parse('(smile != None)'), 38 | ) 39 | self.assertEqual( 40 | Expression(Name(['job', 'best', 'title']), Comparison('>'), 41 | Const('none')), 42 | self.parser.parse('job.best.title > "none"'), 43 | ) 44 | 45 | def test_string_comparisons(self): 46 | self.assertEqual( 47 | Expression(Name('name'), Comparison('~'), Const('gav')), 48 | self.parser.parse('name ~ "gav"'), 49 | ) 50 | self.assertEqual( 51 | Expression(Name('name'), Comparison('!~'), Const('gav')), 52 | self.parser.parse('name !~ "gav"'), 53 | ) 54 | self.assertEqual( 55 | Expression(Name('name'), Comparison('startswith'), Const('gav')), 56 | self.parser.parse('name startswith "gav"'), 57 | ) 58 | self.assertEqual( 59 | Expression(Name('name'), Comparison('not startswith'), Const('rr')), 60 | self.parser.parse('name not startswith "rr"'), 61 | ) 62 | self.assertEqual( 63 | Expression(Name('name'), Comparison('endswith'), Const('gav')), 64 | self.parser.parse('name endswith "gav"'), 65 | ) 66 | self.assertEqual( 67 | Expression(Name('name'), Comparison('not endswith'), Const('gav')), 68 | self.parser.parse('name not endswith "gav"'), 69 | ) 70 | 71 | def test_escaped_chars(self): 72 | self.assertEqual( 73 | Expression(Name('name'), Comparison('~'), 74 | Const(u'Contains a "quoted" str, 年年有余')), 75 | self.parser.parse(u'name ~ "Contains a \\"quoted\\" str, 年年有余"'), 76 | ) 77 | self.assertEqual( 78 | Expression(Name('options'), Comparison('='), Const(u'П и Щ')), 79 | self.parser.parse(u'options = "\\u041f \\u0438 \\u0429"'), 80 | ) 81 | 82 | def test_numbers(self): 83 | self.assertEqual( 84 | Expression(Name('pk'), Comparison('>'), Const(5)), 85 | self.parser.parse('pk > 5'), 86 | ) 87 | self.assertEqual( 88 | Expression(Name('rating'), Comparison('<='), Const(523)), 89 | self.parser.parse('rating <= 5.23e2'), 90 | ) 91 | 92 | def test_logical(self): 93 | self.assertEqual( 94 | Expression( 95 | Expression(Name('age'), Comparison('>='), Const(18)), 96 | Logical('and'), 97 | Expression(Name('age'), Comparison('<='), Const(45)), 98 | ), 99 | self.parser.parse('age >= 18 and age <= 45'), 100 | ) 101 | self.assertEqual( 102 | Expression( 103 | Expression( 104 | Expression(Name('city'), Comparison('='), Const('Ivanovo')), 105 | Logical('and'), 106 | Expression(Name('age'), Comparison('<='), Const(35)), 107 | ), 108 | Logical('or'), 109 | Expression( 110 | Expression(Name('city'), Comparison('='), Const('Paris')), 111 | Logical('and'), 112 | Expression(Name('age'), Comparison('<='), Const(45)), 113 | ), 114 | ), 115 | self.parser.parse('(city = "Ivanovo" and age <= 35) or ' 116 | '(city = "Paris" and age <= 45)'), 117 | ) 118 | 119 | def test_invalid_comparison(self): 120 | invalid_comparisons = ( 121 | 'foo > None', 122 | 'b <= True', 123 | 'c in False', 124 | '1 = 1', 125 | 'a > b', 126 | 'lol ~ None', 127 | 'gav endswith 1', 128 | 'nor not startswith False', 129 | ) 130 | for expr in invalid_comparisons: 131 | self.assertRaises(DjangoQLParserError, self.parser.parse, expr) 132 | 133 | def test_entity_props(self): 134 | self.assertEqual( 135 | Expression(Name(['user', 'group', 'id']), Comparison('='), 136 | Const(5)), 137 | self.parser.parse('user.group.id = 5'), 138 | ) 139 | -------------------------------------------------------------------------------- /test_project/core/tests/test_queryset.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase, override_settings 3 | 4 | from djangoql.queryset import apply_search 5 | from djangoql.schema import DjangoQLSchema, IntField 6 | 7 | from ..models import Book 8 | 9 | 10 | class WrittenInYearField(IntField): 11 | model = Book 12 | name = 'written_in_year' 13 | 14 | def get_lookup_name(self): 15 | return 'written__year' 16 | 17 | 18 | class BookCustomSearchSchema(DjangoQLSchema): 19 | suggest_options = { 20 | Book: ['genre'], 21 | } 22 | 23 | def get_fields(self, model): 24 | if model == Book: 25 | return [ 26 | 'genre', WrittenInYearField(), 27 | ] 28 | 29 | 30 | class DjangoQLQuerySetTest(TestCase): 31 | def do_simple_query_test(self): 32 | qs = Book.objects.djangoql( 33 | 'name = "foo" and author.email = "em@il" ' 34 | 'and written > "2017-01-30"', 35 | ) 36 | where_clause = str(qs.query).split('WHERE')[1].strip() 37 | self.assertEqual( 38 | '("core_book"."name" = foo AND "auth_user"."email" = em@il ' 39 | 'AND "core_book"."written" > 2017-01-30 00:00:00)', 40 | where_clause, 41 | ) 42 | 43 | def test_simple_query(self): 44 | self.do_simple_query_test() 45 | 46 | @override_settings(USE_TZ=False) 47 | def test_simple_query_without_tz(self): 48 | self.do_simple_query_test() 49 | 50 | def test_datetime_like_query(self): 51 | qs = Book.objects.djangoql('written ~ "2017-01-30"') 52 | where_clause = str(qs.query).split('WHERE')[1].strip() 53 | self.assertEqual( 54 | '"core_book"."written" LIKE %2017-01-30% ESCAPE \'\\\'', 55 | where_clause, 56 | ) 57 | 58 | def test_advanced_string_comparison(self): 59 | qs = Book.objects.djangoql('name ~ "war"') 60 | where_clause = str(qs.query).split('WHERE')[1].strip() 61 | self.assertEqual( 62 | '"core_book"."name" LIKE %war% ESCAPE \'\\\'', 63 | where_clause, 64 | ) 65 | qs = Book.objects.djangoql('name startswith "war"') 66 | where_clause = str(qs.query).split('WHERE')[1].strip() 67 | self.assertEqual( 68 | '"core_book"."name" LIKE war% ESCAPE \'\\\'', 69 | where_clause, 70 | ) 71 | qs = Book.objects.djangoql('name not endswith "peace"') 72 | where_clause = str(qs.query).split('WHERE')[1].strip() 73 | self.assertEqual( 74 | 'NOT ("core_book"."name" LIKE %peace ESCAPE \'\\\')', 75 | where_clause, 76 | ) 77 | 78 | def test_apply_search(self): 79 | qs = User.objects.all() 80 | try: 81 | qs = apply_search(qs, 'groups = None') 82 | qs.count() 83 | except Exception as e: 84 | self.fail(e) 85 | 86 | def test_choices(self): 87 | qs = Book.objects.djangoql( 88 | 'genre = "Drama"', 89 | schema=BookCustomSearchSchema, 90 | ) 91 | where_clause = str(qs.query).split('WHERE')[1].strip() 92 | self.assertEqual('"core_book"."genre" = 1', where_clause) 93 | qs = Book.objects.djangoql( 94 | 'genre in ("Drama", "Comics")', 95 | schema=BookCustomSearchSchema, 96 | ) 97 | where_clause = str(qs.query).split('WHERE')[1].strip() 98 | self.assertEqual('"core_book"."genre" IN (1, 2)', where_clause) 99 | 100 | def test_custom_field_query(self): 101 | qs = Book.objects.djangoql( 102 | 'written_in_year = 2017', 103 | schema=BookCustomSearchSchema, 104 | ) 105 | where_clause = str(qs.query).split('WHERE')[1].strip() 106 | self.assertTrue( 107 | where_clause.startswith('"core_book"."written" BETWEEN 2017-01-01'), 108 | ) 109 | 110 | def test_empty_datetime(self): 111 | qs = apply_search(User.objects.all(), 'last_login = None') 112 | where_clause = str(qs.query).split('WHERE')[1].strip() 113 | self.assertEqual('"auth_user"."last_login" IS NULL', where_clause) 114 | -------------------------------------------------------------------------------- /test_project/core/tests/test_schema.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import Group, User 3 | from django.test import TestCase 4 | 5 | from djangoql.exceptions import DjangoQLSchemaError 6 | from djangoql.parser import DjangoQLParser 7 | from djangoql.schema import DjangoQLSchema, IntField 8 | from djangoql.serializers import SuggestionsAPISerializer 9 | 10 | from ..models import Book 11 | 12 | 13 | serializer = SuggestionsAPISerializer('/suggestions/') 14 | 15 | 16 | class ExcludeUserSchema(DjangoQLSchema): 17 | exclude = (User,) 18 | 19 | 20 | class IncludeUserGroupSchema(DjangoQLSchema): 21 | include = (Group, User) 22 | 23 | 24 | class IncludeExcludeSchema(DjangoQLSchema): 25 | include = (Group,) 26 | exclude = (Book,) 27 | 28 | 29 | class BookCustomFieldsSchema(DjangoQLSchema): 30 | def get_fields(self, model): 31 | if model == Book: 32 | return ['name', 'is_published'] 33 | return super(BookCustomFieldsSchema, self).get_fields(model) 34 | 35 | 36 | class WrittenInYearField(IntField): 37 | model = Book 38 | name = 'written_in_year' 39 | 40 | def get_lookup_name(self): 41 | return 'written__year' 42 | 43 | 44 | class BookCustomSearchSchema(DjangoQLSchema): 45 | def get_fields(self, model): 46 | if model == Book: 47 | return [ 48 | WrittenInYearField(), 49 | ] 50 | 51 | 52 | class DjangoQLSchemaTest(TestCase): 53 | def all_models(self): 54 | models = [] 55 | for app_label in apps.app_configs: 56 | models.extend(apps.get_app_config(app_label).get_models()) 57 | return models 58 | 59 | def test_default(self): 60 | schema_dict = serializer.serialize(DjangoQLSchema(Book)) 61 | self.assertIsInstance(schema_dict, dict) 62 | self.assertEqual('core.book', schema_dict.get('current_model')) 63 | models = schema_dict.get('models') 64 | self.assertIsInstance(models, dict) 65 | all_model_labels = sorted([str(m._meta) for m in self.all_models()]) 66 | session_model = all_model_labels.pop() 67 | self.assertEqual('sessions.session', session_model) 68 | self.assertListEqual(all_model_labels, sorted(models.keys())) 69 | 70 | def test_exclude(self): 71 | schema_dict = serializer.serialize(ExcludeUserSchema(Book)) 72 | self.assertEqual('core.book', schema_dict['current_model']) 73 | self.assertListEqual(sorted(schema_dict['models'].keys()), [ 74 | 'admin.logentry', 75 | 'auth.group', 76 | 'auth.permission', 77 | 'contenttypes.contenttype', 78 | 'core.book', 79 | ]) 80 | 81 | def test_include(self): 82 | schema_dict = serializer.serialize(IncludeUserGroupSchema(User)) 83 | self.assertEqual('auth.user', schema_dict['current_model']) 84 | self.assertListEqual(sorted(schema_dict['models'].keys()), [ 85 | 'auth.group', 86 | 'auth.user', 87 | ]) 88 | 89 | def test_get_fields(self): 90 | default_schema = DjangoQLSchema(Book) 91 | default = serializer.serialize(default_schema)['models']['core.book'] 92 | custom_schema = BookCustomFieldsSchema(Book) 93 | custom = serializer.serialize(custom_schema)['models']['core.book'] 94 | self.assertListEqual(list(default.keys()), [ 95 | 'author', 96 | 'content_type', 97 | 'genre', 98 | 'id', 99 | 'is_published', 100 | 'name', 101 | 'object_id', 102 | 'price', 103 | 'rating', 104 | 'similar_books', 105 | 'written', 106 | ]) 107 | self.assertListEqual(list(custom.keys()), ['name', 'is_published']) 108 | 109 | def test_circular_references(self): 110 | models = serializer.serialize(DjangoQLSchema(Book))['models'] 111 | # If Book references Author then Author should also reference Book back 112 | book_author_field = models['core.book'].get('author') 113 | self.assertIsNotNone(book_author_field) 114 | self.assertEqual('relation', book_author_field['type']) 115 | self.assertEqual('auth.user', book_author_field['relation']) 116 | self.assertEqual('relation', models['auth.user']['book']['type']) 117 | 118 | def test_custom_search(self): 119 | models = serializer.serialize(BookCustomSearchSchema(Book))['models'] 120 | self.assertListEqual( 121 | list(models['core.book'].keys()), 122 | ['written_in_year'], 123 | ) 124 | 125 | def test_invalid_config(self): 126 | try: 127 | IncludeExcludeSchema(Group) 128 | self.fail('Invalid schema with include & exclude raises no error') 129 | except DjangoQLSchemaError: 130 | pass 131 | try: 132 | IncludeUserGroupSchema(Book) 133 | self.fail('Schema was initialized with a model excluded from it') 134 | except DjangoQLSchemaError: 135 | pass 136 | try: 137 | IncludeUserGroupSchema(User()) 138 | self.fail('Schema was initialized with an instance of a model') 139 | except DjangoQLSchemaError: 140 | pass 141 | 142 | def test_validation_pass(self): 143 | samples = [ 144 | 'first_name = "Lolita"', 145 | 'groups.id < 42', 146 | 'groups = None', # that's ok to compare a model to None 147 | 'groups != None', 148 | 'groups.name in ("Stoics") and is_staff = False', 149 | 'date_joined > "1753-01-01"', 150 | 'date_joined > "1753-01-01 01:24"', 151 | 'date_joined > "1753-01-01 01:24:42"', 152 | ] 153 | for query in samples: 154 | ast = DjangoQLParser().parse(query) 155 | try: 156 | IncludeUserGroupSchema(User).validate(ast) 157 | except DjangoQLSchemaError as e: 158 | self.fail(e) 159 | 160 | def test_validation_fail(self): 161 | samples = [ 162 | 'gav = 1', # unknown field 163 | 'groups.gav > 1', # unknown related field 164 | 'groups = "lol"', # can't compare model to a value 165 | 'groups.name != 1', # bad value type 166 | 'is_staff = True and gav < 2', # complex expression with valid part 167 | 'date_joined < "1753-30-01"', # bad timestamps 168 | 'date_joined < "1753-01-01 12"', 169 | 'date_joined < "1753-01-01 12AM"', 170 | ] 171 | for query in samples: 172 | ast = DjangoQLParser().parse(query) 173 | try: 174 | IncludeUserGroupSchema(User).validate(ast) 175 | self.fail("This query should't pass validation: %s" % query) 176 | except DjangoQLSchemaError: 177 | pass 178 | -------------------------------------------------------------------------------- /test_project/core/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib.auth.models import Group, User 4 | from django.shortcuts import render 5 | from django.views.decorators.http import require_GET 6 | 7 | from djangoql.exceptions import DjangoQLError 8 | from djangoql.queryset import apply_search 9 | from djangoql.schema import DjangoQLSchema 10 | from djangoql.serializers import DjangoQLSchemaSerializer 11 | 12 | 13 | class UserQLSchema(DjangoQLSchema): 14 | include = (User, Group) 15 | suggest_options = { 16 | Group: ['name'], 17 | } 18 | 19 | 20 | @require_GET 21 | def completion_demo(request): 22 | q = request.GET.get('q', '') 23 | error = '' 24 | query = User.objects.all().order_by('username') 25 | if q: 26 | try: 27 | query = apply_search(query, q, schema=UserQLSchema) 28 | except DjangoQLError as e: 29 | query = query.none() 30 | error = str(e) 31 | # You may want to use SuggestionsAPISerializer and an additional API 32 | # endpoint (see in djangoql.views) for asynchronous suggestions loading 33 | introspections = DjangoQLSchemaSerializer().serialize( 34 | UserQLSchema(query.model), 35 | ) 36 | return render(request, 'completion_demo.html', context={ 37 | 'q': q, 38 | 'error': error, 39 | 'search_results': query, 40 | 'introspections': json.dumps(introspections), 41 | }) 42 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) 22 | raise 23 | execute_from_command_line(sys.argv) 24 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivelum/djangoql/be5c0eacdbc6a842d79bb410826cd166f59e010d/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = 'o56l!!8q!rwqy13optbmnycu97^tvh@bsk1t!-^$&7o@t4nsbg' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | DJDT = False 30 | 31 | ALLOWED_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', 'ci.ivelum.com'] 32 | INTERNAL_IPS = ['127.0.0.1'] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | 'django.contrib.admin', 39 | 'django.contrib.auth', 40 | 'django.contrib.contenttypes', 41 | 'django.contrib.sessions', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | 45 | 'djangoql', 46 | 47 | 'core', 48 | ] 49 | 50 | if DJDT: 51 | MIDDLEWARE_CLASSES = ['debug_toolbar.middleware.DebugToolbarMiddleware'] 52 | INSTALLED_APPS += ['debug_toolbar'] 53 | else: 54 | MIDDLEWARE_CLASSES = [] 55 | 56 | MIDDLEWARE_CLASSES += [ 57 | 'django.middleware.security.SecurityMiddleware', 58 | 'django.contrib.sessions.middleware.SessionMiddleware', 59 | 'django.middleware.common.CommonMiddleware', 60 | 'django.middleware.csrf.CsrfViewMiddleware', 61 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 62 | 'django.contrib.messages.middleware.MessageMiddleware', 63 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 64 | ] 65 | 66 | MIDDLEWARE = MIDDLEWARE_CLASSES # Django 2.0 67 | 68 | ROOT_URLCONF = 'test_project.urls' 69 | 70 | TEMPLATES = [ 71 | { 72 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 73 | 'DIRS': [], 74 | 'APP_DIRS': True, 75 | 'OPTIONS': { 76 | 'context_processors': [ 77 | 'django.template.context_processors.debug', 78 | 'django.template.context_processors.request', 79 | 'django.contrib.auth.context_processors.auth', 80 | 'django.contrib.messages.context_processors.messages', 81 | ], 82 | }, 83 | }, 84 | ] 85 | 86 | WSGI_APPLICATION = 'test_project.wsgi.application' 87 | 88 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 89 | 90 | # Database 91 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 92 | 93 | DATABASES = { 94 | 'default': { 95 | 'ENGINE': 'django.db.backends.sqlite3', 96 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 97 | }, 98 | } 99 | 100 | 101 | # Password validation 102 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 103 | 104 | AUTH_PASSWORD_VALIDATORS = [ 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa 110 | }, 111 | { 112 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa 113 | }, 114 | { 115 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa 116 | }, 117 | ] 118 | 119 | 120 | # Internationalization 121 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 122 | 123 | LANGUAGE_CODE = 'en-us' 124 | 125 | TIME_ZONE = 'UTC' 126 | 127 | USE_I18N = True 128 | 129 | USE_L10N = True 130 | 131 | USE_TZ = True 132 | 133 | 134 | # Static files (CSS, JavaScript, Images) 135 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 136 | 137 | STATIC_URL = '/static/' 138 | -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/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: url(r'^$', 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: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls import include 18 | from django.contrib import admin 19 | 20 | 21 | try: 22 | from django.urls import re_path # Django >= 4.0 23 | except ImportError: 24 | try: 25 | from django.conf.urls import re_path # Django < 4.0 26 | except ImportError: # Django < 2.0 27 | from django.conf.urls import url as re_path 28 | 29 | from core.admin import zaibatsu_admin_site 30 | from core.views import completion_demo 31 | 32 | 33 | urlpatterns = [ 34 | re_path(r'^admin/', admin.site.urls), 35 | re_path(r'^zaibatsu-admin/', zaibatsu_admin_site.urls), 36 | re_path(r'^$', completion_demo), 37 | ] 38 | 39 | if settings.DEBUG and settings.DJDT: 40 | import debug_toolbar 41 | urlpatterns = [ 42 | re_path(r'^__debug__/', include(debug_toolbar.urls)), 43 | ] + urlpatterns 44 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project 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/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: './completion-widget/index.js', 6 | output: { 7 | path: path.resolve(__dirname, 'djangoql/static/djangoql/'), 8 | filename: 'js/completion.js', 9 | environment: { 10 | arrowFunction: false, 11 | bigIntLiteral: false, 12 | const: false, 13 | destructuring: false, 14 | dynamicImport: false, 15 | forOf: false, 16 | module: false, 17 | }, 18 | }, 19 | devtool: 'source-map', 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | loader: 'babel-loader', 25 | options: { 26 | presets: [ 27 | [ 28 | '@babel/preset-env', 29 | { 30 | targets: { 31 | ie: '9', 32 | }, 33 | }, 34 | ], 35 | ], 36 | }, 37 | }, 38 | { 39 | test: /\.css$/, 40 | use: [ 41 | { loader: MiniCssExtractPlugin.loader }, 42 | 'css-loader', 43 | ], 44 | }, 45 | ], 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ 49 | filename: 'css/completion.css', 50 | }), 51 | ], 52 | }; 53 | --------------------------------------------------------------------------------