]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
split ReadOnlyColumnCollection from writeable methods
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 29 Jan 2026 22:56:30 +0000 (17:56 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 30 Jan 2026 14:59:36 +0000 (09:59 -0500)
The :class:`.ColumnCollection` class hierarchy has been refactored to allow
column names such as ``add``, ``remove``, ``update``, ``extend``, and
``clear`` to be used without conflicts. :class:`.ColumnCollection` is now
an abstract base class, with mutation operations moved to
:class:`.WriteableColumnCollection` and :class:`.DedupeColumnCollection`
subclasses. The :class:`.ReadOnlyColumnCollection` exposed as attributes
such as :attr:`.Table.c` no longer includes mutation methods that raised
:class:`.NotImplementedError`, allowing these common column names to be
accessed naturally, e.g. ``table.c.add``, ``table.c.remove``,
``table.c.update``, etc.

Change-Id: I22da8314fe7c451003e948d774040d86901bbca4

doc/build/changelog/unreleased_21/colcollection.rst [new file with mode: 0644]
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/util.py
lib/sqlalchemy/sql/__init__.py
lib/sqlalchemy/sql/base.py
lib/sqlalchemy/sql/dml.py
lib/sqlalchemy/sql/expression.py
lib/sqlalchemy/sql/functions.py
lib/sqlalchemy/sql/selectable.py
test/base/test_utils.py
test/sql/test_metadata.py

diff --git a/doc/build/changelog/unreleased_21/colcollection.rst b/doc/build/changelog/unreleased_21/colcollection.rst
new file mode 100644 (file)
index 0000000..3a74840
--- /dev/null
@@ -0,0 +1,13 @@
+.. change::
+    :tags: usecase, sql
+
+    The :class:`.ColumnCollection` class hierarchy has been refactored to allow
+    column names such as ``add``, ``remove``, ``update``, ``extend``, and
+    ``clear`` to be used without conflicts. :class:`.ColumnCollection` is now
+    an abstract base class, with mutation operations moved to
+    :class:`.WriteableColumnCollection` and :class:`.DedupeColumnCollection`
+    subclasses. The :class:`.ReadOnlyColumnCollection` exposed as attributes
+    such as :attr:`.Table.c` no longer includes mutation methods that raised
+    :class:`.NotImplementedError`, allowing these common column names to be
+    accessed naturally, e.g. ``table.c.add``, ``table.c.remove``,
+    ``table.c.update``, etc.
index df3961ecd4931e4e0e7815c98c2f219fa8eb60e7..22528a4240261e1d5c8338b3c6762a6a1eb4acfe 100644 (file)
@@ -1133,25 +1133,7 @@ class Mapper(
 
     """
 
-    columns: ReadOnlyColumnCollection[str, Column[Any]]
-    """A collection of :class:`_schema.Column` or other scalar expression
-    objects maintained by this :class:`_orm.Mapper`.
-
-    The collection behaves the same as that of the ``c`` attribute on
-    any :class:`_schema.Table` object,
-    except that only those columns included in
-    this mapping are present, and are keyed based on the attribute name
-    defined in the mapping, not necessarily the ``key`` attribute of the
-    :class:`_schema.Column` itself.   Additionally, scalar expressions mapped
-    by :func:`.column_property` are also present here.
-
-    This is a *read only* attribute determined during mapper construction.
-    Behavior is undefined if directly modified.
-
-    """
-
-    c: ReadOnlyColumnCollection[str, Column[Any]]
-    """A synonym for :attr:`_orm.Mapper.columns`."""
+    _columns: sql_base.WriteableColumnCollection[str, Column[Any]]
 
     @util.memoized_property
     def _path_registry(self) -> _CachingEntityRegistry:
@@ -1654,7 +1636,7 @@ class Mapper(
         }
 
     def _configure_properties(self) -> None:
-        self.columns = self.c = sql_base.ColumnCollection()  # type: ignore
+        self._columns = sql_base.WriteableColumnCollection()
 
         # object attribute names mapped to MapperProperty objects
         self._props = util.OrderedDict()
@@ -2108,7 +2090,7 @@ class Mapper(
                 # to be addressable in subqueries
                 col.key = col._tq_key_label = key
 
-            self.columns.add(col, key)
+            self._columns.add(col, key)
 
             for col in prop.columns:
                 for proxy_col in col.proxy_set:
@@ -2477,6 +2459,29 @@ class Mapper(
 
         return self._columntoproperty[column]
 
+    @HasMemoized.memoized_attribute
+    def columns(self) -> ReadOnlyColumnCollection[str, Column[Any]]:
+        """A collection of :class:`_schema.Column` or other scalar expression
+        objects maintained by this :class:`_orm.Mapper`.
+
+        The collection behaves the same as that of the ``c`` attribute on any
+        :class:`_schema.Table` object, except that only those columns included
+        in this mapping are present, and are keyed based on the attribute name
+        defined in the mapping, not necessarily the ``key`` attribute of the
+        :class:`_schema.Column` itself.   Additionally, scalar expressions
+        mapped by :func:`.column_property` are also present here.
+
+        This is a *read only* attribute determined during mapper construction.
+        Behavior is undefined if directly modified.
+
+        """
+        return self._columns.as_readonly()
+
+    @HasMemoized.memoized_attribute
+    def c(self) -> ReadOnlyColumnCollection[str, Column[Any]]:
+        """A synonym for :attr:`_orm.Mapper.columns`."""
+        return self._columns.as_readonly()
+
     @property
     def iterate_properties(self):
         """return an iterator of all MapperProperty objects."""
index ce6c750c5b96350952075d5b835c053ebf770981..16de51cef6a02391dc794c87d3b6399bdc1275c1 100644 (file)
@@ -81,7 +81,7 @@ from ..sql import util as sql_util
 from ..sql import visitors
 from ..sql._typing import is_selectable
 from ..sql.annotation import SupportsCloneAnnotations
-from ..sql.base import ColumnCollection
+from ..sql.base import WriteableColumnCollection
 from ..sql.cache_key import HasCacheKey
 from ..sql.cache_key import MemoizedHasCacheKey
 from ..sql.elements import ColumnElement
@@ -1263,7 +1263,7 @@ class AliasedInsp(
             (key, self._adapt_element(col)) for key, col in cols_plus_keys
         ]
 
-        return ColumnCollection(cols_plus_keys)
+        return WriteableColumnCollection(cols_plus_keys)
 
     def _memo(self, key, callable_, *args, **kw):
         if key in self._memoized_values:
@@ -1608,7 +1608,7 @@ class Bundle(
         ]
         self.exprs = coerced_exprs
 
-        self.c = self.columns = ColumnCollection(
+        self.c = self.columns = WriteableColumnCollection(
             (getattr(col, "key", col._label), col)
             for col in [e._annotations.get("bundle", e) for e in coerced_exprs]
         ).as_readonly()
index 7e5287051d6ac712b472059a12eb644ba398a300..ae4d32e36ac9495076be9efb9f961d5acdaac691 100644 (file)
@@ -113,6 +113,7 @@ from .expression import update as update
 from .expression import Values as Values
 from .expression import values as values
 from .expression import within_group as within_group
+from .expression import WriteableColumnCollection as WriteableColumnCollection
 from .visitors import ClauseVisitor as ClauseVisitor
 
 
index 8ffa489608d3e136d056033bf4e8f2132f6bc37f..47fefe239629c074f688af99a46fcefe1b138a7f 100644 (file)
@@ -1702,9 +1702,8 @@ class _ColumnMetrics(Generic[_COL_co]):
 
 
 class ColumnCollection(Generic[_COLKEY, _COL_co]):
-    """Collection of :class:`_expression.ColumnElement` instances,
-    typically for
-    :class:`_sql.FromClause` objects.
+    """Base class for collection of :class:`_expression.ColumnElement`
+    instances, typically for :class:`_sql.FromClause` objects.
 
     The :class:`_sql.ColumnCollection` object is most commonly available
     as the :attr:`_schema.Table.c` or :attr:`_schema.Table.columns` collection
@@ -1771,34 +1770,16 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
         [Column('x', Integer(), table=None),
          Column('y', Integer(), table=None)]
 
-    The base :class:`_expression.ColumnCollection` object can store
-    duplicates, which can
-    mean either two columns with the same key, in which case the column
-    returned by key  access is **arbitrary**::
-
-        >>> x1, x2 = Column("x", Integer), Column("x", Integer)
-        >>> cc = ColumnCollection(columns=[(x1.name, x1), (x2.name, x2)])
-        >>> list(cc)
-        [Column('x', Integer(), table=None),
-         Column('x', Integer(), table=None)]
-        >>> cc["x"] is x1
-        False
-        >>> cc["x"] is x2
-        True
-
-    Or it can also mean the same column multiple times.   These cases are
-    supported as :class:`_expression.ColumnCollection`
-    is used to represent the columns in
-    a SELECT statement which may include duplicates.
-
-    A special subclass :class:`.DedupeColumnCollection` exists which instead
+    The :class:`_expression.ColumnCollection` base class is read-only.
+    For mutation operations, the :class:`.WriteableColumnCollection` subclass
+    provides methods such as :meth:`.WriteableColumnCollection.add`.
+    A special subclass :class:`.DedupeColumnCollection` exists which
     maintains SQLAlchemy's older behavior of not allowing duplicates; this
     collection is used for schema level objects like :class:`_schema.Table`
-    and
-    :class:`.PrimaryKeyConstraint` where this deduping is helpful.  The
-    :class:`.DedupeColumnCollection` class also has additional mutation methods
-    as the schema constructs have more use cases that require removal and
-    replacement of columns.
+    and :class:`.PrimaryKeyConstraint` where this deduping is helpful.
+    The :class:`.DedupeColumnCollection` class also has additional mutation
+    methods as the schema constructs have more use cases that require removal
+    and replacement of columns.
 
     .. versionchanged:: 1.4 :class:`_expression.ColumnCollection`
        now stores duplicate
@@ -1807,6 +1788,11 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
        former behavior in those cases where deduplication as well as
        additional replace/remove operations are needed.
 
+    .. versionchanged:: 2.1 :class:`_expression.ColumnCollection` is now
+       a read-only base class. Mutation operations are available through
+       :class:`.WriteableColumnCollection` and :class:`.DedupeColumnCollection`
+       subclasses.
+
 
     """
 
@@ -1814,20 +1800,15 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
 
     _collection: List[Tuple[_COLKEY, _COL_co, _ColumnMetrics[_COL_co]]]
     _index: Dict[Union[None, str, int], Tuple[_COLKEY, _COL_co]]
-    _proxy_index: Dict[ColumnElement[Any], Set[_ColumnMetrics[_COL_co]]]
     _colset: Set[_COL_co]
+    _proxy_index: Dict[ColumnElement[Any], Set[_ColumnMetrics[_COL_co]]]
 
-    def __init__(
-        self, columns: Optional[Iterable[Tuple[_COLKEY, _COL_co]]] = None
-    ):
-        object.__setattr__(self, "_colset", set())
-        object.__setattr__(self, "_index", {})
-        object.__setattr__(
-            self, "_proxy_index", collections.defaultdict(util.OrderedSet)
+    def __init__(self) -> None:
+        raise TypeError(
+            "ColumnCollection is an abstract base class and cannot be "
+            "instantiated directly. Use WriteableColumnCollection or "
+            "DedupeColumnCollection instead."
         )
-        object.__setattr__(self, "_collection", [])
-        if columns:
-            self._initial_populate(columns)
 
     @util.preload_module("sqlalchemy.sql.elements")
     def __clause_element__(self) -> ClauseList:
@@ -1839,11 +1820,6 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
             *self._all_columns,
         )
 
-    def _initial_populate(
-        self, iter_: Iterable[Tuple[_COLKEY, _COL_co]]
-    ) -> None:
-        self._populate_separate_keys(iter_)
-
     @property
     def _all_columns(self) -> List[_COL_co]:
         return [col for (_, col, _) in self._collection]
@@ -1904,7 +1880,7 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
                 else:
                     cols = (self._index[sub_key] for sub_key in key)
 
-                return ColumnCollection(cols).as_readonly()
+                return WriteableColumnCollection(cols).as_readonly()
             else:
                 return self._index[key][1]
         except KeyError as err:
@@ -1966,30 +1942,93 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
             ", ".join(str(c) for c in self),
         )
 
-    def __setitem__(self, key: str, value: Any) -> NoReturn:
-        raise NotImplementedError()
+    # https://github.com/python/mypy/issues/4266
+    __hash__: Optional[int] = None  # type: ignore
 
-    def __delitem__(self, key: str) -> NoReturn:
-        raise NotImplementedError()
+    def contains_column(self, col: ColumnElement[Any]) -> bool:
+        """Checks if a column object exists in this collection"""
+        if col not in self._colset:
+            if isinstance(col, str):
+                raise exc.ArgumentError(
+                    "contains_column cannot be used with string arguments. "
+                    "Use ``col_name in table.c`` instead."
+                )
+            return False
+        else:
+            return True
 
-    def __setattr__(self, key: str, obj: Any) -> NoReturn:
+    def _as_readonly(self) -> ReadOnlyColumnCollection[_COLKEY, _COL_co]:
         raise NotImplementedError()
 
-    def clear(self) -> NoReturn:
-        """Dictionary clear() is not implemented for
-        :class:`_sql.ColumnCollection`."""
-        raise NotImplementedError()
+    def corresponding_column(
+        self, column: _COL, require_embedded: bool = False
+    ) -> Optional[Union[_COL, _COL_co]]:
+        """Given a :class:`_expression.ColumnElement`, return the exported
+        :class:`_expression.ColumnElement` object from this
+        :class:`_expression.ColumnCollection`
+        which corresponds to that original :class:`_expression.ColumnElement`
+        via a common
+        ancestor column.
 
-    def remove(self, column: Any) -> NoReturn:
-        raise NotImplementedError()
+        :param column: the target :class:`_expression.ColumnElement`
+                      to be matched.
 
-    def update(self, iter_: Any) -> NoReturn:
-        """Dictionary update() is not implemented for
-        :class:`_sql.ColumnCollection`."""
+        :param require_embedded: only return corresponding columns for
+         the given :class:`_expression.ColumnElement`, if the given
+         :class:`_expression.ColumnElement`
+         is actually present within a sub-element
+         of this :class:`_expression.Selectable`.
+         Normally the column will match if
+         it merely shares a common ancestor with one of the exported
+         columns of this :class:`_expression.Selectable`.
+
+        .. seealso::
+
+            :meth:`_expression.Selectable.corresponding_column`
+            - invokes this method
+            against the collection returned by
+            :attr:`_expression.Selectable.exported_columns`.
+
+        .. versionchanged:: 1.4 the implementation for ``corresponding_column``
+           was moved onto the :class:`_expression.ColumnCollection` itself.
+
+        """
         raise NotImplementedError()
 
-    # https://github.com/python/mypy/issues/4266
-    __hash__: Optional[int] = None  # type: ignore
+
+class WriteableColumnCollection(ColumnCollection[_COLKEY, _COL_co]):
+    """A :class:`_sql.ColumnCollection` that allows mutation operations.
+
+    This is the writable form of :class:`_sql.ColumnCollection` that
+    implements methods such as :meth:`.add`, :meth:`.remove`, :meth:`.update`,
+    and :meth:`.clear`.
+
+    This class is used internally for building column collections during
+    construction of SQL constructs. For schema-level objects that require
+    deduplication behavior, use :class:`.DedupeColumnCollection`.
+
+    .. versionadded:: 2.1
+
+    """
+
+    __slots__ = ()
+
+    def __init__(
+        self, columns: Optional[Iterable[Tuple[_COLKEY, _COL_co]]] = None
+    ):
+        object.__setattr__(self, "_colset", set())
+        object.__setattr__(self, "_index", {})
+        object.__setattr__(
+            self, "_proxy_index", collections.defaultdict(util.OrderedSet)
+        )
+        object.__setattr__(self, "_collection", [])
+        if columns:
+            self._initial_populate(columns)
+
+    def _initial_populate(
+        self, iter_: Iterable[Tuple[_COLKEY, _COL_co]]
+    ) -> None:
+        self._populate_separate_keys(iter_)
 
     def _populate_separate_keys(
         self, iter_: Iterable[Tuple[_COLKEY, _COL_co]]
@@ -2005,18 +2044,41 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
         )
         self._index.update({k: (k, col) for k, col, _ in reversed(collection)})
 
+    def __getstate__(self) -> Dict[str, Any]:
+        return {
+            "_collection": [(k, c) for k, c, _ in self._collection],
+            "_index": self._index,
+        }
+
+    def __setstate__(self, state: Dict[str, Any]) -> None:
+        object.__setattr__(self, "_index", state["_index"])
+        object.__setattr__(
+            self, "_proxy_index", collections.defaultdict(util.OrderedSet)
+        )
+        object.__setattr__(
+            self,
+            "_collection",
+            [
+                (k, c, _ColumnMetrics(self, c))
+                for (k, c) in state["_collection"]
+            ],
+        )
+        object.__setattr__(
+            self, "_colset", {col for k, col, _ in self._collection}
+        )
+
     def add(
         self,
         column: ColumnElement[Any],
         key: Optional[_COLKEY] = None,
     ) -> None:
-        """Add a column to this :class:`_sql.ColumnCollection`.
+        """Add a column to this :class:`_sql.WriteableColumnCollection`.
 
         .. note::
 
             This method is **not normally used by user-facing code**, as the
-            :class:`_sql.ColumnCollection` is usually part of an existing
-            object such as a :class:`_schema.Table`. To add a
+            :class:`_sql.WriteableColumnCollection` is usually part of an
+            existing object such as a :class:`_schema.Table`. To add a
             :class:`_schema.Column` to an existing :class:`_schema.Table`
             object, use the :meth:`_schema.Table.append_column` method.
 
@@ -2044,46 +2106,14 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
         if colkey not in self._index:
             self._index[colkey] = (colkey, _column)
 
-    def __getstate__(self) -> Dict[str, Any]:
-        return {
-            "_collection": [(k, c) for k, c, _ in self._collection],
-            "_index": self._index,
-        }
-
-    def __setstate__(self, state: Dict[str, Any]) -> None:
-        object.__setattr__(self, "_index", state["_index"])
-        object.__setattr__(
-            self, "_proxy_index", collections.defaultdict(util.OrderedSet)
-        )
-        object.__setattr__(
-            self,
-            "_collection",
-            [
-                (k, c, _ColumnMetrics(self, c))
-                for (k, c) in state["_collection"]
-            ],
-        )
-        object.__setattr__(
-            self, "_colset", {col for k, col, _ in self._collection}
-        )
-
-    def contains_column(self, col: ColumnElement[Any]) -> bool:
-        """Checks if a column object exists in this collection"""
-        if col not in self._colset:
-            if isinstance(col, str):
-                raise exc.ArgumentError(
-                    "contains_column cannot be used with string arguments. "
-                    "Use ``col_name in table.c`` instead."
-                )
-            return False
-        else:
-            return True
+    def _as_readonly(self) -> ReadOnlyColumnCollection[_COLKEY, _COL_co]:
+        return ReadOnlyColumnCollection(self)
 
     def as_readonly(self) -> ReadOnlyColumnCollection[_COLKEY, _COL_co]:
         """Return a "read only" form of this
-        :class:`_sql.ColumnCollection`."""
+        :class:`_sql.WriteableColumnCollection`."""
 
-        return ReadOnlyColumnCollection(self)
+        return self._as_readonly()
 
     def _init_proxy_index(self) -> None:
         """populate the "proxy index", if empty.
@@ -2121,27 +2151,8 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
         via a common
         ancestor column.
 
-        :param column: the target :class:`_expression.ColumnElement`
-                      to be matched.
-
-        :param require_embedded: only return corresponding columns for
-         the given :class:`_expression.ColumnElement`, if the given
-         :class:`_expression.ColumnElement`
-         is actually present within a sub-element
-         of this :class:`_expression.Selectable`.
-         Normally the column will match if
-         it merely shares a common ancestor with one of the exported
-         columns of this :class:`_expression.Selectable`.
-
-        .. seealso::
-
-            :meth:`_expression.Selectable.corresponding_column`
-            - invokes this method
-            against the collection returned by
-            :attr:`_expression.Selectable.exported_columns`.
-
-        .. versionchanged:: 1.4 the implementation for ``corresponding_column``
-           was moved onto the :class:`_expression.ColumnCollection` itself.
+        See :meth:`.ColumnCollection.corresponding_column` for parameter
+        information.
 
         """
         # TODO: cython candidate
@@ -2221,7 +2232,7 @@ class ColumnCollection(Generic[_COLKEY, _COL_co]):
 _NAMEDCOL = TypeVar("_NAMEDCOL", bound="NamedColumn[Any]")
 
 
-class DedupeColumnCollection(ColumnCollection[str, _NAMEDCOL]):
+class DedupeColumnCollection(WriteableColumnCollection[str, _NAMEDCOL]):
     """A :class:`_expression.ColumnCollection`
     that maintains deduplicating behavior.
 
@@ -2329,7 +2340,7 @@ class DedupeColumnCollection(ColumnCollection[str, _NAMEDCOL]):
     def extend(self, iter_: Iterable[_NAMEDCOL]) -> None:
         self._populate_separate_keys((col.key, col) for col in iter_)
 
-    def remove(self, column: _NAMEDCOL) -> None:  # type: ignore[override]
+    def remove(self, column: _NAMEDCOL) -> None:
         if column not in self._colset:
             raise ValueError(
                 "Can't remove column %r; column is not in this collection"
@@ -2447,28 +2458,42 @@ class ReadOnlyColumnCollection(
 ):
     __slots__ = ("_parent",)
 
-    def __init__(self, collection: ColumnCollection[_COLKEY, _COL_co]):
+    _parent: WriteableColumnCollection[_COLKEY, _COL_co]
+
+    def __init__(
+        self, collection: WriteableColumnCollection[_COLKEY, _COL_co]
+    ):
         object.__setattr__(self, "_parent", collection)
-        object.__setattr__(self, "_colset", collection._colset)
         object.__setattr__(self, "_index", collection._index)
         object.__setattr__(self, "_collection", collection._collection)
+        object.__setattr__(self, "_colset", collection._colset)
         object.__setattr__(self, "_proxy_index", collection._proxy_index)
 
-    def __getstate__(self) -> Dict[str, _COL_co]:
+    def _as_readonly(self) -> ReadOnlyColumnCollection[_COLKEY, _COL_co]:
+        return self
+
+    def __getstate__(self) -> Dict[str, ColumnCollection[_COLKEY, _COL_co]]:
         return {"_parent": self._parent}
 
     def __setstate__(self, state: Dict[str, Any]) -> None:
         parent = state["_parent"]
         self.__init__(parent)  # type: ignore
 
-    def add(self, column: Any, key: Any = ...) -> Any:
-        self._readonly()
+    def corresponding_column(
+        self, column: _COL, require_embedded: bool = False
+    ) -> Optional[Union[_COL, _COL_co]]:
+        """Given a :class:`_expression.ColumnElement`, return the exported
+        :class:`_expression.ColumnElement` object from this
+        :class:`_expression.ColumnCollection`
+        which corresponds to that original :class:`_expression.ColumnElement`
+        via a common
+        ancestor column.
 
-    def extend(self, elements: Any) -> NoReturn:
-        self._readonly()
+        See :meth:`.ColumnCollection.corresponding_column` for parameter
+        information.
 
-    def remove(self, item: Any) -> NoReturn:
-        self._readonly()
+        """
+        return self._parent.corresponding_column(column, require_embedded)
 
 
 class ColumnSet(util.OrderedSet["ColumnClause[Any]"]):
index adad1ebda9fbbbe5893b2a3d6c572a93fcbb0b9c..13eb81e2fc745e928b6962186bb4f3e33405acf0 100644 (file)
@@ -43,7 +43,6 @@ from .base import _exclusive_against
 from .base import _from_objects
 from .base import _generative
 from .base import _select_iterables
-from .base import ColumnCollection
 from .base import ColumnSet
 from .base import CompileState
 from .base import DialectKWArgs
@@ -53,6 +52,7 @@ from .base import Generative
 from .base import HasCompileState
 from .base import HasSyntaxExtensions
 from .base import SyntaxExtension
+from .base import WriteableColumnCollection
 from .elements import BooleanClauseList
 from .elements import ClauseElement
 from .elements import ColumnClause
@@ -427,7 +427,7 @@ class UpdateBase(
     def _generate_fromclause_column_proxies(
         self,
         fromclause: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -857,7 +857,7 @@ class UpdateBase(
         .. versionadded:: 1.4
 
         """
-        return ColumnCollection(
+        return WriteableColumnCollection(
             (c.key, c)
             for c in self._all_selected_columns
             if is_column_element(c)
index 736195ce584486e458424050c5806b2afb5ff9d0..37bb6383a5c63bf4ed98fa9fe00c7b6f997f404e 100644 (file)
@@ -68,6 +68,7 @@ from .base import _select_iterables as _select_iterables
 from .base import ColumnCollection as ColumnCollection
 from .base import Executable as Executable
 from .base import ExecutableStatement as ExecutableStatement
+from .base import WriteableColumnCollection as WriteableColumnCollection
 from .cache_key import CacheKey as CacheKey
 from .dml import Delete as Delete
 from .dml import Insert as Insert
index da21d9a73515f890ca9c015ddd6c5339a39fca41..58451f320f8c48aa4efe4908d36cbba5ee4f1608 100644 (file)
@@ -39,6 +39,7 @@ from .base import ColumnCollection
 from .base import ExecutableStatement
 from .base import Generative
 from .base import HasMemoized
+from .base import WriteableColumnCollection
 from .elements import _type_from_args
 from .elements import AggregateOrderBy
 from .elements import BinaryExpression
@@ -421,7 +422,7 @@ class FunctionElement(
     def c(self) -> ColumnCollection[str, KeyedColumnElement[Any]]:  # type: ignore[override]  # noqa: E501
         """synonym for :attr:`.FunctionElement.columns`."""
 
-        return ColumnCollection(
+        return WriteableColumnCollection(
             columns=[(col.key, col) for col in self._all_selected_columns]
         )
 
index 43ba33547ac01d8367f8206e6eb309267c9b25d0..8a0673dc2af5043c2baeab3f9ad7fd4390514a65 100644 (file)
@@ -85,6 +85,7 @@ from .base import HasMemoized
 from .base import HasSyntaxExtensions
 from .base import Immutable
 from .base import SyntaxExtension
+from .base import WriteableColumnCollection
 from .coercions import _document_text_coercion
 from .elements import _anonymous_label
 from .elements import BindParameter
@@ -260,7 +261,7 @@ class ReturnsRows(roles.ReturnsRowsRole, DQLDMLClauseElement):
     def _generate_fromclause_column_proxies(
         self,
         fromclause: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -644,7 +645,7 @@ class FromClause(roles.AnonymizedFromClauseRole, Selectable):
 
     _is_clone_of: Optional[FromClause]
 
-    _columns: ColumnCollection[Any, Any]
+    _columns: WriteableColumnCollection[Any, Any]
 
     schema: Optional[str] = None
     """Define the 'schema' attribute for this :class:`_expression.FromClause`.
@@ -857,7 +858,7 @@ class FromClause(roles.AnonymizedFromClauseRole, Selectable):
     def _generate_fromclause_column_proxies(
         self,
         fromclause: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -930,7 +931,9 @@ class FromClause(roles.AnonymizedFromClauseRole, Selectable):
                 assert "foreign_keys" in self.__dict__
                 return
 
-            _columns: ColumnCollection[Any, Any] = ColumnCollection()
+            _columns: WriteableColumnCollection[Any, Any] = (
+                WriteableColumnCollection()
+            )
             primary_key = ColumnSet()
             foreign_keys: Set[KeyedColumnElement[Any]] = set()
 
@@ -1019,7 +1022,7 @@ class FromClause(roles.AnonymizedFromClauseRole, Selectable):
 
     def _populate_column_collection(
         self,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -1351,7 +1354,7 @@ class Join(roles.DMLTableRole, FromClause):
     @util.preload_module("sqlalchemy.sql.util")
     def _populate_column_collection(
         self,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -1756,7 +1759,7 @@ class AliasedReturnsRows(NoInit, NamedFromClause):
 
     def _populate_column_collection(
         self,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -2212,7 +2215,7 @@ class CTE(
 
     def _populate_column_collection(
         self,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -3487,7 +3490,7 @@ class Values(roles.InElementRole, HasCTE, Generative, LateralFromClause):
 
     def _populate_column_collection(
         self,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
     ) -> None:
@@ -3619,7 +3622,7 @@ class SelectBase(
     def _generate_fromclause_column_proxies(
         self,
         subquery: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
         *,
@@ -3669,7 +3672,7 @@ class SelectBase(
 
 
         """
-        return self.selected_columns.as_readonly()
+        return self.selected_columns._as_readonly()
 
     def get_label_style(self) -> SelectLabelStyle:
         """
@@ -3988,7 +3991,7 @@ class SelectStatementGrouping(GroupedElement, SelectBase, Generic[_SB]):
     def _generate_fromclause_column_proxies(
         self,
         subquery: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
         *,
@@ -4710,7 +4713,7 @@ class CompoundSelect(
     def _generate_fromclause_column_proxies(
         self,
         subquery: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
         *,
@@ -6778,12 +6781,14 @@ class Select(
             SelectState._column_naming_convention(self._label_style),
         )
 
-        cc: ColumnCollection[str, ColumnElement[Any]] = ColumnCollection(
-            [
-                (conv(c), c)
-                for c in self._all_selected_columns
-                if is_column_element(c)
-            ]
+        cc: WriteableColumnCollection[str, ColumnElement[Any]] = (
+            WriteableColumnCollection(
+                [
+                    (conv(c), c)
+                    for c in self._all_selected_columns
+                    if is_column_element(c)
+                ]
+            )
         )
         return cc.as_readonly()
 
@@ -6800,7 +6805,7 @@ class Select(
     def _generate_fromclause_column_proxies(
         self,
         subquery: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
         *,
@@ -7392,7 +7397,7 @@ class TextualSelect(SelectBase, ExecutableReturnsRows, Generative):
         .. versionadded:: 1.4
 
         """
-        return ColumnCollection(
+        return WriteableColumnCollection(
             (c.key, c) for c in self.column_args
         ).as_readonly()
 
@@ -7418,7 +7423,7 @@ class TextualSelect(SelectBase, ExecutableReturnsRows, Generative):
     def _generate_fromclause_column_proxies(
         self,
         fromclause: FromClause,
-        columns: ColumnCollection[str, KeyedColumnElement[Any]],
+        columns: WriteableColumnCollection[str, KeyedColumnElement[Any]],
         primary_key: ColumnSet,
         foreign_keys: Set[KeyedColumnElement[Any]],
         *,
index 391bbf75d6dcfc05d82b06c9f6fecb14a43ebdf6..cd130d0c27896bf57c17c54045b1a8fd71c88aad 100644 (file)
@@ -962,7 +962,7 @@ class ColumnCollectionCommon(testing.AssertsCompiledSQL):
 
 class ColumnCollectionTest(ColumnCollectionCommon, fixtures.TestBase):
     def _column_collection(self, columns=None):
-        return sql.ColumnCollection(columns=columns)
+        return sql.WriteableColumnCollection(columns=columns)
 
     def test_separate_key_all_cols(self):
         c1, c2 = sql.column("col1"), sql.column("col2")
@@ -994,7 +994,7 @@ class ColumnCollectionTest(ColumnCollectionCommon, fixtures.TestBase):
             column("c2"),
         )
 
-        cc = sql.ColumnCollection()
+        cc = sql.WriteableColumnCollection()
 
         cc.add(c1)
         cc.add(c2a, "c2")
@@ -1027,7 +1027,7 @@ class ColumnCollectionTest(ColumnCollectionCommon, fixtures.TestBase):
             column("c2"),
         )
 
-        cc = sql.ColumnCollection(
+        cc = sql.WriteableColumnCollection(
             columns=[("c1", c1), ("c2", c2a), ("c3", c3), ("c2", c2b)]
         )
 
@@ -1052,7 +1052,7 @@ class ColumnCollectionTest(ColumnCollectionCommon, fixtures.TestBase):
     def test_identical_dupe_construct(self):
         c1, c2, c3 = (column("c1"), column("c2"), column("c3"))
 
-        cc = sql.ColumnCollection(
+        cc = sql.WriteableColumnCollection(
             columns=[("c1", c1), ("c2", c2), ("c3", c3), ("c2", c2)]
         )
 
index 166e1597b9e288dd0f534d61f10d9943359b930a..c2b2cf0dfbc7be1ea8143e861a5b2cb750f1a2d9 100644 (file)
@@ -1971,10 +1971,28 @@ class TableTest(fixtures.TestBase, AssertsCompiledSQL):
             {fk1.constraint, fk2.constraint, fk3},
         )
 
+    def test_c_mutator_names_ok(self):
+        m = MetaData()
+        t1 = Table(
+            "t",
+            m,
+            Column("add", Integer),
+            Column("remove", Integer),
+            Column("clear", Integer),
+            Column("extend", Integer),
+        )
+        self.assert_compile(
+            select(t1.c.add, t1.c.remove, t1.c.clear, t1.c.extend),
+            'SELECT t."add", t.remove, t.clear, t.extend FROM t',
+        )
+
     def test_c_immutable(self):
         m = MetaData()
         t1 = Table("t", m, Column("x", Integer), Column("y", Integer))
-        assert_raises(TypeError, t1.c.extend, [Column("z", Integer)])
+
+        # extend() method doesn't exist on readonly collections
+        # to allow columns named 'extend'
+        assert_raises(AttributeError, lambda: t1.c.extend)
 
         def assign():
             t1.c["z"] = Column("z", Integer)