Skip to content

Commit

Permalink
Migrate to SQLAlchemy 20 (#95)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
michaelbukachi authored Apr 3, 2023
1 parent 342c83e commit e0aa411
Show file tree
Hide file tree
Showing 28 changed files with 203 additions and 265 deletions.
19 changes: 3 additions & 16 deletions .github/workflows/test-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 25 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ post2 = Post.create(body='long-long-long-long-long body', rating=2,
# will output this beauty: <Post #1 body:'Post1' user:'Bill'>
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
Expand Down Expand Up @@ -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: {
Expand All @@ -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()
```
Expand 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -378,8 +360,8 @@ Comment.smart_query(
},
sort_attrs=['user___name', '-created_at'],
schema={
'post': {
'user': JOINED
Comment.post: {
Post.user: JOINED
}
}).all()
```
Expand Down Expand Up @@ -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\_\_
Expand Down Expand Up @@ -473,7 +454,6 @@ class Post(BaseModel):
<Post #2 body:'Post 2 long-long body' user:<User #1 'Bob'>>
```

![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
Expand All @@ -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
Expand All @@ -522,17 +501,13 @@ 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
Some mixins re-use the same functionality. It lives in [`SessionMixin`](sqlalchemy_mixins/session.py) (session access) and [`InspectionMixin`](sqlalchemy_mixins/inspection.py) (inspecting columns, relations etc.) and other mixins inherit them.

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.

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


8 changes: 4 additions & 4 deletions examples/activerecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions examples/all_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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())
Expand Down
39 changes: 9 additions & 30 deletions examples/eagerload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
"""
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions examples/repr.py
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
6 changes: 3 additions & 3 deletions examples/serialize.py
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
Loading

0 comments on commit e0aa411

Please sign in to comment.