j-perm-sql 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: j-perm-sql
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: j-perm plugin for SQL
5
5
  Author-email: Roman <kuschanow@gmail.com>
6
6
  License: MIT
@@ -15,18 +15,23 @@ Requires-Dist: j-perm>=1.9.0
15
15
  # j-perm-sql
16
16
 
17
17
  A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
18
- **SQL `SELECT` queries** from j-perm constructs.
18
+ **SQL queries** from j-perm constructs.
19
19
 
20
20
  * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
21
21
  predicates, joins, …).
22
- * A single new top-level operation — `op: sql` — renders that tree to a
23
- **parameterized** `(sql, params)` pair and hands it to a configurable
24
- executor (any ORM's raw-execute function).
25
- * The SQL constructs live in an **isolated** named pipeline: they mean nothing
26
- outside `op: sql`. `{"$select": …}` used as an ordinary value is just a dict.
27
-
28
- > v1 scope: the full standard **`SELECT`** surface (read-only). DDL/DML
29
- > (`CREATE`/`ALTER`/`INSERT`/`UPDATE`/`DELETE`) is intentionally out of scope.
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.
30
35
 
31
36
  ## Install
32
37
 
@@ -78,12 +83,79 @@ plugins.
78
83
  * `to` — optional destination pointer (template-expanded); the executor's
79
84
  result is written there. If omitted, the result is discarded.
80
85
 
86
+ ## Writing data (`INSERT`/`UPDATE`/`DELETE`)
87
+
88
+ Writing is a **separate, opt-in install** — you don't get it unless you ask for
89
+ it, and `op: sql` stays guaranteed read-only even when both are installed:
90
+
91
+ ```python
92
+ from j_perm_sql import install_sql, install_sql_write
93
+
94
+ install_sql(engine, run_sql) # read-only op: sql (optional)
95
+ install_sql_write(engine, run_sql) # write op: sql_write
96
+ ```
97
+
98
+ `install_sql_write(engine, executor, *, paramstyle="qmark", dialect=None,
99
+ op="sql_write")` registers an isolated **write pipeline** (the full `SELECT`
100
+ surface *plus* the DML statements, so `WHERE` predicates, `SET` expressions and
101
+ `INSERT … SELECT` subqueries all work) and the `op: sql_write` operation. It is
102
+ independent of `install_sql` — either may be installed alone, in any order. It
103
+ selects the sync/async handler the same way (by `asyncio.iscoroutinefunction`).
104
+
105
+ ```js
106
+ {"op": "sql_write", "query": <DML construct tree>, "to": "/dest/path"}
107
+ ```
108
+
109
+ **`$insert`** — exactly one of `values` / `query`:
110
+
111
+ ```python
112
+ {"$insert": {
113
+ "into": "users", # or {"table": "users", "schema": "app"}
114
+ "columns": ["name", "age"], # optional
115
+ "values": [[{"$val": "Ann"}, {"$val": 30}], ...], # cells are operands ($val to bind)
116
+ # ── OR ──
117
+ "query": {"$select": {...}}, # INSERT … SELECT
118
+ }}
119
+ # → INSERT INTO "users" ("name", "age") VALUES (?, ?) params=["Ann", 30]
120
+ ```
121
+
122
+ **`$update`** — single table:
123
+
124
+ ```python
125
+ {"$update": {
126
+ "table": "users", # or {"table": "users", "schema": "app"}
127
+ "set": {"name": {"$val": "Bob"},
128
+ "visits": {"$add": [{"$col": "visits"}, {"$val": 1}]}},
129
+ "where": {"$eq": [{"$col": "id"}, {"$val": 5}]}, # or "all": true
130
+ }}
131
+ # → UPDATE "users" SET "name" = ?, "visits" = ("visits" + ?) WHERE "id" = ?
132
+ ```
133
+
134
+ **`$delete`** — single table:
135
+
136
+ ```python
137
+ {"$delete": {
138
+ "from": "sessions", # or {"table": "sessions", "schema": "app"}
139
+ "where": {"$lt": [{"$col": "last_seen"}, {"$val": "2020-01-01"}]}, # or "all": true
140
+ }}
141
+ # → DELETE FROM "sessions" WHERE "last_seen" < ?
142
+ ```
143
+
144
+ `set` values, `$insert` cells, and `where` predicates are ordinary read
145
+ constructs, so the full expression/predicate/subquery surface (including
146
+ correlated subqueries) is available, and data is always bound as parameters.
147
+
148
+ > **WHERE guard.** A `$update` / `$delete` **without** a `where` raises unless
149
+ > you pass an explicit `"all": true`. This prevents an accidental full-table
150
+ > update/delete.
151
+
81
152
  ## Parameterization & injection safety
82
153
 
83
154
  Data values are **always bound as parameters**, never interpolated:
84
155
 
85
- * `$val` (and the data sides of `$in`, `$between`, `$values`) emit a placeholder
86
- and add the value to `params`.
156
+ * `$val` (and the data sides of `$in`, `$between`, `$values`, `$update`'s `set`
157
+ values, and `$insert`'s row cells) emit a placeholder and add the value to
158
+ `params`.
87
159
  * Identifiers (table/column/alias names) are validated against a conservative
88
160
  charset and quoted.
89
161
  * Function names, CAST types, join types, sort directions, etc. are validated
@@ -94,8 +166,14 @@ Data values are **always bound as parameters**, never interpolated:
94
166
  # → '"name" = ?' with the (possibly malicious) value safely in params
95
167
  ```
96
168
 
97
- Inside `$val`, the value expression is resolved with j-perm's normal value
98
- pipeline, so `$ref`, `${…}` templates, and `@:` dest-pointers all work.
169
+ **Parameters come from inside j-perm no external param source.** Inside `$val`
170
+ the value expression is resolved with j-perm's normal value pipeline, so `$ref`,
171
+ `${…}` templates, and `@:` dest-pointers all work. The `params` list handed to
172
+ the executor is simply the values j-perm itself computed from the document; the
173
+ parameter binding exists only for injection safety and the driver's paramstyle.
174
+ The whole flow (source → SQL → bound values) is self-contained in one j-perm
175
+ run; the executor is just the pipe to the driver. This applies equally to read
176
+ (`op: sql`) and write (`op: sql_write`).
99
177
 
100
178
  ## Dialect / `RenderOptions`
101
179
 
@@ -114,9 +192,9 @@ install_sql(engine, run_sql, dialect=RenderOptions(
114
192
 
115
193
  ## Sync & async
116
194
 
117
- `install_sql` inspects the executor: a coroutine function registers the async
118
- handler (use with `engine.apply_async`); a regular function registers the sync
119
- handler (use with `engine.apply`).
195
+ Both `install_sql` and `install_sql_write` inspect the executor: a coroutine
196
+ function registers the async handler (use with `engine.apply_async`); a regular
197
+ function registers the sync handler (use with `engine.apply`).
120
198
 
121
199
  ```python
122
200
  async def run_sql(sql, params): ...
@@ -134,6 +212,14 @@ await engine.apply_async(spec, source=…, dest=…)
134
212
  | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
135
213
  | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
136
214
 
215
+ **Write (DML)** — only via `install_sql_write` / `op: sql_write`
216
+
217
+ | Construct | Form |
218
+ |---|---|
219
+ | `$insert` | `{into, columns?, values \| query}` (exactly one of `values`/`query`) |
220
+ | `$update` | `{table, set: {col: operand}, where? \| "all": true}` |
221
+ | `$delete` | `{from, where? \| "all": true}` |
222
+
137
223
  **Projection / expressions**
138
224
 
139
225
  | Construct | Renders |
@@ -1,18 +1,23 @@
1
1
  # j-perm-sql
2
2
 
3
3
  A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
4
- **SQL `SELECT` queries** from j-perm constructs.
4
+ **SQL queries** from j-perm constructs.
5
5
 
6
6
  * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
7
7
  predicates, joins, …).
8
- * A single new top-level operation — `op: sql` — renders that tree to a
9
- **parameterized** `(sql, params)` pair and hands it to a configurable
10
- executor (any ORM's raw-execute function).
11
- * The SQL constructs live in an **isolated** named pipeline: they mean nothing
12
- outside `op: sql`. `{"$select": …}` used as an ordinary value is just a dict.
13
-
14
- > v1 scope: the full standard **`SELECT`** surface (read-only). DDL/DML
15
- > (`CREATE`/`ALTER`/`INSERT`/`UPDATE`/`DELETE`) is intentionally out of scope.
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.
16
21
 
17
22
  ## Install
18
23
 
@@ -64,12 +69,79 @@ plugins.
64
69
  * `to` — optional destination pointer (template-expanded); the executor's
65
70
  result is written there. If omitted, the result is discarded.
66
71
 
72
+ ## Writing data (`INSERT`/`UPDATE`/`DELETE`)
73
+
74
+ Writing is a **separate, opt-in install** — you don't get it unless you ask for
75
+ it, and `op: sql` stays guaranteed read-only even when both are installed:
76
+
77
+ ```python
78
+ from j_perm_sql import install_sql, install_sql_write
79
+
80
+ install_sql(engine, run_sql) # read-only op: sql (optional)
81
+ install_sql_write(engine, run_sql) # write op: sql_write
82
+ ```
83
+
84
+ `install_sql_write(engine, executor, *, paramstyle="qmark", dialect=None,
85
+ op="sql_write")` registers an isolated **write pipeline** (the full `SELECT`
86
+ surface *plus* the DML statements, so `WHERE` predicates, `SET` expressions and
87
+ `INSERT … SELECT` subqueries all work) and the `op: sql_write` operation. It is
88
+ independent of `install_sql` — either may be installed alone, in any order. It
89
+ selects the sync/async handler the same way (by `asyncio.iscoroutinefunction`).
90
+
91
+ ```js
92
+ {"op": "sql_write", "query": <DML construct tree>, "to": "/dest/path"}
93
+ ```
94
+
95
+ **`$insert`** — exactly one of `values` / `query`:
96
+
97
+ ```python
98
+ {"$insert": {
99
+ "into": "users", # or {"table": "users", "schema": "app"}
100
+ "columns": ["name", "age"], # optional
101
+ "values": [[{"$val": "Ann"}, {"$val": 30}], ...], # cells are operands ($val to bind)
102
+ # ── OR ──
103
+ "query": {"$select": {...}}, # INSERT … SELECT
104
+ }}
105
+ # → INSERT INTO "users" ("name", "age") VALUES (?, ?) params=["Ann", 30]
106
+ ```
107
+
108
+ **`$update`** — single table:
109
+
110
+ ```python
111
+ {"$update": {
112
+ "table": "users", # or {"table": "users", "schema": "app"}
113
+ "set": {"name": {"$val": "Bob"},
114
+ "visits": {"$add": [{"$col": "visits"}, {"$val": 1}]}},
115
+ "where": {"$eq": [{"$col": "id"}, {"$val": 5}]}, # or "all": true
116
+ }}
117
+ # → UPDATE "users" SET "name" = ?, "visits" = ("visits" + ?) WHERE "id" = ?
118
+ ```
119
+
120
+ **`$delete`** — single table:
121
+
122
+ ```python
123
+ {"$delete": {
124
+ "from": "sessions", # or {"table": "sessions", "schema": "app"}
125
+ "where": {"$lt": [{"$col": "last_seen"}, {"$val": "2020-01-01"}]}, # or "all": true
126
+ }}
127
+ # → DELETE FROM "sessions" WHERE "last_seen" < ?
128
+ ```
129
+
130
+ `set` values, `$insert` cells, and `where` predicates are ordinary read
131
+ constructs, so the full expression/predicate/subquery surface (including
132
+ correlated subqueries) is available, and data is always bound as parameters.
133
+
134
+ > **WHERE guard.** A `$update` / `$delete` **without** a `where` raises unless
135
+ > you pass an explicit `"all": true`. This prevents an accidental full-table
136
+ > update/delete.
137
+
67
138
  ## Parameterization & injection safety
68
139
 
69
140
  Data values are **always bound as parameters**, never interpolated:
70
141
 
71
- * `$val` (and the data sides of `$in`, `$between`, `$values`) emit a placeholder
72
- and add the value to `params`.
142
+ * `$val` (and the data sides of `$in`, `$between`, `$values`, `$update`'s `set`
143
+ values, and `$insert`'s row cells) emit a placeholder and add the value to
144
+ `params`.
73
145
  * Identifiers (table/column/alias names) are validated against a conservative
74
146
  charset and quoted.
75
147
  * Function names, CAST types, join types, sort directions, etc. are validated
@@ -80,8 +152,14 @@ Data values are **always bound as parameters**, never interpolated:
80
152
  # → '"name" = ?' with the (possibly malicious) value safely in params
81
153
  ```
82
154
 
83
- Inside `$val`, the value expression is resolved with j-perm's normal value
84
- pipeline, so `$ref`, `${…}` templates, and `@:` dest-pointers all work.
155
+ **Parameters come from inside j-perm no external param source.** Inside `$val`
156
+ the value expression is resolved with j-perm's normal value pipeline, so `$ref`,
157
+ `${…}` templates, and `@:` dest-pointers all work. The `params` list handed to
158
+ the executor is simply the values j-perm itself computed from the document; the
159
+ parameter binding exists only for injection safety and the driver's paramstyle.
160
+ The whole flow (source → SQL → bound values) is self-contained in one j-perm
161
+ run; the executor is just the pipe to the driver. This applies equally to read
162
+ (`op: sql`) and write (`op: sql_write`).
85
163
 
86
164
  ## Dialect / `RenderOptions`
87
165
 
@@ -100,9 +178,9 @@ install_sql(engine, run_sql, dialect=RenderOptions(
100
178
 
101
179
  ## Sync & async
102
180
 
103
- `install_sql` inspects the executor: a coroutine function registers the async
104
- handler (use with `engine.apply_async`); a regular function registers the sync
105
- handler (use with `engine.apply`).
181
+ Both `install_sql` and `install_sql_write` inspect the executor: a coroutine
182
+ function registers the async handler (use with `engine.apply_async`); a regular
183
+ function registers the sync handler (use with `engine.apply`).
106
184
 
107
185
  ```python
108
186
  async def run_sql(sql, params): ...
@@ -120,6 +198,14 @@ await engine.apply_async(spec, source=…, dest=…)
120
198
  | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
121
199
  | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
122
200
 
201
+ **Write (DML)** — only via `install_sql_write` / `op: sql_write`
202
+
203
+ | Construct | Form |
204
+ |---|---|
205
+ | `$insert` | `{into, columns?, values \| query}` (exactly one of `values`/`query`) |
206
+ | `$update` | `{table, set: {col: operand}, where? \| "all": true}` |
207
+ | `$delete` | `{from, where? \| "all": true}` |
208
+
123
209
  **Projection / expressions**
124
210
 
125
211
  | Construct | Renders |
@@ -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.2.0"
10
10
  description = "j-perm plugin for SQL"
11
11
  authors = [
12
12
  { name = "Roman", email = "kuschanow@gmail.com" },
@@ -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",
@@ -0,0 +1,138 @@
1
+ """DML construct handlers — standard ``INSERT`` / ``UPDATE`` / ``DELETE``.
2
+
3
+ These build on the read-only constructs in :mod:`.constructs`: a write
4
+ statement's sub-parts (``WHERE`` predicates, ``SET`` values, ``INSERT … SELECT``
5
+ subqueries) are ordinary read constructs that recurse through the same pipeline.
6
+
7
+ Only the standard, broadly portable forms are supported. Non-standard surface
8
+ (``RETURNING``, ``ON CONFLICT``/upsert, ``UPDATE … FROM``, ``DELETE … USING``)
9
+ is intentionally out of scope.
10
+
11
+ Safety:
12
+
13
+ * Data values are always bound as parameters (``$val`` / operands), never
14
+ interpolated — exactly as for ``SELECT``.
15
+ * Table and column names are validated and quoted as identifiers.
16
+ * ``UPDATE`` / ``DELETE`` without a ``where`` raise unless an explicit
17
+ ``"all": true`` flag is given, to guard against accidental full-table writes.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from functools import partial
22
+
23
+ from .constructs import build_sql_specials
24
+ from .dialect import RenderOptions
25
+ from .render import (
26
+ fragment,
27
+ is_query,
28
+ render_construct,
29
+ render_operand,
30
+ render_operands,
31
+ )
32
+
33
+
34
+ def _table_target(spec, opts: RenderOptions) -> str:
35
+ """Quote a plain table target (``INSERT INTO`` / ``UPDATE`` / ``DELETE FROM``).
36
+
37
+ Standard DML targets a single named table (optionally schema-qualified) — no
38
+ alias and no subquery. Accepts a string name or ``{"table", "schema"?}``.
39
+ """
40
+ if isinstance(spec, str):
41
+ return opts.quote_ref(spec)
42
+ if isinstance(spec, dict) and "table" in spec and not is_query(spec):
43
+ parts = ([spec["schema"]] if spec.get("schema") else []) + [spec["table"]]
44
+ return ".".join(opts.quote_identifier(p) for p in parts)
45
+ raise ValueError(f"invalid table target: {spec!r}")
46
+
47
+
48
+ def _where_clause(spec, ctx) -> tuple[str, list]:
49
+ """Render the optional ``WHERE`` of an UPDATE/DELETE, enforcing the guard.
50
+
51
+ Returns ``(sql_suffix, params)``. Without a ``where`` key an explicit
52
+ ``"all": true`` is required, else :class:`ValueError`.
53
+ """
54
+ if "where" in spec:
55
+ wf = render_construct(spec["where"], ctx)
56
+ return f" WHERE {wf['sql']}", wf["params"]
57
+ if spec.get("all") is True:
58
+ return "", []
59
+ raise ValueError(
60
+ "UPDATE/DELETE without 'where' requires an explicit \"all\": true"
61
+ )
62
+
63
+
64
+ def insert(node, ctx, *, opts: RenderOptions) -> dict:
65
+ spec = node["$insert"]
66
+ sql = f"INSERT INTO {_table_target(spec['into'], opts)}"
67
+ if spec.get("columns"):
68
+ cols = ", ".join(opts.quote_identifier(c) for c in spec["columns"])
69
+ sql += f" ({cols})"
70
+ has_values = "values" in spec
71
+ has_query = "query" in spec
72
+ if has_values == has_query:
73
+ raise ValueError("$insert requires exactly one of 'values' or 'query'")
74
+ params: list = []
75
+ if has_values:
76
+ rows = spec["values"]
77
+ if not rows:
78
+ raise ValueError("$insert 'values' requires at least one row")
79
+ rendered_rows: list[str] = []
80
+ width: int | None = None
81
+ for row in rows:
82
+ if width is None:
83
+ width = len(row)
84
+ elif len(row) != width:
85
+ raise ValueError("all $insert rows must have the same length")
86
+ frag = render_operands(row, ctx, opts)
87
+ rendered_rows.append(f"({frag['sql']})")
88
+ params += frag["params"]
89
+ sql += " VALUES " + ", ".join(rendered_rows)
90
+ else:
91
+ if not is_query(spec["query"]):
92
+ raise ValueError("$insert 'query' must be a SELECT/set-op query construct")
93
+ q = render_construct(spec["query"], ctx)
94
+ sql += f" {q['sql']}"
95
+ params += q["params"]
96
+ return fragment(sql, params)
97
+
98
+
99
+ def _render_set(spec, ctx, opts: RenderOptions) -> dict:
100
+ if not isinstance(spec, dict) or not spec:
101
+ raise ValueError("$update 'set' must be a non-empty mapping of column -> value")
102
+ assignments: list[str] = []
103
+ params: list = []
104
+ for col_name, value in spec.items():
105
+ rhs = render_operand(value, ctx, opts)
106
+ assignments.append(f"{opts.quote_identifier(col_name)} = {rhs['sql']}")
107
+ params += rhs["params"]
108
+ return fragment(", ".join(assignments), params)
109
+
110
+
111
+ def update(node, ctx, *, opts: RenderOptions) -> dict:
112
+ spec = node["$update"]
113
+ set_frag = _render_set(spec["set"], ctx, opts)
114
+ sql = f"UPDATE {_table_target(spec['table'], opts)} SET {set_frag['sql']}"
115
+ params = list(set_frag["params"])
116
+ where_sql, where_params = _where_clause(spec, ctx)
117
+ return fragment(sql + where_sql, params + where_params)
118
+
119
+
120
+ def delete(node, ctx, *, opts: RenderOptions) -> dict:
121
+ spec = node["$delete"]
122
+ sql = f"DELETE FROM {_table_target(spec['from'], opts)}"
123
+ where_sql, where_params = _where_clause(spec, ctx)
124
+ return fragment(sql + where_sql, list(where_params))
125
+
126
+
127
+ def build_sql_write_specials(opts: RenderOptions) -> dict:
128
+ """Build the ``{key: handler}`` mapping for the write SQL pipeline.
129
+
130
+ The write pipeline is a superset of the read pipeline: all ``SELECT``
131
+ constructs plus the DML statements, so subqueries / predicates / ``SET``
132
+ expressions resolve in the same pipeline.
133
+ """
134
+ specials = dict(build_sql_specials(opts))
135
+ specials["$insert"] = partial(insert, opts=opts)
136
+ specials["$update"] = partial(update, opts=opts)
137
+ specials["$delete"] = partial(delete, opts=opts)
138
+ return specials
@@ -19,24 +19,35 @@ from __future__ import annotations
19
19
  from j_perm import ActionHandler, AsyncActionHandler
20
20
 
21
21
  from .dialect import RenderOptions
22
- from .render import SQL_PIPELINE_NAME, is_fragment
22
+ from .render import ACTIVE_PIPELINE_KEY, SQL_PIPELINE_NAME, is_fragment
23
23
 
24
24
 
25
25
  class SqlRenderer:
26
- """Render a SQL construct tree to ``(sql, params)`` for a target dialect."""
26
+ """Render a SQL construct tree to ``(sql, params)`` for a target dialect.
27
27
 
28
- def __init__(self, opts: RenderOptions) -> None:
28
+ *pipeline_name* selects which isolated pipeline the tree (and all its
29
+ recursion) is dispatched through — the read-only ``"sql"`` pipeline by
30
+ default, or the write pipeline for ``op: sql_write``.
31
+ """
32
+
33
+ def __init__(self, opts: RenderOptions, pipeline_name: str = SQL_PIPELINE_NAME) -> None:
29
34
  self.opts = opts
35
+ self.pipeline_name = pipeline_name
30
36
 
31
37
  def render(self, query, ctx) -> tuple:
32
38
  # Render against a scratch dest so the real document is never clobbered,
33
39
  # but expose the real dest under _real_dest so @: pointers inside $val
34
- # can still read the document being built.
40
+ # can still read the document being built. The active pipeline name is
41
+ # threaded through metadata so recursion stays in this pipeline.
35
42
  render_ctx = ctx.copy(
36
43
  new_dest={},
37
- new_metadata={**ctx.metadata, "_real_dest": ctx.dest},
44
+ new_metadata={
45
+ **ctx.metadata,
46
+ "_real_dest": ctx.dest,
47
+ ACTIVE_PIPELINE_KEY: self.pipeline_name,
48
+ },
38
49
  )
39
- frag = ctx.engine.run_pipeline(SQL_PIPELINE_NAME, query, render_ctx).dest
50
+ frag = ctx.engine.run_pipeline(self.pipeline_name, query, render_ctx).dest
40
51
  if not is_fragment(frag):
41
52
  raise ValueError("top-level SQL query must be a SQL construct")
42
53
  return self.opts.finalize(frag["sql"], frag["params"])
@@ -12,14 +12,29 @@ from j_perm import ActionNode, OpMatcher
12
12
 
13
13
  from .dialect import RenderOptions
14
14
  from .handler import AsyncSqlHandler, SqlHandler, SqlRenderer
15
- from .pipeline import build_sql_pipeline
16
- from .render import SQL_PIPELINE_NAME
15
+ from .pipeline import (
16
+ SQL_PIPELINE_NAME,
17
+ SQL_WRITE_PIPELINE_NAME,
18
+ build_sql_pipeline,
19
+ build_sql_write_pipeline,
20
+ )
17
21
 
18
- __all__ = ["install_sql"]
22
+ __all__ = ["install_sql", "install_sql_write"]
23
+
24
+
25
+ def _register_op(engine, executor, renderer, op):
26
+ """Register an ``op`` handler, choosing sync/async by the executor."""
27
+ if asyncio.iscoroutinefunction(executor):
28
+ handler = AsyncSqlHandler(executor, renderer)
29
+ else:
30
+ handler = SqlHandler(executor, renderer)
31
+ engine.main_pipeline.registry.register(
32
+ ActionNode(name=op, priority=10, matcher=OpMatcher(op), handler=handler)
33
+ )
19
34
 
20
35
 
21
36
  def install_sql(engine, executor, *, paramstyle: str = "qmark", dialect=None, op: str = "sql"):
22
- """Install SQL support into *engine*.
37
+ """Install read-only SQL (``SELECT``) support into *engine*.
23
38
 
24
39
  Args:
25
40
  engine: a built ``j_perm`` engine (e.g. from ``build_default_engine``).
@@ -36,12 +51,31 @@ def install_sql(engine, executor, *, paramstyle: str = "qmark", dialect=None, op
36
51
  """
37
52
  opts = dialect if dialect is not None else RenderOptions(paramstyle=paramstyle)
38
53
  engine.register_pipeline(SQL_PIPELINE_NAME, build_sql_pipeline(opts))
39
- renderer = SqlRenderer(opts)
40
- if asyncio.iscoroutinefunction(executor):
41
- handler = AsyncSqlHandler(executor, renderer)
42
- else:
43
- handler = SqlHandler(executor, renderer)
44
- engine.main_pipeline.registry.register(
45
- ActionNode(name=op, priority=10, matcher=OpMatcher(op), handler=handler)
46
- )
54
+ _register_op(engine, executor, SqlRenderer(opts, SQL_PIPELINE_NAME), op)
55
+ return engine
56
+
57
+
58
+ def install_sql_write(
59
+ engine, executor, *, paramstyle: str = "qmark", dialect=None, op: str = "sql_write"
60
+ ):
61
+ """Install write (DML) SQL support — ``INSERT`` / ``UPDATE`` / ``DELETE``.
62
+
63
+ This is a separate, opt-in install: it registers an isolated write pipeline
64
+ (read constructs + DML) and a distinct ``op`` so that ``op: sql`` — if also
65
+ installed — stays guaranteed read-only. Independent of :func:`install_sql`
66
+ (either may be installed alone, in any order).
67
+
68
+ Args:
69
+ engine: a built ``j_perm`` engine.
70
+ executor: ``executor(sql, params) -> result`` (sync or coroutine).
71
+ paramstyle: placeholder style when *dialect* is not given.
72
+ dialect: an explicit :class:`RenderOptions`; overrides *paramstyle*.
73
+ op: the operation name to register (default ``"sql_write"``).
74
+
75
+ Returns:
76
+ The same *engine*, for chaining.
77
+ """
78
+ opts = dialect if dialect is not None else RenderOptions(paramstyle=paramstyle)
79
+ engine.register_pipeline(SQL_WRITE_PIPELINE_NAME, build_sql_write_pipeline(opts))
80
+ _register_op(engine, executor, SqlRenderer(opts, SQL_WRITE_PIPELINE_NAME), op)
47
81
  return engine
@@ -17,16 +17,23 @@ from j_perm import (
17
17
  )
18
18
 
19
19
  from .constructs import build_sql_specials
20
+ from .constructs_write import build_sql_write_specials
20
21
  from .dialect import RenderOptions
21
22
  from .render import SQL_PIPELINE_NAME
22
23
 
23
- __all__ = ["build_sql_pipeline", "SQL_PIPELINE_NAME"]
24
+ #: Name the write (DML) SQL pipeline is registered under on the engine.
25
+ SQL_WRITE_PIPELINE_NAME = "sql_write"
24
26
 
27
+ __all__ = [
28
+ "build_sql_pipeline",
29
+ "build_sql_write_pipeline",
30
+ "SQL_PIPELINE_NAME",
31
+ "SQL_WRITE_PIPELINE_NAME",
32
+ ]
25
33
 
26
- def build_sql_pipeline(opts: RenderOptions | None = None) -> Pipeline:
27
- """Build the isolated SQL pipeline for the given dialect options."""
28
- opts = opts if opts is not None else RenderOptions()
29
- specials = build_sql_specials(opts)
34
+
35
+ def _build_pipeline(specials: dict) -> Pipeline:
36
+ """Assemble an isolated SQL pipeline from a ``{key: handler}`` mapping."""
30
37
  registry = ActionTypeRegistry()
31
38
  registry.register(
32
39
  ActionNode(
@@ -45,3 +52,15 @@ def build_sql_pipeline(opts: RenderOptions | None = None) -> Pipeline:
45
52
  )
46
53
  )
47
54
  return Pipeline(registry=registry, track_execution=True)
55
+
56
+
57
+ def build_sql_pipeline(opts: RenderOptions | None = None) -> Pipeline:
58
+ """Build the isolated read-only SQL pipeline for the given dialect options."""
59
+ opts = opts if opts is not None else RenderOptions()
60
+ return _build_pipeline(build_sql_specials(opts))
61
+
62
+
63
+ def build_sql_write_pipeline(opts: RenderOptions | None = None) -> Pipeline:
64
+ """Build the isolated write (DML) SQL pipeline (read constructs + DML)."""
65
+ opts = opts if opts is not None else RenderOptions()
66
+ return _build_pipeline(build_sql_write_specials(opts))
@@ -19,9 +19,15 @@ from typing import Any
19
19
 
20
20
  from .dialect import PLACEHOLDER, RenderOptions
21
21
 
22
- #: Name the SQL value-pipeline is registered under on the engine.
22
+ #: Name the read-only SQL value-pipeline is registered under on the engine.
23
23
  SQL_PIPELINE_NAME = "sql"
24
24
 
25
+ #: Metadata key carrying the name of the pipeline that recursion should dispatch
26
+ #: through. Set by :class:`~j_perm_sql.handler.SqlRenderer` so a write
27
+ #: statement's sub-parts resolve in the write pipeline; defaults to the read
28
+ #: pipeline when absent.
29
+ ACTIVE_PIPELINE_KEY = "_sql_pipeline"
30
+
25
31
  #: Keys whose presence marks a node as a *query* (must be parenthesised when
26
32
  #: used as an operand, subquery, or derived table).
27
33
  _QUERY_KEYS = frozenset(
@@ -45,8 +51,14 @@ def is_query(node: Any) -> bool:
45
51
 
46
52
 
47
53
  def render(node: Any, ctx) -> Any:
48
- """Dispatch *node* through the isolated SQL pipeline and return the result."""
49
- return ctx.engine.run_pipeline(SQL_PIPELINE_NAME, node, ctx).dest
54
+ """Dispatch *node* through the active SQL pipeline and return the result.
55
+
56
+ The active pipeline name is read from ``ctx.metadata`` (set by the renderer)
57
+ so recursion stays within the same pipeline the top-level operation chose;
58
+ it defaults to the read-only :data:`SQL_PIPELINE_NAME`.
59
+ """
60
+ name = ctx.metadata.get(ACTIVE_PIPELINE_KEY, SQL_PIPELINE_NAME)
61
+ return ctx.engine.run_pipeline(name, node, ctx).dest
50
62
 
51
63
 
52
64
  def render_construct(node: Any, ctx) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: j-perm-sql
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: j-perm plugin for SQL
5
5
  Author-email: Roman <kuschanow@gmail.com>
6
6
  License: MIT
@@ -15,18 +15,23 @@ Requires-Dist: j-perm>=1.9.0
15
15
  # j-perm-sql
16
16
 
17
17
  A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
18
- **SQL `SELECT` queries** from j-perm constructs.
18
+ **SQL queries** from j-perm constructs.
19
19
 
20
20
  * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
21
21
  predicates, joins, …).
22
- * A single new top-level operation — `op: sql` — renders that tree to a
23
- **parameterized** `(sql, params)` pair and hands it to a configurable
24
- executor (any ORM's raw-execute function).
25
- * The SQL constructs live in an **isolated** named pipeline: they mean nothing
26
- outside `op: sql`. `{"$select": …}` used as an ordinary value is just a dict.
27
-
28
- > v1 scope: the full standard **`SELECT`** surface (read-only). DDL/DML
29
- > (`CREATE`/`ALTER`/`INSERT`/`UPDATE`/`DELETE`) is intentionally out of scope.
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.
30
35
 
31
36
  ## Install
32
37
 
@@ -78,12 +83,79 @@ plugins.
78
83
  * `to` — optional destination pointer (template-expanded); the executor's
79
84
  result is written there. If omitted, the result is discarded.
80
85
 
86
+ ## Writing data (`INSERT`/`UPDATE`/`DELETE`)
87
+
88
+ Writing is a **separate, opt-in install** — you don't get it unless you ask for
89
+ it, and `op: sql` stays guaranteed read-only even when both are installed:
90
+
91
+ ```python
92
+ from j_perm_sql import install_sql, install_sql_write
93
+
94
+ install_sql(engine, run_sql) # read-only op: sql (optional)
95
+ install_sql_write(engine, run_sql) # write op: sql_write
96
+ ```
97
+
98
+ `install_sql_write(engine, executor, *, paramstyle="qmark", dialect=None,
99
+ op="sql_write")` registers an isolated **write pipeline** (the full `SELECT`
100
+ surface *plus* the DML statements, so `WHERE` predicates, `SET` expressions and
101
+ `INSERT … SELECT` subqueries all work) and the `op: sql_write` operation. It is
102
+ independent of `install_sql` — either may be installed alone, in any order. It
103
+ selects the sync/async handler the same way (by `asyncio.iscoroutinefunction`).
104
+
105
+ ```js
106
+ {"op": "sql_write", "query": <DML construct tree>, "to": "/dest/path"}
107
+ ```
108
+
109
+ **`$insert`** — exactly one of `values` / `query`:
110
+
111
+ ```python
112
+ {"$insert": {
113
+ "into": "users", # or {"table": "users", "schema": "app"}
114
+ "columns": ["name", "age"], # optional
115
+ "values": [[{"$val": "Ann"}, {"$val": 30}], ...], # cells are operands ($val to bind)
116
+ # ── OR ──
117
+ "query": {"$select": {...}}, # INSERT … SELECT
118
+ }}
119
+ # → INSERT INTO "users" ("name", "age") VALUES (?, ?) params=["Ann", 30]
120
+ ```
121
+
122
+ **`$update`** — single table:
123
+
124
+ ```python
125
+ {"$update": {
126
+ "table": "users", # or {"table": "users", "schema": "app"}
127
+ "set": {"name": {"$val": "Bob"},
128
+ "visits": {"$add": [{"$col": "visits"}, {"$val": 1}]}},
129
+ "where": {"$eq": [{"$col": "id"}, {"$val": 5}]}, # or "all": true
130
+ }}
131
+ # → UPDATE "users" SET "name" = ?, "visits" = ("visits" + ?) WHERE "id" = ?
132
+ ```
133
+
134
+ **`$delete`** — single table:
135
+
136
+ ```python
137
+ {"$delete": {
138
+ "from": "sessions", # or {"table": "sessions", "schema": "app"}
139
+ "where": {"$lt": [{"$col": "last_seen"}, {"$val": "2020-01-01"}]}, # or "all": true
140
+ }}
141
+ # → DELETE FROM "sessions" WHERE "last_seen" < ?
142
+ ```
143
+
144
+ `set` values, `$insert` cells, and `where` predicates are ordinary read
145
+ constructs, so the full expression/predicate/subquery surface (including
146
+ correlated subqueries) is available, and data is always bound as parameters.
147
+
148
+ > **WHERE guard.** A `$update` / `$delete` **without** a `where` raises unless
149
+ > you pass an explicit `"all": true`. This prevents an accidental full-table
150
+ > update/delete.
151
+
81
152
  ## Parameterization & injection safety
82
153
 
83
154
  Data values are **always bound as parameters**, never interpolated:
84
155
 
85
- * `$val` (and the data sides of `$in`, `$between`, `$values`) emit a placeholder
86
- and add the value to `params`.
156
+ * `$val` (and the data sides of `$in`, `$between`, `$values`, `$update`'s `set`
157
+ values, and `$insert`'s row cells) emit a placeholder and add the value to
158
+ `params`.
87
159
  * Identifiers (table/column/alias names) are validated against a conservative
88
160
  charset and quoted.
89
161
  * Function names, CAST types, join types, sort directions, etc. are validated
@@ -94,8 +166,14 @@ Data values are **always bound as parameters**, never interpolated:
94
166
  # → '"name" = ?' with the (possibly malicious) value safely in params
95
167
  ```
96
168
 
97
- Inside `$val`, the value expression is resolved with j-perm's normal value
98
- pipeline, so `$ref`, `${…}` templates, and `@:` dest-pointers all work.
169
+ **Parameters come from inside j-perm no external param source.** Inside `$val`
170
+ the value expression is resolved with j-perm's normal value pipeline, so `$ref`,
171
+ `${…}` templates, and `@:` dest-pointers all work. The `params` list handed to
172
+ the executor is simply the values j-perm itself computed from the document; the
173
+ parameter binding exists only for injection safety and the driver's paramstyle.
174
+ The whole flow (source → SQL → bound values) is self-contained in one j-perm
175
+ run; the executor is just the pipe to the driver. This applies equally to read
176
+ (`op: sql`) and write (`op: sql_write`).
99
177
 
100
178
  ## Dialect / `RenderOptions`
101
179
 
@@ -114,9 +192,9 @@ install_sql(engine, run_sql, dialect=RenderOptions(
114
192
 
115
193
  ## Sync & async
116
194
 
117
- `install_sql` inspects the executor: a coroutine function registers the async
118
- handler (use with `engine.apply_async`); a regular function registers the sync
119
- handler (use with `engine.apply`).
195
+ Both `install_sql` and `install_sql_write` inspect the executor: a coroutine
196
+ function registers the async handler (use with `engine.apply_async`); a regular
197
+ function registers the sync handler (use with `engine.apply`).
120
198
 
121
199
  ```python
122
200
  async def run_sql(sql, params): ...
@@ -134,6 +212,14 @@ await engine.apply_async(spec, source=…, dest=…)
134
212
  | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
135
213
  | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
136
214
 
215
+ **Write (DML)** — only via `install_sql_write` / `op: sql_write`
216
+
217
+ | Construct | Form |
218
+ |---|---|
219
+ | `$insert` | `{into, columns?, values \| query}` (exactly one of `values`/`query`) |
220
+ | `$update` | `{table, set: {col: operand}, where? \| "all": true}` |
221
+ | `$delete` | `{from, where? \| "all": true}` |
222
+
137
223
  **Projection / expressions**
138
224
 
139
225
  | Construct | Renders |
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  src/j_perm_sql/__init__.py
4
4
  src/j_perm_sql/constructs.py
5
+ src/j_perm_sql/constructs_write.py
5
6
  src/j_perm_sql/dialect.py
6
7
  src/j_perm_sql/handler.py
7
8
  src/j_perm_sql/install.py
File without changes