|
30 | 30 | requested_aggregates, |
31 | 31 | ) |
32 | 32 |
|
| 33 | +#: Where the window puts each row's number within its group. Dropped again |
| 34 | +#: before the row is turned back into a model, which knows no such column. |
| 35 | +_ROW_NUMBER_ALIAS = "_orm_rn" |
| 36 | + |
| 37 | +#: Where the grouped count puts each group's total. |
| 38 | +_COUNT_ALIAS = "_orm_count" |
| 39 | + |
33 | 40 | _TORTOISE_FIELD_MAP: dict[str, type] = { |
34 | 41 | "IntField": int, |
35 | 42 | "SmallIntField": int, |
@@ -669,24 +676,45 @@ async def batch_group_items( |
669 | 676 | per_group_limit: int, |
670 | 677 | order_input: Any | None = None, |
671 | 678 | ) -> dict[tuple, list[Any]]: |
672 | | - """Per-group fallback since Tortoise has limited window function support.""" |
673 | | - groups_qs = await query.group_by(*group_key_fields).values(*group_key_fields) |
674 | | - items_by_key: dict[tuple, list[Any]] = {} |
| 679 | + """Every group's first rows in one windowed query. |
| 680 | +
|
| 681 | + Tortoise has no window expression, so the query it built is wrapped in |
| 682 | + SQL that numbers rows within each group and keeps the low numbers. The |
| 683 | + wrapping keeps the placeholders and values Tortoise produced, so filter |
| 684 | + values are still bound rather than pasted into the statement. |
| 685 | + """ |
| 686 | + order_clauses = ( |
| 687 | + _build_tortoise_order_from_input(order_input) if order_input else [] |
| 688 | + ) |
| 689 | + sql, values = _windowed_sql( |
| 690 | + query, model, group_key_fields, order_clauses, per_group_limit |
| 691 | + ) |
| 692 | + rows = await query._db.execute_query_dict(sql, values) |
675 | 693 |
|
676 | | - for group_dict in groups_qs: |
| 694 | + items_by_key: dict[tuple, list[Any]] = defaultdict(list) |
| 695 | + for row in rows: |
677 | 696 | key = tuple( |
678 | | - str(group_dict[k]) if group_dict[k] is not None else None |
| 697 | + str(row[k]) if row.get(k) is not None else None |
679 | 698 | for k in group_key_fields |
680 | 699 | ) |
681 | | - scoped = query.filter(**{k: group_dict[k] for k in group_key_fields}) |
682 | | - if order_input: |
683 | | - order_clauses = _build_tortoise_order_from_input(order_input) |
684 | | - if order_clauses: |
685 | | - scoped = scoped.order_by(*order_clauses) |
686 | | - items = await scoped.limit(per_group_limit) |
687 | | - items_by_key[key] = list(items) |
688 | | - |
689 | | - return items_by_key |
| 700 | + row.pop(_ROW_NUMBER_ALIAS, None) |
| 701 | + items_by_key[key].append(model._init_from_db(**row)) |
| 702 | + return dict(items_by_key) |
| 703 | + |
| 704 | + async def group_counts( |
| 705 | + self, query: Any, key_field: str, info: Any |
| 706 | + ) -> dict[Any, int]: |
| 707 | + """Each group's real total, which the windowed page cannot report.""" |
| 708 | + from tortoise.functions import Count |
| 709 | + |
| 710 | + # Inherited ordering would drag non-grouped columns into the statement. |
| 711 | + counted = ( |
| 712 | + query.order_by() |
| 713 | + .group_by(key_field) |
| 714 | + .annotate(**{_COUNT_ALIAS: Count(query.model._meta.pk_attr)}) |
| 715 | + .values(key_field, _COUNT_ALIAS) |
| 716 | + ) |
| 717 | + return {row[key_field]: row[_COUNT_ALIAS] for row in await counted} |
690 | 718 |
|
691 | 719 | # -- Queryset overrides -------------------------------------------------- |
692 | 720 |
|
@@ -760,9 +788,64 @@ async def materialize_query(self, query: Any, info: Any) -> list[Any]: |
760 | 788 |
|
761 | 789 | # -- Optimizer ----------------------------------------------------------- |
762 | 790 |
|
| 791 | + _supports_windowed_pages = True |
| 792 | + |
763 | 793 | def optimizer_extension(self, **kwargs: Any) -> type[SchemaExtension]: |
764 | 794 | return OptimizerExtension.configure(backend=self, store=self._store) |
765 | 795 |
|
| 796 | + def instance_pk(self, instance: Any) -> Any: |
| 797 | + return getattr(instance, "pk", None) |
| 798 | + |
| 799 | + def _relation_connection_spec( |
| 800 | + self, model: type, field_name: str, relation: str |
| 801 | + ) -> Any: |
| 802 | + from tortoise.fields.relational import BackwardFKRelation |
| 803 | + |
| 804 | + from strawberry_orm.backends._base import RelationConnectionSpec |
| 805 | + |
| 806 | + field = model._meta.fields_map.get(relation) # type: ignore[attr-defined] |
| 807 | + key_field = getattr(field, "relation_field", None) |
| 808 | + # Only a reverse foreign key keeps the parent's key on the related row. |
| 809 | + # A many-to-many hides it in the through table, leaving the window |
| 810 | + # nothing to partition by. |
| 811 | + if not isinstance(field, BackwardFKRelation) or key_field is None: |
| 812 | + return None |
| 813 | + return RelationConnectionSpec( |
| 814 | + model=model, |
| 815 | + field_name=field_name, |
| 816 | + relation=relation, |
| 817 | + related_model=self._relation_target_model(model, relation), |
| 818 | + key_field=key_field, |
| 819 | + ) |
| 820 | + |
| 821 | + def relation_base_query(self, spec: Any, pks: list[Any], info: Any) -> Any: |
| 822 | + qs = spec.related_model.filter(**{f"{spec.key_field}__in": pks}) |
| 823 | + restrict = self.relation_scope( |
| 824 | + spec.model, spec.field_name, info, on=spec.relation |
| 825 | + ) |
| 826 | + return qs if restrict is None else restrict(qs, info) |
| 827 | + |
| 828 | + def _make_relation_query_resolver( |
| 829 | + self, model: type, field_name: str, relation: str |
| 830 | + ) -> Any: |
| 831 | + backend = self |
| 832 | + spec = self._relation_connection_spec(model, field_name, relation) |
| 833 | + |
| 834 | + def resolver(self: Any, info: Any) -> Any: |
| 835 | + from strawberry_orm.batching import page_attr |
| 836 | + |
| 837 | + page = getattr(self, page_attr(field_name), None) |
| 838 | + if page is not None: |
| 839 | + return page |
| 840 | + qs = spec.related_model.filter( |
| 841 | + **{spec.key_field: backend.instance_pk(self)} |
| 842 | + ) |
| 843 | + restrict = backend.relation_scope(model, field_name, info, on=relation) |
| 844 | + return qs if restrict is None else restrict(qs, info) |
| 845 | + |
| 846 | + resolver.__name__ = field_name |
| 847 | + return resolver |
| 848 | + |
766 | 849 | def _apply_nested_queryset( |
767 | 850 | self, |
768 | 851 | qs: Any, |
@@ -886,6 +969,13 @@ def _walk_selections( |
886 | 969 | if not is_rel: |
887 | 970 | continue |
888 | 971 |
|
| 972 | + # A field that answers for itself will ignore whatever the |
| 973 | + # prefetch loads, so do not pay for it. |
| 974 | + if self.resolves_itself( |
| 975 | + self._type_name_for_model(current_model), field_name |
| 976 | + ): |
| 977 | + continue |
| 978 | + |
889 | 979 | related_model = field_obj.related_model |
890 | 980 |
|
891 | 981 | ancestor = _find_custom_ancestor(full_path) |
@@ -1795,6 +1885,84 @@ def _extract_tortoise_overlapping_order( |
1795 | 1885 | return clauses |
1796 | 1886 |
|
1797 | 1887 |
|
| 1888 | +def _db_columns(model: type) -> dict[str, str]: |
| 1889 | + """Field name to column name, for every column the model really has.""" |
| 1890 | + return dict(model._meta.fields_db_projection) # type: ignore[attr-defined] |
| 1891 | + |
| 1892 | + |
| 1893 | +def _quote_ident(name: str, model: type, quote_char: str) -> str: |
| 1894 | + """Quote *name* for the dialect, having checked the model owns it. |
| 1895 | +
|
| 1896 | + Only ever called with names the caller derived from the model, so an |
| 1897 | + unknown one means a bug rather than user input; refusing it anyway keeps |
| 1898 | + the window's SQL from being assembled out of anything but real columns. |
| 1899 | + """ |
| 1900 | + columns = set(_db_columns(model).values()) |
| 1901 | + if name not in columns: |
| 1902 | + raise ValueError( |
| 1903 | + f"{model.__name__} has no column {name!r} to build a window from." |
| 1904 | + ) |
| 1905 | + return f"{quote_char}{name}{quote_char}" |
| 1906 | + |
| 1907 | + |
| 1908 | +def _window_ordering( |
| 1909 | + model: type, order_clauses: list[str], quote_char: str |
| 1910 | +) -> list[str]: |
| 1911 | + """Render the window's ORDER BY, falling back to the primary key.""" |
| 1912 | + meta = model._meta # type: ignore[attr-defined] |
| 1913 | + projection = _db_columns(model) |
| 1914 | + rendered: list[str] = [] |
| 1915 | + for clause in order_clauses: |
| 1916 | + descending = clause.startswith("-") |
| 1917 | + field = clause[1:] if descending else clause |
| 1918 | + column = projection.get(field, field) |
| 1919 | + direction = " DESC" if descending else " ASC" |
| 1920 | + rendered.append(_quote_ident(column, model, quote_char) + direction) |
| 1921 | + if not rendered: |
| 1922 | + pk_column = projection.get(meta.pk_attr, meta.db_pk_column) |
| 1923 | + rendered.append(_quote_ident(pk_column, model, quote_char) + " ASC") |
| 1924 | + return rendered |
| 1925 | + |
| 1926 | + |
| 1927 | +def _windowed_sql( |
| 1928 | + query: Any, |
| 1929 | + model: type, |
| 1930 | + group_key_fields: list[str], |
| 1931 | + order_clauses: list[str], |
| 1932 | + per_group_limit: int, |
| 1933 | +) -> tuple[str, list[Any]]: |
| 1934 | + """Wrap *query* in SQL keeping the first rows of every group. |
| 1935 | +
|
| 1936 | + Returns the statement and the values its placeholders still expect, so the |
| 1937 | + caller binds them rather than embedding them. |
| 1938 | + """ |
| 1939 | + query._choose_db_if_not_chosen() |
| 1940 | + query._make_query() |
| 1941 | + inner, values = query.query.get_parameterized_sql() |
| 1942 | + |
| 1943 | + quote_char = query._db.query_class.SQL_CONTEXT.quote_char |
| 1944 | + projection = _db_columns(model) |
| 1945 | + partition = ", ".join( |
| 1946 | + _quote_ident(projection.get(field, field), model, quote_char) |
| 1947 | + for field in group_key_fields |
| 1948 | + ) |
| 1949 | + ordering = ", ".join(_window_ordering(model, order_clauses, quote_char)) |
| 1950 | + alias = f"{quote_char}{_ROW_NUMBER_ALIAS}{quote_char}" |
| 1951 | + |
| 1952 | + sql = ( |
| 1953 | + f"SELECT * FROM (" |
| 1954 | + f"SELECT _orm_inner.*, ROW_NUMBER() OVER (" |
| 1955 | + f"PARTITION BY {partition} ORDER BY {ordering}" |
| 1956 | + f") AS {alias} FROM ({inner}) _orm_inner" |
| 1957 | + f") _orm_windowed WHERE {alias} <= {int(per_group_limit)} " |
| 1958 | + # Ordered by the row number so each group comes back in the order the |
| 1959 | + # window put it in; without it the rows arrive however the database |
| 1960 | + # found them and the requested order is lost. |
| 1961 | + f"ORDER BY {alias}" |
| 1962 | + ) |
| 1963 | + return sql, values |
| 1964 | + |
| 1965 | + |
1798 | 1966 | def _build_tortoise_order_from_input(order_input: Any) -> list[str]: |
1799 | 1967 | """Convert an order input to Tortoise order_by strings.""" |
1800 | 1968 | order_list = order_input if isinstance(order_input, list) else [order_input] |
|
0 commit comments