From c87572b60cbcb869c41a7b4283a11c5c14ef048c Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Thu, 9 May 2024 22:21:11 +0200 Subject: [PATCH] Add ``insert_default`` to ``Column``. Added :paramref:`_schema.Column.insert_default` as an alias of :paramref:`_schema.Column.default` for compatibility with func:`_orm.mapped_column`. Fixes: #11374 Change-Id: I5509b6cbac7b37ac37430a88442b1319cc9c1024 --- doc/build/changelog/unreleased_20/11374.rst | 7 ++ doc/build/faq/ormconfiguration.rst | 91 +++++++++++++++++++++ doc/build/orm/dataclasses.rst | 2 +- lib/sqlalchemy/orm/_orm_constructors.py | 25 ++++++ lib/sqlalchemy/sql/schema.py | 26 ++++-- test/sql/test_metadata.py | 34 ++++++-- 6 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/11374.rst diff --git a/doc/build/changelog/unreleased_20/11374.rst b/doc/build/changelog/unreleased_20/11374.rst new file mode 100644 index 0000000000..d52da2e767 --- /dev/null +++ b/doc/build/changelog/unreleased_20/11374.rst @@ -0,0 +1,7 @@ +.. change:: + :tags: schema, usecase + :tickets: 11374 + + Added :paramref:`_schema.Column.insert_default` as an alias of + :paramref:`_schema.Column.default` for compatibility with + :func:`_orm.mapped_column`. diff --git a/doc/build/faq/ormconfiguration.rst b/doc/build/faq/ormconfiguration.rst index 90d74d23ee..bfcf117ae0 100644 --- a/doc/build/faq/ormconfiguration.rst +++ b/doc/build/faq/ormconfiguration.rst @@ -349,3 +349,94 @@ loads directly to primary key values just loaded. .. seealso:: :ref:`subquery_eager_loading` + +.. _defaults_default_factory_insert_default: + +What are ``default``, ``default_factory`` and ``insert_default`` and what should I use? +--------------------------------------------------------------------------------------- + +There's a bit of a clash in SQLAlchemy's API here due to the addition of PEP-681 +dataclass transforms, which is strict about its naming conventions. PEP-681 comes +into play if you are using :class:`_orm.MappedAsDataclass` as shown in :ref:`orm_declarative_native_dataclasses`. +If you are not using MappedAsDataclass, then it does not apply. + +Part One - Classic SQLAlchemy that is not using dataclasses +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When **not** using :class:`_orm.MappedAsDataclass`, as has been the case for many years +in SQLAlchemy, the :func:`_orm.mapped_column` (and :class:`_schema.Column`) +construct supports a parameter :paramref:`_orm.mapped_column.default`. +This indicates a Python-side default (as opposed to a server side default that +would be part of your database's schema definition) that will take place when +an ``INSERT`` statement is emitted. This default can be **any** of a static Python value +like a string, **or** a Python callable function, **or** a SQLAlchemy SQL construct. +Full documentation for :paramref:`_orm.mapped_column.default` is at +:ref:`defaults_client_invoked_sql`. + +When using :paramref:`_orm.mapped_column.default` with an ORM mapping that is **not** +using :class:`_orm.MappedAsDataclass`, this default value /callable **does not show +up on your object when you first construct it**. It only takes place when SQLAlchemy +works up an ``INSERT`` statement for your object. + +A very important thing to note is that when using :func:`_orm.mapped_column` +(and :class:`_schema.Column`), the classic :paramref:`_orm.mapped_column.default` +parameter is also available under a new name, called +:paramref:`_orm.mapped_column.insert_default`. If you build a +:func:`_orm.mapped_column` and you are **not** using :class:`_orm.MappedAsDataclass`, the +:paramref:`_orm.mapped_column.default` and :paramref:`_orm.mapped_column.insert_default` +parameters are **synonymous**. + +Part Two - Using Dataclasses support with MappedAsDataclass +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When you **are** using :class:`_orm.MappedAsDataclass`, that is, the specific form +of mapping used at :ref:`orm_declarative_native_dataclasses`, the meaning of the +:paramref:`_orm.mapped_column.default` keyword changes. We recognize that it's not +ideal that this name changes its behavior, however there was no alternative as +PEP-681 requires :paramref:`_orm.mapped_column.default` to take on this meaning. + +When dataclasses are used, the :paramref:`_orm.mapped_column.default` parameter must +be used the way it's described at +`Python Dataclasses `_ - it refers +to a constant value like a string or a number, and **is applied to your object +immediately when constructed**. It is also at the moment also applied to the +:paramref:`_orm.mapped_column.default` parameter of :class:`_schema.Column` where +it would be used in an ``INSERT`` statement automatically even if not present +on the object. If you instead want to use a callable for your dataclass, +which will be applied to the object when constructed, you would use +:paramref:`_orm.mapped_column.default_factory`. + +To get access to the ``INSERT``-only behavior of :paramref:`_orm.mapped_column.default` +that is described in part one above, you would use the +:paramref:`_orm.mapped_column.insert_default` parameter instead. +:paramref:`_orm.mapped_column.insert_default` when dataclasses are used continues +to be a direct route to the Core-level "default" process where the parameter can +be a static value or callable. + +.. list-table:: Summary Chart + :header-rows: 1 + + * - Construct + - Works with dataclasses? + - Works without dataclasses? + - Accepts scalar? + - Accepts callable? + - Populates object immediately? + * - :paramref:`_orm.mapped_column.default` + - ✔ + - ✔ + - ✔ + - Only if no dataclasses + - Only if dataclasses + * - :paramref:`_orm.mapped_column.insert_default` + - ✔ + - ✔ + - ✔ + - ✔ + - ✖ + * - :paramref:`_orm.mapped_column.default_factory` + - ✔ + - ✖ + - ✖ + - ✔ + - Only if dataclasses diff --git a/doc/build/orm/dataclasses.rst b/doc/build/orm/dataclasses.rst index e737597cf1..910d6a21c5 100644 --- a/doc/build/orm/dataclasses.rst +++ b/doc/build/orm/dataclasses.rst @@ -18,7 +18,7 @@ attrs_ third party integration library. .. _orm_declarative_native_dataclasses: Declarative Dataclass Mapping -------------------------------- +----------------------------- SQLAlchemy :ref:`Annotated Declarative Table ` mappings may be augmented with an additional diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index 0bb6e31919..7d215059af 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -257,12 +257,28 @@ def mapped_column( be used instead**. This is necessary to disambiguate the callable from being interpreted as a dataclass level default. + .. seealso:: + + :ref:`defaults_default_factory_insert_default` + + :paramref:`_orm.mapped_column.insert_default` + + :paramref:`_orm.mapped_column.default_factory` + :param insert_default: Passed directly to the :paramref:`_schema.Column.default` parameter; will supersede the value of :paramref:`_orm.mapped_column.default` when present, however :paramref:`_orm.mapped_column.default` will always apply to the constructor default for a dataclasses mapping. + .. seealso:: + + :ref:`defaults_default_factory_insert_default` + + :paramref:`_orm.mapped_column.default` + + :paramref:`_orm.mapped_column.default_factory` + :param sort_order: An integer that indicates how this mapped column should be sorted compared to the others when the ORM is creating a :class:`_schema.Table`. Among mapped columns that have the same @@ -297,6 +313,15 @@ def mapped_column( specifies a default-value generation function that will take place as part of the ``__init__()`` method as generated by the dataclass process. + + .. seealso:: + + :ref:`defaults_default_factory_insert_default` + + :paramref:`_orm.mapped_column.default` + + :paramref:`_orm.mapped_column.insert_default` + :param compare: Specific to :ref:`orm_declarative_native_dataclasses`, indicates if this field should be included in comparison operations when generating the diff --git a/lib/sqlalchemy/sql/schema.py b/lib/sqlalchemy/sql/schema.py index 0ee69df44f..276e4edf4a 100644 --- a/lib/sqlalchemy/sql/schema.py +++ b/lib/sqlalchemy/sql/schema.py @@ -63,6 +63,7 @@ from . import roles from . import type_api from . import visitors from .base import _DefaultDescriptionTuple +from .base import _NoArg from .base import _NoneName from .base import _SentinelColumnCharacterization from .base import _SentinelDefaultCharacterization @@ -1516,7 +1517,8 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause[_T]): name: Optional[str] = None, type_: Optional[_TypeEngineArgument[_T]] = None, autoincrement: _AutoIncrementType = "auto", - default: Optional[Any] = None, + default: Optional[Any] = _NoArg.NO_ARG, + insert_default: Optional[Any] = _NoArg.NO_ARG, doc: Optional[str] = None, key: Optional[str] = None, index: Optional[bool] = None, @@ -1753,6 +1755,11 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause[_T]): :ref:`metadata_defaults_toplevel` + :param insert_default: An alias of :paramref:`.Column.default` + for compatibility with :func:`_orm.mapped_column`. + + .. versionadded: 2.0.31 + :param doc: optional String that can be used by the ORM or similar to document attributes on the Python side. This attribute does **not** render SQL comments; use the @@ -2106,12 +2113,19 @@ class Column(DialectKWArgs, SchemaItem, ColumnClause[_T]): # otherwise, add DDL-related events self._set_type(self.type) - if default is not None: - if not isinstance(default, (ColumnDefault, Sequence)): - default = ColumnDefault(default) + if insert_default is not _NoArg.NO_ARG: + resolved_default = insert_default + elif default is not _NoArg.NO_ARG: + resolved_default = default + else: + resolved_default = None + + if resolved_default is not None: + if not isinstance(resolved_default, (ColumnDefault, Sequence)): + resolved_default = ColumnDefault(resolved_default) - self.default = default - l_args.append(default) + self.default = resolved_default + l_args.append(resolved_default) else: self.default = None diff --git a/test/sql/test_metadata.py b/test/sql/test_metadata.py index a54a5fcc8d..97c2f08645 100644 --- a/test/sql/test_metadata.py +++ b/test/sql/test_metadata.py @@ -751,13 +751,25 @@ class MetaDataTest(fixtures.TestBase, ComparesTables): comment="foo", ), "Column('foo', Integer(), table=None, primary_key=True, " - "nullable=False, onupdate=%s, default=%s, server_default=%s, " - "comment='foo')" - % ( - ColumnDefault(1), - ColumnDefault(42), - DefaultClause("42"), + f"nullable=False, onupdate={ColumnDefault(1)}, default=" + f"{ColumnDefault(42)}, server_default={DefaultClause('42')}, " + "comment='foo')", + ), + ( + Column( + "foo", + Integer, + primary_key=True, + nullable=False, + onupdate=1, + insert_default=42, + server_default="42", + comment="foo", ), + "Column('foo', Integer(), table=None, primary_key=True, " + f"nullable=False, onupdate={ColumnDefault(1)}, default=" + f"{ColumnDefault(42)}, server_default={DefaultClause('42')}, " + "comment='foo')", ), ( Table("bar", MetaData(), Column("x", String)), @@ -4691,6 +4703,16 @@ class ColumnDefaultsTest(fixtures.TestBase): assert c.onupdate.arg == target assert c.onupdate.column is c + def test_column_insert_default(self): + c = self._fixture(insert_default="y") + assert c.default.arg == "y" + + def test_column_insert_default_predecende_on_default(self): + c = self._fixture(insert_default="x", default="y") + assert c.default.arg == "x" + c = self._fixture(default="y", insert_default="x") + assert c.default.arg == "x" + class ColumnOptionsTest(fixtures.TestBase): def test_default_generators(self): -- 2.47.2