Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 38 additions & 8 deletions django_cte/cte.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import functools
import warnings
from copy import copy

import django
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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. "
Expand Down
9 changes: 7 additions & 2 deletions django_cte/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 18 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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},
Expand Down
17 changes: 14 additions & 3 deletions tests/test_cte.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down