From: Mike Bayer Date: Fri, 13 Jan 2023 22:24:14 +0000 (-0500) Subject: implement polymorphic_abstract=True feature X-Git-Tag: rel_2_0_0rc3~14 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e07130c597422d5f9a5d734e1411d8fef0c2deff;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git implement polymorphic_abstract=True feature Added a new parameter to :class:`_orm.Mapper` called :paramref:`_orm.Mapper.polymorphic_abstract`. The purpose of this directive is so that the ORM will not consider the class to be instantiated or loaded directly, only subclasses. The actual effect is that the :class:`_orm.Mapper` will prevent direct instantiation of instances of the class and will expect that the class does not have a distinct polymorphic identity configured. In practice, the class that is mapped with :paramref:`_orm.Mapper.polymorphic_abstract` can be used as the target of a :func:`_orm.relationship` as well as be used in queries; subclasses must of course include polymorphic identities in their mappings. The new parameter is automatically applied to classes that subclass the :class:`.AbstractConcreteBase` class, as this class is not intended to be instantiated. Additionally, updated some areas of the single table inheritance documentation to include mapped_column(nullable=False) for all subclass-only columns; the mappings as given didn't work as the columns were no longer nullable using Annotated Declarative Table style. Fixes: #9060 Change-Id: Ief0278e3945a33a6ff38ac14d39c38ce24910d7f --- diff --git a/doc/build/changelog/unreleased_20/9060.rst b/doc/build/changelog/unreleased_20/9060.rst new file mode 100644 index 0000000000..85bdf01d4d --- /dev/null +++ b/doc/build/changelog/unreleased_20/9060.rst @@ -0,0 +1,25 @@ +.. change:: + :tags: orm, feature + :tickets: 9060 + + Added a new parameter to :class:`_orm.Mapper` called + :paramref:`_orm.Mapper.polymorphic_abstract`. The purpose of this directive + is so that the ORM will not consider the class to be instantiated or loaded + directly, only subclasses. The actual effect is that the + :class:`_orm.Mapper` will prevent direct instantiation of instances + of the class and will expect that the class does not have a distinct + polymorphic identity configured. + + In practice, the class that is mapped with + :paramref:`_orm.Mapper.polymorphic_abstract` can be used as the target of a + :func:`_orm.relationship` as well as be used in queries; subclasses must of + course include polymorphic identities in their mappings. + + The new parameter is automatically applied to classes that subclass + the :class:`.AbstractConcreteBase` class, as this class is not intended + to be instantiated. + + .. seealso:: + + :ref:`orm_inheritance_abstract_poly` + diff --git a/doc/build/orm/declarative_config.rst b/doc/build/orm/declarative_config.rst index 7653f9192a..c7464580b7 100644 --- a/doc/build/orm/declarative_config.rst +++ b/doc/build/orm/declarative_config.rst @@ -450,6 +450,11 @@ created perhaps within distinct databases:: DefaultBase.metadata.create_all(some_engine) OtherBase.metadata.create_all(some_other_engine) +.. seealso:: + + :ref:`orm_inheritance_abstract_poly` - an alternative form of "abstract" + mapped class that is appropriate for inheritance hierarchies. + ``__table_cls__`` ~~~~~~~~~~~~~~~~~ diff --git a/doc/build/orm/inheritance.rst b/doc/build/orm/inheritance.rst index 01067685ec..d35967e4ca 100644 --- a/doc/build/orm/inheritance.rst +++ b/doc/build/orm/inheritance.rst @@ -287,10 +287,11 @@ inheritance, except only the base class specifies ``__tablename__``. A discriminator column is also required on the base table so that classes can be differentiated from each other. -Even though subclasses share the base table for all of their attributes, -when using Declarative, :class:`_schema.Column` objects may still be specified on -subclasses, indicating that the column is to be mapped only to that subclass; -the :class:`_schema.Column` will be applied to the same base :class:`_schema.Table` object:: +Even though subclasses share the base table for all of their attributes, when +using Declarative, :class:`_orm.mapped_column` objects may still be specified +on subclasses, indicating that the column is to be mapped only to that +subclass; the :class:`_orm.mapped_column` will be applied to the same base +:class:`_schema.Table` object:: class Employee(Base): __tablename__ = "employee" @@ -305,7 +306,7 @@ the :class:`_schema.Column` will be applied to the same base :class:`_schema.Tab class Manager(Employee): - manager_data: Mapped[str] + manager_data: Mapped[str] = mapped_column(nullable=True) __mapper_args__ = { "polymorphic_identity": "manager", @@ -313,7 +314,7 @@ the :class:`_schema.Column` will be applied to the same base :class:`_schema.Tab class Engineer(Employee): - engineer_info: Mapped[str] + engineer_info: Mapped[str] = mapped_column(nullable=True) __mapper_args__ = { "polymorphic_identity": "engineer", @@ -321,7 +322,11 @@ the :class:`_schema.Column` will be applied to the same base :class:`_schema.Tab Note that the mappers for the derived classes Manager and Engineer omit the ``__tablename__``, indicating they do not have a mapped table of -their own. +their own. Additionally, a :func:`_orm.mapped_column` directive with +``nullable=True`` is included; as the Python types declared for these classes +do not include ``Optional[]``, the column would normally be mapped as +``NOT NULL``, which would not be appropriate as this column only expects to +be populated for those rows that correspond to that particular subclass. .. _orm_inheritance_column_conflicts: @@ -352,14 +357,14 @@ comes up when two subclasses want to specify *the same* column, as below:: __mapper_args__ = { "polymorphic_identity": "engineer", } - start_date: Mapped[datetime] + start_date: Mapped[datetime] = mapped_column(nullable=True) class Manager(Employee): __mapper_args__ = { "polymorphic_identity": "manager", } - start_date: Mapped[datetime] + start_date: Mapped[datetime] = mapped_column(nullable=True) Above, the ``start_date`` column declared on both ``Engineer`` and ``Manager`` will result in an error: @@ -399,7 +404,9 @@ mapped, if already present, else to map a new column:: "polymorphic_identity": "engineer", } - start_date: Mapped[datetime] = mapped_column(use_existing_column=True) + start_date: Mapped[datetime] = mapped_column( + nullable=True, use_existing_column=True + ) class Manager(Employee): @@ -407,7 +414,9 @@ mapped, if already present, else to map a new column:: "polymorphic_identity": "manager", } - start_date: Mapped[datetime] = mapped_column(use_existing_column=True) + start_date: Mapped[datetime] = mapped_column( + nullable=True, use_existing_column=True + ) Above, when ``Manager`` is mapped, the ``start_date`` column is already present on the ``Employee`` class, having been provided by the @@ -444,7 +453,9 @@ from a reusable mixin class:: class HasStartDate: - start_date: Mapped[datetime] = mapped_column(use_existing_column=True) + start_date: Mapped[datetime] = mapped_column( + nullable=True, use_existing_column=True + ) class Engineer(HasStartDate, Employee): @@ -488,7 +499,7 @@ relationship:: class Manager(Employee): - manager_data: Mapped[str] + manager_data: Mapped[str] = mapped_column(nullable=True) __mapper_args__ = { "polymorphic_identity": "manager", @@ -496,7 +507,7 @@ relationship:: class Engineer(Employee): - engineer_info: Mapped[str] + engineer_info: Mapped[str] = mapped_column(nullable=True) __mapper_args__ = { "polymorphic_identity": "engineer", @@ -527,7 +538,7 @@ or subclasses:: class Manager(Employee): - manager_name: Mapped[str] + manager_name: Mapped[str] = mapped_column(nullable=True) company_id: Mapped[int] = mapped_column(ForeignKey("company.id")) company: Mapped[Company] = relationship(back_populates="managers") @@ -538,7 +549,7 @@ or subclasses:: class Engineer(Employee): - engineer_info: Mapped[str] + engineer_info: Mapped[str] = mapped_column(nullable=True) __mapper_args__ = { "polymorphic_identity": "engineer", @@ -549,6 +560,172 @@ Above, the ``Manager`` class will have a ``Manager.company`` attribute; loads against the ``employee`` with an additional WHERE clause that limits rows to those with ``type = 'manager'``. +.. _orm_inheritance_abstract_poly: + +Building Deeper Hierarchies with ``polymorphic_abstract`` ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +.. versionadded:: 2.0 + +When building any kind of inheritance hierarchy, a mapped class may include the +:paramref:`_orm.Mapper.polymorphic_abstract` parameter set to ``True``, which +indicates that the class should be mapped normally, however would not expect to +be instantiated directly and would not include a +:paramref:`_orm.Mapper.polymorphic_identity`. Subclasses may then be declared +as subclasses of this mapped class, which themselves can include a +:paramref:`_orm.Mapper.polymorphic_identity` and therefore be used normally. +This allows a series of subclasses to be referenced at once by a common base +class which is considered to be "abstract" within the hierarchy, both in +queries as well as in :func:`_orm.relationship` declarations. This use differs +from the use of the :ref:`declarative_abstract` attribute with Declarative, +which leaves the target class entirely unmapped and thus not usable as a mapped +class by itself. :paramref:`_orm.Mapper.polymorphic_abstract` may be applied to +any class or classes at any level in the hierarchy, including on multiple +levels at once. + +As an example, suppose ``Manager`` and ``Principal`` were both to be classified +against a superclass ``Executive``, and ``Engineer`` and ``Sysadmin`` were +classified against a superclass ``Technologist``. Neither ``Executive`` or +``Technologist`` is ever instantiated, therefore have no +:paramref:`_orm.Mapper.polymorphic_identity`. These classes can be configured +using :paramref:`_orm.Mapper.polymorphic_abstract` as follows:: + + class Employee(Base): + __tablename__ = "employee" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + type: Mapped[str] + + __mapper_args__ = { + "polymorphic_identity": "employee", + "polymorphic_on": "type", + } + + + class Executive(Employee): + """An executive of the company""" + + executive_background: Mapped[str] = mapped_column(nullable=True) + + __mapper_args__ = {"polymorphic_abstract": True} + + + class Technologist(Employee): + """An employee who works with technology""" + + competencies: Mapped[str] = mapped_column(nullable=True) + + __mapper_args__ = {"polymorphic_abstract": True} + + + class Manager(Executive): + """a manager""" + + __mapper_args__ = {"polymorphic_identity": "manager"} + + + class Principal(Executive): + """a principal of the company""" + + __mapper_args__ = {"polymorphic_identity": "principal"} + + + class Engineer(Technologist): + """an engineer""" + + __mapper_args__ = {"polymorphic_identity": "engineer"} + + + class SysAdmin(Technologist): + """a systems administrator""" + + __mapper_args__ = {"polymorphic_identity": "engineer"} + +In the above example, the new classes ``Technologist`` and ``Executive`` +are ordinary mapped classes, and also indicate new columns to be added to the +superclass called ``executive_background`` and ``competencies``. However, +they both lack a setting for :paramref:`_orm.Mapper.polymorphic_identity`; +this is because it's not expected that ``Technologist`` or ``Executive`` would +ever be instantiated directly; we'd always have one of ``Manager``, ``Principal``, +``Engineer`` or ``SysAdmin``. We can however query for +``Principal`` and ``Technologist`` roles, as well as have them be targets +of :func:`_orm.relationship`. The example below demonstrates a SELECT +statement for ``Technologist`` objects: + + +.. sourcecode:: python+sql + + session.scalars(select(Technologist)).all() + {execsql} + SELECT employee.id, employee.name, employee.type, employee.competencies + FROM employee + WHERE employee.type IN (?, ?) + [...] ('engineer', 'sysadmin') + +The ``Technologist`` and ``Executive`` abstract mapped classes may also be +made the targets of :func:`_orm.relationship` mappings, like any other +mapped class. We can extend the above example to include ``Company``, +with separate collections ``Company.technologists`` and ``Company.principals``:: + + class Company(Base): + __tablename__ = "company" + id = Column(Integer, primary_key=True) + + executives: Mapped[List[Executive]] = relationship() + technologists: Mapped[List[Technologist]] = relationship() + + + class Employee(Base): + __tablename__ = "employee" + id: Mapped[int] = mapped_column(primary_key=True) + + # foreign key to "company.id" is added + company_id: Mapped[int] = mapped_column(ForeignKey("company.id")) + + # rest of mapping is the same + name: Mapped[str] + type: Mapped[str] + + __mapper_args__ = { + "polymorphic_on": "type", + } + + + # Executive, Technologist, Manager, Principal, Engineer, SysAdmin + # classes from previous example would follow here unchanged + +Using the above mapping we can use joins and relationship loading techniques +across ``Company.technologists`` and ``Company.executives`` individually: + +.. sourcecode:: python+sql + + session.scalars( + select(Company) + .join(Company.technologists) + .where(Technologist.competency.ilike("%java%")) + .options(selectinload(Company.executives)) + ).all() + {execsql} + SELECT company.id + FROM company JOIN employee ON company.id = employee.company_id AND employee.type IN (?, ?) + WHERE lower(employee.competencies) LIKE lower(?) + [...] ('engineer', 'sysadmin', '%java%') + + SELECT employee.company_id AS employee_company_id, employee.id AS employee_id, + employee.name AS employee_name, employee.type AS employee_type, + employee.executive_background AS employee_executive_background + FROM employee + WHERE employee.company_id IN (?) AND employee.type IN (?, ?) + [...] (1, 'manager', 'principal') + + + +.. seealso:: + + :ref:`declarative_abstract` - Declarative parameter which allows a + Declarative class to be completely un-mapped within a hierarchy, while + still extending from a mapped superclass. + Loading Single Inheritance Mappings +++++++++++++++++++++++++++++++++++ diff --git a/lib/sqlalchemy/ext/declarative/extensions.py b/lib/sqlalchemy/ext/declarative/extensions.py index f5bae0695a..2cb55a5ae8 100644 --- a/lib/sqlalchemy/ext/declarative/extensions.py +++ b/lib/sqlalchemy/ext/declarative/extensions.py @@ -301,6 +301,7 @@ class AbstractConcreteBase(ConcreteBase): def mapper_args(): args = m_args() args["polymorphic_on"] = pjoin.c[discriminator_name] + args["polymorphic_abstract"] = True if strict_attrs: args["include_properties"] = ( set(pjoin.primary_key) diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 20ad635b0f..9c2c9acf1e 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -222,6 +222,7 @@ class Mapper( polymorphic_identity: Optional[Any] = None, concrete: bool = False, with_polymorphic: Optional[_WithPolymorphicArg] = None, + polymorphic_abstract: bool = False, polymorphic_load: Optional[Literal["selectin", "inline"]] = None, allow_partial_pks: bool = True, batch: bool = True, @@ -266,6 +267,20 @@ class Mapper( produced as a result of the ``__tablename__`` and :class:`_schema.Column` arguments present. + :param polymorphic_abstract: Indicates this class will be mapped in a + polymorphic hierarchy, but not directly instantiated. The class is + mapped normally, except that it has no requirement for a + :paramref:`_orm.Mapper.polymorphic_identity` within an inheritance + hierarchy. The class however must be part of a polymorphic + inheritance scheme which uses + :paramref:`_orm.Mapper.polymorphic_on` at the base. + + .. versionadded:: 2.0 + + .. seealso:: + + :ref:`orm_inheritance_abstract_poly` + :param always_refresh: If True, all query operations for this mapped class will overwrite all data within object instances that already exist within the session, erasing any in-memory changes with @@ -607,12 +622,16 @@ class Mapper( :ref:`inheritance_toplevel` :param polymorphic_identity: Specifies the value which - identifies this particular class as returned by the - column expression referred to by the ``polymorphic_on`` - setting. As rows are received, the value corresponding - to the ``polymorphic_on`` column expression is compared - to this value, indicating which subclass should - be used for the newly reconstructed object. + identifies this particular class as returned by the column expression + referred to by the :paramref:`_orm.Mapper.polymorphic_on` setting. As + rows are received, the value corresponding to the + :paramref:`_orm.Mapper.polymorphic_on` column expression is compared + to this value, indicating which subclass should be used for the newly + reconstructed object. + + .. seealso:: + + :ref:`inheritance_toplevel` :param properties: A dictionary mapping the string names of object attributes to :class:`.MapperProperty` instances, which define the @@ -781,6 +800,7 @@ class Mapper( if polymorphic_on is not None else None ) + self.polymorphic_abstract = polymorphic_abstract self._dependency_processors = [] self.validators = util.EMPTY_DICT self.passive_updates = passive_updates @@ -1262,19 +1282,21 @@ class Mapper( if self.polymorphic_identity is None: self._identity_class = self.class_ - if self.inherits.base_mapper.polymorphic_on is not None: + if ( + not self.polymorphic_abstract + and self.inherits.base_mapper.polymorphic_on is not None + ): util.warn( - "Mapper %s does not indicate a polymorphic_identity, " + f"{self} does not indicate a 'polymorphic_identity', " "yet is part of an inheritance hierarchy that has a " - "polymorphic_on column of '%s'. Objects of this type " - "cannot be loaded polymorphically which can lead to " - "degraded or incorrect loading behavior in some " - "scenarios. Please establish a polmorphic_identity " - "for this class, or leave it un-mapped. " - "To omit mapping an intermediary class when using " - "declarative, set the '__abstract__ = True' " - "attribute on that class." - % (self, self.inherits.base_mapper.polymorphic_on) + f"'polymorphic_on' column of " + f"'{self.inherits.base_mapper.polymorphic_on}'. " + "If this is an intermediary class that should not be " + "instantiated, the class may either be left unmapped, " + "or may include the 'polymorphic_abstract=True' " + "parameter in its Mapper arguments. To leave the " + "class unmapped when using Declarative, set the " + "'__abstract__ = True' attribute on the class." ) elif self.concrete: self._identity_class = self.class_ @@ -1859,7 +1881,6 @@ class Mapper( # column in the property self.polymorphic_on = prop.columns[0] polymorphic_key = prop.key - else: # no polymorphic_on was set. # check inheriting mappers for one. @@ -1894,16 +1915,36 @@ class Mapper( self._polymorphic_attr_key = None return + if self.polymorphic_abstract and self.polymorphic_on is None: + raise sa_exc.InvalidRequestError( + "The Mapper.polymorphic_abstract parameter may only be used " + "on a mapper hierarchy which includes the " + "Mapper.polymorphic_on parameter at the base of the hierarchy." + ) + if setter: def _set_polymorphic_identity(state): dict_ = state.dict # TODO: what happens if polymorphic_on column attribute name # does not match .key? + + polymorphic_identity = ( + state.manager.mapper.polymorphic_identity + ) + if ( + polymorphic_identity is None + and state.manager.mapper.polymorphic_abstract + ): + raise sa_exc.InvalidRequestError( + f"Can't instantiate class for {state.manager.mapper}; " + "mapper is marked polymorphic_abstract=True" + ) + state.get_impl(polymorphic_key).set( state, dict_, - state.manager.mapper.polymorphic_identity, + polymorphic_identity, None, ) @@ -2480,7 +2521,13 @@ class Mapper( if self.single and self.inherits and self.polymorphic_on is not None: return self.polymorphic_on._annotate( {"parententity": self, "parentmapper": self} - ).in_([m.polymorphic_identity for m in self.self_and_descendants]) + ).in_( + [ + m.polymorphic_identity + for m in self.self_and_descendants + if not m.polymorphic_abstract + ] + ) else: return None diff --git a/test/ext/declarative/test_inheritance.py b/test/ext/declarative/test_inheritance.py index dcfb3f8502..2aa0f2cc31 100644 --- a/test/ext/declarative/test_inheritance.py +++ b/test/ext/declarative/test_inheritance.py @@ -316,6 +316,13 @@ class ConcreteInhTest( self._roundtrip(Employee, Manager, Engineer, Boss) + with expect_raises_message( + sa_exc.InvalidRequestError, + r"Can't instantiate class for Mapper\[Employee\(pjoin\)\]; " + r"mapper is marked polymorphic_abstract=True", + ): + Employee() + @testing.combinations(True, False) def test_abstract_concrete_extension_descriptor_refresh( self, use_strict_attrs diff --git a/test/orm/declarative/test_inheritance.py b/test/orm/declarative/test_inheritance.py index f3506a3100..02d2852972 100644 --- a/test/orm/declarative/test_inheritance.py +++ b/test/orm/declarative/test_inheritance.py @@ -44,7 +44,7 @@ class DeclarativeTestBase(fixtures.TestBase, testing.AssertsExecutionResults): class DeclarativeInheritanceTest(DeclarativeTestBase): - @testing.emits_warning(r".*does not indicate a polymorphic_identity") + @testing.emits_warning(r".*does not indicate a 'polymorphic_identity'") def test_we_must_copy_mapper_args(self): class Person(Base): diff --git a/test/orm/inheritance/test_basic.py b/test/orm/inheritance/test_basic.py index 4e9107d1c4..905f0c50d6 100644 --- a/test/orm/inheritance/test_basic.py +++ b/test/orm/inheritance/test_basic.py @@ -3666,8 +3666,8 @@ class NoPolyIdentInMiddleTest(fixtures.MappedTest): ) with expect_warnings( - r"Mapper Mapper\[B\(base\)\] does not indicate a " - "polymorphic_identity," + r"Mapper\[B\(base\)\] does not indicate a " + "'polymorphic_identity'," ): cls.mapper_registry.map_imperatively(B, inherits=A) cls.mapper_registry.map_imperatively( @@ -3695,8 +3695,7 @@ class NoPolyIdentInMiddleTest(fixtures.MappedTest): __mapper_args__ = {"polymorphic_identity": "b"} with expect_warnings( - r"Mapper Mapper\[C\(a\)\] does not indicate a " - "polymorphic_identity," + r"Mapper\[C\(a\)\] does not indicate a " "'polymorphic_identity'," ): class C(A): diff --git a/test/orm/inheritance/test_single.py b/test/orm/inheritance/test_single.py index eb8d0c01a4..6ede1b0590 100644 --- a/test/orm/inheritance/test_single.py +++ b/test/orm/inheritance/test_single.py @@ -1,6 +1,10 @@ +from __future__ import annotations + from contextlib import nullcontext +from typing import List from sqlalchemy import and_ +from sqlalchemy import exc from sqlalchemy import ForeignKey from sqlalchemy import func from sqlalchemy import inspect @@ -16,14 +20,19 @@ from sqlalchemy.orm import Bundle from sqlalchemy.orm import column_property from sqlalchemy.orm import join as orm_join 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 selectinload from sqlalchemy.orm import Session from sqlalchemy.orm import subqueryload from sqlalchemy.orm import with_polymorphic from sqlalchemy.sql.selectable import LABEL_STYLE_TABLENAME_PLUS_COL from sqlalchemy.testing import AssertsCompiledSQL from sqlalchemy.testing import eq_ +from sqlalchemy.testing import expect_raises_message from sqlalchemy.testing import fixtures +from sqlalchemy.testing import mock from sqlalchemy.testing.assertsql import CompiledSQL from sqlalchemy.testing.fixtures import fixture_session from sqlalchemy.testing.schema import Column @@ -2083,3 +2092,218 @@ class ColExprTest(AssertsCompiledSQL, fixtures.TestBase): "SELECT (SELECT max(employee.id) AS max_1 FROM employee " "WHERE employee.type IN (__[POSTCOMPILE_type_1])) AS anon_1", ) + + +class AbstractPolymorphicTest( + AssertsCompiledSQL, fixtures.DeclarativeMappedTest +): + """test new polymorphic_abstract feature added as of #9060""" + + __dialect__ = "default" + + @classmethod + def setup_classes(cls): + Base = cls.DeclarativeBasic + + class Company(Base): + __tablename__ = "company" + id = Column(Integer, primary_key=True) + + executives: Mapped[List[Executive]] = relationship() + technologists: Mapped[List[Technologist]] = relationship() + + class Employee(Base): + __tablename__ = "employee" + id: Mapped[int] = mapped_column(primary_key=True) + company_id: Mapped[int] = mapped_column(ForeignKey("company.id")) + name: Mapped[str] + type: Mapped[str] + + __mapper_args__ = { + "polymorphic_on": "type", + } + + class Executive(Employee): + """An executive of the company""" + + executive_background: Mapped[str] = mapped_column(nullable=True) + __mapper_args__ = {"polymorphic_abstract": True} + + class Technologist(Employee): + """An employee who works with technology""" + + competencies: Mapped[str] = mapped_column(nullable=True) + __mapper_args__ = {"polymorphic_abstract": True} + + class Manager(Executive): + """a manager""" + + __mapper_args__ = {"polymorphic_identity": "manager"} + + class Principal(Executive): + """a principal of the company""" + + __mapper_args__ = {"polymorphic_identity": "principal"} + + class Engineer(Technologist): + """an engineer""" + + __mapper_args__ = {"polymorphic_identity": "engineer"} + + class SysAdmin(Technologist): + """a systems administrator""" + + __mapper_args__ = {"polymorphic_identity": "sysadmin"} + + def test_select_against_abstract(self): + Technologist = self.classes.Technologist + + self.assert_compile( + select(Technologist).where( + Technologist.competencies.like("%Java%") + ), + "SELECT employee.id, employee.company_id, employee.name, " + "employee.type, employee.competencies FROM employee " + "WHERE employee.competencies LIKE :competencies_1 " + "AND employee.type IN (:type_1_1, :type_1_2)", + checkparams={ + "competencies_1": "%Java%", + "type_1_1": "engineer", + "type_1_2": "sysadmin", + }, + render_postcompile=True, + ) + + def test_relationship_join(self): + Technologist = self.classes.Technologist + Company = self.classes.Company + + self.assert_compile( + select(Company) + .join(Company.technologists) + .where(Technologist.competencies.like("%Java%")), + "SELECT company.id FROM company JOIN employee " + "ON company.id = employee.company_id AND employee.type " + "IN (:type_1_1, :type_1_2) WHERE employee.competencies " + "LIKE :competencies_1", + checkparams={ + "competencies_1": "%Java%", + "type_1_1": "engineer", + "type_1_2": "sysadmin", + }, + render_postcompile=True, + ) + + @testing.fixture + def data_fixture(self, connection): + Company = self.classes.Company + Engineer = self.classes.Engineer + Manager = self.classes.Manager + Principal = self.classes.Principal + + with Session(connection) as sess: + sess.add( + Company( + technologists=[ + Engineer(name="e1", competencies="Java programming") + ], + executives=[ + Manager(name="m1", executive_background="eb1"), + Principal(name="p1", executive_background="eb2"), + ], + ) + ) + sess.flush() + + yield sess + + def test_relationship_join_w_eagerload(self, data_fixture): + Company = self.classes.Company + Technologist = self.classes.Technologist + + session = data_fixture + + with self.sql_execution_asserter() as asserter: + session.scalars( + select(Company) + .join(Company.technologists) + .where(Technologist.competencies.ilike("%java%")) + .options(selectinload(Company.executives)) + ).all() + + asserter.assert_( + CompiledSQL( + "SELECT company.id FROM company JOIN employee ON " + "company.id = employee.company_id AND employee.type " + "IN (__[POSTCOMPILE_type_1]) WHERE " + "lower(employee.competencies) LIKE lower(:competencies_1)", + [ + { + "type_1": ["engineer", "sysadmin"], + "competencies_1": "%java%", + } + ], + ), + CompiledSQL( + "SELECT employee.company_id AS employee_company_id, " + "employee.id AS employee_id, employee.name AS employee_name, " + "employee.type AS employee_type, " + "employee.executive_background AS " + "employee_executive_background " + "FROM employee WHERE employee.company_id " + "IN (__[POSTCOMPILE_primary_keys]) " + "AND employee.type IN (__[POSTCOMPILE_type_1])", + [ + { + "primary_keys": [mock.ANY], + "type_1": ["manager", "principal"], + } + ], + ), + ) + + @testing.variation("given_type", ["none", "invalid", "valid"]) + def test_no_instantiate(self, given_type): + Technologist = self.classes.Technologist + + with expect_raises_message( + exc.InvalidRequestError, + r"Can't instantiate class for Mapper\[Technologist\(employee\)\]; " + r"mapper is marked polymorphic_abstract=True", + ): + if given_type.none: + Technologist() + elif given_type.invalid: + Technologist(type="madeup") + elif given_type.valid: + Technologist(type="engineer") + else: + given_type.fail() + + def test_not_supported_wo_poly_inheriting(self, decl_base): + class MyClass(decl_base): + __tablename__ = "my_table" + + id: Mapped[int] = mapped_column(primary_key=True) + + with expect_raises_message( + exc.InvalidRequestError, + "The Mapper.polymorphic_abstract parameter may only be used " + "on a mapper hierarchy which includes the Mapper.polymorphic_on", + ): + + class Nope(MyClass): + __mapper_args__ = {"polymorphic_abstract": True} + + def test_not_supported_wo_poly_base(self, decl_base): + with expect_raises_message( + exc.InvalidRequestError, + "The Mapper.polymorphic_abstract parameter may only be used " + "on a mapper hierarchy which includes the Mapper.polymorphic_on", + ): + + class Nope(decl_base): + __tablename__ = "my_table" + + id: Mapped[int] = mapped_column(primary_key=True) + __mapper_args__ = {"polymorphic_abstract": True}