# ruff: noqa: PLR0911
import contextlib
import dataclasses
import datetime
import decimal
import hashlib
from collections.abc import Iterable, Mapping, Sequence
from typing import Any, Final, Literal, Optional, Protocol, Union, cast, overload
from sqlalchemy import (
Column,
Delete,
Dialect,
Select,
Table,
UnaryExpression,
Update,
and_,
any_,
inspect,
or_,
tuple_,
)
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import (
InstrumentedAttribute,
MapperProperty,
RelationshipProperty,
class_mapper,
joinedload,
lazyload,
selectinload,
)
from sqlalchemy.orm.strategy_options import (
_AbstractLoad, # pyright: ignore[reportPrivateUsage]
)
from sqlalchemy.sql import ColumnElement, ColumnExpressionArgument
from sqlalchemy.sql.base import ExecutableOption
from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate
from sqlalchemy.sql.elements import Label
from typing_extensions import TypeAlias
from advanced_alchemy.base import ModelProtocol, model_to_dict
from advanced_alchemy.exceptions import ErrorMessages, RepositoryError
from advanced_alchemy.exceptions import wrap_sqlalchemy_exception as _wrap_sqlalchemy_exception
from advanced_alchemy.filters import (
InAnyFilter,
PaginationFilter,
StatementFilter,
StatementTypeT,
)
from advanced_alchemy.operations import UpsertStrategy, resolve_column_default
from advanced_alchemy.repository._typing import arrays_equal, is_numpy_array
from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType
from advanced_alchemy.types.file_object import StoredObject
from advanced_alchemy.utils.serialization import encode_complex_type, encode_json
DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950
DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = {
int,
float,
str,
bool,
bytes,
decimal.Decimal,
datetime.date,
datetime.datetime,
datetime.time,
datetime.timedelta,
}
WhereClauseT = ColumnExpressionArgument[bool]
SingleLoad: TypeAlias = Union[
_AbstractLoad,
Literal["*"],
InstrumentedAttribute[Any],
RelationshipProperty[Any],
MapperProperty[Any],
]
LoadCollection: TypeAlias = Sequence[Union[SingleLoad, Sequence[SingleLoad]]]
ExecutableOptions: TypeAlias = Sequence[ExecutableOption]
LoadSpec: TypeAlias = Union[LoadCollection, SingleLoad, ExecutableOption, ExecutableOptions]
def _sort_kv_by_key_str(kv: tuple[object, object]) -> str:
return str(kv[0])
def _sort_normalized_value(value: Any) -> str:
return encode_json(_canonicalize_cache_key_value(value))
def _normalize_cache_key_value(value: Any) -> Any:
"""Normalize values into a deterministic JSON-serializable form.
Used for list/list_and_count cache keys.
"""
if value is None or isinstance(value, (int, float, str, bool)):
return value
# Try shared encoder first for scalars (datetime, uuid, bytes, etc)
if (
not isinstance(value, (list, tuple, set, frozenset, dict))
and (encoded := encode_complex_type(value)) is not None
):
return encoded
if isinstance(value, set):
value_set = cast("set[Any]", value) # type: ignore[redundant-cast]
normalized = [_normalize_cache_key_value(v) for v in value_set]
normalized.sort(key=_sort_normalized_value)
return normalized
if isinstance(value, (list, tuple)):
value_seq = cast("Sequence[Any]", value)
return [_normalize_cache_key_value(v) for v in value_seq]
if isinstance(value, dict):
value_dict = cast("dict[object, object]", value)
normalized_dict: dict[str, Any] = {}
for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str):
normalized_dict[str(k)] = _normalize_cache_key_value(v)
return normalized_dict
if dataclasses.is_dataclass(value) and not isinstance(value, type): # pyright: ignore[reportUnknownArgumentType]
return _normalize_cache_key_value(dataclasses.asdict(value))
if isinstance(value, InstrumentedAttribute):
return {"__attr__": value.key}
if isinstance(value, ColumnElement):
# Safe fallback for non-dataclass expressions in ordering/kwargs.
value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast]
return {"__sql__": str(value_expr)}
return {"__repr__": repr(value)} # pyright: ignore[reportUnknownArgumentType]
def _canonicalize_cache_key_value(value: Any) -> Any:
"""Convert dict-like objects into a stable, ordered representation."""
if isinstance(value, Mapping):
value_map = cast("Mapping[object, object]", value)
return [
[str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str)
]
if isinstance(value, list):
value_list = cast("list[Any]", value) # type: ignore[redundant-cast]
return [_canonicalize_cache_key_value(v) for v in value_list]
return value
def _build_cache_key( # pyright: ignore[reportUnusedFunction]
*,
model_name: str,
version_token: str,
method: str,
filters: Sequence[Union[StatementFilter, ColumnElement[bool]]],
kwargs: dict[str, Any],
order_by: Optional[Union[list[OrderingPair], OrderingPair]],
execution_options: dict[str, Any],
uniquify: bool,
count_with_window_function: Optional[bool] = None,
) -> Optional[str]:
"""Build a stable cache key for list/list_and_count operations.
Returns None if the query specification includes non-cacheable filter
expressions (e.g., raw SQLAlchemy boolean expressions).
"""
normalized_filters: list[dict[str, Any]] = []
for filter_ in filters:
if isinstance(filter_, ColumnElement):
return None
normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)})
normalized_order_by: Optional[list[Any]] = None
if order_by is not None:
order_items = order_by if isinstance(order_by, list) else [order_by]
normalized_order_by = []
for item in order_items:
if isinstance(item, UnaryExpression):
normalized_order_by.append({"expr": str(item)})
else:
col, desc = item
normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)})
payload: dict[str, Any] = {
"method": method,
"model": model_name,
"version": version_token,
"filters": normalized_filters,
"kwargs": _normalize_cache_key_value(kwargs),
"order_by": normalized_order_by,
"execution_options": _normalize_cache_key_value(execution_options),
"uniquify": uniquify,
}
if count_with_window_function is not None:
payload["count_with_window_function"] = bool(count_with_window_function)
try:
encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8")
except TypeError: # pragma: no cover
return None
digest = hashlib.sha256(encoded).hexdigest()
return f"{model_name}:{method}:{digest}"
OrderByT: TypeAlias = Union[
str,
InstrumentedAttribute[Any],
RelationshipProperty[Any],
]
# NOTE: For backward compatibility with Litestar - this is imported from here within the litestar codebase.
wrap_sqlalchemy_exception = _wrap_sqlalchemy_exception
DEFAULT_ERROR_MESSAGE_TEMPLATES: ErrorMessages = {
"integrity": "There was a data validation error during processing",
"foreign_key": "A foreign key is missing or invalid",
"multiple_rows": "Multiple matching rows found",
"duplicate_key": "A record matching the supplied data already exists.",
"other": "There was an error during data processing",
"check_constraint": "The data failed a check constraint during processing",
"not_found": "The requested resource was not found",
}
"""Default error messages for repository errors."""
[docs]
def get_instrumented_attr(
model: type[ModelProtocol],
key: Union[str, InstrumentedAttribute[Any]],
) -> InstrumentedAttribute[Any]:
"""Get an instrumented attribute from a model.
Args:
model: SQLAlchemy model class.
key: Either a string attribute name or an :class:`sqlalchemy.orm.InstrumentedAttribute`.
Returns:
:class:`sqlalchemy.orm.InstrumentedAttribute`: The instrumented attribute from the model.
"""
if isinstance(key, str):
return cast("InstrumentedAttribute[Any]", getattr(model, key))
return key
def get_primary_key_info(
model: type[ModelProtocol],
) -> tuple[tuple["Column[Any]", ...], tuple[str, ...]]:
"""Extract primary key columns and attribute names from a SQLAlchemy model.
This function safely inspects a model to retrieve its primary key information,
handling cases where the model may not be properly mapped (e.g., mock objects
in tests).
Args:
model: SQLAlchemy model class to inspect.
Returns:
A tuple of (pk_columns, pk_attr_names) where:
- pk_columns: Tuple of Column objects representing the primary key
- pk_attr_names: Tuple of ORM attribute names for the primary key columns
Returns empty tuples if the model cannot be inspected (e.g., unmapped models).
Example:
>>> pk_columns, pk_attr_names = get_primary_key_info(UserRole)
>>> # For a model with composite key (user_id, role_id):
>>> # pk_columns = (Column('user_id', ...), Column('role_id', ...))
>>> # pk_attr_names = ('user_id', 'role_id')
"""
try:
mapper = inspect(model)
except NoInspectionAvailable:
return (), ()
else:
pk_columns: tuple[Column[Any], ...] = tuple(mapper.primary_key) # type: ignore[union-attr]
pk_attr_names: tuple[str, ...] = tuple(
mapper.get_property_by_column(col).key # type: ignore[union-attr]
for col in pk_columns
)
return pk_columns, pk_attr_names
def validate_composite_pk_value(
pk_value: Any,
pk_attr_names: tuple[str, ...],
model_name: str,
) -> tuple[Any, ...]:
"""Validate and normalize a composite primary key value to a tuple.
Args:
pk_value: Primary key value (must be tuple or dict for composite PKs).
pk_attr_names: Tuple of ORM attribute names for the PK columns.
model_name: Model class name for error messages.
Returns:
Validated tuple of PK values in column order.
Raises:
TypeError: If pk_value is not a tuple or dict.
ValueError: If tuple length is wrong, dict is missing keys, or any value is None.
"""
num_pk_columns = len(pk_attr_names)
if isinstance(pk_value, tuple):
pk_tuple = cast("tuple[Any, ...]", pk_value) # type: ignore[redundant-cast]
if len(pk_tuple) != num_pk_columns:
msg = (
f"Composite primary key for {model_name} has "
f"{num_pk_columns} columns {list(pk_attr_names)}, "
f"but {len(pk_tuple)} values provided: {pk_tuple!r}"
)
raise ValueError(msg)
# Validate no None values
for i, val in enumerate(pk_tuple):
if val is None:
msg = f"Primary key value for '{pk_attr_names[i]}' cannot be None in composite key for {model_name}"
raise ValueError(msg)
return pk_tuple
if isinstance(pk_value, dict):
pk_dict = cast("dict[str, Any]", pk_value)
provided_keys = set(pk_dict.keys())
required_keys = set(pk_attr_names)
missing_keys = required_keys - provided_keys
if missing_keys:
msg = (
f"Composite primary key for {model_name} requires "
f"attributes {sorted(required_keys)}, but missing: {sorted(missing_keys)}"
)
raise ValueError(msg)
# Validate no None values and build tuple
result_values: list[Any] = []
for attr_name in pk_attr_names:
val = pk_dict[attr_name]
if val is None:
msg = f"Primary key value for '{attr_name}' cannot be None in composite key for {model_name}"
raise ValueError(msg)
result_values.append(val)
return tuple(result_values)
# Not a valid type for composite PK
pk_type_name = type(pk_value).__name__
msg = (
f"Composite primary key for {model_name} requires tuple or dict, "
f"got {pk_type_name}: {pk_value!r}. Expected columns: {list(pk_attr_names)}"
)
raise TypeError(msg)
def is_composite_pk(pk_columns: tuple[Any, ...]) -> bool:
"""Check if a primary key has multiple columns.
Args:
pk_columns: Tuple of primary key Column objects.
Returns:
True if the model has 2 or more primary key columns, False otherwise.
Example:
>>> is_composite_pk(repo._pk_columns) # Single PK model
False
>>> is_composite_pk(
... repo._pk_columns
... ) # Model with (user_id, role_id) PK
True
"""
return len(pk_columns) > 1
def extract_pk_value_from_instance(
instance: ModelProtocol,
pk_attr_names: tuple[str, ...],
) -> PrimaryKeyType:
"""Extract the primary key value(s) from a model instance.
Args:
instance: Model instance to extract primary key from.
pk_attr_names: Tuple of ORM attribute names for the PK columns.
Returns:
- For single PK: scalar value (int, str, UUID, etc.)
- For composite PK: tuple of values in column order
Example:
# Single primary key
>>> user = User(id=123, name="Alice")
>>> extract_pk_value_from_instance(user, ("id",))
123
# Composite primary key
>>> assignment = UserRole(user_id=1, role_id=5)
>>> extract_pk_value_from_instance(
... assignment, ("user_id", "role_id")
... )
(1, 5)
"""
if len(pk_attr_names) == 1:
return getattr(instance, pk_attr_names[0])
return tuple(getattr(instance, attr_name) for attr_name in pk_attr_names)
def pk_values_present(
instance: ModelProtocol,
pk_attr_names: tuple[str, ...],
) -> bool:
"""Check if all primary key values are set on an instance.
Args:
instance: Model instance to check.
pk_attr_names: Tuple of ORM attribute names for the PK columns.
Returns:
True if all PK values are non-None, False otherwise.
Example:
>>> user = User(id=123)
>>> pk_values_present(user, ("id",))
True
>>> user = User(id=None)
>>> pk_values_present(user, ("id",))
False
"""
return all(getattr(instance, attr_name, None) is not None for attr_name in pk_attr_names)
def normalize_pk_to_tuple(
pk_value: PrimaryKeyType,
pk_attr_names: tuple[str, ...],
model_name: str,
) -> tuple[Any, ...]:
"""Normalize a primary key value to tuple format.
This function converts various PK input formats (scalar, tuple, dict) to
a consistent tuple format for internal processing.
Args:
pk_value: Primary key value (scalar, tuple, or dict).
pk_attr_names: Tuple of ORM attribute names for the PK columns.
model_name: Model class name for error messages.
Returns:
Tuple representation of the primary key.
Raises:
ValueError: If composite PK is passed a scalar value.
Example:
# Single PK - wraps scalar in tuple
>>> normalize_pk_to_tuple(123, ("id",), "User")
(123,)
# Composite PK - tuple passes through
>>> normalize_pk_to_tuple(
... (1, 5), ("user_id", "role_id"), "UserRole"
... )
(1, 5)
# Composite PK - dict converted to tuple
>>> normalize_pk_to_tuple(
... {"user_id": 1, "role_id": 5},
... ("user_id", "role_id"),
... "UserRole",
... )
(1, 5)
"""
if len(pk_attr_names) == 1:
# Single PK - wrap scalar in tuple
return (pk_value,)
if isinstance(pk_value, tuple):
return cast("tuple[Any, ...]", pk_value) # type: ignore[redundant-cast]
if isinstance(pk_value, dict):
pk_dict = cast("dict[str, Any]", pk_value)
return tuple(pk_dict[attr_name] for attr_name in pk_attr_names)
# Scalar passed for composite PK - error
pk_type_name = type(pk_value).__name__
msg = f"Composite primary key for {model_name} requires tuple or dict, got {pk_type_name}: {pk_value!r}"
raise ValueError(msg)
def _convert_relationship_value(
value: Any,
related_model: type[ModelT],
is_collection: bool,
) -> Any:
"""Convert a relationship value, handling dicts, lists, and instances.
Args:
value: The value to convert (dict, list, model instance, or None).
related_model: The SQLAlchemy model class for the relationship.
is_collection: Whether this is a collection relationship (uselist=True).
Returns:
Converted value appropriate for the relationship type.
"""
if value is None:
return None
if is_collection:
# One-to-many or many-to-many: expect a list
if not isinstance(value, (list, tuple)):
# Single item provided for collection - wrap in list
value = [value]
return [
model_from_dict(related_model, **item) if isinstance(item, dict) else item
for item in value # pyright: ignore[reportUnknownVariableType]
]
# One-to-one or many-to-one: expect single value
if isinstance(value, dict):
return model_from_dict(related_model, **value)
return value
[docs]
def model_from_dict(model: type[ModelT], /, **kwargs: Any) -> ModelT:
"""Create an ORM model instance from a dictionary of attributes.
This function recursively converts nested dictionaries into their
corresponding SQLAlchemy model instances for relationship attributes.
Args:
model: The SQLAlchemy model class to instantiate.
**kwargs: Keyword arguments containing model attribute values.
For relationship attributes, values can be:
- None: Sets the relationship to None
- dict: Recursively converted to the related model instance
- list[dict]: Each dict converted to related model instances
- Model instance: Passed through unchanged
Returns:
ModelT: A new instance of the model populated with the provided values.
Example:
Basic usage with nested relationships::
data = {
"name": "John Doe",
"profile": {"bio": "Developer"},
"addresses": [
{"street": "123 Main St"},
{"street": "456 Oak Ave"},
],
}
user = model_from_dict(User, **data)
# user.profile is a Profile instance
# user.addresses is a list of Address instances
"""
mapper = class_mapper(model)
mapper_attrs = mapper.attrs
converted_data: dict[str, Any] = {}
# Iterate over kwargs instead of mapper.attrs for better performance
# when only a subset of attributes is provided (O(InputKeys) vs O(TotalColumns))
for key, value in kwargs.items():
# Skip keys that aren't mapped attributes (e.g., extra fields)
if key not in mapper_attrs:
continue
attr = mapper_attrs[key]
# Check if this attribute is a relationship
if isinstance(attr, RelationshipProperty):
related_model: type[ModelT] = attr.mapper.class_
converted_data[key] = _convert_relationship_value(
value=value,
related_model=related_model,
is_collection=attr.uselist or False,
)
else:
# Regular column attribute - pass through
converted_data[key] = value
return model(**converted_data)
def get_abstract_loader_options(
loader_options: Union[LoadSpec, None],
default_loader_options: Union[list[_AbstractLoad], None] = None,
default_options_have_wildcards: bool = False,
merge_with_default: bool = True,
inherit_lazy_relationships: bool = True,
cycle_count: int = 0,
) -> tuple[list[_AbstractLoad], bool]:
"""Generate SQLAlchemy loader options for eager loading relationships.
Args:
loader_options :class:`~advanced_alchemy.repository.typing.LoadSpec`|:class:`None` Specification for how to load relationships. Can be:
- None: Use defaults
- :class:`sqlalchemy.orm.strategy_options._AbstractLoad`: Direct SQLAlchemy loader option
- :class:`sqlalchemy.orm.InstrumentedAttribute`: Model relationship attribute
- :class:`sqlalchemy.orm.RelationshipProperty`: SQLAlchemy relationship
- str: "*" for wildcard loading
- :class:`typing.Sequence` of the above
default_loader_options: :class:`typing.Sequence` of :class:`sqlalchemy.orm.strategy_options._AbstractLoad` loader options to start with.
default_options_have_wildcards: Whether the default options contain wildcards.
merge_with_default: Whether to merge the default options with the loader options.
inherit_lazy_relationships: Whether to inherit the ``lazy`` configuration from the model's relationships.
cycle_count: Number of times this function has been called recursively.
Returns:
tuple[:class:`list`[:class:`sqlalchemy.orm.strategy_options._AbstractLoad`], bool]: A tuple containing:
- :class:`list` of :class:`sqlalchemy.orm.strategy_options._AbstractLoad` SQLAlchemy loader option objects
- Boolean indicating if any wildcard loaders are present
"""
loads: list[_AbstractLoad] = []
if cycle_count == 0 and not inherit_lazy_relationships:
loads.append(lazyload("*"))
if cycle_count == 0 and merge_with_default and default_loader_options is not None:
loads.extend(default_loader_options)
options_have_wildcards = default_options_have_wildcards
if loader_options is None:
return (loads, options_have_wildcards)
if isinstance(loader_options, _AbstractLoad):
return ([loader_options], options_have_wildcards)
if isinstance(loader_options, InstrumentedAttribute):
loader_options = [loader_options.property]
if isinstance(loader_options, RelationshipProperty):
class_ = loader_options.class_attribute
return (
[selectinload(class_)]
if loader_options.uselist
else [joinedload(class_, innerjoin=loader_options.innerjoin)],
options_have_wildcards if loader_options.uselist else True,
)
if isinstance(loader_options, str) and loader_options == "*":
options_have_wildcards = True
return ([joinedload("*")], options_have_wildcards)
if isinstance(loader_options, (list, tuple)):
for attribute in loader_options: # pyright: ignore[reportUnknownVariableType]
if isinstance(attribute, (list, tuple)):
load_chain, options_have_wildcards = get_abstract_loader_options(
loader_options=attribute, # pyright: ignore[reportUnknownArgumentType]
default_options_have_wildcards=options_have_wildcards,
inherit_lazy_relationships=inherit_lazy_relationships,
merge_with_default=merge_with_default,
cycle_count=cycle_count + 1,
)
loader = load_chain[-1]
for sub_load in load_chain[-2::-1]:
loader = sub_load.options(loader)
loads.append(loader)
else:
load_chain, options_have_wildcards = get_abstract_loader_options(
loader_options=attribute, # pyright: ignore[reportUnknownArgumentType]
default_options_have_wildcards=options_have_wildcards,
inherit_lazy_relationships=inherit_lazy_relationships,
merge_with_default=merge_with_default,
cycle_count=cycle_count + 1,
)
loads.extend(load_chain)
return (loads, options_have_wildcards)
[docs]
class FilterableRepositoryProtocol(Protocol[ModelT]):
"""Protocol defining the interface for filterable repositories.
This protocol defines the required attributes and methods that any
filterable repository implementation must provide.
"""
model_type: type[ModelT]
"""The SQLAlchemy model class this repository manages."""
[docs]
class FilterableRepository(FilterableRepositoryProtocol[ModelT]):
"""Default implementation of a filterable repository.
Provides core filtering, ordering and pagination functionality for
SQLAlchemy models.
"""
model_type: type[ModelT]
"""The SQLAlchemy model class this repository manages."""
prefer_any_dialects: Optional[tuple[str]] = ("postgresql",)
"""List of dialects that prefer to use ``field.id = ANY(:1)`` instead of ``field.id IN (...)``."""
order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None
"""List or single :class:`~advanced_alchemy.repository.typing.OrderingPair` to use for sorting."""
_prefer_any: bool = False
"""Whether to prefer ANY() over IN() in queries."""
_dialect: Dialect
"""The SQLAlchemy :class:`sqlalchemy.dialects.Dialect` being used."""
@overload
def _apply_filters(
self,
*filters: Union[StatementFilter, ColumnElement[bool]],
apply_pagination: bool = True,
statement: Select[tuple[ModelT]],
) -> Select[tuple[ModelT]]: ...
@overload
def _apply_filters(
self,
*filters: Union[StatementFilter, ColumnElement[bool]],
apply_pagination: bool = True,
statement: Delete,
) -> Delete: ...
@overload
def _apply_filters(
self,
*filters: Union[StatementFilter, ColumnElement[bool]],
apply_pagination: bool = True,
statement: Union[ReturningDelete[tuple[ModelT]], ReturningUpdate[tuple[ModelT]]],
) -> Union[ReturningDelete[tuple[ModelT]], ReturningUpdate[tuple[ModelT]]]: ...
@overload
def _apply_filters(
self,
*filters: Union[StatementFilter, ColumnElement[bool]],
apply_pagination: bool = True,
statement: Update,
) -> Update: ...
def _apply_filters(
self,
*filters: Union[StatementFilter, ColumnElement[bool]],
apply_pagination: bool = True,
statement: StatementTypeT,
) -> StatementTypeT:
"""Apply filters to a SQL statement.
Args:
*filters: Filter conditions to apply.
apply_pagination: Whether to apply pagination filters.
statement: The base SQL statement to filter.
Returns:
StatementTypeT: The filtered SQL statement.
"""
for filter_ in filters:
if isinstance(filter_, (PaginationFilter,)):
if apply_pagination:
statement = filter_.append_to_statement(statement, self.model_type)
elif isinstance(filter_, (InAnyFilter,)):
statement = filter_.append_to_statement(statement, self.model_type)
elif isinstance(filter_, ColumnElement):
statement = cast("StatementTypeT", statement.where(filter_))
else:
statement = filter_.append_to_statement(statement, self.model_type)
return statement
def _filter_select_by_kwargs(
self,
statement: StatementTypeT,
kwargs: Union[dict[Any, Any], Iterable[tuple[Any, Any]]],
) -> StatementTypeT:
"""Filter a statement using keyword arguments.
Args:
statement: :class:`sqlalchemy.sql.Select` The SQL statement to filter.
kwargs: Dictionary or iterable of tuples containing filter criteria.
Keys should be model attribute names, values are what to filter for.
Returns:
StatementTypeT: The filtered SQL statement.
"""
for key, val in dict(kwargs).items():
field = get_instrumented_attr(self.model_type, key)
statement = cast("StatementTypeT", statement.where(field == val))
return statement
def _apply_order_by(
self,
statement: StatementTypeT,
order_by: Union[
OrderingPair,
list[OrderingPair],
],
) -> StatementTypeT:
"""Apply ordering to a SQL statement.
Args:
statement: The SQL statement to order.
order_by: Ordering specification. Either a single tuple or list of tuples where:
- First element is the field name or :class:`sqlalchemy.orm.InstrumentedAttribute` to order by
- Second element is a boolean indicating descending (True) or ascending (False)
Returns:
StatementTypeT: The ordered SQL statement.
"""
if not isinstance(order_by, list):
order_by = [order_by]
for order_field in order_by:
if isinstance(order_field, UnaryExpression):
statement = statement.order_by(order_field) # type: ignore
else:
field = get_instrumented_attr(self.model_type, order_field[0])
statement = self._order_by_attribute(statement, field, order_field[1])
return statement
@staticmethod
def _order_by_attribute(
statement: StatementTypeT,
field: InstrumentedAttribute[Any],
is_desc: bool,
) -> StatementTypeT:
"""Apply ordering by a single attribute to a SQL statement.
Args:
statement: The SQL statement to order.
field: The model attribute to order by.
is_desc: Whether to order in descending (True) or ascending (False) order.
Returns:
StatementTypeT: The ordered SQL statement.
"""
if isinstance(statement, Select):
statement = cast("StatementTypeT", statement.order_by(field.desc() if is_desc else field.asc()))
return statement
def _type_must_use_in_instead_of_any(self, matched_values: Sequence[Any], field_type: Any = None) -> bool:
"""Determine if ``field.in_()`` should be used instead of ``any_()`` for compatibility.
Uses SQLAlchemy's type introspection to detect types that may have DBAPI
serialization issues with the ``ANY()`` operator. Checks if actual values match
the column's expected ``python_type``; mismatches indicate complex types that
need the safer ``IN()`` operator. Falls back to Python type checking when
SQLAlchemy type information is unavailable.
Args:
matched_values: Values to be used in the filter.
field_type: Optional SQLAlchemy TypeEngine from the column.
Returns:
bool: True if ``field.in_()`` should be used instead of ``any_()``.
"""
if not matched_values:
return False
if field_type is not None:
try:
expected_python_type = getattr(field_type, "python_type", None)
if expected_python_type is not None:
for value in matched_values:
if value is not None and not isinstance(value, expected_python_type):
return True
except (AttributeError, NotImplementedError):
return True
return any(value is not None and type(value) not in DEFAULT_SAFE_TYPES for value in matched_values)
def _get_insertmanyvalues_max_parameters(self, chunk_size: Optional[int] = None) -> int:
if chunk_size is not None:
if chunk_size < 1:
msg = "chunk_size must be greater than zero"
raise ValueError(msg)
return chunk_size
dialect_limit = getattr(self._dialect, "insertmanyvalues_max_parameters", None)
if isinstance(dialect_limit, int) and not isinstance(dialect_limit, bool) and dialect_limit > 0:
return dialect_limit
return DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS
def _get_upsert_chunk_size(self, column_count: int, chunk_size: Optional[int]) -> int:
"""Calculate native-upsert rows per chunk using dialect parameter and page limits."""
parameter_limited_rows = max(1, self._get_insertmanyvalues_max_parameters(chunk_size) // max(1, column_count))
dialect_page_size = getattr(self._dialect, "insertmanyvalues_page_size", None)
if isinstance(dialect_page_size, int) and not isinstance(dialect_page_size, bool) and dialect_page_size > 0:
return min(parameter_limited_rows, dialect_page_size)
return parameter_limited_rows
def _build_upsert_match_filter(
self, row_chunk: Sequence[dict[str, Any]], match_fields: Sequence[str]
) -> ColumnElement[bool]:
"""Build the smallest exact-key predicate supported by the dialect."""
match_columns = [get_instrumented_attr(self.model_type, field_name) for field_name in match_fields]
if len(match_columns) == 1:
match_values = [row[match_fields[0]] for row in row_chunk]
use_in = not self._prefer_any or self._type_must_use_in_instead_of_any(match_values, match_columns[0].type)
return match_columns[0].in_(match_values) if use_in else any_(match_values) == match_columns[0] # type: ignore[arg-type]
if self._dialect.name != "mssql":
match_values = [tuple(row[field_name] for field_name in match_fields) for row in row_chunk]
return tuple_(*match_columns).in_(match_values)
return or_(
*[
and_(*[column == row[field_name] for column, field_name in zip(match_columns, match_fields)])
for row in row_chunk
]
)
@staticmethod
def _has_duplicate_match_keys(data: Sequence[ModelT], match_fields: Sequence[str]) -> bool:
"""Detect duplicate source keys, which dialects treat differently.
A single native statement containing two rows with the same conflict key
raises on some backends (postgresql: "cannot affect row a second time")
and silently last-writer-wins on others. The ORM fallback merges such
rows deterministically, so duplicated keys route the batch there.
"""
hashable_match_keys: set[tuple[Any, ...]] = set()
unhashable_match_keys: list[tuple[Any, ...]] = []
for datum in data:
match_key = tuple(getattr(datum, field_name, MISSING) for field_name in match_fields)
if any(value is MISSING or value is None for value in match_key):
continue
try:
is_duplicate = match_key in hashable_match_keys
except TypeError:
is_duplicate = any(
len(existing_key) == len(match_key)
and all(
compare_values(existing_value, key_value)
for existing_value, key_value in zip(existing_key, match_key)
)
for existing_key in unhashable_match_keys
)
if not is_duplicate:
unhashable_match_keys.append(match_key)
else:
if not is_duplicate:
hashable_match_keys.add(match_key)
if is_duplicate:
return True
return False
def _requires_orm_upsert(self, data: Sequence[ModelT]) -> bool:
"""Detect batches whose semantics depend on the ORM unit of work.
Native Core DML never runs mapper-level events, relationship
save-update cascades, or the FileObject flush listeners. Models and
instances relying on any of those take the ORM fallback so their
behavior matches ``add_many`` and ``update_many``.
"""
mapper = self.model_type.__mapper__
if len(mapper.tables) > 1:
return True
if any(
ancestor.dispatch.before_insert
or ancestor.dispatch.after_insert
or ancestor.dispatch.before_update
or ancestor.dispatch.after_update
for ancestor in mapper.iterate_to_root()
):
return True
if any(isinstance(column.type, StoredObject) for column in mapper.columns):
return True
relationship_keys = [relationship.key for relationship in mapper.relationships]
if not relationship_keys:
return False
return any(datum.__dict__.get(key) for datum in data for key in relationship_keys)
def _resolve_upsert_update_columns(
self,
data: Sequence[ModelT],
strategy: UpsertStrategy,
) -> Optional[list[str]]:
"""Resolve columns that have uniform update intent across the batch.
Native DML bypasses ORM default/onupdate handling. Only explicitly set
attributes and Python ``onupdate`` columns should be copied into an
existing row; insert-only defaults such as ``created_at`` must not be
overwritten. Rows with different update shapes use the ORM fallback so
an omitted value in one row cannot be replaced by another row's default.
"""
if not data:
return []
table = cast("Table", self.model_type.__table__)
if any(
col.onupdate is not None and hasattr(getattr(col.onupdate, "arg", None), "_compiler_dispatch")
for col in table.columns
):
return None
protected_columns = set(strategy.conflict_columns)
protected_columns.update(column.key for column in table.primary_key.columns)
update_column_sets: list[set[str]] = []
for datum in data:
instance_state = inspect(datum)
update_column_sets.append(
{
column.key
for column in table.columns
if column.key not in protected_columns
and (has_upsert_update_intent(datum, instance_state, column.key) or column.onupdate is not None)
}
)
first_update_column_set = update_column_sets[0]
if any(update_column_set != first_update_column_set for update_column_set in update_column_sets[1:]):
return None
return [column.key for column in table.columns if column.key in first_update_column_set]
def _rows_require_fallback_upsert(
self,
input_rows: Sequence[dict[str, Any]],
strategy: UpsertStrategy,
match_fields: Sequence[str],
) -> bool:
"""Detect row shapes whose portable semantics require the ORM fallback.
``MERGE`` (oracle/mssql) and ``INSERT OR UPDATE`` (spanner) do not
transparently invoke server-side ``Sequence`` / ``Identity`` defaults
the way ``INSERT ... ON CONFLICT`` does on postgresql: a hand-built
statement that omits an autoincrement PK column will produce a NULL
violation. When the resolved strategy is one of those kinds and any
row is missing a PK column, fall back to the ORM-managed
SELECT+partition path so the sequence/identity machinery runs
normally.
"""
if not input_rows:
return False
first_row_columns = set(input_rows[0])
if any(set(row) != first_row_columns for row in input_rows[1:]):
return True
if any(field not in row or row[field] is None for row in input_rows for field in match_fields):
return True
if strategy.kind not in {"merge", "insert_or_update"}:
return False
table = cast("Table", self.model_type.__table__)
primary_key_columns = {column.key for column in table.primary_key.columns}
if any(not primary_key_columns.issubset(row.keys()) for row in input_rows):
return True
sql_default_columns = {
column.key
for column in table.columns
if column.default is not None and hasattr(getattr(column.default, "arg", None), "_compiler_dispatch")
}
return any(not sql_default_columns.issubset(row.keys()) for row in input_rows)
def _extract_upsert_row(self, instance: ModelT) -> dict[str, Any]:
"""Convert a model instance to a row dict suitable for native upsert execution.
Invokes Python-side callable defaults for columns missing from the
instance (UUID factories on PK columns, ``default=datetime.utcnow``
audit timestamps, etc.) because the native dispatch path bypasses
SQLAlchemy's ORM flush where these defaults normally fire.
Columns whose default is server-managed (``Sequence``, ``Identity``,
``server_default``) are omitted from the row when their value is
``None`` so the database supplies them.
"""
model_values = model_to_dict(instance)
table = cast("Table", self.model_type.__table__)
prepared_row = dict(model_values)
instance_state = inspect(instance)
for column in table.columns:
has_update_intent = has_upsert_update_intent(instance, instance_state, column.key)
if column.onupdate is not None and not has_update_intent:
onupdate_value = getattr(column.onupdate, "arg", None)
if callable(onupdate_value):
prepared_row[column.key] = resolve_column_default(onupdate_value)
continue
if onupdate_value is not None:
prepared_row[column.key] = onupdate_value
continue
if has_update_intent:
prepared_row[column.key] = getattr(instance, column.key)
continue
if prepared_row.get(column.key) is not None:
continue
value_default = column.onupdate if column.onupdate is not None else column.default
default_value = getattr(value_default, "arg", None) if value_default is not None else None
if hasattr(default_value, "_compiler_dispatch"):
prepared_row.pop(column.key, None)
continue
if callable(default_value):
prepared_row[column.key] = resolve_column_default(default_value)
continue
if value_default is not None and default_value is not None:
prepared_row[column.key] = default_value
continue
if column.primary_key or value_default is not None or column.server_default is not None:
prepared_row.pop(column.key, None)
return prepared_row
@staticmethod
def _normalize_match_value(value: Any) -> Any:
"""Fold the common collation-insensitive comparisons databases apply to text keys."""
if isinstance(value, str):
return value.casefold().rstrip()
return value
@classmethod
def _order_upsert_results(
cls,
instances: Sequence[ModelT],
input_rows: Sequence[dict[str, Any]],
match_fields: Sequence[str],
) -> list[ModelT]:
"""Restore input ordering because RETURNING/OUTPUT order is undefined.
Pairs by exact match-key equality first. Rows the database matched
under a collation-insensitive comparison (``'foo'`` upserted onto a
stored ``'FOO'``) get a second, normalized pass; a pairing that is
still ambiguous raises instead of returning a misaligned list.
"""
instances_by_match_key: dict[tuple[Any, ...], ModelT] = {}
unhashable_instances: list[tuple[tuple[Any, ...], ModelT]] = []
for instance in instances:
match_key = tuple(getattr(instance, field_name) for field_name in match_fields)
try:
instances_by_match_key[match_key] = instance
except TypeError:
unhashable_instances.append((match_key, instance))
ordered_instances: list[Optional[ModelT]] = []
matched_instance_ids: set[int] = set()
for row in input_rows:
match_key = tuple(row[field_name] for field_name in match_fields)
try:
matched_instance: Optional[ModelT] = instances_by_match_key.get(match_key)
except TypeError:
matched_instance = next(
(
candidate_instance
for candidate_key, candidate_instance in unhashable_instances
if len(candidate_key) == len(match_key)
and all(
compare_values(candidate_value, key_value)
for candidate_value, key_value in zip(candidate_key, match_key)
)
),
None,
)
if matched_instance is not None:
matched_instance_ids.add(id(matched_instance))
ordered_instances.append(matched_instance)
unmatched_positions = [position for position, instance in enumerate(ordered_instances) if instance is None]
leftover_instances = [instance for instance in instances if id(instance) not in matched_instance_ids]
if not unmatched_positions and not leftover_instances:
return cast("list[ModelT]", ordered_instances)
ambiguity_msg = (
"upsert_many could not unambiguously pair hydrated rows with input rows; "
"verify that the batch keys are unique under the database's comparison rules"
)
if len(unmatched_positions) != len(leftover_instances):
raise RepositoryError(ambiguity_msg)
leftovers_by_normalized_key: dict[tuple[Any, ...], list[ModelT]] = {}
for instance in leftover_instances:
normalized_key = tuple(
cls._normalize_match_value(getattr(instance, field_name)) for field_name in match_fields
)
with contextlib.suppress(TypeError):
leftovers_by_normalized_key.setdefault(normalized_key, []).append(instance)
for position in unmatched_positions:
row = input_rows[position]
normalized_key = tuple(cls._normalize_match_value(row[field_name]) for field_name in match_fields)
try:
candidates = leftovers_by_normalized_key.get(normalized_key, [])
except TypeError:
candidates = []
if len(candidates) != 1:
raise RepositoryError(ambiguity_msg)
ordered_instances[position] = candidates.pop()
return cast("list[ModelT]", ordered_instances)
def column_has_defaults(column: Any) -> bool:
"""Check if a column has any type of default value or update handler.
This includes:
- Python-side defaults (column.default)
- Server-side defaults (column.server_default)
- Python-side onupdate handlers (column.onupdate)
- Server-side onupdate handlers (column.server_onupdate)
Args:
column: SQLAlchemy column object to check
Returns:
bool: True if the column has any type of default or update handler
"""
# Label objects (from column_property) don't have default/onupdate attributes
# Return False for these as they represent computed values, not defaulted columns
if isinstance(column, Label):
return False
# Use defensive attribute checking for safety with other column-like objects
return (
getattr(column, "default", None) is not None
or getattr(column, "server_default", None) is not None
or getattr(column, "onupdate", None) is not None
or getattr(column, "server_onupdate", None) is not None
)
def was_attribute_set(instance: Any, mapper: Any, attr_name: str) -> bool:
"""Check if an attribute was explicitly set on a model instance.
This function distinguishes between attributes that were explicitly set
(even to None) versus attributes that are simply uninitialized and defaulting
to None. This is crucial for partial updates where only modified fields
should be copied.
Args:
instance: The model instance to check.
mapper: The SQLAlchemy mapper/inspector for the instance.
attr_name: The name of the attribute to check.
Returns:
bool: True if the attribute was explicitly set, False if uninitialized.
"""
try:
# Get the attribute state
attr_state = mapper.attrs.get(attr_name)
if attr_state is None:
return False
# Check if the attribute has history (was modified)
# For a new transient instance, modified attributes will have history
history = attr_state.history
if history.has_changes():
return True
# For attributes with no history, check if they're in the instance dict
# This handles the case where an attribute was set during __init__
return hasattr(instance, "__dict__") and attr_name in instance.__dict__
except (AttributeError, KeyError): # pragma: no cover
# If we can't determine, assume it was set to be safe
return True
def has_upsert_update_intent(instance: Any, instance_state: Any, attribute_name: str) -> bool:
"""Check whether an attribute on an instance should be included in an upsert update.
Distinguishes constructor input on transient instances from unmodified
attributes on persistent instances.
Args:
instance: The model instance to inspect.
instance_state: The SQLAlchemy inspection state for the instance.
attribute_name: The attribute name to check.
Returns:
bool: True if the attribute has update intent, False otherwise.
"""
if instance_state.transient:
return was_attribute_set(instance, instance_state, attribute_name)
attribute_state = instance_state.attrs.get(attribute_name)
return bool(attribute_state is not None and attribute_state.history.has_changes())
def compare_values(existing_value: Any, new_value: Any) -> bool:
"""Safely compare two values, handling numpy arrays and other special types.
This function handles the comparison of values that may include numpy arrays
(such as pgvector's Vector type) which cannot be directly compared using
standard equality operators due to their element-wise comparison behavior.
Args:
existing_value: The current value to compare.
new_value: The new value to compare against.
Returns:
bool: True if values are equal, False otherwise.
"""
# Handle None comparisons
if existing_value is None and new_value is None:
return True
if existing_value is None or new_value is None:
return False
# Handle numpy arrays or array-like objects
if is_numpy_array(existing_value) or is_numpy_array(new_value):
# Both values must be arrays for them to be considered equal
if not (is_numpy_array(existing_value) and is_numpy_array(new_value)):
return False
return arrays_equal(existing_value, new_value)
# Standard equality comparison for all other types
try:
return bool(existing_value == new_value)
except (ValueError, TypeError):
# If comparison fails for any reason, consider them different
# This is a safe fallback that will trigger updates when unsure
return False