From: Mike Bayer Date: Sat, 26 Mar 2022 20:20:34 +0000 (-0400) Subject: column_descriptions or equiv for DML, core select X-Git-Tag: rel_2_0_0b1~399 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=9a8d95ce05a88c1efb02b52a88ff350d5d751d91;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git column_descriptions or equiv for DML, core select 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 --- diff --git a/doc/build/changelog/unreleased_14/7861.rst b/doc/build/changelog/unreleased_14/7861.rst new file mode 100644 index 0000000000..49ac82ad84 --- /dev/null +++ b/doc/build/changelog/unreleased_14/7861.rst @@ -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. diff --git a/doc/build/glossary.rst b/doc/build/glossary.rst index bfb2e00a83..2e74113089 100644 --- a/doc/build/glossary.rst +++ b/doc/build/glossary.rst @@ -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. diff --git a/doc/build/orm/queryguide.rst b/doc/build/orm/queryguide.rst index f36244e7af..f6b1eefae1 100644 --- a/doc/build/orm/queryguide.rst +++ b/doc/build/orm/queryguide.rst @@ -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': , + 'expr': , + 'name': 'User', + 'type': }, + {'aliased': False, + 'entity': , + 'expr': <....InstrumentedAttribute object at ...>, + 'name': 'id', + 'type': Integer()}, + {'aliased': True, + 'entity': , + 'expr': , + 'name': 'user2', + 'type': }] + + +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=, primary_key=True, nullable=False), + 'name': 'id', + 'type': Integer()}, + {'expr': Column('name', String(length=30), table=), + 'name': 'name', + 'type': String(length=30)}, + {'expr': Column('fullname', String(), table=), + 'name': 'fullname', + 'type': String()}, + {'expr': Column('id', Integer(), table=
, 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': , + 'expr': , + 'name': 'User', + 'table': Table('user_account', ...), + 'type': } + +.. 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': , + 'expr': , + '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 diff --git a/lib/sqlalchemy/orm/persistence.py b/lib/sqlalchemy/orm/persistence.py index 519eb393f6..6478aac15b 100644 --- a/lib/sqlalchemy/orm/persistence.py +++ b/lib/sqlalchemy/orm/persistence.py @@ -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) diff --git a/lib/sqlalchemy/orm/query.py b/lib/sqlalchemy/orm/query.py index 23dbfc32c6..ea5d5406ef 100644 --- a/lib/sqlalchemy/orm/query.py +++ b/lib/sqlalchemy/orm/query.py @@ -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) diff --git a/lib/sqlalchemy/sql/dml.py b/lib/sqlalchemy/sql/dml.py index 10316dd2bb..f5fb6b2f34 100644 --- a/lib/sqlalchemy/sql/dml.py +++ b/lib/sqlalchemy/sql/dml.py @@ -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=, ...) + }, + { + "name": "name", + "type": String(), + "expr": Column("name", String(), table=, ...) + }, + ] + + .. 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") diff --git a/lib/sqlalchemy/sql/selectable.py b/lib/sqlalchemy/sql/selectable.py index 260daa9e9b..2f37317f26 100644 --- a/lib/sqlalchemy/sql/selectable.py +++ b/lib/sqlalchemy/sql/selectable.py @@ -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 diff --git a/test/orm/test_core_compilation.py b/test/orm/test_core_compilation.py index 1bf2105890..d6d229f792 100644 --- a/test/orm/test_core_compilation.py +++ b/test/orm/test_core_compilation.py @@ -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" diff --git a/test/sql/test_selectable.py b/test/sql/test_selectable.py index a63ae5be46..4944f2d57c 100644 --- a/test/sql/test_selectable.py +++ b/test/sql/test_selectable.py @@ -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