├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── setup.cfg ├── setup.py ├── sqlalchemy_filters ├── __init__.py ├── exceptions.py ├── filters.py ├── loads.py ├── models.py ├── pagination.py └── sorting.py ├── test ├── __init__.py ├── conftest.py ├── interface │ ├── __init__.py │ ├── test_filters.py │ ├── test_loads.py │ ├── test_models.py │ ├── test_pagination.py │ └── test_sorting.py └── models.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | plugins = 3 | coverage_conditional_plugin 4 | 5 | [coverage:coverage_conditional_plugin] 6 | rules = 7 | "package_version('sqlalchemy') < (1, 4)": no_cover_sqlalchemy_lt_1_4 8 | "package_version('sqlalchemy') >= (1, 4)": no_cover_sqlalchemy_gte_1_4 9 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests CI 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_dispatch 6 | 7 | jobs: 8 | tests: 9 | name: ${{ matrix.tox }} 10 | runs-on: ubuntu-20.04 11 | 12 | services: 13 | mariadb: 14 | image: mariadb:10 15 | ports: 16 | - 3306:3306 17 | env: 18 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 19 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 20 | 21 | postgres: 22 | image: postgres 23 | ports: 24 | - 5432:5432 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_HOST_AUTH_METHOD: trust 28 | POSTGRES_DB: test_sqlalchemy_filters 29 | POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=en_US.utf8 --lc-ctype=en_US.utf8" 30 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 31 | 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | include: 37 | # sqlalchemylatest (i.e. > 2.0.0) is not yet supported 38 | # for any version of python 39 | 40 | - {python: '3.7', tox: "py37-sqlalchemy1.0"} 41 | - {python: '3.7', tox: "py37-sqlalchemy1.1"} 42 | - {python: '3.7', tox: "py37-sqlalchemy1.2"} 43 | - {python: '3.7', tox: "py37-sqlalchemy1.3"} 44 | - {python: '3.7', tox: "py37-sqlalchemy1.4"} 45 | 46 | - {python: '3.8', tox: "py38-sqlalchemy1.0"} 47 | - {python: '3.8', tox: "py38-sqlalchemy1.1"} 48 | - {python: '3.8', tox: "py38-sqlalchemy1.2"} 49 | - {python: '3.8', tox: "py38-sqlalchemy1.3"} 50 | - {python: '3.8', tox: "py38-sqlalchemy1.4"} 51 | 52 | - {python: '3.9', tox: "py39-sqlalchemy1.0"} 53 | - {python: '3.9', tox: "py39-sqlalchemy1.1"} 54 | - {python: '3.9', tox: "py39-sqlalchemy1.2"} 55 | - {python: '3.9', tox: "py39-sqlalchemy1.3"} 56 | - {python: '3.9', tox: "py39-sqlalchemy1.4"} 57 | 58 | # python3.10 with sqlalchemy <= 1.1 errors with: 59 | # AttributeError: module 'collections' has no attribute 'MutableMapping' 60 | - {python: '3.10', tox: "py310-sqlalchemy1.2"} 61 | - {python: '3.10', tox: "py310-sqlalchemy1.3"} 62 | - {python: '3.10', tox: "py310-sqlalchemy1.4"} 63 | 64 | steps: 65 | - uses: actions/checkout@v2 66 | - uses: actions/setup-python@v4 67 | with: 68 | python-version: ${{ matrix.python }} 69 | 70 | - run: pip install tox~=3.28 71 | - run: tox -e ${{ matrix.tox }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | *.egg-info/ 7 | 8 | # Unit test / coverage reports 9 | .coverage 10 | .coverage.* 11 | .cache 12 | .tox 13 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | Here you can see the full list of changes between sqlalchemy-filters 5 | versions, where semantic versioning is used: *major.minor.patch*. 6 | 7 | 0.13.0 8 | ------ 9 | 10 | Released 2023-04-13 11 | 12 | * Add support for SQLAlchemy 1.4 (#69) thanks to @bodik 13 | * Add support for Python 3.9 & Python 3.10 14 | * Drop support for Python 2.7, 3.5 & 3.6 15 | 16 | 0.12.0 17 | ------ 18 | 19 | Released 2020-05-12 20 | 21 | * Add support for hybrid attributes (properties and methods): filtering 22 | and sorting (#45) as a continuation of the work started here (#32) 23 | by @vkylamba 24 | - Addresses (#22) 25 | 26 | 0.11.0 27 | ------ 28 | 29 | Released 2020-04-25 30 | 31 | * Add support for the ``not_ilike`` operator (#40) thanks to @bodik 32 | * Add support for the ``any`` and ``not_any`` operators (#36) thanks 33 | to @bodik 34 | * Add ability to use the ``select_from`` clause to apply filters 35 | (#34) thanks to @bodik 36 | * Add new parameter to ``apply_filters`` to disable ``auto_join`` on 37 | demand (#35) thanks to @bodik 38 | * Add support for Python 3.8 (#43) 39 | * Drop support for Python 3.4 (#33) 40 | * Fix Python 3.7 deprecations (#41) thanks to @bodik 41 | * Add multiple SQLAlchemy versions support: ``1.0``, ``1.1``, ``1.2``, 42 | ``1.3`` (#33) 43 | 44 | 0.10.0 45 | ------ 46 | 47 | Released 2019-03-13 48 | 49 | * Add ``nullsfirst`` and ``nullslast`` sorting options (#30) 50 | 51 | 0.9.0 52 | ----- 53 | 54 | Released 2019-03-07 55 | 56 | * Add compatibility (no official support) with Python 2.7 (#23 which 57 | addresses #18 thanks to @itdependsnetworks) 58 | * Add support for Python 3.7 (#25) 59 | * Add support (tests) for PostgreSQL (#28) 60 | * Fix and improve documentation (#21 thanks to @daviskirk, #28) 61 | 62 | 0.8.0 63 | ----- 64 | 65 | Released 2018-06-25 66 | 67 | * Adds support for ``ilike`` (case-insensitive) string comparison (#19 68 | thanks to @rockwelln) 69 | * Drop support for Python 3.3 (#20) 70 | 71 | 0.7.0 72 | ----- 73 | 74 | Released 2018-02-12 75 | 76 | * Filters and sorts on related models now result in an "automatic join" 77 | if the query being filtered does not already contain the related model 78 | 79 | 0.6.0 80 | ----- 81 | 82 | Released 2017-11-30 83 | 84 | * Adds support for restricting the columns that are loaded from the 85 | database. 86 | 87 | 0.5.0 88 | ----- 89 | 90 | Released 2017-11-15 91 | 92 | * Adds support for queries against multiple models, e.g. joins. 93 | 94 | 0.4.0 95 | ----- 96 | 97 | Released 2017-06-21 98 | 99 | * Adds support for queries based on model fields or aggregate functions. 100 | 101 | 0.3.0 102 | ----- 103 | 104 | Released 2017-05-22 105 | 106 | * Adds support for boolean functions within filters 107 | * Adds the possibility of supplying a single dictionary as filters when 108 | only one filter is provided 109 | * Makes the ``op`` filter attribute optional: ``==`` is the default 110 | operator 111 | 112 | 0.2.0 113 | ----- 114 | 115 | Released 2017-01-06 116 | 117 | * Adds apply query pagination 118 | * Adds apply query sort 119 | * Adds Travis CI 120 | * Starts using Tox 121 | * Refactors Makefile and conftest 122 | 123 | 0.1.0 124 | ----- 125 | 126 | Released 2016-09-08 127 | 128 | * Initial version 129 | * Adds apply query filters 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Student.com 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.rst 2 | include LICENSE 3 | include README.rst 4 | 5 | global-exclude __pycache__ 6 | global-exclude *.pyc 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | POSTGRES_VERSION?=9.6 4 | MYSQL_VERSION?=5.7 5 | 6 | 7 | rst-lint: 8 | rst-lint README.rst 9 | rst-lint CHANGELOG.rst 10 | 11 | flake8: 12 | flake8 sqlalchemy_filters test setup.py 13 | 14 | test: flake8 15 | pytest test $(ARGS) 16 | 17 | coverage: flake8 rst-lint 18 | coverage run --source sqlalchemy_filters -m pytest test $(ARGS) 19 | coverage report --show-missing --fail-under 100 20 | 21 | 22 | # Docker test containers 23 | 24 | mysql-container: 25 | docker run -d --rm --name mysql-sqlalchemy-filters -p 3306:3306 \ 26 | -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ 27 | mysql:$(MYSQL_VERSION) 28 | 29 | postgres-container: 30 | docker run -d --rm --name postgres-sqlalchemy-filters -p 5432:5432 \ 31 | -e POSTGRES_USER=postgres \ 32 | -e POSTGRES_HOST_AUTH_METHOD=trust \ 33 | -e POSTGRES_DB=test_sqlalchemy_filters \ 34 | -e POSTGRES_INITDB_ARGS="--encoding=UTF8 --lc-collate=en_US.utf8 --lc-ctype=en_US.utf8" \ 35 | postgres:$(POSTGRES_VERSION) 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SQLAlchemy filters 2 | ================== 3 | 4 | .. pull-quote:: 5 | 6 | Filter, sort and paginate SQLAlchemy query objects. Ideal for 7 | exposing these actions over a REST API. 8 | 9 | 10 | .. image:: https://img.shields.io/pypi/v/sqlalchemy-filters.svg 11 | :target: https://pypi.org/project/sqlalchemy-filters/ 12 | 13 | .. image:: https://img.shields.io/pypi/pyversions/sqlalchemy-filters.svg 14 | :target: https://pypi.org/project/sqlalchemy-filters/ 15 | 16 | .. image:: https://img.shields.io/pypi/format/sqlalchemy-filters.svg 17 | :target: https://pypi.org/project/sqlalchemy-filters/ 18 | 19 | .. image:: https://github.com/juliotrigo/sqlalchemy-filters/actions/workflows/tests.yml/badge.svg 20 | :target: https://github.com/juliotrigo/sqlalchemy-filters/actions 21 | 22 | 23 | Filtering 24 | --------- 25 | 26 | Assuming that we have a SQLAlchemy_ ``query`` object: 27 | 28 | .. code-block:: python 29 | 30 | from sqlalchemy import Column, Integer, String 31 | from sqlalchemy.ext.declarative import declarative_base 32 | 33 | 34 | class Base(object): 35 | id = Column(Integer, primary_key=True) 36 | name = Column(String(50), nullable=False) 37 | count = Column(Integer, nullable=True) 38 | 39 | @hybrid_property 40 | def count_square(self): 41 | return self.count * self.count 42 | 43 | @hybrid_method 44 | def three_times_count(self): 45 | return self.count * 3 46 | 47 | 48 | Base = declarative_base(cls=Base) 49 | 50 | 51 | class Foo(Base): 52 | 53 | __tablename__ = 'foo' 54 | 55 | # ... 56 | 57 | query = session.query(Foo) 58 | 59 | Then we can apply filters to that ``query`` object (multiple times): 60 | 61 | .. code-block:: python 62 | 63 | from sqlalchemy_filters import apply_filters 64 | 65 | 66 | # `query` should be a SQLAlchemy query object 67 | 68 | filter_spec = [{'field': 'name', 'op': '==', 'value': 'name_1'}] 69 | filtered_query = apply_filters(query, filter_spec) 70 | 71 | more_filters = [{'field': 'foo_id', 'op': 'is_not_null'}] 72 | filtered_query = apply_filters(filtered_query, more_filters) 73 | 74 | result = filtered_query.all() 75 | 76 | It is also possible to filter queries that contain multiple models, 77 | including joins: 78 | 79 | .. code-block:: python 80 | 81 | class Bar(Base): 82 | 83 | __tablename__ = 'bar' 84 | 85 | foo_id = Column(Integer, ForeignKey('foo.id')) 86 | 87 | 88 | .. code-block:: python 89 | 90 | query = session.query(Foo).join(Bar) 91 | 92 | filter_spec = [ 93 | {'model': 'Foo', 'field': 'name', 'op': '==', 'value': 'name_1'}, 94 | {'model': 'Bar', 'field': 'count', 'op': '>=', 'value': 5}, 95 | ] 96 | filtered_query = apply_filters(query, filter_spec) 97 | 98 | result = filtered_query.all() 99 | 100 | 101 | ``apply_filters`` will attempt to automatically join models to ``query`` 102 | if they're not already present and a model-specific filter is supplied. 103 | For example, the value of ``filtered_query`` in the following two code 104 | blocks is identical: 105 | 106 | .. code-block:: python 107 | 108 | query = session.query(Foo).join(Bar) # join pre-applied to query 109 | 110 | filter_spec = [ 111 | {'model': 'Foo', 'field': 'name', 'op': '==', 'value': 'name_1'}, 112 | {'model': 'Bar', 'field': 'count', 'op': '>=', 'value': 5}, 113 | ] 114 | filtered_query = apply_filters(query, filter_spec) 115 | 116 | .. code-block:: python 117 | 118 | query = session.query(Foo) # join to Bar will be automatically applied 119 | 120 | filter_spec = [ 121 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 122 | {'model': 'Bar', 'field': 'count', 'op': '>=', 'value': 5}, 123 | ] 124 | filtered_query = apply_filters(query, filter_spec) 125 | 126 | The automatic join is only possible if SQLAlchemy_ can implictly 127 | determine the condition for the join, for example because of a foreign 128 | key relationship. 129 | 130 | Automatic joins allow flexibility for clients to filter and sort by related 131 | objects without specifying all possible joins on the server beforehand. Feature 132 | can be explicitly disabled by passing ``do_auto_join=False`` argument to the 133 | ``apply_filters`` call. 134 | 135 | Note that first filter of the second block does not specify a model. 136 | It is implictly applied to the ``Foo`` model because that is the only 137 | model in the original query passed to ``apply_filters``. 138 | 139 | It is also possible to apply filters to queries defined by fields, functions or 140 | ``select_from`` clause: 141 | 142 | .. code-block:: python 143 | 144 | query_alt_1 = session.query(Foo.id, Foo.name) 145 | query_alt_2 = session.query(func.count(Foo.id)) 146 | query_alt_3 = session.query().select_from(Foo).add_column(Foo.id) 147 | 148 | Hybrid attributes 149 | ^^^^^^^^^^^^^^^^^ 150 | 151 | You can filter by a `hybrid attribute`_: a `hybrid property`_ or a `hybrid method`_. 152 | 153 | .. code-block:: python 154 | 155 | query = session.query(Foo) 156 | 157 | filter_spec = [{'field': 'count_square', 'op': '>=', 'value': 25}] 158 | filter_spec = [{'field': 'three_times_count', 'op': '>=', 'value': 15}] 159 | 160 | filtered_query = apply_filters(query, filter_spec) 161 | result = filtered_query.all() 162 | 163 | 164 | Restricted Loads 165 | ---------------- 166 | 167 | You can restrict the fields that SQLAlchemy_ loads from the database by 168 | using the ``apply_loads`` function: 169 | 170 | .. code-block:: python 171 | 172 | query = session.query(Foo, Bar).join(Bar) 173 | load_spec = [ 174 | {'model': 'Foo', 'fields': ['name']}, 175 | {'model': 'Bar', 'fields': ['count']} 176 | ] 177 | query = apply_loads(query, load_spec) # will load only Foo.name and Bar.count 178 | 179 | 180 | The effect of the ``apply_loads`` function is to ``_defer_`` the load 181 | of any other fields to when/if they're accessed, rather than loading 182 | them when the query is executed. It only applies to fields that would be 183 | loaded during normal query execution. 184 | 185 | 186 | Effect on joined queries 187 | ^^^^^^^^^^^^^^^^^^^^^^^^ 188 | 189 | The default SQLAlchemy_ join is lazy, meaning that columns from the 190 | joined table are loaded only when required. Therefore ``apply_loads`` 191 | has limited effect in the following scenario: 192 | 193 | .. code-block:: python 194 | 195 | query = session.query(Foo).join(Bar) 196 | load_spec = [ 197 | {'model': 'Foo', 'fields': ['name']} 198 | {'model': 'Bar', 'fields': ['count']} # ignored 199 | ] 200 | query = apply_loads(query, load_spec) # will load only Foo.name 201 | 202 | 203 | ``apply_loads`` cannot be applied to columns that are loaded as 204 | `joined eager loads `_. 205 | This is because a joined eager load does not add the joined model to the 206 | original query, as explained 207 | `here `_ 208 | 209 | The following would not prevent all columns from ``Bar`` being eagerly 210 | loaded: 211 | 212 | .. code-block:: python 213 | 214 | query = session.query(Foo).options(joinedload(Foo.bar)) 215 | load_spec = [ 216 | {'model': 'Foo', 'fields': ['name']} 217 | {'model': 'Bar', 'fields': ['count']} 218 | ] 219 | query = apply_loads(query, load_spec) 220 | 221 | .. sidebar:: Automatic Join 222 | 223 | In fact, what happens here is that ``Bar`` is automatically joined 224 | to ``query``, because it is determined that ``Bar`` is not part of 225 | the original query. The ``load_spec`` therefore has no effect 226 | because the automatic join results in lazy evaluation. 227 | 228 | If you wish to perform a joined load with restricted columns, you must 229 | specify the columns as part of the joined load, rather than with 230 | ``apply_loads``: 231 | 232 | .. code-block:: python 233 | 234 | query = session.query(Foo).options(joinedload(Bar).load_only('count')) 235 | load_spec = [ 236 | {'model': 'Foo', 'fields': ['name']} 237 | ] 238 | query = apply_loads(query. load_spec) # will load ony Foo.name and Bar.count 239 | 240 | 241 | Sort 242 | ---- 243 | 244 | .. code-block:: python 245 | 246 | from sqlalchemy_filters import apply_sort 247 | 248 | 249 | # `query` should be a SQLAlchemy query object 250 | 251 | sort_spec = [ 252 | {'model': 'Foo', 'field': 'name', 'direction': 'asc'}, 253 | {'model': 'Bar', 'field': 'id', 'direction': 'desc'}, 254 | ] 255 | sorted_query = apply_sort(query, sort_spec) 256 | 257 | result = sorted_query.all() 258 | 259 | 260 | ``apply_sort`` will attempt to automatically join models to ``query`` if 261 | they're not already present and a model-specific sort is supplied. 262 | The behaviour is the same as in ``apply_filters``. 263 | 264 | This allows flexibility for clients to sort by fields on related objects 265 | without specifying all possible joins on the server beforehand. 266 | 267 | Hybrid attributes 268 | ^^^^^^^^^^^^^^^^^ 269 | 270 | You can sort by a `hybrid attribute`_: a `hybrid property`_ or a `hybrid method`_. 271 | 272 | 273 | Pagination 274 | ---------- 275 | 276 | .. code-block:: python 277 | 278 | from sqlalchemy_filters import apply_pagination 279 | 280 | 281 | # `query` should be a SQLAlchemy query object 282 | 283 | query, pagination = apply_pagination(query, page_number=1, page_size=10) 284 | 285 | page_size, page_number, num_pages, total_results = pagination 286 | 287 | assert 10 == len(query) 288 | assert 10 == page_size == pagination.page_size 289 | assert 1 == page_number == pagination.page_number 290 | assert 3 == num_pages == pagination.num_pages 291 | assert 22 == total_results == pagination.total_results 292 | 293 | Filters format 294 | -------------- 295 | 296 | Filters must be provided in a list and will be applied sequentially. 297 | Each filter will be a dictionary element in that list, using the 298 | following format: 299 | 300 | .. code-block:: python 301 | 302 | filter_spec = [ 303 | {'model': 'model_name', 'field': 'field_name', 'op': '==', 'value': 'field_value'}, 304 | {'model': 'model_name', 'field': 'field_2_name', 'op': '!=', 'value': 'field_2_value'}, 305 | # ... 306 | ] 307 | 308 | The ``model`` key is optional if the original query being filtered only 309 | applies to one model. 310 | 311 | If there is only one filter, the containing list may be omitted: 312 | 313 | .. code-block:: python 314 | 315 | filter_spec = {'field': 'field_name', 'op': '==', 'value': 'field_value'} 316 | 317 | Where ``field`` is the name of the field that will be filtered using the 318 | operator provided in ``op`` (optional, defaults to ``==``) and the 319 | provided ``value`` (optional, depending on the operator). 320 | 321 | This is the list of operators that can be used: 322 | 323 | - ``is_null`` 324 | - ``is_not_null`` 325 | - ``==``, ``eq`` 326 | - ``!=``, ``ne`` 327 | - ``>``, ``gt`` 328 | - ``<``, ``lt`` 329 | - ``>=``, ``ge`` 330 | - ``<=``, ``le`` 331 | - ``like`` 332 | - ``ilike`` 333 | - ``not_ilike`` 334 | - ``in`` 335 | - ``not_in`` 336 | - ``any`` 337 | - ``not_any`` 338 | 339 | any / not_any 340 | ^^^^^^^^^^^^^ 341 | 342 | PostgreSQL specific operators allow to filter queries on columns of type ``ARRAY``. 343 | Use ``any`` to filter if a value is present in an array and ``not_any`` if it's not. 344 | 345 | Boolean Functions 346 | ^^^^^^^^^^^^^^^^^ 347 | ``and``, ``or``, and ``not`` functions can be used and nested within the 348 | filter specification: 349 | 350 | .. code-block:: python 351 | 352 | filter_spec = [ 353 | { 354 | 'or': [ 355 | { 356 | 'and': [ 357 | {'field': 'field_name', 'op': '==', 'value': 'field_value'}, 358 | {'field': 'field_2_name', 'op': '!=', 'value': 'field_2_value'}, 359 | ] 360 | }, 361 | { 362 | 'not': [ 363 | {'field': 'field_3_name', 'op': '==', 'value': 'field_3_value'} 364 | ] 365 | }, 366 | ], 367 | } 368 | ] 369 | 370 | 371 | Note: ``or`` and ``and`` must reference a list of at least one element. 372 | ``not`` must reference a list of exactly one element. 373 | 374 | Sort format 375 | ----------- 376 | 377 | Sort elements must be provided as dictionaries in a list and will be 378 | applied sequentially: 379 | 380 | .. code-block:: python 381 | 382 | sort_spec = [ 383 | {'model': 'Foo', 'field': 'name', 'direction': 'asc'}, 384 | {'model': 'Bar', 'field': 'id', 'direction': 'desc'}, 385 | # ... 386 | ] 387 | 388 | Where ``field`` is the name of the field that will be sorted using the 389 | provided ``direction``. 390 | 391 | The ``model`` key is optional if the original query being sorted only 392 | applies to one model. 393 | 394 | nullsfirst / nullslast 395 | ^^^^^^^^^^^^^^^^^^^^^^ 396 | 397 | .. code-block:: python 398 | 399 | sort_spec = [ 400 | {'model': 'Baz', 'field': 'count', 'direction': 'asc', 'nullsfirst': True}, 401 | {'model': 'Qux', 'field': 'city', 'direction': 'desc', 'nullslast': True}, 402 | # ... 403 | ] 404 | 405 | ``nullsfirst`` is an optional attribute that will place ``NULL`` values first 406 | if set to ``True``, according to the `SQLAlchemy documentation `__. 407 | 408 | ``nullslast`` is an optional attribute that will place ``NULL`` values last 409 | if set to ``True``, according to the `SQLAlchemy documentation `__. 410 | 411 | If none of them are provided, then ``NULL`` values will be sorted according 412 | to the RDBMS being used. SQL defines that ``NULL`` values should be placed 413 | together when sorting, but it does not specify whether they should be placed 414 | first or last. 415 | 416 | Even though both ``nullsfirst`` and ``nullslast`` are part of SQLAlchemy_, 417 | they will raise an unexpected exception if the RDBMS that is being used does 418 | not support them. 419 | 420 | At the moment they are 421 | `supported by PostgreSQL `_, 422 | but they are **not** supported by SQLite and MySQL. 423 | 424 | 425 | 426 | Running tests 427 | ------------- 428 | 429 | The default configuration uses **SQLite**, **MySQL** (if the driver is 430 | installed, which is the case when ``tox`` is used) and **PostgreSQL** 431 | (if the driver is installed, which is the case when ``tox`` is used) to 432 | run the tests, with the following URIs: 433 | 434 | .. code-block:: shell 435 | 436 | sqlite+pysqlite:///test_sqlalchemy_filters.db 437 | mysql+mysqlconnector://root:@localhost:3306/test_sqlalchemy_filters 438 | postgresql+psycopg2://postgres:@localhost:5432/test_sqlalchemy_filters?client_encoding=utf8' 439 | 440 | A test database will be created, used during the tests and destroyed 441 | afterwards for each RDBMS configured. 442 | 443 | There are Makefile targets to run docker containers locally for both 444 | **MySQL** and **PostgreSQL**, using the default ports and configuration: 445 | 446 | .. code-block:: shell 447 | 448 | $ make mysql-container 449 | $ make postgres-container 450 | 451 | To run the tests locally: 452 | 453 | .. code-block:: shell 454 | 455 | $ # Create/activate a virtual environment 456 | $ pip install tox 457 | $ tox 458 | 459 | There are some other Makefile targets that can be used to run the tests: 460 | 461 | There are other Makefile targets to run the tests, but extra 462 | dependencies will have to be installed: 463 | 464 | .. code-block:: shell 465 | 466 | $ pip install -U --editable ".[dev,mysql,postgresql]" 467 | $ # using default settings 468 | $ make test 469 | $ make coverage 470 | 471 | $ # overriding DB parameters 472 | $ ARGS='--mysql-test-db-uri mysql+mysqlconnector://root:@192.168.99.100:3340/test_sqlalchemy_filters' make test 473 | $ ARGS='--sqlite-test-db-uri sqlite+pysqlite:///test_sqlalchemy_filters.db' make test 474 | 475 | $ ARGS='--mysql-test-db-uri mysql+mysqlconnector://root:@192.168.99.100:3340/test_sqlalchemy_filters' make coverage 476 | $ ARGS='--sqlite-test-db-uri sqlite+pysqlite:///test_sqlalchemy_filters.db' make coverage 477 | 478 | 479 | 480 | Database management systems 481 | --------------------------- 482 | 483 | The following RDBMS are supported (tested): 484 | 485 | - SQLite 486 | - MySQL 487 | - PostgreSQL 488 | 489 | 490 | SQLAlchemy support 491 | ------------------ 492 | 493 | The following SQLAlchemy_ versions are supported: ``1.0``, ``1.1``, 494 | ``1.2``, ``1.3``, ``1.4``. 495 | 496 | 497 | Changelog 498 | --------- 499 | 500 | Consult the `CHANGELOG `_ 501 | document for fixes and enhancements of each version. 502 | 503 | 504 | License 505 | ------- 506 | 507 | Apache 2.0. See `LICENSE `_ 508 | for details. 509 | 510 | 511 | .. _SQLAlchemy: https://www.sqlalchemy.org/ 512 | .. _hybrid attribute: https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html 513 | .. _hybrid property: https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html#sqlalchemy.ext.hybrid.hybrid_property 514 | .. _hybrid method: https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html#sqlalchemy.ext.hybrid.hybrid_method 515 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | from codecs import open 5 | from setuptools import setup, find_packages 6 | 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | with open(os.path.join(here, 'README.rst'), 'r', 'utf-8') as handle: 11 | readme = handle.read() 12 | 13 | setup( 14 | name='sqlalchemy-filters', 15 | version='0.13.0', 16 | description='A library to filter SQLAlchemy queries.', 17 | long_description=readme, 18 | long_description_content_type='text/x-rst', 19 | author='Student.com', 20 | author_email='wearehiring@student.com', 21 | url='https://github.com/juliotrigo/sqlalchemy-filters', 22 | packages=find_packages(exclude=['test', 'test.*']), 23 | python_requires='>=3.7', 24 | install_requires=['sqlalchemy>=1.0.16', 'six>=1.10.0'], 25 | extras_require={ 26 | 'dev': [ 27 | 'pytest>=4.6.9', 28 | 'coverage~=5.0.4', 29 | 'sqlalchemy-utils>=0.37', 30 | 'flake8', 31 | 'restructuredtext-lint', 32 | 'Pygments', 33 | 'coverage-conditional-plugin', 34 | ], 35 | 'mysql': ['mysql-connector-python-rf==2.2.2'], 36 | 'postgresql': ['psycopg2==2.8.4'], 37 | }, 38 | zip_safe=True, 39 | license='Apache License, Version 2.0', 40 | classifiers=[ 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: Apache Software License", 43 | "Operating System :: OS Independent", 44 | "Programming Language :: Python", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.7", 47 | "Programming Language :: Python :: 3.8", 48 | "Programming Language :: Python :: 3.9", 49 | "Programming Language :: Python :: 3.10", 50 | "Topic :: Database", 51 | "Topic :: Database :: Front-Ends", 52 | "Topic :: Software Development :: Libraries :: Python Modules", 53 | ] 54 | ) 55 | -------------------------------------------------------------------------------- /sqlalchemy_filters/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .filters import apply_filters # noqa: F401 4 | from .loads import apply_loads # noqa: F401 5 | from .pagination import apply_pagination # noqa: F401 6 | from .sorting import apply_sort # noqa: F401 7 | -------------------------------------------------------------------------------- /sqlalchemy_filters/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class BadFilterFormat(Exception): 5 | pass 6 | 7 | 8 | class BadSortFormat(Exception): 9 | pass 10 | 11 | 12 | class BadLoadFormat(Exception): 13 | pass 14 | 15 | 16 | class BadSpec(Exception): 17 | pass 18 | 19 | 20 | class FieldNotFound(Exception): 21 | pass 22 | 23 | 24 | class BadQuery(Exception): 25 | pass 26 | 27 | 28 | class InvalidPage(Exception): 29 | pass 30 | -------------------------------------------------------------------------------- /sqlalchemy_filters/filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import namedtuple 3 | from collections.abc import Iterable 4 | from inspect import signature 5 | from itertools import chain 6 | 7 | from six import string_types 8 | from sqlalchemy import and_, or_, not_, func 9 | 10 | from .exceptions import BadFilterFormat 11 | from .models import Field, auto_join, get_model_from_spec, get_default_model 12 | 13 | 14 | BooleanFunction = namedtuple( 15 | 'BooleanFunction', ('key', 'sqlalchemy_fn', 'only_one_arg') 16 | ) 17 | BOOLEAN_FUNCTIONS = [ 18 | BooleanFunction('or', or_, False), 19 | BooleanFunction('and', and_, False), 20 | BooleanFunction('not', not_, True), 21 | ] 22 | """ 23 | Sqlalchemy boolean functions that can be parsed from the filter definition. 24 | """ 25 | 26 | 27 | class Operator(object): 28 | 29 | OPERATORS = { 30 | 'is_null': lambda f: f.is_(None), 31 | 'is_not_null': lambda f: f.isnot(None), 32 | '==': lambda f, a: f == a, 33 | 'eq': lambda f, a: f == a, 34 | '!=': lambda f, a: f != a, 35 | 'ne': lambda f, a: f != a, 36 | '>': lambda f, a: f > a, 37 | 'gt': lambda f, a: f > a, 38 | '<': lambda f, a: f < a, 39 | 'lt': lambda f, a: f < a, 40 | '>=': lambda f, a: f >= a, 41 | 'ge': lambda f, a: f >= a, 42 | '<=': lambda f, a: f <= a, 43 | 'le': lambda f, a: f <= a, 44 | 'like': lambda f, a: f.like(a), 45 | 'ilike': lambda f, a: f.ilike(a), 46 | 'not_ilike': lambda f, a: ~f.ilike(a), 47 | 'in': lambda f, a: f.in_(a), 48 | 'not_in': lambda f, a: ~f.in_(a), 49 | 'any': lambda f, a: f.any(a), 50 | 'not_any': lambda f, a: func.not_(f.any(a)), 51 | } 52 | 53 | def __init__(self, operator=None): 54 | if not operator: 55 | operator = '==' 56 | 57 | if operator not in self.OPERATORS: 58 | raise BadFilterFormat('Operator `{}` not valid.'.format(operator)) 59 | 60 | self.operator = operator 61 | self.function = self.OPERATORS[operator] 62 | self.arity = len(signature(self.function).parameters) 63 | 64 | 65 | class Filter(object): 66 | 67 | def __init__(self, filter_spec): 68 | self.filter_spec = filter_spec 69 | 70 | try: 71 | filter_spec['field'] 72 | except KeyError: 73 | raise BadFilterFormat('`field` is a mandatory filter attribute.') 74 | except TypeError: 75 | raise BadFilterFormat( 76 | 'Filter spec `{}` should be a dictionary.'.format(filter_spec) 77 | ) 78 | 79 | self.operator = Operator(filter_spec.get('op')) 80 | self.value = filter_spec.get('value') 81 | value_present = True if 'value' in filter_spec else False 82 | if not value_present and self.operator.arity == 2: 83 | raise BadFilterFormat('`value` must be provided.') 84 | 85 | def get_named_models(self): 86 | if "model" in self.filter_spec: 87 | return {self.filter_spec['model']} 88 | return set() 89 | 90 | def format_for_sqlalchemy(self, query, default_model): 91 | filter_spec = self.filter_spec 92 | operator = self.operator 93 | value = self.value 94 | 95 | model = get_model_from_spec(filter_spec, query, default_model) 96 | 97 | function = operator.function 98 | arity = operator.arity 99 | 100 | field_name = self.filter_spec['field'] 101 | field = Field(model, field_name) 102 | sqlalchemy_field = field.get_sqlalchemy_field() 103 | 104 | if arity == 1: 105 | return function(sqlalchemy_field) 106 | 107 | if arity == 2: 108 | return function(sqlalchemy_field, value) 109 | 110 | 111 | class BooleanFilter(object): 112 | 113 | def __init__(self, function, *filters): 114 | self.function = function 115 | self.filters = filters 116 | 117 | def get_named_models(self): 118 | models = set() 119 | for filter in self.filters: 120 | models.update(filter.get_named_models()) 121 | return models 122 | 123 | def format_for_sqlalchemy(self, query, default_model): 124 | return self.function(*[ 125 | filter.format_for_sqlalchemy(query, default_model) 126 | for filter in self.filters 127 | ]) 128 | 129 | 130 | def _is_iterable_filter(filter_spec): 131 | """ `filter_spec` may be a list of nested filter specs, or a dict. 132 | """ 133 | return ( 134 | isinstance(filter_spec, Iterable) and 135 | not isinstance(filter_spec, (string_types, dict)) 136 | ) 137 | 138 | 139 | def build_filters(filter_spec): 140 | """ Recursively process `filter_spec` """ 141 | 142 | if _is_iterable_filter(filter_spec): 143 | return list(chain.from_iterable( 144 | build_filters(item) for item in filter_spec 145 | )) 146 | 147 | if isinstance(filter_spec, dict): 148 | # Check if filter spec defines a boolean function. 149 | for boolean_function in BOOLEAN_FUNCTIONS: 150 | if boolean_function.key in filter_spec: 151 | # The filter spec is for a boolean-function 152 | # Get the function argument definitions and validate 153 | fn_args = filter_spec[boolean_function.key] 154 | 155 | if not _is_iterable_filter(fn_args): 156 | raise BadFilterFormat( 157 | '`{}` value must be an iterable across the function ' 158 | 'arguments'.format(boolean_function.key) 159 | ) 160 | if boolean_function.only_one_arg and len(fn_args) != 1: 161 | raise BadFilterFormat( 162 | '`{}` must have one argument'.format( 163 | boolean_function.key 164 | ) 165 | ) 166 | if not boolean_function.only_one_arg and len(fn_args) < 1: 167 | raise BadFilterFormat( 168 | '`{}` must have one or more arguments'.format( 169 | boolean_function.key 170 | ) 171 | ) 172 | return [ 173 | BooleanFilter( 174 | boolean_function.sqlalchemy_fn, *build_filters(fn_args) 175 | ) 176 | ] 177 | 178 | return [Filter(filter_spec)] 179 | 180 | 181 | def get_named_models(filters): 182 | models = set() 183 | for filter in filters: 184 | models.update(filter.get_named_models()) 185 | return models 186 | 187 | 188 | def apply_filters(query, filter_spec, do_auto_join=True): 189 | """Apply filters to a SQLAlchemy query. 190 | 191 | :param query: 192 | A :class:`sqlalchemy.orm.Query` instance. 193 | 194 | :param filter_spec: 195 | A dict or an iterable of dicts, where each one includes 196 | the necesary information to create a filter to be applied to the 197 | query. 198 | 199 | Example:: 200 | 201 | filter_spec = [ 202 | {'model': 'Foo', 'field': 'name', 'op': '==', 'value': 'foo'}, 203 | ] 204 | 205 | If the query being modified refers to a single model, the `model` key 206 | may be omitted from the filter spec. 207 | 208 | Filters may be combined using boolean functions. 209 | 210 | Example: 211 | 212 | filter_spec = { 213 | 'or': [ 214 | {'model': 'Foo', 'field': 'id', 'op': '==', 'value': '1'}, 215 | {'model': 'Bar', 'field': 'id', 'op': '==', 'value': '2'}, 216 | ] 217 | } 218 | 219 | :returns: 220 | The :class:`sqlalchemy.orm.Query` instance after all the filters 221 | have been applied. 222 | """ 223 | filters = build_filters(filter_spec) 224 | 225 | default_model = get_default_model(query) 226 | 227 | filter_models = get_named_models(filters) 228 | if do_auto_join: 229 | query = auto_join(query, *filter_models) 230 | 231 | sqlalchemy_filters = [ 232 | filter.format_for_sqlalchemy(query, default_model) 233 | for filter in filters 234 | ] 235 | 236 | if sqlalchemy_filters: 237 | query = query.filter(*sqlalchemy_filters) 238 | 239 | return query 240 | -------------------------------------------------------------------------------- /sqlalchemy_filters/loads.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Load 2 | 3 | from .exceptions import BadLoadFormat 4 | from .models import Field, auto_join, get_model_from_spec, get_default_model 5 | 6 | 7 | class LoadOnly(object): 8 | 9 | def __init__(self, load_spec): 10 | self.load_spec = load_spec 11 | 12 | try: 13 | field_names = load_spec['fields'] 14 | except KeyError: 15 | raise BadLoadFormat('`fields` is a mandatory attribute.') 16 | except TypeError: 17 | raise BadLoadFormat( 18 | 'Load spec `{}` should be a dictionary.'.format(load_spec) 19 | ) 20 | 21 | self.field_names = field_names 22 | 23 | def get_named_models(self): 24 | if "model" in self.load_spec: 25 | return {self.load_spec['model']} 26 | return set() 27 | 28 | def format_for_sqlalchemy(self, query, default_model): 29 | load_spec = self.load_spec 30 | field_names = self.field_names 31 | 32 | model = get_model_from_spec(load_spec, query, default_model) 33 | fields = [Field(model, field_name) for field_name in field_names] 34 | 35 | return Load(model).load_only( 36 | *[field.get_sqlalchemy_field() for field in fields] 37 | ) 38 | 39 | 40 | def get_named_models(loads): 41 | models = set() 42 | for load in loads: 43 | models.update(load.get_named_models()) 44 | return models 45 | 46 | 47 | def apply_loads(query, load_spec): 48 | """Apply load restrictions to a :class:`sqlalchemy.orm.Query` instance. 49 | 50 | :param load_spec: 51 | A list of dictionaries, where each item contains the fields to load 52 | for each model. 53 | 54 | Example:: 55 | 56 | load_spec = [ 57 | {'model': 'Foo', fields': ['id', 'name']}, 58 | {'model': 'Bar', 'fields': ['name']}, 59 | ] 60 | 61 | If the query being modified refers to a single model, the `model` key 62 | may be omitted from the load spec. The following shorthand form is 63 | also accepted when the model can be inferred:: 64 | 65 | load_spec = ['id', 'name'] 66 | 67 | :returns: 68 | The :class:`sqlalchemy.orm.Query` instance after the load restrictions 69 | have been applied. 70 | """ 71 | if ( 72 | isinstance(load_spec, list) and 73 | all(map(lambda item: isinstance(item, str), load_spec)) 74 | ): 75 | load_spec = {'fields': load_spec} 76 | 77 | if isinstance(load_spec, dict): 78 | load_spec = [load_spec] 79 | 80 | loads = [LoadOnly(item) for item in load_spec] 81 | 82 | default_model = get_default_model(query) 83 | 84 | load_models = get_named_models(loads) 85 | query = auto_join(query, *load_models) 86 | 87 | sqlalchemy_loads = [ 88 | load.format_for_sqlalchemy(query, default_model) for load in loads 89 | ] 90 | if sqlalchemy_loads: 91 | query = query.options(*sqlalchemy_loads) 92 | 93 | return query 94 | -------------------------------------------------------------------------------- /sqlalchemy_filters/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import __version__ as sqlalchemy_version 2 | from sqlalchemy.exc import InvalidRequestError 3 | from sqlalchemy.orm import mapperlib 4 | from sqlalchemy.inspection import inspect 5 | from sqlalchemy.util import symbol 6 | import types 7 | 8 | from .exceptions import BadQuery, FieldNotFound, BadSpec 9 | 10 | 11 | def sqlalchemy_version_lt(version): 12 | """compares sqla version < version""" 13 | 14 | return tuple(sqlalchemy_version.split('.')) < tuple(version.split('.')) 15 | 16 | 17 | class Field(object): 18 | 19 | def __init__(self, model, field_name): 20 | self.model = model 21 | self.field_name = field_name 22 | 23 | def get_sqlalchemy_field(self): 24 | if self.field_name not in self._get_valid_field_names(): 25 | raise FieldNotFound( 26 | 'Model {} has no column `{}`.'.format( 27 | self.model, self.field_name 28 | ) 29 | ) 30 | sqlalchemy_field = getattr(self.model, self.field_name) 31 | 32 | # If it's a hybrid method, then we call it so that we can work with 33 | # the result of the execution and not with the method object itself 34 | if isinstance(sqlalchemy_field, types.MethodType): 35 | sqlalchemy_field = sqlalchemy_field() 36 | 37 | return sqlalchemy_field 38 | 39 | def _get_valid_field_names(self): 40 | inspect_mapper = inspect(self.model) 41 | columns = inspect_mapper.columns 42 | orm_descriptors = inspect_mapper.all_orm_descriptors 43 | 44 | column_names = columns.keys() 45 | hybrid_names = [ 46 | key for key, item in orm_descriptors.items() 47 | if _is_hybrid_property(item) or _is_hybrid_method(item) 48 | ] 49 | 50 | return set(column_names) | set(hybrid_names) 51 | 52 | 53 | def _is_hybrid_property(orm_descriptor): 54 | return orm_descriptor.extension_type == symbol('HYBRID_PROPERTY') 55 | 56 | 57 | def _is_hybrid_method(orm_descriptor): 58 | return orm_descriptor.extension_type == symbol('HYBRID_METHOD') 59 | 60 | 61 | def get_model_from_table(table): # pragma: no_cover_sqlalchemy_lt_1_4 62 | """Resolve model class from table object""" 63 | 64 | for registry in mapperlib._all_registries(): 65 | for mapper in registry.mappers: 66 | if table in mapper.tables: 67 | return mapper.class_ 68 | return None 69 | 70 | 71 | def get_query_models(query): 72 | """Get models from query. 73 | 74 | :param query: 75 | A :class:`sqlalchemy.orm.Query` instance. 76 | 77 | :returns: 78 | A dictionary with all the models included in the query. 79 | """ 80 | models = [col_desc['entity'] for col_desc in query.column_descriptions] 81 | 82 | # account joined entities 83 | if sqlalchemy_version_lt('1.4'): # pragma: no_cover_sqlalchemy_gte_1_4 84 | models.extend(mapper.class_ for mapper in query._join_entities) 85 | else: # pragma: no_cover_sqlalchemy_lt_1_4 86 | try: 87 | models.extend( 88 | mapper.class_ 89 | for mapper 90 | in query._compile_state()._join_entities 91 | ) 92 | except InvalidRequestError: 93 | # query might not contain columns yet, hence cannot be compiled 94 | # try to infer the models from various internals 95 | for table_tuple in query._setup_joins + query._legacy_setup_joins: 96 | model_class = get_model_from_table(table_tuple[0]) 97 | if model_class: 98 | models.append(model_class) 99 | 100 | # account also query.select_from entities 101 | model_class = None 102 | if sqlalchemy_version_lt('1.4'): # pragma: no_cover_sqlalchemy_gte_1_4 103 | if query._select_from_entity: 104 | model_class = ( 105 | query._select_from_entity 106 | if sqlalchemy_version_lt('1.1') 107 | else query._select_from_entity.class_ 108 | ) 109 | else: # pragma: no_cover_sqlalchemy_lt_1_4 110 | if query._from_obj: 111 | model_class = get_model_from_table(query._from_obj[0]) 112 | if model_class and (model_class not in models): 113 | models.append(model_class) 114 | 115 | return {model.__name__: model for model in models} 116 | 117 | 118 | def get_model_from_spec(spec, query, default_model=None): 119 | """ Determine the model to which a spec applies on a given query. 120 | 121 | A spec that does not specify a model may be applied to a query that 122 | contains a single model. Otherwise the spec must specify the model to 123 | which it applies, and that model must be present in the query. 124 | 125 | :param query: 126 | A :class:`sqlalchemy.orm.Query` instance. 127 | 128 | :param spec: 129 | A dictionary that may or may not contain a model name to resolve 130 | against the query. 131 | 132 | :returns: 133 | A model instance. 134 | 135 | :raise BadSpec: 136 | If the spec is ambiguous or refers to a model not in the query. 137 | 138 | :raise BadQuery: 139 | If the query contains no models. 140 | 141 | """ 142 | models = get_query_models(query) 143 | if not models: 144 | raise BadQuery('The query does not contain any models.') 145 | 146 | model_name = spec.get('model') 147 | if model_name is not None: 148 | models = [v for (k, v) in models.items() if k == model_name] 149 | if not models: 150 | raise BadSpec( 151 | 'The query does not contain model `{}`.'.format(model_name) 152 | ) 153 | model = models[0] 154 | else: 155 | if len(models) == 1: 156 | model = list(models.values())[0] 157 | elif default_model is not None: 158 | return default_model 159 | else: 160 | raise BadSpec("Ambiguous spec. Please specify a model.") 161 | 162 | return model 163 | 164 | 165 | def get_model_class_by_name(registry, name): 166 | """ Return the model class matching `name` in the given `registry`. 167 | """ 168 | for cls in registry.values(): 169 | if getattr(cls, '__name__', None) == name: 170 | return cls 171 | 172 | 173 | def get_default_model(query): 174 | """ Return the singular model from `query`, or `None` if `query` contains 175 | multiple models. 176 | """ 177 | query_models = get_query_models(query).values() 178 | if len(query_models) == 1: 179 | default_model, = iter(query_models) 180 | else: 181 | default_model = None 182 | return default_model 183 | 184 | 185 | def auto_join(query, *model_names): 186 | """ Automatically join models to `query` if they're not already present 187 | and the join can be done implicitly. 188 | """ 189 | # every model has access to the registry, so we can use any from the query 190 | query_models = get_query_models(query).values() 191 | last_model = list(query_models)[-1] 192 | model_registry = ( 193 | last_model._decl_class_registry 194 | if sqlalchemy_version_lt('1.4') 195 | else last_model.registry._class_registry 196 | ) 197 | 198 | for name in model_names: 199 | model = get_model_class_by_name(model_registry, name) 200 | if model and (model not in get_query_models(query).values()): 201 | try: 202 | if sqlalchemy_version_lt('1.4'): # pragma: no_cover_sqlalchemy_gte_1_4 203 | query = query.join(model) 204 | else: # pragma: no_cover_sqlalchemy_lt_1_4 205 | # https://docs.sqlalchemy.org/en/14/changelog/migration_14.html 206 | # Many Core and ORM statement objects now perform much of 207 | # their construction and validation in the compile phase 208 | tmp = query.join(model) 209 | tmp._compile_state() 210 | query = tmp 211 | except InvalidRequestError: 212 | pass # can't be autojoined 213 | return query 214 | -------------------------------------------------------------------------------- /sqlalchemy_filters/pagination.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import math 3 | from collections import namedtuple 4 | 5 | from sqlalchemy_filters.exceptions import InvalidPage 6 | 7 | 8 | def apply_pagination(query, page_number=None, page_size=None): 9 | """Apply pagination to a SQLAlchemy query object. 10 | 11 | :param page_number: 12 | Page to be returned (starts and defaults to 1). 13 | 14 | :param page_size: 15 | Maximum number of results to be returned in the page (defaults 16 | to the total results). 17 | 18 | :returns: 19 | A 2-tuple with the paginated SQLAlchemy query object and 20 | a pagination namedtuple. 21 | 22 | The pagination object contains information about the results 23 | and pages: ``page_size`` (defaults to ``total_results``), 24 | ``page_number`` (defaults to 1), ``num_pages`` and 25 | ``total_results``. 26 | 27 | Basic usage:: 28 | 29 | query, pagination = apply_pagination(query, 1, 10) 30 | >>> len(query) 31 | 10 32 | >>> pagination.page_size 33 | 10 34 | >>> pagination.page_number 35 | 1 36 | >>> pagination.num_pages 37 | 3 38 | >>> pagination.total_results 39 | 22 40 | >>> page_size, page_number, num_pages, total_results = pagination 41 | """ 42 | total_results = query.count() 43 | query = _limit(query, page_size) 44 | 45 | # Page size defaults to total results 46 | if page_size is None or (page_size > total_results and total_results > 0): 47 | page_size = total_results 48 | 49 | query = _offset(query, page_number, page_size) 50 | 51 | # Page number defaults to 1 52 | if page_number is None: 53 | page_number = 1 54 | 55 | num_pages = _calculate_num_pages(page_number, page_size, total_results) 56 | 57 | Pagination = namedtuple( 58 | 'Pagination', 59 | ['page_number', 'page_size', 'num_pages', 'total_results'] 60 | ) 61 | return query, Pagination(page_number, page_size, num_pages, total_results) 62 | 63 | 64 | def _limit(query, page_size): 65 | if page_size is not None: 66 | if page_size < 0: 67 | raise InvalidPage( 68 | 'Page size should not be negative: {}'.format(page_size) 69 | ) 70 | 71 | query = query.limit(page_size) 72 | 73 | return query 74 | 75 | 76 | def _offset(query, page_number, page_size): 77 | if page_number is not None: 78 | if page_number < 1: 79 | raise InvalidPage( 80 | 'Page number should be positive: {}'.format(page_number) 81 | ) 82 | 83 | query = query.offset((page_number - 1) * page_size) 84 | 85 | return query 86 | 87 | 88 | def _calculate_num_pages(page_number, page_size, total_results): 89 | if page_size == 0: 90 | return 0 91 | 92 | return math.ceil(float(total_results) / float(page_size)) 93 | -------------------------------------------------------------------------------- /sqlalchemy_filters/sorting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .exceptions import BadSortFormat 4 | from .models import Field, auto_join, get_model_from_spec, get_default_model 5 | 6 | SORT_ASCENDING = 'asc' 7 | SORT_DESCENDING = 'desc' 8 | 9 | 10 | class Sort(object): 11 | 12 | def __init__(self, sort_spec): 13 | self.sort_spec = sort_spec 14 | 15 | try: 16 | field_name = sort_spec['field'] 17 | direction = sort_spec['direction'] 18 | except KeyError: 19 | raise BadSortFormat( 20 | '`field` and `direction` are mandatory attributes.' 21 | ) 22 | except TypeError: 23 | raise BadSortFormat( 24 | 'Sort spec `{}` should be a dictionary.'.format(sort_spec) 25 | ) 26 | 27 | if direction not in [SORT_ASCENDING, SORT_DESCENDING]: 28 | raise BadSortFormat('Direction `{}` not valid.'.format(direction)) 29 | 30 | self.field_name = field_name 31 | self.direction = direction 32 | self.nullsfirst = sort_spec.get('nullsfirst') 33 | self.nullslast = sort_spec.get('nullslast') 34 | 35 | def get_named_models(self): 36 | if "model" in self.sort_spec: 37 | return {self.sort_spec['model']} 38 | return set() 39 | 40 | def format_for_sqlalchemy(self, query, default_model): 41 | sort_spec = self.sort_spec 42 | direction = self.direction 43 | field_name = self.field_name 44 | 45 | model = get_model_from_spec(sort_spec, query, default_model) 46 | 47 | field = Field(model, field_name) 48 | sqlalchemy_field = field.get_sqlalchemy_field() 49 | 50 | if direction == SORT_ASCENDING: 51 | sort_fnc = sqlalchemy_field.asc 52 | elif direction == SORT_DESCENDING: 53 | sort_fnc = sqlalchemy_field.desc 54 | 55 | if self.nullsfirst: 56 | return sort_fnc().nullsfirst() 57 | elif self.nullslast: 58 | return sort_fnc().nullslast() 59 | else: 60 | return sort_fnc() 61 | 62 | 63 | def get_named_models(sorts): 64 | models = set() 65 | for sort in sorts: 66 | models.update(sort.get_named_models()) 67 | return models 68 | 69 | 70 | def apply_sort(query, sort_spec): 71 | """Apply sorting to a :class:`sqlalchemy.orm.Query` instance. 72 | 73 | :param sort_spec: 74 | A list of dictionaries, where each one of them includes 75 | the necesary information to order the elements of the query. 76 | 77 | Example:: 78 | 79 | sort_spec = [ 80 | {'model': 'Foo', 'field': 'name', 'direction': 'asc'}, 81 | {'model': 'Bar', 'field': 'id', 'direction': 'desc'}, 82 | { 83 | 'model': 'Qux', 84 | 'field': 'surname', 85 | 'direction': 'desc', 86 | 'nullslast': True, 87 | }, 88 | { 89 | 'model': 'Baz', 90 | 'field': 'count', 91 | 'direction': 'asc', 92 | 'nullsfirst': True, 93 | }, 94 | ] 95 | 96 | If the query being modified refers to a single model, the `model` key 97 | may be omitted from the sort spec. 98 | 99 | :returns: 100 | The :class:`sqlalchemy.orm.Query` instance after the provided 101 | sorting has been applied. 102 | """ 103 | if isinstance(sort_spec, dict): 104 | sort_spec = [sort_spec] 105 | 106 | sorts = [Sort(item) for item in sort_spec] 107 | 108 | default_model = get_default_model(query) 109 | 110 | sort_models = get_named_models(sorts) 111 | query = auto_join(query, *sort_models) 112 | 113 | sqlalchemy_sorts = [ 114 | sort.format_for_sqlalchemy(query, default_model) for sort in sorts 115 | ] 116 | 117 | if sqlalchemy_sorts: 118 | query = query.order_by(*sqlalchemy_sorts) 119 | 120 | return query 121 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def error_value(exception): 5 | return exception.value.args[0] 6 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlalchemy_utils import create_database, drop_database, database_exists 7 | 8 | from test.models import Base, BasePostgresqlSpecific 9 | 10 | 11 | SQLITE_TEST_DB_URI = 'SQLITE_TEST_DB_URI' 12 | MYSQL_TEST_DB_URI = 'MYSQL_TEST_DB_URI' 13 | POSTGRESQL_TEST_DB_URI = 'POSTGRESQL_TEST_DB_URI' 14 | 15 | 16 | def pytest_addoption(parser): 17 | parser.addoption( 18 | '--sqlite-test-db-uri', 19 | action='store', 20 | dest=SQLITE_TEST_DB_URI, 21 | default='sqlite+pysqlite:///test_sqlalchemy_filters.db', 22 | help=( 23 | 'DB uri for testing (e.g. ' 24 | '"sqlite+pysqlite:///test_sqlalchemy_filters.db")' 25 | ) 26 | ) 27 | 28 | parser.addoption( 29 | '--mysql-test-db-uri', 30 | action='store', 31 | dest=MYSQL_TEST_DB_URI, 32 | default=( 33 | 'mysql+mysqlconnector://root:@localhost:3306' 34 | '/test_sqlalchemy_filters' 35 | ), 36 | help=( 37 | 'DB uri for testing (e.g. ' 38 | '"mysql+mysqlconnector://username:password@localhost:3306' 39 | '/test_sqlalchemy_filters")' 40 | ) 41 | ) 42 | 43 | parser.addoption( 44 | '--postgresql-test-db-uri', 45 | action='store', 46 | dest=POSTGRESQL_TEST_DB_URI, 47 | default=( 48 | 'postgresql+psycopg2://postgres:@localhost:5432' 49 | '/test_sqlalchemy_filters?client_encoding=utf8' 50 | ), 51 | help=( 52 | 'DB uri for testing (e.g. ' 53 | '"postgresql+psycopg2://username:password@localhost:5432' 54 | '/test_sqlalchemy_filters?client_encoding=utf8")' 55 | ) 56 | ) 57 | 58 | 59 | @pytest.fixture(scope='session') 60 | def config(request): 61 | return { 62 | SQLITE_TEST_DB_URI: request.config.getoption(SQLITE_TEST_DB_URI), 63 | MYSQL_TEST_DB_URI: request.config.getoption(MYSQL_TEST_DB_URI), 64 | POSTGRESQL_TEST_DB_URI: request.config.getoption( 65 | POSTGRESQL_TEST_DB_URI 66 | ), 67 | } 68 | 69 | 70 | def test_db_keys(): 71 | """Decide what DB backends to use to run the tests.""" 72 | test_db_uris = [] 73 | test_db_uris.append(SQLITE_TEST_DB_URI) 74 | 75 | try: 76 | import mysql # noqa: F401 77 | except ImportError: 78 | pass 79 | else: 80 | test_db_uris.append(MYSQL_TEST_DB_URI) 81 | 82 | try: 83 | import psycopg2 # noqa: F401 84 | except ImportError: 85 | pass 86 | else: 87 | test_db_uris.append(POSTGRESQL_TEST_DB_URI) 88 | 89 | return test_db_uris 90 | 91 | 92 | @pytest.fixture(scope='session', params=test_db_keys()) 93 | def db_uri(request, config): 94 | return config[request.param] 95 | 96 | 97 | @pytest.fixture(scope='session') 98 | def is_postgresql(db_uri): 99 | if 'postgresql' in db_uri: 100 | return True 101 | return False 102 | 103 | 104 | @pytest.fixture(scope='session') 105 | def is_sqlite(db_uri): 106 | if 'sqlite' in db_uri: 107 | return True 108 | return False 109 | 110 | 111 | @pytest.fixture(scope='session') 112 | def db_engine_options(db_uri, is_postgresql): 113 | if is_postgresql: 114 | return dict( 115 | client_encoding='utf8', 116 | connect_args={'client_encoding': 'utf8'} 117 | ) 118 | return {} 119 | 120 | 121 | @pytest.fixture(scope='session') 122 | def connection(db_uri, db_engine_options, is_postgresql): 123 | create_db(db_uri) 124 | engine = create_engine(db_uri, **db_engine_options) 125 | Base.metadata.create_all(engine) 126 | connection = engine.connect() 127 | Base.metadata.bind = engine 128 | if is_postgresql: 129 | BasePostgresqlSpecific.metadata.create_all(engine) 130 | BasePostgresqlSpecific.metadata.bind = engine 131 | 132 | yield connection 133 | 134 | Base.metadata.drop_all() 135 | destroy_database(db_uri) 136 | 137 | 138 | @pytest.fixture() 139 | def session(connection, is_postgresql): 140 | Session = sessionmaker(bind=connection) 141 | db_session = Session() 142 | 143 | yield db_session 144 | 145 | for table in reversed(Base.metadata.sorted_tables): 146 | db_session.execute(table.delete()) 147 | if is_postgresql: 148 | for table in reversed(BasePostgresqlSpecific.metadata.sorted_tables): 149 | db_session.execute(table.delete()) 150 | 151 | db_session.commit() 152 | db_session.close() 153 | 154 | 155 | def create_db(uri): 156 | """Drop the database at ``uri`` and create a brand new one. """ 157 | destroy_database(uri) 158 | create_database(uri) 159 | 160 | 161 | def destroy_database(uri): 162 | """Destroy the database at ``uri``, if it exists. """ 163 | if database_exists(uri): 164 | drop_database(uri) 165 | -------------------------------------------------------------------------------- /test/interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliotrigo/sqlalchemy-filters/e03b1aeb6b7a4f7709a7a57d6ca5d302a719b9fa/test/interface/__init__.py -------------------------------------------------------------------------------- /test/interface/test_filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | import pytest 6 | from six import string_types 7 | from sqlalchemy import func 8 | from sqlalchemy.orm import joinedload 9 | 10 | from sqlalchemy_filters import apply_filters 11 | from sqlalchemy_filters.exceptions import ( 12 | BadFilterFormat, BadSpec, FieldNotFound 13 | ) 14 | 15 | from test.models import Foo, Bar, Qux, Corge 16 | 17 | 18 | ARRAY_NOT_SUPPORTED = ( 19 | "ARRAY type and operators supported only by PostgreSQL" 20 | ) 21 | 22 | 23 | STRING_DATE_TIME_NOT_SUPPORTED = ( 24 | "TODO: String Time / DateTime values currently not working as filters by " 25 | "SQLite" 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def multiple_foos_inserted(session, multiple_bars_inserted): 31 | foo_1 = Foo(id=1, bar_id=1, name='name_1', count=50) 32 | foo_2 = Foo(id=2, bar_id=2, name='name_2', count=100) 33 | foo_3 = Foo(id=3, bar_id=3, name='name_1', count=None) 34 | foo_4 = Foo(id=4, bar_id=4, name='name_4', count=150) 35 | session.add_all([foo_1, foo_2, foo_3, foo_4]) 36 | session.commit() 37 | 38 | 39 | @pytest.fixture 40 | def multiple_bars_inserted(session): 41 | bar_1 = Bar(id=1, name='name_1', count=5) 42 | bar_2 = Bar(id=2, name='name_2', count=10) 43 | bar_3 = Bar(id=3, name='name_1', count=None) 44 | bar_4 = Bar(id=4, name='name_4', count=15) 45 | session.add_all([bar_1, bar_2, bar_3, bar_4]) 46 | session.commit() 47 | 48 | 49 | @pytest.fixture 50 | def multiple_quxs_inserted(session): 51 | qux_1 = Qux( 52 | id=1, name='name_1', count=5, 53 | created_at=datetime.date(2016, 7, 12), 54 | execution_time=datetime.datetime(2016, 7, 12, 1, 5, 9), 55 | expiration_time=datetime.time(1, 5, 9) 56 | ) 57 | qux_2 = Qux( 58 | id=2, name='name_2', count=10, 59 | created_at=datetime.date(2016, 7, 13), 60 | execution_time=datetime.datetime(2016, 7, 13, 2, 5, 9), 61 | expiration_time=datetime.time(2, 5, 9) 62 | ) 63 | qux_3 = Qux( 64 | id=3, name='name_1', count=None, 65 | created_at=None, execution_time=None, expiration_time=None 66 | ) 67 | qux_4 = Qux( 68 | id=4, name='name_4', count=15, 69 | created_at=datetime.date(2016, 7, 14), 70 | execution_time=datetime.datetime(2016, 7, 14, 3, 5, 9), 71 | expiration_time=datetime.time(3, 5, 9) 72 | ) 73 | session.add_all([qux_1, qux_2, qux_3, qux_4]) 74 | session.commit() 75 | 76 | 77 | @pytest.fixture 78 | def multiple_corges_inserted(session, is_postgresql): 79 | if is_postgresql: 80 | corge_1 = Corge(id=1, name='name_1', tags=[]) 81 | corge_2 = Corge(id=2, name='name_2', tags=['foo']) 82 | corge_3 = Corge(id=3, name='name_3', tags=['foo', 'bar']) 83 | corge_4 = Corge(id=4, name='name_4', tags=['bar', 'baz']) 84 | session.add_all([corge_1, corge_2, corge_3, corge_4]) 85 | session.commit() 86 | 87 | 88 | class TestFiltersNotApplied: 89 | 90 | def test_no_filters_provided(self, session): 91 | query = session.query(Bar) 92 | filters = [] 93 | 94 | filtered_query = apply_filters(query, filters) 95 | 96 | assert query == filtered_query 97 | 98 | @pytest.mark.parametrize('filter_', ['some text', 1, '']) 99 | def test_wrong_filters_format(self, session, filter_): 100 | query = session.query(Bar) 101 | filters = [filter_] 102 | 103 | with pytest.raises(BadFilterFormat) as err: 104 | apply_filters(query, filters) 105 | 106 | expected_error = 'Filter spec `{}` should be a dictionary.'.format( 107 | filter_ 108 | ) 109 | assert expected_error == err.value.args[0] 110 | 111 | def test_invalid_operator(self, session): 112 | query = session.query(Bar) 113 | filters = [{'field': 'name', 'op': 'op_not_valid', 'value': 'name_1'}] 114 | 115 | with pytest.raises(BadFilterFormat) as err: 116 | apply_filters(query, filters) 117 | 118 | assert 'Operator `op_not_valid` not valid.' == err.value.args[0] 119 | 120 | @pytest.mark.usefixtures('multiple_bars_inserted') 121 | def test_no_operator_provided(self, session): 122 | query = session.query(Bar) 123 | filters = [{'field': 'name', 'value': 'name_1'}] 124 | 125 | filtered_query = apply_filters(query, filters) 126 | result = filtered_query.all() 127 | 128 | assert len(result) == 2 129 | assert result[0].id == 1 130 | assert result[1].id == 3 131 | 132 | def test_no_field_provided(self, session): 133 | query = session.query(Bar) 134 | filters = [{'op': '==', 'value': 'name_1'}] 135 | 136 | with pytest.raises(BadFilterFormat) as err: 137 | apply_filters(query, filters) 138 | 139 | expected_error = '`field` is a mandatory filter attribute.' 140 | assert expected_error == err.value.args[0] 141 | 142 | # TODO: replace this test once we add the option to compare against 143 | # another field 144 | def test_no_value_provided(self, session): 145 | query = session.query(Bar) 146 | filters = [{'field': 'name', 'op': '==', }] 147 | 148 | with pytest.raises(BadFilterFormat) as err: 149 | apply_filters(query, filters) 150 | 151 | assert '`value` must be provided.' == err.value.args[0] 152 | 153 | def test_invalid_field(self, session): 154 | query = session.query(Bar) 155 | filters = [{'field': 'invalid_field', 'op': '==', 'value': 'name_1'}] 156 | 157 | with pytest.raises(FieldNotFound) as err: 158 | apply_filters(query, filters) 159 | 160 | expected_error = ( 161 | "Model has no column `invalid_field`." 162 | ) 163 | assert expected_error == err.value.args[0] 164 | 165 | @pytest.mark.parametrize('attr_name', [ 166 | 'metadata', # model attribute 167 | 'foos', # model relationship 168 | ]) 169 | def test_invalid_field_but_valid_model_attribute(self, session, attr_name): 170 | query = session.query(Bar) 171 | filters = [{'field': attr_name, 'op': '==', 'value': 'name_1'}] 172 | 173 | with pytest.raises(FieldNotFound) as err: 174 | apply_filters(query, filters) 175 | 176 | expected_error = ( 177 | "Model has no column `{}`.".format( 178 | attr_name 179 | ) 180 | ) 181 | assert expected_error == err.value.args[0] 182 | 183 | 184 | class TestMultipleModels: 185 | 186 | # TODO: multi-model should be tested for each filter type 187 | @pytest.mark.usefixtures('multiple_bars_inserted') 188 | @pytest.mark.usefixtures('multiple_quxs_inserted') 189 | def test_multiple_models(self, session): 190 | query = session.query(Bar, Qux) 191 | filters = [ 192 | {'model': 'Bar', 'field': 'name', 'op': '==', 'value': 'name_1'}, 193 | {'model': 'Qux', 'field': 'name', 'op': '==', 'value': 'name_1'}, 194 | ] 195 | 196 | filtered_query = apply_filters(query, filters) 197 | result = filtered_query.all() 198 | 199 | assert len(result) == 4 200 | bars, quxs = zip(*result) 201 | assert set(map(type, bars)) == {Bar} 202 | assert {bar.id for bar in bars} == {1, 3} 203 | assert {bar.name for bar in bars} == {"name_1"} 204 | assert set(map(type, quxs)) == {Qux} 205 | assert {qux.id for qux in quxs} == {1, 3} 206 | assert {qux.name for qux in quxs} == {"name_1"} 207 | 208 | 209 | class TestAutoJoin: 210 | 211 | @pytest.mark.usefixtures('multiple_foos_inserted') 212 | def test_auto_join(self, session): 213 | 214 | query = session.query(Foo) 215 | filters = [ 216 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 217 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 218 | ] 219 | 220 | filtered_query = apply_filters(query, filters) 221 | result = filtered_query.all() 222 | 223 | assert len(result) == 1 224 | assert result[0].id == 3 225 | assert result[0].bar_id == 3 226 | assert result[0].bar.count is None 227 | 228 | @pytest.mark.usefixtures('multiple_foos_inserted') 229 | def test_do_not_auto_join(self, session): 230 | 231 | query = session.query(Foo) 232 | filters = [ 233 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 234 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 235 | ] 236 | 237 | with pytest.raises(BadSpec) as exc: 238 | apply_filters(query, filters, do_auto_join=False) 239 | 240 | assert 'The query does not contain model `Bar`' in str(exc) 241 | 242 | @pytest.mark.usefixtures('multiple_foos_inserted') 243 | def test_noop_if_query_contains_named_models(self, session): 244 | 245 | query = session.query(Foo).join(Bar) 246 | filters = [ 247 | {'model': 'Foo', 'field': 'name', 'op': '==', 'value': 'name_1'}, 248 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 249 | ] 250 | 251 | filtered_query = apply_filters(query, filters) 252 | result = filtered_query.all() 253 | 254 | assert len(result) == 1 255 | assert result[0].id == 3 256 | assert result[0].bar_id == 3 257 | assert result[0].bar.count is None 258 | 259 | @pytest.mark.usefixtures('multiple_foos_inserted') 260 | def test_auto_join_to_invalid_model(self, session): 261 | 262 | query = session.query(Foo) 263 | filters = [ 264 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 265 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 266 | {'model': 'Qux', 'field': 'created_at', 'op': 'is_not_null'} 267 | ] 268 | with pytest.raises(BadSpec) as err: 269 | apply_filters(query, filters) 270 | 271 | assert 'The query does not contain model `Qux`.' == err.value.args[0] 272 | 273 | @pytest.mark.usefixtures('multiple_foos_inserted') 274 | def test_ambiguous_query(self, session): 275 | 276 | query = session.query(Foo).join(Bar) 277 | filters = [ 278 | {'field': 'name', 'op': '==', 'value': 'name_1'}, # ambiguous 279 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 280 | ] 281 | with pytest.raises(BadSpec) as err: 282 | apply_filters(query, filters) 283 | 284 | assert 'Ambiguous spec. Please specify a model.' == err.value.args[0] 285 | 286 | @pytest.mark.usefixtures('multiple_foos_inserted') 287 | def test_eager_load(self, session): 288 | 289 | # behaves as if the joinedload wasn't present 290 | query = session.query(Foo).options(joinedload(Foo.bar)) 291 | filters = [ 292 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 293 | {'model': 'Bar', 'field': 'count', 'op': 'is_null'}, 294 | ] 295 | 296 | filtered_query = apply_filters(query, filters) 297 | result = filtered_query.all() 298 | 299 | assert len(result) == 1 300 | assert result[0].id == 3 301 | assert result[0].bar_id == 3 302 | assert result[0].bar.count is None 303 | 304 | 305 | class TestApplyIsNullFilter: 306 | 307 | @pytest.mark.usefixtures('multiple_bars_inserted') 308 | def test_filter_field_with_null_values(self, session): 309 | query = session.query(Bar) 310 | filters = [{'field': 'count', 'op': 'is_null'}] 311 | 312 | filtered_query = apply_filters(query, filters) 313 | result = filtered_query.all() 314 | 315 | assert len(result) == 1 316 | assert result[0].id == 3 317 | 318 | @pytest.mark.usefixtures('multiple_bars_inserted') 319 | def test_filter_field_with_no_null_values(self, session): 320 | query = session.query(Bar) 321 | filters = [{'field': 'name', 'op': 'is_null'}] 322 | 323 | filtered_query = apply_filters(query, filters) 324 | result = filtered_query.all() 325 | 326 | assert len(result) == 0 327 | 328 | 329 | class TestApplyIsNotNullFilter: 330 | 331 | @pytest.mark.usefixtures('multiple_bars_inserted') 332 | def test_filter_field_with_null_values(self, session): 333 | query = session.query(Bar) 334 | filters = [{'field': 'count', 'op': 'is_not_null'}] 335 | 336 | filtered_query = apply_filters(query, filters) 337 | result = filtered_query.all() 338 | 339 | assert len(result) == 3 340 | assert result[0].id == 1 341 | assert result[1].id == 2 342 | assert result[2].id == 4 343 | 344 | @pytest.mark.usefixtures('multiple_bars_inserted') 345 | def test_filter_field_with_no_null_values(self, session): 346 | query = session.query(Bar) 347 | filters = [{'field': 'name', 'op': 'is_not_null'}] 348 | 349 | filtered_query = apply_filters(query, filters) 350 | result = filtered_query.all() 351 | 352 | assert len(result) == 4 353 | assert result[0].id == 1 354 | assert result[1].id == 2 355 | assert result[2].id == 3 356 | assert result[3].id == 4 357 | 358 | 359 | class TestApplyFiltersMultipleTimes: 360 | 361 | @pytest.mark.usefixtures('multiple_bars_inserted') 362 | def test_concatenate_queries(self, session): 363 | query = session.query(Bar) 364 | filters = [{'field': 'name', 'op': '==', 'value': 'name_1'}] 365 | 366 | filtered_query = apply_filters(query, filters) 367 | result = filtered_query.all() 368 | 369 | assert len(result) == 2 370 | assert result[0].id == 1 371 | assert result[0].name == 'name_1' 372 | assert result[1].id == 3 373 | assert result[1].name == 'name_1' 374 | 375 | filters = [{'field': 'id', 'op': '==', 'value': 3}] 376 | 377 | filtered_query = apply_filters(filtered_query, filters) 378 | result = filtered_query.all() 379 | 380 | assert len(result) == 1 381 | assert result[0].id == 3 382 | assert result[0].name == 'name_1' 383 | 384 | 385 | class TestApplyFilterWithoutList: 386 | 387 | @pytest.mark.usefixtures('multiple_bars_inserted') 388 | def test_a_single_dict_can_be_supplied_as_filters(self, session): 389 | query = session.query(Bar) 390 | filters = {'field': 'name', 'op': '==', 'value': 'name_1'} 391 | 392 | filtered_query = apply_filters(query, filters) 393 | result = filtered_query.all() 394 | 395 | assert len(result) == 2 396 | assert result[0].id == 1 397 | assert result[0].name == 'name_1' 398 | assert result[1].id == 3 399 | assert result[1].name == 'name_1' 400 | 401 | 402 | class TestApplyFilterOnFieldBasedQuery: 403 | 404 | @pytest.mark.usefixtures('multiple_bars_inserted') 405 | def test_apply_filter_on_single_field_query(self, session): 406 | query = session.query(Bar.id) 407 | filters = [{'field': 'name', 'op': '==', 'value': 'name_1'}] 408 | 409 | filtered_query = apply_filters(query, filters) 410 | result = filtered_query.all() 411 | 412 | assert len(result) == 2 413 | assert result[0] == (1,) 414 | assert result[1] == (3,) 415 | 416 | @pytest.mark.usefixtures('multiple_bars_inserted') 417 | def test_apply_filter_on_aggregate_query(self, session): 418 | query = session.query(func.count(Bar.id)) 419 | filters = [{'field': 'name', 'op': '==', 'value': 'name_1'}] 420 | 421 | filtered_query = apply_filters(query, filters) 422 | result = filtered_query.all() 423 | 424 | assert len(result) == 1 425 | assert result[0] == (2,) 426 | 427 | 428 | class TestApplyEqualToFilter: 429 | 430 | @pytest.mark.parametrize('operator', ['==', 'eq']) 431 | @pytest.mark.usefixtures('multiple_bars_inserted') 432 | def test_one_filter_applied_to_a_single_model(self, session, operator): 433 | query = session.query(Bar) 434 | filters = [{'field': 'name', 'op': operator, 'value': 'name_1'}] 435 | 436 | filtered_query = apply_filters(query, filters) 437 | result = filtered_query.all() 438 | 439 | assert len(result) == 2 440 | assert result[0].id == 1 441 | assert result[0].name == 'name_1' 442 | assert result[1].id == 3 443 | assert result[1].name == 'name_1' 444 | 445 | @pytest.mark.parametrize( 446 | 'filters', [ 447 | [ # filters using `==` in a list 448 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 449 | {'field': 'id', 'op': '==', 'value': 3} 450 | ], 451 | ( # filters using `eq` in a tuple 452 | {'field': 'name', 'op': 'eq', 'value': 'name_1'}, 453 | {'field': 'id', 'op': 'eq', 'value': 3} 454 | ) 455 | ] 456 | ) 457 | @pytest.mark.usefixtures('multiple_bars_inserted') 458 | def test_multiple_filters_applied_to_a_single_model( 459 | self, session, filters 460 | ): 461 | query = session.query(Bar) 462 | 463 | filtered_query = apply_filters(query, filters) 464 | result = filtered_query.all() 465 | 466 | assert len(result) == 1 467 | assert result[0].id == 3 468 | assert result[0].name == 'name_1' 469 | 470 | 471 | class TestApplyNotEqualToFilter: 472 | 473 | @pytest.mark.parametrize('operator', ['!=', 'ne']) 474 | @pytest.mark.usefixtures('multiple_bars_inserted') 475 | def test_one_filter_applied_to_a_single_model(self, session, operator): 476 | query = session.query(Bar) 477 | filters = [{'field': 'name', 'op': operator, 'value': 'name_1'}] 478 | 479 | filtered_query = apply_filters(query, filters) 480 | result = filtered_query.all() 481 | 482 | assert len(result) == 2 483 | assert result[0].id == 2 484 | assert result[0].name == 'name_2' 485 | assert result[1].id == 4 486 | assert result[1].name == 'name_4' 487 | 488 | @pytest.mark.parametrize('operator', ['!=', 'ne']) 489 | @pytest.mark.usefixtures('multiple_bars_inserted') 490 | def test_multiple_filters_applied_to_a_single_model( 491 | self, session, operator 492 | ): 493 | query = session.query(Bar) 494 | filters = [ 495 | {'field': 'name', 'op': operator, 'value': 'name_2'}, 496 | {'field': 'id', 'op': operator, 'value': 3} 497 | ] 498 | 499 | filtered_query = apply_filters(query, filters) 500 | result = filtered_query.all() 501 | 502 | assert len(result) == 2 503 | assert result[0].id == 1 504 | assert result[0].name == 'name_1' 505 | assert result[1].id == 4 506 | assert result[1].name == 'name_4' 507 | 508 | 509 | class TestApplyGreaterThanFilter: 510 | 511 | @pytest.mark.parametrize('operator', ['>', 'gt']) 512 | @pytest.mark.usefixtures('multiple_bars_inserted') 513 | def test_one_filter_applied_to_a_single_model(self, session, operator): 514 | query = session.query(Bar) 515 | filters = [{'field': 'count', 'op': operator, 'value': '5'}] 516 | 517 | filtered_query = apply_filters(query, filters) 518 | result = filtered_query.all() 519 | 520 | assert len(result) == 2 521 | assert result[0].id == 2 522 | assert result[1].id == 4 523 | 524 | @pytest.mark.parametrize('operator', ['>', 'gt']) 525 | @pytest.mark.usefixtures('multiple_bars_inserted') 526 | def test_multiple_filters_applied_to_a_single_model( 527 | self, session, operator 528 | ): 529 | query = session.query(Bar) 530 | filters = [ 531 | {'field': 'count', 'op': operator, 'value': '5'}, 532 | {'field': 'id', 'op': operator, 'value': 2}, 533 | ] 534 | 535 | filtered_query = apply_filters(query, filters) 536 | result = filtered_query.all() 537 | 538 | assert len(result) == 1 539 | assert result[0].id == 4 540 | 541 | 542 | class TestApplyLessThanFilter: 543 | 544 | @pytest.mark.parametrize('operator', ['<', 'lt']) 545 | @pytest.mark.usefixtures('multiple_bars_inserted') 546 | def test_one_filter_applied_to_a_single_model(self, session, operator): 547 | query = session.query(Bar) 548 | filters = [{'field': 'count', 'op': operator, 'value': '7'}] 549 | 550 | filtered_query = apply_filters(query, filters) 551 | result = filtered_query.all() 552 | 553 | assert len(result) == 1 554 | assert result[0].id == 1 555 | 556 | @pytest.mark.parametrize('operator', ['<', 'lt']) 557 | @pytest.mark.usefixtures('multiple_bars_inserted') 558 | def test_multiple_filters_applied_to_a_single_model( 559 | self, session, operator 560 | ): 561 | query = session.query(Bar) 562 | filters = [ 563 | {'field': 'count', 'op': operator, 'value': '7'}, 564 | {'field': 'id', 'op': operator, 'value': 1}, 565 | ] 566 | 567 | filtered_query = apply_filters(query, filters) 568 | result = filtered_query.all() 569 | 570 | assert len(result) == 0 571 | 572 | 573 | class TestApplyGreaterOrEqualThanFilter: 574 | 575 | @pytest.mark.parametrize('operator', ['>=', 'ge']) 576 | @pytest.mark.usefixtures('multiple_bars_inserted') 577 | def test_one_filter_applied_to_a_single_model(self, session, operator): 578 | query = session.query(Bar) 579 | filters = [{'field': 'count', 'op': operator, 'value': '5'}] 580 | 581 | filtered_query = apply_filters(query, filters) 582 | result = filtered_query.all() 583 | 584 | assert len(result) == 3 585 | assert result[0].id == 1 586 | assert result[1].id == 2 587 | assert result[2].id == 4 588 | 589 | @pytest.mark.parametrize('operator', ['>=', 'ge']) 590 | @pytest.mark.usefixtures('multiple_bars_inserted') 591 | def test_multiple_filters_applied_to_a_single_model( 592 | self, session, operator 593 | ): 594 | query = session.query(Bar) 595 | filters = [ 596 | {'field': 'count', 'op': operator, 'value': '5'}, 597 | {'field': 'id', 'op': operator, 'value': 4}, 598 | ] 599 | 600 | filtered_query = apply_filters(query, filters) 601 | result = filtered_query.all() 602 | 603 | assert len(result) == 1 604 | assert result[0].id == 4 605 | 606 | 607 | class TestApplyLessOrEqualThanFilter: 608 | 609 | @pytest.mark.parametrize('operator', ['<=', 'le']) 610 | @pytest.mark.usefixtures('multiple_bars_inserted') 611 | def test_one_filter_applied_to_a_single_model(self, session, operator): 612 | query = session.query(Bar) 613 | filters = [{'field': 'count', 'op': operator, 'value': '15'}] 614 | 615 | filtered_query = apply_filters(query, filters) 616 | result = filtered_query.all() 617 | 618 | assert len(result) == 3 619 | assert result[0].id == 1 620 | assert result[1].id == 2 621 | assert result[2].id == 4 622 | 623 | @pytest.mark.parametrize('operator', ['<=', 'le']) 624 | @pytest.mark.usefixtures('multiple_bars_inserted') 625 | def test_multiple_filters_applied_to_a_single_model( 626 | self, session, operator 627 | ): 628 | query = session.query(Bar) 629 | filters = [ 630 | {'field': 'count', 'op': operator, 'value': '15'}, 631 | {'field': 'id', 'op': operator, 'value': 1}, 632 | ] 633 | 634 | filtered_query = apply_filters(query, filters) 635 | result = filtered_query.all() 636 | 637 | assert len(result) == 1 638 | assert result[0].id == 1 639 | 640 | 641 | class TestApplyLikeFilter: 642 | 643 | @pytest.mark.usefixtures('multiple_bars_inserted') 644 | def test_one_filter_applied_to_a_single_model(self, session): 645 | query = session.query(Bar) 646 | filters = [{'field': 'name', 'op': 'like', 'value': '%me_1'}] 647 | 648 | filtered_query = apply_filters(query, filters) 649 | result = filtered_query.all() 650 | 651 | assert len(result) == 2 652 | assert result[0].id == 1 653 | assert result[1].id == 3 654 | 655 | 656 | class TestApplyILikeFilter: 657 | 658 | @pytest.mark.usefixtures('multiple_bars_inserted') 659 | def test_one_filter_applied_to_a_single_model(self, session): 660 | query = session.query(Bar) 661 | filters = [{'field': 'name', 'op': 'ilike', 'value': '%ME_1'}] 662 | 663 | filtered_query = apply_filters(query, filters) 664 | result = filtered_query.all() 665 | 666 | assert len(result) == 2 667 | assert result[0].id == 1 668 | assert result[1].id == 3 669 | 670 | 671 | class TestApplyNotILikeFilter: 672 | 673 | @pytest.mark.usefixtures('multiple_bars_inserted') 674 | def test_one_filter_applied_to_a_single_model(self, session): 675 | query = session.query(Bar) 676 | filters = [{'field': 'name', 'op': 'not_ilike', 'value': '%ME_1'}] 677 | 678 | filtered_query = apply_filters(query, filters) 679 | result = filtered_query.all() 680 | 681 | assert len(result) == 2 682 | assert result[0].id == 2 683 | assert result[1].id == 4 684 | 685 | 686 | class TestApplyInFilter: 687 | 688 | @pytest.mark.usefixtures('multiple_bars_inserted') 689 | def test_field_not_in_value_list(self, session): 690 | query = session.query(Bar) 691 | filters = [{'field': 'count', 'op': 'in', 'value': [1, 2, 3]}] 692 | 693 | filtered_query = apply_filters(query, filters) 694 | result = filtered_query.all() 695 | 696 | assert len(result) == 0 697 | 698 | @pytest.mark.usefixtures('multiple_bars_inserted') 699 | def test_field_in_value_list(self, session): 700 | query = session.query(Bar) 701 | filters = [{'field': 'count', 'op': 'in', 'value': [15, 2, 3]}] 702 | 703 | filtered_query = apply_filters(query, filters) 704 | result = filtered_query.all() 705 | 706 | assert len(result) == 1 707 | assert result[0].id == 4 708 | 709 | 710 | class TestApplyNotInFilter: 711 | 712 | @pytest.mark.usefixtures('multiple_bars_inserted') 713 | def test_field_not_in_value_list(self, session): 714 | query = session.query(Bar) 715 | filters = [{'field': 'count', 'op': 'not_in', 'value': [1, 2, 3]}] 716 | 717 | filtered_query = apply_filters(query, filters) 718 | result = filtered_query.all() 719 | 720 | assert len(result) == 3 721 | assert result[0].id == 1 722 | assert result[1].id == 2 723 | assert result[2].id == 4 724 | 725 | @pytest.mark.usefixtures('multiple_bars_inserted') 726 | def test_field_in_value_list(self, session): 727 | query = session.query(Bar) 728 | filters = [{'field': 'count', 'op': 'not_in', 'value': [15, 2, 3]}] 729 | 730 | filtered_query = apply_filters(query, filters) 731 | result = filtered_query.all() 732 | 733 | assert len(result) == 2 734 | assert result[0].id == 1 735 | assert result[1].id == 2 736 | 737 | 738 | class TestDateFields: 739 | 740 | @pytest.mark.parametrize( 741 | 'value', 742 | [ 743 | datetime.date(2016, 7, 14), 744 | datetime.date(2016, 7, 14).isoformat() 745 | ] 746 | ) 747 | @pytest.mark.usefixtures('multiple_quxs_inserted') 748 | def test_filter_date_equality(self, session, value): 749 | query = session.query(Qux) 750 | filters = [{ 751 | 'field': 'created_at', 752 | 'op': '==', 753 | 'value': value 754 | }] 755 | 756 | filtered_query = apply_filters(query, filters) 757 | result = filtered_query.all() 758 | 759 | assert len(result) == 1 760 | assert result[0].created_at == datetime.date(2016, 7, 14) 761 | 762 | @pytest.mark.parametrize( 763 | 'value', 764 | [ 765 | datetime.date(2016, 7, 13), 766 | datetime.date(2016, 7, 13).isoformat() 767 | ] 768 | ) 769 | @pytest.mark.usefixtures('multiple_quxs_inserted') 770 | def test_filter_multiple_dates(self, session, value): 771 | query = session.query(Qux) 772 | filters = [{ 773 | 'field': 'created_at', 774 | 'op': '>=', 775 | 'value': value 776 | }] 777 | 778 | filtered_query = apply_filters(query, filters) 779 | result = filtered_query.all() 780 | 781 | assert len(result) == 2 782 | assert result[0].created_at == datetime.date(2016, 7, 13) 783 | assert result[1].created_at == datetime.date(2016, 7, 14) 784 | 785 | @pytest.mark.usefixtures('multiple_quxs_inserted') 786 | def test_null_date(self, session): 787 | query = session.query(Qux) 788 | filters = [{'field': 'created_at', 'op': 'is_null'}] 789 | 790 | filtered_query = apply_filters(query, filters) 791 | result = filtered_query.all() 792 | 793 | assert len(result) == 1 794 | assert result[0].created_at is None 795 | 796 | 797 | class TestTimeFields: 798 | 799 | @pytest.mark.parametrize( 800 | 'value', 801 | [ 802 | datetime.time(3, 5, 9), 803 | datetime.time(3, 5, 9).isoformat() # '03:05:09' 804 | ] 805 | ) 806 | @pytest.mark.usefixtures('multiple_quxs_inserted') 807 | def test_filter_time_equality(self, session, is_sqlite, value): 808 | if isinstance(value, string_types) and is_sqlite: 809 | pytest.skip(STRING_DATE_TIME_NOT_SUPPORTED) 810 | 811 | query = session.query(Qux) 812 | filters = [{'field': 'expiration_time', 'op': '==', 'value': value}] 813 | 814 | filtered_query = apply_filters(query, filters) 815 | result = filtered_query.all() 816 | 817 | assert len(result) == 1 818 | assert result[0].expiration_time == datetime.time(3, 5, 9) 819 | 820 | @pytest.mark.parametrize( 821 | 'value', 822 | [ 823 | datetime.time(2, 5, 9), 824 | datetime.time(2, 5, 9).isoformat() # '02:05:09' 825 | ] 826 | ) 827 | @pytest.mark.usefixtures('multiple_quxs_inserted') 828 | def test_filter_multiple_times(self, session, is_sqlite, value): 829 | if isinstance(value, string_types) and is_sqlite: 830 | pytest.skip(STRING_DATE_TIME_NOT_SUPPORTED) 831 | 832 | query = session.query(Qux) 833 | filters = [{ 834 | 'field': 'expiration_time', 835 | 'op': '>=', 836 | 'value': value 837 | }] 838 | 839 | filtered_query = apply_filters(query, filters) 840 | result = filtered_query.all() 841 | 842 | assert len(result) == 2 843 | assert result[0].expiration_time == datetime.time(2, 5, 9) 844 | assert result[1].expiration_time == datetime.time(3, 5, 9) 845 | 846 | @pytest.mark.usefixtures('multiple_quxs_inserted') 847 | def test_null_time(self, session): 848 | query = session.query(Qux) 849 | filters = [{'field': 'expiration_time', 'op': 'is_null'}] 850 | 851 | filtered_query = apply_filters(query, filters) 852 | result = filtered_query.all() 853 | 854 | assert len(result) == 1 855 | assert result[0].expiration_time is None 856 | 857 | 858 | class TestDateTimeFields: 859 | 860 | @pytest.mark.parametrize( 861 | 'value', 862 | [ 863 | datetime.datetime(2016, 7, 14, 3, 5, 9), 864 | # '2016-07-14T03:05:09' 865 | datetime.datetime(2016, 7, 14, 3, 5, 9).isoformat() 866 | ] 867 | ) 868 | @pytest.mark.usefixtures('multiple_quxs_inserted') 869 | def test_filter_datetime_equality(self, session, is_sqlite, value): 870 | if isinstance(value, string_types) and is_sqlite: 871 | pytest.skip(STRING_DATE_TIME_NOT_SUPPORTED) 872 | 873 | query = session.query(Qux) 874 | filters = [{ 875 | 'field': 'execution_time', 876 | 'op': '==', 877 | 'value': value 878 | }] 879 | 880 | filtered_query = apply_filters(query, filters) 881 | result = filtered_query.all() 882 | 883 | assert len(result) == 1 884 | assert result[0].execution_time == datetime.datetime( 885 | 2016, 7, 14, 3, 5, 9 886 | ) 887 | 888 | @pytest.mark.parametrize( 889 | 'value', 890 | [ 891 | datetime.datetime(2016, 7, 13, 2, 5, 9), 892 | # '2016-07-13T02:05:09' 893 | datetime.datetime(2016, 7, 13, 2, 5, 9).isoformat() 894 | ] 895 | ) 896 | @pytest.mark.usefixtures('multiple_quxs_inserted') 897 | def test_filter_multiple_datetimes(self, session, is_sqlite, value): 898 | if isinstance(value, string_types) and is_sqlite: 899 | pytest.skip(STRING_DATE_TIME_NOT_SUPPORTED) 900 | 901 | query = session.query(Qux) 902 | filters = [{ 903 | 'field': 'execution_time', 904 | 'op': '>=', 905 | 'value': value 906 | }] 907 | 908 | filtered_query = apply_filters(query, filters) 909 | result = filtered_query.all() 910 | 911 | assert len(result) == 2 912 | assert result[0].execution_time == datetime.datetime( 913 | 2016, 7, 13, 2, 5, 9 914 | ) 915 | assert result[1].execution_time == datetime.datetime( 916 | 2016, 7, 14, 3, 5, 9 917 | ) 918 | 919 | @pytest.mark.usefixtures('multiple_quxs_inserted') 920 | def test_null_datetime(self, session): 921 | query = session.query(Qux) 922 | filters = [{'field': 'execution_time', 'op': 'is_null'}] 923 | 924 | filtered_query = apply_filters(query, filters) 925 | result = filtered_query.all() 926 | 927 | assert len(result) == 1 928 | assert result[0].execution_time is None 929 | 930 | 931 | class TestApplyBooleanFunctions: 932 | 933 | @pytest.mark.usefixtures('multiple_bars_inserted') 934 | def test_or(self, session): 935 | query = session.query(Bar) 936 | filters = [ 937 | {'or': [ 938 | {'field': 'id', 'op': '==', 'value': 1}, 939 | {'field': 'id', 'op': '==', 'value': 3}, 940 | ]}, 941 | ] 942 | 943 | filtered_query = apply_filters(query, filters) 944 | result = filtered_query.all() 945 | 946 | assert len(result) == 2 947 | assert result[0].id == 1 948 | assert result[1].id == 3 949 | 950 | @pytest.mark.usefixtures('multiple_bars_inserted') 951 | def test_or_with_one_arg(self, session): 952 | query = session.query(Bar) 953 | filters = [ 954 | {'or': [ 955 | {'field': 'id', 'op': '==', 'value': 1}, 956 | ]}, 957 | ] 958 | 959 | filtered_query = apply_filters(query, filters) 960 | result = filtered_query.all() 961 | 962 | assert len(result) == 1 963 | assert result[0].id == 1 964 | 965 | @pytest.mark.usefixtures('multiple_bars_inserted') 966 | def test_or_with_three_args(self, session): 967 | query = session.query(Bar) 968 | filters = [ 969 | {'or': [ 970 | {'field': 'id', 'op': '==', 'value': 1}, 971 | {'field': 'id', 'op': '==', 'value': 3}, 972 | {'field': 'id', 'op': '==', 'value': 4}, 973 | ]}, 974 | ] 975 | 976 | filtered_query = apply_filters(query, filters) 977 | result = filtered_query.all() 978 | 979 | assert len(result) == 3 980 | assert result[0].id == 1 981 | assert result[1].id == 3 982 | assert result[2].id == 4 983 | 984 | @pytest.mark.parametrize( 985 | ('or_args', 'expected_error'), [ 986 | ( 987 | [], 988 | '`or` must have one or more arguments' 989 | ), 990 | ( 991 | {}, 992 | '`or` value must be an iterable across the function arguments' 993 | ), 994 | ( 995 | 'hello', 996 | '`or` value must be an iterable across the function arguments' 997 | ), 998 | ] 999 | ) 1000 | @pytest.mark.usefixtures('multiple_bars_inserted') 1001 | def test_or_with_bad_format(self, session, or_args, expected_error): 1002 | query = session.query(Bar) 1003 | filters = [{'or': or_args}] 1004 | 1005 | with pytest.raises(BadFilterFormat) as exc: 1006 | apply_filters(query, filters) 1007 | 1008 | assert expected_error in str(exc) 1009 | 1010 | @pytest.mark.usefixtures('multiple_bars_inserted') 1011 | def test_and(self, session): 1012 | query = session.query(Bar) 1013 | filters = [ 1014 | {'and': [ 1015 | {'field': 'id', 'op': '<=', 'value': 2}, 1016 | {'field': 'count', 'op': '>=', 'value': 6}, 1017 | ]}, 1018 | ] 1019 | 1020 | filtered_query = apply_filters(query, filters) 1021 | result = filtered_query.all() 1022 | 1023 | assert len(result) == 1 1024 | assert result[0].id == 2 1025 | 1026 | @pytest.mark.usefixtures('multiple_bars_inserted') 1027 | def test_and_with_one_arg(self, session): 1028 | query = session.query(Bar) 1029 | filters = [ 1030 | {'and': [ 1031 | {'field': 'id', 'op': '==', 'value': 3}, 1032 | ]}, 1033 | ] 1034 | 1035 | filtered_query = apply_filters(query, filters) 1036 | result = filtered_query.all() 1037 | 1038 | assert len(result) == 1 1039 | assert result[0].id == 3 1040 | 1041 | @pytest.mark.usefixtures('multiple_bars_inserted') 1042 | def test_and_with_three_args(self, session): 1043 | query = session.query(Bar) 1044 | filters = [ 1045 | {'and': [ 1046 | {'field': 'id', 'op': '<=', 'value': 3}, 1047 | {'field': 'name', 'op': '==', 'value': 'name_1'}, 1048 | {'field': 'count', 'op': 'is_not_null'}, 1049 | ]}, 1050 | ] 1051 | 1052 | filtered_query = apply_filters(query, filters) 1053 | result = filtered_query.all() 1054 | 1055 | assert len(result) == 1 1056 | assert result[0].id == 1 1057 | 1058 | @pytest.mark.parametrize( 1059 | ('and_args', 'expected_error'), [ 1060 | ( 1061 | [], 1062 | '`and` must have one or more arguments' 1063 | ), 1064 | ( 1065 | {}, 1066 | '`and` value must be an iterable across the function arguments' 1067 | ), 1068 | ( 1069 | 'hello', 1070 | '`and` value must be an iterable across the function arguments' 1071 | ), 1072 | ] 1073 | ) 1074 | @pytest.mark.usefixtures('multiple_bars_inserted') 1075 | def test_and_with_bad_format(self, session, and_args, expected_error): 1076 | query = session.query(Bar) 1077 | filters = [{'and': and_args}] 1078 | 1079 | with pytest.raises(BadFilterFormat) as exc: 1080 | apply_filters(query, filters) 1081 | 1082 | assert expected_error in str(exc) 1083 | 1084 | @pytest.mark.usefixtures('multiple_bars_inserted') 1085 | def test_not(self, session): 1086 | query = session.query(Bar) 1087 | filters = [ 1088 | {'not': [ 1089 | {'field': 'id', 'op': '==', 'value': 3}, 1090 | ]}, 1091 | ] 1092 | 1093 | filtered_query = apply_filters(query, filters) 1094 | result = filtered_query.all() 1095 | 1096 | assert len(result) == 3 1097 | assert result[0].id == 1 1098 | assert result[1].id == 2 1099 | assert result[2].id == 4 1100 | 1101 | @pytest.mark.parametrize( 1102 | ('not_args', 'expected_error'), [ 1103 | ( 1104 | [{'field': 'id', 'op': '==', 'value': 1}, 1105 | {'field': 'id', 'op': '==', 'value': 2}], 1106 | '`not` must have one argument' 1107 | ), 1108 | ( 1109 | [], 1110 | '`not` must have one argument' 1111 | ), 1112 | ( 1113 | {}, 1114 | '`not` value must be an iterable across the function arguments' 1115 | ), 1116 | ( 1117 | 'hello', 1118 | '`not` value must be an iterable across the function arguments' 1119 | ), 1120 | ] 1121 | ) 1122 | @pytest.mark.usefixtures('multiple_bars_inserted') 1123 | def test_not_with_bad_format(self, session, not_args, expected_error): 1124 | query = session.query(Bar) 1125 | filters = [{'not': not_args}] 1126 | 1127 | with pytest.raises(BadFilterFormat) as exc: 1128 | apply_filters(query, filters) 1129 | 1130 | assert expected_error in str(exc) 1131 | 1132 | @pytest.mark.usefixtures('multiple_bars_inserted') 1133 | def test_complex(self, session): 1134 | query = session.query(Bar) 1135 | filters = [ 1136 | { 1137 | 'and': [ 1138 | { 1139 | 'or': [ 1140 | {'field': 'id', 'op': '==', 'value': 2}, 1141 | {'field': 'id', 'op': '==', 'value': 3}, 1142 | ] 1143 | }, 1144 | { 1145 | 'not': [ 1146 | {'field': 'name', 'op': '==', 'value': 'name_2'} 1147 | ] 1148 | }, 1149 | ], 1150 | } 1151 | ] 1152 | 1153 | filtered_query = apply_filters(query, filters) 1154 | result = filtered_query.all() 1155 | 1156 | assert len(result) == 1 1157 | assert result[0].id == 3 1158 | 1159 | @pytest.mark.usefixtures('multiple_bars_inserted') 1160 | def test_complex_using_tuples(self, session): 1161 | query = session.query(Bar) 1162 | filters = ( 1163 | { 1164 | 'and': ( 1165 | { 1166 | 'or': ( 1167 | {'field': 'id', 'op': '==', 'value': 2}, 1168 | {'field': 'id', 'op': '==', 'value': 3}, 1169 | ) 1170 | }, 1171 | { 1172 | 'not': ( 1173 | {'field': 'name', 'op': '==', 'value': 'name_2'}, 1174 | ) 1175 | }, 1176 | ), 1177 | }, 1178 | ) 1179 | 1180 | filtered_query = apply_filters(query, filters) 1181 | result = filtered_query.all() 1182 | 1183 | assert len(result) == 1 1184 | assert result[0].id == 3 1185 | 1186 | 1187 | class TestApplyArrayFilters: 1188 | 1189 | @pytest.mark.usefixtures('multiple_corges_inserted') 1190 | def test_any_value_in_array(self, session, is_postgresql): 1191 | if not is_postgresql: 1192 | pytest.skip(ARRAY_NOT_SUPPORTED) 1193 | 1194 | query = session.query(Corge) 1195 | filters = [{'field': 'tags', 'op': 'any', 'value': 'foo'}] 1196 | 1197 | filtered_query = apply_filters(query, filters) 1198 | result = filtered_query.all() 1199 | 1200 | assert len(result) == 2 1201 | assert result[0].id == 2 1202 | assert result[1].id == 3 1203 | 1204 | @pytest.mark.usefixtures('multiple_corges_inserted') 1205 | def test_not_any_values_in_array(self, session, is_postgresql): 1206 | if not is_postgresql: 1207 | pytest.skip(ARRAY_NOT_SUPPORTED) 1208 | 1209 | query = session.query(Corge) 1210 | filters = [{'field': 'tags', 'op': 'not_any', 'value': 'foo'}] 1211 | 1212 | filtered_query = apply_filters(query, filters) 1213 | result = filtered_query.all() 1214 | 1215 | assert len(result) == 2 1216 | assert result[0].id == 1 1217 | assert result[1].id == 4 1218 | 1219 | 1220 | class TestHybridAttributes: 1221 | 1222 | @pytest.mark.usefixtures('multiple_bars_inserted') 1223 | @pytest.mark.parametrize( 1224 | ('field, expected_error'), 1225 | [ 1226 | ('foos', "Model has no column `foos`."), 1227 | ( 1228 | '__mapper__', 1229 | "Model has no column `__mapper__`.", 1230 | ), 1231 | ( 1232 | 'not_valid', 1233 | "Model has no column `not_valid`.", 1234 | ), 1235 | ] 1236 | ) 1237 | def test_orm_descriptors_not_valid_hybrid_attributes( 1238 | self, session, field, expected_error 1239 | ): 1240 | query = session.query(Bar) 1241 | filters = [ 1242 | { 1243 | 'model': 'Bar', 1244 | 'field': field, 1245 | 'op': '==', 1246 | 'value': 100 1247 | } 1248 | ] 1249 | with pytest.raises(FieldNotFound) as exc: 1250 | apply_filters(query, filters) 1251 | 1252 | assert expected_error in str(exc) 1253 | 1254 | @pytest.mark.usefixtures('multiple_bars_inserted') 1255 | @pytest.mark.usefixtures('multiple_quxs_inserted') 1256 | def test_filter_by_hybrid_properties(self, session): 1257 | query = session.query(Bar, Qux) 1258 | filters = [ 1259 | { 1260 | 'model': 'Bar', 1261 | 'field': 'count_square', 1262 | 'op': '==', 1263 | 'value': 100 1264 | }, 1265 | { 1266 | 'model': 'Qux', 1267 | 'field': 'count_square', 1268 | 'op': '>=', 1269 | 'value': 26 1270 | }, 1271 | ] 1272 | 1273 | filtered_query = apply_filters(query, filters) 1274 | result = filtered_query.all() 1275 | 1276 | assert len(result) == 2 1277 | bars, quxs = zip(*result) 1278 | 1279 | assert set(map(type, bars)) == {Bar} 1280 | assert {bar.id for bar in bars} == {2} 1281 | assert {bar.count_square for bar in bars} == {100} 1282 | 1283 | assert set(map(type, quxs)) == {Qux} 1284 | assert {qux.id for qux in quxs} == {2, 4} 1285 | assert {qux.count_square for qux in quxs} == {100, 225} 1286 | 1287 | @pytest.mark.usefixtures('multiple_bars_inserted') 1288 | @pytest.mark.usefixtures('multiple_quxs_inserted') 1289 | def test_filter_by_hybrid_methods(self, session): 1290 | query = session.query(Bar, Qux) 1291 | filters = [ 1292 | { 1293 | 'model': 'Bar', 1294 | 'field': 'three_times_count', 1295 | 'op': '==', 1296 | 'value': 30 1297 | }, 1298 | { 1299 | 'model': 'Qux', 1300 | 'field': 'three_times_count', 1301 | 'op': '>=', 1302 | 'value': 31 1303 | }, 1304 | ] 1305 | 1306 | filtered_query = apply_filters(query, filters) 1307 | result = filtered_query.all() 1308 | 1309 | assert len(result) == 1 1310 | bars, quxs = zip(*result) 1311 | 1312 | assert set(map(type, bars)) == {Bar} 1313 | assert {bar.id for bar in bars} == {2} 1314 | assert {bar.three_times_count() for bar in bars} == {30} 1315 | 1316 | assert set(map(type, quxs)) == {Qux} 1317 | assert {qux.id for qux in quxs} == {4} 1318 | assert {qux.three_times_count() for qux in quxs} == {45} 1319 | -------------------------------------------------------------------------------- /test/interface/test_loads.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from sqlalchemy.orm import joinedload 4 | 5 | from sqlalchemy_filters import apply_loads 6 | from sqlalchemy_filters.exceptions import BadLoadFormat, BadSpec, FieldNotFound 7 | from test.models import Foo, Bar 8 | from test import error_value 9 | 10 | 11 | @pytest.fixture 12 | def multiple_bars_inserted(session): 13 | bar_1 = Bar(id=1, name='name_1', count=5) 14 | bar_2 = Bar(id=2, name='name_2', count=10) 15 | bar_3 = Bar(id=3, name='name_1', count=None) 16 | bar_4 = Bar(id=4, name='name_4', count=15) 17 | session.add_all([bar_1, bar_2, bar_3, bar_4]) 18 | session.commit() 19 | 20 | 21 | @pytest.fixture 22 | def multiple_foos_inserted(multiple_bars_inserted, session): 23 | foo_1 = Foo(id=1, name='name_1', count=5, bar_id=1) 24 | foo_2 = Foo(id=2, name='name_2', count=10, bar_id=2) 25 | foo_3 = Foo(id=3, name='name_1', count=None, bar_id=3) 26 | foo_4 = Foo(id=4, name='name_4', count=15, bar_id=4) 27 | session.add_all([foo_1, foo_2, foo_3, foo_4]) 28 | session.commit() 29 | 30 | 31 | class TestLoadNotApplied(object): 32 | 33 | @pytest.mark.parametrize('spec', [1, []]) 34 | def test_wrong_spec_format(self, session, spec): 35 | query = session.query(Bar) 36 | load_spec = [spec] 37 | 38 | with pytest.raises(BadLoadFormat) as err: 39 | apply_loads(query, load_spec) 40 | 41 | expected_error = 'Load spec `{}` should be a dictionary.'.format(spec) 42 | assert expected_error == error_value(err) 43 | 44 | def test_field_not_provided(self, session): 45 | query = session.query(Bar) 46 | load_spec = [{}] 47 | 48 | with pytest.raises(BadLoadFormat) as err: 49 | apply_loads(query, load_spec) 50 | 51 | expected_error = '`fields` is a mandatory attribute.' 52 | assert expected_error == error_value(err) 53 | 54 | def test_invalid_field(self, session): 55 | query = session.query(Bar) 56 | load_spec = [{'fields': ['invalid_field']}] 57 | 58 | with pytest.raises(FieldNotFound) as err: 59 | apply_loads(query, load_spec) 60 | 61 | expected_error = ( 62 | "Model has no column `invalid_field`." 63 | ) 64 | assert expected_error == error_value(err) 65 | 66 | 67 | class TestLoadsApplied(object): 68 | 69 | def test_no_load_provided(self, session): 70 | query = session.query(Bar) 71 | load_spec = [] 72 | 73 | restricted_query = apply_loads(query, load_spec) 74 | 75 | # defers all fields 76 | expected = ( 77 | "SELECT bar.id AS bar_id \n" 78 | "FROM bar" 79 | ) 80 | assert str(restricted_query) == expected 81 | 82 | def test_single_value(self, session): 83 | 84 | query = session.query(Bar) 85 | loads = [ 86 | {'fields': ['name']} 87 | ] 88 | 89 | restricted_query = apply_loads(query, loads) 90 | 91 | expected = ( 92 | "SELECT bar.id AS bar_id, bar.name AS bar_name \n" 93 | "FROM bar" 94 | ) 95 | assert str(restricted_query) == expected 96 | 97 | def test_multiple_values_single_model(self, session): 98 | 99 | query = session.query(Foo) 100 | loads = [ 101 | {'fields': ['name', 'count']} 102 | ] 103 | 104 | restricted_query = apply_loads(query, loads) 105 | 106 | expected = ( 107 | "SELECT foo.id AS foo_id, foo.name AS foo_name, " 108 | "foo.count AS foo_count \n" 109 | "FROM foo" 110 | ) 111 | assert str(restricted_query) == expected 112 | 113 | def test_multiple_values_multiple_models(self, session): 114 | 115 | query = session.query(Foo, Bar) 116 | loads = [ 117 | {'model': 'Foo', 'fields': ['count']}, 118 | {'model': 'Bar', 'fields': ['count']}, 119 | ] 120 | 121 | restricted_query = apply_loads(query, loads) 122 | 123 | expected = ( 124 | "SELECT foo.id AS foo_id, foo.count AS foo_count, " 125 | "bar.id AS bar_id, bar.count AS bar_count \n" 126 | "FROM foo, bar" 127 | ) 128 | assert str(restricted_query) == expected 129 | 130 | def test_multiple_values_multiple_models_joined(self, session, db_uri): 131 | 132 | query = session.query(Foo, Bar).join(Bar) 133 | loads = [ 134 | {'model': 'Foo', 'fields': ['count']}, 135 | {'model': 'Bar', 'fields': ['count']}, 136 | ] 137 | 138 | restricted_query = apply_loads(query, loads) 139 | 140 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 141 | 142 | expected = ( 143 | "SELECT foo.id AS foo_id, foo.count AS foo_count, " 144 | "bar.id AS bar_id, bar.count AS bar_count \n" 145 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 146 | ) 147 | assert str(restricted_query) == expected 148 | 149 | def test_multiple_values_multiple_models_lazy_load(self, session, db_uri): 150 | 151 | query = session.query(Foo).join(Bar) 152 | loads = [ 153 | {'model': 'Foo', 'fields': ['count']}, 154 | {'model': 'Bar', 'fields': ['count']}, 155 | ] 156 | 157 | restricted_query = apply_loads(query, loads) 158 | 159 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 160 | 161 | # Bar is lazily joined, so the second loads directive has no effect 162 | expected = ( 163 | "SELECT foo.id AS foo_id, foo.count AS foo_count \n" 164 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 165 | ) 166 | assert str(restricted_query) == expected 167 | 168 | def test_a_single_dict_can_be_supplied_as_load_spec(self, session): 169 | 170 | query = session.query(Foo) 171 | load_spec = {'fields': ['name', 'count']} 172 | 173 | restricted_query = apply_loads(query, load_spec) 174 | 175 | expected = ( 176 | "SELECT foo.id AS foo_id, foo.name AS foo_name, " 177 | "foo.count AS foo_count \n" 178 | "FROM foo" 179 | ) 180 | assert str(restricted_query) == expected 181 | 182 | def test_a_list_of_fields_can_be_supplied_as_load_spec(self, session): 183 | 184 | query = session.query(Foo) 185 | load_spec = ['name', 'count'] 186 | 187 | restricted_query = apply_loads(query, load_spec) 188 | 189 | expected = ( 190 | "SELECT foo.id AS foo_id, foo.name AS foo_name, " 191 | "foo.count AS foo_count \n" 192 | "FROM foo" 193 | ) 194 | assert str(restricted_query) == expected 195 | 196 | def test_eager_load(self, session, db_uri): 197 | 198 | query = session.query(Foo).options(joinedload(Foo.bar)) 199 | load_spec = [ 200 | {'model': 'Foo', 'fields': ['name']}, 201 | {'model': 'Bar', 'fields': ['count']} 202 | ] 203 | restricted_query = apply_loads(query, load_spec) 204 | 205 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 206 | 207 | # autojoin has no effect 208 | expected = ( 209 | "SELECT " 210 | "foo.id AS foo_id, foo.name AS foo_name, " 211 | "foo.bar_id AS foo_bar_id, " 212 | "bar_1.id AS bar_1_id, bar_1.name AS bar_1_name, " 213 | "bar_1.count AS bar_1_count \n" 214 | "FROM foo {join} bar ON bar.id = foo.bar_id " 215 | "LEFT OUTER JOIN bar AS bar_1 ON bar_1.id = foo.bar_id".format( 216 | join=join_type 217 | ) 218 | ) 219 | 220 | assert str(restricted_query) == expected 221 | 222 | 223 | class TestAutoJoin: 224 | 225 | @pytest.mark.usefixtures('multiple_foos_inserted') 226 | def test_auto_join(self, session, db_uri): 227 | 228 | query = session.query(Foo) 229 | loads = [ 230 | {'fields': ['count']}, 231 | {'model': 'Bar', 'fields': ['count']}, 232 | ] 233 | 234 | restricted_query = apply_loads(query, loads) 235 | 236 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 237 | 238 | # Bar is lazily joined, so the second loads directive has no effect 239 | expected = ( 240 | "SELECT foo.id AS foo_id, foo.count AS foo_count \n" 241 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 242 | ) 243 | assert str(restricted_query) == expected 244 | 245 | @pytest.mark.usefixtures('multiple_foos_inserted') 246 | def test_noop_if_query_contains_named_models(self, session, db_uri): 247 | 248 | query = session.query(Foo, Bar).join(Bar) 249 | loads = [ 250 | {'model': 'Foo', 'fields': ['count']}, 251 | {'model': 'Bar', 'fields': ['count']}, 252 | ] 253 | 254 | restricted_query = apply_loads(query, loads) 255 | 256 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 257 | 258 | expected = ( 259 | "SELECT foo.id AS foo_id, foo.count AS foo_count, " 260 | "bar.id AS bar_id, bar.count AS bar_count \n" 261 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 262 | ) 263 | assert str(restricted_query) == expected 264 | 265 | @pytest.mark.usefixtures('multiple_foos_inserted') 266 | def test_auto_join_to_invalid_model(self, session): 267 | 268 | query = session.query(Foo, Bar) 269 | loads = [ 270 | {'model': 'Foo', 'fields': ['count']}, 271 | {'model': 'Bar', 'fields': ['count']}, 272 | {'model': 'Qux', 'fields': ['count']}, 273 | ] 274 | 275 | with pytest.raises(BadSpec) as err: 276 | apply_loads(query, loads) 277 | 278 | assert 'The query does not contain model `Qux`.' == err.value.args[0] 279 | 280 | @pytest.mark.usefixtures('multiple_foos_inserted') 281 | def test_ambiguous_query(self, session): 282 | 283 | query = session.query(Foo, Bar) 284 | loads = [ 285 | {'fields': ['count']}, # ambiguous 286 | {'model': 'Bar', 'fields': ['count']}, 287 | {'model': 'Qux', 'fields': ['count']}, 288 | ] 289 | 290 | with pytest.raises(BadSpec) as err: 291 | apply_loads(query, loads) 292 | 293 | assert 'Ambiguous spec. Please specify a model.' == err.value.args[0] 294 | -------------------------------------------------------------------------------- /test/interface/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import func 3 | from sqlalchemy.orm import joinedload 4 | 5 | from sqlalchemy_filters.exceptions import BadSpec, BadQuery 6 | from sqlalchemy_filters.models import ( 7 | auto_join, get_default_model, get_query_models, get_model_class_by_name, 8 | get_model_from_spec, sqlalchemy_version_lt, get_model_from_table 9 | ) 10 | from test.models import Base, Bar, Foo, Qux 11 | 12 | 13 | class TestGetQueryModels(object): 14 | @pytest.mark.skipif( 15 | sqlalchemy_version_lt('1.4'), reason='tests sqlalchemy 1.4 code' 16 | ) 17 | def test_returns_none_for_unknown_table(self): 18 | 19 | class FakeUnmappedTable: 20 | pass 21 | 22 | table = FakeUnmappedTable() 23 | 24 | result = get_model_from_table(table) 25 | assert result is None 26 | 27 | def test_query_with_no_models(self, session): 28 | query = session.query() 29 | 30 | entities = get_query_models(query) 31 | 32 | assert {} == entities 33 | 34 | def test_query_with_one_model(self, session): 35 | query = session.query(Bar) 36 | 37 | entities = get_query_models(query) 38 | 39 | assert {'Bar': Bar} == entities 40 | 41 | def test_query_with_select_from_model(self, session): 42 | query = session.query().select_from(Bar) 43 | 44 | entities = get_query_models(query) 45 | 46 | assert {'Bar': Bar} == entities 47 | 48 | def test_query_with_select_from_and_join_model(self, session): 49 | query = session.query().select_from(Bar).join(Foo) 50 | 51 | entities = get_query_models(query) 52 | 53 | assert {'Bar': Bar, 'Foo': Foo} == entities 54 | 55 | def test_query_with_multiple_models(self, session): 56 | query = session.query(Bar, Qux) 57 | 58 | entities = get_query_models(query) 59 | 60 | assert {'Bar': Bar, 'Qux': Qux} == entities 61 | 62 | def test_query_with_duplicated_models(self, session): 63 | query = session.query(Bar, Qux, Bar) 64 | 65 | entities = get_query_models(query) 66 | 67 | assert {'Bar': Bar, 'Qux': Qux} == entities 68 | 69 | def test_query_with_one_field(self, session): 70 | query = session.query(Foo.id) 71 | 72 | entities = get_query_models(query) 73 | 74 | assert {'Foo': Foo} == entities 75 | 76 | def test_query_with_multiple_fields(self, session): 77 | query = session.query(Foo.id, Bar.id, Bar.name) 78 | 79 | entities = get_query_models(query) 80 | 81 | assert {'Foo': Foo, 'Bar': Bar} == entities 82 | 83 | def test_query_with_aggregate_func(self, session): 84 | query = session.query(func.count(Foo.id)) 85 | 86 | entities = get_query_models(query) 87 | 88 | assert {'Foo': Foo} == entities 89 | 90 | def test_query_with_join(self, session): 91 | query = session.query(Foo).join(Bar) 92 | 93 | entities = get_query_models(query) 94 | 95 | assert {'Foo': Foo, 'Bar': Bar} == entities 96 | 97 | def test_query_with_multiple_joins(self, session): 98 | query = session.query(Foo).join(Bar).join(Qux, Bar.id == Qux.id) 99 | 100 | entities = get_query_models(query) 101 | 102 | assert {'Foo': Foo, 'Bar': Bar, 'Qux': Qux} == entities 103 | 104 | def test_query_with_joinedload(self, session): 105 | query = session.query(Foo).options(joinedload(Foo.bar)) 106 | 107 | entities = get_query_models(query) 108 | 109 | # Bar is not added to the query since the joinedload is transparent 110 | assert {'Foo': Foo} == entities 111 | 112 | 113 | class TestGetModelFromSpec: 114 | 115 | def test_query_with_no_models(self, session): 116 | query = session.query() 117 | spec = {'field': 'name', 'op': '==', 'value': 'name_1'} 118 | 119 | with pytest.raises(BadQuery) as err: 120 | get_model_from_spec(spec, query) 121 | 122 | assert 'The query does not contain any models.' == err.value.args[0] 123 | 124 | def test_query_with_named_model(self, session): 125 | query = session.query(Bar) 126 | spec = {'model': 'Bar'} 127 | 128 | model = get_model_from_spec(spec, query) 129 | assert model == Bar 130 | 131 | def test_query_with_missing_named_model(self, session): 132 | query = session.query(Bar) 133 | spec = {'model': 'Buz'} 134 | 135 | with pytest.raises(BadSpec) as err: 136 | get_model_from_spec(spec, query) 137 | 138 | assert 'The query does not contain model `Buz`.' == err.value.args[0] 139 | 140 | def test_multiple_models_ambiquous_spec(self, session): 141 | query = session.query(Bar, Qux) 142 | spec = {'field': 'name', 'op': '==', 'value': 'name_1'} 143 | 144 | with pytest.raises(BadSpec) as err: 145 | get_model_from_spec(spec, query) 146 | 147 | assert 'Ambiguous spec. Please specify a model.' == err.value.args[0] 148 | 149 | 150 | class TestGetModelClassByName: 151 | 152 | @pytest.fixture 153 | def registry(self): 154 | return ( 155 | Base._decl_class_registry 156 | if sqlalchemy_version_lt('1.4') 157 | else Base.registry._class_registry 158 | ) 159 | 160 | def test_exists(self, registry): 161 | assert get_model_class_by_name(registry, 'Foo') == Foo 162 | 163 | def test_model_does_not_exist(self, registry): 164 | assert get_model_class_by_name(registry, 'Missing') is None 165 | 166 | 167 | class TestGetDefaultModel: 168 | 169 | def test_single_model_query(self, session): 170 | query = session.query(Foo) 171 | assert get_default_model(query) == Foo 172 | 173 | def test_multi_model_query(self, session): 174 | query = session.query(Foo).join(Bar) 175 | assert get_default_model(query) is None 176 | 177 | def test_empty_query(self, session): 178 | query = session.query() 179 | assert get_default_model(query) is None 180 | 181 | 182 | class TestAutoJoin: 183 | 184 | def test_model_not_present(self, session, db_uri): 185 | query = session.query(Foo) 186 | query = auto_join(query, 'Bar') 187 | 188 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 189 | 190 | expected = ( 191 | "SELECT " 192 | "foo.id AS foo_id, foo.name AS foo_name, " 193 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id \n" 194 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 195 | ) 196 | assert str(query) == expected 197 | 198 | def test_model_already_present(self, session): 199 | query = session.query(Foo, Bar) 200 | 201 | # no join applied 202 | expected = ( 203 | "SELECT " 204 | "foo.id AS foo_id, foo.name AS foo_name, " 205 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id, " 206 | "bar.id AS bar_id, bar.name AS bar_name, bar.count AS bar_count \n" 207 | "FROM foo, bar" 208 | ) 209 | assert str(query) == expected 210 | 211 | query = auto_join(query, 'Bar') 212 | assert str(query) == expected # no change 213 | 214 | def test_model_already_joined(self, session, db_uri): 215 | query = session.query(Foo).join(Bar) 216 | 217 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 218 | 219 | expected = ( 220 | "SELECT " 221 | "foo.id AS foo_id, foo.name AS foo_name, " 222 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id \n" 223 | "FROM foo {join} bar ON bar.id = foo.bar_id".format(join=join_type) 224 | ) 225 | assert str(query) == expected 226 | 227 | query = auto_join(query, 'Bar') 228 | assert str(query) == expected # no change 229 | 230 | def test_model_eager_joined(self, session, db_uri): 231 | query = session.query(Foo).options(joinedload(Foo.bar)) 232 | 233 | join_type = "INNER JOIN" if "mysql" in db_uri else "JOIN" 234 | 235 | expected_eager = ( 236 | "SELECT " 237 | "foo.id AS foo_id, foo.name AS foo_name, " 238 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id, " 239 | "bar_1.id AS bar_1_id, bar_1.name AS bar_1_name, " 240 | "bar_1.count AS bar_1_count \n" 241 | "FROM foo LEFT OUTER JOIN bar AS bar_1 ON bar_1.id = foo.bar_id" 242 | ) 243 | assert str(query) == expected_eager 244 | 245 | expected_joined = ( 246 | "SELECT " 247 | "foo.id AS foo_id, foo.name AS foo_name, " 248 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id, " 249 | "bar_1.id AS bar_1_id, bar_1.name AS bar_1_name, " 250 | "bar_1.count AS bar_1_count \n" 251 | "FROM foo {join} bar ON bar.id = foo.bar_id " 252 | "LEFT OUTER JOIN bar AS bar_1 ON bar_1.id = foo.bar_id".format( 253 | join=join_type 254 | ) 255 | ) 256 | 257 | query = auto_join(query, 'Bar') 258 | assert str(query) == expected_joined 259 | 260 | def test_model_does_not_exist(self, session, db_uri): 261 | query = session.query(Foo) 262 | 263 | expected = ( 264 | "SELECT " 265 | "foo.id AS foo_id, foo.name AS foo_name, " 266 | "foo.count AS foo_count, foo.bar_id AS foo_bar_id \n" 267 | "FROM foo" 268 | ) 269 | assert str(query) == expected 270 | 271 | query = auto_join(query, 'Missing') 272 | assert str(query) == expected # no change 273 | -------------------------------------------------------------------------------- /test/interface/test_pagination.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from collections import namedtuple 4 | 5 | import pytest 6 | 7 | from sqlalchemy_filters import apply_pagination 8 | from sqlalchemy_filters.exceptions import InvalidPage 9 | from test import error_value 10 | from test.models import Bar 11 | 12 | 13 | Pagination = namedtuple( 14 | 'Pagination', ['page_number', 'page_size', 'num_pages', 'total_results'] 15 | ) 16 | 17 | 18 | class TestPaginationFixtures(object): 19 | 20 | @pytest.fixture 21 | def multiple_bars_inserted(self, session): 22 | bar_1 = Bar(id=1, name='name_1', count=5) 23 | bar_2 = Bar(id=2, name='name_2', count=10) 24 | bar_3 = Bar(id=3, name='name_1', count=None) 25 | bar_4 = Bar(id=4, name='name_4', count=15) 26 | bar_5 = Bar(id=5, name='name_5', count=17) 27 | bar_6 = Bar(id=6, name='name_5', count=17) 28 | bar_7 = Bar(id=7, name='name_7', count=None) 29 | bar_8 = Bar(id=8, name='name_8', count=18) 30 | session.add_all( 31 | [bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8] 32 | ) 33 | session.commit() 34 | 35 | 36 | class TestWrongPagination(TestPaginationFixtures): 37 | 38 | @pytest.mark.parametrize( 39 | 'page_number, page_size', 40 | [ 41 | (-2, None), (-2, 0), (-2, 1), (-2, 2), 42 | (-1, None), (-1, 0), (-1, 1), (-1, 2), 43 | (0, None), (0, 0), (0, 1), (-0, 2), 44 | ] 45 | ) 46 | @pytest.mark.usefixtures('multiple_bars_inserted') 47 | def test_wrong_page_number(self, session, page_number, page_size): 48 | query = session.query(Bar) 49 | 50 | with pytest.raises(InvalidPage) as err: 51 | apply_pagination(query, page_number, page_size) 52 | 53 | expected_error = 'Page number should be positive: {}'.format( 54 | page_number 55 | ) 56 | assert error_value(err) == expected_error 57 | 58 | @pytest.mark.parametrize( 59 | 'page_number, page_size', 60 | [ 61 | (-2, None), (-2, 0), (-2, 1), (-2, 2), 62 | (-1, None), (-1, 0), (-1, 1), (-1, 2), 63 | (0, None), (0, 0), (0, 1), (-0, 2), 64 | ] 65 | ) 66 | def test_wrong_page_number_with_no_results( 67 | self, session, page_number, page_size 68 | ): 69 | query = session.query(Bar) 70 | 71 | with pytest.raises(InvalidPage) as err: 72 | apply_pagination(query, page_number, page_size) 73 | 74 | expected_error = 'Page number should be positive: {}'.format( 75 | page_number 76 | ) 77 | assert error_value(err) == expected_error 78 | 79 | @pytest.mark.parametrize( 80 | 'page_number, page_size', 81 | [ 82 | (None, -2), (-1, -2), (0, -2), (1, -2), (2, -2), 83 | (None, -1), (-1, -1), (0, -1), (1, -1), (2, -1), 84 | ] 85 | ) 86 | @pytest.mark.usefixtures('multiple_bars_inserted') 87 | def test_wrong_page_size(self, session, page_number, page_size): 88 | query = session.query(Bar) 89 | 90 | with pytest.raises(InvalidPage) as err: 91 | apply_pagination(query, page_number, page_size) 92 | 93 | expected_error = 'Page size should not be negative: {}'.format( 94 | page_size 95 | ) 96 | assert error_value(err) == expected_error 97 | 98 | 99 | class TestNoPaginationProvided(TestPaginationFixtures): 100 | 101 | @pytest.mark.usefixtures('multiple_bars_inserted') 102 | def test_no_pagination_info_provided(self, session): 103 | query = session.query(Bar) 104 | page_size = None 105 | page_number = None 106 | 107 | paginated_query, pagination = apply_pagination( 108 | query, page_number, page_size 109 | ) 110 | 111 | assert query == paginated_query 112 | assert Pagination( 113 | page_number=1, page_size=8, num_pages=1, total_results=8 114 | ) == pagination 115 | 116 | result = paginated_query.all() 117 | 118 | assert len(result) == 8 119 | for i in range(8): 120 | assert result[i].id == i + 1 121 | 122 | 123 | class TestNoPageNumberProvided(TestPaginationFixtures): 124 | 125 | @pytest.mark.usefixtures('multiple_bars_inserted') 126 | def test_page_size_greater_than_total_records(self, session): 127 | query = session.query(Bar) 128 | page_size = 5000 129 | page_number = None 130 | 131 | paginated_query, pagination = apply_pagination( 132 | query, page_number, page_size 133 | ) 134 | 135 | assert query != paginated_query 136 | assert Pagination( 137 | page_number=1, page_size=8, num_pages=1, total_results=8 138 | ) == pagination 139 | 140 | result = paginated_query.all() 141 | 142 | assert len(result) == 8 143 | for i in range(8): 144 | assert result[i].id == i + 1 145 | 146 | @pytest.mark.usefixtures('multiple_bars_inserted') 147 | def test_page_size_provided(self, session): 148 | query = session.query(Bar) 149 | page_size = 2 150 | page_number = None 151 | 152 | paginated_query, pagination = apply_pagination( 153 | query, page_number, page_size 154 | ) 155 | 156 | assert query != paginated_query 157 | assert Pagination( 158 | page_number=1, page_size=2, num_pages=4, total_results=8 159 | ) == pagination 160 | 161 | result = paginated_query.all() 162 | 163 | assert len(result) == 2 164 | assert result[0].id == 1 165 | assert result[1].id == 2 166 | 167 | 168 | class TestNoPageSizeProvided(TestPaginationFixtures): 169 | 170 | @pytest.mark.usefixtures('multiple_bars_inserted') 171 | def test_first_page(self, session): 172 | query = session.query(Bar) 173 | page_size = None 174 | page_number = 1 175 | 176 | paginated_query, pagination = apply_pagination( 177 | query, page_number, page_size 178 | ) 179 | 180 | assert query != paginated_query 181 | assert Pagination( 182 | page_number=1, page_size=8, num_pages=1, total_results=8 183 | ) == pagination 184 | 185 | result = paginated_query.all() 186 | 187 | assert len(result) == 8 188 | for i in range(8): 189 | assert result[i].id == i + 1 190 | 191 | @pytest.mark.parametrize('page_number', [2, 3, 4]) 192 | @pytest.mark.usefixtures('multiple_bars_inserted') 193 | def test_page_number_greater_than_one(self, session, page_number): 194 | query = session.query(Bar) 195 | page_size = None 196 | 197 | paginated_query, pagination = apply_pagination( 198 | query, page_number, page_size 199 | ) 200 | 201 | assert query != paginated_query 202 | assert Pagination( 203 | page_number=page_number, page_size=8, num_pages=1, total_results=8 204 | ) == pagination 205 | 206 | result = paginated_query.all() 207 | 208 | assert len(result) == 0 209 | 210 | 211 | class TestApplyPagination(TestPaginationFixtures): 212 | 213 | @pytest.mark.parametrize('page_number', [1, 2, 3]) 214 | @pytest.mark.usefixtures('multiple_bars_inserted') 215 | def test_page_size_zero(self, session, page_number): 216 | query = session.query(Bar) 217 | page_size = 0 218 | 219 | paginated_query, pagination = apply_pagination( 220 | query, page_number, page_size 221 | ) 222 | 223 | assert query != paginated_query 224 | assert Pagination( 225 | page_number=page_number, page_size=0, num_pages=0, total_results=8 226 | ) == pagination 227 | 228 | result = paginated_query.all() 229 | 230 | assert len(result) == 0 231 | 232 | @pytest.mark.usefixtures('multiple_bars_inserted') 233 | def test_page_size_zero_and_no_page_number_provided(self, session): 234 | query = session.query(Bar) 235 | page_size = 0 236 | page_number = None 237 | 238 | paginated_query, pagination = apply_pagination( 239 | query, page_number, page_size 240 | ) 241 | 242 | assert query != paginated_query 243 | assert Pagination( 244 | page_number=1, page_size=0, num_pages=0, total_results=8 245 | ) == pagination 246 | 247 | result = paginated_query.all() 248 | 249 | assert len(result) == 0 250 | 251 | @pytest.mark.usefixtures('multiple_bars_inserted') 252 | def test_page_number_and_page_size_provided(self, session): 253 | query = session.query(Bar) 254 | page_size = 2 255 | page_number = 3 256 | 257 | paginated_query, pagination = apply_pagination( 258 | query, page_number, page_size 259 | ) 260 | assert query != paginated_query 261 | assert Pagination( 262 | page_number=3, page_size=2, num_pages=4, total_results=8 263 | ) == pagination 264 | 265 | result = paginated_query.all() 266 | 267 | assert len(result) == 2 268 | assert result[0].id == 5 269 | assert result[1].id == 6 270 | 271 | @pytest.mark.usefixtures('multiple_bars_inserted') 272 | def test_get_individual_record(self, session): 273 | query = session.query(Bar) 274 | page_size = 1 275 | page_number = 5 276 | 277 | paginated_query, pagination = apply_pagination( 278 | query, page_number, page_size 279 | ) 280 | 281 | assert query != paginated_query 282 | assert Pagination( 283 | page_number=5, page_size=1, num_pages=8, total_results=8 284 | ) == pagination 285 | 286 | result = paginated_query.all() 287 | 288 | assert len(result) == 1 289 | assert result[0].id == 5 290 | 291 | @pytest.mark.parametrize('page_number', [5, 6, 7]) 292 | @pytest.mark.usefixtures('multiple_bars_inserted') 293 | def test_page_number_greater_than_number_of_pages( 294 | self, session, page_number 295 | ): 296 | query = session.query(Bar) 297 | page_size = 2 298 | 299 | paginated_query, pagination = apply_pagination( 300 | query, page_number, page_size 301 | ) 302 | 303 | assert query != paginated_query 304 | assert Pagination( 305 | page_number=page_number, page_size=2, num_pages=4, total_results=8 306 | ) == pagination 307 | 308 | result = paginated_query.all() 309 | 310 | assert len(result) == 0 311 | 312 | @pytest.mark.usefixtures('multiple_bars_inserted') 313 | def test_last_complete_page(self, session): 314 | query = session.query(Bar) 315 | page_size = 2 316 | page_number = 4 317 | 318 | paginated_query, pagination = apply_pagination( 319 | query, page_number, page_size 320 | ) 321 | 322 | assert query != paginated_query 323 | assert Pagination( 324 | page_number=4, page_size=2, num_pages=4, total_results=8 325 | ) == pagination 326 | 327 | result = paginated_query.all() 328 | 329 | assert len(result) == 2 330 | assert result[0].id == 7 331 | assert result[1].id == 8 332 | 333 | @pytest.mark.usefixtures('multiple_bars_inserted') 334 | def test_last_incomplete_page(self, session): 335 | query = session.query(Bar) 336 | page_size = 5 337 | page_number = 2 338 | 339 | paginated_query, pagination = apply_pagination( 340 | query, page_number, page_size 341 | ) 342 | 343 | assert query != paginated_query 344 | assert Pagination( 345 | page_number=2, page_size=5, num_pages=2, total_results=8 346 | ) == pagination 347 | 348 | result = paginated_query.all() 349 | 350 | assert len(result) == 3 351 | assert result[0].id == 6 352 | assert result[1].id == 7 353 | assert result[2].id == 8 354 | 355 | @pytest.mark.usefixtures('multiple_bars_inserted') 356 | def test_get_first_page(self, session): 357 | query = session.query(Bar) 358 | page_size = 2 359 | page_number = 1 360 | 361 | paginated_query, pagination = apply_pagination( 362 | query, page_number, page_size 363 | ) 364 | 365 | assert query != paginated_query 366 | assert Pagination( 367 | page_number=1, page_size=2, num_pages=4, total_results=8 368 | ) == pagination 369 | 370 | result = paginated_query.all() 371 | 372 | assert len(result) == 2 373 | assert result[0].id == 1 374 | assert result[1].id == 2 375 | 376 | 377 | class TestQueryWithNoResults: 378 | 379 | def test_page_size_and_page_number_provided(self, session): 380 | query = session.query(Bar) 381 | page_size = 2 382 | page_number = 1 383 | 384 | paginated_query, pagination = apply_pagination( 385 | query, page_number, page_size 386 | ) 387 | 388 | assert query != paginated_query 389 | assert Pagination( 390 | page_number=1, page_size=2, num_pages=0, total_results=0 391 | ) == pagination 392 | 393 | result = paginated_query.all() 394 | 395 | assert len(result) == 0 396 | -------------------------------------------------------------------------------- /test/interface/test_sorting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | from sqlalchemy.orm import joinedload 8 | from sqlalchemy_filters.exceptions import BadSortFormat, BadSpec, FieldNotFound 9 | from sqlalchemy_filters.sorting import apply_sort 10 | from test import error_value 11 | from test.models import Foo, Bar, Qux 12 | 13 | 14 | NULLSFIRST_NOT_SUPPORTED = ( 15 | "'nullsfirst' only supported by PostgreSQL in the current tests" 16 | ) 17 | NULLSLAST_NOT_SUPPORTED = ( 18 | "'nullslast' only supported by PostgreSQL in the current tests" 19 | ) 20 | 21 | 22 | @pytest.fixture 23 | def multiple_foos_inserted(session): 24 | foo_1 = Foo(id=1, bar_id=1, name='name_1', count=1) 25 | foo_2 = Foo(id=2, bar_id=2, name='name_2', count=1) 26 | foo_3 = Foo(id=3, bar_id=3, name='name_1', count=1) 27 | foo_4 = Foo(id=4, bar_id=4, name='name_4', count=1) 28 | foo_5 = Foo(id=5, bar_id=5, name='name_1', count=2) 29 | foo_6 = Foo(id=6, bar_id=6, name='name_4', count=2) 30 | foo_7 = Foo(id=7, bar_id=7, name='name_1', count=2) 31 | foo_8 = Foo(id=8, bar_id=8, name='name_5', count=2) 32 | session.add_all([foo_1, foo_2, foo_3, foo_4, foo_5, foo_6, foo_7, foo_8]) 33 | session.commit() 34 | 35 | 36 | @pytest.fixture 37 | def multiple_bars_with_no_nulls_inserted(session): 38 | bar_1 = Bar(id=1, name='name_1', count=5) 39 | bar_2 = Bar(id=2, name='name_2', count=10) 40 | bar_3 = Bar(id=3, name='name_1', count=3) 41 | bar_4 = Bar(id=4, name='name_4', count=12) 42 | bar_5 = Bar(id=5, name='name_1', count=2) 43 | bar_6 = Bar(id=6, name='name_4', count=15) 44 | bar_7 = Bar(id=7, name='name_1', count=2) 45 | bar_8 = Bar(id=8, name='name_5', count=1) 46 | session.add_all([bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8]) 47 | session.commit() 48 | 49 | 50 | @pytest.fixture 51 | def multiple_bars_with_nulls_inserted(session): 52 | bar_1 = Bar(id=1, name='name_1', count=5) 53 | bar_2 = Bar(id=2, name='name_2', count=20) 54 | bar_3 = Bar(id=3, name='name_1', count=None) 55 | bar_4 = Bar(id=4, name='name_4', count=10) 56 | bar_5 = Bar(id=5, name='name_1', count=40) 57 | bar_6 = Bar(id=6, name='name_4', count=None) 58 | bar_7 = Bar(id=7, name='name_1', count=30) 59 | bar_8 = Bar(id=8, name='name_5', count=50) 60 | session.add_all( 61 | [bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8] 62 | ) 63 | session.commit() 64 | 65 | 66 | class TestSortNotApplied(object): 67 | 68 | def test_no_sort_provided(self, session): 69 | query = session.query(Bar) 70 | order_by = [] 71 | 72 | filtered_query = apply_sort(query, order_by) 73 | 74 | assert query == filtered_query 75 | 76 | @pytest.mark.parametrize('sort', ['some text', 1, []]) 77 | def test_wrong_sort_format(self, session, sort): 78 | query = session.query(Bar) 79 | order_by = [sort] 80 | 81 | with pytest.raises(BadSortFormat) as err: 82 | apply_sort(query, order_by) 83 | 84 | expected_error = 'Sort spec `{}` should be a dictionary.'.format(sort) 85 | assert expected_error == error_value(err) 86 | 87 | def test_field_not_provided(self, session): 88 | query = session.query(Bar) 89 | order_by = [{'direction': 'asc'}] 90 | 91 | with pytest.raises(BadSortFormat) as err: 92 | apply_sort(query, order_by) 93 | 94 | expected_error = '`field` and `direction` are mandatory attributes.' 95 | assert expected_error == error_value(err) 96 | 97 | def test_invalid_field(self, session): 98 | query = session.query(Bar) 99 | order_by = [{'field': 'invalid_field', 'direction': 'asc'}] 100 | 101 | with pytest.raises(FieldNotFound) as err: 102 | apply_sort(query, order_by) 103 | 104 | expected_error = ( 105 | "Model has no column `invalid_field`." 106 | ) 107 | assert expected_error == error_value(err) 108 | 109 | def test_direction_not_provided(self, session): 110 | query = session.query(Bar) 111 | order_by = [{'field': 'name'}] 112 | 113 | with pytest.raises(BadSortFormat) as err: 114 | apply_sort(query, order_by) 115 | 116 | expected_error = '`field` and `direction` are mandatory attributes.' 117 | assert expected_error == error_value(err) 118 | 119 | def test_invalid_direction(self, session): 120 | query = session.query(Bar) 121 | order_by = [{'field': 'name', 'direction': 'invalid_direction'}] 122 | 123 | with pytest.raises(BadSortFormat) as err: 124 | apply_sort(query, order_by) 125 | 126 | expected_error = 'Direction `invalid_direction` not valid.' 127 | assert expected_error == error_value(err) 128 | 129 | 130 | class TestSortApplied(object): 131 | 132 | """Tests that results are sorted only according to the provided 133 | filters. 134 | 135 | Does NOT test how rows with the same values are sorted since this is 136 | not consistent across RDBMS. 137 | 138 | Does NOT test whether `NULL` field values are placed first or last 139 | when sorting since this may differ across RDBMSs. 140 | 141 | SQL defines that `NULL` values should be placed together when 142 | sorting, but it does not specify whether they should be placed first 143 | or last. 144 | """ 145 | 146 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 147 | def test_single_sort_field_asc(self, session): 148 | query = session.query(Bar) 149 | order_by = [{'field': 'name', 'direction': 'asc'}] 150 | 151 | sorted_query = apply_sort(query, order_by) 152 | results = sorted_query.all() 153 | 154 | assert [result.name for result in results] == [ 155 | 'name_1', 'name_1', 'name_1', 'name_1', 156 | 'name_2', 157 | 'name_4', 'name_4', 158 | 'name_5', 159 | ] 160 | 161 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 162 | def test_single_sort_field_desc(self, session): 163 | query = session.query(Bar) 164 | order_by = [{'field': 'name', 'direction': 'desc'}] 165 | 166 | sorted_query = apply_sort(query, order_by) 167 | results = sorted_query.all() 168 | 169 | assert [result.name for result in results] == [ 170 | 'name_5', 171 | 'name_4', 'name_4', 172 | 'name_2', 173 | 'name_1', 'name_1', 'name_1', 'name_1', 174 | ] 175 | 176 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 177 | def test_multiple_sort_fields(self, session): 178 | query = session.query(Bar) 179 | order_by = [ 180 | {'field': 'name', 'direction': 'asc'}, 181 | {'field': 'count', 'direction': 'desc'}, 182 | {'field': 'id', 'direction': 'desc'}, 183 | ] 184 | 185 | sorted_query = apply_sort(query, order_by) 186 | results = sorted_query.all() 187 | 188 | assert [ 189 | (result.name, result.count, result.id) for result in results 190 | ] == [ 191 | ('name_1', 5, 1), 192 | ('name_1', 3, 3), 193 | ('name_1', 2, 7), 194 | ('name_1', 2, 5), 195 | ('name_2', 10, 2), 196 | ('name_4', 15, 6), 197 | ('name_4', 12, 4), 198 | ('name_5', 1, 8), 199 | ] 200 | 201 | def test_multiple_models(self, session): 202 | 203 | bar_1 = Bar(id=1, name='name_1', count=15) 204 | bar_2 = Bar(id=2, name='name_2', count=10) 205 | bar_3 = Bar(id=3, name='name_1', count=20) 206 | bar_4 = Bar(id=4, name='name_1', count=10) 207 | 208 | qux_1 = Qux( 209 | id=1, name='name_1', count=15, 210 | created_at=datetime.date(2016, 7, 12), 211 | execution_time=datetime.datetime(2016, 7, 12, 1, 5, 9) 212 | ) 213 | qux_2 = Qux( 214 | id=2, name='name_2', count=10, 215 | created_at=datetime.date(2016, 7, 13), 216 | execution_time=datetime.datetime(2016, 7, 13, 2, 5, 9) 217 | ) 218 | qux_3 = Qux( 219 | id=3, name='name_1', count=10, 220 | created_at=None, execution_time=None 221 | ) 222 | qux_4 = Qux( 223 | id=4, name='name_1', count=20, 224 | created_at=datetime.date(2016, 7, 14), 225 | execution_time=datetime.datetime(2016, 7, 14, 3, 5, 9) 226 | ) 227 | 228 | session.add_all( 229 | [bar_1, bar_2, bar_3, bar_4, qux_1, qux_2, qux_3, qux_4] 230 | ) 231 | session.commit() 232 | 233 | query = session.query(Bar).join(Qux, Bar.id == Qux.id) 234 | order_by = [ 235 | {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, 236 | {'model': 'Qux', 'field': 'count', 'direction': 'asc'}, 237 | ] 238 | 239 | sorted_query = apply_sort(query, order_by) 240 | results = sorted_query.all() 241 | 242 | assert len(results) == 4 243 | assert results[0].id == 3 244 | assert results[1].id == 1 245 | assert results[2].id == 4 246 | assert results[3].id == 2 247 | 248 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 249 | def test_a_single_dict_can_be_supplied_as_sort_spec(self, session): 250 | query = session.query(Bar) 251 | sort_spec = {'field': 'name', 'direction': 'desc'} 252 | 253 | sorted_query = apply_sort(query, sort_spec) 254 | results = sorted_query.all() 255 | 256 | assert [result.name for result in results] == [ 257 | 'name_5', 258 | 'name_4', 'name_4', 259 | 'name_2', 260 | 'name_1', 'name_1', 'name_1', 'name_1', 261 | ] 262 | 263 | 264 | class TestAutoJoin: 265 | 266 | @pytest.mark.usefixtures( 267 | 'multiple_bars_with_no_nulls_inserted', 268 | 'multiple_foos_inserted' 269 | ) 270 | def test_auto_join(self, session): 271 | query = session.query(Foo) 272 | order_by = [ 273 | {'field': 'count', 'direction': 'desc'}, 274 | {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, 275 | {'field': 'id', 'direction': 'asc'}, 276 | ] 277 | 278 | sorted_query = apply_sort(query, order_by) 279 | results = sorted_query.all() 280 | 281 | assert [ 282 | (result.count, result.bar.name, result.id) for result in results 283 | ] == [ 284 | (2, 'name_1', 5), 285 | (2, 'name_1', 7), 286 | (2, 'name_4', 6), 287 | (2, 'name_5', 8), 288 | (1, 'name_1', 1), 289 | (1, 'name_1', 3), 290 | (1, 'name_2', 2), 291 | (1, 'name_4', 4), 292 | ] 293 | 294 | @pytest.mark.usefixtures( 295 | 'multiple_bars_with_no_nulls_inserted', 296 | 'multiple_foos_inserted' 297 | ) 298 | def test_noop_if_query_contains_named_models(self, session): 299 | query = session.query(Foo).join(Bar) 300 | order_by = [ 301 | {'model': 'Foo', 'field': 'count', 'direction': 'desc'}, 302 | {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, 303 | {'model': 'Foo', 'field': 'id', 'direction': 'asc'}, 304 | ] 305 | 306 | sorted_query = apply_sort(query, order_by) 307 | results = sorted_query.all() 308 | 309 | assert [ 310 | (result.count, result.bar.name, result.id) for result in results 311 | ] == [ 312 | (2, 'name_1', 5), 313 | (2, 'name_1', 7), 314 | (2, 'name_4', 6), 315 | (2, 'name_5', 8), 316 | (1, 'name_1', 1), 317 | (1, 'name_1', 3), 318 | (1, 'name_2', 2), 319 | (1, 'name_4', 4), 320 | ] 321 | 322 | @pytest.mark.usefixtures( 323 | 'multiple_bars_with_no_nulls_inserted', 324 | 'multiple_foos_inserted' 325 | ) 326 | def test_auto_join_to_invalid_model(self, session): 327 | query = session.query(Foo) 328 | order_by = [ 329 | {'model': 'Foo', 'field': 'count', 'direction': 'desc'}, 330 | {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, 331 | {'model': 'Qux', 'field': 'count', 'direction': 'asc'} 332 | ] 333 | 334 | with pytest.raises(BadSpec) as err: 335 | apply_sort(query, order_by) 336 | 337 | assert 'The query does not contain model `Qux`.' == err.value.args[0] 338 | 339 | @pytest.mark.usefixtures( 340 | 'multiple_bars_with_no_nulls_inserted', 341 | 'multiple_foos_inserted' 342 | ) 343 | def test_ambiguous_query(self, session): 344 | query = session.query(Foo).join(Bar) 345 | order_by = [ 346 | {'field': 'count', 'direction': 'asc'}, # ambiguous 347 | {'model': 'Bar', 'field': 'name', 'direction': 'desc'}, 348 | ] 349 | with pytest.raises(BadSpec) as err: 350 | apply_sort(query, order_by) 351 | 352 | assert 'Ambiguous spec. Please specify a model.' == err.value.args[0] 353 | 354 | @pytest.mark.usefixtures( 355 | 'multiple_bars_with_no_nulls_inserted', 356 | 'multiple_foos_inserted' 357 | ) 358 | def test_eager_load(self, session): 359 | # behaves as if the joinedload wasn't present 360 | query = session.query(Foo).options(joinedload(Foo.bar)) 361 | order_by = [ 362 | {'field': 'count', 'direction': 'desc'}, 363 | {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, 364 | {'field': 'id', 'direction': 'asc'}, 365 | ] 366 | 367 | sorted_query = apply_sort(query, order_by) 368 | results = sorted_query.all() 369 | 370 | assert [ 371 | (result.count, result.bar.name, result.id) for result in results 372 | ] == [ 373 | (2, 'name_1', 5), 374 | (2, 'name_1', 7), 375 | (2, 'name_4', 6), 376 | (2, 'name_5', 8), 377 | (1, 'name_1', 1), 378 | (1, 'name_1', 3), 379 | (1, 'name_2', 2), 380 | (1, 'name_4', 4), 381 | ] 382 | 383 | 384 | class TestSortNullsFirst(object): 385 | 386 | """Tests `nullsfirst`. 387 | 388 | This is currently not supported by MySQL and SQLite. Only tested for 389 | PostgreSQL. 390 | """ 391 | 392 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 393 | def test_single_sort_field_asc_nulls_first(self, session, is_postgresql): 394 | if not is_postgresql: 395 | pytest.skip(NULLSFIRST_NOT_SUPPORTED) 396 | 397 | query = session.query(Bar) 398 | order_by = [ 399 | {'field': 'count', 'direction': 'asc', 'nullsfirst': True} 400 | ] 401 | 402 | sorted_query = apply_sort(query, order_by) 403 | results = sorted_query.all() 404 | 405 | assert [result.count for result in results] == [ 406 | None, None, 5, 10, 20, 30, 40, 50, 407 | ] 408 | 409 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 410 | def test_single_sort_field_desc_nulls_first(self, session, is_postgresql): 411 | if not is_postgresql: 412 | pytest.skip(NULLSFIRST_NOT_SUPPORTED) 413 | 414 | query = session.query(Bar) 415 | order_by = [ 416 | {'field': 'count', 'direction': 'desc', 'nullsfirst': True} 417 | ] 418 | 419 | sorted_query = apply_sort(query, order_by) 420 | results = sorted_query.all() 421 | 422 | assert [result.count for result in results] == [ 423 | None, None, 50, 40, 30, 20, 10, 5, 424 | ] 425 | 426 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 427 | def test_multiple_sort_fields_asc_nulls_first( 428 | self, session, is_postgresql 429 | ): 430 | if not is_postgresql: 431 | pytest.skip(NULLSFIRST_NOT_SUPPORTED) 432 | 433 | query = session.query(Bar) 434 | order_by = [ 435 | {'field': 'name', 'direction': 'asc'}, 436 | {'field': 'count', 'direction': 'asc', 'nullsfirst': True}, 437 | ] 438 | 439 | sorted_query = apply_sort(query, order_by) 440 | results = sorted_query.all() 441 | 442 | assert [(result.name, result.count) for result in results] == [ 443 | ('name_1', None), 444 | ('name_1', 5), 445 | ('name_1', 30), 446 | ('name_1', 40), 447 | ('name_2', 20), 448 | ('name_4', None), 449 | ('name_4', 10), 450 | ('name_5', 50), 451 | ] 452 | 453 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 454 | def test_multiple_sort_fields_desc_nulls_first( 455 | self, session, is_postgresql 456 | ): 457 | if not is_postgresql: 458 | pytest.skip(NULLSFIRST_NOT_SUPPORTED) 459 | 460 | query = session.query(Bar) 461 | order_by = [ 462 | {'field': 'name', 'direction': 'asc'}, 463 | {'field': 'count', 'direction': 'desc', 'nullsfirst': True}, 464 | ] 465 | 466 | sorted_query = apply_sort(query, order_by) 467 | results = sorted_query.all() 468 | 469 | assert [(result.name, result.count) for result in results] == [ 470 | ('name_1', None), 471 | ('name_1', 40), 472 | ('name_1', 30), 473 | ('name_1', 5), 474 | ('name_2', 20), 475 | ('name_4', None), 476 | ('name_4', 10), 477 | ('name_5', 50), 478 | ] 479 | 480 | 481 | class TestSortNullsLast(object): 482 | 483 | """Tests `nullslast`. 484 | 485 | This is currently not supported by MySQL and SQLite. Only tested for 486 | PostgreSQL. 487 | """ 488 | 489 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 490 | def test_single_sort_field_asc_nulls_last(self, session, is_postgresql): 491 | if not is_postgresql: 492 | pytest.skip(NULLSLAST_NOT_SUPPORTED) 493 | 494 | query = session.query(Bar) 495 | order_by = [ 496 | {'field': 'count', 'direction': 'asc', 'nullslast': True} 497 | ] 498 | 499 | sorted_query = apply_sort(query, order_by) 500 | results = sorted_query.all() 501 | 502 | assert [result.count for result in results] == [ 503 | 5, 10, 20, 30, 40, 50, None, None, 504 | ] 505 | 506 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 507 | def test_single_sort_field_desc_nulls_last(self, session, is_postgresql): 508 | if not is_postgresql: 509 | pytest.skip(NULLSLAST_NOT_SUPPORTED) 510 | 511 | query = session.query(Bar) 512 | order_by = [ 513 | {'field': 'count', 'direction': 'desc', 'nullslast': True} 514 | ] 515 | 516 | sorted_query = apply_sort(query, order_by) 517 | results = sorted_query.all() 518 | 519 | assert [result.count for result in results] == [ 520 | 50, 40, 30, 20, 10, 5, None, None, 521 | ] 522 | 523 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 524 | def test_multiple_sort_fields_asc_nulls_last(self, session, is_postgresql): 525 | if not is_postgresql: 526 | pytest.skip(NULLSLAST_NOT_SUPPORTED) 527 | 528 | query = session.query(Bar) 529 | order_by = [ 530 | {'field': 'name', 'direction': 'asc'}, 531 | {'field': 'count', 'direction': 'asc', 'nullslast': True}, 532 | ] 533 | 534 | sorted_query = apply_sort(query, order_by) 535 | results = sorted_query.all() 536 | 537 | assert [(result.name, result.count) for result in results] == [ 538 | ('name_1', 5), 539 | ('name_1', 30), 540 | ('name_1', 40), 541 | ('name_1', None), 542 | ('name_2', 20), 543 | ('name_4', 10), 544 | ('name_4', None), 545 | ('name_5', 50), 546 | ] 547 | 548 | @pytest.mark.usefixtures('multiple_bars_with_nulls_inserted') 549 | def test_multiple_sort_fields_desc_nulls_last( 550 | self, session, is_postgresql 551 | ): 552 | if not is_postgresql: 553 | pytest.skip(NULLSLAST_NOT_SUPPORTED) 554 | 555 | query = session.query(Bar) 556 | order_by = [ 557 | {'field': 'name', 'direction': 'asc'}, 558 | {'field': 'count', 'direction': 'desc', 'nullslast': True}, 559 | ] 560 | 561 | sorted_query = apply_sort(query, order_by) 562 | results = sorted_query.all() 563 | 564 | assert [(result.name, result.count) for result in results] == [ 565 | ('name_1', 40), 566 | ('name_1', 30), 567 | ('name_1', 5), 568 | ('name_1', None), 569 | ('name_2', 20), 570 | ('name_4', 10), 571 | ('name_4', None), 572 | ('name_5', 50), 573 | ] 574 | 575 | 576 | class TestSortHybridAttributes(object): 577 | 578 | """Tests that results are sorted only according to the provided 579 | filters. 580 | 581 | Does NOT test how rows with the same values are sorted since this is 582 | not consistent across RDBMS. 583 | 584 | Does NOT test whether `NULL` field values are placed first or last 585 | when sorting since this may differ across RDBMSs. 586 | 587 | SQL defines that `NULL` values should be placed together when 588 | sorting, but it does not specify whether they should be placed first 589 | or last. 590 | """ 591 | 592 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 593 | def test_single_sort_hybrid_property_asc(self, session): 594 | query = session.query(Bar) 595 | order_by = [{'field': 'count_square', 'direction': 'asc'}] 596 | 597 | sorted_query = apply_sort(query, order_by) 598 | results = sorted_query.all() 599 | 600 | assert [result.count_square for result in results] == [ 601 | 1, 4, 4, 9, 25, 100, 144, 225 602 | ] 603 | 604 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 605 | def test_single_sort_hybrid_property_desc(self, session): 606 | query = session.query(Bar) 607 | order_by = [{'field': 'count_square', 'direction': 'desc'}] 608 | 609 | sorted_query = apply_sort(query, order_by) 610 | results = sorted_query.all() 611 | 612 | assert [result.count_square for result in results] == [ 613 | 225, 144, 100, 25, 9, 4, 4, 1 614 | ] 615 | 616 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 617 | def test_single_sort_hybrid_method_asc(self, session): 618 | query = session.query(Bar) 619 | order_by = [{'field': 'three_times_count', 'direction': 'asc'}] 620 | 621 | sorted_query = apply_sort(query, order_by) 622 | results = sorted_query.all() 623 | 624 | assert [result.three_times_count() for result in results] == [ 625 | 3, 6, 6, 9, 15, 30, 36, 45 626 | ] 627 | 628 | @pytest.mark.usefixtures('multiple_bars_with_no_nulls_inserted') 629 | def test_single_sort_hybrid_method_desc(self, session): 630 | query = session.query(Bar) 631 | order_by = [{'field': 'three_times_count', 'direction': 'desc'}] 632 | 633 | sorted_query = apply_sort(query, order_by) 634 | results = sorted_query.all() 635 | 636 | assert [result.three_times_count() for result in results] == [ 637 | 45, 36, 30, 15, 9, 6, 6, 3 638 | ] 639 | -------------------------------------------------------------------------------- /test/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sqlalchemy import ( 4 | Column, Date, DateTime, ForeignKey, Integer, String, Time 5 | ) 6 | from sqlalchemy.dialects.postgresql import ARRAY 7 | from sqlalchemy.ext.declarative import declarative_base 8 | from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method 9 | from sqlalchemy.orm import relationship 10 | 11 | 12 | class Base(object): 13 | id = Column(Integer, primary_key=True) 14 | name = Column(String(50), nullable=False) 15 | count = Column(Integer, nullable=True) 16 | 17 | @hybrid_property 18 | def count_square(self): 19 | return self.count * self.count 20 | 21 | @hybrid_method 22 | def three_times_count(self): 23 | return self.count * 3 24 | 25 | 26 | Base = declarative_base(cls=Base) 27 | BasePostgresqlSpecific = declarative_base(cls=Base) 28 | 29 | 30 | class Foo(Base): 31 | 32 | __tablename__ = 'foo' 33 | 34 | bar_id = Column(Integer, ForeignKey('bar.id'), nullable=True) 35 | bar = relationship('Bar', back_populates='foos') 36 | 37 | 38 | class Bar(Base): 39 | 40 | __tablename__ = 'bar' 41 | foos = relationship('Foo', back_populates='bar') 42 | 43 | 44 | class Baz(Base): 45 | 46 | __tablename__ = 'baz' 47 | 48 | qux_id = Column(Integer, ForeignKey('qux.id'), nullable=True) 49 | 50 | 51 | class Qux(Base): 52 | 53 | __tablename__ = 'qux' 54 | 55 | created_at = Column(Date) 56 | execution_time = Column(DateTime) 57 | expiration_time = Column(Time) 58 | 59 | 60 | class Corge(BasePostgresqlSpecific): 61 | 62 | __tablename__ = 'corge' 63 | 64 | tags = Column(ARRAY(String, dimensions=1)) 65 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py37,py38,py39,py310}-sqlalchemy{1.0,1.1,1.2,1.3,1.4,latest} 3 | skipsdist = True 4 | 5 | [testenv] 6 | whitelist_externals = make 7 | usedevelop = true 8 | extras = 9 | dev 10 | mysql 11 | postgresql 12 | deps = 13 | {py37,py38,py39,py310}: sqlalchemy-utils~=0.37.8 14 | sqlalchemy1.0: sqlalchemy>=1.0,<1.1 15 | sqlalchemy1.1: sqlalchemy>=1.1,<1.2 16 | sqlalchemy1.2: sqlalchemy>=1.2,<1.3 17 | sqlalchemy1.3: sqlalchemy>=1.3,<1.4 18 | sqlalchemy1.4: sqlalchemy>=1.4,<1.5 19 | commands = 20 | make coverage ARGS='-x -vv' 21 | --------------------------------------------------------------------------------