django-cte

repository·main·Indexed 19 days ago

https://github.com/dimagi/django-cte

A library providing support for Common Table Expressions (CTEs) within the Django ORM. It enables the definition of complex recursive and hierarchical queries using the CTE and with_cte functions, allowing developers to wrap Django QuerySets as named subqueries in a WITH clause. The package includes support for materialized CTEs, recursive CTEs via CTE.recursive, and specialized tools like QJoin for CTE-based joins and JITMixin for dynamic object modification.

Tokens
7.1K
Snippets
28
Records
34
Agent score
65%

What's inside django-cte

  1. Perform a Left Outer Join with a CTE

    main

    While Django does not provide granular control over join types, you can perform a LEFT OUTER JOIN using the experimental _join_type argument in CTE.join(...).

    WARNING: This feature is experimental. Django might automatically convert a LEFT OUTER JOIN to an INNER JOIN during query construction. Always verify the generated SQL.

    from django.db.models.sql.constants import LOUTER
    
    totals = CTE(
        Order.objects
        .values("region_id")
        .annotate(total=Sum("amount"))
        .filter(total__gt=100)
    )
    
    orders = with_cte(
        totals,
        select=totals
        .join(Order, region=totals.col.region_id, _join_type=LOUTER)
        .annotate(region_total=totals.col.total)
    )
  2. Use Multiple Named Common Table Expressions

    main

    You can include multiple CTEs in a single query. To avoid name collisions, assign each CTE a unique name using the name parameter in CTE(...) or CTE.recursive(...).

    Important: When using multiple CTEs, you must pass all of them as positional arguments to with_cte(cte1, cte2, ..., select=...). A CTE can also reference another CTE defined earlier in the same query.

    # Define first CTE with a custom name
    rootmap = CTE.recursive(make_root_mapping, name="rootmap")
    
    # Define second CTE that references the first
    totals = CTE(
        rootmap.join(Order, region_id=rootmap.col.name)
        .values(root=rootmap.col.root)
        .annotate(
            orders_count=Count("id"),
            region_total=Sum("amount"),
        ),
        name="totals",
    )
    
    # Attach BOTH to the query
    root_regions = with_cte(
        rootmap,
        totals,
        select=totals.join(Region, name=totals.col.root)
        .annotate(
            orders_count=totals.col.orders_count,
            region_total=totals.col.region_total,
        )
    )
  3. Select directly FROM a Common Table Expression

    main

    If you want the final SELECT clause of your SQL to target the CTE itself rather than a model, use CTE(...).queryset() in the select argument of with_cte.

    If you pass the CTE object directly to select=... (e.g., with_cte(cte, select=cte)), the .queryset() call is optional and will be inferred.

    # Returns a queryset where the FROM clause is the CTE
    cte = CTE(
        Order.objects
        .annotate(region_parent=F("region__parent_id")),
    )
    orders = with_cte(cte, select=cte.queryset())
  4. Create Recursive Common Table Expressions

    main

    Recursive CTEs allow for hierarchical queries (like traversing tree structures). They are constructed using CTE.recursive(make_recursive_query_func).

    The function passed to CTE.recursive must return a query that combines two elements using .union():

    1. A non-recursive query element (the base case/root nodes).
    2. A recursive query element (the step that joins the CTE back to the model to find descendants).

    Use all=True in the .union() call to perform a UNION ALL.

    def make_regions_cte(cte):
        # non-recursive: get root nodes
        return Region.objects.filter(
            parent__isnull=True
        ).values(
            "name",
            path=F("name"),
            depth=Value(0, output_field=IntegerField()),
        ).union(
            # recursive union: get descendants
            cte.join(Region, parent=cte.col.name).values(
                "name",
                path=Concat(
                    cte.col.path, Value(" / "), F("name"),
                    output_field=TextField(),
                ),
                depth=cte.col.depth + Value(1, output_field=IntegerField()),
            ),
            all=True,
        )
    
    cte = CTE.recursive(make_regions_cte)
    
    regions = with_cte(
        cte,
        select=cte.join(Region, name=cte.col.name)
        .annotate(
            path=cte.col.path,
            depth=cte.col.depth,
        )
        .filter(depth=2)
        .order_by("path")
    )
  5. Create Simple Common Table Expressions

    main

    Simple CTEs are temporary tables or views that exist only for the duration of a query. You can construct them using CTE(...) and attach them to a Django queryset using with_cte(cte, select=queryset). To link the CTE to your main query, use the <CTE>.join(...) method to create a JOIN and ON condition. You can then access CTE columns via <CTE>.col.<column_name> to annotate your queryset.

    Note: django-cte always uses the WITH RECURSIVE keyword in the generated SQL, even for non-recursive CTEs. On databases like PostgreSQL and SQLite, this has no effect on non-recursive queries.

    from django_cte import CTE, with_cte
    
    # 1. Define the CTE
    cte = CTE(
        Order.objects
        .values("region_id")
        .annotate(total=Sum("amount"))
    )
    
    # 2. Attach to queryset and join
    orders = with_cte(
        cte,
        select=cte.join(Order, region=cte.col.region_id)
        .annotate(region_total=cte.col.total)
    )
  6. Run tests against PostgreSQL

    main

    To run the test suite against a PostgreSQL database, you must first create a database and then export the DB_SETTINGS environment variable as a JSON string containing your database configuration.

    Warning: Running pytest with these settings will delete the test_django_cte database if it exists.

    # 1. Create the database
    psql -U username -h localhost -p 5432 -c 'create database django_cte;'
    
    # 2. Export settings as a JSON string
    export PG_DB_SETTINGS='{
        "ENGINE":"django.db.backends.postgresql_psycopg2",
        "NAME":"django_cte",
        "USER":"username",
        "PASSWORD":"password",
        "HOST":"localhost",
        "PORT":"5432"
    }'
    
    # 3. Run pytest using the settings
    DB_SETTINGS="$PG_DB_SETTINGS" pytest
  7. Use Materialized CTEs

    main

    For PostgreSQL 12+ and SQLite 3.35+, you can enforce the use of the MATERIALIZED keyword to prevent the optimizer from folding the CTE into the main query. Use the materialized=True parameter in the CTE constructor.

    cte = CTE(
        Order.objects.values('id'),
        materialized=True
    )
  8. Reference CTE columns using `cte.col`

    main

    When performing joins or annotations involving a CTE, you must reference its columns using the col attribute on the CTE instance. This ensures that the references are correctly mapped to the CTE's name in the generated SQL.

    Pattern:

    • Use cte.col.fieldname to refer to a column in the CTE on the Right-Hand Side (RHS) of a comparison or join.
    # Correct way to reference a column named 'id' in the CTE
    cte.join(MyModel, my_model_id=cte.col.id)
  9. How CTEQuery processes SQL compilation

    main

    The CTEQuery class is a mixin for Django's Query objects that enables the use of Common Table Expressions (CTEs). It works by intercepting the SQL compilation process via a CTECompiler. When a query containing CTEs is compiled, CTEQuery collects all associated CTEs, generates their individual SQL statements, and prepends them to the main query using a WITH RECURSIVE clause.

    Key behaviors:

    • CTE Collection: It manages a collection of CTEs via the _with_ctes attribute.
    • SQL Generation: It uses generate_cte_sql to wrap the base query with the WITH RECURSIVE syntax.
    • Materialization: It respects the materialized attribute on CTEs, using the AS MATERIALIZED syntax when present.
    • Recursive Support: The library always uses WITH RECURSIVE to ensure compatibility with recursive CTE requirements in PostgreSQL.
    # Conceptual usage of a CTEQuery-enabled object
    # (Note: Actual instantiation depends on the specific CTE implementation used)
    query = some_cte_query_object
    sql, params = query.get_compiler().as_sql()
  10. Migration from django-cte v1 to v2+

    main

    If you are upgrading from version 1.x to version 2.0 or later, note the following breaking change:

    • Custom Model Managers: In version 1.x, a custom model manager was required on models used to construct CTE queries. In version 2.0 and later, a custom model manager is no longer required.
  11. Model definitions for django-cte examples

    main

    The following Django models are used as the basis for the Common Table Expression (CTE) examples in the documentation. They represent a hierarchical Region structure and an Order model linked to those regions.

    class Order(Model):
        id = AutoField(primary_key=True)
        region = ForeignKey("Region", on_delete=CASCADE)
        amount = IntegerField(default=0)
    
        class Meta:
            db_table = "orders"
    
    
    class Region(Model):
        name = TextField(primary_key=True)
        parent = ForeignKey("self", null=True, on_delete=CASCADE)
    
        class Meta:
            db_table = "region"