Skip to content

Add CYCLE clause support for recursive CTEs - #134

Open
heinrichf-cdgnm wants to merge 19 commits into
dimagi:mainfrom
heinrichf-cdgnm:cycle-clause
Open

heinrichf-cdgnm wants to merge 19 commits into
dimagi:mainfrom
heinrichf-cdgnm:cycle-clause

Conversation

@heinrichf-cdgnm

@heinrichf-cdgnm heinrichf-cdgnm commented Jan 13, 2026 •

Copy link
Copy Markdown
Contributor

Converting the USING path column to Python is involved with psycopg2. psycopg3 will convert it to a list of string tuples. I tried my best to document this in the code and in the docs.

The columns the clause adds are read through cte.col, e.g. .annotate(is_cycle=cte.col.is_cycle).

Closes #96

@heinrichf-cdgnm

Copy link
Copy Markdown
Contributor Author

Pushed some cleanup on top.

  • The cycle argument is parsed into a CycleConfig in one place now, and invalid config raises instead of silently dropping the clause.
  • Column names are quoted, so mixed case names work.
  • is_cycle and path are no longer annotated onto the queryset by join(). They resolve through cte.col instead: .annotate(is_cycle=cte.col.is_cycle).
  • Two path assertions only held on psycopg2. They compare parsed values now.
  • Both doc examples were broken. Fixed, and they run on psycopg2 and psycopg 3.

@millerdev

Copy link
Copy Markdown
Contributor

@heinrichf-cdgnm Thank you for the contribution! I intend to review and hope to get this merged, but have been on vacation recently and am swamped with other work currently. Will get to it as soon as I can.

@millerdev millerdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some things I'd like to seen cleaned up before this is merged.

Comment thread django_cte/cte.py Outdated
Comment thread django_cte/cte.py Outdated
Comment thread django_cte/cte.py Outdated
Comment thread django_cte/cte.py Outdated
Comment thread django_cte/cycle.py Outdated
Comment on lines +17 to +23
:param cycle_value: SQL literal assigned to the mark column when a
cycle is detected (default: "true"). Interpolated into the query as
written, so a string value must include its own quotes.
:param default_value: SQL literal assigned to the mark column when no
cycle is detected (default: "false"). Interpolated as written.
:param path_column: Name of the generated path column (default:
"path").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These seem vulnerable to SQL injection. Would be better to interpret the value by type. So a Python boolean True is converted to true; a str is quoted; int, float, and datetime can be handled similarly. Maybe it could support an expression like Value or RawSQL if the user wants to do something more exotic?

@heinrichf-cdgnm heinrichf-cdgnm Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. to and default take a bool, int, float, str, date or datetime, a Value, or a RawSQL without params.

Postgres only accepts constants there AFAICT, so something like a placeholder, a cast or a signed number results in a syntax error. Values are therefore written into the SQL by Python type:

  • str is quoted (as an escape string constant E'...' if it contains a backslash)
  • float -> float8 '1.5'
  • negative int -> bigint '-1'
  • date and datetime -> date '...', timestamp '...' or timestamptz '...'

The type of to then decides the output field of the mark column. For Value and RawSQL the output field is the expression's output_field,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tests/test_recursive.py Outdated
Comment thread tests/test_recursive.py Outdated
Comment thread tests/test_recursive.py Outdated
Comment thread tests/test_recursive.py Outdated
Comment thread tests/test_recursive.py Outdated
@heinrichf-cdgnm

Copy link
Copy Markdown
Contributor Author

Pushed changes according to the review, I hope I didn't miss anything. I did not resolve the conversations in case you would like to do that or there is more to be discussed.

@millerdev millerdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like where this is heading. Would still like to see some changes to compile_mark_value.

Comment thread django_cte/cycle.py
self.cycle_value = cycle_value
self.default_value = default_value
self.cycle_sql, mark_field = compile_mark_value(cycle_value)
self.default_sql, _ = compile_mark_value(default_value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a test for (and does it break) if cycle_value and default_value have different types? Should that be detected here and maybe raise ValueError if the types do not match?

Comment thread django_cte/cycle.py
)


def compile_mark_value(value):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using Django's connection.ops.compose_sql() at SQL-compile time? It's better for a few reasons:

  • Escaping: it uses the driver's own quoting, so you don't depend on the E'' / standard_conforming_strings details that quote_string handles by hand.
  • Types: it formats every type the docs list (bool, int, float, str, date, datetime) with the driver's adapters.
  • Invalid input: the driver rejects values it can't represent, such as strings containing NUL (e.g., "abc\x00def").
def compile_mark_value(value, connection):
    """Get the SQL of a mark column value

    PostgreSQL accepts only constants in TO and DEFAULT, not parameters
    or casts, so the literal value is written into the SQL.
    """
    return connection.ops.compose_sql("%s", [value]).replace("%", "%%")

Replacing % to %% is still necessary because the result is embedded in a query that Django later runs with params, and compose_sql returns plain SQL with single %.

compile_mark_value is currently called in CycleClause.__init__, where there's no connection. The call will need to move into as_sql/the compiler, where compiler.connection is available. That's also more correct for multi-database setups.

A separate function could be used to get the output field. Not sure if it makes sense to do that here in CycleClause.__init__, or later at compile time?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for CYCLE clause

2 participants