Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

'contains' operator #59

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions sqlalchemy_filters/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
83 changes: 66 additions & 17 deletions sqlalchemy_filters/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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.

Expand All @@ -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}

Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions test/interface/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
15 changes: 13 additions & 2 deletions test/interface/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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
Expand Down