SQLAlchemy 2.1.6__py3-none-any.whl → 2.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. sqlalchemy/__init__.py +1 -1
  2. sqlalchemy/dialects/mssql/base.py +10 -18
  3. sqlalchemy/dialects/oracle/base.py +1 -1
  4. sqlalchemy/dialects/postgresql/array.py +3 -4
  5. sqlalchemy/dialects/postgresql/base.py +6 -3
  6. sqlalchemy/engine/base.py +40 -36
  7. sqlalchemy/engine/url.py +2 -1
  8. sqlalchemy/ext/asyncio/session.py +71 -0
  9. sqlalchemy/inspection.py +2 -2
  10. sqlalchemy/orm/context.py +24 -0
  11. sqlalchemy/orm/mapper.py +33 -0
  12. sqlalchemy/orm/persistence.py +35 -10
  13. sqlalchemy/orm/query.py +3 -3
  14. sqlalchemy/orm/relationships.py +22 -6
  15. sqlalchemy/orm/session.py +101 -18
  16. sqlalchemy/orm/strategies.py +25 -13
  17. sqlalchemy/orm/strategy_options.py +10 -4
  18. sqlalchemy/orm/util.py +7 -9
  19. sqlalchemy/pool/base.py +18 -2
  20. sqlalchemy/sql/_annotated_cols.py +3 -3
  21. sqlalchemy/sql/_elements_constructors.py +7 -6
  22. sqlalchemy/sql/_typing.py +21 -0
  23. sqlalchemy/sql/compiler.py +17 -13
  24. sqlalchemy/sql/elements.py +65 -16
  25. sqlalchemy/sql/functions.py +4 -3
  26. sqlalchemy/sql/operators.py +2 -2
  27. sqlalchemy/sql/schema.py +70 -4
  28. sqlalchemy/sql/sqltypes.py +3 -4
  29. sqlalchemy/sql/type_api.py +16 -3
  30. sqlalchemy/util/compat.py +36 -0
  31. sqlalchemy/util/queue.py +4 -8
  32. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/METADATA +1 -1
  33. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/RECORD +37 -37
  34. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/WHEEL +0 -0
  35. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/licenses/AUTHORS +0 -0
  36. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/licenses/LICENSE +0 -0
  37. {sqlalchemy-2.1.6.dist-info → sqlalchemy-2.1.8.dist-info}/top_level.txt +0 -0
sqlalchemy/__init__.py CHANGED
@@ -285,7 +285,7 @@ from .types import Uuid as Uuid
285
285
  from .types import VARBINARY as VARBINARY
286
286
  from .types import VARCHAR as VARCHAR
287
287
 
288
- __version__ = "2.1.6"
288
+ __version__ = "2.1.8"
289
289
 
290
290
 
291
291
  def __go(lcls: Any) -> None:
@@ -3553,35 +3553,27 @@ class MSDialect(default._BackendsMultiReflection, default.DefaultDialect):
3553
3553
  else:
3554
3554
  raise exc.NoSuchTableError(f"{owner}.{viewname}")
3555
3555
 
3556
- def _temp_table_name_like_pattern(self, tablename):
3557
- # LIKE uses '%' to match zero or more characters and '_' to match any
3558
- # single character. We want to match literal underscores, so T-SQL
3559
- # requires that we enclose them in square brackets.
3560
- return tablename + (
3561
- ("[_][_][_]%") if not tablename.startswith("##") else ""
3562
- )
3563
-
3564
3556
  def _get_internal_temp_table_name(self, connection, tablename):
3565
3557
  # it's likely that schema is always "dbo", but since we can
3566
3558
  # get it here, let's get it.
3567
3559
  # see https://stackoverflow.com/questions/8311959/
3568
3560
  # specifying-schema-for-temporary-tables
3569
3561
 
3562
+ # object_id() resolves a local temp table name only against the
3563
+ # current session's tables (plus any ##global one), so the lookup
3564
+ # neither sees nor locks other sessions' same-named temp tables in
3565
+ # tempdb, as a scan of tempdb's catalog by name would. See #13754.
3570
3566
  try:
3571
3567
  return connection.execute(
3572
3568
  sql.text(
3573
- "select table_schema, table_name "
3574
- "from tempdb.information_schema.tables "
3575
- "where table_name like :p1"
3569
+ "select s.name, o.name "
3570
+ "from tempdb.sys.objects as o "
3571
+ "join tempdb.sys.schemas as s "
3572
+ "on s.schema_id = o.schema_id "
3573
+ "where o.object_id = object_id(:qualified, 'U')"
3576
3574
  ),
3577
- {"p1": self._temp_table_name_like_pattern(tablename)},
3575
+ {"qualified": f"tempdb..[{tablename}]"},
3578
3576
  ).one()
3579
- except exc.MultipleResultsFound as me:
3580
- raise exc.UnreflectableTableError(
3581
- "Found more than one temporary table named '%s' in tempdb "
3582
- "at this time. Cannot reliably resolve that name to its "
3583
- "internal table name." % tablename
3584
- ) from me
3585
3577
  except exc.NoResultFound as ne:
3586
3578
  raise exc.NoSuchTableError(
3587
3579
  "Unable to find a temporary table named '%s' in tempdb."
@@ -1932,7 +1932,7 @@ class OracleDDLCompiler(compiler.DDLCompiler):
1932
1932
  def define_constraint_cascades(self, constraint):
1933
1933
  text = ""
1934
1934
  if constraint.ondelete is not None:
1935
- text += " ON DELETE %s" % constraint.ondelete
1935
+ text += self.define_constraint_ondelete_cascade(constraint)
1936
1936
 
1937
1937
  # oracle has no ON UPDATE CASCADE -
1938
1938
  # its only available via triggers
@@ -29,6 +29,7 @@ from ...sql.visitors import InternalTraversal
29
29
  if TYPE_CHECKING:
30
30
  from ...engine.interfaces import Dialect
31
31
  from ...sql._typing import _ColumnExpressionArgument
32
+ from ...sql._typing import _CovariantTypeEngineArgument
32
33
  from ...sql._typing import _TypeEngineArgument
33
34
  from ...sql.elements import ColumnElement
34
35
  from ...sql.elements import Grouping
@@ -324,7 +325,7 @@ class ARRAY(sqltypes.ARRAY[_T]):
324
325
 
325
326
  def __init__(
326
327
  self,
327
- item_type: _TypeEngineArgument[_T],
328
+ item_type: _CovariantTypeEngineArgument[_T],
328
329
  as_tuple: bool = False,
329
330
  dimensions: Optional[int] = None,
330
331
  zero_indexes: bool = False,
@@ -366,9 +367,7 @@ class ARRAY(sqltypes.ARRAY[_T]):
366
367
  "Do not nest ARRAY types; ARRAY(basetype) "
367
368
  "handles multi-dimensional arrays of basetype"
368
369
  )
369
- if isinstance(item_type, type):
370
- item_type = item_type()
371
- self.item_type = item_type
370
+ self.item_type = sqltypes.to_instance(item_type)
372
371
  self.as_tuple = as_tuple
373
372
  self.dimensions = dimensions
374
373
  self.zero_indexes = zero_indexes
@@ -2783,10 +2783,13 @@ class PGDDLCompiler(compiler.DDLCompiler):
2783
2783
 
2784
2784
  @util.memoized_property
2785
2785
  def _fk_ondelete_pattern(self):
2786
+ # a keyword phrase, optionally followed by the column list accepted
2787
+ # by SET NULL / SET DEFAULT. column names may be double-quoted, as
2788
+ # rendered by pg_get_constraintdef() when reflected
2789
+ ident = r'(?:[\w$]+|"(?:[^"]|"")+")'
2786
2790
  return re.compile(
2787
- r"^(?:RESTRICT|CASCADE|SET (?:NULL|DEFAULT)(?:\s*\(.+\))?"
2788
- r"|NO ACTION)$",
2789
- re.I,
2791
+ r"^\w+(?:\s+\w+)*"
2792
+ rf"(?:\s*\(\s*{ident}(?:\s*,\s*{ident})*\s*\))?$"
2790
2793
  )
2791
2794
 
2792
2795
  def define_constraint_ondelete_cascade(self, constraint):
sqlalchemy/engine/base.py CHANGED
@@ -129,6 +129,11 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
129
129
 
130
130
  _execution_options: _ExecuteOptions
131
131
 
132
+ # set to True at the class level by ConnectionEvents when listeners
133
+ # are associated with the Connection class; set at the instance level
134
+ # when listeners are associated with a specific Connection
135
+ _has_events: bool = False
136
+
132
137
  _transaction: Optional[RootTransaction]
133
138
  _nested_transaction: Optional[NestedTransaction]
134
139
 
@@ -167,10 +172,13 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
167
172
  # if _has_events is sent explicitly as False,
168
173
  # then don't join the dispatch of the engine; we don't
169
174
  # want to handle any of the engine's events in that case.
175
+ # the engine's _has_events is checked alongside our own at
176
+ # each event point; leave our own _has_events unset so that
177
+ # the class-level flag, set by listeners on the Connection
178
+ # class, takes effect
170
179
  self.dispatch = self.dispatch._join(engine.dispatch)
171
- self._has_events = _has_events or (
172
- _has_events is None and engine._has_events
173
- )
180
+ else:
181
+ self._has_events = _has_events
174
182
 
175
183
  self._execution_options = engine._execution_options
176
184
 
@@ -181,6 +189,29 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
181
189
  # characteristics.LoggingTokenCharacteristic
182
190
  _message_formatter: Any = None
183
191
 
192
+ def _log_parameters(
193
+ self,
194
+ parameters: _AnyExecuteParams | None,
195
+ stats: str,
196
+ *,
197
+ ismulti: bool,
198
+ ) -> None:
199
+ if not self.engine.hide_parameters:
200
+ self._log_info(
201
+ "[%s] %r",
202
+ stats,
203
+ sql_util._repr_params(
204
+ parameters,
205
+ batches=10,
206
+ ismulti=ismulti,
207
+ ),
208
+ )
209
+ else:
210
+ self._log_info(
211
+ "[%s] [SQL parameters hidden due to hide_parameters=True]",
212
+ stats,
213
+ )
214
+
184
215
  def _log_info(self, message: str, *arg: Any, **kw: Any) -> None:
185
216
  fmt = self._message_formatter
186
217
 
@@ -1893,22 +1924,10 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
1893
1924
 
1894
1925
  stats = context._get_cache_stats()
1895
1926
 
1896
- if not self.engine.hide_parameters:
1897
- self._log_info(
1898
- "[%s] %r",
1899
- stats,
1900
- sql_util._repr_params(
1901
- effective_parameters,
1902
- batches=10,
1903
- ismulti=context.executemany,
1904
- ),
1905
- )
1906
- else:
1907
- self._log_info(
1908
- "[%s] [SQL parameters hidden due to "
1909
- "hide_parameters=True]",
1910
- stats,
1911
- )
1927
+ self._log_parameters(
1928
+ effective_parameters, stats, ismulti=context.executemany
1929
+ )
1930
+
1912
1931
  if context.execute_style is ExecuteStyle.EXECUTEMANY:
1913
1932
  effective_parameters = cast(
1914
1933
  "_CoreMultiExecuteParams", effective_parameters
@@ -2080,22 +2099,7 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
2080
2099
  else:
2081
2100
  stats = f"insertmanyvalues{imv_stats}"
2082
2101
 
2083
- if not self.engine.hide_parameters:
2084
- self._log_info(
2085
- "[%s] %r",
2086
- stats,
2087
- sql_util._repr_params(
2088
- sub_params,
2089
- batches=10,
2090
- ismulti=False,
2091
- ),
2092
- )
2093
- else:
2094
- self._log_info(
2095
- "[%s] [SQL parameters hidden due to "
2096
- "hide_parameters=True]",
2097
- stats,
2098
- )
2102
+ self._log_parameters(sub_params, stats, ismulti=False)
2099
2103
 
2100
2104
  for fn in do_execute_dispatch:
2101
2105
  if fn(
@@ -2176,7 +2180,7 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
2176
2180
 
2177
2181
  if self._echo:
2178
2182
  self._log_info(statement)
2179
- self._log_info("[raw sql] %r", parameters)
2183
+ self._log_parameters(parameters, "raw sql", ismulti=False)
2180
2184
 
2181
2185
  for fn in (
2182
2186
  ()
sqlalchemy/engine/url.py CHANGED
@@ -576,7 +576,8 @@ class URL(NamedTuple):
576
576
 
577
577
  """
578
578
 
579
- if not set(names).intersection(self.query):
579
+ names = set(names)
580
+ if not names.intersection(self.query):
580
581
  return self
581
582
 
582
583
  return URL(
@@ -12,6 +12,7 @@ from typing import Callable
12
12
  from typing import cast
13
13
  from typing import Concatenate
14
14
  from typing import Dict
15
+ from typing import FrozenSet
15
16
  from typing import Generic
16
17
  from typing import Iterable
17
18
  from typing import Iterator
@@ -2164,6 +2165,76 @@ class AsyncSessionTransaction(
2164
2165
  self._raise_for_not_started()
2165
2166
  return self.sync_transaction
2166
2167
 
2168
+ @property
2169
+ def connections(self) -> FrozenSet[AsyncConnection]:
2170
+ """The :class:`_asyncio.AsyncConnection` objects this transaction
2171
+ has already acquired.
2172
+
2173
+ This is the asyncio-facing counterpart to
2174
+ :attr:`_orm.SessionTransaction.connections`.
2175
+
2176
+ .. note::
2177
+
2178
+ A savepoint holds only the connections it has used itself.
2179
+ To get all connections in the outer transaction, even while a
2180
+ savepoint is active, use :meth:`.AsyncSession.get_transaction`
2181
+ rather than :meth:`.AsyncSession.get_nested_transaction`::
2182
+
2183
+ root = async_session.get_transaction()
2184
+ connections = root.connections
2185
+
2186
+ .. versionadded:: 2.1.7
2187
+
2188
+ .. seealso::
2189
+
2190
+ :attr:`_orm.SessionTransaction.connections` - main documentation
2191
+ for "connections"
2192
+
2193
+ """
2194
+ return frozenset(
2195
+ engine.AsyncConnection._retrieve_proxy_for_target(conn)
2196
+ for conn in self._sync_transaction().connections
2197
+ )
2198
+
2199
+ def bound_connection(
2200
+ self,
2201
+ bindkey: Optional[_EntityBindKey[Any]] = None,
2202
+ *,
2203
+ bind: Optional[_AsyncSessionBind] = None,
2204
+ **kw: Any,
2205
+ ) -> AsyncConnection:
2206
+ """Return the :class:`_asyncio.AsyncConnection` this transaction has
2207
+ already acquired for the given bind key.
2208
+
2209
+ This is the asyncio-facing counterpart to
2210
+ :meth:`_orm.SessionTransaction.bound_connection`.
2211
+ :paramref:`.AsyncSessionTransaction.bound_connection.bind` is given
2212
+ as an asyncio object, and is translated on the way in.
2213
+
2214
+ .. note::
2215
+
2216
+ A savepoint can return only a connection it has used itself.
2217
+ To look up a connection in the outer transaction, even while a
2218
+ savepoint is active, use :meth:`.AsyncSession.get_transaction`
2219
+ rather than :meth:`.AsyncSession.get_nested_transaction`::
2220
+
2221
+ root = async_session.get_transaction()
2222
+ connection = root.bound_connection(bindkey)
2223
+
2224
+ .. versionadded:: 2.1.7
2225
+
2226
+ .. seealso::
2227
+
2228
+ :meth:`_orm.SessionTransaction.bound_connection` - main
2229
+ documentation for "bound_connection"
2230
+
2231
+ """
2232
+ if bind is not None:
2233
+ kw["bind"] = engine._get_sync_engine_or_connection(bind)
2234
+ return engine.AsyncConnection._retrieve_proxy_for_target(
2235
+ self._sync_transaction().bound_connection(bindkey, **kw)
2236
+ )
2237
+
2167
2238
  async def rollback(self) -> None:
2168
2239
  """Roll back this :class:`_asyncio.AsyncTransaction`."""
2169
2240
  await greenlet_spawn(self._sync_transaction().rollback)
sqlalchemy/inspection.py CHANGED
@@ -104,11 +104,11 @@ def inspect(subject: Inspectable[_IN], raiseerr: bool = True) -> _IN: ...
104
104
 
105
105
 
106
106
  @overload
107
- def inspect(subject: Any, raiseerr: Literal[False] = ...) -> Optional[Any]: ...
107
+ def inspect(subject: Any, raiseerr: Literal[True] = ...) -> Any: ...
108
108
 
109
109
 
110
110
  @overload
111
- def inspect(subject: Any, raiseerr: bool = True) -> Any: ...
111
+ def inspect(subject: Any, raiseerr: bool) -> Optional[Any]: ...
112
112
 
113
113
 
114
114
  def inspect(subject: Any, raiseerr: bool = True) -> Any:
sqlalchemy/orm/context.py CHANGED
@@ -14,6 +14,7 @@ from typing import AbstractSet
14
14
  from typing import Any
15
15
  from typing import cast
16
16
  from typing import Dict
17
+ from typing import FrozenSet
17
18
  from typing import Iterable
18
19
  from typing import List
19
20
  from typing import Optional
@@ -84,6 +85,7 @@ if TYPE_CHECKING:
84
85
  from .query import Query
85
86
  from .session import _BindArguments
86
87
  from .session import Session
88
+ from .util import LoaderCriteriaOption
87
89
  from ..engine import Result
88
90
  from ..engine.interfaces import _CoreSingleExecuteParams
89
91
  from ..sql._typing import _ColumnsClauseArgument
@@ -1115,6 +1117,13 @@ class _ORMSelectCompileState(_ORMCompileState, SelectState):
1115
1117
  _explicit_correlate_froms: AbstractSet[FromClause] = util.EMPTY_SET
1116
1118
  _implicit_correlate_froms: AbstractSet[FromClause] = util.EMPTY_SET
1117
1119
 
1120
+ # with_loader_criteria() options whose criteria contain this SELECT,
1121
+ # either directly or within an enclosing SELECT; see
1122
+ # LoaderCriteriaOption._should_include()
1123
+ _containing_loader_criteria: FrozenSet[LoaderCriteriaOption] = (
1124
+ util.EMPTY_SET
1125
+ )
1126
+
1118
1127
  @classmethod
1119
1128
  def _create_orm_context(
1120
1129
  cls,
@@ -1138,6 +1147,21 @@ class _ORMSelectCompileState(_ORMCompileState, SelectState):
1138
1147
  kw.get("lateral", False),
1139
1148
  )
1140
1149
 
1150
+ # the nearest enclosing ORM SELECT has already collected the
1151
+ # options of all the SELECTs enclosing it
1152
+ for entry in reversed(compiler.stack):
1153
+ enclosing_state = entry.get("compile_state")
1154
+ if isinstance(enclosing_state, _ORMSelectCompileState):
1155
+ self._containing_loader_criteria = (
1156
+ enclosing_state._containing_loader_criteria
1157
+ )
1158
+ break
1159
+
1160
+ if "for_loader_criteria" in statement._annotations:
1161
+ self._containing_loader_criteria |= {
1162
+ statement._annotations["for_loader_criteria"]
1163
+ }
1164
+
1141
1165
  select_statement = statement
1142
1166
 
1143
1167
  # if we are a select() that was never a legacy Query, we won't
sqlalchemy/orm/mapper.py CHANGED
@@ -2754,6 +2754,39 @@ class Mapper(
2754
2754
  for table, columns in self._cols_by_table.items()
2755
2755
  }
2756
2756
 
2757
+ @HasMemoized.memoized_attribute
2758
+ def _insert_orm_defaults(
2759
+ self,
2760
+ ) -> Mapping[FromClause, Sequence[Tuple[str, ColumnElement[Any], Any]]]:
2761
+ """per table, (propkey, column, value) for ORM-level defaults on
2762
+ column properties, i.e. dataclass defaults, which the column's own
2763
+ default would not deliver to an INSERT.
2764
+
2765
+ Normally the ORM-level default is the same as the column's default.
2766
+ They differ when the column is shared with another mapper, as is the
2767
+ case for use_existing_column on single inheritance subclasses, #13748
2768
+
2769
+ """
2770
+ return {
2771
+ table: [
2772
+ (prop.key, col, prop._default_scalar_value)
2773
+ for col in columns
2774
+ # None indicates no ORM-level default; a None value would
2775
+ # be omitted from the INSERT in any case
2776
+ if (prop := self._columntoproperty[col])._default_scalar_value
2777
+ is not None
2778
+ # skip if the column's own default already delivers the
2779
+ # same value
2780
+ and not (
2781
+ col.default is not None
2782
+ and col.default.compare_argument(
2783
+ prop._default_scalar_value
2784
+ )
2785
+ )
2786
+ ]
2787
+ for table, columns in self._cols_by_table.items()
2788
+ }
2789
+
2757
2790
  @HasMemoized.memoized_attribute
2758
2791
  def _propkey_to_col(self):
2759
2792
  return {
@@ -322,6 +322,19 @@ def _organize_states_for_delete(base_mapper, states, uowtransaction):
322
322
  yield (state, dict_, mapper, connection, update_version_id)
323
323
 
324
324
 
325
+ def _set_insert_value(col, value, params, value_params):
326
+ """Place a value for an INSERT into either ``value_params``, if it is a
327
+ SQL expression, or ``params``, if it is a plain value.
328
+
329
+ """
330
+ if hasattr(value, "__clause_element__"):
331
+ value_params[col] = value.__clause_element__()
332
+ elif isinstance(value, sql.ClauseElement):
333
+ value_params[col] = value
334
+ else:
335
+ params[col.key] = value
336
+
337
+
325
338
  def _collect_insert_commands(
326
339
  table,
327
340
  states_to_insert,
@@ -351,19 +364,19 @@ def _collect_insert_commands(
351
364
  col = propkey_to_col[propkey]
352
365
  if value is None and col not in eval_none and not render_nulls:
353
366
  continue
354
- elif not bulk and (
355
- hasattr(value, "__clause_element__")
356
- or isinstance(value, sql.ClauseElement)
357
- ):
358
- value_params[col] = (
359
- value.__clause_element__()
360
- if hasattr(value, "__clause_element__")
361
- else value
362
- )
363
- else:
367
+ elif bulk:
364
368
  params[col.key] = value
369
+ else:
370
+ _set_insert_value(col, value, params, value_params)
365
371
 
366
372
  if not bulk:
373
+ # ORM-level defaults, i.e. dataclass defaults, for columns that
374
+ # have no value, where the column's own default does not
375
+ # deliver the value, #13748
376
+ for propkey, col, value in mapper._insert_orm_defaults[table]:
377
+ if col.key not in params and col not in value_params:
378
+ _set_insert_value(col, value, params, value_params)
379
+
367
380
  # for all the columns that have no default and we don't have
368
381
  # a value and where "None" is not a special value, add
369
382
  # explicit None to the INSERT. This is a legacy behavior
@@ -1659,6 +1672,18 @@ def _postfetch(
1659
1672
  ):
1660
1673
  prefetch_cols = list(prefetch_cols) + [mapper.version_id_col]
1661
1674
 
1675
+ if not isupdate:
1676
+ # ORM-level defaults that _collect_insert_commands delivered to the
1677
+ # INSERT for attributes with no value are handled in the same way as
1678
+ # prefetched column defaults, #13748
1679
+ orm_default_cols = [
1680
+ col
1681
+ for pkey, col, _ in mapper._insert_orm_defaults[table]
1682
+ if dict_.get(pkey) is None and params.get(col.key) is not None
1683
+ ]
1684
+ if orm_default_cols:
1685
+ prefetch_cols = list(prefetch_cols) + orm_default_cols
1686
+
1662
1687
  refresh_flush = bool(mapper.class_manager.dispatch.refresh_flush)
1663
1688
  if refresh_flush:
1664
1689
  load_evt_attrs = []
sqlalchemy/orm/query.py CHANGED
@@ -1307,7 +1307,7 @@ class Query(
1307
1307
  for prop in mapper.iterate_properties:
1308
1308
  if (
1309
1309
  isinstance(prop, relationships.RelationshipProperty)
1310
- and prop.mapper is entity_zero.mapper # type: ignore[union-attr] # noqa: E501
1310
+ and prop.mapper is entity_zero.mapper
1311
1311
  ):
1312
1312
  property = prop # type: ignore[assignment] # noqa: A001
1313
1313
  break
@@ -1316,7 +1316,7 @@ class Query(
1316
1316
  "Could not locate a property which relates instances "
1317
1317
  "of class '%s' to instances of class '%s'"
1318
1318
  % (
1319
- entity_zero.mapper.class_.__name__, # type: ignore[union-attr] # noqa: E501
1319
+ entity_zero.mapper.class_.__name__,
1320
1320
  instance.__class__.__name__,
1321
1321
  )
1322
1322
  )
@@ -1325,7 +1325,7 @@ class Query(
1325
1325
  with_parent(
1326
1326
  instance,
1327
1327
  property, # type: ignore[arg-type]
1328
- entity_zero.entity, # type: ignore[union-attr]
1328
+ entity_zero.entity,
1329
1329
  )
1330
1330
  )
1331
1331
 
@@ -667,7 +667,7 @@ class RelationshipProperty(
667
667
 
668
668
  def _memoized_attr_entity(self) -> _InternalEntityType[_PT]:
669
669
  if self._of_type:
670
- return inspect(self._of_type) # type: ignore[return-value]
670
+ return inspect(self._of_type) # type: ignore[no-any-return]
671
671
  else:
672
672
  return self.prop.entity
673
673
 
@@ -1914,22 +1914,38 @@ class RelationshipProperty(
1914
1914
  self._attribute_options.dataclasses_default_factory
1915
1915
  is not _NoArg.NO_ARG
1916
1916
  ):
1917
+ default_factory = (
1918
+ self._attribute_options.dataclasses_default_factory
1919
+ )
1920
+
1917
1921
  # write only / dynamic relationships have no collection_class,
1918
1922
  # however they accept a list of objects when assigned, so
1919
1923
  # ``list`` is the expected default_factory for these
1920
- expected_default_factory = (
1924
+ exact_default_factory = (
1921
1925
  list if is_write_only or is_dynamic else self.collection_class
1922
1926
  )
1923
1927
 
1924
- if (
1925
- self._attribute_options.dataclasses_default_factory
1926
- is not expected_default_factory
1928
+ # the plain list / set / dict that a custom collection class
1929
+ # acts as is also accepted, such as ``dict`` for
1930
+ # attribute_keyed_dict()
1931
+ plain_collection_type = util.duck_type_collection(
1932
+ exact_default_factory
1933
+ )
1934
+ if plain_collection_type is exact_default_factory:
1935
+ plain_collection_type = None
1936
+
1937
+ if default_factory is not exact_default_factory and (
1938
+ plain_collection_type is None
1939
+ or default_factory is not plain_collection_type
1927
1940
  ):
1941
+ expected = str(exact_default_factory)
1942
+ if plain_collection_type is not None:
1943
+ expected += f" or {plain_collection_type}"
1928
1944
  raise sa_exc.ArgumentError(
1929
1945
  "For relationship "
1930
1946
  f"{self._format_as_string(cls, key)} using "
1931
1947
  "dataclass options, default_factory must be exactly "
1932
- f"{expected_default_factory}"
1948
+ f"{expected}"
1933
1949
  )
1934
1950
 
1935
1951
  @util.preload_module("sqlalchemy.orm.mapper")