diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2a1f049 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[coverage:run] +plugins = + coverage_conditional_plugin + +[coverage:coverage_conditional_plugin] +rules = + "package_version('sqlalchemy') < (1, 4)": no_cover_sqlalchemy_lt_1_4 + "package_version('sqlalchemy') >= (1, 4)": no_cover_sqlalchemy_gte_1_4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f831037 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,70 @@ +name: Tests CI +on: + - push + - pull_request + +jobs: + tests: + name: ${{ matrix.tox }} + runs-on: ubuntu-20.04 + + 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: + # sqlalchemylatest (i.e. > 2.0.0) is not yet supported + # for any version of python + + - {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-sqlalchemy1.4"} + + - {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-sqlalchemy1.4"} + + - {python: '3.9', tox: "py39-sqlalchemy1.0"} + - {python: '3.9', tox: "py39-sqlalchemy1.1"} + - {python: '3.9', tox: "py39-sqlalchemy1.2"} + - {python: '3.9', tox: "py39-sqlalchemy1.3"} + - {python: '3.9', tox: "py39-sqlalchemy1.4"} + + # python3.10 with sqlalchemy <= 1.1 errors with: + # AttributeError: module 'collections' has no attribute 'MutableMapping' + - {python: '3.10', tox: "py310-sqlalchemy1.2"} + - {python: '3.10', tox: "py310-sqlalchemy1.3"} + - {python: '3.10', tox: "py310-sqlalchemy1.4"} + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - run: pip install tox~=3.28 + - run: tox -e ${{ matrix.tox }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6c9de39..0000000 --- a/.travis.yml +++ /dev/null @@ -1,87 +0,0 @@ -language: python -python: 3.7 - -dist: xenial - -services: - - docker - -before_install: - - make mysql-container - - make postgres-container - -install: - - pip install tox - -matrix: - include: - - stage: test - python: 2.7 - env: TOX_ENV="py27-sqlalchemy1.0" - - python: 2.7 - env: TOX_ENV="py27-sqlalchemy1.1" - - python: 2.7 - env: TOX_ENV="py27-sqlalchemy1.2" - - python: 2.7 - env: TOX_ENV="py27-sqlalchemy1.3" - - python: 2.7 - env: TOX_ENV="py27-sqlalchemylatest" - - - python: 3.5 - env: TOX_ENV="py35-sqlalchemy1.0" - - python: 3.5 - env: TOX_ENV="py35-sqlalchemy1.1" - - python: 3.5 - env: TOX_ENV="py35-sqlalchemy1.2" - - python: 3.5 - env: TOX_ENV="py35-sqlalchemy1.3" - - python: 3.5 - env: TOX_ENV="py35-sqlalchemylatest" - - - python: 3.6 - env: TOX_ENV="py36-sqlalchemy1.0" - - python: 3.6 - env: TOX_ENV="py36-sqlalchemy1.1" - - python: 3.6 - env: TOX_ENV="py36-sqlalchemy1.2" - - python: 3.6 - env: TOX_ENV="py36-sqlalchemy1.3" - - python: 3.6 - env: TOX_ENV="py36-sqlalchemylatest" - - - python: 3.7 - env: TOX_ENV="py37-sqlalchemy1.0" - - python: 3.7 - env: TOX_ENV="py37-sqlalchemy1.1" - - python: 3.7 - env: TOX_ENV="py37-sqlalchemy1.2" - - python: 3.7 - env: TOX_ENV="py37-sqlalchemy1.3" - - python: 3.7 - env: TOX_ENV="py37-sqlalchemylatest" - - - python: 3.8 - env: TOX_ENV="py38-sqlalchemy1.0" - - python: 3.8 - env: TOX_ENV="py38-sqlalchemy1.1" - - python: 3.8 - env: TOX_ENV="py38-sqlalchemy1.2" - - python: 3.8 - env: TOX_ENV="py38-sqlalchemy1.3" - - python: 3.8 - env: TOX_ENV="py38-sqlalchemylatest" - - - stage: deploy - script: skip - deploy: - provider: pypi - user: mattbennett - password: - secure: "x27Zk+mvbYOtuf3XojYBh3O32KTv79SebMzkXE1lmtSms/pvRT+OG5eUAvWq1tro0shQdvv1X86aK9UHNzkRpIJEvbyH8zP3nWr/TlI62iZJC6y9m/c6JWwIUBk3p+CDHSwA1i6QXrxnffRfE/KSlAuTOY2FDYFDUBdVrEQrqIJa68Ij9200kxNcYWC9JmJKLaGl1l/V9M2K7YDsll3PxBByrCQeM95oeuoL3U81fPV0CJnsPNXAMtQHC9+dv+WE9UCo9QVsxWrnJGvCVUAfNQ7Ub+pECwSqzIL1O1ltqi0JH5CafwF6EMcQWxjh6R7IuN224QoAGTBzJ+ngKMxV7aQr58rkP82CyGaYEI7w9pvM9cZ6nBVIdgfCe8EfXCg+tTYy6v7SptT66wzP+GdX7sOuNFDYadmXGHZdwtUtEDFHwiCETRJNt/t5ONejlktY0fXlRUCta5UbYj755iryhk1lE7Ldj3sU336WjG74iAFdAd0fO8gVM0OwKLYZeaU2mN4JBI4qcp6n2S0sNtoyqXzm9OL/76Is5vAzUQavto9ao9bw0HN+ZWVLyE4+NLKS3ti+Pi/YofieoUFZ6kQJQRGja3tt7RCl0MYHqdIQQWx9MJWdiDpkyqqtR+0Q2sSYo8g4TsJe8zCuQLOflwd9pLKXVwdfqMUVGFCbdzZ/rig=" - on: - tags: true - repo: juliotrigo/sqlalchemy-filters - distributions: "sdist bdist_wheel" - -script: - - tox -e $TOX_ENV diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 821e30a..4cf510f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,14 @@ Release Notes Here you can see the full list of changes between sqlalchemy-filters versions, where semantic versioning is used: *major.minor.patch*. +0.13.0 +------ + +Released 2023-04-13 + +* Add support for SQLAlchemy 1.4 (#69) thanks to @bodik +* Add support for Python 3.9 & Python 3.10 +* Drop support for Python 2.7, 3.5 & 3.6 0.12.0 ------ diff --git a/README.rst b/README.rst index 8c3b8f2..2dd9cda 100644 --- a/README.rst +++ b/README.rst @@ -16,8 +16,8 @@ SQLAlchemy filters .. image:: https://img.shields.io/pypi/format/sqlalchemy-filters.svg :target: https://pypi.org/project/sqlalchemy-filters/ -.. image:: https://travis-ci.org/juliotrigo/sqlalchemy-filters.svg?branch=master - :target: https://travis-ci.org/juliotrigo/sqlalchemy-filters +.. image:: https://github.com/juliotrigo/sqlalchemy-filters/actions/workflows/tests.yml/badge.svg + :target: https://github.com/juliotrigo/sqlalchemy-filters/actions Filtering @@ -487,19 +487,11 @@ The following RDBMS are supported (tested): - PostgreSQL -Python 2 --------- - -There is no active support for Python 2. However, it is compatible as of -February 2019, if you install ``funcsigs``, included in the ``python2`` -extra requirements. - - SQLAlchemy support ------------------ The following SQLAlchemy_ versions are supported: ``1.0``, ``1.1``, -``1.2``, ``1.3``. +``1.2``, ``1.3``, ``1.4``. Changelog diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2bcd70e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 diff --git a/setup.py b/setup.py index 5e9c10e..5dc0a57 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='sqlalchemy-filters', - version='0.12.0', + version='0.13.0', description='A library to filter SQLAlchemy queries.', long_description=readme, long_description_content_type='text/x-rst', @@ -20,19 +20,20 @@ author_email='wearehiring@student.com', url='https://github.com/juliotrigo/sqlalchemy-filters', packages=find_packages(exclude=['test', 'test.*']), + python_requires='>=3.7', install_requires=['sqlalchemy>=1.0.16', 'six>=1.10.0'], extras_require={ 'dev': [ 'pytest>=4.6.9', 'coverage~=5.0.4', - 'sqlalchemy-utils~=0.36.3', + 'sqlalchemy-utils>=0.37', 'flake8', 'restructuredtext-lint', 'Pygments', + 'coverage-conditional-plugin', ], 'mysql': ['mysql-connector-python-rf==2.2.2'], 'postgresql': ['psycopg2==2.8.4'], - 'python2': ['funcsigs>=1.0.2'], }, zip_safe=True, license='Apache License, Version 2.0', @@ -42,10 +43,10 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Database", "Topic :: Database :: Front-Ends", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/sqlalchemy_filters/filters.py b/sqlalchemy_filters/filters.py index 356c4fd..ba9d919 100644 --- a/sqlalchemy_filters/filters.py +++ b/sqlalchemy_filters/filters.py @@ -1,17 +1,7 @@ # -*- coding: utf-8 -*- from collections import namedtuple -try: - from collections.abc import Iterable -except ImportError: # pragma: no cover - # For python2 capability. - from collections import Iterable -try: - from inspect import signature -except ImportError: # pragma: no cover - # For python2 capability. NOTE: This is in not handled in install_requires - # but rather in extras_require. You can install with - # 'pip install sqlalchemy-filters[python2]' - from funcsigs import signature +from collections.abc import Iterable +from inspect import signature from itertools import chain from six import string_types diff --git a/sqlalchemy_filters/models.py b/sqlalchemy_filters/models.py index 1c79516..b4f3084 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: no_cover_sqlalchemy_lt_1_4 + """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: no_cover_sqlalchemy_gte_1_4 + models.extend(mapper.class_ for mapper in query._join_entities) + else: # pragma: no_cover_sqlalchemy_lt_1_4 + 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: no_cover_sqlalchemy_gte_1_4 + 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: no_cover_sqlalchemy_lt_1_4 + 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(): + if model and (model not in get_query_models(query).values()): try: - query = query.join(model) + if sqlalchemy_version_lt('1.4'): # pragma: no_cover_sqlalchemy_gte_1_4 + query = query.join(model) + else: # pragma: no_cover_sqlalchemy_lt_1_4 + # 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_models.py b/test/interface/test_models.py index 0531095..2ef7f88 100644 --- a/test/interface/test_models.py +++ b/test/interface/test_models.py @@ -5,12 +5,24 @@ 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, get_model_from_table ) from test.models import Base, Bar, Foo, Qux class TestGetQueryModels(object): + @pytest.mark.skipif( + sqlalchemy_version_lt('1.4'), reason='tests sqlalchemy 1.4 code' + ) + def test_returns_none_for_unknown_table(self): + + class FakeUnmappedTable: + pass + + table = FakeUnmappedTable() + + result = get_model_from_table(table) + assert result is None def test_query_with_no_models(self, session): query = session.query() @@ -33,6 +45,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 +151,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..ca2fca8 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 = {py37,py38,py39,py310}-sqlalchemy{1.0,1.1,1.2,1.3,1.4,latest} skipsdist = True [testenv] @@ -10,13 +10,11 @@ extras = mysql postgresql deps = - py27: funcsigs - # https://docs.pytest.org/en/latest/py27-py34-deprecation.html - py27: pytest<5.0.0 - {py35,py36,py37,py38}: pytest~=5.4.1 + {py37,py38,py39,py310}: 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 sqlalchemy1.3: sqlalchemy>=1.3,<1.4 + sqlalchemy1.4: sqlalchemy>=1.4,<1.5 commands = make coverage ARGS='-x -vv'