diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2023-04-10 12:56:47 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2023-04-12 15:11:03 -0400 |
commit | 9f43b10e9014e694cb89fe2899dc52f602bf2197 (patch) | |
tree | 03c54cbc80bd98e9d358b6a21a029b8524142696 | |
parent | 6e5ed192c6435ec107eae524bb2c6959c38bc654 (diff) | |
download | sqlalchemy-9f43b10e9014e694cb89fe2899dc52f602bf2197.tar.gz |
establish column_property and query_expression as readonly from a dc perspective
Fixed bug in ORM Declarative Dataclasses where the
:func:`_orm.queryable_attribute` and :func:`_orm.column_property`
constructs, which are documented as read-only constructs in the context of
a Declarative mapping, could not be used with a
:class:`_orm.MappedAsDataclass` class without adding ``init=False``, which
in the case of :func:`_orm.queryable_attribute` was not possible as no
``init`` parameter was included. These constructs have been modified from a
dataclass perspective to be assumed to be "read only", setting
``init=False`` by default and no longer including them in the pep-681
constructor. The dataclass parameters for :func:`_orm.column_property`
``init``, ``default``, ``default_factory``, ``kw_only`` are now deprecated;
these fields don't apply to :func:`_orm.column_property` as used in a
Declarative dataclasses configuration where the construct would be
read-only. Also added read-specific parameter
:paramref:`_orm.queryable_attribute.compare` to
:func:`_orm.queryable_attribute`; :paramref:`_orm.queryable_attribute.repr`
was already present.
Added missing :paramref:`_orm.mapped_column.active_history` parameter
to :func:`_orm.mapped_column` construct.
Fixes: #9628
Change-Id: I2ab44d6b763b20410bd1ebb5ac949a6d223f1ce2
-rw-r--r-- | doc/build/changelog/unreleased_20/9628.rst | 29 | ||||
-rw-r--r-- | doc/build/orm/declarative_tables.rst | 4 | ||||
-rw-r--r-- | lib/sqlalchemy/ext/mypy/names.py | 16 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/_orm_constructors.py | 116 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/decl_api.py | 9 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/interfaces.py | 26 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/properties.py | 15 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/base.py | 3 | ||||
-rw-r--r-- | lib/sqlalchemy/util/deprecations.py | 6 | ||||
-rw-r--r-- | lib/sqlalchemy/util/langhelpers.py | 3 | ||||
-rw-r--r-- | test/ext/mypy/plain_files/dataclass_transforms_one.py | 50 | ||||
-rw-r--r-- | test/ext/mypy/plain_files/pep681.py | 32 | ||||
-rw-r--r-- | test/orm/declarative/test_basic.py | 20 | ||||
-rw-r--r-- | test/orm/declarative/test_dc_transforms.py | 106 | ||||
-rw-r--r-- | test/orm/test_deferred.py | 8 | ||||
-rw-r--r-- | test/orm/test_deprecations.py | 61 |
16 files changed, 407 insertions, 97 deletions
diff --git a/doc/build/changelog/unreleased_20/9628.rst b/doc/build/changelog/unreleased_20/9628.rst new file mode 100644 index 000000000..0d9368349 --- /dev/null +++ b/doc/build/changelog/unreleased_20/9628.rst @@ -0,0 +1,29 @@ +.. change:: + :tags: bug, orm + :tickets: 9628 + + Fixed bug in ORM Declarative Dataclasses where the + :func:`_orm.queryable_attribute` and :func:`_orm.column_property` + constructs, which are documented as read-only constructs in the context of + a Declarative mapping, could not be used with a + :class:`_orm.MappedAsDataclass` class without adding ``init=False``, which + in the case of :func:`_orm.queryable_attribute` was not possible as no + ``init`` parameter was included. These constructs have been modified from a + dataclass perspective to be assumed to be "read only", setting + ``init=False`` by default and no longer including them in the pep-681 + constructor. The dataclass parameters for :func:`_orm.column_property` + ``init``, ``default``, ``default_factory``, ``kw_only`` are now deprecated; + these fields don't apply to :func:`_orm.column_property` as used in a + Declarative dataclasses configuration where the construct would be + read-only. Also added read-specific parameter + :paramref:`_orm.queryable_attribute.compare` to + :func:`_orm.queryable_attribute`; :paramref:`_orm.queryable_attribute.repr` + was already present. + + + +.. change:: + :tags: bug, orm + + Added missing :paramref:`_orm.mapped_column.active_history` parameter + to :func:`_orm.mapped_column` construct. diff --git a/doc/build/orm/declarative_tables.rst b/doc/build/orm/declarative_tables.rst index d9a11087d..0ee40cd07 100644 --- a/doc/build/orm/declarative_tables.rst +++ b/doc/build/orm/declarative_tables.rst @@ -1336,8 +1336,8 @@ declaration, typing tools will be able to match the attribute to the .. _orm_imperative_table_column_options: -Applying Load, Persistence and Mapping Options for Mapped Table Columns -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Applying Load, Persistence and Mapping Options for Imperative Table Columns +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The section :ref:`orm_declarative_column_options` reviewed how to set load and persistence options when using the :func:`_orm.mapped_column` construct diff --git a/lib/sqlalchemy/ext/mypy/names.py b/lib/sqlalchemy/ext/mypy/names.py index fac6bf5b1..989f25592 100644 --- a/lib/sqlalchemy/ext/mypy/names.py +++ b/lib/sqlalchemy/ext/mypy/names.py @@ -17,6 +17,7 @@ from typing import Union from mypy.nodes import ARG_POS from mypy.nodes import CallExpr from mypy.nodes import ClassDef +from mypy.nodes import Decorator from mypy.nodes import Expression from mypy.nodes import FuncDef from mypy.nodes import MemberExpr @@ -261,7 +262,20 @@ def type_id_for_unbound_type( def type_id_for_callee(callee: Expression) -> Optional[int]: if isinstance(callee, (MemberExpr, NameExpr)): - if isinstance(callee.node, OverloadedFuncDef): + if isinstance(callee.node, Decorator) and isinstance( + callee.node.func, FuncDef + ): + if callee.node.func.type and isinstance( + callee.node.func.type, CallableType + ): + ret_type = get_proper_type(callee.node.func.type.ret_type) + + if isinstance(ret_type, Instance): + return type_id_for_fullname(ret_type.type.fullname) + + return None + + elif isinstance(callee.node, OverloadedFuncDef): if ( callee.node.impl and callee.node.impl.type diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index 57acc5706..1a1780158 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -126,6 +126,7 @@ def mapped_column( insert_default: Optional[Any] = _NoArg.NO_ARG, server_default: Optional[_ServerDefaultType] = None, server_onupdate: Optional[FetchedValue] = None, + active_history: bool = False, quote: Optional[bool] = None, system: bool = False, comment: Optional[str] = None, @@ -258,6 +259,20 @@ def mapped_column( .. versionadded:: 2.0.4 + :param active_history=False: + + When ``True``, indicates that the "previous" value for a + scalar attribute should be loaded when replaced, if not + already loaded. Normally, history tracking logic for + simple non-primary-key scalar values only needs to be + aware of the "new" value in order to perform a flush. This + flag is available for applications that make use of + :func:`.attributes.get_history` or :meth:`.Session.is_modified` + which also need to know the "previous" value of the attribute. + + .. versionadded:: 2.0.10 + + :param init: Specific to :ref:`orm_declarative_native_dataclasses`, specifies if the mapped attribute should be part of the ``__init__()`` method as generated by the dataclass process. @@ -301,6 +316,7 @@ def mapped_column( index=index, unique=unique, info=info, + active_history=active_history, nullable=nullable, onupdate=onupdate, primary_key=primary_key, @@ -318,6 +334,19 @@ def mapped_column( ) +@util.deprecated_params( + **{ + arg: ( + "2.0", + f"The :paramref:`_orm.column_property.{arg}` parameter is " + "deprecated for :func:`_orm.column_property`. This parameter " + "applies to a writeable-attribute in a Declarative Dataclasses " + "configuration only, and :func:`_orm.column_property` is treated " + "as a read-only attribute in this context.", + ) + for arg in ("init", "kw_only", "default", "default_factory") + } +) def column_property( column: _ORMColumnExprArgument[_T], *additional_columns: _ORMColumnExprArgument[Any], @@ -325,7 +354,7 @@ def column_property( deferred: bool = False, raiseload: bool = False, comparator_factory: Optional[Type[PropComparator[_T]]] = None, - init: Union[_NoArg, bool] = _NoArg.NO_ARG, + init: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 repr: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 default: Optional[Any] = _NoArg.NO_ARG, default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, @@ -338,49 +367,58 @@ def column_property( ) -> MappedSQLExpression[_T]: r"""Provide a column-level property for use with a mapping. - Column-based properties can normally be applied to the mapper's - ``properties`` dictionary using the :class:`_schema.Column` - element directly. - Use this function when the given column is not directly present within - the mapper's selectable; examples include SQL expressions, functions, - and scalar SELECT queries. + With Declarative mappings, :func:`_orm.column_property` is used to + map read-only SQL expressions to a mapped class. + + When using Imperative mappings, :func:`_orm.column_property` also + takes on the role of mapping table columns with additional features. + When using fully Declarative mappings, the :func:`_orm.mapped_column` + construct should be used for this purpose. + + With Declarative Dataclass mappings, :func:`_orm.column_property` + is considered to be **read only**, and will not be included in the + Dataclass ``__init__()`` constructor. The :func:`_orm.column_property` function returns an instance of :class:`.ColumnProperty`. - Columns that aren't present in the mapper's selectable won't be - persisted by the mapper and are effectively "read-only" attributes. + .. seealso:: + + :ref:`mapper_column_property_sql_expressions` - general use of + :func:`_orm.column_property` to map SQL expressions + + :ref:`orm_imperative_table_column_options` - usage of + :func:`_orm.column_property` with Imperative Table mappings to apply + additional options to a plain :class:`_schema.Column` object :param \*cols: - list of Column objects to be mapped. + list of Column objects to be mapped. :param active_history=False: - When ``True``, indicates that the "previous" value for a - scalar attribute should be loaded when replaced, if not - already loaded. Normally, history tracking logic for - simple non-primary-key scalar values only needs to be - aware of the "new" value in order to perform a flush. This - flag is available for applications that make use of - :func:`.attributes.get_history` or :meth:`.Session.is_modified` - which also need to know - the "previous" value of the attribute. + + Used only for Imperative Table mappings, or legacy-style Declarative + mappings (i.e. which have not been upgraded to + :func:`_orm.mapped_column`), for column-based attributes that are + expected to be writeable; use :func:`_orm.mapped_column` with + :paramref:`_orm.mapped_column.active_history` for Declarative mappings. + See that parameter for functional details. :param comparator_factory: a class which extends - :class:`.ColumnProperty.Comparator` which provides custom SQL - clause generation for comparison operations. + :class:`.ColumnProperty.Comparator` which provides custom SQL + clause generation for comparison operations. :param group: a group name for this property when marked as deferred. :param deferred: - when True, the column property is "deferred", meaning that - it does not load immediately, and is instead loaded when the - attribute is first accessed on an instance. See also - :func:`~sqlalchemy.orm.deferred`. + when True, the column property is "deferred", meaning that + it does not load immediately, and is instead loaded when the + attribute is first accessed on an instance. See also + :func:`~sqlalchemy.orm.deferred`. :param doc: - optional string that will be applied as the doc on the - class-bound descriptor. + optional string that will be applied as the doc on the + class-bound descriptor. :param expire_on_flush=True: Disable expiry on flush. A column_property() which refers @@ -410,20 +448,25 @@ def column_property( :ref:`orm_queryguide_deferred_raiseload` - .. seealso:: + :param init: + + :param default: - :ref:`column_property_options` - to map columns while including - mapping options + :param default_factory: - :ref:`mapper_column_property_sql_expressions` - to map SQL - expressions + :param kw_only: """ return MappedSQLExpression( column, *additional_columns, attribute_options=_AttributeOptions( - init, repr, default, default_factory, compare, kw_only + False if init is _NoArg.NO_ARG else init, + repr, + default, + default_factory, + compare, + kw_only, ), group=group, deferred=deferred, @@ -433,6 +476,7 @@ def column_property( expire_on_flush=expire_on_flush, info=info, doc=doc, + _assume_readonly_dc_attributes=True, ) @@ -2017,6 +2061,7 @@ def query_expression( default_expr: _ORMColumnExprArgument[_T] = sql.null(), *, repr: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 + compare: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 expire_on_flush: bool = True, info: Optional[_InfoType] = None, doc: Optional[str] = None, @@ -2036,16 +2081,17 @@ def query_expression( prop = MappedSQLExpression( default_expr, attribute_options=_AttributeOptions( - _NoArg.NO_ARG, + False, repr, _NoArg.NO_ARG, _NoArg.NO_ARG, - _NoArg.NO_ARG, + compare, _NoArg.NO_ARG, ), expire_on_flush=expire_on_flush, info=info, doc=doc, + _assume_readonly_dc_attributes=True, ) prop.strategy_key = (("query_expression", True),) diff --git a/lib/sqlalchemy/orm/decl_api.py b/lib/sqlalchemy/orm/decl_api.py index 60d2fbc2b..ed001023b 100644 --- a/lib/sqlalchemy/orm/decl_api.py +++ b/lib/sqlalchemy/orm/decl_api.py @@ -37,11 +37,9 @@ from . import clsregistry from . import instrumentation from . import interfaces from . import mapperlib -from ._orm_constructors import column_property from ._orm_constructors import composite from ._orm_constructors import deferred from ._orm_constructors import mapped_column -from ._orm_constructors import query_expression from ._orm_constructors import relationship from ._orm_constructors import synonym from .attributes import InstrumentedAttribute @@ -59,7 +57,6 @@ from .descriptor_props import Composite from .descriptor_props import Synonym from .descriptor_props import Synonym as _orm_synonym from .mapper import Mapper -from .properties import ColumnProperty from .properties import MappedColumn from .relationships import RelationshipProperty from .state import InstanceState @@ -153,15 +150,12 @@ class DeclarativeAttributeIntercept( MappedColumn, RelationshipProperty, Composite, - ColumnProperty, Synonym, mapped_column, relationship, composite, - column_property, synonym, deferred, - query_expression, ), ) class DCTransformDeclarative(DeclarativeAttributeIntercept): @@ -1549,15 +1543,12 @@ class registry: MappedColumn, RelationshipProperty, Composite, - ColumnProperty, Synonym, mapped_column, relationship, composite, - column_property, synonym, deferred, - query_expression, ), ) @overload diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index 866749139..2af883da2 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -269,6 +269,15 @@ _DEFAULT_ATTRIBUTE_OPTIONS = _AttributeOptions( _NoArg.NO_ARG, ) +_DEFAULT_READONLY_ATTRIBUTE_OPTIONS = _AttributeOptions( + False, + _NoArg.NO_ARG, + _NoArg.NO_ARG, + _NoArg.NO_ARG, + _NoArg.NO_ARG, + _NoArg.NO_ARG, +) + class _DCAttributeOptions: """mixin for descriptors or configurational objects that include dataclass @@ -519,19 +528,24 @@ class MapperProperty( """ def __init__( - self, attribute_options: Optional[_AttributeOptions] = None + self, + attribute_options: Optional[_AttributeOptions] = None, + _assume_readonly_dc_attributes: bool = False, ) -> None: self._configure_started = False self._configure_finished = False - if ( - attribute_options - and attribute_options != _DEFAULT_ATTRIBUTE_OPTIONS - ): + + if _assume_readonly_dc_attributes: + default_attrs = _DEFAULT_READONLY_ATTRIBUTE_OPTIONS + else: + default_attrs = _DEFAULT_ATTRIBUTE_OPTIONS + + if attribute_options and attribute_options != default_attrs: self._has_dataclass_arguments = True self._attribute_options = attribute_options else: self._has_dataclass_arguments = False - self._attribute_options = _DEFAULT_ATTRIBUTE_OPTIONS + self._attribute_options = default_attrs def init(self) -> None: """Called after all mappers are created to assemble diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py index 2f7b85d88..f00775874 100644 --- a/lib/sqlalchemy/orm/properties.py +++ b/lib/sqlalchemy/orm/properties.py @@ -151,8 +151,12 @@ class ColumnProperty( info: Optional[_InfoType] = None, doc: Optional[str] = None, _instrument: bool = True, + _assume_readonly_dc_attributes: bool = False, ): - super().__init__(attribute_options=attribute_options) + super().__init__( + attribute_options=attribute_options, + _assume_readonly_dc_attributes=_assume_readonly_dc_attributes, + ) columns = (column,) + additional_columns self.columns = [ coercions.expect(roles.LabeledColumnExprRole, c) for c in columns @@ -532,6 +536,7 @@ class MappedColumn( "deferred", "deferred_group", "deferred_raiseload", + "active_history", "_attribute_options", "_has_dataclass_arguments", "_use_existing_column", @@ -579,7 +584,7 @@ class MappedColumn( self.deferred = bool( self.deferred_group or self.deferred_raiseload ) - + self.active_history = kw.pop("active_history", False) self._sort_order = kw.pop("sort_order", 0) self.column = cast("Column[_T]", Column(*arg, **kw)) self.foreign_keys = self.column.foreign_keys @@ -597,6 +602,7 @@ class MappedColumn( new.deferred_group = self.deferred_group new.deferred_raiseload = self.deferred_raiseload new.foreign_keys = new.column.foreign_keys + new.active_history = self.active_history new._has_nullable = self._has_nullable new._attribute_options = self._attribute_options new._has_insert_default = self._has_insert_default @@ -612,13 +618,14 @@ class MappedColumn( @property def mapper_property_to_assign(self) -> Optional[MapperProperty[_T]]: - if self.deferred: + if self.deferred or self.active_history: return ColumnProperty( self.column, - deferred=True, + deferred=self.deferred, group=self.deferred_group, raiseload=self.deferred_raiseload, attribute_options=self._attribute_options, + active_history=self.active_history, ) else: return None diff --git a/lib/sqlalchemy/sql/base.py b/lib/sqlalchemy/sql/base.py index 1752a4dc1..8186f6ade 100644 --- a/lib/sqlalchemy/sql/base.py +++ b/lib/sqlalchemy/sql/base.py @@ -101,6 +101,9 @@ if not TYPE_CHECKING: class _NoArg(Enum): NO_ARG = 0 + def __repr__(self): + return f"_NoArg.{self.name}" + NO_ARG = _NoArg.NO_ARG diff --git a/lib/sqlalchemy/util/deprecations.py b/lib/sqlalchemy/util/deprecations.py index 097150712..e32ab9e0d 100644 --- a/lib/sqlalchemy/util/deprecations.py +++ b/lib/sqlalchemy/util/deprecations.py @@ -226,6 +226,7 @@ def deprecated_params(**specs: Tuple[str, str]) -> Callable[[_F], _F]: check_defaults: Union[Set[str], Tuple[()]] if spec.defaults is not None: + defaults = dict( zip( spec.args[(len(spec.args) - len(spec.defaults)) :], @@ -234,6 +235,11 @@ def deprecated_params(**specs: Tuple[str, str]) -> Callable[[_F], _F]: ) check_defaults = set(defaults).intersection(messages) check_kw = set(messages).difference(defaults) + elif spec.kwonlydefaults is not None: + + defaults = spec.kwonlydefaults + check_defaults = set(defaults).intersection(messages) + check_kw = set(messages).difference(defaults) else: check_defaults = () check_kw = set(messages) diff --git a/lib/sqlalchemy/util/langhelpers.py b/lib/sqlalchemy/util/langhelpers.py index 6ff069c4e..903d8bdeb 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -290,6 +290,9 @@ def %(name)s%(grouped_args)s: """ % metadata ) + + mod = sys.modules[fn.__module__] + env.update(vars(mod)) env.update({targ_name: target, fn_name: fn, "__name__": fn.__module__}) decorated = cast( diff --git a/test/ext/mypy/plain_files/dataclass_transforms_one.py b/test/ext/mypy/plain_files/dataclass_transforms_one.py new file mode 100644 index 000000000..b7b88590f --- /dev/null +++ b/test/ext/mypy/plain_files/dataclass_transforms_one.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy.orm import column_property +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import MappedAsDataclass +from sqlalchemy.orm import query_expression + + +class Base(DeclarativeBase): + pass + + +class TestInitialSupport(Base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] + x: Mapped[Optional[int]] = mapped_column(default=None) + y: Mapped[Optional[int]] = mapped_column(kw_only=True) + + +tis = TestInitialSupport(data="some data", y=5) + +# EXPECTED_TYPE: str +reveal_type(tis.data) + +# EXPECTED_RE_TYPE: .*Union\[builtins.int, None\] +reveal_type(tis.y) + +tis.data = "some other data" + + +class TestTicket9628(MappedAsDataclass, Base): + __tablename__ = "ticket_9628" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] = mapped_column() + + d2: Mapped[str] = column_property(data + "Asdf") + d3: Mapped[str] = query_expression(data + "Asdf") + + +# d2 and d3 are not required, as these have init=False. We omit +# them from dataclass transforms entirely as these are never intended +# to be writeable fields in a 2.0 declarative mapping +t9628 = TestTicket9628(data="asf") diff --git a/test/ext/mypy/plain_files/pep681.py b/test/ext/mypy/plain_files/pep681.py deleted file mode 100644 index caa219d78..000000000 --- a/test/ext/mypy/plain_files/pep681.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from sqlalchemy.orm import DeclarativeBase -from sqlalchemy.orm import Mapped -from sqlalchemy.orm import mapped_column -from sqlalchemy.orm import MappedAsDataclass - - -class Base(MappedAsDataclass, DeclarativeBase): - pass - - -class A(Base): - __tablename__ = "a" - - id: Mapped[int] = mapped_column(primary_key=True, init=False) - data: Mapped[str] - x: Mapped[Optional[int]] = mapped_column(default=None) - y: Mapped[Optional[int]] = mapped_column(kw_only=True) - - -a1 = A(data="some data", y=5) - -# EXPECTED_TYPE: str -reveal_type(a1.data) - -# EXPECTED_RE_TYPE: .*Union\[builtins.int, None\] -reveal_type(a1.y) - -a1.data = "some other data" diff --git a/test/orm/declarative/test_basic.py b/test/orm/declarative/test_basic.py index 2d712c823..698b66db1 100644 --- a/test/orm/declarative/test_basic.py +++ b/test/orm/declarative/test_basic.py @@ -2593,6 +2593,26 @@ class DeclarativeMultiBaseTest( sess.expunge_all() eq_(sess.query(User).all(), [User(name="u1", a="a", b="b")]) + def test_active_history_columns(self): + class Foo(Base): + __tablename__ = "foo" + + id = Column( + Integer, primary_key=True, test_needs_autoincrement=True + ) + a = column_property(Column(String), active_history=True) + b = mapped_column(String, active_history=True) + c = column_property(Column(String)) + d = mapped_column(String) + + self.assert_compile( + select(Foo), "SELECT foo.id, foo.a, foo.b, foo.c, foo.d FROM foo" + ) + eq_(Foo.a.impl.active_history, True) + eq_(Foo.b.impl.active_history, True) + eq_(Foo.c.impl.active_history, False) + eq_(Foo.d.impl.active_history, False) + def test_column_properties(self): class Address(Base, fixtures.ComparableEntity): diff --git a/test/orm/declarative/test_dc_transforms.py b/test/orm/declarative/test_dc_transforms.py index 031aad5d5..576ee7fbf 100644 --- a/test/orm/declarative/test_dc_transforms.py +++ b/test/orm/declarative/test_dc_transforms.py @@ -39,11 +39,13 @@ from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import MappedAsDataclass from sqlalchemy.orm import MappedColumn +from sqlalchemy.orm import query_expression from sqlalchemy.orm import registry from sqlalchemy.orm import registry as _RegistryType from sqlalchemy.orm import relationship from sqlalchemy.orm import Session from sqlalchemy.orm import synonym +from sqlalchemy.sql.base import _NoArg from sqlalchemy.testing import AssertsCompiledSQL from sqlalchemy.testing import eq_ from sqlalchemy.testing import eq_regex @@ -1355,9 +1357,7 @@ class DataclassArgsTest(fixtures.TestBase): else: return args, args - @testing.fixture( - params=["mapped_column", "synonym", "deferred", "column_property"] - ) + @testing.fixture(params=["mapped_column", "synonym", "deferred"]) def mapped_expr_constructor(self, request): name = request.param @@ -1367,8 +1367,6 @@ class DataclassArgsTest(fixtures.TestBase): yield synonym("some_int", default=7, init=True) elif name == "deferred": yield deferred(Column(Integer), default=7, init=True) - elif name == "column_property": - yield column_property(Column(Integer), default=7, init=True) def test_attrs_rejected_if_not_a_dc( self, mapped_expr_constructor, decl_base: Type[DeclarativeBase] @@ -1725,7 +1723,6 @@ class DataclassArgsTest(fixtures.TestBase): @testing.combinations( mapped_column, lambda **kw: synonym("some_int", **kw), - lambda **kw: column_property(Column(Integer), **kw), lambda **kw: deferred(Column(Integer), **kw), lambda **kw: composite("foo", **kw), lambda **kw: relationship("Foo", **kw), @@ -1752,6 +1749,28 @@ class DataclassArgsTest(fixtures.TestBase): prop = construct(**kw) eq_(prop._attribute_options, exp) + @testing.variation("use_arguments", [True, False]) + @testing.combinations( + lambda **kw: column_property(Column(Integer), **kw), + lambda **kw: query_expression(**kw), + argnames="construct", + ) + def test_ro_attribute_options(self, use_arguments, construct): + if use_arguments: + kw = { + "repr": False, + "compare": True, + } + exp = interfaces._AttributeOptions( + False, False, _NoArg.NO_ARG, _NoArg.NO_ARG, True, _NoArg.NO_ARG + ) + else: + kw = {} + exp = interfaces._DEFAULT_READONLY_ATTRIBUTE_OPTIONS + + prop = construct(**kw) + eq_(prop._attribute_options, exp) + class MixinColumnTest(fixtures.TestBase, testing.AssertsCompiledSQL): """tests for #8718""" @@ -1978,3 +1997,78 @@ class CompositeTest(fixtures.TestBase, testing.AssertsCompiledSQL): "state='NY', zip_='12345'))", ) eq_(repr(u2), "mymodule.User(name='u2', address=None)") + + +class ReadOnlyAttrTest(fixtures.TestBase, testing.AssertsCompiledSQL): + """tests related to #9628""" + + __dialect__ = "default" + + @testing.combinations( + (query_expression,), (column_property,), argnames="construct" + ) + def test_default_behavior( + self, dc_decl_base: Type[MappedAsDataclass], construct + ): + class MyClass(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] = mapped_column() + + const: Mapped[str] = construct(data + "asdf") + + m1 = MyClass(data="foo") + eq_(m1, MyClass(data="foo")) + ne_(m1, MyClass(data="bar")) + + eq_regex( + repr(m1), + r".*MyClass\(id=None, data='foo', const=None\)", + ) + + @testing.combinations( + (query_expression,), (column_property,), argnames="construct" + ) + def test_no_repr_behavior( + self, dc_decl_base: Type[MappedAsDataclass], construct + ): + class MyClass(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] = mapped_column() + + const: Mapped[str] = construct(data + "asdf", repr=False) + + m1 = MyClass(data="foo") + + eq_regex( + repr(m1), + r".*MyClass\(id=None, data='foo'\)", + ) + + @testing.combinations( + (query_expression,), (column_property,), argnames="construct" + ) + def test_enable_compare( + self, dc_decl_base: Type[MappedAsDataclass], construct + ): + class MyClass(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] = mapped_column() + + const: Mapped[str] = construct(data + "asdf", compare=True) + + m1 = MyClass(data="foo") + eq_(m1, MyClass(data="foo")) + ne_(m1, MyClass(data="bar")) + + m2 = MyClass(data="foo") + m2.const = "some const" + ne_(m2, MyClass(data="foo")) + m3 = MyClass(data="foo") + m3.const = "some const" + eq_(m2, m3) diff --git a/test/orm/test_deferred.py b/test/orm/test_deferred.py index 5ce7475f2..e1eb6e9e1 100644 --- a/test/orm/test_deferred.py +++ b/test/orm/test_deferred.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Union + import sqlalchemy as sa from sqlalchemy import ForeignKey from sqlalchemy import func @@ -132,8 +136,8 @@ class DeferredTest(AssertsCompiledSQL, _fixtures.FixtureTest): ], ) - @testing.combinations(True, False, None, "deferred_parameter") - def test_group_defer_newstyle(self, deferred_parameter): + @testing.combinations(True, False, None, argnames="deferred_parameter") + def test_group_defer_newstyle(self, deferred_parameter: Union[bool, None]): class Base(DeclarativeBase): pass diff --git a/test/orm/test_deprecations.py b/test/orm/test_deprecations.py index 722563964..a3e2f4ef7 100644 --- a/test/orm/test_deprecations.py +++ b/test/orm/test_deprecations.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import sqlalchemy as sa from sqlalchemy import cast +from sqlalchemy import column from sqlalchemy import desc from sqlalchemy import event from sqlalchemy import exc as sa_exc @@ -31,6 +32,8 @@ from sqlalchemy.orm import deferred from sqlalchemy.orm import foreign from sqlalchemy.orm import instrumentation from sqlalchemy.orm import joinedload +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from sqlalchemy.orm import scoped_session from sqlalchemy.orm import Session @@ -48,6 +51,7 @@ from sqlalchemy.testing import AssertsCompiledSQL from sqlalchemy.testing import eq_ from sqlalchemy.testing import eq_ignore_whitespace from sqlalchemy.testing import expect_deprecated +from sqlalchemy.testing import expect_raises_message from sqlalchemy.testing import fixtures from sqlalchemy.testing import is_ from sqlalchemy.testing import is_true @@ -399,6 +403,63 @@ class MiscDeprecationsTest(fixtures.TestBase): is_(EvaluatorCompiler, _EvaluatorCompiler) + @testing.combinations( + ("init", True), + ("kw_only", True, testing.requires.python310), + ("default", 5), + ("default_factory", lambda: 10), + argnames="paramname, value", + ) + def test_column_property_dc_attributes(self, paramname, value): + with expect_deprecated( + rf"The column_property.{paramname} parameter is deprecated " + r"for column_property\(\)", + raise_on_any_unexpected=True, + ): + column_property(column("q"), **{paramname: value}) + + @testing.requires.python310 + def test_column_property_dc_attributes_still_function(self, dc_decl_base): + with expect_deprecated( + r"The column_property.init parameter is deprecated " + r"for column_property\(\)", + r"The column_property.default parameter is deprecated " + r"for column_property\(\)", + r"The column_property.default_factory parameter is deprecated " + r"for column_property\(\)", + r"The column_property.kw_only parameter is deprecated " + r"for column_property\(\)", + raise_on_any_unexpected=True, + ): + + class MyClass(dc_decl_base): + __tablename__ = "a" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + data: Mapped[str] = mapped_column() + + const1: Mapped[str] = column_property( + data + "asdf", init=True, default="foobar" + ) + const2: Mapped[str] = column_property( + data + "asdf", + init=True, + default_factory=lambda: "factory_foo", + ) + const3: Mapped[str] = column_property( + data + "asdf", init=True, kw_only=True + ) + + m1 = MyClass(data="d1", const3="c3") + eq_(m1.const1, "foobar") + eq_(m1.const2, "factory_foo") + eq_(m1.const3, "c3") + + with expect_raises_message( + TypeError, "missing 1 required keyword-only argument: 'const3'" + ): + MyClass(data="d1") + class DeprecatedQueryTest(_fixtures.FixtureTest, AssertsCompiledSQL): __dialect__ = "default" |