diff --git a/.travis.yml b/.travis.yml index 4a88fd4..4784cc0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,13 @@ python: # command to install dependencies install: "pip install -r requirements-dev.txt" # command to run tests -script: nosetests --with-coverage --cover-inclusive --cover-package=sqlalchemy_mixins +script: + - 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 after_success: - codeclimate-test-reporter \ No newline at end of file diff --git a/README.md b/README.md index c7cee2d..d19ef7b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Why it's cool: 1. [Beauty \_\_repr\_\_](#beauty-__repr__) 1. [Internal architecture notes](#internal-architecture-notes) 1. [Comparison with existing solutions](#comparison-with-existing-solutions) +1. [Changelog](#changelog) ## Installation @@ -64,13 +65,16 @@ Here's a quick demo of what out mixins can do. bob = User.create(name='Bob') post1 = Post.create(body='Post 1', user=bob, rating=3) post2 = Post.create(body='long-long-long-long-long body', rating=2, - user=User.create(name='Bill')) + user=User.create(name='Bill'), + comments=[Comment.create(body='cool!', user=bob)]) -# filter using operators ('in', 'like') and relations ('user') +# filter using operators like 'in' and 'contains' and relations like 'user' # will output this beauty: print(Post.where(rating__in=[2, 3, 4], user___name__like='%Bi%').all()) -# eager load user with post -print(Post.with_(['user']).first()) +# 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()) # sort by rating DESC, user name ASC print(Post.sort('-rating', 'user___name').all()) ``` @@ -170,7 +174,7 @@ Well, now you can easily set what ORM relations you want to eager load User.with_({ User.posts: { Post.comments: { - Comment.user: None + Comment.user: JOINED } } }.all() @@ -181,23 +185,24 @@ or we can write strings instead of class properties: User.with_({ 'posts': { 'comments': { - 'user': None + 'user': JOINED } } }.all() ``` ### Subquery load -Sometimes we want to load relations in separate query, i.e. do [subqueryload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html). +Sometimes we want to load relations in separate query, i.e. do [subqueryload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html#sqlalchemy.orm.subqueryload). For example, we load posts on page like [this](http://www.qopy.me/3V4Tsu_GTpCMJySzvVH1QQ), -and for each post we want to have user and all comments (to display their count). +and for each post we want to have user and all comments (and comment authors). -To speed up query, we load posts in separate query, but, in this separate query, join user +To speed up query, we load comments in separate query, but, in this separate query, join user ```python -from sqlalchemy_mixins import SUBQUERYLOAD +from sqlalchemy_mixins import JOINED, SUBQUERYLOAD Post.with_({ - 'comments': (SUBQUERYLOAD, { # load posts in separate query - 'user': None # but, in this separate query, join user + 'user': JOINED, # joinedload user + 'comments': (SUBQUERYLOAD, { # load comments in separate query + 'user': JOINED # but, in this separate query, join user }) }} ``` @@ -206,19 +211,21 @@ Here, posts will be loaded on first query, and comments with users - in second o See [SQLAlchemy docs](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html) for explaining relationship loading techniques. -> Default loading method is [joinedload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html?highlight=joinedload#sqlalchemy.orm.joinedload) -> (`None` in schema) -> -> Explicitly use `SUBQUERYLOAD` if you want it. - -### Quick joined load -For simple cases, when you want to just [joinedload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html?highlight=joinedload#sqlalchemy.orm.joinedload) +### Quick eager load +For simple cases, when you want to just +[joinedload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html#sqlalchemy.orm.joinedload) +or [subqueryload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html#sqlalchemy.orm.subqueryload) a few relations, we have easier syntax for you: ```python -Comment.with_(['user', 'post']).first() +Comment.with_joined('user', 'post', 'post.comments').first() +User.with_subquery('posts', 'posts.comments').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) @@ -308,7 +315,7 @@ Comment.smart_query( sort_attrs=['user___name', '-created_at'], schema={ 'post': { - 'user': None + 'user': JOINED } }).all() ``` @@ -398,3 +405,43 @@ But: ### Beauty \_\_repr\_\_ [sqlalchemy-repr](https://github.com/manicmaniac/sqlalchemy-repr) already does this, but there you can't choose which columns to output. It simply prints all columns, which can lead to too big output. + +# Changelog + +## v0.2 + +More clear methods in [`sqlalchemy_mixins.EagerLoadMixin`](sqlalchemy_mixins/eagerload.py): + + * *added* `with_subquery` method: it's like `with_joined`, but for [subqueryload](http://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html#sqlalchemy.orm.subqueryload). + So you can now write: + + ```python + User.with_subquery('posts', 'comments').all() + ``` + * `with_joined` method *arguments change*: instead of + + ```python + Comment.with_joined(['user','post']) + ``` + + now simply write + + ```python + Comment.with_joined('user','post') + ``` + + * `with_` method *arguments change*: it now accepts *only dict schemas*. If you want to quickly joinedload relations, use `with_joined` + * `with_dict` method *removed*. Instead, use `with_` method + +Other changes in [`sqlalchemy_mixins.EagerLoadMixin`](sqlalchemy_mixins/eagerload.py): + + * constants *rename*: use cleaner `JOINED` and `SUBQUERY` instead of `JOINEDLOAD` and `SUBQUERYLOAD` + * do not allow `None` in schema anymore, so instead of + ```python + Comment.with_({'user': None}) + ``` + + write + ```python + Comment.with_({'user': JOINED}) + ``` diff --git a/examples/all_features.py b/examples/all_features.py index f0b86fc..e52ce90 100644 --- a/examples/all_features.py +++ b/examples/all_features.py @@ -1,57 +1,75 @@ -""" -Demonstrates how to use AllFeaturesMixin. -It just combines other mixins, so look to their examples for details -""" -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_mixins import AllFeaturesMixin - -Base = declarative_base() - - -class BaseModel(Base, AllFeaturesMixin): - __abstract__ = True - pass - - -class User(BaseModel): - __tablename__ = 'user' - __repr_attrs__ = ['name'] - - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.String) - - -class Post(BaseModel): - __tablename__ = 'post' - __repr_attrs__ = ['body', 'user'] - - id = sa.Column(sa.Integer, primary_key=True) - body = sa.Column(sa.String) - rating = sa.Column(sa.Integer) - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) - - # we use this relation in smart_query, so it should be explicitly set - # (not just a backref from User class) - user = sa.orm.relationship('User') - - -engine = sa.create_engine('sqlite:///:memory:') -session = scoped_session(sessionmaker(bind=engine)) - -Base.metadata.create_all(engine) -BaseModel.set_session(session) - -bob = User.create(name='Bob') -post1 = Post.create(body='Post 1', user=bob, rating=3) -post2 = Post.create(body='long-long-long-long-long body', rating=2, - user=User.create(name='Bill')) - -# filter using operators like 'in' and 'contains' and relations like 'user' -print(Post.where(rating__in=[2, 3, 4], user___name__like='%Bi%').all()) -# eager load user with post -print(Post.with_(['user']).first()) -# sort by rating DESC, user name ASC -print(Post.sort('-rating', 'user___name').all()) +""" +Demonstrates how to use AllFeaturesMixin. +It just combines other mixins, so look to their examples for details +""" +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_mixins import AllFeaturesMixin + +Base = declarative_base() + + +class BaseModel(Base, AllFeaturesMixin): + __abstract__ = True + pass + + +class User(BaseModel): + __tablename__ = 'user' + __repr_attrs__ = ['name'] + + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String) + + +class Post(BaseModel): + __tablename__ = 'post' + __repr_attrs__ = ['body', 'user'] + + id = sa.Column(sa.Integer, primary_key=True) + body = sa.Column(sa.String) + rating = sa.Column(sa.Integer) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) + + # we use this relation in smart_query, so it should be explicitly set + # (not just a backref from User class) + user = sa.orm.relationship('User', backref='posts') # but for eagerload + # backref is OK + comments = sa.orm.relationship('Comment') + + +class Comment(BaseModel): + __tablename__ = 'comment' + + id = sa.Column(sa.Integer, primary_key=True) + body = sa.Column(sa.String) + post_id = sa.Column(sa.Integer, sa.ForeignKey('post.id')) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) + + post = sa.orm.relationship('Post') + user = sa.orm.relationship('User') + + +engine = sa.create_engine('sqlite:///:memory:', echo=True) +session = scoped_session(sessionmaker(bind=engine)) + +Base.metadata.create_all(engine) +BaseModel.set_session(session) + +bob = User.create(name='Bob') +post1 = Post.create(body='Post 1', user=bob, rating=3) +post2 = Post.create(body='long-long-long-long-long body', rating=2, + user=User.create(name='Bill'), + comments=[Comment.create(body='cool!', user=bob)]) + +# filter using operators like 'in' and 'contains' and relations like 'user' +# 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()) +# 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 10fd5fb..d1c8b9d 100644 --- a/examples/eagerload.py +++ b/examples/eagerload.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Query, scoped_session, sessionmaker from sqlalchemy_mixins import EagerLoadMixin, ReprMixin -from sqlalchemy_mixins.eagerload import SUBQUERYLOAD, eager_expr +from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY, eager_expr def log(msg): @@ -30,6 +30,7 @@ class User(BaseModel): id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String) posts = sa.orm.relationship('Post') + comments = sa.orm.relationship('Comment') class Post(BaseModel): @@ -123,32 +124,59 @@ def reset_session(): #################### Demo ###################### -#### 0. simple flat joinedload #### -# in simplest cases, you may want to just join some relations. +#### 0. simple flat joinedload/subqueryload #### +# in simplest cases, you may want to just eager load a few relations. # for such cases, EagerLoadMixin has simple syntax -# 'user' and 'post' are relationship names from Comment class -schema = ['user', 'post'] -# same schema using class properties -# schema = [Comment.user, Comment.post] +#### 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() # SQL will be like -# note that we select user as parent entity and as post.comments.user -# EagerLoadMixin will make table aliases for us """ SELECT comment.*, user_1.*, post_1.* FROM comment LEFT OUTER JOIN user AS user_1 ON user_1.id = comment.user_id LEFT OUTER JOIN post AS post_1 ON post_1.id = comment.post_id +LEFT OUTER JOIN comment AS comment_1 ON post_1.id = comment_1.post_id LIMIT 1 OFFSET 1 """ +# now, to get relationships, NO additional query is needed +log('NO ADDITIONAL SQL. BEGIN') +user = comment.user +post = comment.post +comments = post.comments +log('NO ADDITIONAL SQL. END') +#### 0.2 subqueryload #### reset_session() -comment = Comment.with_(schema).first() +users = User.with_subquery('posts', 'posts.comments').all() +# same using class properties (except 'posts.comments'): +# users = User.with_subquery(User.posts).all() +# there will be 3 queries: +## first. on users: +""" +SELECT user.* FROM user +""" +# second. on posts: +""" +SELECT post.* FROM (SELECT user.id AS user_id FROM user) AS anon_1 +JOIN post ON anon_1.user_id = post.user_id +""" +# third. on post comments +""" +SELECT comment.* FROM (SELECT user.id AS user_id FROM user) AS anon_1 +JOIN post AS post_1 ON anon_1.user_id = post_1.user_id +JOIN comment ON post_1.id = comment.post_id +""" # now, to get relationships, NO additional query is needed -post = comment.post -user = comment.user +log('NO ADDITIONAL SQL. BEGIN') +posts = users[0].posts +comments = posts[0].comments +log('NO ADDITIONAL SQL. END') #### 1. nested joinedload #### # for nested eagerload, you should use dict instead of lists| @@ -157,9 +185,9 @@ def reset_session(): # here, # 'posts': { ... } # is equal to - # 'posts': (JOINEDLOAD, { ... }) + # 'posts': (JOINED, { ... }) 'comments': { # to each post join its comments - 'user': None # and join user to each comment + 'user': JOINED # and join user to each comment } } } @@ -167,10 +195,13 @@ def reset_session(): # schema = { # User.posts: { # Post.comments: { -# Comment.user: None +# Comment.user: JOINED # } # } # } +session = reset_session() +###### 1.1 query-level: more flexible +user = session.query(User).options(*eager_expr(schema)).get(1) # SQL will be like # note that we select user as parent entity and as post.comments.user @@ -183,18 +214,17 @@ def reset_session(): LEFT OUTER JOIN user AS user_1 ON user_1.id = comment_1.user_id WHERE user.id = 1 """ -session = reset_session() -###### 1.1 query-level: more flexible -user = session.query(User).options(*eager_expr(schema)).get(1) reset_session() ###### 1.2 ORM-level: more convenient user = User.with_(schema).get(1) # now, to get relationships, NO additional query is needed +log('NO ADDITIONAL SQL. BEGIN') post = user.posts[0] comment = post.comments[0] comment_user = comment.user +log('NO ADDITIONAL SQL. END') #### 2. combination of joinedload and subqueryload #### @@ -202,14 +232,14 @@ def reset_session(): # 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': (SUBQUERYLOAD, { # load posts in separate query - 'user': None # but, in this separate query, join 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.comments: (SUBQUERYLOAD, { # load posts in separate query - Comment.user: None # but, in this separate query, join comments + Post.comments: (SUBQUERY, { # load comments in separate query + Comment.user: JOINED # but, in this separate query, join comments }) } @@ -217,7 +247,7 @@ def reset_session(): reset_session() posts = session.query(Post).options(*eager_expr(schema)).all() -###### 2.1 query-level: more flexible +###### 2.2 ORM-level: more convenient reset_session() posts = Post.with_(schema).all() @@ -235,7 +265,9 @@ def reset_session(): LEFT OUTER JOIN user AS user_1 ON user_1.id = comment.user_id """ # now, to get relationships, NO additional query is needed +log('NO ADDITIONAL SQL. BEGIN') comments1 = posts[0].comments comments2 = posts[1].comments user1 = posts[0].comments[0].user -user2 = posts[1].comments[0].user \ No newline at end of file +user2 = posts[1].comments[0].user +log('NO ADDITIONAL SQL. END') diff --git a/examples/smartquery.py b/examples/smartquery.py index 0c353d9..ee90c60 100644 --- a/examples/smartquery.py +++ b/examples/smartquery.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Query, scoped_session, sessionmaker -from sqlalchemy_mixins import SmartQueryMixin, ReprMixin +from sqlalchemy_mixins import SmartQueryMixin, ReprMixin, JOINED def log(msg): @@ -343,13 +343,13 @@ class Comment(BaseModel): schema = { 'post': { - 'user': None + 'user': JOINED } } # schema can use class properties too (see EagerLoadMixin): # schema = { # Comment.post: { -# Post.user: None +# Post.user: JOINED # } # } res = Comment.smart_query( diff --git a/setup.py b/setup.py index 41264b0..09c31f6 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ def requirements(): return [line.rstrip('\n') for line in open(filename).readlines()] setup(name='sqlalchemy_mixins', - version='0.1.7', + version='0.2', description='Active Record, Django-like queries, nested eager load ' 'and beauty __repr__ for SQLAlchemy', url='https://github.com/absent1706/sqlalchemy-mixins', diff --git a/sqlalchemy_mixins/__init__.py b/sqlalchemy_mixins/__init__.py index 4c3cd43..1b6c7e8 100644 --- a/sqlalchemy_mixins/__init__.py +++ b/sqlalchemy_mixins/__init__.py @@ -5,7 +5,7 @@ # high-level mixins from .activerecord import ActiveRecordMixin, ModelNotFoundError from .smartquery import SmartQueryMixin -from .eagerload import EagerLoadMixin, JOINEDLOAD, SUBQUERYLOAD +from .eagerload import EagerLoadMixin, JOINED, SUBQUERY from .repr import ReprMixin diff --git a/sqlalchemy_mixins/eagerload.py b/sqlalchemy_mixins/eagerload.py index d460612..2f036cc 100644 --- a/sqlalchemy_mixins/eagerload.py +++ b/sqlalchemy_mixins/eagerload.py @@ -1,123 +1,135 @@ -try: - from typing import List -except ImportError: # pragma: no cover - pass - -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import subqueryload -from sqlalchemy.orm.attributes import InstrumentedAttribute - -from .session import SessionMixin - -JOINEDLOAD = 'joined' -SUBQUERYLOAD = 'subquery' - - -def eager_expr(schema): - flat_schema = _flatten_schema(schema) - return _eager_expr_from_flat_schema(flat_schema) - - -def _flatten_schema(schema): - 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 - - if isinstance(value, tuple): - join_method, inner_schema = value[0], value[1] - elif isinstance(value, dict): - join_method, inner_schema = JOINEDLOAD, value - else: - join_method, inner_schema = value or JOINEDLOAD, None - - full_path = parent_path + '.' + path if parent_path else path - result[full_path] = join_method - - if inner_schema: - _flatten(inner_schema, full_path, result) - - result = {} - _flatten(schema, '', result) - return result - - -def _eager_expr_from_flat_schema(flat_schema): - result = [] - for path, join_method in flat_schema.items(): - if join_method == JOINEDLOAD: - result.append(joinedload(path)) - elif join_method == SUBQUERYLOAD: - result.append(subqueryload(path)) - else: - raise ValueError('Bad join method `{}` in `{}`' - .format(join_method, path)) - return result - - -class EagerLoadMixin(SessionMixin): - __abstract__ = True - - @classmethod - def with_(cls, schema): - """ - Query class and eager load schema at once. - Schema is list (with_joined() will be called) - or dict(with_dict() will be called) - :type schema: dict | List[basestring] | List[InstrumentedAttribute] - """ - return cls.with_dict(schema) if isinstance(schema, dict) \ - else cls.with_joined(schema) - - @classmethod - def with_dict(cls, schema): - """ - Query class and eager load schema at once. - - Example 1: - schema = { - User.educator_school: { - School.educators: (SUBQUERYLOAD, None), - School.district: None - }, - User.educator_district: { - District.schools: (SUBQUERYLOAD, { - School.educators: None - }) - } - } - User.with_dict(schema).first() - - Example 2 (with strings, not recommended): - schema = { - 'educator_school': { - 'educators': (SUBQUERYLOAD, None), - 'district': None - }, - 'educator_district': { - 'schools': (SUBQUERYLOAD, { - 'educators': None - }) - } - } - User.with_dict(schema).first() - """ - return cls.query.options(*eager_expr(schema or {})) - - @classmethod - def with_joined(cls, paths): - """ - Eagerload for simple cases where we need to just - joined load some relations without nesting - :type paths: List[str] | List[InstrumentedAttribute] - - Example 1: - Product.with_dict(Product.grade_from, Product.grade_to).first() - - Example 2 (with strings, not recommended): - Product.with_dict('grade_from', 'grade_to').first() - """ - flat_schema = {path: JOINEDLOAD for path in paths} - return cls.query.options(*_eager_expr_from_flat_schema(flat_schema)) +try: + from typing import List +except ImportError: # pragma: no cover + pass + +from sqlalchemy.orm import joinedload +from sqlalchemy.orm import subqueryload +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from .session import SessionMixin + +JOINED = 'joined' +SUBQUERY = 'subquery' + + +def eager_expr(schema): + """ + :type schema: dict + """ + flat_schema = _flatten_schema(schema) + return _eager_expr_from_flat_schema(flat_schema) + + +def _flatten_schema(schema): + """ + :type schema: dict + """ + def _flatten(schema, parent_path, result): + """ + :type schema: dict + """ + 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 + + if isinstance(value, tuple): + join_method, inner_schema = value[0], value[1] + elif isinstance(value, dict): + join_method, inner_schema = JOINED, value + else: + join_method, inner_schema = value, None + + full_path = parent_path + '.' + path if parent_path else path + result[full_path] = join_method + + if inner_schema: + _flatten(inner_schema, full_path, result) + + result = {} + _flatten(schema, '', result) + return result + + +def _eager_expr_from_flat_schema(flat_schema): + """ + :type flat_schema: dict + """ + result = [] + for path, join_method in flat_schema.items(): + if join_method == JOINED: + result.append(joinedload(path)) + elif join_method == SUBQUERY: + result.append(subqueryload(path)) + else: + raise ValueError('Bad join method `{}` in `{}`' + .format(join_method, path)) + return result + + +class EagerLoadMixin(SessionMixin): + __abstract__ = True + + @classmethod + def with_(cls, schema): + """ + Query class and eager load schema at once. + :type schema: dict + + 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 + }) + } + User.with_(schema).first() + """ + return cls.query.options(*eager_expr(schema or {})) + + @classmethod + 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 + + :type paths: *List[str] | *List[InstrumentedAttribute] + + 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] + return cls.query.options(*options) + + @classmethod + 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) + + :type paths: *List[str] | *List[InstrumentedAttribute] + + 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] + return cls.query.options(*options) diff --git a/sqlalchemy_mixins/tests/test_eagerload.py b/sqlalchemy_mixins/tests/test_eagerload.py index 1e85f0a..d8bbd78 100644 --- a/sqlalchemy_mixins/tests/test_eagerload.py +++ b/sqlalchemy_mixins/tests/test_eagerload.py @@ -1,341 +1,392 @@ -import unittest - -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 Session - -from sqlalchemy_mixins import EagerLoadMixin -from sqlalchemy_mixins.eagerload import SUBQUERYLOAD, eager_expr - -Base = declarative_base() -engine = create_engine('sqlite:///:memory:', echo=False) -sess = Session(engine) -# sess = scoped_session(sessionmaker(bind=engine)) - - -class BaseModel(Base, EagerLoadMixin): - __abstract__ = True - pass - - -class User(BaseModel): - __tablename__ = 'user' - __repr_attrs__ = ['name'] - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.String) - posts = sa.orm.relationship('Post') - - -class Post(BaseModel): - __tablename__ = 'post' - id = sa.Column(sa.Integer, primary_key=True) - body = sa.Column(sa.String) - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) - archived = sa.Column(sa.Boolean, default=False) - - user = sa.orm.relationship('User') - comments = sa.orm.relationship('Comment') - - -class Comment(BaseModel): - __tablename__ = 'comment' - __repr_attrs__ = ['body'] - id = sa.Column(sa.Integer, primary_key=True) - body = sa.Column(sa.String) - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) - post_id = sa.Column(sa.Integer, sa.ForeignKey('post.id')) - rating = sa.Column(sa.Integer) - - user = sa.orm.relationship('User') - post = sa.orm.relationship('Post') - - -class TestEagerLoad(unittest.TestCase): - def setUp(self): - Base.metadata.drop_all(engine) - Base.metadata.create_all(engine) - - BaseModel.set_session(sess) - u1 = User(name='Bill u1') - sess.add(u1) - sess.commit() - - u2 = User(name='Alex u2') - sess.add(u2) - sess.commit() - - u3 = User(name='Bishop u3') - sess.add(u3) - sess.commit() - - # u4 = User() - # u4.name = 'u4' - # sess.add(u4) - # sess.commit() - - sess.commit() - - p11 = Post( - id=11, - body='1234567890123', - archived=True - ) - p11.user = u1 - sess.add(p11) - sess.commit() - - p12 = Post( - id=12, - body='1234567890', - user=u1 - ) - sess.add(p12) - sess.commit() - - p21 = Post( - id=21, - body='p21 by u2', - user=u2 - ) - sess.add(p21) - sess.commit() - - p22 = Post( - id=22, - body='p22 by u2', - user=u2 - ) - sess.add(p22) - sess.commit() - - cm11 = Comment( - id=11, - body='cm11 to p11', - user=u1, - post=p11, - rating=1 - ) - sess.add(cm11) - sess.commit() - - cm12 = Comment( - id=12, - body='cm12 to p12', - user=u2, - post=p11, - rating=2 - ) - sess.add(cm12) - sess.commit() - - cm21 = Comment( - id=21, - body='cm21 to p21', - user=u1, - post=p21, - rating=1 - ) - sess.add(cm21) - sess.commit() - - cm22 = Comment( - id=22, - body='cm22 to p22', - user=u3, - post=p22, - rating=3 - ) - sess.add(cm22) - sess.commit() - - self.query_count = 0 - - # noinspection PyUnusedLocal - @event.listens_for(sess.connection(), 'before_cursor_execute') - def before_cursor_execute(conn, cursor, statement, parameters, - context, executemany): - self.query_count += 1 - - -class TestNoEagerLoad(TestEagerLoad): - def test_no_eagerload(self): - self.assertEqual(self.query_count, 0) - post = Post.query.get(11) - self.assertEqual(self.query_count, 1) - - # to get relationship, ADDITIONAL query is needed - comment = post.comments[0] - self.assertEqual(self.query_count, 2) - - # to get relationship, ADDITIONAL query is needed - _ = comment.user - self.assertEqual(self.query_count, 3) - - -class TestEagerExpr(TestEagerLoad): - """test of low-level eager_expr function""" - def _test_ok(self, schema): - self.assertEqual(self.query_count, 0) - user = sess.query(User).options(*eager_expr(schema)).get(1) - self.assertEqual(self.query_count, 2) - - # now, to get relationships, NO additional query is needed - post = user.posts[0] - _ = post.comments[0] - self.assertEqual(self.query_count, 2) - - def test_ok_strings(self): - schema = { - User.posts: (SUBQUERYLOAD, { - Post.comments: None - }) - } - self._test_ok(schema) - - def test_ok_class_properties(self): - schema = { - 'posts': (SUBQUERYLOAD, { - 'comments': None - }) - } - self._test_ok(schema) - - def test_bad_join_method_strings(self): - # strings - schema = { - 'posts': ('WRONG JOIN METHOD', { - Post.comments: 'OTHER WRONG JOIN METHOD' - }) - } - with self.assertRaises(ValueError): - sess.query(User).options(*eager_expr(schema)).get(1) - - # class properties - schema = { - User.posts: ('WRONG JOIN METHOD', { - Post.comments: 'OTHER WRONG JOIN METHOD' - }) - } - with self.assertRaises(ValueError): - sess.query(User).options(*eager_expr(schema)).get(1) - - -class TestOrmWithList(TestEagerLoad): - def _test(self, list_schema): - self.assertEqual(self.query_count, 0) - # relationship is loaded immediately - post = Post.with_(list_schema).get(11) - self.assertEqual(self.query_count, 1) - - # now, to get relationship, NO additional query is needed - _ = post.comments[0] - _ = post.user - self.assertEqual(self.query_count, 1) - - def test_strings(self): - list_schema = ['comments', 'user'] - self._test(list_schema) - - def test_class_properties(self): - list_schema = [Post.comments, Post.user] - self._test(list_schema) - - -class TestOrmWithDict(TestEagerLoad): - def _test_joinedload(self, schema): - self.assertEqual(self.query_count, 0) - post = Post.with_(schema).get(11) - self.assertEqual(self.query_count, 1) - - # now, to get relationship, NO additional query is needed - _ = post.comments[0] - self.assertEqual(self.query_count, 1) - - def test_joinedload_strings(self): - schema = {'comments': None} - self._test_joinedload(schema) - - def test_joinedload_class_properties(self): - schema = {Post.comments: None} - self._test_joinedload(schema) - - def _test_subqueryload(self, schema): - self.assertEqual(self.query_count, 0) - post = Post.with_(schema).get(11) - self.assertEqual(self.query_count, 2) - - # to get relationship, NO additional query is needed - _ = post.comments[0] - self.assertEqual(self.query_count, 2) - - def test_subqueryload_strings(self): - schema = {'comments': (SUBQUERYLOAD, None)} - self._test_subqueryload(schema) - - def test_subqueryload_class_properties(self): - schema = {Post.comments: (SUBQUERYLOAD, None)} - self._test_subqueryload(schema) - - def _test_combined_load(self, schema): - self.assertEqual(self.query_count, 0) - user = User.with_(schema).get(1) - self.assertEqual(self.query_count, 2) - - # now, to get relationships, NO additional query is needed - post = user.posts[0] - _ = post.comments[0] - self.assertEqual(self.query_count, 2) - - def test_combined_load_strings(self): - schema = { - User.posts: (SUBQUERYLOAD, { - Post.comments: None - }) - } - self._test_combined_load(schema) - - def test_combined_load_class_properties(self): - schema = { - 'posts': (SUBQUERYLOAD, { - 'comments': None - }) - } - self._test_combined_load(schema) - - def _test_combined_load_2(self, schema): - self.assertEqual(self.query_count, 0) - user = User.with_(schema).get(1) - self.assertEqual(self.query_count, 2) - - # now, to get relationships, NO additional query is needed - post = user.posts[0] - comment = post.comments[0] - _ = comment.user - self.assertEqual(self.query_count, 2) - - def test_combined_load_2_strings(self): - schema = { - User.posts: (SUBQUERYLOAD, { - Post.comments: { - Comment.user: None - } - }) - } - self._test_combined_load(schema) - - def test_combined_load_2_class_properties(self): - schema = { - 'posts': (SUBQUERYLOAD, { - 'comments': { - 'user': None - } - }) - } - self._test_combined_load(schema) - - -if __name__ == '__main__': # pragma: no cover - unittest.main() +import unittest + +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 Session + +from sqlalchemy_mixins import EagerLoadMixin +from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY, eager_expr + +Base = declarative_base() +engine = create_engine('sqlite:///:memory:', echo=False) +sess = Session(engine) +# sess = scoped_session(sessionmaker(bind=engine)) + + +class BaseModel(Base, EagerLoadMixin): + __abstract__ = True + pass + + +class User(BaseModel): + __tablename__ = 'user' + __repr_attrs__ = ['name'] + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String) + posts = sa.orm.relationship('Post') + + +class Post(BaseModel): + __tablename__ = 'post' + id = sa.Column(sa.Integer, primary_key=True) + body = sa.Column(sa.String) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) + archived = sa.Column(sa.Boolean, default=False) + + user = sa.orm.relationship('User') + comments = sa.orm.relationship('Comment') + + +class Comment(BaseModel): + __tablename__ = 'comment' + __repr_attrs__ = ['body'] + id = sa.Column(sa.Integer, primary_key=True) + body = sa.Column(sa.String) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id')) + post_id = sa.Column(sa.Integer, sa.ForeignKey('post.id')) + rating = sa.Column(sa.Integer) + + user = sa.orm.relationship('User') + post = sa.orm.relationship('Post') + + +class TestEagerLoad(unittest.TestCase): + def setUp(self): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + BaseModel.set_session(sess) + u1 = User(name='Bill u1') + sess.add(u1) + sess.commit() + + u2 = User(name='Alex u2') + sess.add(u2) + sess.commit() + + u3 = User(name='Bishop u3') + sess.add(u3) + sess.commit() + + # u4 = User() + # u4.name = 'u4' + # sess.add(u4) + # sess.commit() + + sess.commit() + + p11 = Post( + id=11, + body='1234567890123', + archived=True + ) + p11.user = u1 + sess.add(p11) + sess.commit() + + p12 = Post( + id=12, + body='1234567890', + user=u1 + ) + sess.add(p12) + sess.commit() + + p21 = Post( + id=21, + body='p21 by u2', + user=u2 + ) + sess.add(p21) + sess.commit() + + p22 = Post( + id=22, + body='p22 by u2', + user=u2 + ) + sess.add(p22) + sess.commit() + + cm11 = Comment( + id=11, + body='cm11 to p11', + user=u1, + post=p11, + rating=1 + ) + sess.add(cm11) + sess.commit() + + cm12 = Comment( + id=12, + body='cm12 to p12', + user=u2, + post=p11, + rating=2 + ) + sess.add(cm12) + sess.commit() + + cm21 = Comment( + id=21, + body='cm21 to p21', + user=u1, + post=p21, + rating=1 + ) + sess.add(cm21) + sess.commit() + + cm22 = Comment( + id=22, + body='cm22 to p22', + user=u3, + post=p22, + rating=3 + ) + sess.add(cm22) + sess.commit() + + self.query_count = 0 + + # noinspection PyUnusedLocal + @event.listens_for(sess.connection(), 'before_cursor_execute') + def before_cursor_execute(conn, cursor, statement, parameters, + context, executemany): + self.query_count += 1 + + +class TestNoEagerLoad(TestEagerLoad): + def test_no_eagerload(self): + self.assertEqual(self.query_count, 0) + post = Post.query.get(11) + self.assertEqual(self.query_count, 1) + + # to get relationship, ADDITIONAL query is needed + comment = post.comments[0] + self.assertEqual(self.query_count, 2) + + # to get relationship, ADDITIONAL query is needed + _ = comment.user + self.assertEqual(self.query_count, 3) + + +class TestEagerExpr(TestEagerLoad): + """test of low-level eager_expr function""" + def _test_ok(self, schema): + self.assertEqual(self.query_count, 0) + user = sess.query(User).options(*eager_expr(schema)).get(1) + self.assertEqual(self.query_count, 2) + + # now, to get relationships, NO additional query is needed + post = user.posts[0] + _ = post.comments[0] + self.assertEqual(self.query_count, 2) + + def test_ok_strings(self): + schema = { + User.posts: (SUBQUERY, { + Post.comments: JOINED + }) + } + 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' + }) + } + with self.assertRaises(ValueError): + sess.query(User).options(*eager_expr(schema)).get(1) + + # class properties + schema = { + User.posts: ('WRONG JOIN METHOD', { + Post.comments: 'OTHER WRONG JOIN METHOD' + }) + } + with self.assertRaises(ValueError): + 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) + post = Post.with_joined(Post.comments, Post.user).get(11) + self.assertEqual(self.query_count, 1) + + # now, to get relationship, NO additional query is needed + _ = post.comments[0] + _ = post.user + self.assertEqual(self.query_count, 1) + + +class TestOrmWithSubquery(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_subquery('user', 'comments', 'comments.user').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) + + # now, to get relationship, NO additional query is needed + _ = post.user + _ = post.comments[0] + _ = post.comments[0].user + self.assertEqual(self.query_count, 4) + + +class TestOrmWithSubqueryClassProperties(TestEagerLoad): + def test(self): + self.assertEqual(self.query_count, 0) + post = Post.with_subquery(Post.comments, Post.user).get(11) + # 3 queries were executed: + # 1 - on posts + # 2 - on comments (eagerload subquery) + # 3 - on user (eagerload subquery) + self.assertEqual(self.query_count, 3) + + # now, to get relationship, NO additional query is needed + _ = post.comments[0] + _ = post.user + self.assertEqual(self.query_count, 3) + +class TestOrmWithDict(TestEagerLoad): + def _test_joinedload(self, schema): + self.assertEqual(self.query_count, 0) + post = Post.with_(schema).get(11) + self.assertEqual(self.query_count, 1) + + # now, to get relationship, NO additional query is needed + _ = 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) + + def _test_subqueryload(self, schema): + self.assertEqual(self.query_count, 0) + post = Post.with_(schema).get(11) + self.assertEqual(self.query_count, 2) + + # to get relationship, NO additional query is needed + _ = post.comments[0] + self.assertEqual(self.query_count, 2) + + def test_subqueryload_strings(self): + schema = {'comments': SUBQUERY} + self._test_subqueryload(schema) + + def test_subqueryload_class_properties(self): + schema = {Post.comments: SUBQUERY} + self._test_subqueryload(schema) + + def _test_combined_load(self, schema): + self.assertEqual(self.query_count, 0) + user = User.with_(schema).get(1) + self.assertEqual(self.query_count, 2) + + # now, to get relationships, NO additional query is needed + post = user.posts[0] + _ = post.comments[0] + self.assertEqual(self.query_count, 2) + + def test_combined_load_strings(self): + schema = { + User.posts: (SUBQUERY, { + Post.comments: JOINED + }) + } + self._test_combined_load(schema) + + def test_combined_load_class_properties(self): + schema = { + 'posts': (SUBQUERY, { + 'comments': JOINED + }) + } + self._test_combined_load(schema) + + def _test_combined_load_2(self, schema): + self.assertEqual(self.query_count, 0) + user = User.with_(schema).get(1) + self.assertEqual(self.query_count, 2) + + # now, to get relationships, NO additional query is needed + post = user.posts[0] + comment = post.comments[0] + _ = comment.user + self.assertEqual(self.query_count, 2) + + def test_combined_load_2_strings(self): + schema = { + User.posts: (SUBQUERY, { + Post.comments: { + Comment.user: JOINED + } + }) + } + self._test_combined_load(schema) + + def test_combined_load_2_class_properties(self): + schema = { + 'posts': (SUBQUERY, { + 'comments': { + 'user': JOINED + } + }) + } + self._test_combined_load(schema) + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/sqlalchemy_mixins/tests/test_smartquery.py b/sqlalchemy_mixins/tests/test_smartquery.py index 23669ab..32a2457 100644 --- a/sqlalchemy_mixins/tests/test_smartquery.py +++ b/sqlalchemy_mixins/tests/test_smartquery.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method from sqlalchemy.orm import Session from sqlalchemy_mixins import SmartQueryMixin -from sqlalchemy_mixins.eagerload import SUBQUERYLOAD +from sqlalchemy_mixins.eagerload import JOINED, SUBQUERY Base = declarative_base() engine = create_engine('sqlite:///:memory:', echo=False) @@ -583,7 +583,7 @@ def test_schema_with_strings(self): sort_attrs=['user___name', '-created_at'], schema={ 'post': { - 'user': None + 'user': JOINED } }).all() self.assertEqual(res, [cm12, cm21, cm22]) @@ -600,7 +600,7 @@ def test_schema_with_class_properties(self): sort_attrs=['user___name', '-created_at'], schema={ Comment.post: { - Post.user: None + Post.user: JOINED } }).all() self.assertEqual(res, [cm12, cm21, cm22]) @@ -682,7 +682,7 @@ def test_explicitly_set_in_schema_joinedload(self): filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ 'post': { - 'comments': None + 'comments': JOINED } } ) @@ -717,7 +717,7 @@ def test_explicitly_set_in_schema_subqueryload(self): filters=dict(post___public=True, post___user___name__like='Bi%'), schema={ 'post': { - 'comments': (SUBQUERYLOAD, None) + 'comments': SUBQUERY } } ).all() @@ -752,7 +752,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': (SUBQUERYLOAD, None) + 'post': SUBQUERY } ).all() self.assertEqual(self.query_count, 2)