]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
implement polymorphic_abstract=True feature
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 13 Jan 2023 22:24:14 +0000 (17:24 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 14 Jan 2023 21:45:01 +0000 (16:45 -0500)
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

doc/build/changelog/unreleased_20/9060.rst [new file with mode: 0644]
doc/build/orm/declarative_config.rst
doc/build/orm/inheritance.rst
lib/sqlalchemy/ext/declarative/extensions.py
lib/sqlalchemy/orm/mapper.py
test/ext/declarative/test_inheritance.py
test/orm/declarative/test_inheritance.py
test/orm/inheritance/test_basic.py
test/orm/inheritance/test_single.py

diff --git a/doc/build/changelog/unreleased_20/9060.rst b/doc/build/changelog/unreleased_20/9060.rst
new file mode 100644 (file)
index 0000000..85bdf01
--- /dev/null
@@ -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`
+
index 7653f9192ac9c5524eb3a37419f72cefe74ec74f..c7464580b72a516a9ea7636e389a28b017cdc3d3 100644 (file)
@@ -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__``
 ~~~~~~~~~~~~~~~~~
 
index 01067685ec0038dbe829d8dc24cb8e60a6f9974d..d35967e4ca5086f1f8973dee67a66584c21e7a95 100644 (file)
@@ -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
 +++++++++++++++++++++++++++++++++++
index f5bae0695a60f7bade223202adaf38dfbe166a01..2cb55a5ae8835dd9bc4996b68f450bfe8d85a77a 100644 (file)
@@ -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)
index 20ad635b0f44c86e06391b620139a0e20fff24bb..9c2c9acf1e5f8cb5f06f2c4dbd90ae9a6f34e053 100644 (file)
@@ -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
 
index dcfb3f85021f495f6ffee1a864dbb0dc3a73dc19..2aa0f2cc312507fa04a5621a0c0da3766084f330 100644 (file)
@@ -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
index f3506a3100adc885e715f65e364ccf6124af87ad..02d2852972003fefadf2afa26e97d65a0d2a3aa9 100644 (file)
@@ -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):
 
index 4e9107d1c46958568d278921533cb77d72799c0b..905f0c50d6b681e2bdf711a8f1af8d59eb2d6d3f 100644 (file)
@@ -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):
index eb8d0c01a4466034901bd4a9c318e98962ca7bf4..6ede1b05903c71892f466b0f6fbba8816867cbae 100644 (file)
@@ -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}