]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
column_descriptions or equiv for DML, core select
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 26 Mar 2022 20:20:34 +0000 (16:20 -0400)
committermike bayer <mike_mp@zzzcomputing.com>
Mon, 28 Mar 2022 20:50:33 +0000 (20:50 +0000)
Added new attributes :attr:`.ValuesBase.returning_column_descriptions` and
:attr:`.ValuesBase.entity_description` to allow for inspection of ORM
attributes and entities that are installed as part of an :class:`.Insert`,
:class:`.Update`, or :class:`.Delete` construct. The
:attr:`.Select.column_descriptions` accessor is also now implemented for
Core-only selectables.

Fixes: #7861
Change-Id: Ia6a1cd24c798ba61f4e8e8eac90a0fd00d738342

doc/build/changelog/unreleased_14/7861.rst [new file with mode: 0644]
doc/build/glossary.rst
doc/build/orm/queryguide.rst
lib/sqlalchemy/orm/persistence.py
lib/sqlalchemy/orm/query.py
lib/sqlalchemy/sql/dml.py
lib/sqlalchemy/sql/selectable.py
test/orm/test_core_compilation.py
test/sql/test_selectable.py

diff --git a/doc/build/changelog/unreleased_14/7861.rst b/doc/build/changelog/unreleased_14/7861.rst
new file mode 100644 (file)
index 0000000..49ac82a
--- /dev/null
@@ -0,0 +1,10 @@
+.. change::
+    :tags: usecase, orm
+    :tickets: 7861
+
+    Added new attributes :attr:`.UpdateBase.returning_column_descriptions` and
+    :attr:`.UpdateBase.entity_description` to allow for inspection of ORM
+    attributes and entities that are installed as part of an :class:`.Insert`,
+    :class:`.Update`, or :class:`.Delete` construct. The
+    :attr:`.Select.column_descriptions` accessor is also now implemented for
+    Core-only selectables.
index bfb2e00a83f503a76faaafc71a76ebdea0866289..2e74113089cf80e541ffacd7c24b9a76456b79e6 100644 (file)
@@ -130,8 +130,9 @@ Glossary
         within the join expression.
 
     plugin
+    plugin-enabled
     plugin-specific
-        "plugin-specific" generally indicates a function or method in
+        "plugin-enabled" or "plugin-specific" generally indicates a function or method in
         SQLAlchemy Core which will behave differently when used in an ORM
         context.
 
index f36244e7aff9831ff1c645b837cd6389b1c6fa43..f6b1eefae170bcfc4a4243eef0e73f8ec7c88d61 100644 (file)
@@ -1096,6 +1096,104 @@ matching objects locally present in the :class:`_orm.Session`. See the section
     >>> conn.close()
     ROLLBACK
 
+.. _queryguide_inspection:
+
+Inspecting entities and columns from ORM-enabled SELECT and DML statements
+==========================================================================
+
+The :func:`.select` construct, as well as the :func:`.insert`, :func:`.update`
+and :func:`.delete` constructs (for the latter DML constructs, as of SQLAlchemy
+1.4.33), all support the ability to inspect the entities in which these
+statements are created against, as well as the columns and datatypes that would
+be returned in a result set.
+
+For a :class:`.Select` object, this information is available from the
+:attr:`.Select.column_descriptions` attribute. This attribute operates in the
+same way as the legacy :attr:`.Query.column_descriptions` attribute. The format
+returned is a list of dictionaries::
+
+    >>> from pprint import pprint
+    >>> user_alias = aliased(User, name='user2')
+    >>> stmt = select(User, User.id, user_alias)
+    >>> pprint(stmt.column_descriptions)
+    [{'aliased': False,
+        'entity': <class 'User'>,
+        'expr': <class 'User'>,
+        'name': 'User',
+        'type': <class 'User'>},
+        {'aliased': False,
+        'entity': <class 'User'>,
+        'expr': <....InstrumentedAttribute object at ...>,
+        'name': 'id',
+        'type': Integer()},
+        {'aliased': True,
+        'entity': <AliasedClass ...; User>,
+        'expr': <AliasedClass ...; User>,
+        'name': 'user2',
+        'type': <class 'User'>}]
+
+
+When :attr:`.Select.column_descriptions` is used with non-ORM objects
+such as plain :class:`.Table` or :class:`.Column` objects, the entries
+will contain basic information about individual columns returned in all
+cases::
+
+    >>> stmt = select(user_table, address_table.c.id)
+    >>> pprint(stmt.column_descriptions)
+    [{'expr': Column('id', Integer(), table=<user_account>, primary_key=True, nullable=False),
+        'name': 'id',
+        'type': Integer()},
+        {'expr': Column('name', String(length=30), table=<user_account>),
+        'name': 'name',
+        'type': String(length=30)},
+        {'expr': Column('fullname', String(), table=<user_account>),
+        'name': 'fullname',
+        'type': String()},
+        {'expr': Column('id', Integer(), table=<address>, primary_key=True, nullable=False),
+        'name': 'id_1',
+        'type': Integer()}]
+
+.. versionchanged:: 1.4.33 The :attr:`.Select.column_descriptions` attribute now returns
+   a value when used against a :class:`.Select` that is not ORM-enabled.  Previously,
+   this would raise ``NotImplementedError``.
+
+
+For :func:`.insert`, :func:`.update` and :func:`.delete` constructs, there are
+two separate attributes. One is :attr:`.UpdateBase.entity_description` which
+returns information about the primary ORM entity and database table which the
+DML construct would be affecting::
+
+    >>> from sqlalchemy import update
+    >>> stmt = update(User).values(name="somename").returning(User.id)
+    >>> pprint(stmt.entity_description)
+    {'entity': <class 'User'>,
+        'expr': <class 'User'>,
+        'name': 'User',
+        'table': Table('user_account', ...),
+        'type': <class 'User'>}
+
+.. tip::  The :attr:`.UpdateBase.entity_description` includes an entry
+   ``"table"`` which is actually the **table to be inserted, updated or
+   deleted** by the statement, which is **not** always the same as the SQL
+   "selectable" to which the class may be mapped. For example, in a
+   joined-table inheritance scenario, ``"table"`` will refer to the local table
+   for the given entity.
+
+The other is :attr:`.UpdateBase.returning_column_descriptions` which
+delivers information about the columns present in the RETURNING collection
+in a manner roughly similar to that of :attr:`.Select.column_descriptions`::
+
+    >>> pprint(stmt.returning_column_descriptions)
+    [{'aliased': False,
+        'entity': <class 'User'>,
+        'expr': <sqlalchemy.orm.attributes.InstrumentedAttribute ...>,
+        'name': 'id',
+        'type': Integer()}]
+
+.. versionadded:: 1.4.33 Added the :attr:`.UpdateBase.entity_description`
+   and :attr:`.UpdateBase.returning_column_descriptions` attributes.
+
+
 .. _queryguide_additional:
 
 Additional ORM API Constructs
index 519eb393f691baadffbd88c7c79cd12e86cd9610..6478aac15b0959bd56c4bad09ad382b0376ccdca 100644 (file)
@@ -42,6 +42,7 @@ from ..sql.base import _entity_namespace_key
 from ..sql.base import CompileState
 from ..sql.base import Options
 from ..sql.dml import DeleteDMLState
+from ..sql.dml import InsertDMLState
 from ..sql.dml import UpdateDMLState
 from ..sql.elements import BooleanClauseList
 from ..sql.selectable import LABEL_STYLE_TABLENAME_PLUS_COL
@@ -2133,8 +2134,59 @@ class BulkUDCompileState(CompileState):
         }
 
 
+class ORMDMLState:
+    @classmethod
+    def get_entity_description(cls, statement):
+        ext_info = statement.table._annotations["parententity"]
+        mapper = ext_info.mapper
+        if ext_info.is_aliased_class:
+            _label_name = ext_info.name
+        else:
+            _label_name = mapper.class_.__name__
+
+        return {
+            "name": _label_name,
+            "type": mapper.class_,
+            "expr": ext_info.entity,
+            "entity": ext_info.entity,
+            "table": mapper.local_table,
+        }
+
+    @classmethod
+    def get_returning_column_descriptions(cls, statement):
+        def _ent_for_col(c):
+            return c._annotations.get("parententity", None)
+
+        def _attr_for_col(c, ent):
+            if ent is None:
+                return c
+            proxy_key = c._annotations.get("proxy_key", None)
+            if not proxy_key:
+                return c
+            else:
+                return getattr(ent.entity, proxy_key, c)
+
+        return [
+            {
+                "name": c.key,
+                "type": c.type,
+                "expr": _attr_for_col(c, ent),
+                "aliased": ent.is_aliased_class,
+                "entity": ent.entity,
+            }
+            for c, ent in [
+                (c, _ent_for_col(c)) for c in statement._all_selected_columns
+            ]
+        ]
+
+
+@CompileState.plugin_for("orm", "insert")
+class ORMInsert(ORMDMLState, InsertDMLState):
+    pass
+
+
 @CompileState.plugin_for("orm", "update")
-class BulkORMUpdate(UpdateDMLState, BulkUDCompileState):
+class BulkORMUpdate(ORMDMLState, UpdateDMLState, BulkUDCompileState):
     @classmethod
     def create_for_statement(cls, statement, compiler, **kw):
 
@@ -2352,7 +2404,7 @@ class BulkORMUpdate(UpdateDMLState, BulkUDCompileState):
 
 
 @CompileState.plugin_for("orm", "delete")
-class BulkORMDelete(DeleteDMLState, BulkUDCompileState):
+class BulkORMDelete(ORMDMLState, DeleteDMLState, BulkUDCompileState):
     @classmethod
     def create_for_statement(cls, statement, compiler, **kw):
         self = cls.__new__(cls)
index 23dbfc32c6d27a9ad72b82fd1cbdf36151d69d34..ea5d5406efc732743cd516f05cbe42a3ccd8d841 100644 (file)
@@ -2391,6 +2391,15 @@ class Query(
                 }
             ]
 
+        .. seealso::
+
+            This API is available using :term:`2.0 style` queries as well,
+            documented at:
+
+            * :ref:`queryguide_inspection`
+
+            * :attr:`.Select.column_descriptions`
+
         """
 
         return _column_descriptions(self, legacy=True)
index 10316dd2bbc82393a515072a9791f0e3a4e3c616..f5fb6b2f34c7f996cd2e16ec19fdfc1bd01a375b 100644 (file)
@@ -78,6 +78,21 @@ class DMLState(CompileState):
     def __init__(self, statement, compiler, **kw):
         raise NotImplementedError()
 
+    @classmethod
+    def get_entity_description(cls, statement):
+        return {"name": statement.table.name, "table": statement.table}
+
+    @classmethod
+    def get_returning_column_descriptions(cls, statement):
+        return [
+            {
+                "name": c.key,
+                "type": c.type,
+                "expr": c,
+            }
+            for c in statement._all_selected_columns
+        ]
+
     @property
     def dml_table(self):
         return self.statement.table
@@ -438,6 +453,89 @@ class UpdateBase(
         self._hints = self._hints.union({(selectable, dialect_name): text})
         return self
 
+    @property
+    def entity_description(self):
+        """Return a :term:`plugin-enabled` description of the table and/or entity
+        which this DML construct is operating against.
+
+        This attribute is generally useful when using the ORM, as an
+        extended structure which includes information about mapped
+        entities is returned.  The section :ref:`queryguide_inspection`
+        contains more background.
+
+        For a Core statement, the structure returned by this accessor
+        is derived from the :attr:`.UpdateBase.table` attribute, and
+        refers to the :class:`.Table` being inserted, updated, or deleted::
+
+            >>> stmt = insert(user_table)
+            >>> stmt.entity_description
+            {
+                "name": "user_table",
+                "table": Table("user_table", ...)
+            }
+
+        .. versionadded:: 1.4.33
+
+        .. seealso::
+
+            :attr:`.UpdateBase.returning_column_descriptions`
+
+            :attr:`.Select.column_descriptions` - entity information for
+            a :func:`.select` construct
+
+            :ref:`queryguide_inspection` - ORM background
+
+        """
+        meth = DMLState.get_plugin_class(self).get_entity_description
+        return meth(self)
+
+    @property
+    def returning_column_descriptions(self):
+        """Return a :term:`plugin-enabled` description of the columns
+        which this DML construct is RETURNING against, in other words
+        the expressions established as part of :meth:`.UpdateBase.returning`.
+
+        This attribute is generally useful when using the ORM, as an
+        extended structure which includes information about mapped
+        entities is returned.  The section :ref:`queryguide_inspection`
+        contains more background.
+
+        For a Core statement, the structure returned by this accessor is
+        derived from the same objects that are returned by the
+        :attr:`.UpdateBase.exported_columns` accessor::
+
+            >>> stmt = insert(user_table).returning(user_table.c.id, user_table.c.name)
+            >>> stmt.entity_description
+            [
+                {
+                    "name": "id",
+                    "type": Integer,
+                    "expr": Column("id", Integer(), table=<user>, ...)
+                },
+                {
+                    "name": "name",
+                    "type": String(),
+                    "expr": Column("name", String(), table=<user>, ...)
+                },
+            ]
+
+        .. versionadded:: 1.4.33
+
+        .. seealso::
+
+            :attr:`.UpdateBase.entity_description`
+
+            :attr:`.Select.column_descriptions` - entity information for
+            a :func:`.select` construct
+
+            :ref:`queryguide_inspection` - ORM background
+
+        """  # noqa E501
+        meth = DMLState.get_plugin_class(
+            self
+        ).get_returning_column_descriptions
+        return meth(self)
+
 
 SelfValuesBase = typing.TypeVar("SelfValuesBase", bound="ValuesBase")
 
index 260daa9e9b335ab606e2e89e04121a514f7e6378..2f37317f26e3f03368ee0970d1dc72b5b87548d7 100644 (file)
@@ -3729,7 +3729,16 @@ class SelectState(util.MemoizedSlots, CompileState):
 
     @classmethod
     def get_column_descriptions(cls, statement):
-        cls._plugin_not_implemented()
+        return [
+            {
+                "name": name,
+                "type": element.type,
+                "expr": element,
+            }
+            for _, name, _, element, _ in (
+                statement._generate_columns_plus_names(False)
+            )
+        ]
 
     @classmethod
     def from_statement(cls, statement, from_statement):
@@ -4311,8 +4320,43 @@ class Select(
 
     @property
     def column_descriptions(self):
-        """Return a 'column descriptions' structure which may be
-        :term:`plugin-specific`.
+        """Return a :term:`plugin-enabled` 'column descriptions' structure
+        referring to the columns which are SELECTed by this statement.
+
+        This attribute is generally useful when using the ORM, as an
+        extended structure which includes information about mapped
+        entities is returned.  The section :ref:`queryguide_inspection`
+        contains more background.
+
+        For a Core-only statement, the structure returned by this accessor
+        is derived from the same objects that are returned by the
+        :attr:`.Select.selected_columns` accessor, formatted as a list of
+        dictionaries which contain the keys ``name``, ``type`` and ``expr``,
+        which indicate the column expressions to be selected::
+
+            >>> stmt = select(user_table)
+            >>> stmt.column_descriptions
+            [
+                {
+                    'name': 'id',
+                    'type': Integer(),
+                    'expr': Column('id', Integer(), ...)},
+                {
+                    'name': 'name',
+                    'type': String(length=30),
+                    'expr': Column('name', String(length=30), ...)}
+            ]
+
+        .. versionchanged:: 1.4.33 The :attr:`.Select.column_descriptions`
+           attribute returns a structure for a Core-only set of entities,
+           not just ORM-only entities.
+
+        .. seealso::
+
+            :attr:`.UpdateBase.entity_description` - entity information for
+            an :func:`.insert`, :func:`.update`, or :func:`.delete`
+
+            :ref:`queryguide_inspection` - ORM background
 
         """
         meth = SelectState.get_plugin_class(self).get_column_descriptions
index 1bf21058903f4f6b861133d72585a59ed76a269e..d6d229f792ba90cd4fd370f11de2982a79a6f6de 100644 (file)
@@ -1,5 +1,6 @@
 from sqlalchemy import bindparam
 from sqlalchemy import Column
+from sqlalchemy import delete
 from sqlalchemy import exc
 from sqlalchemy import func
 from sqlalchemy import insert
@@ -12,6 +13,7 @@ from sqlalchemy import select
 from sqlalchemy import testing
 from sqlalchemy import text
 from sqlalchemy import union
+from sqlalchemy import update
 from sqlalchemy import util
 from sqlalchemy.orm import aliased
 from sqlalchemy.orm import column_property
@@ -111,6 +113,18 @@ class SelectableTest(QueryTest, AssertsCompiledSQL):
                 }
             ],
         ),
+        (
+            lambda user_alias: (user_alias,),
+            lambda User, user_alias: [
+                {
+                    "name": None,
+                    "type": User,
+                    "aliased": True,
+                    "expr": user_alias,
+                    "entity": user_alias,
+                }
+            ],
+        ),
         (
             lambda User: (User.id,),
             lambda User: [
@@ -161,17 +175,101 @@ class SelectableTest(QueryTest, AssertsCompiledSQL):
                 },
             ],
         ),
+        (
+            lambda user_table: (user_table,),
+            lambda user_table: [
+                {
+                    "name": "id",
+                    "type": testing.eq_type_affinity(sqltypes.Integer),
+                    "expr": user_table.c.id,
+                },
+                {
+                    "name": "name",
+                    "type": testing.eq_type_affinity(sqltypes.String),
+                    "expr": user_table.c.name,
+                },
+            ],
+        ),
     )
     def test_column_descriptions(self, cols, expected):
         User, Address = self.classes("User", "Address")
+        ua = aliased(User)
 
-        cols = testing.resolve_lambda(cols, User=User, Address=Address)
-        expected = testing.resolve_lambda(expected, User=User, Address=Address)
+        cols = testing.resolve_lambda(
+            cols,
+            User=User,
+            Address=Address,
+            user_alias=ua,
+            user_table=inspect(User).local_table,
+        )
+        expected = testing.resolve_lambda(
+            expected,
+            User=User,
+            Address=Address,
+            user_alias=ua,
+            user_table=inspect(User).local_table,
+        )
 
         stmt = select(*cols)
-
         eq_(stmt.column_descriptions, expected)
 
+    @testing.combinations(insert, update, delete, argnames="dml_construct")
+    @testing.combinations(
+        (
+            lambda User: User,
+            lambda User: (User.id, User.name),
+            lambda User, user_table: {
+                "name": "User",
+                "type": User,
+                "expr": User,
+                "entity": User,
+                "table": user_table,
+            },
+            lambda User: [
+                {
+                    "name": "id",
+                    "type": testing.eq_type_affinity(sqltypes.Integer),
+                    "aliased": False,
+                    "expr": User.id,
+                    "entity": User,
+                },
+                {
+                    "name": "name",
+                    "type": testing.eq_type_affinity(sqltypes.String),
+                    "aliased": False,
+                    "expr": User.name,
+                    "entity": User,
+                },
+            ],
+        ),
+        argnames="entity, cols, expected_entity, expected_returning",
+    )
+    def test_dml_descriptions(
+        self, dml_construct, entity, cols, expected_entity, expected_returning
+    ):
+        User, Address = self.classes("User", "Address")
+
+        lambda_args = dict(
+            User=User,
+            Address=Address,
+            user_table=inspect(User).local_table,
+        )
+        entity = testing.resolve_lambda(entity, **lambda_args)
+        cols = testing.resolve_lambda(cols, **lambda_args)
+        expected_entity = testing.resolve_lambda(
+            expected_entity, **lambda_args
+        )
+        expected_returning = testing.resolve_lambda(
+            expected_returning, **lambda_args
+        )
+
+        stmt = dml_construct(entity)
+        if cols:
+            stmt = stmt.returning(*cols)
+
+        eq_(stmt.entity_description, expected_entity)
+        eq_(stmt.returning_column_descriptions, expected_returning)
+
 
 class ColumnsClauseFromsTest(QueryTest, AssertsCompiledSQL):
     __dialect__ = "default"
index a63ae5be46f80d6aa88308ac292ca246005c47c2..4944f2d57cd50eaa98308c076968161307465024 100644 (file)
@@ -6,11 +6,13 @@ from sqlalchemy import bindparam
 from sqlalchemy import Boolean
 from sqlalchemy import cast
 from sqlalchemy import Column
+from sqlalchemy import delete
 from sqlalchemy import exc
 from sqlalchemy import exists
 from sqlalchemy import false
 from sqlalchemy import ForeignKey
 from sqlalchemy import func
+from sqlalchemy import insert
 from sqlalchemy import Integer
 from sqlalchemy import join
 from sqlalchemy import literal_column
@@ -29,6 +31,7 @@ from sqlalchemy import true
 from sqlalchemy import type_coerce
 from sqlalchemy import TypeDecorator
 from sqlalchemy import union
+from sqlalchemy import update
 from sqlalchemy import util
 from sqlalchemy.sql import Alias
 from sqlalchemy.sql import annotation
@@ -88,6 +91,135 @@ class SelectableTest(
 ):
     __dialect__ = "default"
 
+    @testing.combinations(
+        (
+            (table1.c.col1, table1.c.col2),
+            [
+                {
+                    "name": "col1",
+                    "type": table1.c.col1.type,
+                    "expr": table1.c.col1,
+                },
+                {
+                    "name": "col2",
+                    "type": table1.c.col2.type,
+                    "expr": table1.c.col2,
+                },
+            ],
+        ),
+        (
+            (table1,),
+            [
+                {
+                    "name": "col1",
+                    "type": table1.c.col1.type,
+                    "expr": table1.c.col1,
+                },
+                {
+                    "name": "col2",
+                    "type": table1.c.col2.type,
+                    "expr": table1.c.col2,
+                },
+                {
+                    "name": "col3",
+                    "type": table1.c.col3.type,
+                    "expr": table1.c.col3,
+                },
+                {
+                    "name": "colx",
+                    "type": table1.c.colx.type,
+                    "expr": table1.c.colx,
+                },
+            ],
+        ),
+        (
+            (func.count(table1.c.col1),),
+            [
+                {
+                    "name": "count",
+                    "type": testing.eq_type_affinity(Integer),
+                    "expr": testing.eq_clause_element(
+                        func.count(table1.c.col1)
+                    ),
+                }
+            ],
+        ),
+        (
+            (func.count(table1.c.col1), func.count(table1.c.col2)),
+            [
+                {
+                    "name": "count",
+                    "type": testing.eq_type_affinity(Integer),
+                    "expr": testing.eq_clause_element(
+                        func.count(table1.c.col1)
+                    ),
+                },
+                {
+                    "name": "count_1",
+                    "type": testing.eq_type_affinity(Integer),
+                    "expr": testing.eq_clause_element(
+                        func.count(table1.c.col2)
+                    ),
+                },
+            ],
+        ),
+    )
+    def test_core_column_descriptions(self, cols, expected):
+        stmt = select(*cols)
+        # reverse eq_ is so eq_clause_element works
+        eq_(expected, stmt.column_descriptions)
+
+    @testing.combinations(insert, update, delete, argnames="dml_construct")
+    @testing.combinations(
+        (
+            table1,
+            (table1.c.col1, table1.c.col2),
+            {"name": "table1", "table": table1},
+            [
+                {
+                    "name": "col1",
+                    "type": table1.c.col1.type,
+                    "expr": table1.c.col1,
+                },
+                {
+                    "name": "col2",
+                    "type": table1.c.col2.type,
+                    "expr": table1.c.col2,
+                },
+            ],
+        ),
+        (
+            table1,
+            (func.count(table1.c.col1),),
+            {"name": "table1", "table": table1},
+            [
+                {
+                    "name": None,
+                    "type": testing.eq_type_affinity(Integer),
+                    "expr": testing.eq_clause_element(
+                        func.count(table1.c.col1)
+                    ),
+                },
+            ],
+        ),
+        (
+            table1,
+            None,
+            {"name": "table1", "table": table1},
+            [],
+        ),
+        argnames="entity, cols, expected_entity, expected_returning",
+    )
+    def test_dml_descriptions(
+        self, dml_construct, entity, cols, expected_entity, expected_returning
+    ):
+        stmt = dml_construct(entity)
+        if cols:
+            stmt = stmt.returning(*cols)
+
+        eq_(stmt.entity_description, expected_entity)
+        eq_(expected_returning, stmt.returning_column_descriptions)
+
     def test_indirect_correspondence_on_labels(self):
         # this test depends upon 'distance' to
         # get the right result