Skip to content

Commit

Permalink
Merge "Support state expiration for with_expression(); rename deferre…
Browse files Browse the repository at this point in the history
…d_expression"
  • Loading branch information
zzzeek authored and Gerrit Code Review committed Jun 26, 2017
2 parents fae82dd + 9ac0f81 commit 33d083c
Show file tree
Hide file tree
Showing 9 changed files with 105 additions and 34 deletions.
8 changes: 4 additions & 4 deletions doc/build/changelog/migration_12.rst
Expand Up @@ -218,12 +218,12 @@ are loaded with additional SELECT statements:
ORM attributes that can receive ad-hoc SQL expressions
------------------------------------------------------

A new ORM attribute type :func:`.orm.deferred_expression` is added which
A new ORM attribute type :func:`.orm.query_expression` is added which
is similar to :func:`.orm.deferred`, except its SQL expression
is determined at query time using a new option :func:`.orm.with_expression`;
if not specified, the attribute defaults to ``None``::

from sqlalchemy.orm import deferred_expression
from sqlalchemy.orm import query_expression
from sqlalchemy.orm import with_expression

class A(Base):
Expand All @@ -233,7 +233,7 @@ if not specified, the attribute defaults to ``None``::
y = Column(Integer)

# will be None normally...
expr = deferred_expression()
expr = query_expression()

# but let's give it x + y
a1 = session.query(A).options(
Expand All @@ -242,7 +242,7 @@ if not specified, the attribute defaults to ``None``::

.. seealso::

:ref:`mapper_deferred_expression`
:ref:`mapper_query_expression`

:ticket:`3058`

Expand Down
2 changes: 1 addition & 1 deletion doc/build/orm/loading_columns.rst
Expand Up @@ -136,7 +136,7 @@ Column Deferral API

.. autofunction:: deferred

.. autofunction:: deferred_expression
.. autofunction:: query_expression

.. autofunction:: load_only

Expand Down
34 changes: 22 additions & 12 deletions doc/build/orm/mapped_sql_expr.rst
Expand Up @@ -206,7 +206,7 @@ The plain descriptor approach is useful as a last resort, but is less performant
in the usual case than both the hybrid and column property approaches, in that
it needs to emit a SQL query upon each access.

.. _mapper_deferred_expression:
.. _mapper_querytime_expression:

Query-time SQL expressions as mapped attributes
-----------------------------------------------
Expand All @@ -223,19 +223,19 @@ The above query returns tuples of the form ``(A object, integer)``.
An option exists which can apply the ad-hoc ``A.x + A.y`` expression to the
returned ``A`` objects instead of as a separate tuple entry; this is the
:func:`.with_expression` query option in conjunction with the
:func:`.deferred_expression` attribute mapping. The class is mapped
:func:`.query_expression` attribute mapping. The class is mapped
to include a placeholder attribute where any particular SQL expression
may be applied::

from sqlalchemy.orm import deferred_expression
from sqlalchemy.orm import query_expression

class A(Base):
__tablename__ = 'a'
id = Column(Integer, primary_key=True)
x = Column(Integer)
y = Column(Integer)

expr = deferred_expression()
expr = query_expression()

We can then query for objects of type ``A``, applying an arbitrary
SQL expression to be populated into ``A.expr``::
Expand All @@ -244,27 +244,37 @@ SQL expression to be populated into ``A.expr``::
q = session.query(A).options(
with_expression(A.expr, A.x + A.y))

The :func:`.deferred_expression` mapping has these caveats:
The :func:`.query_expression` mapping has these caveats:

* On an object where :func:`.deferred_expression` were not used to populate
* On an object where :func:`.query_expression` were not used to populate
the attribute, the attribute on an object instance will have the value
``None``.

* The query_expression value **does not refresh when the object is
expired**. Once the object is expired, either via :meth:`.Session.expire`
or via the expire_on_commit behavior of :meth:`.Session.commit`, the value is
removed from the attribute and will return ``None`` on subsequent access.
Only by running a new :class:`.Query` that touches the object which includes
a new :func:`.with_expression` directive will the attribute be set to a
non-None value.

* The mapped attribute currently **cannot** be applied to other parts of the
query and make use of the ad-hoc expression; that is, this won't work::
query, such as the WHERE clause, the ORDER BY clause, and make use of the
ad-hoc expression; that is, this won't work::

# wont work
q = session.query(A).options(
with_expression(A.expr, A.x + A.y)
).filter(A.expr > 5)
).filter(A.expr > 5).order_by(A.expr)

The ``A.expr`` expression will resolve to NULL in the above WHERE clause.
To use the expression throughout the query, assign to a variable and
use that::
The ``A.expr`` expression will resolve to NULL in the above WHERE clause
and ORDER BY clause. To use the expression throughout the query, assign to a
variable and use that::

a_expr = A.x + A.y
q = session.query(A).options(
with_expression(A.expr, a_expr)
).filter(a_expr > 5)
).filter(a_expr > 5).order_by(a_expr)

.. versionadded:: 1.2

6 changes: 3 additions & 3 deletions lib/sqlalchemy/orm/__init__.py
Expand Up @@ -178,18 +178,18 @@ def deferred(*columns, **kw):
return ColumnProperty(deferred=True, *columns, **kw)


def deferred_expression():
def query_expression():
"""Indicate an attribute that populates from a query-time SQL expression.
.. versionadded:: 1.2
.. seealso::
:ref:`mapper_deferred_expression`
:ref:`mapper_query_expression`
"""
prop = ColumnProperty(_sql.null())
prop.strategy_key = (("deferred_expression", True),)
prop.strategy_key = (("query_expression", True),)
return prop

mapper = public_factory(Mapper, ".orm.mapper")
Expand Down
15 changes: 10 additions & 5 deletions lib/sqlalchemy/orm/attributes.py
Expand Up @@ -388,7 +388,7 @@ def __init__(self, class_, key,
callable_, dispatch, trackparent=False, extension=None,
compare_function=None, active_history=False,
parent_token=None, expire_missing=True,
send_modified_events=True,
send_modified_events=True, accepts_scalar_loader=None,
**kwargs):
r"""Construct an AttributeImpl.
Expand Down Expand Up @@ -449,6 +449,11 @@ def __init__(self, class_, key,
else:
self.is_equal = compare_function

if accepts_scalar_loader is not None:
self.accepts_scalar_loader = accepts_scalar_loader
else:
self.accepts_scalar_loader = self.default_accepts_scalar_loader

# TODO: pass in the manager here
# instead of doing a lookup
attr = manager_of_class(class_)[key]
Expand All @@ -465,7 +470,7 @@ def __init__(self, class_, key,
__slots__ = (
'class_', 'key', 'callable_', 'dispatch', 'trackparent',
'parent_token', 'send_modified_events', 'is_equal', 'expire_missing',
'_modified_token'
'_modified_token', 'accepts_scalar_loader'
)

def _init_modified_token(self):
Expand Down Expand Up @@ -657,7 +662,7 @@ def set_committed_value(self, state, dict_, value):
class ScalarAttributeImpl(AttributeImpl):
"""represents a scalar value-holding InstrumentedAttribute."""

accepts_scalar_loader = True
default_accepts_scalar_loader = True
uses_objects = False
supports_population = True
collection = False
Expand Down Expand Up @@ -743,7 +748,7 @@ class ScalarObjectAttributeImpl(ScalarAttributeImpl):
"""

accepts_scalar_loader = False
default_accepts_scalar_loader = False
uses_objects = True
supports_population = True
collection = False
Expand Down Expand Up @@ -867,7 +872,7 @@ class CollectionAttributeImpl(AttributeImpl):
semantics to the orm layer independent of the user data implementation.
"""
accepts_scalar_loader = False
default_accepts_scalar_loader = False
uses_objects = True
supports_population = True
collection = True
Expand Down
2 changes: 1 addition & 1 deletion lib/sqlalchemy/orm/dynamic.py
Expand Up @@ -44,7 +44,7 @@ def init_class_attribute(self, mapper):

class DynamicAttributeImpl(attributes.AttributeImpl):
uses_objects = True
accepts_scalar_loader = False
default_accepts_scalar_loader = False
supports_population = False
collection = False

Expand Down
11 changes: 10 additions & 1 deletion lib/sqlalchemy/orm/strategies.py
Expand Up @@ -195,7 +195,7 @@ def create_row_processor(


@log.class_logger
@properties.ColumnProperty.strategy_for(deferred_expression=True)
@properties.ColumnProperty.strategy_for(query_expression=True)
class ExpressionColumnLoader(ColumnLoader):
def __init__(self, parent, strategy_key):
super(ExpressionColumnLoader, self).__init__(parent, strategy_key)
Expand Down Expand Up @@ -235,6 +235,15 @@ def create_row_processor(
else:
populators["expire"].append((self.key, True))

def init_class_attribute(self, mapper):
self.is_class_level = True

_register_attribute(
self.parent_property, mapper, useobject=False,
compare_function=self.columns[0].type.compare_values,
accepts_scalar_loader=False
)


@log.class_logger
@properties.ColumnProperty.strategy_for(deferred=True, instrument=True)
Expand Down
6 changes: 3 additions & 3 deletions lib/sqlalchemy/orm/strategy_options.py
Expand Up @@ -1356,7 +1356,7 @@ def undefer_group(name):
def with_expression(loadopt, key, expression):
r"""Apply an ad-hoc SQL expression to a "deferred expression" attribute.
This option is used in conjunction with the :func:`.orm.deferred_expression`
This option is used in conjunction with the :func:`.orm.query_expression`
mapper-level construct that indicates an attribute which should be the
target of an ad-hoc SQL expression.
Expand All @@ -1375,7 +1375,7 @@ def with_expression(loadopt, key, expression):
.. seealso::
:ref:`mapper_deferred_expression`
:ref:`mapper_query_expression`
"""

Expand All @@ -1384,7 +1384,7 @@ def with_expression(loadopt, key, expression):

return loadopt.set_column_strategy(
(key, ),
{"deferred_expression": True},
{"query_expression": True},
opts={"expression": expression}
)

Expand Down
55 changes: 51 additions & 4 deletions test/orm/test_deferred.py
Expand Up @@ -3,7 +3,7 @@
from sqlalchemy.orm import mapper, deferred, defer, undefer, Load, \
load_only, undefer_group, create_session, synonym, relationship, Session,\
joinedload, defaultload, aliased, contains_eager, with_polymorphic, \
deferred_expression, with_expression
query_expression, with_expression
from sqlalchemy.testing import eq_, AssertsCompiledSQL, assert_raises_message
from test.orm import _fixtures
from sqlalchemy.testing.schema import Column
Expand Down Expand Up @@ -886,7 +886,7 @@ class A(fixtures.ComparableEntity, Base):
x = Column(Integer)
y = Column(Integer)

my_expr = deferred_expression()
my_expr = query_expression()

bs = relationship("B", order_by="B.id")

Expand All @@ -897,7 +897,7 @@ class B(fixtures.ComparableEntity, Base):
p = Column(Integer)
q = Column(Integer)

b_expr = deferred_expression()
b_expr = query_expression()

@classmethod
def insert_data(cls):
Expand Down Expand Up @@ -972,4 +972,51 @@ def test_no_sql_not_set_up(self):
def go():
eq_(a1.my_expr, None)

self.assert_sql_count(testing.db, go, 0)
self.assert_sql_count(testing.db, go, 0)

def test_dont_explode_on_expire_individual(self):
A = self.classes.A

s = Session()
q = s.query(A).options(
with_expression(A.my_expr, A.x + A.y)).filter(A.x > 1).\
order_by(A.id)

a1 = q.first()

eq_(a1.my_expr, 5)

s.expire(a1, ['my_expr'])

eq_(a1.my_expr, None)

# comes back
q = s.query(A).options(
with_expression(A.my_expr, A.x + A.y)).filter(A.x > 1).\
order_by(A.id)
q.first()
eq_(a1.my_expr, 5)

def test_dont_explode_on_expire_whole(self):
A = self.classes.A

s = Session()
q = s.query(A).options(
with_expression(A.my_expr, A.x + A.y)).filter(A.x > 1).\
order_by(A.id)

a1 = q.first()

eq_(a1.my_expr, 5)

s.expire(a1)

eq_(a1.my_expr, None)

# comes back
q = s.query(A).options(
with_expression(A.my_expr, A.x + A.y)).filter(A.x > 1).\
order_by(A.id)
q.first()
eq_(a1.my_expr, 5)

0 comments on commit 33d083c

Please sign in to comment.