From 0bd6630a703dfcec2f39e015563d5c5146ed1c06 Mon Sep 17 00:00:00 2001 From: Cody Fincher <204685+cofin@users.noreply.github.com> Date: Thu, 14 Nov 2024 18:09:25 -0600 Subject: [PATCH] feat: remove lambda statement usage (#288) * feat: remove lambda statement usage --- .gitignore | 1 + .pre-commit-config.yaml | 10 +- README.md | 44 ++-- advanced_alchemy/filters.py | 221 +++------------- advanced_alchemy/repository/_async.py | 253 ++++++------------- advanced_alchemy/repository/_sync.py | 251 ++++++------------ advanced_alchemy/repository/_util.py | 111 ++++---- advanced_alchemy/repository/memory/_async.py | 6 +- advanced_alchemy/repository/memory/_sync.py | 6 +- advanced_alchemy/service/_async.py | 18 +- advanced_alchemy/service/_sync.py | 18 +- docs/conf.py | 1 + pyproject.toml | 1 + tests/integration/test_lambda_stmt.py | 115 --------- tests/unit/test_repository.py | 35 ++- uv.lock | 48 ++-- 16 files changed, 357 insertions(+), 782 deletions(-) delete mode 100644 tests/integration/test_lambda_stmt.py diff --git a/.gitignore b/.gitignore index c033e6d3..b98b7b61 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ cython_debug/ /docs/changelog.md .cursorrules .cursorignore +.zed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e890363d..c9b69077 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,9 +24,13 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit rev: "v0.7.3" hooks: - - id: ruff - args: ["--fix","--unsafe-fixes"] - - id: ruff-format + # Run the linter. + - id: ruff + types_or: [ python, pyi ] + args: [ --fix ] + # Run the formatter. + - id: ruff-format + types_or: [ python, pyi ] - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: diff --git a/README.md b/README.md index 99d69e52..77db42bc 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ Litestar Logo - Dark

- -
+ @@ -40,20 +39,18 @@ offering: - Pre-configured base classes with audit columns UUID or Big Integer primary keys and a [sentinel column](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns). - Synchronous and asynchronous repositories featuring: - - Common CRUD operations for SQLAlchemy models - - Bulk inserts, updates, upserts, and deletes with dialect-specific enhancements - - [lambda_stmt](https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.lambda_stmt) when possible - for improved query building performance - - Integrated counts, pagination, sorting, filtering with `LIKE`, `IN`, and dates before and/or after. + - Common CRUD operations for SQLAlchemy models + - Bulk inserts, updates, upserts, and deletes with dialect-specific enhancements + - Integrated counts, pagination, sorting, filtering with `LIKE`, `IN`, and dates before and/or after. - Tested support for multiple database backends including: - - SQLite via [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/) or [sqlite](https://docs.python.org/3/library/sqlite3.html) - - Postgres via [asyncpg](https://magicstack.github.io/asyncpg/current/) or [psycopg3 (async or sync)](https://www.psycopg.org/psycopg3/) - - MySQL via [asyncmy](https://github.com/long2ice/asyncmy) - - Oracle via [oracledb (async or sync)](https://oracle.github.io/python-oracledb/) (tested on 18c and 23c) - - Google Spanner via [spanner-sqlalchemy](https://github.com/googleapis/python-spanner-sqlalchemy/) - - DuckDB via [duckdb_engine](https://github.com/Mause/duckdb_engine) - - Microsoft SQL Server via [pyodbc](https://github.com/mkleehammer/pyodbc) or [aioodbc](https://github.com/aio-libs/aioodbc) - - CockroachDB via [sqlalchemy-cockroachdb (async or sync)](https://github.com/cockroachdb/sqlalchemy-cockroachdb) + - SQLite via [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/) or [sqlite](https://docs.python.org/3/library/sqlite3.html) + - Postgres via [asyncpg](https://magicstack.github.io/asyncpg/current/) or [psycopg3 (async or sync)](https://www.psycopg.org/psycopg3/) + - MySQL via [asyncmy](https://github.com/long2ice/asyncmy) + - Oracle via [oracledb (async or sync)](https://oracle.github.io/python-oracledb/) (tested on 18c and 23c) + - Google Spanner via [spanner-sqlalchemy](https://github.com/googleapis/python-spanner-sqlalchemy/) + - DuckDB via [duckdb_engine](https://github.com/Mause/duckdb_engine) + - Microsoft SQL Server via [pyodbc](https://github.com/mkleehammer/pyodbc) or [aioodbc](https://github.com/aio-libs/aioodbc) + - CockroachDB via [sqlalchemy-cockroachdb (async or sync)](https://github.com/cockroachdb/sqlalchemy-cockroachdb) - ...and much more ## Usage @@ -71,9 +68,10 @@ pip install advanced-alchemy Advanced Alchemy includes a set of asynchronous and synchronous repository classes for easy CRUD operations on your SQLAlchemy models. - +
Click to expand the example + ```python from advanced_alchemy.base import UUIDBase @@ -139,9 +137,10 @@ For a full standalone example, see the sample [here][standalone-example] Advanced Alchemy includes an additional service class to make working with a repository easier. This class is designed to accept data as a dictionary or SQLAlchemy model, and it will handle the type conversions for you. - +
Here's the same example from above but using a service to create the data: + ```python from advanced_alchemy.base import UUIDBase @@ -217,8 +216,10 @@ Advanced Alchemy is the official SQLAlchemy integration for Litestar. In addition to installing with `pip install advanced-alchemy`, it can also be installed as a Litestar extra with `pip install litestar[sqlalchemy]`. +
Litestar Example + ```python from litestar import Litestar @@ -239,8 +240,10 @@ For a full Litestar example, check [here][litestar-example] #### FastAPI +
FastAPI Example + ```python from fastapi import FastAPI @@ -260,8 +263,10 @@ For a full FastAPI example, see [here][fastapi-example] #### Starlette +
Pre-built Example Apps + ```python from starlette.applications import Starlette @@ -279,8 +284,10 @@ alchemy = StarletteAdvancedAlchemy( #### Sanic +
Pre-built Example Apps + ```python from sanic import Sanic @@ -307,9 +314,8 @@ Before contributing, please review the [contribution guide][contributing]. If you have any questions, reach out to us on [Discord][discord], our org-wide [GitHub discussions][litestar-discussions] page, or the [project-specific GitHub discussions page][project-discussions]. -
- +

Litestar Logo - Light diff --git a/advanced_alchemy/filters.py b/advanced_alchemy/filters.py index 20ff2169..8682d374 100644 --- a/advanced_alchemy/filters.py +++ b/advanced_alchemy/filters.py @@ -9,14 +9,15 @@ from operator import attrgetter from typing import TYPE_CHECKING, Any, Generic, Literal, cast -from sqlalchemy import BinaryExpression, and_, any_, or_, text +from sqlalchemy import BinaryExpression, Delete, Select, Update, and_, any_, or_, text from typing_extensions import TypeVar if TYPE_CHECKING: from typing import Callable - from sqlalchemy import ColumnElement, Select, StatementLambdaElement + from sqlalchemy import ColumnElement from sqlalchemy.orm import InstrumentedAttribute + from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from typing_extensions import TypeAlias from advanced_alchemy import base @@ -36,32 +37,29 @@ "InAnyFilter", "StatementFilter", "StatementFilterT", + "StatementTypeT", ) T = TypeVar("T") ModelT = TypeVar("ModelT", bound="base.ModelProtocol") StatementFilterT = TypeVar("StatementFilterT", bound="StatementFilter") +StatementTypeT = TypeVar( + "StatementTypeT", + bound="ReturningDelete[tuple[Any]] | ReturningUpdate[tuple[Any]] | Select[tuple[Any]] | Select[Any] | Update | Delete", +) FilterTypes: TypeAlias = "BeforeAfter | OnBeforeAfter | CollectionFilter[Any] | LimitOffset | OrderBy | SearchFilter | NotInCollectionFilter[Any] | NotInSearchFilter" """Aggregate type alias of the types supported for collection filtering.""" class StatementFilter(ABC): @abstractmethod - def append_to_statement(self, statement: Select[tuple[ModelT]], model: type[ModelT]) -> Select[tuple[ModelT]]: - return statement - - @abstractmethod - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - *args: Any, - **kwargs: Any, - ) -> StatementLambdaElement: + def append_to_statement( + self, statement: StatementTypeT, model: type[ModelT], *args: Any, **kwargs: Any + ) -> StatementTypeT: return statement @staticmethod def _get_instrumented_attr(model: Any, key: str | InstrumentedAttribute[Any]) -> InstrumentedAttribute[Any]: - # copy this here to avoid a circular import of `get_instrumented_attribute`. Maybe we move that function somewhere else? if isinstance(key, str): return cast("InstrumentedAttribute[Any]", getattr(model, key)) return key @@ -78,36 +76,12 @@ class BeforeAfter(StatementFilter): after: datetime | None """Filter results where field later than this.""" - def append_to_statement(self, statement: Select[tuple[ModelT]], model: type[ModelT]) -> Select[tuple[ModelT]]: - field = self._get_instrumented_attr(model, self.field_name) - if self.before is not None: - statement = statement.where(field < self.before) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] - if self.after is not None: - statement = statement.where(field > self.after) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] - return statement - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - model: type[ModelT], - ) -> StatementLambdaElement: + def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: field = self._get_instrumented_attr(model, self.field_name) if self.before is not None: - before = self.before - statement = statement.add_criteria( - lambda s: s.where(field < before), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, model, self.field_name, self.before], - ) + statement = cast("StatementTypeT", statement.where(field < self.before)) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] if self.after is not None: - after = self.after - statement = statement.add_criteria( - lambda s: s.where(field > after), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, model, self.field_name, self.after], - ) + statement = cast("StatementTypeT", statement.where(field > self.after)) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] return statement @@ -122,36 +96,12 @@ class OnBeforeAfter(StatementFilter): on_or_after: datetime | None """Filter results where field on or later than this.""" - def append_to_statement(self, statement: Select[tuple[ModelT]], model: type[ModelT]) -> Select[tuple[ModelT]]: + def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: field = self._get_instrumented_attr(model, self.field_name) if self.on_or_before is not None: - statement = statement.where(field <= self.on_or_before) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] + statement = cast("StatementTypeT", statement.where(field <= self.on_or_before)) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] if self.on_or_after is not None: - statement = statement.where(field >= self.on_or_after) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] - return statement - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - model: type[ModelT], - ) -> StatementLambdaElement: - field = self._get_instrumented_attr(model, self.field_name) - if self.on_or_before is not None: - on_or_before = self.on_or_before - statement = statement.add_criteria( - lambda s: s.where(field <= on_or_before), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, model.__name__, self.field_name, self.on_or_before], - ) - if self.on_or_after is not None: - on_or_after = self.on_or_after - statement = statement.add_criteria( - lambda s: s.where(field >= on_or_after), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, model.__name__, self.field_name, self.on_or_after], - ) + statement = cast("StatementTypeT", statement.where(field >= self.on_or_after)) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] return statement @@ -172,45 +122,18 @@ class CollectionFilter(InAnyFilter, Generic[T]): def append_to_statement( self, - statement: Select[tuple[ModelT]], - model: type[ModelT], - prefer_any: bool = False, - ) -> Select[tuple[ModelT]]: - field = self._get_instrumented_attr(model, self.field_name) - if self.values is None: - return statement - if not self.values: - return statement.where(text("1=-1")) - if prefer_any: - return statement.where(any_(self.values) == field) # type: ignore[arg-type] - return statement.where(field.in_(self.values)) - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, + statement: StatementTypeT, model: type[ModelT], prefer_any: bool = False, - ) -> StatementLambdaElement: + ) -> StatementTypeT: field = self._get_instrumented_attr(model, self.field_name) if self.values is None: return statement if not self.values: - return statement.add_criteria(lambda s: s.where(text("1=-1")), enable_tracking=False) + return cast("StatementTypeT", statement.where(text("1=-1"))) if prefer_any: - values = self.values - return statement.add_criteria( - lambda s: s.where(any_(values) == field), # type: ignore[arg-type] - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, self.field_name, model.__name__], - ) - values = self.values - return statement.add_criteria( - lambda s: s.where(field.in_(values)), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, self.field_name, model.__name__], - ) + return cast("StatementTypeT", statement.where(any_(self.values) == field)) # type: ignore[arg-type] + return cast("StatementTypeT", statement.where(field.in_(self.values))) @dataclass @@ -226,41 +149,16 @@ class NotInCollectionFilter(InAnyFilter, Generic[T]): def append_to_statement( self, - statement: Select[tuple[ModelT]], + statement: StatementTypeT, model: type[ModelT], prefer_any: bool = False, - ) -> Select[tuple[ModelT]]: + ) -> StatementTypeT: field = self._get_instrumented_attr(model, self.field_name) if not self.values: return statement if prefer_any: - return statement.where(any_(self.values) == field) # type: ignore[arg-type] - return statement.where(field.in_(self.values)) - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - model: type[ModelT], - prefer_any: bool = False, - ) -> StatementLambdaElement: - field = self._get_instrumented_attr(model, self.field_name) - if not self.values: - return statement - if prefer_any: - values = self.values - return statement.add_criteria( - lambda s: s.where(any_(values) != field), # type: ignore[arg-type] - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, self.field_name, model.__name__], - ) - values = self.values - return statement.add_criteria( - lambda s: s.where(field.notin_(values)), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, self.field_name, model.__name__], - ) + return cast("StatementTypeT", statement.where(any_(self.values) != field)) # type: ignore[arg-type] + return cast("StatementTypeT", statement.where(field.notin_(self.values))) class PaginationFilter(StatementFilter, ABC): @@ -276,22 +174,10 @@ class LimitOffset(PaginationFilter): offset: int """Value for ``OFFSET`` clause of query.""" - def append_to_statement(self, statement: Select[tuple[ModelT]], model: type[ModelT]) -> Select[tuple[ModelT]]: - return statement.limit(self.limit).offset(self.offset) - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - model: type[ModelT], - ) -> StatementLambdaElement: - limit = self.limit - offset = self.offset - return statement.add_criteria( - lambda s: s.limit(limit).offset(offset), - track_bound_values=True, - track_closure_variables=False, - track_on=[self.__class__.__name__, limit, offset, model.__name__], - ) + def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: + if isinstance(statement, Select): + return cast("StatementTypeT", statement.limit(self.limit).offset(self.offset)) + return statement @dataclass @@ -303,25 +189,13 @@ class OrderBy(StatementFilter): sort_order: Literal["asc", "desc"] = "asc" """Sort ascending or descending""" - def append_to_statement(self, statement: Select[tuple[ModelT]], model: type[ModelT]) -> Select[tuple[ModelT]]: + def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT: + if not isinstance(statement, Select): + return statement field = self._get_instrumented_attr(model, self.field_name) if self.sort_order == "desc": - return statement.order_by(field.desc()) - return statement.order_by(field.asc()) - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, - model: type[ModelT], - ) -> StatementLambdaElement: - field = self._get_instrumented_attr(model, self.field_name) - fragment = field.desc() if self.sort_order == "desc" else field.asc() - return statement.add_criteria( - lambda s: s.order_by(fragment), - track_bound_values=False, - track_closure_variables=False, - track_on=[self.__class__.__name__, model.__name__, self.field_name, self.sort_order], - ) + return cast("StatementTypeT", statement.order_by(field.desc())) + return cast("StatementTypeT", statement.order_by(field.asc())) @dataclass @@ -357,30 +231,11 @@ def get_search_clauses(self, model: type[ModelT]) -> list[BinaryExpression[bool] def append_to_statement( self, - statement: Select[tuple[ModelT]], - model: type[ModelT], - ) -> Select[tuple[ModelT]]: - where_clause = self._operator(*self.get_search_clauses(model)) - return statement.where(where_clause) - - def append_to_lambda_statement( - self, - statement: StatementLambdaElement, + statement: StatementTypeT, model: type[ModelT], - ) -> StatementLambdaElement: + ) -> StatementTypeT: where_clause = self._operator(*self.get_search_clauses(model)) - return statement.add_criteria( - lambda s: s.where(where_clause), - track_bound_values=True, - track_closure_variables=True, - track_on=[ - self.__class__.__name__, - model.__name__, - str(self.normalized_field_names), - self.value, - self.ignore_case, - ], - ) + return cast("StatementTypeT", statement.where(where_clause)) @dataclass diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 99bc15f2..40a4d345 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -18,14 +18,14 @@ ) from sqlalchemy import ( + Delete, Result, RowMapping, Select, - StatementLambdaElement, TextClause, + Update, any_, delete, - lambda_stmt, over, select, text, @@ -53,8 +53,9 @@ from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement + from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate - from advanced_alchemy.filters import StatementFilter + from advanced_alchemy.filters import StatementFilter, StatementTypeT DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 @@ -67,7 +68,7 @@ class SQLAlchemyAsyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Pr id_attribute: Any match_fields: list[str] | str | None = None - statement: Select[tuple[ModelT]] | StatementLambdaElement + statement: Select[tuple[ModelT]] session: AsyncSession | async_scoped_session[AsyncSession] auto_expunge: bool auto_refresh: bool @@ -78,7 +79,7 @@ class SQLAlchemyAsyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Pr def __init__( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, session: AsyncSession | async_scoped_session[AsyncSession], auto_expunge: bool = False, auto_refresh: bool = True, @@ -178,7 +179,7 @@ async def get( item_id: Any, *, auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -189,7 +190,7 @@ async def get_one( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -200,7 +201,7 @@ async def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -241,7 +242,7 @@ async def get_and_update( async def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, load: LoadSpec | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, execution_options: dict[str, Any] | None = None, @@ -280,7 +281,7 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: ... + ) -> Update | ReturningUpdate[tuple[ModelT]]: ... async def upsert( self, @@ -314,7 +315,7 @@ async def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, force_basic_query_mode: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -327,7 +328,7 @@ async def list( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -378,7 +379,7 @@ class SQLAlchemyAsyncRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT], Filte def __init__( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, session: AsyncSession | async_scoped_session[AsyncSession], auto_expunge: bool = False, auto_refresh: bool = True, @@ -752,17 +753,16 @@ async def delete_where( execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type - statement = lambda_stmt(lambda: delete(model_type)) # pyright: ignore[reportUnknownLambdaType] - if loader_options: - statement = statement.options(*loader_options) - if execution_options: - statement = statement.execution_options(**execution_options) + statement = self._get_base_stmt( + statement=delete(model_type), + loader_options=loader_options, + execution_options=execution_options, + ) statement = self._filter_select_by_kwargs(statement=statement, kwargs=kwargs) statement = self._apply_filters(*filters, statement=statement, apply_pagination=False) instances: list[ModelT] = [] if self._dialect.delete_executemany_returning: - statement += lambda s: s.returning(model_type) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] - instances.extend(await self.session.scalars(statement)) + instances.extend(await self.session.scalars(statement.returning(model_type))) else: instances.extend( await self.list( @@ -779,7 +779,7 @@ async def delete_where( # backends will return a -1 if they can't determine impacted rowcount # only compare length of selected instances to results if it's >= 0 await self.session.rollback() - raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") + raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: @@ -821,52 +821,27 @@ async def exists( ) return existing > 0 - def _to_lambda_stmt( - self, - statement: Select[tuple[ModelT]] | StatementLambdaElement, - global_track_bound_values: bool = True, - track_closure_variables: bool = True, - enable_tracking: bool = True, - track_bound_values: bool = True, - track_on: object | None = None, - ) -> StatementLambdaElement: - if isinstance(statement, Select): - statement = lambda_stmt( - lambda: statement, - track_bound_values=track_bound_values, - global_track_bound_values=global_track_bound_values, - track_closure_variables=track_closure_variables, - enable_tracking=enable_tracking, - track_on=track_on, - ) - return statement - def _get_base_stmt( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement, - global_track_bound_values: bool = True, - track_closure_variables: bool = True, - enable_tracking: bool = True, - track_bound_values: bool = True, + statement: StatementTypeT, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - track_on: object | None = None, - ) -> StatementLambdaElement: - statement = self._to_lambda_stmt( - statement=statement, - global_track_bound_values=global_track_bound_values, - track_closure_variables=track_closure_variables, - enable_tracking=enable_tracking, - track_bound_values=track_bound_values, - track_on=track_on, - ) + ) -> StatementTypeT: + """Get base statement with options applied. + + Args: + statement: The select statement to modify + loader_options: Options for loading relationships + execution_options: Options for statement execution + + Returns: + Modified select statement + """ if loader_options: - statement = statement.add_criteria(lambda s: s.options(*loader_options), enable_tracking=False) + statement = cast("StatementTypeT", statement.options(*loader_options)) if execution_options: - statement = statement.add_criteria( - lambda s: s.execution_options(**execution_options), enable_tracking=False - ) + statement = cast("StatementTypeT", statement.execution_options(**execution_options)) return statement def _get_delete_many_statement( @@ -879,38 +854,27 @@ def _get_delete_many_statement( statement_type: Literal["delete", "select"] = "delete", loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Select[tuple[ModelT]] | Delete | ReturningDelete[tuple[ModelT]]: # Base statement is static - statement = delete(model_type) if statement_type == "delete" else select(model_type) - if loader_options: - statement = statement.options(*loader_options) + statement = self._get_base_stmt( + statement=delete(model_type) if statement_type == "delete" else select(model_type), + loader_options=loader_options, + execution_options=execution_options, + ) if execution_options: statement = statement.execution_options(**execution_options) if supports_returning and statement_type != "select": - statement = statement.returning(model_type) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] + statement = cast("ReturningDelete[tuple[ModelT]]", statement.returning(model_type)) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] if self._prefer_any: - statement = statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] - else: - statement = statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] - return lambda_stmt( - lambda: statement, # pyright: ignore[reportUnknownLambdaType] - track_bound_values=True, - track_on=[ - self._dialect.name, - statement_type, - model_type, - id_attribute, - statement, # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], - ) + return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] async def get( self, item_id: Any, *, auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -948,15 +912,6 @@ async def get( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id_attribute, - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) instance = (await self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() @@ -968,7 +923,7 @@ async def get_one( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1004,12 +959,6 @@ async def get_one( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) @@ -1022,7 +971,7 @@ async def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1055,12 +1004,6 @@ async def get_one_or_none( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) @@ -1236,7 +1179,7 @@ async def get_and_update( async def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1268,21 +1211,15 @@ async def count( statement=statement, loader_options=loader_options, execution_options=execution_options, - enable_tracking=False, - global_track_bound_values=False, - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) - statement = self._get_count_stmt(statement, loader_options, execution_options) - results = await self._execute(statement, uniquify=loader_options_have_wildcard) + results = await self._execute( + statement=self._get_count_stmt( + statement=statement, loader_options=loader_options, execution_options=execution_options + ), + uniquify=loader_options_have_wildcard, + ) return cast(int, results.scalar_one()) async def update( @@ -1419,29 +1356,20 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Update | ReturningUpdate[tuple[ModelT]]: # Base update statement is static - statement = update(model_type) - if supports_returning: - statement = statement.returning(model_type) - if loader_options: - statement = statement.options(*loader_options) - if execution_options: - statement = statement.execution_options(**execution_options) - return lambda_stmt( - lambda: statement, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - statement, - f"{loader_options!s}:{execution_options!s}", - ], + statement = self._get_base_stmt( + statement=update(table=model_type), loader_options=loader_options, execution_options=execution_options ) + if supports_returning: + return statement.returning(model_type) + + return statement async def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, force_basic_query_mode: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, @@ -1529,7 +1457,7 @@ async def _list_and_count_window( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1564,23 +1492,15 @@ async def _list_and_count_window( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = statement.add_criteria( - lambda s: s.add_columns(over(sql_func.count())), - enable_tracking=False, - ) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) - result = await self._execute(statement, uniquify=loader_options_have_wildcard) + result = await self._execute( + statement.add_columns(over(sql_func.count())), uniquify=loader_options_have_wildcard + ) count: int = 0 instances: list[ModelT] = [] for i, (instance, count_value) in enumerate(result): @@ -1594,7 +1514,7 @@ async def _list_and_count_basic( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1629,12 +1549,6 @@ async def _list_and_count_basic( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] @@ -1658,22 +1572,12 @@ async def _list_and_count_basic( def _get_count_stmt( self, - statement: StatementLambdaElement, + statement: Select[tuple[ModelT]], loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Select[tuple[int]]: # Count statement transformations are static - return statement.add_criteria( - lambda s: s.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None), - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], - ) + return statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None) async def upsert( self, @@ -1890,7 +1794,7 @@ async def list( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1925,12 +1829,6 @@ async def list( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] @@ -1999,7 +1897,7 @@ async def _attach_to_session( async def _execute( self, - statement: Select[Any] | StatementLambdaElement, + statement: Select[Any], uniquify: bool = False, ) -> Result[Any]: result = await self.session.execute(statement) @@ -2278,6 +2176,11 @@ def check_not_found(item_or_none: T | None) -> T: async def execute( self, - statement: Select[Any] | StatementLambdaElement, + statement: ReturningDelete[tuple[Any]] + | ReturningUpdate[tuple[Any]] + | Select[tuple[Any]] + | Update + | Delete + | Select[Any], ) -> Result[Any]: return await self.session.execute(statement) diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index 63a05087..f00562a6 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -20,14 +20,14 @@ ) from sqlalchemy import ( + Delete, Result, RowMapping, Select, - StatementLambdaElement, TextClause, + Update, any_, delete, - lambda_stmt, over, select, text, @@ -54,8 +54,9 @@ from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement + from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate - from advanced_alchemy.filters import StatementFilter + from advanced_alchemy.filters import StatementFilter, StatementTypeT DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 @@ -68,7 +69,7 @@ class SQLAlchemySyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Pro id_attribute: Any match_fields: list[str] | str | None = None - statement: Select[tuple[ModelT]] | StatementLambdaElement + statement: Select[tuple[ModelT]] session: Session | scoped_session[Session] auto_expunge: bool auto_refresh: bool @@ -79,7 +80,7 @@ class SQLAlchemySyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Pro def __init__( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, session: Session | scoped_session[Session], auto_expunge: bool = False, auto_refresh: bool = True, @@ -179,7 +180,7 @@ def get( item_id: Any, *, auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -190,7 +191,7 @@ def get_one( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -201,7 +202,7 @@ def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -242,7 +243,7 @@ def get_and_update( def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, load: LoadSpec | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, execution_options: dict[str, Any] | None = None, @@ -281,7 +282,7 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: ... + ) -> Update | ReturningUpdate[tuple[ModelT]]: ... def upsert( self, @@ -315,7 +316,7 @@ def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, force_basic_query_mode: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -328,7 +329,7 @@ def list( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -379,7 +380,7 @@ class SQLAlchemySyncRepository(SQLAlchemySyncRepositoryProtocol[ModelT], Filtera def __init__( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, session: Session | scoped_session[Session], auto_expunge: bool = False, auto_refresh: bool = True, @@ -753,17 +754,16 @@ def delete_where( execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type - statement = lambda_stmt(lambda: delete(model_type)) # pyright: ignore[reportUnknownLambdaType] - if loader_options: - statement = statement.options(*loader_options) - if execution_options: - statement = statement.execution_options(**execution_options) + statement = self._get_base_stmt( + statement=delete(model_type), + loader_options=loader_options, + execution_options=execution_options, + ) statement = self._filter_select_by_kwargs(statement=statement, kwargs=kwargs) statement = self._apply_filters(*filters, statement=statement, apply_pagination=False) instances: list[ModelT] = [] if self._dialect.delete_executemany_returning: - statement += lambda s: s.returning(model_type) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType] - instances.extend(self.session.scalars(statement)) + instances.extend(self.session.scalars(statement.returning(model_type))) else: instances.extend( self.list( @@ -780,7 +780,7 @@ def delete_where( # backends will return a -1 if they can't determine impacted rowcount # only compare length of selected instances to results if it's >= 0 self.session.rollback() - raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") + raise RepositoryError(detail="Deleted count does not match fetched count. Rollback issued.") self._flush_or_commit(auto_commit=auto_commit) for instance in instances: @@ -822,52 +822,27 @@ def exists( ) return existing > 0 - def _to_lambda_stmt( - self, - statement: Select[tuple[ModelT]] | StatementLambdaElement, - global_track_bound_values: bool = True, - track_closure_variables: bool = True, - enable_tracking: bool = True, - track_bound_values: bool = True, - track_on: object | None = None, - ) -> StatementLambdaElement: - if isinstance(statement, Select): - statement = lambda_stmt( - lambda: statement, - track_bound_values=track_bound_values, - global_track_bound_values=global_track_bound_values, - track_closure_variables=track_closure_variables, - enable_tracking=enable_tracking, - track_on=track_on, - ) - return statement - def _get_base_stmt( self, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement, - global_track_bound_values: bool = True, - track_closure_variables: bool = True, - enable_tracking: bool = True, - track_bound_values: bool = True, + statement: StatementTypeT, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - track_on: object | None = None, - ) -> StatementLambdaElement: - statement = self._to_lambda_stmt( - statement=statement, - global_track_bound_values=global_track_bound_values, - track_closure_variables=track_closure_variables, - enable_tracking=enable_tracking, - track_bound_values=track_bound_values, - track_on=track_on, - ) + ) -> StatementTypeT: + """Get base statement with options applied. + + Args: + statement: The select statement to modify + loader_options: Options for loading relationships + execution_options: Options for statement execution + + Returns: + Modified select statement + """ if loader_options: - statement = statement.add_criteria(lambda s: s.options(*loader_options), enable_tracking=False) + statement = cast("StatementTypeT", statement.options(*loader_options)) if execution_options: - statement = statement.add_criteria( - lambda s: s.execution_options(**execution_options), enable_tracking=False - ) + statement = cast("StatementTypeT", statement.execution_options(**execution_options)) return statement def _get_delete_many_statement( @@ -880,38 +855,27 @@ def _get_delete_many_statement( statement_type: Literal["delete", "select"] = "delete", loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Select[tuple[ModelT]] | Delete | ReturningDelete[tuple[ModelT]]: # Base statement is static - statement = delete(model_type) if statement_type == "delete" else select(model_type) - if loader_options: - statement = statement.options(*loader_options) + statement = self._get_base_stmt( + statement=delete(model_type) if statement_type == "delete" else select(model_type), + loader_options=loader_options, + execution_options=execution_options, + ) if execution_options: statement = statement.execution_options(**execution_options) if supports_returning and statement_type != "select": - statement = statement.returning(model_type) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] + statement = cast("ReturningDelete[tuple[ModelT]]", statement.returning(model_type)) # type: ignore[union-attr,assignment] # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType] if self._prefer_any: - statement = statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] - else: - statement = statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] - return lambda_stmt( - lambda: statement, # pyright: ignore[reportUnknownLambdaType] - track_bound_values=True, - track_on=[ - self._dialect.name, - statement_type, - model_type, - id_attribute, - statement, # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], - ) + return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] def get( self, item_id: Any, *, auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -949,15 +913,6 @@ def get( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id_attribute, - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) instance = (self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() @@ -969,7 +924,7 @@ def get_one( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1005,12 +960,6 @@ def get_one( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) @@ -1023,7 +972,7 @@ def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1056,12 +1005,6 @@ def get_one_or_none( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) @@ -1237,7 +1180,7 @@ def get_and_update( def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -1269,21 +1212,15 @@ def count( statement=statement, loader_options=loader_options, execution_options=execution_options, - enable_tracking=False, - global_track_bound_values=False, - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) statement = self._apply_filters(*filters, apply_pagination=False, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) - statement = self._get_count_stmt(statement, loader_options, execution_options) - results = self._execute(statement, uniquify=loader_options_have_wildcard) + results = self._execute( + statement=self._get_count_stmt( + statement=statement, loader_options=loader_options, execution_options=execution_options + ), + uniquify=loader_options_have_wildcard, + ) return cast(int, results.scalar_one()) def update( @@ -1420,29 +1357,20 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Update | ReturningUpdate[tuple[ModelT]]: # Base update statement is static - statement = update(model_type) - if supports_returning: - statement = statement.returning(model_type) - if loader_options: - statement = statement.options(*loader_options) - if execution_options: - statement = statement.execution_options(**execution_options) - return lambda_stmt( - lambda: statement, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - statement, - f"{loader_options!s}:{execution_options!s}", - ], + statement = self._get_base_stmt( + statement=update(table=model_type), loader_options=loader_options, execution_options=execution_options ) + if supports_returning: + return statement.returning(model_type) + + return statement def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, force_basic_query_mode: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, @@ -1530,7 +1458,7 @@ def _list_and_count_window( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1565,23 +1493,13 @@ def _list_and_count_window( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = statement.add_criteria( - lambda s: s.add_columns(over(sql_func.count())), - enable_tracking=False, - ) statement = self._apply_filters(*filters, statement=statement) statement = self._filter_select_by_kwargs(statement, kwargs) - result = self._execute(statement, uniquify=loader_options_have_wildcard) + result = self._execute(statement.add_columns(over(sql_func.count())), uniquify=loader_options_have_wildcard) count: int = 0 instances: list[ModelT] = [] for i, (instance, count_value) in enumerate(result): @@ -1595,7 +1513,7 @@ def _list_and_count_basic( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1630,12 +1548,6 @@ def _list_and_count_basic( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] @@ -1659,22 +1571,12 @@ def _list_and_count_basic( def _get_count_stmt( self, - statement: StatementLambdaElement, + statement: Select[tuple[ModelT]], loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: + ) -> Select[tuple[int]]: # Count statement transformations are static - return statement.add_criteria( - lambda s: s.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None), - track_bound_values=False, - track_closure_variables=False, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), - f"{loader_options!s}:{execution_options!s}", - ], - ) + return statement.with_only_columns(sql_func.count(text("1")), maintain_column_froms=True).order_by(None) def upsert( self, @@ -1891,7 +1793,7 @@ def list( self, *filters: StatementFilter | ColumnElement[bool], auto_expunge: bool | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -1926,12 +1828,6 @@ def list( statement=statement, loader_options=loader_options, execution_options=execution_options, - track_on=[ - self._dialect.name, - f"{self.model_type.__name__}", - id(statement), # pyright: ignore[reportUnknownArgumentType] - f"{loader_options!s}:{execution_options!s}", - ], ) if order_by is None: order_by = self.order_by or [] @@ -2000,7 +1896,7 @@ def _attach_to_session( def _execute( self, - statement: Select[Any] | StatementLambdaElement, + statement: Select[Any], uniquify: bool = False, ) -> Result[Any]: result = self.session.execute(statement) @@ -2279,6 +2175,11 @@ def check_not_found(item_or_none: T | None) -> T: def execute( self, - statement: Select[Any] | StatementLambdaElement, + statement: ReturningDelete[tuple[Any]] + | ReturningUpdate[tuple[Any]] + | Select[tuple[Any]] + | Update + | Delete + | Select[Any], ) -> Result[Any]: return self.session.execute(statement) diff --git a/advanced_alchemy/repository/_util.py b/advanced_alchemy/repository/_util.py index 04cee2c5..5cf24d52 100644 --- a/advanced_alchemy/repository/_util.py +++ b/advanced_alchemy/repository/_util.py @@ -1,7 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, Literal, Protocol, Sequence, Union, cast +from typing import TYPE_CHECKING, Any, Iterable, Literal, Protocol, Sequence, Union, cast, overload +from sqlalchemy import ( + Select, +) from sqlalchemy.orm import InstrumentedAttribute, MapperProperty, RelationshipProperty, joinedload, selectinload from sqlalchemy.orm.strategy_options import ( _AbstractLoad, # pyright: ignore[reportPrivateUsage] # pyright: ignore[reportPrivateUsage] @@ -16,14 +19,17 @@ InAnyFilter, PaginationFilter, StatementFilter, + StatementTypeT, ) from advanced_alchemy.repository.typing import ModelT, OrderingPair if TYPE_CHECKING: from sqlalchemy import ( + Delete, Dialect, - StatementLambdaElement, + Update, ) + from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate from advanced_alchemy.base import ModelProtocol @@ -135,12 +141,44 @@ class FilterableRepository(FilterableRepositoryProtocol[ModelT]): order_by: list[OrderingPair] | OrderingPair | None = None """List of ordering pairs to use for sorting.""" + @overload + def _apply_filters( + self, + *filters: StatementFilter | ColumnElement[bool], + apply_pagination: bool = True, + statement: Select[tuple[ModelT]], + ) -> Select[tuple[ModelT]]: ... + + @overload def _apply_filters( self, *filters: StatementFilter | ColumnElement[bool], apply_pagination: bool = True, - statement: StatementLambdaElement, - ) -> StatementLambdaElement: + statement: Delete, + ) -> Delete: ... + + @overload + def _apply_filters( + self, + *filters: StatementFilter | ColumnElement[bool], + apply_pagination: bool = True, + statement: ReturningDelete[tuple[ModelT]] | ReturningUpdate[tuple[ModelT]], + ) -> ReturningDelete[tuple[ModelT]] | ReturningUpdate[tuple[ModelT]]: ... + + @overload + def _apply_filters( + self, + *filters: StatementFilter | ColumnElement[bool], + apply_pagination: bool = True, + statement: Update, + ) -> Update: ... + + def _apply_filters( + self, + *filters: StatementFilter | ColumnElement[bool], + apply_pagination: bool = True, + statement: StatementTypeT, + ) -> StatementTypeT: """Apply filters to a select statement. Args: @@ -148,67 +186,36 @@ def _apply_filters( apply_pagination: applies pagination filters if true statement: select statement to apply filters - Keyword Args: - select: select to apply filters against - Returns: The select with filters applied. """ for filter_ in filters: if isinstance(filter_, (PaginationFilter,)): if apply_pagination: - statement = filter_.append_to_lambda_statement(statement, self.model_type) + statement = filter_.append_to_statement(statement, self.model_type) elif isinstance(filter_, (InAnyFilter,)): - statement = filter_.append_to_lambda_statement(statement, self.model_type, prefer_any=self._prefer_any) + statement = filter_.append_to_statement(statement, self.model_type) elif isinstance(filter_, ColumnElement): - statement = self._filter_by_expression(expression=filter_, statement=statement) + statement = cast("StatementTypeT", statement.where(filter_)) else: - statement = filter_.append_to_lambda_statement(statement, self.model_type) + statement = filter_.append_to_statement(statement, self.model_type) return statement def _filter_select_by_kwargs( self, - statement: StatementLambdaElement, + statement: StatementTypeT, kwargs: dict[Any, Any] | Iterable[tuple[Any, Any]], - ) -> StatementLambdaElement: + ) -> StatementTypeT: for key, val in dict(kwargs).items(): - statement = self._filter_by_where(statement=statement, field_name=key, value=val) + field = get_instrumented_attr(self.model_type, key) + statement = cast("StatementTypeT", statement.where(field == val)) return statement - def _filter_by_expression( - self, - statement: StatementLambdaElement, - expression: ColumnElement[bool], - ) -> StatementLambdaElement: - """Add a where clause to the statement.""" - # Static WHERE clause - no need to track - return statement.add_criteria( - lambda s: s.where(expression), - track_bound_values=True, - track_closure_variables=False, - track_on=[self._dialect.name, self.model_type.__name__, id(expression)], - ) - - def _filter_by_where( - self, - statement: StatementLambdaElement, - field_name: str | InstrumentedAttribute[Any], - value: Any, - ) -> StatementLambdaElement: - field = get_instrumented_attr(self.model_type, field_name) - # Track only the value parameter since it's dynamic - return statement.add_criteria( - lambda s: s.where(field == value), - track_bound_values=True, - track_closure_variables=False, - track_on=[self._dialect.name, self.model_type.__name__, field, id(value)], - ) - def _apply_order_by( self, - statement: StatementLambdaElement, + statement: StatementTypeT, order_by: list[tuple[str | InstrumentedAttribute[Any], bool]] | tuple[str | InstrumentedAttribute[Any], bool], - ) -> StatementLambdaElement: + ) -> StatementTypeT: if not isinstance(order_by, list): order_by = [order_by] for order_field, is_desc in order_by: @@ -218,14 +225,10 @@ def _apply_order_by( def _order_by_attribute( self, - statement: StatementLambdaElement, + statement: StatementTypeT, field: InstrumentedAttribute[Any], is_desc: bool, - ) -> StatementLambdaElement: - fragment = field.desc() if is_desc else field.asc() - # Static ORDER BY - no need to track - return statement.add_criteria( - lambda s: s.order_by(fragment), - track_closure_variables=False, - track_on=[self._dialect.name, self.model_type.__name__, field, is_desc], - ) + ) -> StatementTypeT: + if not isinstance(statement, Select): + return statement + return cast("StatementTypeT", statement.order_by(field.desc() if is_desc else field.asc())) diff --git a/advanced_alchemy/repository/memory/_async.py b/advanced_alchemy/repository/memory/_async.py index e6d69f16..37fbfb8b 100644 --- a/advanced_alchemy/repository/memory/_async.py +++ b/advanced_alchemy/repository/memory/_async.py @@ -11,6 +11,7 @@ Dialect, Select, StatementLambdaElement, + Update, ) from sqlalchemy.orm import InstrumentedAttribute @@ -46,6 +47,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] + from sqlalchemy.sql.dml import ReturningUpdate from advanced_alchemy.repository._util import ( LoadSpec, @@ -399,8 +401,8 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: - return cast("StatementLambdaElement", self.statement) + ) -> Update | ReturningUpdate[tuple[ModelT]]: + return self.statement # type: ignore[no-any-return] # pyright: ignore[reportReturnType] @classmethod async def check_health(cls, session: AsyncSession | async_scoped_session[AsyncSession]) -> bool: diff --git a/advanced_alchemy/repository/memory/_sync.py b/advanced_alchemy/repository/memory/_sync.py index dc3c514e..4ace515e 100644 --- a/advanced_alchemy/repository/memory/_sync.py +++ b/advanced_alchemy/repository/memory/_sync.py @@ -13,6 +13,7 @@ Dialect, Select, StatementLambdaElement, + Update, ) from sqlalchemy.orm import InstrumentedAttribute, Session @@ -47,6 +48,7 @@ from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] + from sqlalchemy.sql.dml import ReturningUpdate from advanced_alchemy.repository._util import ( LoadSpec, @@ -400,8 +402,8 @@ def _get_update_many_statement( supports_returning: bool, loader_options: list[_AbstractLoad] | None, execution_options: dict[str, Any] | None, - ) -> StatementLambdaElement: - return cast("StatementLambdaElement", self.statement) + ) -> Update | ReturningUpdate[tuple[ModelT]]: + return self.statement # type: ignore[no-any-return] # pyright: ignore[reportReturnType] @classmethod def check_health(cls, session: Session | scoped_session[Session]) -> bool: diff --git a/advanced_alchemy/service/_async.py b/advanced_alchemy/service/_async.py index 1b94ed53..e5ca5a27 100644 --- a/advanced_alchemy/service/_async.py +++ b/advanced_alchemy/service/_async.py @@ -36,7 +36,7 @@ from advanced_alchemy.utils.dataclass import Empty, EmptyType if TYPE_CHECKING: - from sqlalchemy import Select, StatementLambdaElement + from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio.scoping import async_scoped_session from sqlalchemy.orm import InstrumentedAttribute @@ -104,7 +104,7 @@ class SQLAlchemyAsyncRepositoryReadService(Generic[ModelT], ResultConverter): def __init__( self, session: AsyncSession | async_scoped_session[AsyncSession], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, @@ -146,7 +146,7 @@ def __init__( async def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -208,7 +208,7 @@ async def get( self, item_id: Any, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, auto_expunge: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, @@ -246,7 +246,7 @@ async def get( async def get_one( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, load: LoadSpec | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, @@ -281,7 +281,7 @@ async def get_one( async def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -349,7 +349,7 @@ async def to_model( async def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, force_basic_query_mode: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, @@ -392,7 +392,7 @@ async def list_and_count( async def new( cls, session: AsyncSession | async_scoped_session[AsyncSession] | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, config: SQLAlchemyAsyncConfig | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -429,7 +429,7 @@ async def new( async def list( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, diff --git a/advanced_alchemy/service/_sync.py b/advanced_alchemy/service/_sync.py index c971d0d1..20251695 100644 --- a/advanced_alchemy/service/_sync.py +++ b/advanced_alchemy/service/_sync.py @@ -38,7 +38,7 @@ from advanced_alchemy.utils.dataclass import Empty, EmptyType if TYPE_CHECKING: - from sqlalchemy import Select, StatementLambdaElement + from sqlalchemy import Select from sqlalchemy.orm import InstrumentedAttribute, Session from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.sql import ColumnElement @@ -105,7 +105,7 @@ class SQLAlchemySyncRepositoryReadService(Generic[ModelT], ResultConverter): def __init__( self, session: Session | scoped_session[Session], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool = False, auto_refresh: bool = True, auto_commit: bool = False, @@ -147,7 +147,7 @@ def __init__( def count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, execution_options: dict[str, Any] | None = None, @@ -209,7 +209,7 @@ def get( self, item_id: Any, *, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, id_attribute: str | InstrumentedAttribute[Any] | None = None, auto_expunge: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, @@ -247,7 +247,7 @@ def get( def get_one( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, load: LoadSpec | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, @@ -282,7 +282,7 @@ def get_one( def get_one_or_none( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -350,7 +350,7 @@ def to_model( def list_and_count( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, force_basic_query_mode: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, @@ -393,7 +393,7 @@ def list_and_count( def new( cls, session: Session | scoped_session[Session] | None = None, - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, config: SQLAlchemySyncConfig | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, load: LoadSpec | None = None, @@ -430,7 +430,7 @@ def new( def list( self, *filters: StatementFilter | ColumnElement[bool], - statement: Select[tuple[ModelT]] | StatementLambdaElement | None = None, + statement: Select[tuple[ModelT]] | None = None, auto_expunge: bool | None = None, order_by: list[OrderingPair] | OrderingPair | None = None, error_messages: ErrorMessages | None | EmptyType = Empty, diff --git a/docs/conf.py b/docs/conf.py index 0170888d..0997ce3c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -149,6 +149,7 @@ (PY_CLASS, "Litestar"), (PY_CLASS, "DTOFieldDefinition"), (PY_CLASS, "advanced_alchemy.extensions.litestar.plugins._slots_base.SlotsBase"), + (PY_CLASS, "advanced_alchemy.filters.StatementTypeT"), (PY_CLASS, "Default"), (PY_CLASS, "bytes-like"), (PY_CLASS, "scoped_session"), diff --git a/pyproject.toml b/pyproject.toml index 23cb786d..f47a77bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -421,6 +421,7 @@ venvPath = "." add_editors_note = true cache = true ruff_fix = true +ruff_format = true update_docstrings = true [tool.unasyncd.files] diff --git a/tests/integration/test_lambda_stmt.py b/tests/integration/test_lambda_stmt.py deleted file mode 100644 index 24a10ac6..00000000 --- a/tests/integration/test_lambda_stmt.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest -from sqlalchemy import ForeignKey, String, create_engine, func, select -from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, sessionmaker - -from advanced_alchemy.base import UUIDBase -from advanced_alchemy.repository import SQLAlchemySyncRepository - -if TYPE_CHECKING: - from pytest import MonkeyPatch - -xfail = pytest.mark.xfail - - -# This test does not work when run in group for some reason. -# If you run individually, it'll pass. -@pytest.mark.xdist_group("lambda") -@xfail() -def test_lambda_statement_quirks(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: - from sqlalchemy.orm import DeclarativeBase - - from advanced_alchemy import base - - orm_registry = base.create_registry() - - class NewUUIDBase(base.UUIDPrimaryKey, base.CommonTableAttributes, DeclarativeBase): - registry = orm_registry - - class NewBigIntBase(base.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase): - registry = orm_registry - - monkeypatch.setattr(base, "UUIDBase", NewUUIDBase) - - monkeypatch.setattr(base, "BigIntBase", NewBigIntBase) - - class Country(UUIDBase): - name: Mapped[str] = mapped_column(String(length=50)) # pyright: ignore - - class State(UUIDBase): - name: Mapped[str] = mapped_column(String(length=50)) # pyright: ignore - country_id: Mapped[str] = mapped_column(ForeignKey(Country.id)) - - country = relationship(Country) - - class USStateRepository(SQLAlchemySyncRepository[State]): - model_type = State - - engine = create_engine("sqlite:///:memory:", echo=True) - session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False) - - with engine.begin() as conn: - State.metadata.create_all(conn) - - engine.clear_compiled_cache() - with session_factory() as db_session: - usa = Country(name="United States of America") - france = Country(name="France") - db_session.add(usa) - db_session.add(france) - - california = State(name="California", country=usa) - oregon = State(name="Oregon", country=usa) - ile_de_france = State(name="Île-de-France", country=france) - - repo = USStateRepository(session=db_session) - repo.add_many([california, oregon, ile_de_france], auto_commit=True) - - # Using only the ORM, this works fine: - - stmt = select(State).where(State.country_id == usa.id).with_only_columns(func.count()) - count = db_session.execute(stmt).scalar_one() - assert count == 2, f"Expected 2, got {count}" - count = db_session.execute(stmt).scalar_one() - assert count == 2, f"Expected 2, got {count}" - - stmt = select(State).where(State.country == usa).with_only_columns(func.count(), maintain_column_froms=True) - count = db_session.execute(stmt).scalar_one() - assert count == 2, f"Expected 2, got {count}" - count = db_session.execute(stmt).scalar_one() - assert count == 2, f"Expected 2, got {count}" - - # Using the repository, this works: - stmt1 = select(State).where(State.country_id == usa.id) - - count = repo.count(statement=stmt1) - assert count == 2, f"Expected 2, got {count}" - - count = repo.count(statement=stmt1) - assert count == 2, f"Expected 2, got {count}" - - # But this would fail (only after the second query) (lambda caching test): - stmt2 = select(State).where(State.country == usa) - - count = repo.count(statement=stmt2) - assert count == 2, f"Count Expected 2, got {count}" - - count = repo.count(State.country == usa) - assert count == 2, f"Count Expression Expected 2, got {count}" - - count = repo.count(statement=stmt2) - assert count == 2, f"Recount Statement Expected 2, got {count}" - - # It also failed with - states = repo.list(statement=stmt2) - count = len(states) - assert count == 2, f"List Statement Expected 2, got {count}" - - _states, count = repo.list_and_count(statement=stmt2) - assert count == 2, f"List and Count Expected 2, got {count}" - _states, count = repo.list_and_count(statement=stmt2, force_basic_query_mode=True) - assert count == 2, f"List and Count (force_basic_query_mode) Expected 2, got {count}" diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index 48f8bd47..04bf835f 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -598,7 +598,7 @@ async def test_sqlalchemy_repo_list_with_pagination( """Test list operation with pagination.""" statement = MagicMock() mock_repo_execute.return_value = MagicMock() - mocker.patch.object(LimitOffset, "append_to_lambda_statement", return_value=statement) + mocker.patch.object(LimitOffset, "append_to_statement", return_value=statement) mock_repo_execute.return_value = MagicMock() await maybe_async(mock_repo.list(LimitOffset(2, 3))) mock_repo._execute.assert_called_with(statement, uniquify=False) # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage] @@ -613,7 +613,7 @@ async def test_sqlalchemy_repo_list_with_before_after_filter( statement = MagicMock() mocker.patch.object(mock_repo.model_type.updated_at, "__lt__", return_value="lt") mocker.patch.object(mock_repo.model_type.updated_at, "__gt__", return_value="gt") - mocker.patch.object(BeforeAfter, "append_to_lambda_statement", return_value=statement) + mocker.patch.object(BeforeAfter, "append_to_statement", return_value=statement) mock_repo_execute.return_value = MagicMock() await maybe_async(mock_repo.list(BeforeAfter("updated_at", datetime.max, datetime.min))) mock_repo._execute.assert_called_with(statement, uniquify=False) # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage] @@ -629,7 +629,7 @@ async def test_sqlalchemy_repo_list_with_on_before_after_filter( statement = MagicMock() mocker.patch.object(mock_repo.model_type.updated_at, "__le__", return_value="le") mocker.patch.object(mock_repo.model_type.updated_at, "__ge__", return_value="ge") - mocker.patch.object(OnBeforeAfter, "append_to_lambda_statement", return_value=statement) + mocker.patch.object(OnBeforeAfter, "append_to_statement", return_value=statement) mock_repo_execute.return_value = MagicMock() await maybe_async(mock_repo.list(OnBeforeAfter("updated_at", datetime.max, datetime.min))) @@ -646,7 +646,7 @@ async def test_sqlalchemy_repo_list_with_collection_filter( field_name = "id" mock_repo_execute.return_value = MagicMock() mock_repo.statement.where.return_value = mock_repo.statement # pyright: ignore[reportFunctionMemberAccess] - mocker.patch.object(CollectionFilter, "append_to_lambda_statement", return_value=mock_repo.statement) + mocker.patch.object(CollectionFilter, "append_to_statement", return_value=mock_repo.statement) values = [1, 2, 3] await maybe_async(mock_repo.list(CollectionFilter(field_name, values))) mock_repo._execute.assert_called_with(mock_repo.statement, uniquify=False) # pyright: ignore[reportFunctionMemberAccess,reportPrivateUsage] @@ -664,7 +664,7 @@ async def test_sqlalchemy_repo_list_with_null_collection_filter( mock_repo.statement.where.return_value = mock_repo.statement # pyright: ignore[reportFunctionMemberAccess] monkeypatch.setattr( CollectionFilter, - "append_to_lambda_statement", + "append_to_statement", MagicMock(return_value=mock_repo.statement), ) await maybe_async(mock_repo.list(CollectionFilter(field_name, None))) # pyright: ignore[reportFunctionMemberAccess,reportUnknownArgumentType] @@ -685,7 +685,7 @@ async def test_sqlalchemy_repo_empty_list_with_collection_filter( await maybe_async(mock_repo.list(CollectionFilter(field_name, values))) monkeypatch.setattr( CollectionFilter, - "append_to_lambda_statement", + "append_to_statement", MagicMock(return_value=mock_repo.statement), ) await maybe_async(mock_repo.list(CollectionFilter(field_name, values))) @@ -704,7 +704,7 @@ async def test_sqlalchemy_repo_list_with_not_in_collection_filter( mock_repo.statement.where.return_value = mock_repo.statement # pyright: ignore[reportFunctionMemberAccess] monkeypatch.setattr( NotInCollectionFilter, - "append_to_lambda_statement", + "append_to_statement", MagicMock(return_value=mock_repo.statement), ) values = [1, 2, 3] @@ -724,7 +724,7 @@ async def test_sqlalchemy_repo_list_with_null_not_in_collection_filter( mock_repo.statement.where.return_value = mock_repo.statement # pyright: ignore[reportFunctionMemberAccess] monkeypatch.setattr( NotInCollectionFilter, - "append_to_lambda_statement", + "append_to_statement", MagicMock(return_value=mock_repo.statement), ) await maybe_async(mock_repo.list(NotInCollectionFilter[str](field_name, None))) # pyright: ignore[reportFunctionMemberAccess] @@ -801,7 +801,7 @@ def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyn """Ensures we don't filter on an empty collection.""" statement = MagicMock() filter = CollectionFilter(field_name="id", values=[]) # type:ignore[var-annotated] - statement = filter.append_to_lambda_statement(statement, MagicMock()) # type:ignore[assignment] + statement = filter.append_to_statement(statement, MagicMock()) # type:ignore[assignment] mock_repo.statement.where.assert_not_called() # pyright: ignore[reportFunctionMemberAccess] @@ -813,12 +813,23 @@ def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyn (datetime.max, None), ], ) -def test_filter_on_datetime_field(before: datetime, after: datetime, mock_repo: SQLAlchemyAsyncRepository[Any]) -> None: +def test_filter_on_datetime_field( + before: datetime, + after: datetime, + mock_repo: SQLAlchemyAsyncRepository[Any], + mocker: MockerFixture, + monkeypatch: MonkeyPatch, +) -> None: """Test through branches of _filter_on_datetime_field()""" - field_mock = MagicMock() + field_mock = MagicMock(return_value=before or after) statement = MagicMock() field_mock.__gt__ = field_mock.__lt__ = lambda self, other: True # pyright: ignore[reportFunctionMemberAccess,reportUnknownLambdaType] + monkeypatch.setattr( + BeforeAfter, + "append_to_statement", + MagicMock(return_value=mock_repo.statement), + ) filter = BeforeAfter(field_name="updated_at", before=before, after=after) - statement = filter.append_to_lambda_statement(statement, MagicMock()) # type:ignore[assignment] + statement = filter.append_to_statement(statement, MagicMock(return_value=before or after)) # type:ignore[assignment] mock_repo.model_type.updated_at = field_mock mock_repo.statement.where.assert_not_called() # pyright: ignore[reportFunctionMemberAccess] diff --git a/uv.lock b/uv.lock index c9d96d4e..f79c0732 100644 --- a/uv.lock +++ b/uv.lock @@ -1285,29 +1285,29 @@ wheels = [ [[package]] name = "faker" -version = "30.8.2" +version = "33.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/df/7574c0d13f0bbab725e52bec4b00783aaa14163fe9093dde11a928a4c638/faker-30.8.2.tar.gz", hash = "sha256:aa31b52cdae3673d6a78b4857c7bcdc0e98f201a5cb77d7827fa9e6b5876da94", size = 1808329 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/c7/0782eb872b96ee571701237be0dd85aef3713a15045ba42752a8aac7c8ce/faker-33.0.0.tar.gz", hash = "sha256:9b01019c1ddaf2253ca2308c0472116e993f4ad8fc9905f82fa965e0c6f932e9", size = 1850076 } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/82/f7d0c0a4ab512fd1572a315eec903d50a578c75d5aa894cf3f5cc04025e5/Faker-30.8.2-py3-none-any.whl", hash = "sha256:4a82b2908cd19f3bba1a4da2060cc4eb18a40410ccdf9350d071d79dc92fe3ce", size = 1846458 }, + { url = "https://files.pythonhosted.org/packages/c0/c3/0451555e7a9a233bc17f128cff7654ec60036d4ccbb8397dd949f28df176/Faker-33.0.0-py3-none-any.whl", hash = "sha256:68e5580cb6b4226710886e595eabc13127149d6e71e9d1db65506a7fbe2c7fce", size = 1889118 }, ] [[package]] name = "fastapi" -version = "0.115.4" +version = "0.115.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/db/5781f19bd30745885e0737ff3fdd4e63e7bc691710f9da691128bb0dc73b/fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349", size = 300737 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/29/f71316b9273b6552a263748e49cd7b83898dc9499a663d30c7b9cb853cb8/fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289", size = 301047 } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/f6/af0d1f58f86002be0cf1e2665cdd6f7a4a71cdc8a7a9438cdc9e3b5375fe/fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742", size = 94732 }, + { url = "https://files.pythonhosted.org/packages/54/c4/148d5046a96c428464557264877ae5a9338a83bbe0df045088749ec89820/fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796", size = 94866 }, ] [package.optional-dependencies] @@ -1553,7 +1553,7 @@ wheels = [ [[package]] name = "google-cloud-spanner" -version = "3.50.0" +version = "3.50.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1564,21 +1564,21 @@ dependencies = [ { name = "protobuf" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/c2/46b5401d253b096712ed3a3fb4c66d667561ea7efff6d2b7b0016fc410c6/google_cloud_spanner-3.50.0.tar.gz", hash = "sha256:d585aa9c2adca1ec494db93706ea0a51bce924a4e154800e049e5599adc9deb5", size = 575252 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/42/1459e6677381389cd96c098b5f515b4c6da28c899389d84043e9ac11b096/google_cloud_spanner-3.50.1.tar.gz", hash = "sha256:82937ea03b55de86bddf622f555aeae65ae86bb4f28ab35bd920ac505917c9bf", size = 575566 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a9/fde441a9f46ff072c5d9c9b91a6b4a2b114acf1eeb5853fd0080f9e9d6b7/google_cloud_spanner-3.50.0-py2.py3-none-any.whl", hash = "sha256:6410e15d0f2b9229c0d23592ea98ab6b7387eff9e609b770b3ee0ecab0ce1fed", size = 416374 }, + { url = "https://files.pythonhosted.org/packages/1c/ce/35abda83da7eacf458a55f628d350bb7c8f91215767b45a5cd2da4528e9b/google_cloud_spanner-3.50.1-py2.py3-none-any.whl", hash = "sha256:9d399aa53fae58816023a4eb31fa267333c3a879a9221229e7f06fdda543884a", size = 416477 }, ] [[package]] name = "googleapis-common-protos" -version = "1.65.0" +version = "1.66.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/3b/1599ceafa875ffb951480c8c74f4b77646a6b80e80970698f2aa93c216ce/googleapis_common_protos-1.65.0.tar.gz", hash = "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0", size = 113657 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/a7/8e9cccdb1c49870de6faea2a2764fa23f627dd290633103540209f03524c/googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c", size = 114376 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/08/49bfe7cf737952cc1a9c43e80cc258ed45dad7f183c5b8276fc94cb3862d/googleapis_common_protos-1.65.0-py2.py3-none-any.whl", hash = "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63", size = 220890 }, + { url = "https://files.pythonhosted.org/packages/a0/0f/c0713fb2b3d28af4b2fded3291df1c4d4f79a00d15c2374a9e010870016c/googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed", size = 221682 }, ] [package.optional-dependencies] @@ -3009,15 +3009,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.388" +version = "1.1.389" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/83/e9867538a794638d2d20ac3ab3106a31aca1d9cfea530c9b2921809dae03/pyright-1.1.388.tar.gz", hash = "sha256:0166d19b716b77fd2d9055de29f71d844874dbc6b9d3472ccd22df91db3dfa34", size = 21939 } +sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/57/7fb00363b7f267a398c5bdf4f55f3e64f7c2076b2e7d2901b3373d52b6ff/pyright-1.1.388-py3-none-any.whl", hash = "sha256:c7068e9f2c23539c6ac35fc9efac6c6c1b9aa5a0ce97a9a8a6cf0090d7cbf84c", size = 18579 }, + { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 }, ] [[package]] @@ -3255,16 +3255,16 @@ wheels = [ [[package]] name = "rich-click" -version = "1.8.3" +version = "1.8.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/a9/a1f1af87e83832d794342fbc09c96cc7cd6798b8dfb8adfbe6ccbef8d70c/rich_click-1.8.3.tar.gz", hash = "sha256:6d75bdfa7aa9ed2c467789a0688bc6da23fbe3a143e19aa6ad3f8bac113d2ab3", size = 38209 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f4/e48dc2850662526a26fb0961aacb0162c6feab934312b109b748ae4efee2/rich_click-1.8.4.tar.gz", hash = "sha256:0f49471f04439269d0e66a6f43120f52d11d594869a2a0be600cfb12eb0616b9", size = 38247 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ea/5a0c5a8e6532e971983d1b0fc99268eb66a10f489da35d9022ce01044191/rich_click-1.8.3-py3-none-any.whl", hash = "sha256:636d9c040d31c5eee242201b5bf4f2d358bfae4db14bb22ec1cafa717cfd02cd", size = 35032 }, + { url = "https://files.pythonhosted.org/packages/84/f3/72f93d8494ee641bde76bfe1208cf4abc44c6f9448673762f6077bc162d6/rich_click-1.8.4-py3-none-any.whl", hash = "sha256:2d2841b3cebe610d5682baa1194beaf78ab00c4fa31931533261b5eba2ee80b7", size = 35071 }, ] [[package]] @@ -4024,11 +4024,11 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/82/dfa23ec2cbed08a801deab02fe7c904bfb00765256b155941d789a338c68/sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e", size = 84502 } +sdist = { url = "https://files.pythonhosted.org/packages/57/61/5bc3aff85dc5bf98291b37cf469dab74b3d0aef2dd88eade9070a200af05/sqlparse-0.5.2.tar.gz", hash = "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f", size = 84951 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156 }, + { url = "https://files.pythonhosted.org/packages/7a/13/5f6654c9d915077fae255686ca6fa42095b62b7337e3e1aa9e82caa6f43a/sqlparse-0.5.2-py3-none-any.whl", hash = "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e", size = 44407 }, ] [[package]] @@ -4298,11 +4298,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "75.3.0.20241107" +version = "75.3.0.20241112" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/8b/626019697000a97e7eddf7d4f5a6951ac72aa167b6d29f06954a901229d4/types-setuptools-75.3.0.20241107.tar.gz", hash = "sha256:f66710e1cd4a936e5fcc12d4e49be1a67c34372cf753e87ebe704426451b4012", size = 43666 } +sdist = { url = "https://files.pythonhosted.org/packages/39/01/8027bb0285d5c65747b53b9d3b11805447bd8df794164a63ac470efbefed/types-setuptools-75.3.0.20241112.tar.gz", hash = "sha256:f9e1ebd17a56f606e16395c4ee4efa1cdc394b9a2a0ee898a624058b4b62ef8f", size = 43723 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/29/71f63f64bbdca142995ac816d1fa72fae298886c73f21086eed05596fc95/types_setuptools-75.3.0.20241107-py3-none-any.whl", hash = "sha256:bc6de6e2bcb6d610556304d0a69fe4ca208ac4896162647314ecfd9fd73d8550", size = 67584 }, + { url = "https://files.pythonhosted.org/packages/35/6a/cbe08e52046544198a69feefc998c0ec198099e96f4254dfb6581e7b482f/types_setuptools-75.3.0.20241112-py3-none-any.whl", hash = "sha256:78cb5fef4a6056d2f37114d27da90f4655a306e4e38042d7034a8a880bc3f5dd", size = 67639 }, ] [[package]]