Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ You can query history models just like any other sqlalchemy declarative model.

```python
>>> from sqlalchemy_history import version_class
>>> import sqlalchemy as sa
>>> ArticleVersion = version_class(Article)
>>> session.query(ArticleVersion).filter_by(name=u'some name').all()
>>> session.scalars(sa.select(ArticleVersion).filter_by(name=u'some name')).all()
```

## How many transactions have been executed?

```python
>>> from sqlalchemy_history import transaction_class
>>> import sqlalchemy as sa
>>> Transaction = transaction_class(Article)
>>> Transaction.query.count()
>>> session.scalar(sa.select(sa.func.count()).select_from(Transaction))
```

## Querying for entities of a class at a given revision

In the following example we find all articles which were affected by transaction 33.

```python
>>> session.query(ArticleVersion).filter_by(transaction_id=33)
>>> session.scalars(sa.select(ArticleVersion).filter_by(transaction_id=33)).all()
```

## Querying for transactions, at which entities of a given class changed
Expand All @@ -30,13 +32,14 @@ In this example we find all transactions which affected any instance of 'Article

```python
>>> TransactionChanges = Article.__versioned__['transaction_changes']
>>> entries = (
... session.query(Transaction)
... .innerjoin(Transaction.changes)
... .filter(
>>> statement = (
... sa.select(Transaction)
... .join(Transaction.changes)
... .where(
... TransactionChanges.entity_name.in_(['Article'])
... )
... )
... entries = session.scalars(statement).all()
```

## Querying for versions of entity that modified given property
Expand All @@ -46,5 +49,5 @@ PropertyModTrackerPlugin.

```python
>>> ArticleVersion = version_class(Article)
>>> session.query(ArticleHistory).filter(ArticleVersion.name_mod).all()
>>> session.scalars(sa.select(ArticleHistory).filter(ArticleVersion.name_mod)).all()
```
6 changes: 3 additions & 3 deletions docs/revert.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ One of the major benefits of SQLAlchemy-History is its ability to revert changes
>>> session.commit()
>>> version.revert()
>>> session.commit() # article lives again!
>>> session.query(Article).first()
>>> session.scalars(sa.select(Article).limit(1)).first()
```

## Revert relationships
Expand Down Expand Up @@ -64,15 +64,15 @@ Now lets say some user first adds an article with couple of tags:
Then lets say another user deletes one of the tags:

```python
>>> tag = session.query(Tag).filter_by(name=u'Interesting')
>>> tag = session.scalar(sa.select(Tag).where(Tag.name == "Interesting"))
>>> session.delete(tag)
>>> session.commit()
```

Now the first user wants to set the article back to its original state. It can be achieved as follows (notice how we use the relations parameter):

```python
>>> article = session.query(Article).get(1)
>>> article = session.get(Article, 1)
>>> article.versions[0].revert(relations=['tags'])
>>> session.commit()
```
2 changes: 1 addition & 1 deletion docs/transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Transaction can be queried just like any other sqlalchemy declarative model.
```python
>>> from sqlalchemy_history import transaction_class
>>> Transaction = transaction_class(Article)
>>> session.query(Transaction).all() # find all transactions
>>> session.scalars(sa.select(Transaction)).all() # find all transactions
```

## UnitOfWork
Expand Down
18 changes: 7 additions & 11 deletions sqlalchemy_history/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def previous(self, obj):
history. If current version is the first version this method returns
None.
"""
return self.previous_query(obj).first()
session = sa.orm.object_session(obj)
return session.scalars(self.previous_query(obj).limit(1)).first()

def index(self, obj):
"""
Expand All @@ -50,7 +51,8 @@ def next(self, obj):
history. If current version is the last version this method returns
None.
"""
return self.next_query(obj).first()
session = sa.orm.object_session(obj)
return session.scalars(self.next_query(obj).limit(1)).first()

def _transaction_id_subquery(self, obj, next_or_prev="next", alias=None):
if next_or_prev == "next":
Expand Down Expand Up @@ -91,12 +93,10 @@ def _transaction_id_subquery(self, obj, next_or_prev="next", alias=None):
return query.scalar_subquery()

def _next_prev_query(self, obj, next_or_prev="next"):
session = sa.orm.object_session(obj)

subquery = self._transaction_id_subquery(obj, next_or_prev=next_or_prev)
subquery = subquery.scalar_subquery()

return session.query(obj.__class__).filter(
return sa.select(obj.__class__).filter(
sa.and_(getattr(obj.__class__, tx_column_name(obj)) == subquery, *parent_criteria(obj))
)

Expand Down Expand Up @@ -145,9 +145,7 @@ def next_query(self, obj):
Returns the query that fetches the next version relative to this
version in the version history.
"""
session = sa.orm.object_session(obj)

return session.query(obj.__class__).filter(
return sa.select(obj.__class__).filter(
sa.and_(
getattr(obj.__class__, tx_column_name(obj)) == getattr(obj, end_tx_column_name(obj)),
*parent_criteria(obj),
Expand All @@ -159,9 +157,7 @@ def previous_query(self, obj):
Returns the query that fetches the previous version relative to this
version in the version history.
"""
session = sa.orm.object_session(obj)

return session.query(obj.__class__).filter(
return sa.select(obj.__class__).filter(
sa.and_(
getattr(obj.__class__, end_tx_column_name(obj)) == getattr(obj, tx_column_name(obj)),
*parent_criteria(obj),
Expand Down
21 changes: 10 additions & 11 deletions sqlalchemy_history/plugins/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,9 @@

```python
>>> import sqlalchemy as sa
>>> activities = session.query(Activity).filter(
... sa.or_(
... Activity.object == article,
... Activity.target == article
... )
... )
>>> activities = session.scalars(
... sa.select(Activity).filter(sa.or_(Activity.object == article, Activity.target == article))
... ).all()
```

#### Also Read
Expand Down Expand Up @@ -223,11 +220,13 @@ def _calculate_tx_id(self, obj):
model = obj.__class__
version_cls = version_class(model)
primary_key = inspect(model).primary_key[0].name
return (
session.query(sa.func.max(version_cls.transaction_id))
.filter(getattr(version_cls, primary_key) == getattr(obj, primary_key))
.scalar()
)
return session.execute(
(
sa.select(sa.func.max(version_cls.transaction_id)).where(
getattr(version_cls, primary_key) == getattr(obj, primary_key)
)
)
).scalar_one_or_none()

def calculate_object_tx_id(self):
self.object_tx_id = self._calculate_tx_id(self.object)
Expand Down
2 changes: 1 addition & 1 deletion sqlalchemy_history/plugins/transaction_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

# find all transactions with 'article' tags
query = (
session.query(Transaction)
sa.select(Transaction)
.join(Transaction.meta_relation)
.filter(
db.and_(
Expand Down
63 changes: 52 additions & 11 deletions sqlalchemy_history/relationship_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,33 @@
from sqlalchemy_history.operation import Operation
from sqlalchemy_history.table_builder import TableBuilder
from sqlalchemy_history.utils import adapt_columns, version_class, option
import warnings
import typing as t
from sqlalchemy.orm import Session
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.sql.selectable import ExecutableReturnsRows


_T = t.TypeVar("_T")


class _WriteOnlyCollectionAdapter(t.Generic[_T]):
"""
Minimal adapter that exposes a write-only-collection-like `select()` API
backed by a preconstructed SQLAlchemy `Select` clause.
"""

def __init__(self, statement: sa.Select[_T]):
self._statement = statement

def select(self) -> sa.Select[_T]:
return self._statement


class RelationshipBuilder(object):
def __init__(self, versioning_manager, model, property_):
property: RelationshipProperty

def __init__(self, versioning_manager, model, property_: RelationshipProperty):
self.manager = versioning_manager
self.property = property_
self.model = model
Expand Down Expand Up @@ -55,21 +78,39 @@ def many_to_one_subquery(self, obj):
subquery = subquery.scalar_subquery()
return getattr(self.remote_cls, tx_column) == subquery

def query(self, obj):
session = sa.orm.object_session(obj)
return session.query(self.remote_cls).filter(self.criteria(obj))
def select(self, obj):
return sa.select(self.remote_cls).filter(self.criteria(obj))

def process_query(self, query):
def process_query(self, query: ExecutableReturnsRows, session: Session):
"""Process given SQLAlchemy Query object depending on the associated RelationshipProperty object.

:param query: SQLAlchemy Query object
This method handles both legacy Query objects (for backward compatibility with
lazy='dynamic' relationships) and modern SQLAlchemy 2.0 select statements, executing
them appropriately based on the relationship's properties.

:param query: SQLAlchemy select clause

Notes
-----
The lazy='dynamic' strategy is deemed legacy in SQLAlchemy and maintained here only
for backward compatibility. Users should migrate to lazy='write_only' for similar
functionality in SQLAlchemy 2.0+.
See: https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#dynamic-relationship-loaders-superseded-by-write-only
"""
if self.property.lazy == "dynamic":
return query
warnings.warn(
f'The lazy="dynamic" strategy is now legacy and is superseded by lazy="write_only" in SQLAlchemy 2.0. '
f"Please consider migrating to the write_only strategy for relationship {self.property.key!r}.",
DeprecationWarning,
stacklevel=2,
)
# Build legacy Query object for backward compatibility
return session.query(self.remote_cls).from_statement(query)
elif self.property.lazy == "write_only":
return _WriteOnlyCollectionAdapter(query)
if self.property.uselist is False:
return query.first()
return query.all()
return session.scalars(query.limit(1)).first()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not clear on why was this limit required?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session.scalars(query).first() would not work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I referred to the migration guide.

The query object used to apply a limit on the statement internally which the new form doesn't do.
There will be a performance regression in the newer form if it is not explicitly applied as all the rows will be returned from the DB but will be discarded in ORM layer.

Refer to the note for more details

return session.scalars(query).all()

def criteria(self, obj):
direction = self.property.direction
Expand Down Expand Up @@ -222,8 +263,8 @@ def reflected_relationship(self):

@property
def relationship(obj):
query = self.query(obj)
return self.process_query(query)
session = sa.orm.object_session(obj)
return self.process_query(self.select(obj), session)

return relationship

Expand Down
5 changes: 3 additions & 2 deletions sqlalchemy_history/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ def changed_entities(self):

tx_column = manager.option(class_, "transaction_column_name")

entities[version_class] = (
session.query(version_class).filter(getattr(version_class, tx_column) == self.id)
entities[version_class] = session.scalars(
sa.select(version_class).filter(getattr(version_class, tx_column) == self.id)
).all()

return entities


Expand Down
26 changes: 14 additions & 12 deletions sqlalchemy_history/unit_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,21 @@ def update_version_validity(self, parent, version_obj):
parent, version_obj, alias=sa.orm.aliased(class_.__table__)
)
subquery = subquery.scalar_subquery()
query = session.query(class_.__table__).filter(
sa.and_(
getattr(class_, tx_column_name(version_obj)) == subquery,
*[
getattr(version_obj, pk) == getattr(class_.__table__.c, pk)
for pk in get_primary_keys(class_)
if pk != tx_column_name(class_)
],

session.execute(
sa.update(class_.__table__)
.where(
sa.and_(
getattr(class_, tx_column_name(version_obj)) == subquery,
*[
getattr(version_obj, pk) == getattr(class_.__table__.c, pk)
for pk in get_primary_keys(class_)
if pk != tx_column_name(class_)
],
)
)
)
query.update(
{end_tx_column_name(version_obj): self.current_transaction.id},
synchronize_session=False,
.values(**{end_tx_column_name(version_obj): self.current_transaction.id})
.execution_options(synchronize_session=False)
)

def create_association_versions(self, session):
Expand Down
6 changes: 3 additions & 3 deletions sqlalchemy_history/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,9 @@ def vacuum(session, model, yield_per=1000):
version_cls = version_class(model)
versions = defaultdict(list)

query = (session.query(version_cls).order_by(option(version_cls, "transaction_column_name"))).yield_per(
yield_per
)
query = session.scalars(
sa.select(version_cls).order_by(option(version_cls, "transaction_column_name"))
).yield_per(yield_per)

primary_key_col = sa.inspection.inspect(model).primary_key[0].name

Expand Down
2 changes: 1 addition & 1 deletion tests/builders/test_table_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def test_assigns_foreign_keys_for_versions(self):
self.session.add(article)
self.session.commit()
cls = version_class(self.Tag)
version = self.session.query(cls).first()
version = self.session.scalars(sa.select(cls)).first()
assert version.name == "some tag"
assert version.id == 1
assert version.article_id == 1
Expand Down
4 changes: 2 additions & 2 deletions tests/inheritance/test_concrete_inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ def test_transaction_changed_entities(self):
self.session.add(article)
self.session.commit()
Transaction = versioning_manager.transaction_cls
transaction = (
self.session.query(Transaction).order_by(sa.sql.expression.desc(Transaction.issued_at))
transaction = self.session.scalars(
sa.select(Transaction).order_by(sa.sql.expression.desc(Transaction.issued_at))
).first()
assert transaction.entity_names == ["Article"]
assert transaction.changed_entities
2 changes: 1 addition & 1 deletion tests/inheritance/test_join_table_inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def test_with_polymorphic(self):
self.session.add(article)
self.session.commit()

version_obj = self.session.query(self.TextItemVersion).first()
version_obj = self.session.scalars(sa.select(self.TextItemVersion)).first()
assert isinstance(version_obj, self.ArticleVersion)

def test_consecutive_insert_and_delete(self):
Expand Down
4 changes: 2 additions & 2 deletions tests/inheritance/test_single_table_inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ def test_transaction_changed_entities(self):
self.session.add(article)
self.session.commit()
Transaction = versioning_manager.transaction_cls
transaction = (
self.session.query(Transaction).order_by(sa.sql.expression.desc(Transaction.issued_at))
transaction = self.session.scalars(
sa.select(Transaction).order_by(sa.sql.expression.desc(Transaction.issued_at))
).first()
assert transaction.entity_names == ["Article"]
assert transaction.changed_entities
Expand Down
Loading