diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..eebf8b9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,72 @@ +name: Tests CI +on: + - push + - pull_request + +jobs: + tests: + name: ${{ matrix.tox }} + runs-on: ubuntu-latest + + services: + mariadb: + image: mariadb:latest + ports: + - 3306:3306 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + postgres: + image: postgres + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: test_sqlalchemy_filters + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=en_US.utf8 --lc-ctype=en_US.utf8" + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + + strategy: + fail-fast: false + matrix: + include: + - {python: '2.7', tox: "py27-sqlalchemy1.0"} + - {python: '2.7', tox: "py27-sqlalchemy1.1"} + - {python: '2.7', tox: "py27-sqlalchemy1.2"} + - {python: '2.7', tox: "py27-sqlalchemy1.3"} + + - {python: '3.5', tox: "py35-sqlalchemy1.0"} + - {python: '3.5', tox: "py35-sqlalchemy1.1"} + - {python: '3.5', tox: "py35-sqlalchemy1.2"} + - {python: '3.5', tox: "py35-sqlalchemy1.3"} + - {python: '3.5', tox: "py35-sqlalchemylatest"} + + - {python: '3.6', tox: "py36-sqlalchemy1.0"} + - {python: '3.6', tox: "py36-sqlalchemy1.1"} + - {python: '3.6', tox: "py36-sqlalchemy1.2"} + - {python: '3.6', tox: "py36-sqlalchemy1.3"} + - {python: '3.6', tox: "py36-sqlalchemylatest"} + + - {python: '3.7', tox: "py37-sqlalchemy1.0"} + - {python: '3.7', tox: "py37-sqlalchemy1.1"} + - {python: '3.7', tox: "py37-sqlalchemy1.2"} + - {python: '3.7', tox: "py37-sqlalchemy1.3"} + - {python: '3.7', tox: "py37-sqlalchemylatest"} + + - {python: '3.8', tox: "py38-sqlalchemy1.0"} + - {python: '3.8', tox: "py38-sqlalchemy1.1"} + - {python: '3.8', tox: "py38-sqlalchemy1.2"} + - {python: '3.8', tox: "py38-sqlalchemy1.3"} + - {python: '3.8', tox: "py38-sqlalchemylatest"} + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + + - run: pip install tox + - run: tox -e ${{ matrix.tox }} diff --git a/setup.py b/setup.py index 5e9c10e..1631980 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,8 @@ 'dev': [ 'pytest>=4.6.9', 'coverage~=5.0.4', - 'sqlalchemy-utils~=0.36.3', + # for sqlalchemy1.4 >= 0.37 is required + 'sqlalchemy-utils>=0.36.3', 'flake8', 'restructuredtext-lint', 'Pygments', diff --git a/sqlalchemy_filters/filters.py b/sqlalchemy_filters/filters.py index 356c4fd..58400b3 100644 --- a/sqlalchemy_filters/filters.py +++ b/sqlalchemy_filters/filters.py @@ -58,6 +58,7 @@ class Operator(object): 'not_in': lambda f, a: ~f.in_(a), 'any': lambda f, a: f.any(a), 'not_any': lambda f, a: func.not_(f.any(a)), + 'contains': lambda f, a: f.contains(a), } def __init__(self, operator=None): diff --git a/sqlalchemy_filters/models.py b/sqlalchemy_filters/models.py index 1c79516..e9e1c3b 100644 --- a/sqlalchemy_filters/models.py +++ b/sqlalchemy_filters/models.py @@ -1,12 +1,19 @@ +from sqlalchemy import __version__ as sqlalchemy_version from sqlalchemy.exc import InvalidRequestError +from sqlalchemy.orm import mapperlib from sqlalchemy.inspection import inspect -from sqlalchemy.orm.mapper import Mapper from sqlalchemy.util import symbol import types from .exceptions import BadQuery, FieldNotFound, BadSpec +def sqlalchemy_version_lt(version): + """compares sqla version < version""" + + return tuple(sqlalchemy_version.split('.')) < tuple(version.split('.')) + + class Field(object): def __init__(self, model, field_name): @@ -51,6 +58,16 @@ def _is_hybrid_method(orm_descriptor): return orm_descriptor.extension_type == symbol('HYBRID_METHOD') +def get_model_from_table(table): # pragma: nocover + """Resolve model class from table object""" + + for registry in mapperlib._all_registries(): + for mapper in registry.mappers: + if table in mapper.tables: + return mapper.class_ + return None + + def get_query_models(query): """Get models from query. @@ -61,20 +78,39 @@ def get_query_models(query): A dictionary with all the models included in the query. """ models = [col_desc['entity'] for col_desc in query.column_descriptions] - models.extend(mapper.class_ for mapper in query._join_entities) + + # account joined entities + if sqlalchemy_version_lt('1.4'): # pragma: nocover + models.extend(mapper.class_ for mapper in query._join_entities) + else: # pragma: nocover + try: + models.extend( + mapper.class_ + for mapper + in query._compile_state()._join_entities + ) + except InvalidRequestError: + # query might not contain columns yet, hence cannot be compiled + # try to infer the models from various internals + for table_tuple in query._setup_joins + query._legacy_setup_joins: + model_class = get_model_from_table(table_tuple[0]) + if model_class: + models.append(model_class) # account also query.select_from entities - if ( - hasattr(query, '_select_from_entity') and - (query._select_from_entity is not None) - ): - model_class = ( - query._select_from_entity.class_ - if isinstance(query._select_from_entity, Mapper) # sqlalchemy>=1.1 - else query._select_from_entity # sqlalchemy==1.0 - ) - if model_class not in models: - models.append(model_class) + model_class = None + if sqlalchemy_version_lt('1.4'): # pragma: nocover + if query._select_from_entity: + model_class = ( + query._select_from_entity + if sqlalchemy_version_lt('1.1') + else query._select_from_entity.class_ + ) + else: # pragma: nocover + if query._from_obj: + model_class = get_model_from_table(query._from_obj[0]) + if model_class and (model_class not in models): + models.append(model_class) return {model.__name__: model for model in models} @@ -152,13 +188,26 @@ def auto_join(query, *model_names): """ # every model has access to the registry, so we can use any from the query query_models = get_query_models(query).values() - model_registry = list(query_models)[-1]._decl_class_registry + last_model = list(query_models)[-1] + model_registry = ( + last_model._decl_class_registry + if sqlalchemy_version_lt('1.4') + else last_model.registry._class_registry + ) for name in model_names: model = get_model_class_by_name(model_registry, name) - if model not in get_query_models(query).values(): - try: - query = query.join(model) + if model and (model not in get_query_models(query).values()): + try: # pragma: nocover + if sqlalchemy_version_lt('1.4'): + query = query.join(model) + else: + # https://docs.sqlalchemy.org/en/14/changelog/migration_14.html + # Many Core and ORM statement objects now perform much of + # their construction and validation in the compile phase + tmp = query.join(model) + tmp._compile_state() + query = tmp except InvalidRequestError: pass # can't be autojoined return query diff --git a/test/interface/test_filters.py b/test/interface/test_filters.py index d904714..bd2b0fe 100644 --- a/test/interface/test_filters.py +++ b/test/interface/test_filters.py @@ -1217,6 +1217,20 @@ def test_not_any_values_in_array(self, session, is_postgresql): assert result[1].id == 4 +class TestApplyContainsFilter: + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_field_contains_value(self, session): + query = session.query(Bar) + filters = [{'field': 'name', 'op': 'contains', 'value': '_1'}] + + filtered_query = apply_filters(query, filters) + result = filtered_query.all() + + assert len(result) == 2 + assert result[0].id == 1 + assert result[1].id == 3 + + class TestHybridAttributes: @pytest.mark.usefixtures('multiple_bars_inserted') diff --git a/test/interface/test_models.py b/test/interface/test_models.py index 0531095..8910efb 100644 --- a/test/interface/test_models.py +++ b/test/interface/test_models.py @@ -5,7 +5,7 @@ from sqlalchemy_filters.exceptions import BadSpec, BadQuery from sqlalchemy_filters.models import ( auto_join, get_default_model, get_query_models, get_model_class_by_name, - get_model_from_spec + get_model_from_spec, sqlalchemy_version_lt ) from test.models import Base, Bar, Foo, Qux @@ -33,6 +33,13 @@ def test_query_with_select_from_model(self, session): assert {'Bar': Bar} == entities + def test_query_with_select_from_and_join_model(self, session): + query = session.query().select_from(Bar).join(Foo) + + entities = get_query_models(query) + + assert {'Bar': Bar, 'Foo': Foo} == entities + def test_query_with_multiple_models(self, session): query = session.query(Bar, Qux) @@ -132,7 +139,11 @@ class TestGetModelClassByName: @pytest.fixture def registry(self): - return Base._decl_class_registry + return ( + Base._decl_class_registry + if sqlalchemy_version_lt('1.4') + else Base.registry._class_registry + ) def test_exists(self, registry): assert get_model_class_by_name(registry, 'Foo') == Foo diff --git a/tox.ini b/tox.ini index 46a9424..79a7e42 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = {py27,py35,py36,py37,py38}-sqlalchemy{1.0,1.1,1.2,1.3,latest} +envlist = {py27}-sqlalchemy{1.0,1.1,1.2,1.3},{py35,py36,py37,py38}-sqlalchemy{1.0,1.1,1.2,1.3,latest} skipsdist = True [testenv] @@ -14,6 +14,9 @@ deps = # https://docs.pytest.org/en/latest/py27-py34-deprecation.html py27: pytest<5.0.0 {py35,py36,py37,py38}: pytest~=5.4.1 + # https://github.com/kvesteri/sqlalchemy-utils/blob/master/CHANGES.rst#0364-2020-04-30 + py27: sqlalchemy-utils==0.36.3 + {py35,py36,py37,py38}: sqlalchemy-utils~=0.37.8 sqlalchemy1.0: sqlalchemy>=1.0,<1.1 sqlalchemy1.1: sqlalchemy>=1.1,<1.2 sqlalchemy1.2: sqlalchemy>=1.2,<1.3