j-perm-sql 0.1.0__tar.gz → 0.3.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.
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.4
2
+ Name: j-perm-sql
3
+ Version: 0.3.0
4
+ Summary: j-perm plugin for SQL
5
+ Author-email: Roman <kuschanow@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kuschanow/j-perm
8
+ Project-URL: Source, https://github.com/kuschanow/j-perm
9
+ Project-URL: Tracker, https://github.com/kuschanow/j-perm/issues
10
+ Project-URL: Documentation, https://github.com/kuschanow/j-perm
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: j-perm>=1.10.0
14
+
15
+ # j-perm-sql
16
+
17
+ A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
18
+ **SQL queries** from j-perm constructs.
19
+
20
+ * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
21
+ predicates, joins, …).
22
+ * A top-level operation renders that tree to a **parameterized** `(sql, params)`
23
+ pair and hands it to a configurable executor (any ORM's raw-execute function):
24
+ `op: sql` for read-only `SELECT`, and (opt-in) `op: sql_write` for
25
+ `INSERT`/`UPDATE`/`DELETE`.
26
+ * The SQL constructs live in **isolated** named pipelines: they mean nothing
27
+ outside those operations. `{"$select": …}` used as an ordinary value is just a
28
+ dict.
29
+
30
+ > Scope: the full standard **`SELECT`** surface (read-only) via `install_sql`,
31
+ > plus standard **`INSERT`/`UPDATE`/`DELETE`** (row content only) via
32
+ > `install_sql_write`. Schema DDL (`CREATE`/`ALTER`/`DROP` of tables/columns)
33
+ > and non-universal DML (`RETURNING`, `ON CONFLICT`/upsert, `UPDATE … FROM`,
34
+ > `DELETE … USING`) are intentionally out of scope.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install j-perm-sql
40
+ ```
41
+
42
+ Requires `j-perm >= 1.10.0`: 1.9.0 made `run_pipeline` a passthrough invoker
43
+ (which this plugin relies on), and 1.10.0 added the `nested_spec_pipeline`
44
+ compile hook and per-pipeline `CompiledSpec` execution that let `op: sql` be
45
+ compiled end-to-end (see [Compilation](#compilation)).
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ from j_perm import build_default_engine
51
+ from j_perm_sql import install_sql
52
+
53
+ def run_sql(sql, params):
54
+ # any ORM's raw execute: cursor.execute(sql, params); return rows
55
+ ...
56
+
57
+ engine = build_default_engine()
58
+ install_sql(engine, run_sql, paramstyle="qmark")
59
+
60
+ engine.apply(
61
+ {"op": "sql", "to": "/rows", "query": {"$select": {
62
+ "columns": [{"$col": {"name": "id"}}, {"$col": {"name": "name"}}],
63
+ "from": {"table": "users"},
64
+ "where": {"$gte": [{"$col": {"name": "age"}}, {"$val": 18}]},
65
+ "order_by": [{"expr": {"$col": {"name": "name"}}}],
66
+ "limit": 50,
67
+ }}},
68
+ source={}, dest={},
69
+ )
70
+ # run_sql receives: ('SELECT "id", "name" FROM "users" WHERE "age" >= ? ORDER BY "name" LIMIT 50', [18])
71
+ # result is written to dest at /rows
72
+ ```
73
+
74
+ `install_sql` **patches an existing engine** — it registers the isolated SQL
75
+ pipeline and the `op: sql` operation. It composes with any engine and any other
76
+ plugins.
77
+
78
+ ## The `op: sql` operation
79
+
80
+ ```js
81
+ {"op": "sql", "query": <SQL construct tree>, "to": "/dest/path"}
82
+ ```
83
+
84
+ * `query` — the SQL construct tree.
85
+ * `to` — optional destination pointer (template-expanded); the executor's
86
+ result is written there. If omitted, the result is discarded.
87
+
88
+ ## Writing data (`INSERT`/`UPDATE`/`DELETE`)
89
+
90
+ Writing is a **separate, opt-in install** — you don't get it unless you ask for
91
+ it, and `op: sql` stays guaranteed read-only even when both are installed:
92
+
93
+ ```python
94
+ from j_perm_sql import install_sql, install_sql_write
95
+
96
+ install_sql(engine, run_sql) # read-only op: sql (optional)
97
+ install_sql_write(engine, run_sql) # write op: sql_write
98
+ ```
99
+
100
+ `install_sql_write(engine, executor, *, paramstyle="qmark", dialect=None,
101
+ op="sql_write")` registers an isolated **write pipeline** (the full `SELECT`
102
+ surface *plus* the DML statements, so `WHERE` predicates, `SET` expressions and
103
+ `INSERT … SELECT` subqueries all work) and the `op: sql_write` operation. It is
104
+ independent of `install_sql` — either may be installed alone, in any order. It
105
+ selects the sync/async handler the same way (by `asyncio.iscoroutinefunction`).
106
+
107
+ ```js
108
+ {"op": "sql_write", "query": <DML construct tree>, "to": "/dest/path"}
109
+ ```
110
+
111
+ **`$insert`** — exactly one of `values` / `query`:
112
+
113
+ ```python
114
+ {"$insert": {
115
+ "into": "users", # or {"table": "users", "schema": "app"}
116
+ "columns": ["name", "age"], # optional
117
+ "values": [[{"$val": "Ann"}, {"$val": 30}], ...], # cells are operands ($val to bind)
118
+ # ── OR ──
119
+ "query": {"$select": {...}}, # INSERT … SELECT
120
+ }}
121
+ # → INSERT INTO "users" ("name", "age") VALUES (?, ?) params=["Ann", 30]
122
+ ```
123
+
124
+ **`$update`** — single table:
125
+
126
+ ```python
127
+ {"$update": {
128
+ "table": "users", # or {"table": "users", "schema": "app"}
129
+ "set": {"name": {"$val": "Bob"},
130
+ "visits": {"$add": [{"$col": "visits"}, {"$val": 1}]}},
131
+ "where": {"$eq": [{"$col": "id"}, {"$val": 5}]}, # or "all": true
132
+ }}
133
+ # → UPDATE "users" SET "name" = ?, "visits" = ("visits" + ?) WHERE "id" = ?
134
+ ```
135
+
136
+ **`$delete`** — single table:
137
+
138
+ ```python
139
+ {"$delete": {
140
+ "from": "sessions", # or {"table": "sessions", "schema": "app"}
141
+ "where": {"$lt": [{"$col": "last_seen"}, {"$val": "2020-01-01"}]}, # or "all": true
142
+ }}
143
+ # → DELETE FROM "sessions" WHERE "last_seen" < ?
144
+ ```
145
+
146
+ `set` values, `$insert` cells, and `where` predicates are ordinary read
147
+ constructs, so the full expression/predicate/subquery surface (including
148
+ correlated subqueries) is available, and data is always bound as parameters.
149
+
150
+ > **WHERE guard.** A `$update` / `$delete` **without** a `where` raises unless
151
+ > you pass an explicit `"all": true`. This prevents an accidental full-table
152
+ > update/delete.
153
+
154
+ ## Parameterization & injection safety
155
+
156
+ Data values are **always bound as parameters**, never interpolated:
157
+
158
+ * `$val` (and the data sides of `$in`, `$between`, `$values`, `$update`'s `set`
159
+ values, and `$insert`'s row cells) emit a placeholder and add the value to
160
+ `params`.
161
+ * Identifiers (table/column/alias names) are validated against a conservative
162
+ charset and quoted.
163
+ * Function names, CAST types, join types, sort directions, etc. are validated
164
+ against whitelists.
165
+
166
+ ```python
167
+ {"$eq": [{"$col": {"name": "name"}}, {"$val": {"$ref": "/user_input"}}]}
168
+ # → '"name" = ?' with the (possibly malicious) value safely in params
169
+ ```
170
+
171
+ **Parameters come from inside j-perm — no external param source.** Inside `$val`
172
+ the value expression is resolved with j-perm's normal value pipeline, so `$ref`,
173
+ `${…}` templates, and `@:` dest-pointers all work. The `params` list handed to
174
+ the executor is simply the values j-perm itself computed from the document; the
175
+ parameter binding exists only for injection safety and the driver's paramstyle.
176
+ The whole flow (source → SQL → bound values) is self-contained in one j-perm
177
+ run; the executor is just the pipe to the driver. This applies equally to read
178
+ (`op: sql`) and write (`op: sql_write`).
179
+
180
+ ## Dialect / `RenderOptions`
181
+
182
+ Everything that genuinely differs between databases is configurable:
183
+
184
+ ```python
185
+ from j_perm_sql import RenderOptions
186
+
187
+ install_sql(engine, run_sql, dialect=RenderOptions(
188
+ paramstyle="numeric", # qmark (?) | format (%s) | numeric ($1) | named (:p1)
189
+ identifier_quote='"', # e.g. "`" for MySQL
190
+ pagination="fetch", # "limit" (LIMIT n OFFSET m) | "fetch" (OFFSET m ROWS FETCH FIRST n ROWS ONLY)
191
+ concat_operator="||", # "||" or "+"
192
+ ))
193
+ ```
194
+
195
+ ## Sync & async
196
+
197
+ Both `install_sql` and `install_sql_write` inspect the executor: a coroutine
198
+ function registers the async handler (use with `engine.apply_async`); a regular
199
+ function registers the sync handler (use with `engine.apply`).
200
+
201
+ ```python
202
+ async def run_sql(sql, params): ...
203
+ install_sql(engine, run_sql) # async
204
+ await engine.apply_async(spec, source=…, dest=…)
205
+ ```
206
+
207
+ ## Compilation
208
+
209
+ `op: sql` / `op: sql_write` are compilable. `engine.compile(spec)` compiles the
210
+ `query` subtree against the isolated SQL pipeline (the engine never needs to
211
+ understand the SQL constructs — it routes the nested spec through the registered
212
+ pipeline by name), and the rendered tree is dispatched through the compiled path
213
+ with per-node memoisation. Re-applying the same `CompiledSpec` keeps every node
214
+ compiled; only `$val` data is re-bound from the live context on each run.
215
+
216
+ ```python
217
+ compiled = engine.compile([{"op": "sql", "to": "/rows", "query": query}])
218
+ compiled.apply(source={"wanted": 1}, dest={}) # renders + executes, fully compiled
219
+ compiled.apply(source={"wanted": 2}, dest={}) # reuses compiled nodes, re-binds values
220
+ ```
221
+
222
+ This requires the `nested_spec_pipeline` compile hook and per-pipeline
223
+ `CompiledSpec` execution added in the core engine (see the "What gets compiled"
224
+ section of the main j-perm README).
225
+
226
+ ## Construct reference
227
+
228
+ **Query**
229
+
230
+ | Construct | Form |
231
+ |---|---|
232
+ | `$select` | `{with?, distinct?, columns?, from?, joins?, where?, group_by?, having?, order_by?, limit?/offset? \| fetch?}` |
233
+ | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
234
+ | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
235
+
236
+ **Write (DML)** — only via `install_sql_write` / `op: sql_write`
237
+
238
+ | Construct | Form |
239
+ |---|---|
240
+ | `$insert` | `{into, columns?, values \| query}` (exactly one of `values`/`query`) |
241
+ | `$update` | `{table, set: {col: operand}, where? \| "all": true}` |
242
+ | `$delete` | `{from, where? \| "all": true}` |
243
+
244
+ **Projection / expressions**
245
+
246
+ | Construct | Renders |
247
+ |---|---|
248
+ | `$col` | `"t"."name" [AS "alias"]`; `"id"`; `*`; `"t".*` |
249
+ | `$val` | a bound parameter |
250
+ | `$func` / `$call` | `NAME([DISTINCT ]args)[ OVER (…)][ AS "alias"]` (use `"*"` for `COUNT(*)`) |
251
+ | `$cast` | `CAST(expr AS TYPE)` |
252
+ | `$case` | searched `CASE WHEN … THEN … [ELSE …] END` |
253
+ | `$concat` | `(a || b …)` |
254
+ | `$add` `$sub` `$mul` `$div` `$mod` | `(a op b …)` |
255
+
256
+ Projection items may also be `{"expr": <operand>, "as": "alias"}`.
257
+
258
+ **Predicates** (WHERE / HAVING / ON)
259
+
260
+ `$and` `$or` `$not` · `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` ·
261
+ `$in`/`$not_in` (list or subquery) · `$between`/`$not_between` ·
262
+ `$like`/`$not_like` (+ `escape`) · `$is_null`/`$is_not_null` ·
263
+ `$exists`/`$not_exists` · `$any`/`$all`/`$some` (quantified).
264
+
265
+ **FROM / JOIN** — *table source* = a table name (string), a
266
+ `{table, as?, schema?}` dict, a nested `$select`/`$values` (derived table, needs
267
+ `as`), or `lateral: true`. `$join`: `{type, table, as?, on? | using?, natural?}`
268
+ with type `inner`/`left`/`right`/`full`/`cross`.
269
+
270
+ **Windows** — `over` on `$func`: `{partition_by?, order_by?, frame?}` where
271
+ `frame` is `{type: "rows"|"range", start, end?}` and a bound is
272
+ `"unbounded preceding" | "unbounded following" | "current row" |
273
+ {preceding: n} | {following: n}`.
274
+
275
+ **GROUP BY** — a list of expressions, or `{"$rollup": […]}` /
276
+ `{"$cube": […]}` / `{"$grouping_sets": [[…], …]}`.
277
+
278
+ **CTE** — `with`: `[{name, columns?, recursive?, query: $select}]`.
279
+
280
+ See `tests/` for end-to-end examples.
281
+
282
+ ## Portability caveats
283
+
284
+ The DSL renders standard SQL and does **not** validate that a target database
285
+ supports every feature — portability is the query author's responsibility:
286
+
287
+ * `LIMIT/OFFSET` vs `OFFSET/FETCH`, `RIGHT/FULL JOIN`, `INTERSECT/EXCEPT`,
288
+ `NATURAL JOIN`, `NULLS FIRST/LAST`, `LATERAL`, `GROUPING SETS/ROLLUP/CUBE`,
289
+ and the concatenation operator (`||` vs `+`) are not universal.
290
+ * CTEs and window functions are standard but require recent versions
291
+ (e.g. MySQL ≥ 8, SQLite ≥ 3.25 for windows / ≥ 3.8.3 for CTEs).
292
+
293
+ Use `RenderOptions` to match the target dialect's placeholder style, identifier
294
+ quoting, pagination form, and concatenation operator.
295
+
296
+ ## License
297
+
298
+ MIT
@@ -0,0 +1,284 @@
1
+ # j-perm-sql
2
+
3
+ A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
4
+ **SQL queries** from j-perm constructs.
5
+
6
+ * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
7
+ predicates, joins, …).
8
+ * A top-level operation renders that tree to a **parameterized** `(sql, params)`
9
+ pair and hands it to a configurable executor (any ORM's raw-execute function):
10
+ `op: sql` for read-only `SELECT`, and (opt-in) `op: sql_write` for
11
+ `INSERT`/`UPDATE`/`DELETE`.
12
+ * The SQL constructs live in **isolated** named pipelines: they mean nothing
13
+ outside those operations. `{"$select": …}` used as an ordinary value is just a
14
+ dict.
15
+
16
+ > Scope: the full standard **`SELECT`** surface (read-only) via `install_sql`,
17
+ > plus standard **`INSERT`/`UPDATE`/`DELETE`** (row content only) via
18
+ > `install_sql_write`. Schema DDL (`CREATE`/`ALTER`/`DROP` of tables/columns)
19
+ > and non-universal DML (`RETURNING`, `ON CONFLICT`/upsert, `UPDATE … FROM`,
20
+ > `DELETE … USING`) are intentionally out of scope.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install j-perm-sql
26
+ ```
27
+
28
+ Requires `j-perm >= 1.10.0`: 1.9.0 made `run_pipeline` a passthrough invoker
29
+ (which this plugin relies on), and 1.10.0 added the `nested_spec_pipeline`
30
+ compile hook and per-pipeline `CompiledSpec` execution that let `op: sql` be
31
+ compiled end-to-end (see [Compilation](#compilation)).
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from j_perm import build_default_engine
37
+ from j_perm_sql import install_sql
38
+
39
+ def run_sql(sql, params):
40
+ # any ORM's raw execute: cursor.execute(sql, params); return rows
41
+ ...
42
+
43
+ engine = build_default_engine()
44
+ install_sql(engine, run_sql, paramstyle="qmark")
45
+
46
+ engine.apply(
47
+ {"op": "sql", "to": "/rows", "query": {"$select": {
48
+ "columns": [{"$col": {"name": "id"}}, {"$col": {"name": "name"}}],
49
+ "from": {"table": "users"},
50
+ "where": {"$gte": [{"$col": {"name": "age"}}, {"$val": 18}]},
51
+ "order_by": [{"expr": {"$col": {"name": "name"}}}],
52
+ "limit": 50,
53
+ }}},
54
+ source={}, dest={},
55
+ )
56
+ # run_sql receives: ('SELECT "id", "name" FROM "users" WHERE "age" >= ? ORDER BY "name" LIMIT 50', [18])
57
+ # result is written to dest at /rows
58
+ ```
59
+
60
+ `install_sql` **patches an existing engine** — it registers the isolated SQL
61
+ pipeline and the `op: sql` operation. It composes with any engine and any other
62
+ plugins.
63
+
64
+ ## The `op: sql` operation
65
+
66
+ ```js
67
+ {"op": "sql", "query": <SQL construct tree>, "to": "/dest/path"}
68
+ ```
69
+
70
+ * `query` — the SQL construct tree.
71
+ * `to` — optional destination pointer (template-expanded); the executor's
72
+ result is written there. If omitted, the result is discarded.
73
+
74
+ ## Writing data (`INSERT`/`UPDATE`/`DELETE`)
75
+
76
+ Writing is a **separate, opt-in install** — you don't get it unless you ask for
77
+ it, and `op: sql` stays guaranteed read-only even when both are installed:
78
+
79
+ ```python
80
+ from j_perm_sql import install_sql, install_sql_write
81
+
82
+ install_sql(engine, run_sql) # read-only op: sql (optional)
83
+ install_sql_write(engine, run_sql) # write op: sql_write
84
+ ```
85
+
86
+ `install_sql_write(engine, executor, *, paramstyle="qmark", dialect=None,
87
+ op="sql_write")` registers an isolated **write pipeline** (the full `SELECT`
88
+ surface *plus* the DML statements, so `WHERE` predicates, `SET` expressions and
89
+ `INSERT … SELECT` subqueries all work) and the `op: sql_write` operation. It is
90
+ independent of `install_sql` — either may be installed alone, in any order. It
91
+ selects the sync/async handler the same way (by `asyncio.iscoroutinefunction`).
92
+
93
+ ```js
94
+ {"op": "sql_write", "query": <DML construct tree>, "to": "/dest/path"}
95
+ ```
96
+
97
+ **`$insert`** — exactly one of `values` / `query`:
98
+
99
+ ```python
100
+ {"$insert": {
101
+ "into": "users", # or {"table": "users", "schema": "app"}
102
+ "columns": ["name", "age"], # optional
103
+ "values": [[{"$val": "Ann"}, {"$val": 30}], ...], # cells are operands ($val to bind)
104
+ # ── OR ──
105
+ "query": {"$select": {...}}, # INSERT … SELECT
106
+ }}
107
+ # → INSERT INTO "users" ("name", "age") VALUES (?, ?) params=["Ann", 30]
108
+ ```
109
+
110
+ **`$update`** — single table:
111
+
112
+ ```python
113
+ {"$update": {
114
+ "table": "users", # or {"table": "users", "schema": "app"}
115
+ "set": {"name": {"$val": "Bob"},
116
+ "visits": {"$add": [{"$col": "visits"}, {"$val": 1}]}},
117
+ "where": {"$eq": [{"$col": "id"}, {"$val": 5}]}, # or "all": true
118
+ }}
119
+ # → UPDATE "users" SET "name" = ?, "visits" = ("visits" + ?) WHERE "id" = ?
120
+ ```
121
+
122
+ **`$delete`** — single table:
123
+
124
+ ```python
125
+ {"$delete": {
126
+ "from": "sessions", # or {"table": "sessions", "schema": "app"}
127
+ "where": {"$lt": [{"$col": "last_seen"}, {"$val": "2020-01-01"}]}, # or "all": true
128
+ }}
129
+ # → DELETE FROM "sessions" WHERE "last_seen" < ?
130
+ ```
131
+
132
+ `set` values, `$insert` cells, and `where` predicates are ordinary read
133
+ constructs, so the full expression/predicate/subquery surface (including
134
+ correlated subqueries) is available, and data is always bound as parameters.
135
+
136
+ > **WHERE guard.** A `$update` / `$delete` **without** a `where` raises unless
137
+ > you pass an explicit `"all": true`. This prevents an accidental full-table
138
+ > update/delete.
139
+
140
+ ## Parameterization & injection safety
141
+
142
+ Data values are **always bound as parameters**, never interpolated:
143
+
144
+ * `$val` (and the data sides of `$in`, `$between`, `$values`, `$update`'s `set`
145
+ values, and `$insert`'s row cells) emit a placeholder and add the value to
146
+ `params`.
147
+ * Identifiers (table/column/alias names) are validated against a conservative
148
+ charset and quoted.
149
+ * Function names, CAST types, join types, sort directions, etc. are validated
150
+ against whitelists.
151
+
152
+ ```python
153
+ {"$eq": [{"$col": {"name": "name"}}, {"$val": {"$ref": "/user_input"}}]}
154
+ # → '"name" = ?' with the (possibly malicious) value safely in params
155
+ ```
156
+
157
+ **Parameters come from inside j-perm — no external param source.** Inside `$val`
158
+ the value expression is resolved with j-perm's normal value pipeline, so `$ref`,
159
+ `${…}` templates, and `@:` dest-pointers all work. The `params` list handed to
160
+ the executor is simply the values j-perm itself computed from the document; the
161
+ parameter binding exists only for injection safety and the driver's paramstyle.
162
+ The whole flow (source → SQL → bound values) is self-contained in one j-perm
163
+ run; the executor is just the pipe to the driver. This applies equally to read
164
+ (`op: sql`) and write (`op: sql_write`).
165
+
166
+ ## Dialect / `RenderOptions`
167
+
168
+ Everything that genuinely differs between databases is configurable:
169
+
170
+ ```python
171
+ from j_perm_sql import RenderOptions
172
+
173
+ install_sql(engine, run_sql, dialect=RenderOptions(
174
+ paramstyle="numeric", # qmark (?) | format (%s) | numeric ($1) | named (:p1)
175
+ identifier_quote='"', # e.g. "`" for MySQL
176
+ pagination="fetch", # "limit" (LIMIT n OFFSET m) | "fetch" (OFFSET m ROWS FETCH FIRST n ROWS ONLY)
177
+ concat_operator="||", # "||" or "+"
178
+ ))
179
+ ```
180
+
181
+ ## Sync & async
182
+
183
+ Both `install_sql` and `install_sql_write` inspect the executor: a coroutine
184
+ function registers the async handler (use with `engine.apply_async`); a regular
185
+ function registers the sync handler (use with `engine.apply`).
186
+
187
+ ```python
188
+ async def run_sql(sql, params): ...
189
+ install_sql(engine, run_sql) # async
190
+ await engine.apply_async(spec, source=…, dest=…)
191
+ ```
192
+
193
+ ## Compilation
194
+
195
+ `op: sql` / `op: sql_write` are compilable. `engine.compile(spec)` compiles the
196
+ `query` subtree against the isolated SQL pipeline (the engine never needs to
197
+ understand the SQL constructs — it routes the nested spec through the registered
198
+ pipeline by name), and the rendered tree is dispatched through the compiled path
199
+ with per-node memoisation. Re-applying the same `CompiledSpec` keeps every node
200
+ compiled; only `$val` data is re-bound from the live context on each run.
201
+
202
+ ```python
203
+ compiled = engine.compile([{"op": "sql", "to": "/rows", "query": query}])
204
+ compiled.apply(source={"wanted": 1}, dest={}) # renders + executes, fully compiled
205
+ compiled.apply(source={"wanted": 2}, dest={}) # reuses compiled nodes, re-binds values
206
+ ```
207
+
208
+ This requires the `nested_spec_pipeline` compile hook and per-pipeline
209
+ `CompiledSpec` execution added in the core engine (see the "What gets compiled"
210
+ section of the main j-perm README).
211
+
212
+ ## Construct reference
213
+
214
+ **Query**
215
+
216
+ | Construct | Form |
217
+ |---|---|
218
+ | `$select` | `{with?, distinct?, columns?, from?, joins?, where?, group_by?, having?, order_by?, limit?/offset? \| fetch?}` |
219
+ | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
220
+ | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
221
+
222
+ **Write (DML)** — only via `install_sql_write` / `op: sql_write`
223
+
224
+ | Construct | Form |
225
+ |---|---|
226
+ | `$insert` | `{into, columns?, values \| query}` (exactly one of `values`/`query`) |
227
+ | `$update` | `{table, set: {col: operand}, where? \| "all": true}` |
228
+ | `$delete` | `{from, where? \| "all": true}` |
229
+
230
+ **Projection / expressions**
231
+
232
+ | Construct | Renders |
233
+ |---|---|
234
+ | `$col` | `"t"."name" [AS "alias"]`; `"id"`; `*`; `"t".*` |
235
+ | `$val` | a bound parameter |
236
+ | `$func` / `$call` | `NAME([DISTINCT ]args)[ OVER (…)][ AS "alias"]` (use `"*"` for `COUNT(*)`) |
237
+ | `$cast` | `CAST(expr AS TYPE)` |
238
+ | `$case` | searched `CASE WHEN … THEN … [ELSE …] END` |
239
+ | `$concat` | `(a || b …)` |
240
+ | `$add` `$sub` `$mul` `$div` `$mod` | `(a op b …)` |
241
+
242
+ Projection items may also be `{"expr": <operand>, "as": "alias"}`.
243
+
244
+ **Predicates** (WHERE / HAVING / ON)
245
+
246
+ `$and` `$or` `$not` · `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` ·
247
+ `$in`/`$not_in` (list or subquery) · `$between`/`$not_between` ·
248
+ `$like`/`$not_like` (+ `escape`) · `$is_null`/`$is_not_null` ·
249
+ `$exists`/`$not_exists` · `$any`/`$all`/`$some` (quantified).
250
+
251
+ **FROM / JOIN** — *table source* = a table name (string), a
252
+ `{table, as?, schema?}` dict, a nested `$select`/`$values` (derived table, needs
253
+ `as`), or `lateral: true`. `$join`: `{type, table, as?, on? | using?, natural?}`
254
+ with type `inner`/`left`/`right`/`full`/`cross`.
255
+
256
+ **Windows** — `over` on `$func`: `{partition_by?, order_by?, frame?}` where
257
+ `frame` is `{type: "rows"|"range", start, end?}` and a bound is
258
+ `"unbounded preceding" | "unbounded following" | "current row" |
259
+ {preceding: n} | {following: n}`.
260
+
261
+ **GROUP BY** — a list of expressions, or `{"$rollup": […]}` /
262
+ `{"$cube": […]}` / `{"$grouping_sets": [[…], …]}`.
263
+
264
+ **CTE** — `with`: `[{name, columns?, recursive?, query: $select}]`.
265
+
266
+ See `tests/` for end-to-end examples.
267
+
268
+ ## Portability caveats
269
+
270
+ The DSL renders standard SQL and does **not** validate that a target database
271
+ supports every feature — portability is the query author's responsibility:
272
+
273
+ * `LIMIT/OFFSET` vs `OFFSET/FETCH`, `RIGHT/FULL JOIN`, `INTERSECT/EXCEPT`,
274
+ `NATURAL JOIN`, `NULLS FIRST/LAST`, `LATERAL`, `GROUPING SETS/ROLLUP/CUBE`,
275
+ and the concatenation operator (`||` vs `+`) are not universal.
276
+ * CTEs and window functions are standard but require recent versions
277
+ (e.g. MySQL ≥ 8, SQLite ≥ 3.25 for windows / ≥ 3.8.3 for CTEs).
278
+
279
+ Use `RenderOptions` to match the target dialect's placeholder style, identifier
280
+ quoting, pagination form, and concatenation operator.
281
+
282
+ ## License
283
+
284
+ MIT
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "j-perm-sql"
9
- version = "0.1.0"
9
+ version = "0.3.0"
10
10
  description = "j-perm plugin for SQL"
11
11
  authors = [
12
12
  { name = "Roman", email = "kuschanow@gmail.com" },
@@ -18,7 +18,7 @@ requires-python = ">=3.10"
18
18
  license = { text = "MIT" }
19
19
 
20
20
  dependencies = [
21
- "j-perm>=1.9.0",
21
+ "j-perm>=1.10.0",
22
22
  ]
23
23
 
24
24
  [tool.pytest.ini_options]
@@ -25,22 +25,32 @@ Quick start::
25
25
  )
26
26
  """
27
27
  from .constructs import build_sql_specials
28
+ from .constructs_write import build_sql_write_specials
28
29
  from .dialect import PLACEHOLDER, RenderOptions
29
30
  from .handler import AsyncSqlHandler, SqlHandler, SqlRenderer
30
- from .install import install_sql
31
- from .pipeline import SQL_PIPELINE_NAME, build_sql_pipeline
31
+ from .install import install_sql, install_sql_write
32
+ from .pipeline import (
33
+ SQL_PIPELINE_NAME,
34
+ SQL_WRITE_PIPELINE_NAME,
35
+ build_sql_pipeline,
36
+ build_sql_write_pipeline,
37
+ )
32
38
  from .render import fragment, is_fragment, is_query, render
33
39
 
34
40
  __all__ = [
35
41
  "install_sql",
42
+ "install_sql_write",
36
43
  "RenderOptions",
37
44
  "PLACEHOLDER",
38
45
  "SqlHandler",
39
46
  "AsyncSqlHandler",
40
47
  "SqlRenderer",
41
48
  "build_sql_pipeline",
49
+ "build_sql_write_pipeline",
42
50
  "build_sql_specials",
51
+ "build_sql_write_specials",
43
52
  "SQL_PIPELINE_NAME",
53
+ "SQL_WRITE_PIPELINE_NAME",
44
54
  "fragment",
45
55
  "is_fragment",
46
56
  "is_query",