diff --git a/CHANGELOG.md b/CHANGELOG.md index ebddad1..1833717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Django CTE change log +## Unreleased + +- Passing `materialized=False` when constructing a `CTE` will now generate a + common table expression with `NOT MATERIALIZED`. Use `None` or do not specify + a value in order to omit the `MATERIALIZED` specification. + ## 3.0.0 - 2026-02-05 - **BREAKING:** on Django 5.2 and later when joining a CTE to a queryset with diff --git a/django_cte/cte.py b/django_cte/cte.py index e3ba70f..d45f48f 100644 --- a/django_cte/cte.py +++ b/django_cte/cte.py @@ -1,3 +1,5 @@ +import functools +import warnings from copy import copy import django @@ -32,6 +34,21 @@ def with_cte(*ctes, select): return select +def _check_cte_kwargs(fn): + @functools.wraps(fn) + def wrapper(*args, _stacklevel=1, **kwargs): + if len(args) > 2: + warnings.warn( + "CTE name and materialized will be keyword-only arguments", + DeprecationWarning, + stacklevel=_stacklevel + 1, + ) + + return fn(*args, **kwargs) + + return wrapper + + class CTE: """Common Table Expression @@ -41,26 +58,38 @@ class CTE: entities (tables, views, functions, other CTE(s), etc.) referenced in the given query as well any query to which this CTE will eventually be added. - :param materialized: Optional parameter (default: False) which enforce - using of MATERIALIZED statement for supporting databases. + :param materialized: Optional parameter (default: None) which generates + the MATERIALIZED / NOT MATERIALIZED statement for supporting databases. """ + VERSION = 1 - def __init__(self, queryset, name="cte", materialized=False): + @_check_cte_kwargs + def __init__(self, queryset, name=None, materialized=None): self._set_queryset(queryset) - self.name = name + self.name = name or "cte" self.col = CTEColumns(self) self.materialized = materialized def __getstate__(self): - return (self.query, self.name, self.materialized, self._iterable_class) + return (CTE.VERSION, self.query, self.name, self.materialized, self._iterable_class) def __setstate__(self, state): + if len(state) > 4: + version, *state = state + else: + version = 0 + if len(state) == 3: # Keep compatibility with the previous serialization method self.query, self.name, self.materialized = state self._iterable_class = ValuesIterable else: self.query, self.name, self.materialized, self._iterable_class = state + + # Preserve the previous default of omitting the `MATERIALIZED` clause + if version == 0 and self.materialized is False: + self.materialized = None + self.col = CTEColumns(self) def __repr__(self): @@ -71,7 +100,8 @@ def _set_queryset(self, queryset): self._iterable_class = getattr(queryset, "_iterable_class", ValuesIterable) @classmethod - def recursive(cls, make_cte_queryset, name="cte", materialized=False): + @_check_cte_kwargs + def recursive(cls, make_cte_queryset, name=None, materialized=None): """Recursive Common Table Expression :param make_cte_queryset: Function taking a single argument (a @@ -82,7 +112,7 @@ def recursive(cls, make_cte_queryset, name="cte", materialized=False): :param materialized: See `materialized` parameter of `__init__`. :returns: The fully constructed recursive cte object. """ - cte = cls(None, name, materialized) + cte = cls(None, name=name, materialized=materialized) cte._set_queryset(make_cte_queryset(cte)) return cte @@ -196,7 +226,7 @@ class With(CTE): @staticmethod @deprecated("Use `django_cte.CTE.recursive` instead.") def recursive(*args, **kw): - return CTE.recursive(*args, **kw) + return CTE.recursive(*args, _stacklevel=3, **kw) @deprecated("CTEQuerySet is deprecated. " diff --git a/django_cte/query.py b/django_cte/query.py index cbba107..c31c39e 100644 --- a/django_cte/query.py +++ b/django_cte/query.py @@ -131,8 +131,13 @@ def generate_cte_sql(connection, query, as_sql): def get_cte_query_template(cte): if cte.materialized: - return "{name} AS MATERIALIZED ({query})" - return "{name} AS ({query})" + materialized = "MATERIALIZED " + elif cte.materialized is False: + materialized = "NOT MATERIALIZED " + else: + materialized = "" + + return f"{{name}} AS {materialized}({{query}})" def _ignore_with_col_aliases(cte_query): diff --git a/pyproject.toml b/pyproject.toml index 77d970a..c7d5e52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,18 @@ build-backend = "flit_core.buildapi" [tool.flit.module] name = "django_cte" -[tool.distutils.bdist_wheel] -universal = true +[tool.pytest] +minversion = "9.0" +addopts = [ + "--strict-markers", +] +filterwarnings = [ + "error::DeprecationWarning", + # stacklevel is incorrect and warning should be emitted in `unmagic` + "default::pytest.PytestRemovedIn10Warning:_pytest", + # in case Pytest fixes ^^^ this is where the warning will be emitted + "default::pytest.PytestRemovedIn10Warning:unmagic", +] [tool.tox] requires = ["tox>=4.43"] @@ -65,7 +75,12 @@ env_list = [ [tool.tox.env_run_base] default_base_python = "python3" -commands = [["pytest", {replace = "posargs", default = [], extend = true}]] +commands = [[ + "pytest", + # deprecation warnings are not errors until they are in a supported Django version + {replace = "if", condition = "factor.djangomain", then = ["-W", "default::django.utils.deprecation.RemovedInNextVersionWarning"], extend = true}, + {replace = "posargs", default = [], extend = true} +]] dependency_groups = ["dev"] deps = [ {replace = "if", condition = "factor.django42", then = ["Django>=4.2,<5.0"], extend = true}, diff --git a/tests/test_cte.py b/tests/test_cte.py index e63b74e..274773b 100644 --- a/tests/test_cte.py +++ b/tests/test_cte.py @@ -286,26 +286,37 @@ def make_root_mapping(rootmap): ('sun', 18, 1374), ]) - def test_materialized_option(self): + def _test_materialized_queryset(self, materialized): totals = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), - materialized=True + materialized=materialized ) - orders = with_cte( + return with_cte( totals, select=totals.join(Order, region=totals.col.region_id) .annotate(region_total=totals.col.total) .order_by("amount") ) + + def test_materialized_query(self): + orders = self._test_materialized_queryset(materialized=True) self.assertTrue( str(orders.query).startswith( 'WITH RECURSIVE "cte" AS MATERIALIZED' ) ) + def test_not_materialized_query(self): + orders = self._test_materialized_queryset(materialized=False) + self.assertTrue( + str(orders.query).startswith( + 'WITH RECURSIVE "cte" AS NOT MATERIALIZED' + ) + ) + def test_update_cte_query(self): cte = CTE( Order.objects