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.
- j_perm_sql-0.3.0/PKG-INFO +298 -0
- j_perm_sql-0.3.0/README.md +284 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/pyproject.toml +2 -2
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/__init__.py +12 -2
- j_perm_sql-0.3.0/src/j_perm_sql/constructs_write.py +138 -0
- j_perm_sql-0.3.0/src/j_perm_sql/handler.py +147 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/install.py +46 -12
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/pipeline.py +24 -5
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/render.py +37 -3
- j_perm_sql-0.3.0/src/j_perm_sql.egg-info/PKG-INFO +298 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql.egg-info/SOURCES.txt +1 -0
- j_perm_sql-0.3.0/src/j_perm_sql.egg-info/requires.txt +1 -0
- j_perm_sql-0.1.0/PKG-INFO +0 -191
- j_perm_sql-0.1.0/README.md +0 -177
- j_perm_sql-0.1.0/src/j_perm_sql/handler.py +0 -75
- j_perm_sql-0.1.0/src/j_perm_sql.egg-info/PKG-INFO +0 -191
- j_perm_sql-0.1.0/src/j_perm_sql.egg-info/requires.txt +0 -1
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/setup.cfg +0 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/constructs.py +0 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql/dialect.py +0 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql.egg-info/dependency_links.txt +0 -0
- {j_perm_sql-0.1.0 → j_perm_sql-0.3.0}/src/j_perm_sql.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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",
|