diff --git a/mise.toml b/mise.toml index fb6083e..af6ddc5 100644 --- a/mise.toml +++ b/mise.toml @@ -191,11 +191,22 @@ description = "Run snapshot-based tests and update snapshots" depends = "uv:install" run = "uv run pytest {{vars.local_pytest_options}} -m snapshot --snapshot-update" -[tasks."test:add-new-snapshots"] +[tasks."test:add-new-snapshots:inline"] +description = "Run snapshot-based tests and add new snapshots" +depends = "uv:install" +wait_for = "uv:install" +run = "uv run pytest {{vars.local_pytest_options}} -n=0 -m inline_snapshot --inline-snapshot=create" + +[tasks."test:add-new-snapshots:file"] description = "Run snapshot-based tests and add new snapshots" depends = "uv:install" +wait_for = "uv:install" run = "uv run pytest {{vars.local_pytest_options}} -m snapshot --snapshot-update-new-only" +[tasks."test:add-new-snapshots"] +description = "Run snapshot-based tests and add new snapshots" +depends = ["test:add-new-snapshots:inline", "test:add-new-snapshots:file"] + # ############### # CI # ############### diff --git a/pyproject.toml b/pyproject.toml index b65b2cf..ece417d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ postgres = ["asyncpg>=0.29.0", "psycopg[binary,pool]>=3.2.3"] test = [ "covdefaults", "diff-cover>=10.3.0", + "inline-snapshot>=0.34.2", "pytest", "pytest-asyncio>=0.24", "pytest-cov", @@ -120,6 +121,7 @@ packages = ["src/strawchemy"] [tool.pytest.ini_options] markers = [ "snapshot: snapshot-based tests using syrupy", + "inline_snapshot: snapshot-based tests using inline-snapshots", "integration: SQLAlchemy integration tests", "geo: Geoalchemy2 integration tests", "aiosqlite: SQLite (aiosqlite) Tests", diff --git a/src/strawchemy/transpiler/_planner.py b/src/strawchemy/transpiler/_planner.py index b50d592..86b2e9b 100644 --- a/src/strawchemy/transpiler/_planner.py +++ b/src/strawchemy/transpiler/_planner.py @@ -13,6 +13,7 @@ from sqlalchemy import and_, func, inspect, not_, null, or_, select, true from sqlalchemy.orm import Mapper, RelationshipProperty, aliased, class_mapper, contains_eager, load_only, raiseload +from sqlalchemy.sql.util import ClauseAdapter from strawchemy.constants import AGGREGATIONS_KEY from strawchemy.dto.inspectors import SQLAlchemyGraphQLInspector @@ -983,69 +984,130 @@ def _plan_relation_joins( return tuple(joins) -def _build_root_aggregations( - query_graph: QueryGraph[Any], context: PlanContext[Any] -) -> dict[QueryNodeType, Label[Any]]: - """Builds root aggregation window-function columns. +def _use_distinct_rank(query_graph: QueryGraph[Any], context: PlanContext[Any]) -> bool: + """Decides whether DISTINCT ON should be emulated via a window rank function. + + On dialects with native ``DISTINCT ON`` (PostgreSQL), emulation is only needed when + ordering is present *and* the distinct-on fields are not the leftmost ORDER BY columns. + With no ordering, or with a compatible prefix ordering, native DISTINCT ON is used. On + dialects without native support, any distinct clause is emulated. Args: query_graph: The graph representation of the query being planned. - context: The shared planning context (``aliases`` used for inspect/root_alias). + context: The shared planning context (``db_features``, ``deterministic_ordering``, + ``default_order_by`` read here). Returns: - A mapping of query node to its labelled root aggregation function - expression, preserving the order in which aggregation children appear, - or an empty mapping when no root aggregations are present. + True if a RANK/row_number window emulation should be used, False for native/none. """ - aliases = context.aliases - result: dict[QueryNodeType, Label[Any]] = {} - if query_graph.selection_tree is None: - return result - selection_tree = query_graph.selection_tree - aggregation_tree = selection_tree.find_child(lambda child: child.value.name == AGGREGATIONS_KEY) - if not aggregation_tree: - return result - for child in aggregation_tree.children: - result.update(aliases.inspect(child).output_functions(aliases.root_alias, lambda func: func.over())) - return result + if not context.db_features.supports_distinct_on: + return bool(query_graph.distinct_on) + if not query_graph.distinct_on: + return False + has_ordering = bool(query_graph.order_by_tree or context.deterministic_ordering or context.default_order_by) + if not has_ordering: + return False + # Native DISTINCT ON requires the distinct-on fields to be the leftmost ORDER BY + # columns, in order; otherwise fall back to row_number emulation. + distinct_fields = [enum.field_definition for enum in query_graph.distinct_on] + order_nodes = query_graph.order_by_nodes + if len(order_nodes) < len(distinct_fields): + return True + is_order_prefix = all( + order_nodes[index].value.model_field is field.model_field for index, field in enumerate(distinct_fields) + ) + return not is_order_prefix -def _use_distinct_rank(query_graph: QueryGraph[Any], context: PlanContext[Any]) -> bool: - """Decides whether DISTINCT ON should be emulated via a RANK() window function. +@dataclass(frozen=True) +class UserStatementPlan: + """Encapsulates applying a user-provided base ``filter_statement``. - Args: - query_graph: The graph representation of the query being planned. - context: The shared planning context (``db_features``, ``deterministic_ordering``, - ``default_order_by`` read here). + A user statement is applied either by inlining its WHERE predicates directly (when the + statement is a plain WHERE-only select of the root model) or, otherwise, via a + primary-key semi-join to the statement reduced to its primary-key columns. - Returns: - True if RANK() window function should be used for DISTINCT ON, False otherwise. + Attributes: + statement: The user-provided base filter statement. + aliases: The query scope, providing the root model and root alias. """ - if context.db_features.supports_distinct_on: - return bool( - query_graph.distinct_on - and (query_graph.order_by_tree or context.deterministic_ordering or context.default_order_by) + + statement: Select[Any] + aliases: AliasContext[Any] + + def is_trivial(self) -> bool: + """Decides whether the statement is a plain WHERE-only select of the root model. + + Compares the statement (public ``ClauseElement.compare``) against a canonical + ``select(model).where(whereclause)``. Any additional clause — join, GROUP BY, + DISTINCT, HAVING, LIMIT, OFFSET, ORDER BY — makes the comparison fail, leaving the + statement to the semi-join path. + + A statement's ``execution_options`` are not preserved when inlined; the emitted SQL + is identical, but non-SQL driver hints attached to the filter statement are dropped. + + Returns: + True if the statement can be inlined as direct WHERE predicates. + """ + canonical = select(self.aliases.model) + where = self.statement.whereclause + if where is not None: + canonical = canonical.where(where) + try: + return self.statement.compare(canonical) + except AttributeError: # uncomparable statement → fall back to the semi-join path + return False + + def inline_where(self, alias: AliasedClass[Any]) -> ColumnElement[bool] | None: + """Adapts the statement's WHERE predicate onto ``alias``. + + The base statement references the model's base-table columns; the main query selects + from an aliased root, so the predicate is rewritten to bind to that alias. + + Args: + alias: The aliased entity the main query selects from. + + Returns: + The adapted WHERE predicate, or None when the statement has no WHERE clause. + """ + where = self.statement.whereclause + if where is None: + return None + adapter = ClauseAdapter(inspect(alias).selectable) + return adapter.traverse(where) + + def semijoin(self) -> FilterSemiJoin: + """Builds the PK semi-join from the root alias to the filter-statement subquery. + + Returns: + A FilterSemiJoin with the subquery alias and the PK-equality onclause. + """ + root_mapper = class_mapper(self.aliases.model) + pk_attributes = SQLAlchemyInspector.pk_attributes(root_mapper) + filter_alias = cast("Alias", self.statement.with_only_columns(*pk_attributes).subquery().alias()) + on_clause = and_( + *[getattr(self.aliases.root_alias, attr.key) == filter_alias.c[attr.key] for attr in pk_attributes] ) - return bool(query_graph.distinct_on) + return FilterSemiJoin(alias=filter_alias, onclause=on_clause) + def apply_to_statement(self, statement: Select[Any], alias: AliasedClass[Any]) -> Select[Any]: + """Applies the user statement to a select being assembled (subquery path). -def _build_filter_semijoin(context: PlanContext[Any]) -> FilterSemiJoin: - """Builds the PK semi-join from the root alias to the filter-statement subquery. + Owns the inline-vs-semijoin decision so call sites do not branch: a trivial + statement's WHERE is inlined onto ``alias``; otherwise the PK semi-join is joined in. - Args: - context: The shared planning context (``aliases`` root alias/model, ``statement``). + Args: + statement: The select being assembled. + alias: The aliased root the statement selects from. - Returns: - A FilterSemiJoin with the subquery alias and the PK-equality onclause. - """ - aliases = context.aliases - statement = context.statement - assert statement is not None - root_mapper = class_mapper(aliases.model) - pk_attributes = SQLAlchemyInspector.pk_attributes(root_mapper) - filter_alias = cast("Alias", statement.with_only_columns(*pk_attributes).subquery().alias()) - on_clause = and_(*[getattr(aliases.root_alias, attr.key) == filter_alias.c[attr.key] for attr in pk_attributes]) - return FilterSemiJoin(alias=filter_alias, onclause=on_clause) + Returns: + The statement with the user filter applied. + """ + if not self.is_trivial(): + semijoin = self.semijoin() + return statement.join(semijoin.alias, onclause=semijoin.onclause) + where = self.inline_where(alias) + return statement.where(where) if where is not None else statement def _dedup_agg_joins(joins: list[Join]) -> list[Join]: @@ -1073,6 +1135,48 @@ def _dedup_agg_joins(joins: list[Join]) -> list[Join]: return result +def _clause_element(column: ColumnElement[Any]) -> ColumnElement[Any]: + """Returns the underlying clause element for a column, unwrapping ORM attributes. + + ``InstrumentedAttribute`` wraps a ``ColumnElement`` via ``__clause_element__()``; calling + ``.compare()`` on the attribute directly does not delegate to the underlying element, so + two attributes that map to the same column but were constructed via different access paths + (e.g. ``getattr(alias, key)`` vs ``field.adapt_to_entity(insp)``) incorrectly compare as + unequal. Unwrapping to the ``AnnotatedColumn`` level gives correct structural equality. + + Args: + column: A column or ORM attribute to unwrap. + + Returns: + The underlying ``ColumnElement``. + """ + if hasattr(column, "__clause_element__"): + return column.__clause_element__() + return column + + +def _dedup_columns(columns: Sequence[ColumnElement[Any]]) -> list[ColumnElement[Any]]: + """Removes structurally duplicate columns, preserving first-seen order. + + The inner subquery accumulates projection columns from several sources (selection, + order-by nodes, root-aggregation arguments). A column reached through more than one + source is the same expression but a distinct object, which SQLAlchemy would otherwise + emit twice with an auto-suffixed label (``id`` and ``id__1``). + + Args: + columns: The assembled projection columns, in selection order. + + Returns: + The columns with later structural duplicates dropped. + """ + unique: list[ColumnElement[Any]] = [] + for column in columns: + col_elem = _clause_element(column) + if not any(col_elem is _clause_element(seen) or col_elem.compare(_clause_element(seen)) for seen in unique): + unique.append(column) + return unique + + def _referenced_function_nodes(filter_plan: FilterPlan, order: OrderPlan, proj: ProjectionPlan) -> list[QueryNodeType]: """Computes the function nodes hoisted into the pagination/distinct subquery. @@ -1155,12 +1259,15 @@ def _assemble_inner_statement( ) only_columns.append(rank_label) - inner_statement = select(inspect(inner_alias)).options(raiseload("*")).with_only_columns(*only_columns) - # Filtered + paginated gets: restrict the subquery to filter-visible rows via a PK - # semi-join, so LIMIT/OFFSET count only those rows. + inner_statement = ( + select(inspect(inner_alias)).options(raiseload("*")).with_only_columns(*_dedup_columns(only_columns)) + ) + # Filtered + paginated gets: restrict the subquery to filter-visible rows; a trivial + # statement inlines the WHERE directly, otherwise a PK semi-join is used. if context.statement is not None: - semijoin = _build_filter_semijoin(context) - inner_statement = inner_statement.join(semijoin.alias, onclause=semijoin.onclause) + inner_statement = UserStatementPlan(context.statement, context.aliases).apply_to_statement( + inner_statement, inner_alias + ) for join in sorted(inner_joins): inner_statement = inner_statement.join(join.target, onclause=join.onclause, isouter=join.is_outer) if where: @@ -1230,8 +1337,16 @@ def _plan_projection_phase( A ProjectionPhase with the root-aggregation column map and the projection plan. """ root_aggregations_map: dict[QueryNodeType, Label[Any]] = {} - if query_graph.selection_tree and query_graph.selection_tree.graph_metadata.metadata.root_aggregations: - root_aggregations_map = _build_root_aggregations(query_graph, context) + selection_tree = query_graph.selection_tree + if selection_tree is not None and selection_tree.graph_metadata.metadata.root_aggregations: + aggregation_tree = selection_tree.find_child(lambda child: child.value.name == AGGREGATIONS_KEY) + if aggregation_tree: + for child in aggregation_tree.children: + root_aggregations_map.update( + context.aliases.inspect(child).output_functions( + context.aliases.root_alias, lambda func: func.over() + ) + ) projection_plan = ProjectionPlan.plan(query_graph, context, agg_plan) return ProjectionPhase(root_aggregations_map=root_aggregations_map, projection_plan=projection_plan) @@ -1424,8 +1539,15 @@ def plan_query( deduped_joins = _dedup_agg_joins(pre_dedup_joins) filter_semijoin: FilterSemiJoin | None = None + where_predicates: tuple[ColumnElement[bool], ...] = filter_plan.where if context.statement is not None: - filter_semijoin = _build_filter_semijoin(context) + user_statement = UserStatementPlan(context.statement, context.aliases) + if user_statement.is_trivial(): + inlined = user_statement.inline_where(context.aliases.root_alias) + if inlined is not None: + where_predicates = (*where_predicates, inlined) + else: + filter_semijoin = user_statement.semijoin() column_map: dict[QueryNodeType, ColumnElement[Any]] = { **aggregation_plan.columns, @@ -1438,7 +1560,7 @@ def plan_query( filter_semijoin=filter_semijoin, projection_columns=projection_plan.columns, load_options=projection_plan.load_options, - where=filter_plan.where, + where=where_predicates, order_by=order.expressions, joins=tuple(deduped_joins), root_aggregation_functions=root_aggregations, diff --git a/tasks.md b/tasks.md index 428fb64..300dff4 100644 --- a/tasks.md +++ b/tasks.md @@ -147,12 +147,28 @@ Run tests ## `test:add-new-snapshots` -- Depends: uv:install +- Depends: test:add-new-snapshots:inline, test:add-new-snapshots:file - **Usage**: `test:add-new-snapshots` Run snapshot-based tests and add new snapshots +## `test:add-new-snapshots:file` + +- Depends: uv:install + +- **Usage**: `test:add-new-snapshots:file` + +Run snapshot-based tests and add new snapshots + +## `test:add-new-snapshots:inline` + +- Depends: uv:install + +- **Usage**: `test:add-new-snapshots:inline` + +Run snapshot-based tests and add new snapshots + ## `test:coverage` Run tests with coverage diff --git a/tests/fixtures.py b/tests/fixtures.py index e819ba6..a7b179d 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -5,7 +5,6 @@ import pytest import strawberry -from syrupy.assertion import SnapshotAssertion from syrupy.extensions.amber import AmberSnapshotExtension from strawchemy import Strawchemy, StrawchemyConfig diff --git a/tests/integration/__snapshots__/test_aggregations.ambr b/tests/integration/__snapshots__/test_aggregations.ambr index 460f390..20ae6ab 100644 --- a/tests/integration/__snapshots__/test_aggregations.ambr +++ b/tests/integration/__snapshots__/test_aggregations.ambr @@ -1752,8 +1752,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -1769,8 +1768,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %s, @@ -1786,8 +1784,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT $1::INTEGER @@ -1803,8 +1800,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -2066,8 +2062,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -2083,8 +2078,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %s, @@ -2100,8 +2094,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT $1::INTEGER @@ -2117,8 +2110,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -2363,8 +2355,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -2380,8 +2371,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %s, @@ -2397,8 +2387,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT $1::INTEGER @@ -2414,8 +2403,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -2677,8 +2665,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -2694,8 +2681,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %s, @@ -2711,8 +2697,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT $1::INTEGER @@ -2728,8 +2713,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -3239,8 +3223,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -3256,8 +3239,7 @@ avg(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -3369,8 +3351,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -3386,8 +3367,7 @@ sum(fruit.sweetness) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.sweetness AS sweetness, - fruit.sweetness AS sweetness__1 + fruit.sweetness AS sweetness FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -3499,8 +3479,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -3516,8 +3495,7 @@ avg(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? @@ -3629,8 +3607,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT %(param_1)s::INTEGER @@ -3646,8 +3623,7 @@ sum(fruit.water_percent) OVER () AS anon_1 FROM ( SELECT fruit.id AS id, - fruit.water_percent AS water_percent, - fruit.water_percent AS water_percent__1 + fruit.water_percent AS water_percent FROM fruit AS fruit ORDER BY fruit.id ASC LIMIT ? diff --git a/tests/integration/__snapshots__/test_custom_resolver.ambr b/tests/integration/__snapshots__/test_custom_resolver.ambr index 12c3599..40387dd 100644 --- a/tests/integration/__snapshots__/test_custom_resolver.ambr +++ b/tests/integration/__snapshots__/test_custom_resolver.ambr @@ -4,12 +4,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = ? - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = ? ''' # --- # name: test_get_one[session-tracked-async-asyncmy_engine] @@ -17,12 +12,7 @@ SELECT color.name, color.id FROM color AS color - INNER JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %s - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %s ''' # --- # name: test_get_one[session-tracked-async-asyncpg_engine] @@ -30,12 +20,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = $1::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = $1::VARCHAR ''' # --- # name: test_get_one[session-tracked-async-psycopg_async_engine] @@ -43,12 +28,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %(name_1)s::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %(name_1)s::VARCHAR ''' # --- # name: test_get_one[session-tracked-sync-psycopg_engine] @@ -56,12 +36,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %(name_1)s::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %(name_1)s::VARCHAR ''' # --- # name: test_get_one[session-tracked-sync-sqlite_engine] @@ -69,11 +44,6 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = ? - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = ? ''' # --- diff --git a/tests/integration/__snapshots__/test_distinct_on.ambr b/tests/integration/__snapshots__/test_distinct_on.ambr index a40cac3..5f70ff1 100644 --- a/tests/integration/__snapshots__/test_distinct_on.ambr +++ b/tests/integration/__snapshots__/test_distinct_on.ambr @@ -6,8 +6,6 @@ FROM ( SELECT user.id AS id, user.name AS name, - user.name AS name__1, - user.id AS id__1, row_number() OVER (PARTITION BY user.name ORDER BY user.name ASC, user.id DESC) AS anon_1 FROM USER AS USER ORDER BY user.name ASC, @@ -25,8 +23,6 @@ FROM ( SELECT user.id AS id, user.name AS name, - user.name AS name__1, - user.id AS id__1, row_number() OVER (PARTITION BY user.name ORDER BY user.name ASC, user.id DESC) AS anon_1 FROM USER AS USER ORDER BY user.name ASC, @@ -39,57 +35,30 @@ # --- # name: test_distinct_and_order_by[session-tracked-async-asyncpg_engine] ''' - SELECT "user".name, + SELECT DISTINCT + ON ("user".name) "user".name, "user".id - FROM ( - SELECT "user".id AS id, - "user".name AS name, - "user".name AS name__1, - "user".id AS id__1, - row_number() OVER (PARTITION BY "user".name ORDER BY "user".name ASC, "user".id DESC) AS anon_1 - FROM "user" AS "user" - ORDER BY "user".name ASC, - "user".id DESC - ) AS "user" - WHERE "user".anon_1 = $1::INTEGER + FROM "user" AS "user" ORDER BY "user".name ASC, "user".id DESC ''' # --- # name: test_distinct_and_order_by[session-tracked-async-psycopg_async_engine] ''' - SELECT "user".name, + SELECT DISTINCT + ON ("user".name) "user".name, "user".id - FROM ( - SELECT "user".id AS id, - "user".name AS name, - "user".name AS name__1, - "user".id AS id__1, - row_number() OVER (PARTITION BY "user".name ORDER BY "user".name ASC, "user".id DESC) AS anon_1 - FROM "user" AS "user" - ORDER BY "user".name ASC, - "user".id DESC - ) AS "user" - WHERE "user".anon_1 = %(param_1)s::INTEGER + FROM "user" AS "user" ORDER BY "user".name ASC, "user".id DESC ''' # --- # name: test_distinct_and_order_by[session-tracked-sync-psycopg_engine] ''' - SELECT "user".name, + SELECT DISTINCT + ON ("user".name) "user".name, "user".id - FROM ( - SELECT "user".id AS id, - "user".name AS name, - "user".name AS name__1, - "user".id AS id__1, - row_number() OVER (PARTITION BY "user".name ORDER BY "user".name ASC, "user".id DESC) AS anon_1 - FROM "user" AS "user" - ORDER BY "user".name ASC, - "user".id DESC - ) AS "user" - WHERE "user".anon_1 = %(param_1)s::INTEGER + FROM "user" AS "user" ORDER BY "user".name ASC, "user".id DESC ''' @@ -101,8 +70,6 @@ FROM ( SELECT user.id AS id, user.name AS name, - user.name AS name__1, - user.id AS id__1, row_number() OVER (PARTITION BY user.name ORDER BY user.name ASC, user.id DESC) AS anon_1 FROM USER AS USER ORDER BY user.name ASC, diff --git a/tests/integration/__snapshots__/test_optimizations.ambr b/tests/integration/__snapshots__/test_optimizations.ambr deleted file mode 100644 index 1ff9fde..0000000 --- a/tests/integration/__snapshots__/test_optimizations.ambr +++ /dev/null @@ -1,946 +0,0 @@ -# serializer version: 1 -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-multiple-aggregations-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > ? - AND anon_1.sum_1 > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-multiple-aggregations-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %s - AND anon_1.sum_1 > %s - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-multiple-aggregations-asyncpg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > $1::FLOAT - AND anon_1.sum_1 > $2::FLOAT - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-multiple-aggregations-psycopg_async_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %(param_1)s - AND anon_1.sum_1 > %(param_2)s - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > ? - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > %s - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-asyncpg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > $1::INTEGER - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-psycopg_async_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > %(param_1)s::INTEGER - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-same-aggregation-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > ? - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-same-aggregation-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %s - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-same-aggregation-asyncpg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > $1::FLOAT - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-filter-order-by-same-aggregation-psycopg_async_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %(param_1)s - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-order-by-multiple-aggregations-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-order-by-multiple-aggregations-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-order-by-multiple-aggregations-asyncpg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-order-by-multiple-aggregations-psycopg_async_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-filter-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - WHERE anon_1.count_1 > ? - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-filter-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - WHERE anon_1.count_1 > %s - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-filter-asyncpg_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - WHERE anon_1.count_1 > $1::INTEGER - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-filter-psycopg_async_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - WHERE anon_1.count_1 > %(param_1)s::INTEGER - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-multiple-aggregations-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-multiple-aggregations-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-multiple-aggregations-asyncpg_engine] - ''' - SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - JOIN LATERAL ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-multiple-aggregations-psycopg_async_engine] - ''' - SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - JOIN LATERAL ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-order-by-aiosqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-order-by-asyncmy_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - INNER JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-order-by-asyncpg_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-async-output-order-by-psycopg_async_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-multiple-aggregations-psycopg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %(param_1)s - AND anon_1.sum_1 > %(param_2)s - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-multiple-aggregations-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > ? - AND anon_1.sum_1 > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-order-by-psycopg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > %(param_1)s::INTEGER - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-order-by-same-aggregation-psycopg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > %(param_1)s - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-order-by-same-aggregation-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.avg_1 > ? - ORDER BY anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-filter-order-by-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - avg(fruit_2.water_percent) AS avg_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE anon_1.count_1 > ? - ORDER BY anon_1.count_1 ASC, - anon_1.avg_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-order-by-multiple-aggregations-psycopg_engine] - ''' - SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN LATERAL ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1 - FROM fruit AS fruit_2 - WHERE color.id = fruit_2.color_id - ) AS anon_1 - ON TRUE - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-order-by-multiple-aggregations-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT avg(fruit_2.water_percent) AS avg_1, - sum(fruit_2.sweetness) AS sum_1, - fruit_2.color_id AS color_id - FROM fruit AS fruit_2 - WHERE fruit_2.color_id IS NOT NULL - GROUP BY fruit_2.color_id - ) SELECT fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - ORDER BY anon_1.avg_1 ASC, - anon_1.sum_1 ASC, - fruit_1.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-filter-psycopg_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - WHERE anon_1.count_1 > %(param_1)s::INTEGER - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-filter-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - WHERE anon_1.count_1 > ? - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-multiple-aggregations-psycopg_engine] - ''' - SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - JOIN LATERAL ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-multiple-aggregations-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT max(fruit_1.sweetness) AS max_1, - max(fruit_1.water_percent) AS max_2, - max(fruit_1.name) AS max_3, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.max_1, - anon_1.max_2, - anon_1.max_3 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY color.id ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-order-by-psycopg_engine] - ''' - SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN LATERAL ( - SELECT count(*) AS count_1 - FROM fruit AS fruit_1 - WHERE color.id = fruit_1.color_id - ) AS anon_1 - ON TRUE - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_aggregation_computation_is_reused[session-tracked-sync-output-order-by-sqlite_engine] - ''' - WITH anon_1 AS ( - SELECT count(*) AS count_1, - fruit_1.color_id AS color_id - FROM fruit AS fruit_1 - WHERE fruit_1.color_id IS NOT NULL - GROUP BY fruit_1.color_id - ) SELECT color.id, - anon_1.count_1 - FROM color AS color - JOIN anon_1 - ON color.id = anon_1.color_id - ORDER BY anon_1.count_1 ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-inner-join-rewrite-aiosqlite_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-inner-join-rewrite-asyncmy_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - INNER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > %s - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-inner-join-rewrite-asyncpg_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > $1::INTEGER - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-inner-join-rewrite-psycopg_async_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > %(sweetness_1)s::INTEGER - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-no-inner-join-rewrite-aiosqlite_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-no-inner-join-rewrite-asyncmy_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > %s - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-no-inner-join-rewrite-asyncpg_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > $1::TIMESTAMP WITHOUT TIME - ZONE - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-async-no-inner-join-rewrite-psycopg_async_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > %(created_at_1)s::TIMESTAMP WITHOUT TIME - ZONE - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-sync-inner-join-rewrite-psycopg_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > %(sweetness_1)s::INTEGER - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-sync-inner-join-rewrite-sqlite_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE fruit_1.sweetness > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-sync-no-inner-join-rewrite-psycopg_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > %(created_at_1)s::TIMESTAMP WITHOUT TIME - ZONE - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- -# name: test_inner_join_rewriting[session-tracked-sync-no-inner-join-rewrite-sqlite_engine] - ''' - SELECT fruit_1.sweetness, - fruit_1.id, - color.id AS id_1 - FROM color AS color - LEFT OUTER JOIN fruit AS fruit_1 - ON color.id = fruit_1.color_id - WHERE color.created_at > ? - ORDER BY color.id ASC, - fruit_1.id ASC - ''' -# --- diff --git a/tests/integration/__snapshots__/test_queries.ambr b/tests/integration/__snapshots__/test_queries.ambr index 8fb1517..56d3c88 100644 --- a/tests/integration/__snapshots__/test_queries.ambr +++ b/tests/integration/__snapshots__/test_queries.ambr @@ -5,15 +5,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT ? OFFSET ? @@ -27,15 +21,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - INNER JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT %s, %s @@ -49,15 +37,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT $1::INTEGER OFFSET $2::INTEGER @@ -71,15 +53,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT %(param_1)s::INTEGER OFFSET %(param_2)s::INTEGER @@ -93,15 +69,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT %(param_1)s::INTEGER OFFSET %(param_2)s::INTEGER @@ -115,15 +85,9 @@ color.id FROM ( SELECT color.name AS name, - color.id AS id, - color.id AS id__1 + color.id AS id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name IN (__[POSTCOMPILE_name_1]) - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name IN (__[POSTCOMPILE_name_1]) ORDER BY color.id ASC LIMIT ? OFFSET ? @@ -136,12 +100,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = ? - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = ? ORDER BY color.id ASC ''' # --- @@ -150,12 +109,7 @@ SELECT color.name, color.id FROM color AS color - INNER JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %s - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %s ORDER BY color.id ASC ''' # --- @@ -164,12 +118,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = $1::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = $1::VARCHAR ORDER BY color.id ASC ''' # --- @@ -178,12 +127,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %(name_1)s::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %(name_1)s::VARCHAR ORDER BY color.id ASC ''' # --- @@ -192,12 +136,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = %(name_1)s::VARCHAR - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = %(name_1)s::VARCHAR ORDER BY color.id ASC ''' # --- @@ -206,12 +145,7 @@ SELECT color.name, color.id FROM color AS color - JOIN ( - SELECT color.id AS id - FROM color - WHERE color.name = ? - ) AS anon_1 - ON color.id = anon_1.id + WHERE color.name = ? ORDER BY color.id ASC ''' # --- diff --git a/tests/integration/__snapshots__/test_query_hooks.ambr b/tests/integration/__snapshots__/test_query_hooks.ambr index 0b3a8d9..0234eb3 100644 --- a/tests/integration/__snapshots__/test_query_hooks.ambr +++ b/tests/integration/__snapshots__/test_query_hooks.ambr @@ -1164,8 +1164,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT ? @@ -1194,8 +1193,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT %s, @@ -1224,8 +1222,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT $1::INTEGER @@ -1254,8 +1251,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT %(param_1)s::INTEGER @@ -1330,8 +1326,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT %(param_1)s::INTEGER @@ -1360,8 +1355,7 @@ SELECT fruit_1.id, color.id AS id_1 FROM ( - SELECT color.id AS id, - color.id AS id__1 + SELECT color.id AS id FROM color AS color ORDER BY color.id ASC LIMIT ? diff --git a/tests/integration/test_optimizations.py b/tests/integration/test_optimizations.py deleted file mode 100644 index 94bf051..0000000 --- a/tests/integration/test_optimizations.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from tests.integration.fixtures import QueryTracker -from tests.typing import AnyQueryExecutor -from tests.utils import maybe_async - -if TYPE_CHECKING: - from syrupy.assertion import SnapshotAssertion - -pytestmark = [pytest.mark.integration] - - -@pytest.mark.parametrize( - "query", - [ - pytest.param( - """ - { - colors(orderBy: { fruitsAggregate: { count: ASC } }) { - fruitsAggregate { - count - } - } - } - """, - id="output-order-by", - ), - pytest.param( - """ - { - colors(filter: { fruitsAggregate: { count: { predicate: { gt: 0 } } } }) { - fruitsAggregate { - count - } - } - } - """, - id="output-filter", - ), - pytest.param( - """ - { - colors( - filter: { fruitsAggregate: { count: { predicate: { gt: 0 } } } }, - orderBy: { fruitsAggregate: { avg: { waterPercent: ASC } } } - ) { - fruits { - id - } - } - } - """, - id="filter-order-by", - ), - pytest.param( - """ - { - colors( - filter: { fruitsAggregate: { avg: { arguments: [waterPercent] predicate: { gt: 0 } } } }, - orderBy: { fruitsAggregate: { avg: { waterPercent: ASC } } } - ) { - fruits { - id - } - } - } - """, - id="filter-order-by-same-aggregation", - ), - pytest.param( - """ - { - colors { - fruitsAggregate { - max { - sweetness - waterPercent - name - } - } - } - } - """, - id="output-multiple-aggregations", - ), - pytest.param( - """ - { - colors( - filter: { - fruitsAggregate: { - sum: { arguments: [sweetness], predicate: { gt: 0 } }, - avg: { arguments: [waterPercent], predicate: { gt: 0 } } - } - } - ) { - fruits { - id - } - } - } - """, - id="filter-multiple-aggregations", - ), - pytest.param( - """ - { - colors( - orderBy: { fruitsAggregate: { sum: { sweetness: ASC }, avg: { waterPercent: ASC } } } - ) { - fruits { - id - } - } - } - """, - id="order-by-multiple-aggregations", - ), - ], -) -@pytest.mark.snapshot -async def test_aggregation_computation_is_reused( - query: str, - any_query: AnyQueryExecutor, - query_tracker: QueryTracker, - sql_snapshot: SnapshotAssertion, -) -> None: - """Test that aggregation computation is reused when filtering and ordering by the same aggregation. - - A single query is issued and the snapshot shows one shared aggregation subquery rather - than a duplicate, regardless of SQLAlchemy's auto-generated alias name. - """ - result = await maybe_async(any_query(query)) - - assert not result.errors - assert result.data - - assert query_tracker.query_count == 1 - assert query_tracker[0].statement_formatted == sql_snapshot - - -@pytest.mark.parametrize( - ("query", "inner_join_expected"), - [ - pytest.param( - """ - { - colors(filter: { fruits: { sweetness: { gt: 1 } } }) { - fruits { - sweetness - } - } - } - """, - True, - id="inner-join-rewrite", - ), - pytest.param( - """ - { - colors(filter: { createdAt: { gt: "1220-01-01T00:00:00" } }) { - fruits { - sweetness - } - } - } - """, - False, - id="no-inner-join-rewrite", - ), - ], -) -@pytest.mark.snapshot -async def test_inner_join_rewriting( - query: str, - inner_join_expected: bool, - any_query: AnyQueryExecutor, - query_tracker: QueryTracker, - sql_snapshot: SnapshotAssertion, -) -> None: - """Test that if WHERE condition only references columns from the null-supplying side of the join, use an inner join.""" - result = await maybe_async(any_query(query)) - assert not result.errors - assert result.data - assert query_tracker.query_count == 1 - assert query_tracker[0].statement_formatted == sql_snapshot - - if inner_join_expected: - assert "LEFT OUTER JOIN" not in query_tracker[0].statement_str - assert query_tracker[0].statement_str.count("JOIN") == 1 - else: - assert query_tracker[0].statement_str.count("LEFT OUTER JOIN") == 1 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..aefa7bd --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import Result + +from strawchemy.transpiler import AsyncQueryExecutor, SyncQueryExecutor + +if TYPE_CHECKING: + from sqlalchemy import Select + + +def empty_query_result() -> MagicMock: + """Builds a mock ``Result`` that yields no rows for any access pattern. + + Returns: + A ``MagicMock`` standing in for a SQLAlchemy ``Result`` with empty row access. + """ + result = MagicMock(spec=Result) + result.all.return_value = [] + result.unique.return_value = result + result.scalars.return_value = result + result.one_or_none.return_value = None + return result + + +@pytest.fixture +def captured_statements(monkeypatch: pytest.MonkeyPatch) -> list[Select[Any]]: + """Captures each emitted ``Select`` without touching a database. + + Patches the sync and async executors' ``execute`` to record ``self.statement()`` + and return an empty result, so ``schema.execute_sync`` runs fully DB-free. + + Args: + monkeypatch: pytest fixture used to patch the executors' ``execute`` methods. + + Returns: + A list appended with one statement per executor execution. + """ + captured: list[Select[Any]] = [] + + def _execute(self: SyncQueryExecutor[Any], session: Any) -> MagicMock: # noqa: ARG001 + # This DB-free path always emits a Select (plan.emit()); the executor's + # StatementLambdaElement branch is never taken here, so narrowing is safe. + captured.append(cast("Select[Any]", self.statement())) + return empty_query_result() + + async def _async_execute(self: AsyncQueryExecutor[Any], session: Any) -> MagicMock: # noqa: ARG001 + captured.append(cast("Select[Any]", self.statement())) + return empty_query_result() + + monkeypatch.setattr(SyncQueryExecutor[Any], "execute", _execute) + monkeypatch.setattr(AsyncQueryExecutor[Any], "execute", _async_execute) + return captured diff --git a/tests/unit/schemas/optimizations.py b/tests/unit/schemas/optimizations.py new file mode 100644 index 0000000..2dd488d --- /dev/null +++ b/tests/unit/schemas/optimizations.py @@ -0,0 +1,40 @@ +"""DB-free strawchemy schema over unit ``Color``/``Fruit`` for optimization tests. + +Built with the ``postgresql`` dialect, but executed under each runtime dialect: the +transpiler re-reads the runtime dialect name at execution time, so the same static +schema emits dialect-specific SQL. The aggregation functions exercised +(count/avg/sum/max) exist in every supported dialect's feature set. +""" + +from __future__ import annotations + +import strawberry + +from strawchemy import Strawchemy +from tests.unit.models import Color, Fruit + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(Fruit, include="all", override=True) +class FruitType: ... + + +@strawchemy.type(Color, include="all", order="all", override=True) +class ColorType: ... + + +@strawchemy.filter(Color, include="all") +class ColorFilter: ... + + +@strawchemy.order(Color, include="all") +class ColorOrder: ... + + +@strawberry.type +class Query: + colors: list[ColorType] = strawchemy.field(filter_input=ColorFilter, order_by_input=ColorOrder) + + +schema = strawberry.Schema(query=Query) diff --git a/tests/unit/transpiler/test_optimizations.py b/tests/unit/transpiler/test_optimizations.py new file mode 100644 index 0000000..71ffaaa --- /dev/null +++ b/tests/unit/transpiler/test_optimizations.py @@ -0,0 +1,602 @@ +"""DB-free unit tests for transpiler SQL optimizations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from inline_snapshot import snapshot + +from tests.unit.schemas.optimizations import schema +from tests.unit.utils import SQLA_DIALECTS, DialectContext +from tests.utils import format_sql + +if TYPE_CHECKING: + from sqlalchemy import Select + +# Module-level snapshots storing every (query, dialect) combination; required because a single +# ``snapshot()`` literal cannot be a param under stacked parametrize (cross-product). + +AGGREGATION_SQL = snapshot( + { + "output-order-by-postgresql": [ + "SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT count(*) AS count_1", + " FROM fruit AS fruit_1", + " WHERE color.id = fruit_1.color_id", + " ) AS anon_1", + " ON TRUE", + " ORDER BY anon_1.count_1 ASC", + ], + "output-order-by-sqlite": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " ORDER BY anon_1.count_1 ASC", + ], + "output-order-by-mysql": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " ORDER BY anon_1.count_1 ASC", + ], + "output-filter-postgresql": [ + "SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT count(*) AS count_1", + " FROM fruit AS fruit_1", + " WHERE color.id = fruit_1.color_id", + " ) AS anon_1", + " ON TRUE", + " WHERE anon_1.count_1 > %(param_1)s", + " ORDER BY color.id ASC", + ], + "output-filter-sqlite": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " WHERE anon_1.count_1 > ?", + " ORDER BY color.id ASC", + ], + "output-filter-mysql": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.count_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " WHERE anon_1.count_1 > %s", + " ORDER BY color.id ASC", + ], + "filter-order-by-postgresql": [ + "SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT count(*) AS count_1,", + " avg(fruit_2.sweetness) AS avg_1", + " FROM fruit AS fruit_2", + " WHERE color.id = fruit_2.color_id", + " ) AS anon_1", + " ON TRUE", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.count_1 > %(param_1)s", + " ORDER BY anon_1.count_1 ASC,", + " anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "filter-order-by-sqlite": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " avg(fruit_2.sweetness) AS avg_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.count_1 > ?", + " ORDER BY anon_1.count_1 ASC,", + " anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "filter-order-by-mysql": [ + "WITH anon_1 AS (", + " SELECT count(*) AS count_1,", + " avg(fruit_2.sweetness) AS avg_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.count_1 > %s", + " ORDER BY anon_1.count_1 ASC,", + " anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "filter-order-by-same-aggregation-postgresql": [ + "SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT avg(fruit_2.sweetness) AS avg_1", + " FROM fruit AS fruit_2", + " WHERE color.id = fruit_2.color_id", + " ) AS anon_1", + " ON TRUE", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > %(param_1)s", + " ORDER BY anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "filter-order-by-same-aggregation-sqlite": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > ?", + " ORDER BY anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "filter-order-by-same-aggregation-mysql": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > %s", + " ORDER BY anon_1.avg_1 ASC,", + " fruit_1.id ASC", + ], + "output-multiple-aggregations-postgresql": [ + "SELECT color.id,", + " anon_1.max_1,", + " anon_1.max_2", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT max(fruit_1.sweetness) AS max_1,", + " max(fruit_1.name) AS max_2", + " FROM fruit AS fruit_1", + " WHERE color.id = fruit_1.color_id", + " ) AS anon_1", + " ON TRUE", + " ORDER BY color.id ASC", + ], + "output-multiple-aggregations-sqlite": [ + "WITH anon_1 AS (", + " SELECT max(fruit_1.sweetness) AS max_1,", + " max(fruit_1.name) AS max_2,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.max_1,", + " anon_1.max_2", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " ORDER BY color.id ASC", + ], + "output-multiple-aggregations-mysql": [ + "WITH anon_1 AS (", + " SELECT max(fruit_1.sweetness) AS max_1,", + " max(fruit_1.name) AS max_2,", + " fruit_1.color_id AS color_id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.color_id IS NOT NULL", + " GROUP BY fruit_1.color_id", + " ) SELECT color.id,", + " anon_1.max_1,", + " anon_1.max_2", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " ORDER BY color.id ASC", + ], + "filter-multiple-aggregations-postgresql": [ + "SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1", + " FROM fruit AS fruit_2", + " WHERE color.id = fruit_2.color_id", + " ) AS anon_1", + " ON TRUE", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > %(param_1)s", + " AND anon_1.sum_1 > %(param_2)s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "filter-multiple-aggregations-sqlite": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > ?", + " AND anon_1.sum_1 > ?", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "filter-multiple-aggregations-mysql": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE anon_1.avg_1 > %s", + " AND anon_1.sum_1 > %s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "order-by-multiple-aggregations-postgresql": [ + "SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN LATERAL (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1", + " FROM fruit AS fruit_2", + " WHERE color.id = fruit_2.color_id", + " ) AS anon_1", + " ON TRUE", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " ORDER BY anon_1.avg_1 ASC,", + " anon_1.sum_1 ASC,", + " fruit_1.id ASC", + ], + "order-by-multiple-aggregations-sqlite": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " ORDER BY anon_1.avg_1 ASC,", + " anon_1.sum_1 ASC,", + " fruit_1.id ASC", + ], + "order-by-multiple-aggregations-mysql": [ + "WITH anon_1 AS (", + " SELECT avg(fruit_2.sweetness) AS avg_1,", + " sum(fruit_2.sweetness) AS sum_1,", + " fruit_2.color_id AS color_id", + " FROM fruit AS fruit_2", + " WHERE fruit_2.color_id IS NOT NULL", + " GROUP BY fruit_2.color_id", + " ) SELECT fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " INNER JOIN anon_1", + " ON color.id = anon_1.color_id", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " ORDER BY anon_1.avg_1 ASC,", + " anon_1.sum_1 ASC,", + " fruit_1.id ASC", + ], + } +) + +INNER_JOIN_SQL = snapshot( + { + "inner-join-rewrite-postgresql": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE fruit_1.sweetness > %(sweetness_1)s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "inner-join-rewrite-sqlite": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE fruit_1.sweetness > ?", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "inner-join-rewrite-mysql": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " INNER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE fruit_1.sweetness > %s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "no-inner-join-rewrite-postgresql": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE color.name = %(name_1)s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "no-inner-join-rewrite-sqlite": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE color.name = ?", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + "no-inner-join-rewrite-mysql": [ + "SELECT fruit_1.sweetness,", + " fruit_1.id,", + " color.id AS id_1", + " FROM color AS color", + " LEFT OUTER JOIN fruit AS fruit_1", + " ON color.id = fruit_1.color_id", + " WHERE color.name = %s", + " ORDER BY color.id ASC,", + " fruit_1.id ASC", + ], + } +) + + +@pytest.mark.inline_snapshot +@pytest.mark.parametrize("dialect_name", ["postgresql", "sqlite", "mysql"]) +@pytest.mark.parametrize( + "query", + [ + pytest.param( + """ + { + colors(orderBy: { fruitsAggregate: { count: ASC } }) { + fruitsAggregate { count } + } + } + """, + id="output-order-by", + ), + pytest.param( + """ + { + colors(filter: { fruitsAggregate: { count: { predicate: { gt: 0 } } } }) { + fruitsAggregate { count } + } + } + """, + id="output-filter", + ), + pytest.param( + """ + { + colors( + filter: { fruitsAggregate: { count: { predicate: { gt: 0 } } } }, + orderBy: { fruitsAggregate: { avg: { sweetness: ASC } } } + ) { + fruits { id } + } + } + """, + id="filter-order-by", + ), + pytest.param( + """ + { + colors( + filter: { fruitsAggregate: { avg: { arguments: [sweetness] predicate: { gt: 0 } } } }, + orderBy: { fruitsAggregate: { avg: { sweetness: ASC } } } + ) { + fruits { id } + } + } + """, + id="filter-order-by-same-aggregation", + ), + pytest.param( + """ + { + colors { + fruitsAggregate { + max { sweetness name } + } + } + } + """, + id="output-multiple-aggregations", + ), + pytest.param( + """ + { + colors( + filter: { + fruitsAggregate: { + sum: { arguments: [sweetness], predicate: { gt: 0 } }, + avg: { arguments: [sweetness], predicate: { gt: 0 } } + } + } + ) { + fruits { id } + } + } + """, + id="filter-multiple-aggregations", + ), + pytest.param( + """ + { + colors( + orderBy: { fruitsAggregate: { sum: { sweetness: ASC }, avg: { sweetness: ASC } } } + ) { + fruits { id } + } + } + """, + id="order-by-multiple-aggregations", + ), + ], +) +def test_aggregation_computation_is_reused( + query: str, dialect_name: str, captured_statements: list[Select[Any]], request: pytest.FixtureRequest +) -> None: + """A single query is emitted with the aggregation computation reused (no duplicate subquery). + + Filtering and ordering by the same aggregation share one subquery; distinct aggregations + each get their own. Verified DB-free by compiling the captured statement per dialect. + """ + result = schema.execute_sync(query, context_value=DialectContext(dialect_name)) # ty: ignore[invalid-argument-type] + + assert not result.errors + assert result.data + + assert len(captured_statements) == 1 + compiled = str(captured_statements[0].compile(dialect=SQLA_DIALECTS[dialect_name])) + assert format_sql(compiled).splitlines() == AGGREGATION_SQL[request.node.callspec.id] + + +@pytest.mark.inline_snapshot +@pytest.mark.parametrize("dialect_name", ["postgresql", "sqlite", "mysql"]) +@pytest.mark.parametrize( + ("query",), # noqa: PT006 + [ + pytest.param( + """ + { + colors(filter: { fruits: { sweetness: { gt: 1 } } }) { + fruits { sweetness } + } + } + """, + id="inner-join-rewrite", + ), + pytest.param( + """ + { + colors(filter: { name: { eq: "x" } }) { + fruits { sweetness } + } + } + """, + id="no-inner-join-rewrite", + ), + ], +) +def test_inner_join_rewriting( + query: str, dialect_name: str, captured_statements: list[Select[Any]], request: pytest.FixtureRequest +) -> None: + """A WHERE that only references the null-supplying side rewrites the LEFT OUTER JOIN to INNER. + + When the filter references the parent's own column instead, the outer join is preserved. + """ + result = schema.execute_sync(query, context_value=DialectContext(dialect_name)) # ty: ignore[invalid-argument-type] + + assert not result.errors + assert result.data + assert len(captured_statements) == 1 + + statement_str = str(captured_statements[0].compile(dialect=SQLA_DIALECTS[dialect_name])) + assert format_sql(statement_str).splitlines() == INNER_JOIN_SQL[request.node.callspec.id] diff --git a/tests/unit/transpiler/test_plan.py b/tests/unit/transpiler/test_plan.py index 934b3fe..697ce7e 100644 --- a/tests/unit/transpiler/test_plan.py +++ b/tests/unit/transpiler/test_plan.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock +import pytest +from inline_snapshot import snapshot from sqlalchemy import Select, func, select from sqlalchemy.dialects import postgresql, sqlite from sqlalchemy.orm import aliased, load_only @@ -15,6 +17,7 @@ from strawchemy.transpiler._query import HookApplier from strawchemy.transpiler.hook import QueryHook from tests.unit.models import Fruit +from tests.utils import format_sql if TYPE_CHECKING: from sqlalchemy.orm.util import AliasedClass @@ -28,6 +31,7 @@ def apply_hook(self, statement: Select[Any], alias: AliasedClass[Any]) -> Select return statement.where(alias.sweetness > 5) +@pytest.mark.inline_snapshot def test_emit_assembles_select_where_order() -> None: """QueryPlan.emit builds SELECT/WHERE/ORDER BY from plan leaf fragments.""" fruit = aliased(Fruit) @@ -40,14 +44,18 @@ def test_emit_assembles_select_where_order() -> None: order_by=(fruit.name.asc(),), ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - - assert "FROM fruit" in compiled - assert "WHERE" in compiled - assert "sweetness" in compiled - assert "ORDER BY" in compiled + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.id", + " FROM fruit AS fruit_1", + " WHERE fruit_1.sweetness > ?", + " ORDER BY fruit_1.name ASC", + ] + ) +@pytest.mark.inline_snapshot def test_emit_without_where_or_order() -> None: """A plan with no predicates or ordering emits a bare SELECT.""" fruit = aliased(Fruit) @@ -60,13 +68,19 @@ def test_emit_without_where_or_order() -> None: order_by=(), ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - - assert "FROM fruit" in compiled - assert "WHERE" not in compiled - assert "ORDER BY" not in compiled + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private", + " FROM fruit AS fruit_1", + ] + ) +@pytest.mark.inline_snapshot def test_emit_applies_filter_semijoin() -> None: """A filter_semijoin renders as a JOIN on the PK-equality onclause.""" fruit = aliased(Fruit) @@ -84,12 +98,24 @@ def test_emit_applies_filter_semijoin() -> None: order_by=(), ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - - assert "JOIN" in compiled - assert "FROM fruit" in compiled + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private", + " FROM fruit AS fruit_1", + " JOIN (", + " SELECT fruit.id AS id", + " FROM fruit", + " ) AS anon_1", + " ON fruit_1.id = anon_1.id", + ] + ) +@pytest.mark.inline_snapshot def test_emit_appends_root_aggregation_columns() -> None: """Root aggregation function columns are added to the projection.""" fruit = aliased(Fruit) @@ -104,12 +130,20 @@ def test_emit_appends_root_aggregation_columns() -> None: root_aggregation_functions=(total,), ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - - assert "count(*) OVER ()" in compiled - assert "total_count" in compiled + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private,", + " count(*) OVER () AS total_count", + " FROM fruit AS fruit_1", + ] + ) +@pytest.mark.inline_snapshot def test_emit_applies_limit_and_offset() -> None: """limit/offset render on the emitted statement.""" fruit = aliased(Fruit) @@ -123,11 +157,22 @@ def test_emit_applies_limit_and_offset() -> None: limit=5, offset=10, ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - assert "LIMIT" in compiled - assert "OFFSET" in compiled + + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private", + " FROM fruit AS fruit_1", + " LIMIT ?", + "OFFSET ?", + ] + ) +@pytest.mark.inline_snapshot def test_emit_applies_native_distinct_on() -> None: """use_distinct_on renders DISTINCT ON and pulls order-by columns into the SELECT list.""" fruit = aliased(Fruit) @@ -141,11 +186,22 @@ def test_emit_applies_native_distinct_on() -> None: distinct_on=(fruit.color_id,), # ty: ignore[invalid-argument-type] use_distinct_on=True, ) - compiled = str(plan.emit().compile(dialect=postgresql.dialect())) - assert "DISTINCT ON" in compiled - assert "color_id" in compiled + assert format_sql(str(plan.emit().compile(dialect=postgresql.dialect()))).splitlines() == snapshot( + [ + "SELECT DISTINCT", + " ON (fruit_1.color_id) fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private", + " FROM fruit AS fruit_1", + " ORDER BY fruit_1.color_id ASC", + ] + ) + +@pytest.mark.inline_snapshot def test_emit_replays_hook_specs() -> None: """QueryPlan.emit replays hook specs via HookApplier, applying apply_hook to the statement. @@ -173,10 +229,17 @@ def test_emit_replays_hook_specs() -> None: hook_applier=applier, ) - compiled = str(plan.emit().compile(dialect=sqlite.dialect())) - - assert "WHERE" in compiled - assert "sweetness" in compiled + assert format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))).splitlines() == snapshot( + [ + "SELECT fruit_1.name,", + " fruit_1.color_id,", + " fruit_1.sweetness,", + " fruit_1.id,", + " fruit_1.private", + " FROM fruit AS fruit_1", + " WHERE fruit_1.sweetness > ?", + ] + ) def test_query_plan_emit_is_a_method() -> None: diff --git a/tests/unit/transpiler/test_planner.py b/tests/unit/transpiler/test_planner.py deleted file mode 100644 index a34fca2..0000000 --- a/tests/unit/transpiler/test_planner.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Tests for the pure planner's frozen result dataclasses.""" - -from __future__ import annotations - -import dataclasses - -from strawchemy.transpiler._planner import AggregationPlan, FilterPlan, OrderPlan, ProjectionPlan - - -def test_pass_dataclasses_are_frozen_with_expected_fields() -> None: - """Each pass-result dataclass is frozen and exposes exactly its spec fields.""" - for cls in (AggregationPlan, FilterPlan, OrderPlan, ProjectionPlan): - assert dataclasses.is_dataclass(cls) - assert cls.__dataclass_params__.frozen is True - assert {f.name for f in dataclasses.fields(FilterPlan)} == {"where", "joins", "referenced_functions"} - assert {f.name for f in dataclasses.fields(OrderPlan)} == {"expressions", "joins", "referenced_functions"} - assert {f.name for f in dataclasses.fields(ProjectionPlan)} == { - "columns", - "load_options", - "aggregation_joins", - "hook_specs", - "referenced_functions", - "transform_map", - } - assert {f.name for f in dataclasses.fields(AggregationPlan)} == {"columns", "joins", "aliases", "node_functions"} - - -def test_pass_dataclasses_construct_with_no_arguments() -> None: - """Each pass-result dataclass is constructible with zero arguments (empty defaults).""" - assert AggregationPlan().joins == () - assert dict(AggregationPlan().columns) == {} - assert FilterPlan().where == () - assert FilterPlan().referenced_functions == frozenset() - assert OrderPlan().expressions == () - assert OrderPlan().joins == () - assert ProjectionPlan().columns == () - assert ProjectionPlan().load_options == () - assert dict(ProjectionPlan().transform_map) == {} - - -def test_aggregation_plan_exposes_plan_classmethod() -> None: - """AggregationPlan.plan is the public entry; the free plan_aggregations is gone.""" - import strawchemy.transpiler._planner as planner - - assert hasattr(AggregationPlan, "plan") - assert not hasattr(planner, "plan_aggregations") - - -def test_filter_plan_exposes_plan_classmethod() -> None: - """FilterPlan.plan is the public entry; the free plan_filter is gone.""" - import strawchemy.transpiler._planner as planner - - assert hasattr(FilterPlan, "plan") - assert not hasattr(planner, "plan_filter") - - -def test_order_plan_exposes_plan_classmethod() -> None: - """OrderPlan.plan is the public entry; the free plan_order is gone.""" - import strawchemy.transpiler._planner as planner - - assert hasattr(OrderPlan, "plan") - assert not hasattr(planner, "plan_order") - - -def test_projection_plan_exposes_plan_classmethod() -> None: - """ProjectionPlan.plan is the public entry; the free plan_projection is gone.""" - import strawchemy.transpiler._planner as planner - - assert hasattr(ProjectionPlan, "plan") - assert not hasattr(planner, "plan_projection") - - -def test_composer_phase_helpers_exist() -> None: - """plan_query/_plan_subquery share FilterPhase/ProjectionPhase extraction helpers.""" - import strawchemy.transpiler._planner as planner - - assert hasattr(planner, "_plan_filter_phase") - assert hasattr(planner, "_plan_projection_phase") diff --git a/tests/unit/transpiler/test_planner_helpers.py b/tests/unit/transpiler/test_planner_helpers.py new file mode 100644 index 0000000..b06e2a9 --- /dev/null +++ b/tests/unit/transpiler/test_planner_helpers.py @@ -0,0 +1,255 @@ +"""DB-free tests for the planner helpers and the filter-statement handling. + +The filter-statement tests drive the public ``plan_query`` entry point (building a +``PlanContext`` and ``QueryGraph`` and compiling the emitted statement) rather than poking +``UserStatementPlan`` directly, so they exercise the real inline-vs-semijoin wiring. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from inline_snapshot import snapshot +from sqlalchemy import Select, select +from sqlalchemy.dialects import sqlite +from sqlalchemy.orm import aliased + +from strawchemy.transpiler._planner import PlanContext, _dedup_columns, _use_distinct_rank, plan_query +from strawchemy.transpiler._query import QueryGraph +from tests.unit.models import Fruit +from tests.utils import format_sql + + +def _filter_statement_sql(statement: Select[tuple[Fruit]], *, limit: int | None = None) -> str: + """Builds and formats the SQL ``plan_query`` emits for a base ``filter_statement``. + + Compile is DB-free (sqlite dialect); the output is sqlparse-formatted to match the + integration ``.ambr`` snapshot style. + + Args: + statement: The user-provided base filter statement applied to the query. + limit: Optional pagination limit, forcing the inner-subquery path. + + Returns: + The formatted compiled SQL string for the emitted plan. + """ + context = PlanContext.create(Fruit, sqlite.dialect(), statement=statement) + plan = plan_query(QueryGraph(context.aliases), context, limit=limit) + return format_sql(str(plan.emit().compile(dialect=sqlite.dialect()))) + + +def test_dedup_columns_removes_structural_duplicates() -> None: + """A column repeated via distinct attribute objects is collapsed to one, order preserved.""" + fruit = aliased(Fruit) + # Capture the aliased attributes as concrete references before building the list. + id_col, name_col, sweetness_col = fruit.id, fruit.name, fruit.sweetness + cols = [id_col, name_col, id_col, sweetness_col] + + result = _dedup_columns(cols) # ty: ignore[invalid-argument-type] + + assert len(result) == 3 + # Verify first-seen order is preserved using object identity. + assert result == [id_col, name_col, sweetness_col] + + +def test_dedup_columns_keeps_distinct_columns() -> None: + """Different columns sharing a name across no aliasing are not collapsed.""" + fruit = aliased(Fruit) + cols = [fruit.id, fruit.name, fruit.sweetness] + + result = _dedup_columns(cols) # ty: ignore[invalid-argument-type] + + assert len(result) == 3 + + +def _distinct_enum(model_field: object) -> SimpleNamespace: + """Stand-in for an EnumDTO exposing field_definition.model_field.""" + return SimpleNamespace(field_definition=SimpleNamespace(model_field=model_field)) + + +def _order_node(model_field: object) -> SimpleNamespace: + """Stand-in for a QueryNodeType exposing value.model_field.""" + return SimpleNamespace(value=SimpleNamespace(model_field=model_field)) + + +def test_use_distinct_rank_native_when_postgres_and_prefix() -> None: + """Postgres + compatible prefix order -> native DISTINCT ON (no rank emulation).""" + col_name = object() + graph = SimpleNamespace( + distinct_on=[_distinct_enum(col_name)], + order_by_nodes=[_order_node(col_name)], + order_by_tree=object(), + ) + context = SimpleNamespace( + db_features=SimpleNamespace(supports_distinct_on=True), + deterministic_ordering=False, + default_order_by=(), + ) + assert _use_distinct_rank(graph, context) is False # ty: ignore[invalid-argument-type] + + +def test_use_distinct_rank_emulates_when_postgres_and_incompatible() -> None: + """Postgres + ordering that is not a distinct prefix -> rank emulation.""" + col_name, col_id = object(), object() + graph = SimpleNamespace( + distinct_on=[_distinct_enum(col_name)], + order_by_nodes=[_order_node(col_id)], + order_by_tree=object(), + ) + context = SimpleNamespace( + db_features=SimpleNamespace(supports_distinct_on=True), + deterministic_ordering=False, + default_order_by=(), + ) + assert _use_distinct_rank(graph, context) is True # ty: ignore[invalid-argument-type] + + +def test_use_distinct_rank_native_when_postgres_and_no_ordering() -> None: + """Postgres + distinct + no ordering -> native (unchanged behaviour).""" + col_name = object() + graph = SimpleNamespace( + distinct_on=[_distinct_enum(col_name)], + order_by_nodes=[], + order_by_tree=None, + ) + context = SimpleNamespace( + db_features=SimpleNamespace(supports_distinct_on=True), + deterministic_ordering=False, + default_order_by=(), + ) + assert _use_distinct_rank(graph, context) is False # ty: ignore[invalid-argument-type] + + +def test_use_distinct_rank_emulates_when_no_native_support() -> None: + """Non-postgres dialect always emulates when distinct is present.""" + col_name = object() + graph = SimpleNamespace( + distinct_on=[_distinct_enum(col_name)], + order_by_nodes=[], + order_by_tree=None, + ) + context = SimpleNamespace( + db_features=SimpleNamespace(supports_distinct_on=False), + deterministic_ordering=False, + default_order_by=(), + ) + assert _use_distinct_rank(graph, context) is True # ty: ignore[invalid-argument-type] + + +@pytest.mark.inline_snapshot +def test_trivial_filter_statement_is_inlined() -> None: + """A trivial WHERE-only filter statement is inlined directly, with no PK semi-join.""" + assert _filter_statement_sql(select(Fruit).where(Fruit.name == "x")).splitlines() == snapshot( + ["SELECT fruit.id", " FROM fruit AS fruit", " WHERE fruit.name = ?"] + ) + + +@pytest.mark.inline_snapshot +def test_trivial_filter_statement_is_inlined_in_pagination_subquery() -> None: + """With pagination the trivial filter is inlined inside the inner subquery, no semi-join.""" + assert _filter_statement_sql(select(Fruit).where(Fruit.name == "x"), limit=5).splitlines() == snapshot( + [ + "SELECT fruit.id", + " FROM (", + " SELECT fruit.id AS id", + " FROM fruit AS fruit", + " WHERE fruit.name = ?", + " LIMIT ?", + " OFFSET ?", + " ) AS fruit", + ] + ) + + +@pytest.mark.parametrize( + ("statement", "expected"), + [ + pytest.param( + select(Fruit).where(Fruit.name == "x").limit(5), + snapshot( + [ + "SELECT fruit.id", + " FROM fruit AS fruit", + " JOIN (", + " SELECT fruit.id AS id", + " FROM fruit", + " WHERE fruit.name = ?", + " LIMIT ?", + " OFFSET ?", + " ) AS anon_1", + " ON fruit.id = anon_1.id", + ] + ), + id="limit", + ), + pytest.param( + select(Fruit).where(Fruit.name == "x").group_by(Fruit.color_id), + snapshot( + [ + "SELECT fruit.id", + " FROM fruit AS fruit", + " JOIN (", + " SELECT fruit.id AS id", + " FROM fruit", + " WHERE fruit.name = ?", + " GROUP BY fruit.color_id", + " ) AS anon_1", + " ON fruit.id = anon_1.id", + ] + ), + id="group_by", + ), + pytest.param( + select(Fruit).where(Fruit.name == "x").distinct(), + snapshot( + [ + "SELECT fruit.id", + " FROM fruit AS fruit", + " JOIN (", + " SELECT DISTINCT fruit.id AS id", + " FROM fruit", + " WHERE fruit.name = ?", + " ) AS anon_1", + " ON fruit.id = anon_1.id", + ] + ), + id="distinct", + ), + pytest.param( + select(Fruit).join(Fruit.color).where(Fruit.name == "x"), + snapshot( + [ + "SELECT fruit.id", + " FROM fruit AS fruit", + " JOIN (", + " SELECT fruit.id AS id", + " FROM fruit", + " JOIN color", + " ON color.id = fruit.color_id", + " WHERE fruit.name = ?", + " ) AS anon_1", + " ON fruit.id = anon_1.id", + ] + ), + id="join", + ), + ], +) +@pytest.mark.inline_snapshot +def test_non_trivial_filter_statement_uses_semijoin(statement: Select[tuple[Fruit]], expected: list[str]) -> None: + """A non-trivial filter statement (LIMIT/GROUP BY/DISTINCT/JOIN) falls back to the PK semi-join.""" + assert _filter_statement_sql(statement).splitlines() == expected + + +def test_dedup_columns_collapses_distinct_objects_for_same_column() -> None: + """Two distinct objects for the same column collapse via structural compare.""" + from sqlalchemy import inspect as sqla_inspect + + fruit = aliased(Fruit) + attr = fruit.id # InstrumentedAttribute + col = sqla_inspect(fruit).selectable.c.id # distinct Column object for the same column + + result = _dedup_columns([attr, fruit.name, col]) # ty: ignore[invalid-argument-type] + + assert len(result) == 2 # attr and col collapse to one diff --git a/tests/unit/utils.py b/tests/unit/utils.py new file mode 100644 index 0000000..4d19e36 --- /dev/null +++ b/tests/unit/utils.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from unittest.mock import MagicMock + +from sqlalchemy import Dialect, Engine +from sqlalchemy.dialects import mysql, postgresql, sqlite +from sqlalchemy.orm import Session + +from strawchemy.typing import SupportedDialect + +SQLA_DIALECTS: dict[str, Dialect] = { + "postgresql": postgresql.dialect(), + "sqlite": sqlite.dialect(), + "mysql": mysql.dialect(), +} +"""Real dialect objects used to compile captured statements (no DB connection).""" + + +@dataclass +class DialectContext: + """Strawberry context whose fake session reports the requested dialect name. + + Only ``get_bind().dialect.name`` is read during planning, so the session is a + ``MagicMock`` and never executes anything. + """ + + dialect_name: SupportedDialect + session: MagicMock = field(init=False) + + def __post_init__(self) -> None: + dialect = MagicMock(spec=Dialect, name="DialectMock") + dialect.name = self.dialect_name + engine = MagicMock(spec=Engine, name="EngineMock", dialect=dialect) + self.session = MagicMock(spec=Session, name="SessionMock", get_bind=MagicMock(return_value=engine)) diff --git a/tests/utils.py b/tests/utils.py index 09d6e6c..b7be77a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,6 +7,7 @@ from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast, overload +import sqlparse import strawberry from sqlalchemy.ext.asyncio import AsyncSession from typing_extensions import TypeIs, override @@ -47,6 +48,10 @@ def factory_iterator() -> Generator[AnyFactory]: yield factory() +def format_sql(statement: str) -> str: + return sqlparse.format(statement, reindent_aligned=True, use_space_around_operators=True, keyword_case="upper") + + @overload def generate_query( session: Session, diff --git a/uv.lock b/uv.lock index df748e6..1f2cd7b 100644 --- a/uv.lock +++ b/uv.lock @@ -106,6 +106,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1107,6 +1116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "faker" version = "40.23.0" @@ -1407,6 +1425,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inline-snapshot" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens", marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd')" }, + { name = "executing", marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd')" }, + { name = "pytest", marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd')" }, + { name = "tomli", marker = "(python_full_version < '3.11' and extra == 'group-10-strawchemy-dev') or (python_full_version < '3.11' and extra == 'group-10-strawchemy-test') or (python_full_version < '3.11' and extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd') or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra == 'group-10-strawchemy-codeflash' and extra == 'group-10-strawchemy-dev') or (extra == 'group-10-strawchemy-dev' and extra == 'group-10-strawchemy-unasyncd') or (extra == 'group-10-strawchemy-test' and extra == 'group-10-strawchemy-unasyncd')" }, + { name = "typing-extensions", marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or (extra == 'group-10-strawchemy-build' and extra == 'group-10-strawchemy-codeflash') or (extra != 'group-10-strawchemy-codeflash' and extra != 'group-10-strawchemy-unasyncd')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/c3/b5c36ab59e355b8d7c59205b8903fa74458c6938c9691f7d7730404f94c9/inline_snapshot-0.34.2.tar.gz", hash = "sha256:d160cb6059e00916c2e846abc014ead6f01fb24479f13696fb8670d7a7937f67", size = 2641142, upload-time = "2026-06-19T21:17:27.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2a/84fe7ef052ac666e95e7cabdf0905aa3f2c90b5a117419760b39b577602a/inline_snapshot-0.34.2-py3-none-any.whl", hash = "sha256:743a514a08ffd0d2d62878b0208d4def5041585affa7db6b89f0ca2e23552361", size = 90717, upload-time = "2026-06-19T21:17:25.629Z" }, +] + [[package]] name = "inquirer" version = "3.4.1" @@ -3574,6 +3609,7 @@ dev = [ { name = "cryptography" }, { name = "debugpy" }, { name = "diff-cover" }, + { name = "inline-snapshot" }, { name = "nox", extra = ["uv"], marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or extra != 'group-10-strawchemy-unasyncd'" }, { name = "nox-uv" }, { name = "psycopg", extra = ["binary", "pool"], marker = "extra == 'group-10-strawchemy-dev' or extra == 'group-10-strawchemy-test' or extra != 'group-10-strawchemy-unasyncd'" }, @@ -3615,6 +3651,7 @@ postgres = [ test = [ { name = "covdefaults" }, { name = "diff-cover" }, + { name = "inline-snapshot" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -3654,6 +3691,7 @@ dev = [ { name = "cryptography" }, { name = "debugpy" }, { name = "diff-cover", specifier = ">=10.3.0" }, + { name = "inline-snapshot", specifier = ">=0.34.2" }, { name = "nox", extras = ["uv"] }, { name = "nox-uv" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.3" }, @@ -3695,6 +3733,7 @@ postgres = [ test = [ { name = "covdefaults" }, { name = "diff-cover", specifier = ">=10.3.0" }, + { name = "inline-snapshot", specifier = ">=0.34.2" }, { name = "pytest" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-cov" },