django-sql-compiler 0.1.1__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. django_sql_compiler-1.0.0/CHANGELOG.md +69 -0
  2. django_sql_compiler-1.0.0/LICENSE +21 -0
  3. django_sql_compiler-1.0.0/PKG-INFO +271 -0
  4. django_sql_compiler-1.0.0/README.md +237 -0
  5. django_sql_compiler-1.0.0/pyproject.toml +83 -0
  6. django_sql_compiler-1.0.0/sql_compiler/__init__.py +7 -0
  7. django_sql_compiler-1.0.0/sql_compiler/compilers/__init__.py +17 -0
  8. {django-sql-compiler-0.1.1 → django_sql_compiler-1.0.0}/sql_compiler/compilers/base.py +3 -3
  9. django_sql_compiler-1.0.0/sql_compiler/compilers/mysql.py +39 -0
  10. django_sql_compiler-1.0.0/sql_compiler/compilers/postgresql.py +80 -0
  11. django_sql_compiler-1.0.0/sql_compiler/composition.py +211 -0
  12. {django-sql-compiler-0.1.1 → django_sql_compiler-1.0.0}/sql_compiler/manager.py +0 -1
  13. django_sql_compiler-1.0.0/sql_compiler/queryset.py +49 -0
  14. django_sql_compiler-1.0.0/sql_compiler/rows.py +93 -0
  15. django_sql_compiler-1.0.0/sql_compiler/utils.py +123 -0
  16. django-sql-compiler-0.1.1/PKG-INFO +0 -114
  17. django-sql-compiler-0.1.1/README.md +0 -97
  18. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/PKG-INFO +0 -114
  19. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/SOURCES.txt +0 -16
  20. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/dependency_links.txt +0 -1
  21. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/not-zip-safe +0 -1
  22. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/requires.txt +0 -1
  23. django-sql-compiler-0.1.1/django_sql_compiler.egg-info/top_level.txt +0 -1
  24. django-sql-compiler-0.1.1/setup.cfg +0 -4
  25. django-sql-compiler-0.1.1/setup.py +0 -29
  26. django-sql-compiler-0.1.1/sql_compiler/__init__.py +0 -4
  27. django-sql-compiler-0.1.1/sql_compiler/compilers/__init__.py +0 -21
  28. django-sql-compiler-0.1.1/sql_compiler/compilers/mysql.py +0 -9
  29. django-sql-compiler-0.1.1/sql_compiler/compilers/postgresql.py +0 -24
  30. django-sql-compiler-0.1.1/sql_compiler/queryset.py +0 -30
  31. django-sql-compiler-0.1.1/sql_compiler/utils.py +0 -24
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [1.0.0] - 2026-11-01
6
+
7
+ The first release with safe SQL composition: `sql_and_params`, `compose`/`ComposeResult` and `fetch_rows` turn querysets into parameterized SQL, embed them in hand-written queries and return typed rows, and psycopg 3 is now supported. `executable_query` users upgrading from 0.2.0 get no breaking changes. Two behavior changes are worth knowing: empty querysets now return zero-row SQL instead of raising `EmptyResultSet` (see Fixed), and a PostgreSQL connection whose raw connection is neither psycopg2 nor psycopg 3 now raises `NotImplementedError`; stock Django never creates one (see Changed).
8
+
9
+ ### Added
10
+
11
+ - `fetch_rows(result, row)` runs a `compose()` result once on its alias through Django's cursor and returns a list of rows: dataclass instances (pass the dataclass type; columns are matched to its constructor fields by exact, case-sensitive name, and fields with defaults may be missing) or dicts in select order (pass `dict`), with values exactly as the driver returns them, on every Django backend. Input other than a `ComposeResult`, or a row kind other than `dict` or a dataclass type, raises `TypeError` before any SQL runs; extra columns, missing fields, duplicate column names, a statement with no result set and a result with no columns raise `ValueError`.
12
+ - `compose(template, **slots)` (and its result type `ComposeResult`) embeds querysets, earlier `compose()` results and plain values in a hand-written SQL template with `%(name)s` slots and returns `(sql, params)` for the querysets' shared alias (`.alias`), with params in placeholder order and repeated for reused slots, values always bound as params, `%%` kept as a literal `%`, on every Django backend; mixed aliases, missing or unused slots, bad `%` forms, a template with no queryset, and Manager, RawQuerySet, Query, expression, Q or tuple slots raise `ValueError` or `TypeError`.
13
+ - `sql_and_params(queryset)` and `SQLCompilerQuerySet.sql_and_params()` return a queryset's parameterized SQL and params (a tuple) compiled for the queryset's own database alias, on every Django backend, for executing through Django's cursor or `raw()` on that alias. Empty querysets return zero-row SQL with the same columns. Anything that isn't a QuerySet raises TypeError.
14
+ - psycopg 3 support (psycopg >=3.2) for the PostgreSQL ENGINE strings `django.db.backends.postgresql` and `django_tenants.postgresql_backend`, with Django's default client-side binding and with `OPTIONS={'server_side_binding': True}`. `executable_query` used to raise TypeError or AttributeError under psycopg 3. Values are escaped by psycopg 3 itself, so some literals differ from psycopg2's (for example strings with a backslash render as `E'...'`).
15
+
16
+ ### Changed
17
+
18
+ - The README and PyPI summary now lead with parameterized composition ("Use Django querysets inside raw SQL, safely.") with one complete CTE example returning typed rows, PyPI keywords were added, and the README's `executable_query` examples no longer pass a params argument (`raw(sql, None)`, `cursor.execute(sql)`), which broke on `%` in inlined values.
19
+ - A PostgreSQL connection whose raw connection is neither psycopg2 nor psycopg 3 now raises `NotImplementedError` naming its module and class.
20
+
21
+ ### Fixed
22
+
23
+ - `executable_query` no longer raises `EmptyResultSet` for querysets Django evaluates as empty (`.none()`, `filter(id__in=[])`, an empty slice such as `qs[5:5]`, an empty HAVING clause, a union of empty parts, a subquery on an empty queryset). It returns SQL that has the same columns as the non-empty queryset and matches no rows, so it can be embedded in a CTE, a UNION or an `IN (...)` subquery. SQL for querysets that already compiled is unchanged. Known limitation: an aggregate written as raw SQL (`RawSQL` or `extra(select=...)`, such as `COUNT(*)`) in a queryset with no aggregate Django recognizes has no GROUP BY, so its empty SQL returns one row instead of none. An empty slice's zero-row SQL has no LIMIT/OFFSET; other sliced querysets keep theirs.
24
+
25
+ ### Packaging
26
+
27
+ - The Development Status classifier is now `5 - Production/Stable`.
28
+
29
+ ## [0.2.0] - 2026-10-25
30
+
31
+ The first release since 0.1.1 (June 2021). It ships correctness fixes and first-time MySQL support.
32
+
33
+ ### Added
34
+
35
+ - MySQL support for `django.db.backends.mysql` (via mysqlclient), contributed in [PR 2](https://github.com/JaredStufftGD/django-sql-compiler/pull/2). It is covered by the live-database test suite and CI. In 0.1.1 the MySQL compiler was a stub and was not registered.
36
+
37
+ ### Fixed
38
+
39
+ - Non-ASCII parameters are decoded with the connection's encoding instead of byte by byte, so values like `café` are no longer garbled. 0.1.1 corrupted them.
40
+ - SQL is compiled for the queryset's own database alias (`.using()` or a database router) instead of the default alias.
41
+ - The unsupported-backend error names the ENGINE instead of saying "None backend is not supported".
42
+
43
+ ### Changed
44
+
45
+ - Decoding is strict. Compiler output that can't be decoded with the connection's codec raises `UnicodeDecodeError` (or `LookupError` for an unknown client encoding) instead of returning garbled text. A Postgres connection on `SQL_ASCII`, or another unusual client encoding, with non-ASCII data now raises where 0.1.1 returned text. A compiler that returns non-bytes raises `TypeError`.
46
+ - Semi-public API:
47
+ - `sql_compiler.utils.bytes_to_string` now requires an `encoding` argument.
48
+ - `BaseSQLCompiler` has a new `python_encoding(connection)` method that custom compiler subclasses must implement (the base raises `NotImplementedError`).
49
+ - `MySQLCompiler` is exported from `sql_compiler.compilers`.
50
+ - Install requirements: Python >=3.10 and Django >=4.2. The package declares no database driver (psycopg2 or mysqlclient comes from your Django setup). Effects on existing environments:
51
+ - With an older Django installed but not pinned, upgrading django-sql-compiler can upgrade Django (accepted, because Django <4.2 is end-of-life).
52
+ - With Django <4.2 pinned, or on Python <3.10, pip stays on 0.1.1.
53
+ - An install from git master already reports 0.2.3, so pip won't treat 0.2.0 as an upgrade. Install `django-sql-compiler==0.2.0` explicitly.
54
+
55
+ ### Packaging
56
+
57
+ - Ships a wheel again (0.1.1 was sdist-only; 0.0.5a0 and 0.1.0 had wheels).
58
+ - Built with flit_core instead of setuptools.
59
+ - The license is declared with PEP 639 `License-Expression: MIT`, and LICENSE ships in both artifacts.
60
+ - Metadata-Version 2.5.
61
+ - CHANGELOG.md ships in the sdist.
62
+
63
+ ### Known issues
64
+
65
+ - psycopg 3 is not supported yet. With psycopg 3 installed, Django uses it and `executable_query` raises (planned for 1.0). 0.1.1 had this issue too.
66
+ - `.none()` querysets raise `EmptyResultSet` (planned for 1.0). 0.1.1 had this issue too.
67
+
68
+ [1.0.0]: https://github.com/JaredStufftGD/django-sql-compiler/releases/tag/v1.0.0
69
+ [0.2.0]: https://github.com/JaredStufftGD/django-sql-compiler/releases/tag/v0.2.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Grok Data
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-sql-compiler
3
+ Version: 1.0.0
4
+ Summary: Use Django querysets inside raw SQL, safely.
5
+ Keywords: django,raw sql,cte,subquery,queryset,sql composition
6
+ Author-email: Grok Data / Jared Stufft <jared@grokdata.tech>
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ License-File: LICENSE
27
+ Requires-Dist: Django>=4.2
28
+ Project-URL: Changelog, https://github.com/JaredStufftGD/django-sql-compiler/blob/master/CHANGELOG.md
29
+ Project-URL: Homepage, https://github.com/JaredStufftGD/django-sql-compiler
30
+ Project-URL: Issues, https://github.com/JaredStufftGD/django-sql-compiler/issues
31
+ Project-URL: Source, https://github.com/JaredStufftGD/django-sql-compiler
32
+ Import-Name: sql_compiler
33
+
34
+ # Django SQL Compiler
35
+
36
+ [![CI](https://github.com/JaredStufftGD/django-sql-compiler/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/JaredStufftGD/django-sql-compiler/actions/workflows/ci.yml)
37
+
38
+ Use Django querysets inside raw SQL, safely.
39
+
40
+ Build any queryset with the ORM (filters, joins, annotations, aggregates, `values()`) and write the rest (CTEs, window functions, anything the ORM can't express well) by hand. The ORM builds and validates the queryset, so user input never has to be pasted into SQL text: every value stays a parameter that the database driver binds, on the queryset's own database. When you need a finished literal SQL string instead (for COPY, CREATE VIEW, logs or another tool), see "Finished literal SQL" under "Choosing an API" below.
41
+
42
+ ## Installation
43
+ ```
44
+ pip install django-sql-compiler
45
+ ```
46
+
47
+ No database driver is installed with the package and there are no extras: it uses the database connection your Django project already has, so the driver (for example psycopg2, psycopg 3 or mysqlclient) comes from your existing Django setup.
48
+
49
+ Add `sql_compiler` to your `INSTALLED_APPS`:
50
+
51
+ ```python
52
+ INSTALLED_APPS = [
53
+ ...,
54
+ 'sql_compiler'
55
+ ]
56
+ ```
57
+
58
+ `sql_and_params()`, `compose()` and `fetch_rows()` work on plain Django querysets with no custom manager. `SQLCompilerManager` is only needed for the queryset methods described below (`qs.sql_and_params()` and the finished-literal-SQL property).
59
+
60
+ ## Filter with the ORM, finish in SQL.
61
+ Any queryset works here; this example uses two filtered ones. The ORM builds and binds them, and the handwritten SQL joins the two sets, groups them, filters on the aggregate and ranks the result in one readable statement.
62
+
63
+ Write the handwritten SQL in your database's dialect; this example's SQL is portable (PostgreSQL, SQLite, MySQL 8).
64
+
65
+ Models and the result type (no custom manager needed):
66
+
67
+ ```python
68
+ import dataclasses
69
+ import datetime
70
+
71
+ from django.db import models
72
+ from sql_compiler import compose, fetch_rows
73
+
74
+
75
+ class Customer(models.Model):
76
+ name = models.CharField(max_length=100)
77
+ region = models.CharField(max_length=20)
78
+
79
+
80
+ class Order(models.Model):
81
+ customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
82
+ placed = models.DateField()
83
+ amount_cents = models.IntegerField()
84
+
85
+
86
+ @dataclasses.dataclass
87
+ class RegionLeader:
88
+ name: str
89
+ orders: int
90
+ total_cents: int
91
+ place: int
92
+ ```
93
+
94
+ Data:
95
+
96
+ ```python
97
+ ada = Customer.objects.create(name='Ada', region='west')
98
+ bo = Customer.objects.create(name='Bo', region='west')
99
+ cy = Customer.objects.create(name='Cy', region='west')
100
+ di = Customer.objects.create(name='Di', region='east')
101
+
102
+ for customer, placed, amount_cents in [
103
+ (ada, datetime.date(2026, 3, 3), 4000),
104
+ (ada, datetime.date(2026, 3, 20), 3000),
105
+ (bo, datetime.date(2026, 3, 10), 9000),
106
+ (bo, datetime.date(2026, 2, 28), 500),
107
+ (cy, datetime.date(2026, 3, 15), 1500),
108
+ (di, datetime.date(2026, 3, 5), 50000),
109
+ ]:
110
+ Order.objects.create(customer=customer, placed=placed, amount_cents=amount_cents)
111
+ ```
112
+
113
+ Query:
114
+
115
+ ```python
116
+ region = 'west' # user input, bound
117
+ start, end = datetime.date(2026, 3, 1), datetime.date(2026, 4, 1) # user input, bound
118
+ min_total_cents = 5000 # extra value in handwritten SQL, bound
119
+
120
+ customers = Customer.objects.filter(region=region).values('id', 'name')
121
+ orders = Order.objects.filter(placed__gte=start, placed__lt=end).values('customer_id', 'amount_cents')
122
+
123
+ leaderboard = compose(
124
+ '''
125
+ WITH c AS (%(customers)s), o AS (%(orders)s)
126
+ SELECT c.name AS name,
127
+ COUNT(*) AS orders,
128
+ SUM(o.amount_cents) AS total_cents,
129
+ RANK() OVER (ORDER BY SUM(o.amount_cents) DESC) AS place
130
+ FROM c JOIN o ON o.customer_id = c.id
131
+ GROUP BY c.id, c.name
132
+ HAVING SUM(o.amount_cents) >= %(min_total_cents)s
133
+ ORDER BY place, name
134
+ ''',
135
+ customers=customers,
136
+ orders=orders,
137
+ min_total_cents=min_total_cents,
138
+ )
139
+ rows = fetch_rows(leaderboard, RegionLeader) # runs on leaderboard.alias ('default' here)
140
+ # [RegionLeader(name='Bo', orders=1, total_cents=9000, place=1),
141
+ # RegionLeader(name='Ada', orders=2, total_cents=7000, place=2)]
142
+ ```
143
+
144
+ - `region`, `start`, `end` and `min_total_cents` are all params that the driver binds. Params follow placeholder order: the CTE params first, then the HAVING value. Params are adapted by the backend, so they are not printed here.
145
+ - The handwritten SQL references only the CTE names, so no table names are needed.
146
+ - Output columns are aliased explicitly in lowercase, because `fetch_rows` matches names exactly.
147
+ - The template is trusted SQL written by you, and a literal `%` in it is written `%%`. The package does not validate SQL or make interpolated identifiers safe.
148
+
149
+ ## Choosing an API
150
+
151
+ ### SQL and params
152
+ `sql_and_params(queryset)` returns the parameterized SQL (a `str` with Django's `%s` placeholders) and its params (a `tuple`) for any Django queryset. It compiles for the database the queryset runs on (`qs.db`, from `.using()` or your database routers), recompiles on every call, and leaves values in params for the driver to bind.
153
+
154
+ ```python
155
+ from django.db import connections
156
+ from sql_compiler import sql_and_params
157
+
158
+ qs = Customer.objects.filter(region='west')
159
+ sql, params = sql_and_params(qs)
160
+ with connections[qs.db].cursor() as cursor:
161
+ cursor.execute('select count(*) from ({}) a'.format(sql), params)
162
+ ```
163
+
164
+ Querysets from `SQLCompilerManager` also have `qs.sql_and_params()`, which returns the same pair. Anything that isn't a QuerySet (a manager, a RawQuerySet) raises `TypeError`.
165
+
166
+ - The pair is for Django's cursor on that alias (`connections[qs.db].cursor()`), and for `qs.raw(sql, params)` (or `Customer.objects.raw(sql, params, using=qs.db)`), which runs on that alias, when the queryset selects the model's own instances (no `values()`, `values_list()` or aggregates). `Customer.objects.raw(sql, params)` without `using=` runs on the router's or default alias, not `qs.db`. The raw driver cursor is not supported (SQLite's, for example, uses `?`).
167
+ - In hand-written SQL around it, keep user input in params and write a literal `%` as `%%`.
168
+ - Empty querysets (`.none()`, `filter(id__in=[])`, an empty HAVING, an empty slice such as `qs[5:5]`) return SQL that matches no rows with the same columns.
169
+ - Known limitation: an aggregate written as raw SQL (`RawSQL` or `extra(select=...)`, such as `COUNT(*)`) in a queryset with no aggregate Django recognizes has no GROUP BY, so its empty SQL returns one row instead of none.
170
+
171
+ ### Compose
172
+ `compose(template, **slots)` fills the `%(name)s` slots of a hand-written SQL template with querysets, earlier `compose()` results and plain values. It returns one result that unpacks as `sql, params` for the querysets' shared database, with params in placeholder order and every value bound by the driver.
173
+
174
+ ```python
175
+ from django.db import connections
176
+ from sql_compiler import compose
177
+
178
+ # start comes from the CTE example above
179
+ result = compose(
180
+ 'WITH recent AS (%(recent)s), big AS (%(big)s) '
181
+ 'SELECT recent.id FROM recent JOIN big ON big.id = recent.id WHERE recent.amount_cents > %(min_cents)s',
182
+ recent=Order.objects.filter(placed__gte=start).values('id', 'amount_cents'),
183
+ big=Order.objects.filter(amount_cents__gte=1000).values('id'),
184
+ min_cents=50,
185
+ )
186
+ with connections[result.alias].cursor() as cursor:
187
+ cursor.execute(*result)
188
+ rows = cursor.fetchall()
189
+ ```
190
+
191
+ - A QuerySet slot inserts its SQL as `sql_and_params` compiles it, a `compose()` result inserts its SQL (so results nest), and anything else becomes a `%s` param. Lists go to the driver unchanged.
192
+ - Write a literal `%` as `%%`. It stays `%%` in the result, also when nested.
193
+ - Embedded SQL is inserted as is, without added parentheses, so write them in the template.
194
+ - All querysets and nested results must share one alias (`result.alias`), otherwise `ValueError`. At least one slot must hold a queryset or a `compose()` result.
195
+ - Missing or unused slots, a positional `%s`, a lone `%` and any other `%(...)` form raise `ValueError` (with the position for `%` problems). A manager, a RawQuerySet, a Query, a Django expression such as `Subquery` and a `Q` raise `TypeError`.
196
+ - A tuple (such as a `sql_and_params` pair) raises `TypeError`, because drivers bind tuples differently. Pass the queryset or a `compose()` result instead.
197
+ - The template is trusted SQL: never build it from user input. Keep input in slots. A `%(name)s` inside a SQL string literal in the template is still a slot.
198
+ - The result is for Django's cursor on `result.alias`, not the raw driver cursor. For `raw()`, use `Order.objects.raw(*result, using=result.alias)`.
199
+ - Empty querysets embed SQL that matches no rows, with the same raw-SQL aggregate limitation as `sql_and_params`.
200
+
201
+ ### Typed rows
202
+ `fetch_rows(result, row)` runs a `compose()` result and returns the rows as dataclass instances or dicts.
203
+
204
+ ```python
205
+ from sql_compiler import fetch_rows
206
+
207
+ # leaderboard: the compose() result from the CTE example above
208
+ dicts = fetch_rows(leaderboard, dict) # [{'name': 'Bo', 'orders': 1, 'total_cents': 9000, 'place': 1}, ...]
209
+ leaders = fetch_rows(leaderboard, RegionLeader) # [RegionLeader(...), ...]
210
+ ```
211
+
212
+ - It runs the result once on `result.alias` through Django's cursor and returns a list (empty when there are no rows). It adds no transaction.
213
+ - `row=dict` gives dicts with the columns in select order. `row=<dataclass type>` builds instances by keyword.
214
+ - For a dataclass, the column names must match the constructor fields exactly and case-sensitively. Fields with a default or default_factory may be missing. `init=False` fields and ClassVar are not columns.
215
+ - Values are passed through exactly as the driver returns them, with no conversion (SQLite returns decimals as float, MySQL returns the SUM of an integer as Decimal, so on MySQL the example's `total_cents` is a `Decimal`), so alias columns explicitly in lowercase.
216
+ - Anything but a `compose()` result, or a row kind other than `dict` or a dataclass type, raises `TypeError` before any SQL runs.
217
+ - A statement with no result set (it has already run), a result with no columns, duplicate column names, and extra columns or missing fields raise `ValueError`, checked before any row is built.
218
+
219
+ ### Finished literal SQL: executable_query
220
+ `executable_query` gives you usable SQL from a QuerySet as one finished string, with every value escaped by the database driver. It is a property of `SQLCompilerQuerySet`, so the model needs `objects = SQLCompilerManager()`.
221
+
222
+ Casting a queryset's `Query` object with `str(qs.query)` prints out what looks like valid SQL, but Django renders it for display: values are not escaped or quoted by the driver, so it is not actually valid SQL once there are dynamic components (such as filters from user input) in the `QuerySet`.
223
+
224
+ Use it when a finished literal string is required: COPY, CREATE VIEW, logs, or passing the SQL to another tool. Prefer the parameterized APIs above whenever the SQL can be executed with params.
225
+
226
+ ```python
227
+ from django.db import connections, models
228
+ from sql_compiler import SQLCompilerManager
229
+
230
+
231
+ class Customer(models.Model):
232
+ objects = SQLCompilerManager()
233
+ name = models.CharField(max_length=100)
234
+ region = models.CharField(max_length=20)
235
+
236
+
237
+ qs = Customer.objects.filter(region='west')
238
+ sql = qs.executable_query
239
+
240
+ with connections[qs.db].cursor() as cursor:
241
+ cursor.execute('CREATE VIEW west_customers AS ' + sql)
242
+
243
+ west = Customer.objects.raw('SELECT * FROM ({}) a'.format(sql), None, using=qs.db)
244
+ ```
245
+
246
+ It works only on the ENGINEs and drivers listed under "Backend and version support"; other backends (for example SQLite) raise `NotImplementedError`. Empty querysets compile to zero-row SQL with the same columns.
247
+
248
+ - Never supply a params argument when passing its output to `raw()` or `cursor.execute()`: `%` characters in inlined values (any `contains` or `startswith` lookup, or a value like `50% off`) can be interpreted as formatting placeholders. Call `cursor.execute(sql)` with no second argument. With `raw()`, pass `None` explicitly as params (`Customer.objects.raw(sql, None)`), because raw()'s default `()` counts as a params argument at the driver. Handwritten text wrapped around the output must not contain `%s` placeholders either.
249
+ - Filter values appear in plaintext in the SQL, so treat it as sensitive data in logs and caches.
250
+ - The property is cached per queryset instance (each `.filter()` returns a new queryset), so read it after the queryset is fully built.
251
+
252
+ ### Django's cursor or raw()
253
+ - Use `connections[result.alias].cursor()` (or `connections[qs.db]`) with `cursor.execute(*result)` when you need arbitrary columns or aggregates, or `fetch_rows` to get rows as dicts or dataclasses.
254
+ - Use `raw(sql, params)` only when the SQL returns the model's own rows, including its primary key.
255
+ - Pass `using=` (`result.alias` or `qs.db`) to `raw()`: `Model.objects.raw` without it runs on the router's or default alias.
256
+ - Never use the raw driver cursor.
257
+
258
+ ## Backend and version support
259
+ The parameterized APIs (`sql_and_params`, `compose`, `fetch_rows`) compile through Django's own backend compiler and work on every Django backend. SQL you write around them can still depend on the backend's features (the CTE example needs CTEs and window functions).
260
+
261
+ `executable_query` supports exactly these Django `ENGINE` values:
262
+
263
+ - `django.db.backends.postgresql`
264
+ - `django.db.backends.postgresql_psycopg2`: a legacy alias for the PostgreSQL backend that Django removed in Django 3.0. The package still accepts the ENGINE string and maps it to the same compiler.
265
+ - `django_tenants.postgresql_backend` (django-tenants)
266
+ - `django.db.backends.mysql` (via mysqlclient)
267
+
268
+ PostgreSQL connections work with psycopg2 and with psycopg 3 (psycopg >=3.2), including Django's `server_side_binding` option. Other PostgreSQL drivers raise `NotImplementedError`.
269
+
270
+ Python >=3.10 and Django >=4.2 are supported, subject to Django's own Python compatibility for each release. CI tests Django 4.2, 5.2 and 6.1 (on Python 3.10 to 3.14), plus live PostgreSQL 18 (psycopg2, psycopg 3 and psycopg 3 with server-side binding) and MySQL 8.4 jobs, and SQLite in every run. A job against Django's development branch runs for information only and is not a support promise.
271
+
@@ -0,0 +1,237 @@
1
+ # Django SQL Compiler
2
+
3
+ [![CI](https://github.com/JaredStufftGD/django-sql-compiler/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/JaredStufftGD/django-sql-compiler/actions/workflows/ci.yml)
4
+
5
+ Use Django querysets inside raw SQL, safely.
6
+
7
+ Build any queryset with the ORM (filters, joins, annotations, aggregates, `values()`) and write the rest (CTEs, window functions, anything the ORM can't express well) by hand. The ORM builds and validates the queryset, so user input never has to be pasted into SQL text: every value stays a parameter that the database driver binds, on the queryset's own database. When you need a finished literal SQL string instead (for COPY, CREATE VIEW, logs or another tool), see "Finished literal SQL" under "Choosing an API" below.
8
+
9
+ ## Installation
10
+ ```
11
+ pip install django-sql-compiler
12
+ ```
13
+
14
+ No database driver is installed with the package and there are no extras: it uses the database connection your Django project already has, so the driver (for example psycopg2, psycopg 3 or mysqlclient) comes from your existing Django setup.
15
+
16
+ Add `sql_compiler` to your `INSTALLED_APPS`:
17
+
18
+ ```python
19
+ INSTALLED_APPS = [
20
+ ...,
21
+ 'sql_compiler'
22
+ ]
23
+ ```
24
+
25
+ `sql_and_params()`, `compose()` and `fetch_rows()` work on plain Django querysets with no custom manager. `SQLCompilerManager` is only needed for the queryset methods described below (`qs.sql_and_params()` and the finished-literal-SQL property).
26
+
27
+ ## Filter with the ORM, finish in SQL.
28
+ Any queryset works here; this example uses two filtered ones. The ORM builds and binds them, and the handwritten SQL joins the two sets, groups them, filters on the aggregate and ranks the result in one readable statement.
29
+
30
+ Write the handwritten SQL in your database's dialect; this example's SQL is portable (PostgreSQL, SQLite, MySQL 8).
31
+
32
+ Models and the result type (no custom manager needed):
33
+
34
+ ```python
35
+ import dataclasses
36
+ import datetime
37
+
38
+ from django.db import models
39
+ from sql_compiler import compose, fetch_rows
40
+
41
+
42
+ class Customer(models.Model):
43
+ name = models.CharField(max_length=100)
44
+ region = models.CharField(max_length=20)
45
+
46
+
47
+ class Order(models.Model):
48
+ customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
49
+ placed = models.DateField()
50
+ amount_cents = models.IntegerField()
51
+
52
+
53
+ @dataclasses.dataclass
54
+ class RegionLeader:
55
+ name: str
56
+ orders: int
57
+ total_cents: int
58
+ place: int
59
+ ```
60
+
61
+ Data:
62
+
63
+ ```python
64
+ ada = Customer.objects.create(name='Ada', region='west')
65
+ bo = Customer.objects.create(name='Bo', region='west')
66
+ cy = Customer.objects.create(name='Cy', region='west')
67
+ di = Customer.objects.create(name='Di', region='east')
68
+
69
+ for customer, placed, amount_cents in [
70
+ (ada, datetime.date(2026, 3, 3), 4000),
71
+ (ada, datetime.date(2026, 3, 20), 3000),
72
+ (bo, datetime.date(2026, 3, 10), 9000),
73
+ (bo, datetime.date(2026, 2, 28), 500),
74
+ (cy, datetime.date(2026, 3, 15), 1500),
75
+ (di, datetime.date(2026, 3, 5), 50000),
76
+ ]:
77
+ Order.objects.create(customer=customer, placed=placed, amount_cents=amount_cents)
78
+ ```
79
+
80
+ Query:
81
+
82
+ ```python
83
+ region = 'west' # user input, bound
84
+ start, end = datetime.date(2026, 3, 1), datetime.date(2026, 4, 1) # user input, bound
85
+ min_total_cents = 5000 # extra value in handwritten SQL, bound
86
+
87
+ customers = Customer.objects.filter(region=region).values('id', 'name')
88
+ orders = Order.objects.filter(placed__gte=start, placed__lt=end).values('customer_id', 'amount_cents')
89
+
90
+ leaderboard = compose(
91
+ '''
92
+ WITH c AS (%(customers)s), o AS (%(orders)s)
93
+ SELECT c.name AS name,
94
+ COUNT(*) AS orders,
95
+ SUM(o.amount_cents) AS total_cents,
96
+ RANK() OVER (ORDER BY SUM(o.amount_cents) DESC) AS place
97
+ FROM c JOIN o ON o.customer_id = c.id
98
+ GROUP BY c.id, c.name
99
+ HAVING SUM(o.amount_cents) >= %(min_total_cents)s
100
+ ORDER BY place, name
101
+ ''',
102
+ customers=customers,
103
+ orders=orders,
104
+ min_total_cents=min_total_cents,
105
+ )
106
+ rows = fetch_rows(leaderboard, RegionLeader) # runs on leaderboard.alias ('default' here)
107
+ # [RegionLeader(name='Bo', orders=1, total_cents=9000, place=1),
108
+ # RegionLeader(name='Ada', orders=2, total_cents=7000, place=2)]
109
+ ```
110
+
111
+ - `region`, `start`, `end` and `min_total_cents` are all params that the driver binds. Params follow placeholder order: the CTE params first, then the HAVING value. Params are adapted by the backend, so they are not printed here.
112
+ - The handwritten SQL references only the CTE names, so no table names are needed.
113
+ - Output columns are aliased explicitly in lowercase, because `fetch_rows` matches names exactly.
114
+ - The template is trusted SQL written by you, and a literal `%` in it is written `%%`. The package does not validate SQL or make interpolated identifiers safe.
115
+
116
+ ## Choosing an API
117
+
118
+ ### SQL and params
119
+ `sql_and_params(queryset)` returns the parameterized SQL (a `str` with Django's `%s` placeholders) and its params (a `tuple`) for any Django queryset. It compiles for the database the queryset runs on (`qs.db`, from `.using()` or your database routers), recompiles on every call, and leaves values in params for the driver to bind.
120
+
121
+ ```python
122
+ from django.db import connections
123
+ from sql_compiler import sql_and_params
124
+
125
+ qs = Customer.objects.filter(region='west')
126
+ sql, params = sql_and_params(qs)
127
+ with connections[qs.db].cursor() as cursor:
128
+ cursor.execute('select count(*) from ({}) a'.format(sql), params)
129
+ ```
130
+
131
+ Querysets from `SQLCompilerManager` also have `qs.sql_and_params()`, which returns the same pair. Anything that isn't a QuerySet (a manager, a RawQuerySet) raises `TypeError`.
132
+
133
+ - The pair is for Django's cursor on that alias (`connections[qs.db].cursor()`), and for `qs.raw(sql, params)` (or `Customer.objects.raw(sql, params, using=qs.db)`), which runs on that alias, when the queryset selects the model's own instances (no `values()`, `values_list()` or aggregates). `Customer.objects.raw(sql, params)` without `using=` runs on the router's or default alias, not `qs.db`. The raw driver cursor is not supported (SQLite's, for example, uses `?`).
134
+ - In hand-written SQL around it, keep user input in params and write a literal `%` as `%%`.
135
+ - Empty querysets (`.none()`, `filter(id__in=[])`, an empty HAVING, an empty slice such as `qs[5:5]`) return SQL that matches no rows with the same columns.
136
+ - Known limitation: an aggregate written as raw SQL (`RawSQL` or `extra(select=...)`, such as `COUNT(*)`) in a queryset with no aggregate Django recognizes has no GROUP BY, so its empty SQL returns one row instead of none.
137
+
138
+ ### Compose
139
+ `compose(template, **slots)` fills the `%(name)s` slots of a hand-written SQL template with querysets, earlier `compose()` results and plain values. It returns one result that unpacks as `sql, params` for the querysets' shared database, with params in placeholder order and every value bound by the driver.
140
+
141
+ ```python
142
+ from django.db import connections
143
+ from sql_compiler import compose
144
+
145
+ # start comes from the CTE example above
146
+ result = compose(
147
+ 'WITH recent AS (%(recent)s), big AS (%(big)s) '
148
+ 'SELECT recent.id FROM recent JOIN big ON big.id = recent.id WHERE recent.amount_cents > %(min_cents)s',
149
+ recent=Order.objects.filter(placed__gte=start).values('id', 'amount_cents'),
150
+ big=Order.objects.filter(amount_cents__gte=1000).values('id'),
151
+ min_cents=50,
152
+ )
153
+ with connections[result.alias].cursor() as cursor:
154
+ cursor.execute(*result)
155
+ rows = cursor.fetchall()
156
+ ```
157
+
158
+ - A QuerySet slot inserts its SQL as `sql_and_params` compiles it, a `compose()` result inserts its SQL (so results nest), and anything else becomes a `%s` param. Lists go to the driver unchanged.
159
+ - Write a literal `%` as `%%`. It stays `%%` in the result, also when nested.
160
+ - Embedded SQL is inserted as is, without added parentheses, so write them in the template.
161
+ - All querysets and nested results must share one alias (`result.alias`), otherwise `ValueError`. At least one slot must hold a queryset or a `compose()` result.
162
+ - Missing or unused slots, a positional `%s`, a lone `%` and any other `%(...)` form raise `ValueError` (with the position for `%` problems). A manager, a RawQuerySet, a Query, a Django expression such as `Subquery` and a `Q` raise `TypeError`.
163
+ - A tuple (such as a `sql_and_params` pair) raises `TypeError`, because drivers bind tuples differently. Pass the queryset or a `compose()` result instead.
164
+ - The template is trusted SQL: never build it from user input. Keep input in slots. A `%(name)s` inside a SQL string literal in the template is still a slot.
165
+ - The result is for Django's cursor on `result.alias`, not the raw driver cursor. For `raw()`, use `Order.objects.raw(*result, using=result.alias)`.
166
+ - Empty querysets embed SQL that matches no rows, with the same raw-SQL aggregate limitation as `sql_and_params`.
167
+
168
+ ### Typed rows
169
+ `fetch_rows(result, row)` runs a `compose()` result and returns the rows as dataclass instances or dicts.
170
+
171
+ ```python
172
+ from sql_compiler import fetch_rows
173
+
174
+ # leaderboard: the compose() result from the CTE example above
175
+ dicts = fetch_rows(leaderboard, dict) # [{'name': 'Bo', 'orders': 1, 'total_cents': 9000, 'place': 1}, ...]
176
+ leaders = fetch_rows(leaderboard, RegionLeader) # [RegionLeader(...), ...]
177
+ ```
178
+
179
+ - It runs the result once on `result.alias` through Django's cursor and returns a list (empty when there are no rows). It adds no transaction.
180
+ - `row=dict` gives dicts with the columns in select order. `row=<dataclass type>` builds instances by keyword.
181
+ - For a dataclass, the column names must match the constructor fields exactly and case-sensitively. Fields with a default or default_factory may be missing. `init=False` fields and ClassVar are not columns.
182
+ - Values are passed through exactly as the driver returns them, with no conversion (SQLite returns decimals as float, MySQL returns the SUM of an integer as Decimal, so on MySQL the example's `total_cents` is a `Decimal`), so alias columns explicitly in lowercase.
183
+ - Anything but a `compose()` result, or a row kind other than `dict` or a dataclass type, raises `TypeError` before any SQL runs.
184
+ - A statement with no result set (it has already run), a result with no columns, duplicate column names, and extra columns or missing fields raise `ValueError`, checked before any row is built.
185
+
186
+ ### Finished literal SQL: executable_query
187
+ `executable_query` gives you usable SQL from a QuerySet as one finished string, with every value escaped by the database driver. It is a property of `SQLCompilerQuerySet`, so the model needs `objects = SQLCompilerManager()`.
188
+
189
+ Casting a queryset's `Query` object with `str(qs.query)` prints out what looks like valid SQL, but Django renders it for display: values are not escaped or quoted by the driver, so it is not actually valid SQL once there are dynamic components (such as filters from user input) in the `QuerySet`.
190
+
191
+ Use it when a finished literal string is required: COPY, CREATE VIEW, logs, or passing the SQL to another tool. Prefer the parameterized APIs above whenever the SQL can be executed with params.
192
+
193
+ ```python
194
+ from django.db import connections, models
195
+ from sql_compiler import SQLCompilerManager
196
+
197
+
198
+ class Customer(models.Model):
199
+ objects = SQLCompilerManager()
200
+ name = models.CharField(max_length=100)
201
+ region = models.CharField(max_length=20)
202
+
203
+
204
+ qs = Customer.objects.filter(region='west')
205
+ sql = qs.executable_query
206
+
207
+ with connections[qs.db].cursor() as cursor:
208
+ cursor.execute('CREATE VIEW west_customers AS ' + sql)
209
+
210
+ west = Customer.objects.raw('SELECT * FROM ({}) a'.format(sql), None, using=qs.db)
211
+ ```
212
+
213
+ It works only on the ENGINEs and drivers listed under "Backend and version support"; other backends (for example SQLite) raise `NotImplementedError`. Empty querysets compile to zero-row SQL with the same columns.
214
+
215
+ - Never supply a params argument when passing its output to `raw()` or `cursor.execute()`: `%` characters in inlined values (any `contains` or `startswith` lookup, or a value like `50% off`) can be interpreted as formatting placeholders. Call `cursor.execute(sql)` with no second argument. With `raw()`, pass `None` explicitly as params (`Customer.objects.raw(sql, None)`), because raw()'s default `()` counts as a params argument at the driver. Handwritten text wrapped around the output must not contain `%s` placeholders either.
216
+ - Filter values appear in plaintext in the SQL, so treat it as sensitive data in logs and caches.
217
+ - The property is cached per queryset instance (each `.filter()` returns a new queryset), so read it after the queryset is fully built.
218
+
219
+ ### Django's cursor or raw()
220
+ - Use `connections[result.alias].cursor()` (or `connections[qs.db]`) with `cursor.execute(*result)` when you need arbitrary columns or aggregates, or `fetch_rows` to get rows as dicts or dataclasses.
221
+ - Use `raw(sql, params)` only when the SQL returns the model's own rows, including its primary key.
222
+ - Pass `using=` (`result.alias` or `qs.db`) to `raw()`: `Model.objects.raw` without it runs on the router's or default alias.
223
+ - Never use the raw driver cursor.
224
+
225
+ ## Backend and version support
226
+ The parameterized APIs (`sql_and_params`, `compose`, `fetch_rows`) compile through Django's own backend compiler and work on every Django backend. SQL you write around them can still depend on the backend's features (the CTE example needs CTEs and window functions).
227
+
228
+ `executable_query` supports exactly these Django `ENGINE` values:
229
+
230
+ - `django.db.backends.postgresql`
231
+ - `django.db.backends.postgresql_psycopg2`: a legacy alias for the PostgreSQL backend that Django removed in Django 3.0. The package still accepts the ENGINE string and maps it to the same compiler.
232
+ - `django_tenants.postgresql_backend` (django-tenants)
233
+ - `django.db.backends.mysql` (via mysqlclient)
234
+
235
+ PostgreSQL connections work with psycopg2 and with psycopg 3 (psycopg >=3.2), including Django's `server_side_binding` option. Other PostgreSQL drivers raise `NotImplementedError`.
236
+
237
+ Python >=3.10 and Django >=4.2 are supported, subject to Django's own Python compatibility for each release. CI tests Django 4.2, 5.2 and 6.1 (on Python 3.10 to 3.14), plus live PostgreSQL 18 (psycopg2, psycopg 3 and psycopg 3 with server-side binding) and MySQL 8.4 jobs, and SQLite in every run. A job against Django's development branch runs for information only and is not a support promise.