From: Mike Bayer Date: Thu, 29 Jan 2026 22:56:30 +0000 (-0500) Subject: split ReadOnlyColumnCollection from writeable methods X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=eff50d9e9b1261f987850604bfa94334744ed594;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git split ReadOnlyColumnCollection from writeable methods 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 --- diff --git a/doc/build/changelog/unreleased_21/colcollection.rst b/doc/build/changelog/unreleased_21/colcollection.rst new file mode 100644 index 0000000000..3a74840121 --- /dev/null +++ b/doc/build/changelog/unreleased_21/colcollection.rst @@ -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. diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index df3961ecd4..22528a4240 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -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.""" diff --git a/lib/sqlalchemy/orm/util.py b/lib/sqlalchemy/orm/util.py index ce6c750c5b..16de51cef6 100644 --- a/lib/sqlalchemy/orm/util.py +++ b/lib/sqlalchemy/orm/util.py @@ -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() diff --git a/lib/sqlalchemy/sql/__init__.py b/lib/sqlalchemy/sql/__init__.py index 7e5287051d..ae4d32e36a 100644 --- a/lib/sqlalchemy/sql/__init__.py +++ b/lib/sqlalchemy/sql/__init__.py @@ -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 diff --git a/lib/sqlalchemy/sql/base.py b/lib/sqlalchemy/sql/base.py index 8ffa489608..47fefe2396 100644 --- a/lib/sqlalchemy/sql/base.py +++ b/lib/sqlalchemy/sql/base.py @@ -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]"]): diff --git a/lib/sqlalchemy/sql/dml.py b/lib/sqlalchemy/sql/dml.py index adad1ebda9..13eb81e2fc 100644 --- a/lib/sqlalchemy/sql/dml.py +++ b/lib/sqlalchemy/sql/dml.py @@ -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) diff --git a/lib/sqlalchemy/sql/expression.py b/lib/sqlalchemy/sql/expression.py index 736195ce58..37bb6383a5 100644 --- a/lib/sqlalchemy/sql/expression.py +++ b/lib/sqlalchemy/sql/expression.py @@ -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 diff --git a/lib/sqlalchemy/sql/functions.py b/lib/sqlalchemy/sql/functions.py index da21d9a735..58451f320f 100644 --- a/lib/sqlalchemy/sql/functions.py +++ b/lib/sqlalchemy/sql/functions.py @@ -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] ) diff --git a/lib/sqlalchemy/sql/selectable.py b/lib/sqlalchemy/sql/selectable.py index 43ba33547a..8a0673dc2a 100644 --- a/lib/sqlalchemy/sql/selectable.py +++ b/lib/sqlalchemy/sql/selectable.py @@ -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]], *, diff --git a/test/base/test_utils.py b/test/base/test_utils.py index 391bbf75d6..cd130d0c27 100644 --- a/test/base/test_utils.py +++ b/test/base/test_utils.py @@ -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)] ) diff --git a/test/sql/test_metadata.py b/test/sql/test_metadata.py index 166e1597b9..c2b2cf0dfb 100644 --- a/test/sql/test_metadata.py +++ b/test/sql/test_metadata.py @@ -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)