From e0aa411c23e906b90731a981288501c95664fdf2 Mon Sep 17 00:00:00 2001 From: Michael Bukachi Date: Mon, 3 Apr 2023 23:43:07 +0300 Subject: [PATCH] Migrate to SQLAlchemy 20 (#95) * wip: Migrate to SQLAlchemy 20 * build: Update actions * wip: Update actions * fix: Fix broken tests * fix: Fix lint checks * build: Add missing dev dependency * build: Fix CI action file * fix: Fix example * build: Upgrade nose * fix: Update CI actions * fix: Remove unused import * refactor: Update declarative base * refactor: Update inspection mixin * docs: Update readme * docs: Update readme --- .github/workflows/test-pr.yml | 19 +---- README.md | 60 +++++++--------- examples/activerecord.py | 8 +-- examples/all_features.py | 12 ++-- examples/eagerload.py | 39 +++-------- examples/repr.py | 6 +- examples/serialize.py | 6 +- examples/smartquery.py | 16 ++--- examples/timestamp.py | 6 +- mypy.ini | 2 +- requirements-dev.txt | 6 +- requirements.txt | 2 +- sqlalchemy_mixins/activerecord.py | 8 +-- sqlalchemy_mixins/eagerload.py | 73 ++++++++++++-------- sqlalchemy_mixins/eagerload.pyi | 8 +-- sqlalchemy_mixins/inspection.py | 8 +-- sqlalchemy_mixins/inspection.pyi | 6 +- sqlalchemy_mixins/smartquery.py | 23 ++---- sqlalchemy_mixins/tests/test_activerecord.py | 6 +- sqlalchemy_mixins/tests/test_eagerload.py | 60 +++------------- sqlalchemy_mixins/tests/test_inspection.py | 6 +- sqlalchemy_mixins/tests/test_repr.py | 6 +- sqlalchemy_mixins/tests/test_serialize.py | 6 +- sqlalchemy_mixins/tests/test_session.py | 6 +- sqlalchemy_mixins/tests/test_smartquery.py | 35 +++++----- sqlalchemy_mixins/tests/test_timestamp.py | 6 +- sqlalchemy_mixins/utils.py | 23 ++++++ sqlalchemy_mixins/utils.pyi | 6 +- 28 files changed, 203 insertions(+), 265 deletions(-) diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index 56f42eb..f6a5679 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, '3.10'] # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it @@ -23,24 +23,11 @@ jobs: run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements-dev.txt - pip install "sqlalchemy<1.4" - name: Run type checks run: mypy sqlalchemy_mixins - - name: Run tests < sqlalchemy1.4 + - name: Run tests run: | - nosetests --with-coverage --cover-inclusive --cover-package=sqlalchemy_mixins - export PYTHONPATH=.:$PYTHONPATH - python examples/activerecord.py - python examples/all_features.py - python examples/eagerload.py - python examples/repr.py - python examples/smartquery.py - python examples/serialize.py - python examples/timestamp.py - - name: Run tests >= sqlalchemy1.4 - run: | - pip install -U sqlalchemy - nosetests --with-coverage --cover-inclusive --cover-package=sqlalchemy_mixins + nose2 --coverage=sqlalchemy_mixins export PYTHONPATH=.:$PYTHONPATH python examples/activerecord.py python examples/all_features.py diff --git a/README.md b/README.md index 2566b33..5377742 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ post2 = Post.create(body='long-long-long-long-long body', rating=2, # will output this beauty: print(Post.where(rating__in=[2, 3, 4], user___name__like='%Bi%').all()) # joinedload post and user -print(Comment.with_joined('user', 'post', 'post.comments').first()) -# subqueryload posts and their comments -print(User.with_subquery('posts', 'posts.comments').first()) +print(Comment.with_joined(Comment.user, Comment.post).first()) +# subqueryload posts +print(User.with_subquery(User.posts).first()) # sort by rating DESC, user name ASC print(Post.sort('-rating', 'user___name').all()) # created_at, updated_at timestamps added automatically @@ -230,17 +230,6 @@ load user, all his posts and comments to every his post in the same query. Well, now you can easily set what ORM relations you want to eager load ```python -User.with_({ - 'posts': { - 'comments': { - 'user': JOINED - } - } -}).all() -``` - -or we can write class properties instead of strings: -```python User.with_({ User.posts: { Post.comments: { @@ -259,9 +248,9 @@ To speed up query, we load comments in separate query, but, in this separate que ```python from sqlalchemy_mixins import JOINED, SUBQUERY Post.with_({ - 'user': JOINED, # joinedload user - 'comments': (SUBQUERY, { # load comments in separate query - 'user': JOINED # but, in this separate query, join user + Post.user: JOINED, # joinedload user + Post.comments: (SUBQUERY, { # load comments in separate query + Comment.user: JOINED # but, in this separate query, join user }) }).all() ``` @@ -277,15 +266,10 @@ or [subqueryload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships a few relations, we have easier syntax for you: ```python -Comment.with_joined('user', 'post', 'post.comments').first() -User.with_subquery('posts', 'posts.comments').all() +Comment.with_joined(Comment.user, Comment.post).first() +User.with_subquery(User.posts).all() ``` -> Note that you can split relations with dot like `post.comments` -> due to [this SQLAlchemy feature](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html#sqlalchemy.orm.subqueryload_all) - - -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/eagerload.py) and [tests](sqlalchemy_mixins/tests/test_eagerload.py) ## Filter and sort by relations @@ -336,7 +320,6 @@ SQLAlchemy's [hybrid attributes](http://docs.sqlalchemy.org/en/latest/orm/extens and [hybrid_methods](http://docs.sqlalchemy.org/en/latest/orm/extensions/hybrid.html?highlight=hybrid_method#sqlalchemy.ext.hybrid.hybrid_method). Using them in our filtering/sorting is straightforward (see examples and tests). -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/smartquery.py) and [tests](sqlalchemy_mixins/tests/test_smartquery.py) ### Automatic eager load relations @@ -358,7 +341,6 @@ comments[0].post.user Cool, isn't it? =) -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/smartquery.py) and [tests](sqlalchemy_mixins/tests/test_smartquery.py) ### All-in-one: smart_query @@ -378,8 +360,8 @@ Comment.smart_query( }, sort_attrs=['user___name', '-created_at'], schema={ - 'post': { - 'user': JOINED + Comment.post: { + Post.user: JOINED } }).all() ``` @@ -410,7 +392,6 @@ Comment.smart_query( > See [this example](examples/smartquery.py#L409) for more details -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/smartquery.py) and [tests](sqlalchemy_mixins/tests/test_smartquery.py) ## Beauty \_\_repr\_\_ @@ -473,7 +454,6 @@ class Post(BaseModel): > ``` -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/repr.py) and [tests](sqlalchemy_mixins/tests/test_repr.py) ## Serialize to dict @@ -495,7 +475,6 @@ print(user.to_dict()) # {'body': 'Post 2', 'id': 2, 'user_id': 1}]} print(user.to_dict(nested=True)) ``` -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/serialize.py) ## Timestamps @@ -522,7 +501,6 @@ session.commit() print("Updated Bob: ", bob.updated_at) # Updated Bob: 2019-03-04 03:53:58.613044 ``` -![icon](http://i.piccy.info/i9/c7168c8821f9e7023e32fd784d0e2f54/1489489664/1113/1127895/rsz_18_256.png) See [full example](examples/timestamp.py) # Internal architecture notes @@ -530,9 +508,6 @@ Some mixins re-use the same functionality. It lives in [`SessionMixin`](sqlalche You can use these mixins standalone if you want. -Here's a UML diagram of mixin hierarchy: -![Mixin hierarchy](http://i.piccy.info/i9/4030c604ef387101a6ec30b7c357134c/1490694900/42743/1127895/diagram.png) - # Comparison with existing solutions There're a lot of extensions for SQLAlchemy, but most of them are not so universal. @@ -693,3 +668,18 @@ removed [TimestampsMixin](#timestamps) from [AllFeaturesMixin](sqlalchemy_mixins ### v1.3 Add support for SQLAlchemy 1.4 + + +### v2.0.0 + +> This version contains breaking changes in multiple methods i.e methods that simplify +> eager loading. The use of strings while eager loading has been removed completely in SQLAlchemy 2.0. +> To much this behaviour, we have also removed the use of strings when eager loading + +1. Migrate to SQLAlchemy 2.0 +2. All methods in the `EagerLoadMixin` no longer accept strings. **Note** This means that you can only pass direct relationships. +3. The `schema` parameter of the `smart_query` method/function no longer accepts string keys. +4. **Dropped Python 3.6** +5. Add Python 3.10 compatibility + + diff --git a/examples/activerecord.py b/examples/activerecord.py index 0f1e6a7..e14620f 100644 --- a/examples/activerecord.py +++ b/examples/activerecord.py @@ -3,9 +3,8 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import ActiveRecordMixin, ReprMixin, ModelNotFoundError @@ -14,7 +13,8 @@ def log(msg): print('\n{}\n'.format(msg)) #################### setup ###################### -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True # we also use ReprMixin which is optional @@ -54,7 +54,7 @@ def public(self, public): db_file = os.path.join(os.path.dirname(__file__), 'test.sqlite') engine = create_engine('sqlite:///{}'.format(db_file), echo=True) # autocommit=True - it's to make you see data in 3rd party DB view tool -session = scoped_session(sessionmaker(bind=engine, autocommit=True)) +session = scoped_session(sessionmaker(bind=engine)) Base.metadata.drop_all(engine) Base.metadata.create_all(engine) diff --git a/examples/all_features.py b/examples/all_features.py index 4a56053..19c7aa5 100644 --- a/examples/all_features.py +++ b/examples/all_features.py @@ -4,11 +4,11 @@ """ from __future__ import print_function import sqlalchemy as sa -from sqlalchemy.orm import scoped_session, sessionmaker -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import AllFeaturesMixin, TimestampsMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True class BaseModel(Base, AllFeaturesMixin, TimestampsMixin): @@ -69,10 +69,10 @@ class Comment(BaseModel): print(Post.where(rating__in=[2, 3, 4], user___name__like='%Bi%').all()) # joinedload post and user -print(Comment.with_joined('user', 'post', 'post.comments').first()) +print(Comment.with_joined(Comment.user, Comment.post).first()) -# subqueryload posts and their comments -print(User.with_subquery('posts', 'posts.comments').first()) +# subqueryload posts +print(User.with_subquery(User.posts).first()) # sort by rating DESC, user name ASC print(Post.sort('-rating', 'user___name').all()) diff --git a/examples/eagerload.py b/examples/eagerload.py index d1c8b9d..04adc40 100644 --- a/examples/eagerload.py +++ b/examples/eagerload.py @@ -3,8 +3,7 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Query, scoped_session, sessionmaker +from sqlalchemy.orm import Query, scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import EagerLoadMixin, ReprMixin from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY, eager_expr @@ -14,7 +13,8 @@ def log(msg): print('\n{}\n'.format(msg)) #################### setup ###################### -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True # we also use ReprMixin which is optional @@ -130,9 +130,7 @@ def reset_session(): #### 0.1 joinedload #### reset_session() -comment = Comment.with_joined('user', 'post', 'post.comments').first() -# same using class properties (except 'post.comments'): -# comment = Comment.with_joined(Comment.user, Comment.post).first() +comment = Comment.with_joined(Comment.user, Comment.post).first() # SQL will be like """ @@ -152,9 +150,7 @@ def reset_session(): #### 0.2 subqueryload #### reset_session() -users = User.with_subquery('posts', 'posts.comments').all() -# same using class properties (except 'posts.comments'): -# users = User.with_subquery(User.posts).all() +users = User.with_subquery(User.posts).all() # there will be 3 queries: ## first. on users: @@ -180,25 +176,14 @@ def reset_session(): #### 1. nested joinedload #### # for nested eagerload, you should use dict instead of lists| +# also make sure you use class properties schema = { - 'posts': { # joined-load posts - # here, - # 'posts': { ... } - # is equal to - # 'posts': (JOINED, { ... }) - 'comments': { # to each post join its comments - 'user': JOINED # and join user to each comment + User.posts: { + Post.comments: { + Comment.user: JOINED } } } -# same schema using class properties -# schema = { -# User.posts: { -# Post.comments: { -# Comment.user: JOINED -# } -# } -# } session = reset_session() ###### 1.1 query-level: more flexible user = session.query(User).options(*eager_expr(schema)).get(1) @@ -231,12 +216,6 @@ def reset_session(): # sometimes we want to load relations in separate query. # i.g. when we load posts, to each post we want to have user and all comments. # when we load many posts, join comments and comments to each user -schema = { - 'comments': (SUBQUERY, { # load comments in separate query - 'user': JOINED # but, in this separate query, join user - }) -} -# the same schema using class properties: schema = { Post.comments: (SUBQUERY, { # load comments in separate query Comment.user: JOINED # but, in this separate query, join comments diff --git a/examples/repr.py b/examples/repr.py index 15f0e3d..dea002a 100644 --- a/examples/repr.py +++ b/examples/repr.py @@ -1,12 +1,12 @@ from __future__ import print_function import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import ReprMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = sa.create_engine('sqlite:///:memory:') session = scoped_session(sessionmaker(bind=engine)) diff --git a/examples/serialize.py b/examples/serialize.py index b2008cb..1ac939c 100644 --- a/examples/serialize.py +++ b/examples/serialize.py @@ -1,12 +1,12 @@ from __future__ import print_function import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import SerializeMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = sa.create_engine('sqlite:///:memory:') session = scoped_session(sessionmaker(bind=engine)) diff --git a/examples/smartquery.py b/examples/smartquery.py index 550b430..9809bfd 100644 --- a/examples/smartquery.py +++ b/examples/smartquery.py @@ -4,10 +4,9 @@ import datetime import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_method from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import Query, scoped_session, sessionmaker +from sqlalchemy.orm import Query, scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import SmartQueryMixin, ReprMixin, JOINED, smart_query @@ -17,7 +16,8 @@ def log(msg): #################### setup ###################### -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True # we also use ReprMixin which is optional @@ -351,16 +351,10 @@ class Comment(BaseModel): #### 3. smart_query() : combination of where(), sort() and eager load #### schema = { - 'post': { - 'user': JOINED + Comment.post: { + Post.user: JOINED } } -# schema can use class properties too (see EagerLoadMixin): -# schema = { -# Comment.post: { -# Post.user: JOINED -# } -# } ##### 3.1 high-level smart_query() class method ##### res = Comment.smart_query( diff --git a/examples/timestamp.py b/examples/timestamp.py index f1a559d..43d6850 100644 --- a/examples/timestamp.py +++ b/examples/timestamp.py @@ -4,12 +4,12 @@ from datetime import datetime import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import scoped_session, sessionmaker, DeclarativeBase from sqlalchemy_mixins import TimestampsMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = sa.create_engine("sqlite:///:memory:") session = scoped_session(sessionmaker(bind=engine)) diff --git a/mypy.ini b/mypy.ini index d6f8df5..d485f5e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -plugins = sqlmypy +plugins = sqlalchemy.ext.mypy.plugin ignore_missing_imports = True disallow_untyped_defs = True [mypy-sqlalchemy_mixins.tests.*,] diff --git a/requirements-dev.txt b/requirements-dev.txt index cebe87a..35d2ad5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ -r requirements.txt -nose +nose2 coverage codeclimate-test-reporter tox -mypy==0.812 -sqlalchemy-stubs==0.4 \ No newline at end of file +mypy==1.1.1 +sqlalchemy[mypy] >= 2.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 240e8c1..54bc376 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -sqlalchemy >= 1.0 +sqlalchemy >= 2.0 six typing; python_version < '3.5' diff --git a/sqlalchemy_mixins/activerecord.py b/sqlalchemy_mixins/activerecord.py index dd76cdb..7b082f1 100644 --- a/sqlalchemy_mixins/activerecord.py +++ b/sqlalchemy_mixins/activerecord.py @@ -28,9 +28,7 @@ def save(self): """ try: self.session.add(self) - self.session.flush() - if not self.session.autocommit: - self.session.commit() + self.session.commit() return self except: self.session.rollback() @@ -54,9 +52,7 @@ def delete(self): """ try: self.session.delete(self) - self.session.flush() - if not self.session.autocommit: - self.session.commit() + self.session.commit() except: self.session.rollback() raise diff --git a/sqlalchemy_mixins/eagerload.py b/sqlalchemy_mixins/eagerload.py index 2f036cc..36cc97f 100644 --- a/sqlalchemy_mixins/eagerload.py +++ b/sqlalchemy_mixins/eagerload.py @@ -1,11 +1,12 @@ +from sqlalchemy.orm.strategy_options import _AbstractLoad + try: from typing import List except ImportError: # pragma: no cover pass -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, Load from sqlalchemy.orm import subqueryload -from sqlalchemy.orm.attributes import InstrumentedAttribute from .session import SessionMixin @@ -17,8 +18,7 @@ def eager_expr(schema): """ :type schema: dict """ - flat_schema = _flatten_schema(schema) - return _eager_expr_from_flat_schema(flat_schema) + return _eager_expr_from_schema(schema) def _flatten_schema(schema): @@ -32,8 +32,9 @@ def _flatten(schema, parent_path, result): for path, value in schema.items(): # for supporting schemas like Product.user: {...}, # we transform, say, Product.user to 'user' string - if isinstance(path, InstrumentedAttribute): - path = path.key + attr = path + path = path.key + if isinstance(value, tuple): join_method, inner_schema = value[0], value[1] @@ -43,7 +44,7 @@ def _flatten(schema, parent_path, result): join_method, inner_schema = value, None full_path = parent_path + '.' + path if parent_path else path - result[full_path] = join_method + result[attr] = join_method if inner_schema: _flatten(inner_schema, full_path, result) @@ -68,6 +69,35 @@ def _eager_expr_from_flat_schema(flat_schema): .format(join_method, path)) return result +def _eager_expr_from_schema(schema): + def _get_expr(schema, result): + for path, value in schema.items(): + if isinstance(value, tuple): + join_method, inner_schema = value[0], value[1] + load_option = _create_eager_load_option(path, join_method) + result.append(load_option.options(*_eager_expr_from_schema(inner_schema))) + elif isinstance(value, dict): + join_method, inner_schema = JOINED, value + load_option = _create_eager_load_option(path, join_method) + result.append(load_option.options(*_eager_expr_from_schema(inner_schema))) + # load_option = _create_eager_load_option(path, value) + else: + result.append(_create_eager_load_option(path, value)) + + result = [] + _get_expr(schema, result) + return result + +def _create_eager_load_option(path, join_method): + if join_method == JOINED: + return joinedload(path) + elif join_method == SUBQUERY: + return subqueryload(path) + else: + raise ValueError('Bad join method `{}` in `{}`' + .format(join_method, path)) + + class EagerLoadMixin(SessionMixin): __abstract__ = True @@ -80,16 +110,9 @@ def with_(cls, schema): Example: schema = { - 'user': JOINED, # joinedload user - 'comments': (SUBQUERY, { # load comments in separate query - 'user': JOINED # but, in this separate query, join user - }) - } - # the same schema using class properties: - schema = { - Post.user: JOINED, - Post.comments: (SUBQUERY, { - Comment.user: JOINED + Post.user: JOINED, # joinedload user + Post.comments: (SUBQUERY, { # load comments in separate query + Comment.user: JOINED # but, in this separate query, join user }) } User.with_(schema).first() @@ -101,15 +124,11 @@ def with_joined(cls, *paths): """ Eagerload for simple cases where we need to just joined load some relations - In strings syntax, you can split relations with dot - due to this SQLAlchemy feature: https://goo.gl/yM2DLX + You can only load direct relationships. - :type paths: *List[str] | *List[InstrumentedAttribute] + :type paths: *List[QueryableAttribute] Example 1: - Comment.with_joined('user', 'post', 'post.comments').first() - - Example 2: Comment.with_joined(Comment.user, Comment.post).first() """ options = [joinedload(path) for path in paths] @@ -120,15 +139,11 @@ def with_subquery(cls, *paths): """ Eagerload for simple cases where we need to just joined load some relations - In strings syntax, you can split relations with dot - (it's SQLAlchemy feature) + You can only load direct relationships. - :type paths: *List[str] | *List[InstrumentedAttribute] + :type paths: *List[QueryableAttribute] Example 1: - User.with_subquery('posts', 'posts.comments').all() - - Example 2: User.with_subquery(User.posts, User.comments).all() """ options = [subqueryload(path) for path in paths] diff --git a/sqlalchemy_mixins/eagerload.pyi b/sqlalchemy_mixins/eagerload.pyi index c6d35ed..436a802 100644 --- a/sqlalchemy_mixins/eagerload.pyi +++ b/sqlalchemy_mixins/eagerload.pyi @@ -1,6 +1,6 @@ -from typing import List +from typing import List, Type -from sqlalchemy.orm import Query +from sqlalchemy.orm import Query, QueryableAttribute from sqlalchemy.orm.strategy_options import Load from sqlalchemy_mixins.session import SessionMixin @@ -23,7 +23,7 @@ class EagerLoadMixin(SessionMixin): def with_(cls, schema: dict) -> Query: ... @classmethod - def with_joined(cls, *paths: List[str]) -> Query: ... + def with_joined(cls, *paths: List[QueryableAttribute]) -> Query: ... @classmethod - def with_subquery(cls, *paths: List[str]) -> Query: ... \ No newline at end of file + def with_subquery(cls, *paths: List[QueryableAttribute]) -> Query: ... \ No newline at end of file diff --git a/sqlalchemy_mixins/inspection.py b/sqlalchemy_mixins/inspection.py index e400144..41d1ea6 100644 --- a/sqlalchemy_mixins/inspection.py +++ b/sqlalchemy_mixins/inspection.py @@ -1,12 +1,12 @@ from sqlalchemy import inspect -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method -from sqlalchemy.orm import RelationshipProperty +from sqlalchemy.orm import RelationshipProperty, DeclarativeBase from .utils import classproperty -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True class InspectionMixin(Base): @@ -35,7 +35,7 @@ def primary_keys(cls): def relations(cls): """Return a `list` of relationship names or the given model """ - return [c.key for c in cls.__mapper__.iterate_properties + return [c.key for c in cls.__mapper__.attrs if isinstance(c, RelationshipProperty)] @classproperty diff --git a/sqlalchemy_mixins/inspection.pyi b/sqlalchemy_mixins/inspection.pyi index 033ff9d..ebc9da1 100644 --- a/sqlalchemy_mixins/inspection.pyi +++ b/sqlalchemy_mixins/inspection.pyi @@ -1,13 +1,13 @@ from typing import List, Protocol, Dict -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_method -from sqlalchemy.orm import Mapper +from sqlalchemy.orm import Mapper, DeclarativeBase from sqlalchemy.orm.interfaces import MapperProperty from sqlalchemy_mixins.utils import classproperty -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True class MappingProtocol(Protocol): __mapper__: Mapper diff --git a/sqlalchemy_mixins/smartquery.py b/sqlalchemy_mixins/smartquery.py index 7860b70..82672d4 100644 --- a/sqlalchemy_mixins/smartquery.py +++ b/sqlalchemy_mixins/smartquery.py @@ -13,8 +13,7 @@ from sqlalchemy.sql import operators, extract # noinspection PyProtectedMember -from .eagerload import _flatten_schema, _eager_expr_from_flat_schema, \ - EagerLoadMixin, SUBQUERY +from .eagerload import EagerLoadMixin, _eager_expr_from_schema from .inspection import InspectionMixin from .utils import classproperty @@ -138,13 +137,6 @@ def smart_query(query, filters=None, sort_attrs=None, schema=None): if not sort_attrs: sort_attrs = [] - # Load schema early since we need it to check whether we should eager load a relationship - if schema: - flat_schema = _flatten_schema(schema) - print(flat_schema) - else: - flat_schema = {} - # sqlalchemy >= 1.4.0, should probably a. check something else to determine if we need to convert # AppenderQuery to a query, b. probably not hack it like this # noinspection PyProtectedMember @@ -163,10 +155,8 @@ def smart_query(query, filters=None, sort_attrs=None, schema=None): loaded_paths = [] for path, al in aliases.items(): relationship_path = path.replace(RELATION_SPLITTER, '.') - if not (relationship_path in flat_schema and flat_schema[relationship_path] == SUBQUERY): - query = query.outerjoin(al[0], al[1]) \ - .options(contains_eager(relationship_path, alias=al[0])) - loaded_paths.append(relationship_path) + query = query.outerjoin(al[0], al[1]) + loaded_paths.append(relationship_path) def recurse_filters(_filters): if isinstance(_filters, abc.Mapping): @@ -206,11 +196,8 @@ def recurse_filters(_filters): except KeyError as e: raise KeyError("Incorrect order path `{}`: {}".format(attr, e)) - if flat_schema: - not_loaded_part = {path: v for path, v in flat_schema.items() - if path not in loaded_paths} - query = query.options(*_eager_expr_from_flat_schema( - not_loaded_part)) + if schema: + query = query.options(*_eager_expr_from_schema(schema)) return query diff --git a/sqlalchemy_mixins/tests/test_activerecord.py b/sqlalchemy_mixins/tests/test_activerecord.py index 9c6ef74..901ac73 100644 --- a/sqlalchemy_mixins/tests/test_activerecord.py +++ b/sqlalchemy_mixins/tests/test_activerecord.py @@ -2,14 +2,14 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import Query, Session +from sqlalchemy.orm import Query, Session, DeclarativeBase from sqlalchemy_mixins import ActiveRecordMixin from sqlalchemy_mixins.activerecord import ModelNotFoundError -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = create_engine('sqlite:///:memory:', echo=False) sess = Session(engine) # sess = scoped_session(sessionmaker(bind=engine)) diff --git a/sqlalchemy_mixins/tests/test_eagerload.py b/sqlalchemy_mixins/tests/test_eagerload.py index ea85f7f..272002e 100644 --- a/sqlalchemy_mixins/tests/test_eagerload.py +++ b/sqlalchemy_mixins/tests/test_eagerload.py @@ -3,14 +3,14 @@ import sqlalchemy as sa from sqlalchemy import create_engine from sqlalchemy import event -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Query +from sqlalchemy.orm import Query, DeclarativeBase from sqlalchemy.orm import Session from sqlalchemy_mixins import EagerLoadMixin from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY, eager_expr -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = create_engine('sqlite:///:memory:', echo=False) sess = Session(engine) # sess = scoped_session(sessionmaker(bind=engine)) @@ -187,7 +187,7 @@ def _test_ok(self, schema): _ = post.comments[0] self.assertEqual(self.query_count, 2) - def test_ok_strings(self): + def test_ok_class_properties(self): schema = { User.posts: (SUBQUERY, { Post.comments: JOINED @@ -195,27 +195,10 @@ def test_ok_strings(self): } self._test_ok(schema) - def test_ok_class_properties(self): - schema = { - 'posts': (SUBQUERY, { - 'comments': JOINED - }) - } - self._test_ok(schema) - def test_bad_join_method(self): # None schema = { - 'posts': None - } - with self.assertRaises(ValueError): - sess.query(User).options(*eager_expr(schema)).get(1) - - # strings - schema = { - 'posts': ('WRONG JOIN METHOD', { - Post.comments: 'OTHER WRONG JOIN METHOD' - }) + User.posts: None } with self.assertRaises(ValueError): sess.query(User).options(*eager_expr(schema)).get(1) @@ -230,22 +213,6 @@ def test_bad_join_method(self): sess.query(User).options(*eager_expr(schema)).get(1) -class TestOrmWithJoinedStrings(TestEagerLoad): - def test(self): - self.assertEqual(self.query_count, 0) - # take post with user and comments (including comment author) - # NOTE: you can separate relations with dot. - # Its due to SQLAlchemy: https://goo.gl/yM2DLX - post = Post.with_joined('user', 'comments', 'comments.user').get(11) - self.assertEqual(self.query_count, 1) - - # now, to get relationship, NO additional query is needed - _ = post.user - _ = post.comments[1] - _ = post.comments[1].user - self.assertEqual(self.query_count, 1) - - class TestOrmWithJoinedClassProperties(TestEagerLoad): def _test(self): self.assertEqual(self.query_count, 0) @@ -264,20 +231,19 @@ def test(self): # take post with user and comments (including comment author) # NOTE: you can separate relations with dot. # Its due to SQLAlchemy: https://goo.gl/yM2DLX - post = Post.with_subquery('user', 'comments', 'comments.user').get(11) + post = Post.with_subquery(Post.user, Post.comments).get(11) # 3 queries were executed: # 1 - on posts # 2 - on user (eagerload subquery) # 3 - on comments (eagerload subquery) - # 4 - on comments authors (eagerload subquery) - self.assertEqual(self.query_count, 4) + self.assertEqual(self.query_count, 3) # now, to get relationship, NO additional query is needed _ = post.user _ = post.comments[0] _ = post.comments[0].user - self.assertEqual(self.query_count, 4) + self.assertEqual(self.query_count, 3) class TestOrmWithSubqueryClassProperties(TestEagerLoad): @@ -306,10 +272,6 @@ def _test_joinedload(self, schema): _ = post.comments[0] self.assertEqual(self.query_count, 1) - def test_joinedload_strings(self): - schema = {'comments': JOINED} - self._test_joinedload(schema) - def test_joinedload_class_properties(self): schema = {Post.comments: JOINED} self._test_joinedload(schema) @@ -324,7 +286,7 @@ def _test_subqueryload(self, schema): self.assertEqual(self.query_count, 2) def test_subqueryload_strings(self): - schema = {'comments': SUBQUERY} + schema = {Post.comments: SUBQUERY} self._test_subqueryload(schema) def test_subqueryload_class_properties(self): @@ -351,8 +313,8 @@ def test_combined_load_strings(self): def test_combined_load_class_properties(self): schema = { - 'posts': (SUBQUERY, { - 'comments': JOINED + User.posts: (SUBQUERY, { + Post.comments: JOINED }) } self._test_combined_load(schema) diff --git a/sqlalchemy_mixins/tests/test_inspection.py b/sqlalchemy_mixins/tests/test_inspection.py index 15185c5..de547fc 100644 --- a/sqlalchemy_mixins/tests/test_inspection.py +++ b/sqlalchemy_mixins/tests/test_inspection.py @@ -2,13 +2,13 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy_mixins import InspectionMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = create_engine('sqlite:///:memory:', echo=False) diff --git a/sqlalchemy_mixins/tests/test_repr.py b/sqlalchemy_mixins/tests/test_repr.py index d79d8c3..3f32eff 100644 --- a/sqlalchemy_mixins/tests/test_repr.py +++ b/sqlalchemy_mixins/tests/test_repr.py @@ -3,13 +3,13 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Query +from sqlalchemy.orm import Query, DeclarativeBase from sqlalchemy.orm import Session from sqlalchemy_mixins import ReprMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = create_engine('sqlite:///:memory:', echo=False) sess = Session(engine) # sess = scoped_session(sessionmaker(bind=engine)) diff --git a/sqlalchemy_mixins/tests/test_serialize.py b/sqlalchemy_mixins/tests/test_serialize.py index 7733090..52b112d 100644 --- a/sqlalchemy_mixins/tests/test_serialize.py +++ b/sqlalchemy_mixins/tests/test_serialize.py @@ -2,13 +2,13 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, DeclarativeBase from sqlalchemy_mixins import SerializeMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True class BaseModel(Base, SerializeMixin): diff --git a/sqlalchemy_mixins/tests/test_session.py b/sqlalchemy_mixins/tests/test_session.py index e1ea497..8a8c521 100644 --- a/sqlalchemy_mixins/tests/test_session.py +++ b/sqlalchemy_mixins/tests/test_session.py @@ -2,12 +2,12 @@ import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, DeclarativeBase from sqlalchemy_mixins.session import SessionMixin, NoSessionError -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True engine = create_engine('sqlite:///:memory:', echo=False) session = Session(engine) diff --git a/sqlalchemy_mixins/tests/test_smartquery.py b/sqlalchemy_mixins/tests/test_smartquery.py index d36a7fb..afa8777 100644 --- a/sqlalchemy_mixins/tests/test_smartquery.py +++ b/sqlalchemy_mixins/tests/test_smartquery.py @@ -1,17 +1,18 @@ import unittest import datetime -import nose import sqlalchemy as sa from sqlalchemy import create_engine from sqlalchemy import event -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, DeclarativeBase from sqlalchemy_mixins import SmartQueryMixin, smart_query from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True + + engine = create_engine('sqlite:///:memory:', echo=False) sess = Session(engine) @@ -663,8 +664,8 @@ def test_schema_with_strings(self): }, sort_attrs=['user___name', '-created_at'], schema={ - 'post': { - 'user': JOINED + Comment.post: { + Post.user: JOINED } }).all() self.assertEqual(res, [cm12, cm21, cm22]) @@ -677,8 +678,8 @@ def test_schema_with_strings(self): }, sort_attrs=['user___name', '-created_at'], schema={ - 'post': { - 'user': JOINED + Comment.post: { + Post.user: JOINED } }).all() self.assertEqual(res, [cm12, cm21, cm22]) @@ -792,8 +793,8 @@ def test_explicitly_set_in_schema_joinedload(self): res = Comment.smart_query( filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ - 'post': { - 'comments': JOINED + Comment.post: { + Post.comments: JOINED } } ) @@ -827,8 +828,8 @@ def test_explicitly_set_in_schema_subqueryload(self): res = Comment.smart_query( filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ - 'post': { - 'comments': SUBQUERY + Comment.post: { + Post.comments: SUBQUERY } } ).all() @@ -853,8 +854,8 @@ def test_lazy_dynamic(self): self._seed() schema = { - 'post': { - 'user': JOINED + Comment.post: { + Post.user: JOINED } } @@ -886,7 +887,7 @@ def test_override_eagerload_method_in_schema(self): res = Comment.smart_query( filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ - 'post': SUBQUERY + Comment.post: SUBQUERY } ).all() self.assertEqual(self.query_count, 2) @@ -900,8 +901,8 @@ def test_override_eagerload_method_in_schema(self): res = Comment.smart_query( filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ - 'post': (SUBQUERY, { # This should load in a separate query - 'user': SUBQUERY # This should also load in a separate query + Comment.post: (SUBQUERY, { # This should load in a separate query + Post.user: SUBQUERY # This should also load in a separate query }) } ).all() diff --git a/sqlalchemy_mixins/tests/test_timestamp.py b/sqlalchemy_mixins/tests/test_timestamp.py index 3aef2a2..8c13065 100644 --- a/sqlalchemy_mixins/tests/test_timestamp.py +++ b/sqlalchemy_mixins/tests/test_timestamp.py @@ -4,12 +4,12 @@ from datetime import datetime import sqlalchemy as sa from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, DeclarativeBase from sqlalchemy_mixins import TimestampsMixin -Base = declarative_base() +class Base(DeclarativeBase): + __abstract__ = True class BaseModel(Base, TimestampsMixin): diff --git a/sqlalchemy_mixins/utils.py b/sqlalchemy_mixins/utils.py index 850cbc4..d05d09c 100644 --- a/sqlalchemy_mixins/utils.py +++ b/sqlalchemy_mixins/utils.py @@ -1,3 +1,6 @@ +from sqlalchemy.orm import RelationshipProperty, Mapper + + # noinspection PyPep8Naming class classproperty(object): """ @@ -12,3 +15,23 @@ def __get__(self, owner_self, owner_cls): return self.fget(owner_cls) +def get_relations(cls): + if isinstance(cls, Mapper): + mapper = cls + else: + mapper = cls.__mapper__ + return [c for c in mapper.attrs + if isinstance(c, RelationshipProperty)] + + +def path_to_relations_list(cls, path): + path_as_list = path.split('.') + relations = get_relations(cls) + relations_list = [] + for item in path_as_list: + for rel in relations: + if rel.key == item: + relations_list.append(rel) + relations = get_relations(rel.entity) + break + return relations_list \ No newline at end of file diff --git a/sqlalchemy_mixins/utils.pyi b/sqlalchemy_mixins/utils.pyi index 4b9e5a8..39df677 100644 --- a/sqlalchemy_mixins/utils.pyi +++ b/sqlalchemy_mixins/utils.pyi @@ -1,4 +1,6 @@ -from typing import Callable, Any +from typing import Callable, Any, List, Type + +from sqlalchemy.orm import DeclarativeBase, RelationshipProperty class classproperty(object): @@ -6,3 +8,5 @@ class classproperty(object): def __init__(self, fget: Callable) -> None: ... def __get__(self, owner_self: Any, owner_cls: Any) -> Any: ... + +def get_relations(cls: Type[DeclarativeBase]) -> List[RelationshipProperty]: ...