j-perm-sql 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """j_perm_sql — build and execute SQL queries from j-perm constructs.
2
+
3
+ SQL is described with a tree of ``$``-constructs (``$select``, ``$col``,
4
+ ``$val``, predicates, joins, …) and rendered by an **isolated** named pipeline.
5
+ A single top-level operation, ``op: sql``, renders that tree to a parameterized
6
+ ``(sql, params)`` pair and hands it to a configurable executor (any ORM's raw
7
+ execute). The SQL constructs are never visible to the engine's normal value
8
+ pipeline — they only mean anything inside ``op: sql``.
9
+
10
+ Quick start::
11
+
12
+ from j_perm import build_default_engine
13
+ from j_perm_sql import install_sql
14
+
15
+ engine = build_default_engine()
16
+ install_sql(engine, my_orm_raw_execute, paramstyle="qmark")
17
+
18
+ engine.apply(
19
+ {"op": "sql", "to": "/rows", "query": {"$select": {
20
+ "columns": [{"$col": {"name": "id"}}],
21
+ "from": {"table": "users"},
22
+ "where": {"$gte": [{"$col": {"name": "age"}}, {"$val": 18}]},
23
+ }}},
24
+ source={}, dest={},
25
+ )
26
+ """
27
+ from .constructs import build_sql_specials
28
+ from .dialect import PLACEHOLDER, RenderOptions
29
+ from .handler import AsyncSqlHandler, SqlHandler, SqlRenderer
30
+ from .install import install_sql
31
+ from .pipeline import SQL_PIPELINE_NAME, build_sql_pipeline
32
+ from .render import fragment, is_fragment, is_query, render
33
+
34
+ __all__ = [
35
+ "install_sql",
36
+ "RenderOptions",
37
+ "PLACEHOLDER",
38
+ "SqlHandler",
39
+ "AsyncSqlHandler",
40
+ "SqlRenderer",
41
+ "build_sql_pipeline",
42
+ "build_sql_specials",
43
+ "SQL_PIPELINE_NAME",
44
+ "fragment",
45
+ "is_fragment",
46
+ "is_query",
47
+ "render",
48
+ ]
@@ -0,0 +1,591 @@
1
+ """SQL construct handlers — the full standard ``SELECT`` surface.
2
+
3
+ Each construct is a callable ``(node, ctx) -> fragment`` (built via
4
+ :func:`build_sql_specials`, closing over a :class:`RenderOptions`). Constructs
5
+ recurse through the isolated SQL pipeline via the helpers in :mod:`.render`.
6
+
7
+ Values are always bound as parameters (``$val`` and the data sides of ``$in`` /
8
+ ``$between`` / ``$values``); identifiers are validated and quoted. Nothing
9
+ user-supplied is interpolated into the SQL string except validated identifiers,
10
+ function names, types, and a small set of whitelisted keywords.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from functools import partial
15
+ from typing import Any
16
+
17
+ from .dialect import PLACEHOLDER, RenderOptions
18
+ from .render import (
19
+ fragment,
20
+ is_query,
21
+ join_fragments,
22
+ render_construct,
23
+ render_operand,
24
+ render_operands,
25
+ render_subquery,
26
+ )
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Whitelists (anything user-supplied that reaches the SQL string verbatim)
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ _COMPARE_SYMBOLS = frozenset({"=", "<>", "!=", "<", "<=", ">", ">="})
33
+ _JOIN_KEYWORDS = {
34
+ "inner": "INNER JOIN",
35
+ "left": "LEFT JOIN",
36
+ "right": "RIGHT JOIN",
37
+ "full": "FULL JOIN",
38
+ "cross": "CROSS JOIN",
39
+ }
40
+ _QUANTIFIERS = {"$any": "ANY", "$all": "ALL", "$some": "SOME"}
41
+ _SETOPS = {
42
+ "$union": "UNION",
43
+ "$union_all": "UNION ALL",
44
+ "$intersect": "INTERSECT",
45
+ "$except": "EXCEPT",
46
+ }
47
+
48
+
49
+ def _int_literal(value: Any, what: str) -> int:
50
+ """Validate an integer literal (LIMIT/OFFSET/frame offsets)."""
51
+ if not isinstance(value, int) or isinstance(value, bool):
52
+ raise ValueError(f"{what} must be an integer, got {value!r}")
53
+ return value
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+ # Leaves: column reference and bound value
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ def col(node, ctx, *, opts: RenderOptions) -> dict:
61
+ spec = node["$col"]
62
+ if isinstance(spec, str):
63
+ spec = {"name": spec}
64
+ name = spec["name"]
65
+ table = spec.get("table")
66
+ if name == "*":
67
+ core = (opts.quote_identifier(table) + "." if table else "") + "*"
68
+ else:
69
+ parts = ([table] if table else []) + [name]
70
+ core = ".".join(opts.quote_identifier(p) for p in parts)
71
+ if spec.get("as"):
72
+ core += f" AS {opts.quote_identifier(spec['as'])}"
73
+ return fragment(core)
74
+
75
+
76
+ def val(node, ctx, *, opts: RenderOptions) -> dict:
77
+ value = ctx.engine.process_value(node["$val"], ctx)
78
+ return fragment(PLACEHOLDER, [value])
79
+
80
+
81
+ # ─────────────────────────────────────────────────────────────────────────────
82
+ # Expressions: functions, cast, case, concat, arithmetic
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ def func(node, ctx, *, opts: RenderOptions) -> dict:
86
+ spec = node.get("$func", node.get("$call"))
87
+ name = opts.validate_func_name(spec["name"])
88
+ arg_sqls: list[str] = []
89
+ params: list = []
90
+ for arg in spec.get("args", []):
91
+ if arg == "*":
92
+ arg_sqls.append("*")
93
+ else:
94
+ frag = render_operand(arg, ctx, opts)
95
+ arg_sqls.append(frag["sql"])
96
+ params += frag["params"]
97
+ distinct = "DISTINCT " if spec.get("distinct") else ""
98
+ sql = f"{name}({distinct}{', '.join(arg_sqls)})"
99
+ if "over" in spec:
100
+ win = _render_window(spec["over"], ctx, opts)
101
+ sql += f" OVER ({win['sql']})"
102
+ params += win["params"]
103
+ if spec.get("as"):
104
+ sql += f" AS {opts.quote_identifier(spec['as'])}"
105
+ return fragment(sql, params)
106
+
107
+
108
+ def _render_window(over, ctx, opts: RenderOptions) -> dict:
109
+ parts: list[str] = []
110
+ params: list = []
111
+ if over.get("partition_by"):
112
+ pf = render_operands(over["partition_by"], ctx, opts)
113
+ parts.append(f"PARTITION BY {pf['sql']}")
114
+ params += pf["params"]
115
+ if over.get("order_by"):
116
+ of = _render_order_by(over["order_by"], ctx, opts)
117
+ parts.append(f"ORDER BY {of['sql']}")
118
+ params += of["params"]
119
+ if "frame" in over:
120
+ parts.append(_render_frame(over["frame"]))
121
+ return fragment(" ".join(parts), params)
122
+
123
+
124
+ def _render_frame(frame) -> str:
125
+ ftype = frame["type"].upper()
126
+ if ftype not in ("ROWS", "RANGE"):
127
+ raise ValueError(f"frame type must be ROWS or RANGE, got {frame['type']!r}")
128
+ start = _frame_bound(frame["start"])
129
+ if "end" in frame:
130
+ return f"{ftype} BETWEEN {start} AND {_frame_bound(frame['end'])}"
131
+ return f"{ftype} {start}"
132
+
133
+
134
+ def _frame_bound(bound) -> str:
135
+ if isinstance(bound, str):
136
+ norm = bound.strip().lower()
137
+ if norm in ("unbounded preceding", "unbounded following", "current row"):
138
+ return norm.upper()
139
+ raise ValueError(f"invalid frame bound: {bound!r}")
140
+ if isinstance(bound, dict):
141
+ if "preceding" in bound:
142
+ return f"{_int_literal(bound['preceding'], 'frame offset')} PRECEDING"
143
+ if "following" in bound:
144
+ return f"{_int_literal(bound['following'], 'frame offset')} FOLLOWING"
145
+ raise ValueError(f"invalid frame bound: {bound!r}")
146
+
147
+
148
+ def cast(node, ctx, *, opts: RenderOptions) -> dict:
149
+ spec = node["$cast"]
150
+ expr = render_operand(spec["expr"], ctx, opts)
151
+ type_str = opts.validate_type(spec["type"])
152
+ sql = f"CAST({expr['sql']} AS {type_str})"
153
+ if spec.get("as"):
154
+ sql += f" AS {opts.quote_identifier(spec['as'])}"
155
+ return fragment(sql, expr["params"])
156
+
157
+
158
+ def case(node, ctx, *, opts: RenderOptions) -> dict:
159
+ spec = node["$case"]
160
+ parts = ["CASE"]
161
+ params: list = []
162
+ for branch in spec["whens"]:
163
+ cond = render_construct(branch["when"], ctx)
164
+ result = render_operand(branch["then"], ctx, opts)
165
+ parts.append(f"WHEN {cond['sql']} THEN {result['sql']}")
166
+ params += cond["params"] + result["params"]
167
+ if "else" in spec:
168
+ els = render_operand(spec["else"], ctx, opts)
169
+ parts.append(f"ELSE {els['sql']}")
170
+ params += els["params"]
171
+ parts.append("END")
172
+ sql = " ".join(parts)
173
+ if spec.get("as"):
174
+ sql += f" AS {opts.quote_identifier(spec['as'])}"
175
+ return fragment(sql, params)
176
+
177
+
178
+ def concat(node, ctx, *, opts: RenderOptions) -> dict:
179
+ frag = render_operands(node["$concat"], ctx, opts, sep=f" {opts.concat_operator} ")
180
+ return fragment(f"({frag['sql']})", frag["params"])
181
+
182
+
183
+ def _arith(node, ctx, *, opts: RenderOptions, key: str, symbol: str) -> dict:
184
+ frag = render_operands(node[key], ctx, opts, sep=f" {symbol} ")
185
+ return fragment(f"({frag['sql']})", frag["params"])
186
+
187
+
188
+ # ─────────────────────────────────────────────────────────────────────────────
189
+ # Predicates
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+
192
+ def _boolean(node, ctx, *, opts: RenderOptions, key: str, word: str) -> dict:
193
+ preds = node[key]
194
+ if not preds:
195
+ raise ValueError(f"{key} requires at least one predicate")
196
+ frags = [render_construct(p, ctx) for p in preds]
197
+ joined = f" {word} ".join(f["sql"] for f in frags)
198
+ params = [p for f in frags for p in f["params"]]
199
+ return fragment(f"({joined})", params)
200
+
201
+
202
+ def not_(node, ctx, *, opts: RenderOptions) -> dict:
203
+ inner = render_construct(node["$not"], ctx)
204
+ return fragment(f"NOT ({inner['sql']})", inner["params"])
205
+
206
+
207
+ def _cmp(node, ctx, *, opts: RenderOptions, key: str, symbol: str) -> dict:
208
+ left, right = node[key]
209
+ lf = render_operand(left, ctx, opts)
210
+ rf = render_operand(right, ctx, opts)
211
+ return fragment(f"{lf['sql']} {symbol} {rf['sql']}", lf["params"] + rf["params"])
212
+
213
+
214
+ def _in(node, ctx, *, opts: RenderOptions, negate: bool) -> dict:
215
+ key = "$not_in" if negate else "$in"
216
+ left, right = node[key]
217
+ lf = render_operand(left, ctx, opts)
218
+ kw = "NOT IN" if negate else "IN"
219
+ if is_query(right):
220
+ sub = render_subquery(right, ctx)
221
+ return fragment(f"{lf['sql']} {kw} {sub['sql']}", lf["params"] + sub["params"])
222
+ values = ctx.engine.process_value(right, ctx)
223
+ if not isinstance(values, (list, tuple)):
224
+ values = [values]
225
+ if not values:
226
+ raise ValueError(f"{key} requires a non-empty value list")
227
+ placeholders = ", ".join(PLACEHOLDER for _ in values)
228
+ return fragment(f"{lf['sql']} {kw} ({placeholders})", lf["params"] + list(values))
229
+
230
+
231
+ def _between(node, ctx, *, opts: RenderOptions, negate: bool) -> dict:
232
+ key = "$not_between" if negate else "$between"
233
+ expr, low, high = node[key]
234
+ ef = render_operand(expr, ctx, opts)
235
+ lf = render_operand(low, ctx, opts)
236
+ hf = render_operand(high, ctx, opts)
237
+ kw = "NOT BETWEEN" if negate else "BETWEEN"
238
+ return fragment(
239
+ f"{ef['sql']} {kw} {lf['sql']} AND {hf['sql']}",
240
+ ef["params"] + lf["params"] + hf["params"],
241
+ )
242
+
243
+
244
+ def _like(node, ctx, *, opts: RenderOptions, negate: bool) -> dict:
245
+ key = "$not_like" if negate else "$like"
246
+ expr, pattern = node[key]
247
+ ef = render_operand(expr, ctx, opts)
248
+ pf = render_operand(pattern, ctx, opts)
249
+ kw = "NOT LIKE" if negate else "LIKE"
250
+ sql = f"{ef['sql']} {kw} {pf['sql']}"
251
+ params = ef["params"] + pf["params"]
252
+ if "escape" in node:
253
+ esc = render_operand(node["escape"], ctx, opts)
254
+ sql += f" ESCAPE {esc['sql']}"
255
+ params += esc["params"]
256
+ return fragment(sql, params)
257
+
258
+
259
+ def _is_null(node, ctx, *, opts: RenderOptions, negate: bool) -> dict:
260
+ key = "$is_not_null" if negate else "$is_null"
261
+ ef = render_operand(node[key], ctx, opts)
262
+ kw = "IS NOT NULL" if negate else "IS NULL"
263
+ return fragment(f"{ef['sql']} {kw}", ef["params"])
264
+
265
+
266
+ def _exists(node, ctx, *, opts: RenderOptions, negate: bool) -> dict:
267
+ key = "$not_exists" if negate else "$exists"
268
+ sub = render_subquery(node[key], ctx)
269
+ kw = "NOT EXISTS" if negate else "EXISTS"
270
+ return fragment(f"{kw} {sub['sql']}", sub["params"])
271
+
272
+
273
+ def _quantified(node, ctx, *, opts: RenderOptions, key: str) -> dict:
274
+ left, op, query = node[key]
275
+ if op not in _COMPARE_SYMBOLS:
276
+ raise ValueError(f"invalid comparison operator: {op!r}")
277
+ lf = render_operand(left, ctx, opts)
278
+ sub = render_subquery(query, ctx)
279
+ return fragment(
280
+ f"{lf['sql']} {op} {_QUANTIFIERS[key]} {sub['sql']}",
281
+ lf["params"] + sub["params"],
282
+ )
283
+
284
+
285
+ # ─────────────────────────────────────────────────────────────────────────────
286
+ # Clause helpers (not registered constructs — they only appear inside $select)
287
+ # ─────────────────────────────────────────────────────────────────────────────
288
+
289
+ def _render_order_by(items, ctx, opts: RenderOptions) -> dict:
290
+ rendered: list[str] = []
291
+ params: list = []
292
+ for item in items:
293
+ if isinstance(item, dict) and "expr" in item:
294
+ ef = render_operand(item["expr"], ctx, opts)
295
+ sql = ef["sql"]
296
+ params += ef["params"]
297
+ direction = item.get("dir")
298
+ if direction is not None:
299
+ if direction.lower() not in ("asc", "desc"):
300
+ raise ValueError(f"order_by dir must be asc/desc, got {direction!r}")
301
+ sql += f" {direction.upper()}"
302
+ nulls = item.get("nulls")
303
+ if nulls is not None:
304
+ if nulls.lower() not in ("first", "last"):
305
+ raise ValueError(f"order_by nulls must be first/last, got {nulls!r}")
306
+ sql += f" NULLS {nulls.upper()}"
307
+ rendered.append(sql)
308
+ else:
309
+ frag = render_operand(item, ctx, opts)
310
+ rendered.append(frag["sql"])
311
+ params += frag["params"]
312
+ return fragment(", ".join(rendered), params)
313
+
314
+
315
+ def _render_group_by(spec, ctx, opts: RenderOptions) -> dict:
316
+ if isinstance(spec, dict):
317
+ if "$rollup" in spec:
318
+ frag = render_operands(spec["$rollup"], ctx, opts)
319
+ return fragment(f"ROLLUP ({frag['sql']})", frag["params"])
320
+ if "$cube" in spec:
321
+ frag = render_operands(spec["$cube"], ctx, opts)
322
+ return fragment(f"CUBE ({frag['sql']})", frag["params"])
323
+ if "$grouping_sets" in spec:
324
+ rendered: list[str] = []
325
+ params: list = []
326
+ for group in spec["$grouping_sets"]:
327
+ gf = render_operands(group, ctx, opts)
328
+ rendered.append(f"({gf['sql']})")
329
+ params += gf["params"]
330
+ return fragment(f"GROUPING SETS ({', '.join(rendered)})", params)
331
+ raise ValueError(f"invalid group_by spec: {spec!r}")
332
+ return render_operands(spec, ctx, opts)
333
+
334
+
335
+ def _source_dict(spec) -> dict:
336
+ """Extract the table-source portion from a $join spec."""
337
+ return {
338
+ "table": spec["table"],
339
+ "as": spec.get("as"),
340
+ "schema": spec.get("schema"),
341
+ "lateral": spec.get("lateral"),
342
+ }
343
+
344
+
345
+ def _render_table_source(node, ctx, opts: RenderOptions) -> dict:
346
+ if isinstance(node, str):
347
+ return fragment(opts.quote_ref(node))
348
+ table = node["table"]
349
+ if is_query(table):
350
+ sub = render_subquery(table, ctx)
351
+ alias = node.get("as")
352
+ if not alias:
353
+ raise ValueError("derived table requires an alias ('as')")
354
+ lateral = "LATERAL " if node.get("lateral") else ""
355
+ return fragment(
356
+ f"{lateral}{sub['sql']} AS {opts.quote_identifier(alias)}", sub["params"]
357
+ )
358
+ parts = ([node["schema"]] if node.get("schema") else []) + [table]
359
+ sql = ".".join(opts.quote_identifier(p) for p in parts)
360
+ if node.get("as"):
361
+ sql += f" AS {opts.quote_identifier(node['as'])}"
362
+ return fragment(sql)
363
+
364
+
365
+ def join(node, ctx, *, opts: RenderOptions) -> dict:
366
+ spec = node["$join"]
367
+ jtype = spec.get("type", "inner").lower()
368
+ if jtype not in _JOIN_KEYWORDS:
369
+ raise ValueError(f"invalid join type: {spec.get('type')!r}")
370
+ natural = "NATURAL " if spec.get("natural") else ""
371
+ source = _render_table_source(_source_dict(spec), ctx, opts)
372
+ sql = f"{natural}{_JOIN_KEYWORDS[jtype]} {source['sql']}"
373
+ params = list(source["params"])
374
+ if "on" in spec:
375
+ cond = render_construct(spec["on"], ctx)
376
+ sql += f" ON {cond['sql']}"
377
+ params += cond["params"]
378
+ elif "using" in spec:
379
+ cols = ", ".join(opts.quote_identifier(c) for c in spec["using"])
380
+ sql += f" USING ({cols})"
381
+ return fragment(sql, params)
382
+
383
+
384
+ def _render_with(entries, ctx, opts: RenderOptions) -> dict:
385
+ recursive = any(e.get("recursive") for e in entries)
386
+ rendered: list[str] = []
387
+ params: list = []
388
+ for entry in entries:
389
+ name = opts.quote_identifier(entry["name"])
390
+ columns = ""
391
+ if entry.get("columns"):
392
+ cols = ", ".join(opts.quote_identifier(c) for c in entry["columns"])
393
+ columns = f" ({cols})"
394
+ sub = render_construct(entry["query"], ctx)
395
+ rendered.append(f"{name}{columns} AS ({sub['sql']})")
396
+ params += sub["params"]
397
+ keyword = "WITH RECURSIVE " if recursive else "WITH "
398
+ return fragment(keyword + ", ".join(rendered), params)
399
+
400
+
401
+ def _render_pagination(spec, opts: RenderOptions) -> str:
402
+ limit = spec.get("limit")
403
+ offset = spec.get("offset")
404
+ fetch = spec.get("fetch")
405
+ if limit is not None:
406
+ limit = _int_literal(limit, "limit")
407
+ if offset is not None:
408
+ offset = _int_literal(offset, "offset")
409
+ if fetch is not None:
410
+ fetch = _int_literal(fetch, "fetch")
411
+ out: list[str] = []
412
+ if opts.pagination == "limit":
413
+ count = limit if limit is not None else fetch
414
+ if count is not None:
415
+ out.append(f"LIMIT {count}")
416
+ if offset is not None:
417
+ out.append(f"OFFSET {offset}")
418
+ else: # fetch
419
+ if offset is not None:
420
+ out.append(f"OFFSET {offset} ROWS")
421
+ count = fetch if fetch is not None else limit
422
+ if count is not None:
423
+ out.append(f"FETCH FIRST {count} ROWS ONLY")
424
+ return " ".join(out)
425
+
426
+
427
+ def _render_select_list(columns, ctx, opts: RenderOptions) -> dict:
428
+ if not columns:
429
+ return fragment("*")
430
+ return join_fragments(
431
+ [_render_projection_item(c, ctx, opts) for c in columns], ", "
432
+ )
433
+
434
+
435
+ def _render_projection_item(item, ctx, opts: RenderOptions) -> dict:
436
+ if isinstance(item, dict) and "expr" in item:
437
+ ef = render_operand(item["expr"], ctx, opts)
438
+ sql = ef["sql"]
439
+ if item.get("as"):
440
+ sql += f" AS {opts.quote_identifier(item['as'])}"
441
+ return fragment(sql, ef["params"])
442
+ return render_operand(item, ctx, opts)
443
+
444
+
445
+ # ─────────────────────────────────────────────────────────────────────────────
446
+ # Top-level query constructs: $select, set operations, $values
447
+ # ─────────────────────────────────────────────────────────────────────────────
448
+
449
+ def select(node, ctx, *, opts: RenderOptions) -> dict:
450
+ spec = node["$select"]
451
+ clauses: list[str] = []
452
+ params: list = []
453
+
454
+ if "with" in spec:
455
+ w = _render_with(spec["with"], ctx, opts)
456
+ clauses.append(w["sql"])
457
+ params += w["params"]
458
+
459
+ head = "SELECT DISTINCT" if spec.get("distinct") else "SELECT"
460
+ cols = _render_select_list(spec.get("columns"), ctx, opts)
461
+ clauses.append(f"{head} {cols['sql']}")
462
+ params += cols["params"]
463
+
464
+ if "from" in spec:
465
+ frm = _render_table_source(spec["from"], ctx, opts)
466
+ clauses.append(f"FROM {frm['sql']}")
467
+ params += frm["params"]
468
+
469
+ for joinspec in spec.get("joins", []):
470
+ jf = render_construct(joinspec, ctx)
471
+ clauses.append(jf["sql"])
472
+ params += jf["params"]
473
+
474
+ if "where" in spec:
475
+ wf = render_construct(spec["where"], ctx)
476
+ clauses.append(f"WHERE {wf['sql']}")
477
+ params += wf["params"]
478
+
479
+ if "group_by" in spec:
480
+ gf = _render_group_by(spec["group_by"], ctx, opts)
481
+ clauses.append(f"GROUP BY {gf['sql']}")
482
+ params += gf["params"]
483
+
484
+ if "having" in spec:
485
+ hf = render_construct(spec["having"], ctx)
486
+ clauses.append(f"HAVING {hf['sql']}")
487
+ params += hf["params"]
488
+
489
+ if "order_by" in spec:
490
+ of = _render_order_by(spec["order_by"], ctx, opts)
491
+ clauses.append(f"ORDER BY {of['sql']}")
492
+ params += of["params"]
493
+
494
+ pagination = _render_pagination(spec, opts)
495
+ if pagination:
496
+ clauses.append(pagination)
497
+
498
+ return fragment(" ".join(clauses), params)
499
+
500
+
501
+ def _setop(node, ctx, *, opts: RenderOptions, key: str) -> dict:
502
+ queries = node[key]
503
+ if not queries:
504
+ raise ValueError(f"{key} requires at least one query")
505
+ frags = [render_construct(q, ctx) for q in queries]
506
+ sql = f" {_SETOPS[key]} ".join(f["sql"] for f in frags)
507
+ params = [p for f in frags for p in f["params"]]
508
+ if "order_by" in node:
509
+ of = _render_order_by(node["order_by"], ctx, opts)
510
+ sql += f" ORDER BY {of['sql']}"
511
+ params += of["params"]
512
+ pagination = _render_pagination(node, opts)
513
+ if pagination:
514
+ sql += f" {pagination}"
515
+ return fragment(sql, params)
516
+
517
+
518
+ def values(node, ctx, *, opts: RenderOptions) -> dict:
519
+ rows = node["$values"]
520
+ if not rows:
521
+ raise ValueError("$values requires at least one row")
522
+ rendered_rows: list[str] = []
523
+ params: list = []
524
+ width: int | None = None
525
+ for row in rows:
526
+ if width is None:
527
+ width = len(row)
528
+ elif len(row) != width:
529
+ raise ValueError("all $values rows must have the same length")
530
+ rendered_rows.append("(" + ", ".join(PLACEHOLDER for _ in row) + ")")
531
+ for cell in row:
532
+ params.append(ctx.engine.process_value(cell, ctx))
533
+ return fragment("VALUES " + ", ".join(rendered_rows), params)
534
+
535
+
536
+ # ─────────────────────────────────────────────────────────────────────────────
537
+ # Registry assembly
538
+ # ─────────────────────────────────────────────────────────────────────────────
539
+
540
+ def build_sql_specials(opts: RenderOptions) -> dict:
541
+ """Build the ``{key: handler}`` mapping for the SQL pipeline.
542
+
543
+ Every handler is bound to *opts* so the SQL pipeline renders for one
544
+ target dialect.
545
+ """
546
+ specials: dict = {
547
+ "$select": select,
548
+ "$col": col,
549
+ "$val": val,
550
+ "$join": join,
551
+ "$func": func,
552
+ "$call": func,
553
+ "$cast": cast,
554
+ "$case": case,
555
+ "$concat": concat,
556
+ "$not": not_,
557
+ "$values": values,
558
+ }
559
+ # comparisons
560
+ for key, symbol in {
561
+ "$eq": "=", "$ne": "<>", "$gt": ">", "$gte": ">=", "$lt": "<", "$lte": "<=",
562
+ }.items():
563
+ specials[key] = partial(_cmp, key=key, symbol=symbol)
564
+ # arithmetic
565
+ for key, symbol in {
566
+ "$add": "+", "$sub": "-", "$mul": "*", "$div": "/", "$mod": "%",
567
+ }.items():
568
+ specials[key] = partial(_arith, key=key, symbol=symbol)
569
+ # boolean connectives
570
+ specials["$and"] = partial(_boolean, key="$and", word="AND")
571
+ specials["$or"] = partial(_boolean, key="$or", word="OR")
572
+ # in / between / like / null / exists (+ negations)
573
+ specials["$in"] = partial(_in, negate=False)
574
+ specials["$not_in"] = partial(_in, negate=True)
575
+ specials["$between"] = partial(_between, negate=False)
576
+ specials["$not_between"] = partial(_between, negate=True)
577
+ specials["$like"] = partial(_like, negate=False)
578
+ specials["$not_like"] = partial(_like, negate=True)
579
+ specials["$is_null"] = partial(_is_null, negate=False)
580
+ specials["$is_not_null"] = partial(_is_null, negate=True)
581
+ specials["$exists"] = partial(_exists, negate=False)
582
+ specials["$not_exists"] = partial(_exists, negate=True)
583
+ # quantified comparisons
584
+ for key in _QUANTIFIERS:
585
+ specials[key] = partial(_quantified, key=key)
586
+ # set operations
587
+ for key in _SETOPS:
588
+ specials[key] = partial(_setop, key=key)
589
+
590
+ # bind opts into every handler
591
+ return {key: partial(fn, opts=opts) for key, fn in specials.items()}
j_perm_sql/dialect.py ADDED
@@ -0,0 +1,120 @@
1
+ """Dialect / rendering options for the SQL plugin.
2
+
3
+ Everything that genuinely differs between SQL databases lives here so the
4
+ construct handlers can stay dialect-agnostic:
5
+
6
+ * ``paramstyle`` – how bound parameters appear (PEP 249 styles).
7
+ * ``identifier_quote`` – the character used to quote identifiers.
8
+ * ``pagination`` – ``"limit"`` (``LIMIT n OFFSET m``) or ``"fetch"``
9
+ (``OFFSET m ROWS FETCH FIRST n ROWS ONLY``).
10
+ * ``concat_operator`` – string concatenation operator (``||`` or ``+``).
11
+
12
+ The construct handlers render with a single neutral placeholder token
13
+ (:data:`PLACEHOLDER`, ``"?"``) and :meth:`RenderOptions.finalize` rewrites
14
+ those into the configured ``paramstyle`` at the very end.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+
21
+ #: Neutral placeholder emitted during rendering; rewritten by ``finalize``.
22
+ PLACEHOLDER = "?"
23
+
24
+ _PARAMSTYLES = frozenset({"qmark", "format", "numeric", "named"})
25
+ _PAGINATIONS = frozenset({"limit", "fetch"})
26
+
27
+ # A single, unqualified identifier part. Conservative on purpose: the strict
28
+ # charset both prevents SQL injection through identifiers and guarantees the
29
+ # rendered SQL never contains a stray ``?`` (so ``finalize`` can rewrite
30
+ # placeholders by a simple left-to-right scan).
31
+ _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
+
33
+ # A SQL type expression for ``CAST`` (e.g. ``VARCHAR(255)``, ``DECIMAL(10, 2)``).
34
+ _TYPE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_ ,()]*$")
35
+
36
+ # A bare function name (rendered as-is, never quoted).
37
+ _FUNC_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RenderOptions:
42
+ """Immutable knobs controlling how SQL is rendered for a target dialect."""
43
+
44
+ paramstyle: str = "qmark"
45
+ identifier_quote: str = '"'
46
+ pagination: str = "limit"
47
+ concat_operator: str = "||"
48
+
49
+ def __post_init__(self) -> None:
50
+ if self.paramstyle not in _PARAMSTYLES:
51
+ raise ValueError(
52
+ f"paramstyle must be one of {sorted(_PARAMSTYLES)}, got {self.paramstyle!r}"
53
+ )
54
+ if self.pagination not in _PAGINATIONS:
55
+ raise ValueError(
56
+ f"pagination must be one of {sorted(_PAGINATIONS)}, got {self.pagination!r}"
57
+ )
58
+ if len(self.identifier_quote) != 1:
59
+ raise ValueError("identifier_quote must be a single character")
60
+
61
+ # -- identifiers --------------------------------------------------------
62
+
63
+ def quote_identifier(self, name: str) -> str:
64
+ """Validate and quote a single identifier part (e.g. a column name)."""
65
+ if not isinstance(name, str) or not _IDENT_RE.match(name):
66
+ raise ValueError(f"invalid SQL identifier: {name!r}")
67
+ q = self.identifier_quote
68
+ return f"{q}{name}{q}"
69
+
70
+ def quote_ref(self, ref: str) -> str:
71
+ """Quote a possibly dotted/star reference (e.g. ``u.id``, ``u.*``, ``*``)."""
72
+ if ref == "*":
73
+ return "*"
74
+ parts = ref.split(".")
75
+ out = [p if p == "*" else self.quote_identifier(p) for p in parts]
76
+ return ".".join(out)
77
+
78
+ def validate_type(self, type_str: str) -> str:
79
+ """Validate a CAST target type string and return it unchanged."""
80
+ if not isinstance(type_str, str) or not _TYPE_RE.match(type_str):
81
+ raise ValueError(f"invalid SQL type: {type_str!r}")
82
+ return type_str
83
+
84
+ def validate_func_name(self, name: str) -> str:
85
+ """Validate a function name and return it unchanged (never quoted)."""
86
+ if not isinstance(name, str) or not _FUNC_RE.match(name):
87
+ raise ValueError(f"invalid SQL function name: {name!r}")
88
+ return name
89
+
90
+ # -- placeholders -------------------------------------------------------
91
+
92
+ def finalize(self, sql: str, params: list) -> tuple[str, list | dict]:
93
+ """Rewrite neutral ``?`` placeholders into the configured paramstyle.
94
+
95
+ Returns ``(sql, params)`` where *params* is a list for positional
96
+ styles and a dict for ``named``.
97
+ """
98
+ count = sql.count(PLACEHOLDER)
99
+ if count != len(params):
100
+ raise ValueError(
101
+ f"placeholder/param mismatch: {count} placeholders, {len(params)} params"
102
+ )
103
+ if self.paramstyle == "qmark":
104
+ return sql, list(params)
105
+ if self.paramstyle == "format":
106
+ return sql.replace(PLACEHOLDER, "%s"), list(params)
107
+
108
+ # numeric / named — rewrite each placeholder positionally
109
+ out: list[str] = []
110
+ idx = 0
111
+ for ch in sql:
112
+ if ch == PLACEHOLDER:
113
+ idx += 1
114
+ out.append(f"${idx}" if self.paramstyle == "numeric" else f":p{idx}")
115
+ else:
116
+ out.append(ch)
117
+ rewritten = "".join(out)
118
+ if self.paramstyle == "numeric":
119
+ return rewritten, list(params)
120
+ return rewritten, {f"p{i + 1}": v for i, v in enumerate(params)}
j_perm_sql/handler.py ADDED
@@ -0,0 +1,75 @@
1
+ """The single top-level ``op: sql`` operation (sync + async).
2
+
3
+ Schema::
4
+
5
+ {"op": "sql", "query": <SQL construct tree>, "to": "/dest/path"}
6
+
7
+ * ``query`` — the SQL construct tree (rendered via the isolated SQL pipeline).
8
+ * ``to`` — optional destination pointer; the executor's result is written
9
+ there (template-expanded). If omitted, the result is discarded.
10
+
11
+ Rendering is identical for sync and async; only the executor call differs
12
+ (call vs ``await``). A single class can't expose both a sync and an async
13
+ ``execute`` (same method name), and ``Pipeline.run_async`` dispatches to the
14
+ async path only for :class:`AsyncActionHandler` — hence two classes over one
15
+ shared :class:`SqlRenderer`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from j_perm import ActionHandler, AsyncActionHandler
20
+
21
+ from .dialect import RenderOptions
22
+ from .render import SQL_PIPELINE_NAME, is_fragment
23
+
24
+
25
+ class SqlRenderer:
26
+ """Render a SQL construct tree to ``(sql, params)`` for a target dialect."""
27
+
28
+ def __init__(self, opts: RenderOptions) -> None:
29
+ self.opts = opts
30
+
31
+ def render(self, query, ctx) -> tuple:
32
+ # Render against a scratch dest so the real document is never clobbered,
33
+ # but expose the real dest under _real_dest so @: pointers inside $val
34
+ # can still read the document being built.
35
+ render_ctx = ctx.copy(
36
+ new_dest={},
37
+ new_metadata={**ctx.metadata, "_real_dest": ctx.dest},
38
+ )
39
+ frag = ctx.engine.run_pipeline(SQL_PIPELINE_NAME, query, render_ctx).dest
40
+ if not is_fragment(frag):
41
+ raise ValueError("top-level SQL query must be a SQL construct")
42
+ return self.opts.finalize(frag["sql"], frag["params"])
43
+
44
+
45
+ def _write_result(step, ctx, result):
46
+ if "to" in step:
47
+ path = ctx.engine.process_value(step["to"], ctx)
48
+ ctx.engine.processor.set(path, ctx, result)
49
+ return ctx.dest
50
+
51
+
52
+ class SqlHandler(ActionHandler):
53
+ """``op: sql`` with a synchronous executor ``executor(sql, params) -> result``."""
54
+
55
+ def __init__(self, executor, renderer: SqlRenderer) -> None:
56
+ self._executor = executor
57
+ self._renderer = renderer
58
+
59
+ def execute(self, step, ctx):
60
+ sql, params = self._renderer.render(step["query"], ctx)
61
+ result = self._executor(sql, params)
62
+ return _write_result(step, ctx, result)
63
+
64
+
65
+ class AsyncSqlHandler(AsyncActionHandler):
66
+ """``op: sql`` with an async executor ``await executor(sql, params) -> result``."""
67
+
68
+ def __init__(self, executor, renderer: SqlRenderer) -> None:
69
+ self._executor = executor
70
+ self._renderer = renderer
71
+
72
+ async def execute(self, step, ctx):
73
+ sql, params = self._renderer.render(step["query"], ctx)
74
+ result = await self._executor(sql, params)
75
+ return _write_result(step, ctx, result)
j_perm_sql/install.py ADDED
@@ -0,0 +1,47 @@
1
+ """``install_sql`` — patch an existing engine with SQL support.
2
+
3
+ Rather than building a new engine, this registers the isolated SQL pipeline
4
+ and adds the single ``op: sql`` top-level operation to an already-built
5
+ ``Engine``. It composes with any engine (and any other plugins).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ from j_perm import ActionNode, OpMatcher
12
+
13
+ from .dialect import RenderOptions
14
+ from .handler import AsyncSqlHandler, SqlHandler, SqlRenderer
15
+ from .pipeline import build_sql_pipeline
16
+ from .render import SQL_PIPELINE_NAME
17
+
18
+ __all__ = ["install_sql"]
19
+
20
+
21
+ def install_sql(engine, executor, *, paramstyle: str = "qmark", dialect=None, op: str = "sql"):
22
+ """Install SQL support into *engine*.
23
+
24
+ Args:
25
+ engine: a built ``j_perm`` engine (e.g. from ``build_default_engine``).
26
+ executor: ``executor(sql, params) -> result``. If it is a coroutine
27
+ function, the async ``op: sql`` handler is registered (use
28
+ with ``engine.apply_async``); otherwise the sync handler.
29
+ paramstyle: placeholder style when *dialect* is not given
30
+ (``qmark`` | ``format`` | ``numeric`` | ``named``).
31
+ dialect: an explicit :class:`RenderOptions`; overrides *paramstyle*.
32
+ op: the operation name to register (default ``"sql"``).
33
+
34
+ Returns:
35
+ The same *engine*, for chaining.
36
+ """
37
+ opts = dialect if dialect is not None else RenderOptions(paramstyle=paramstyle)
38
+ 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
+ )
47
+ return engine
j_perm_sql/pipeline.py ADDED
@@ -0,0 +1,47 @@
1
+ """Assembly of the isolated, named SQL value-pipeline.
2
+
3
+ The SQL constructs live **only** in this pipeline's ``SpecialMatcher`` — they are
4
+ never registered in the engine's default ``value_pipeline``. So ``{"$select": …}``
5
+ outside of the ``sql`` operation is just a plain dict, not a construct.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from j_perm import (
10
+ ActionNode,
11
+ ActionTypeRegistry,
12
+ AlwaysMatcher,
13
+ IdentityHandler,
14
+ Pipeline,
15
+ SpecialMatcher,
16
+ SpecialResolveHandler,
17
+ )
18
+
19
+ from .constructs import build_sql_specials
20
+ from .dialect import RenderOptions
21
+ from .render import SQL_PIPELINE_NAME
22
+
23
+ __all__ = ["build_sql_pipeline", "SQL_PIPELINE_NAME"]
24
+
25
+
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)
30
+ registry = ActionTypeRegistry()
31
+ registry.register(
32
+ ActionNode(
33
+ name="sql_special",
34
+ priority=10,
35
+ matcher=SpecialMatcher(set(specials)),
36
+ handler=SpecialResolveHandler(specials),
37
+ )
38
+ )
39
+ registry.register(
40
+ ActionNode(
41
+ name="sql_identity",
42
+ priority=-999,
43
+ matcher=AlwaysMatcher(),
44
+ handler=IdentityHandler(),
45
+ )
46
+ )
47
+ return Pipeline(registry=registry, track_execution=True)
j_perm_sql/render.py ADDED
@@ -0,0 +1,97 @@
1
+ """Rendering primitives shared by the SQL construct handlers.
2
+
3
+ A *fragment* is the unit every construct produces::
4
+
5
+ {"sql": "<sql text>", "params": [<bound values>]}
6
+
7
+ Values are always bound as parameters (never interpolated), so the rendered
8
+ ``sql`` only ever contains the neutral ``?`` placeholder for data.
9
+
10
+ Recursion happens through the isolated, named SQL pipeline: :func:`render`
11
+ dispatches a node back through ``engine.run_pipeline`` so nested constructs
12
+ resolve via the same handlers. The pipeline is invoked over the caller's
13
+ context as-is (j-perm's ``run_pipeline`` is passthrough), which keeps recursion
14
+ cheap — no per-node deep copy.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .dialect import PLACEHOLDER, RenderOptions
21
+
22
+ #: Name the SQL value-pipeline is registered under on the engine.
23
+ SQL_PIPELINE_NAME = "sql"
24
+
25
+ #: Keys whose presence marks a node as a *query* (must be parenthesised when
26
+ #: used as an operand, subquery, or derived table).
27
+ _QUERY_KEYS = frozenset(
28
+ {"$select", "$union", "$union_all", "$intersect", "$except", "$values"}
29
+ )
30
+
31
+
32
+ def fragment(sql: str, params: list | None = None) -> dict:
33
+ """Build a SQL fragment dict."""
34
+ return {"sql": sql, "params": list(params) if params else []}
35
+
36
+
37
+ def is_fragment(value: Any) -> bool:
38
+ """True if *value* is a rendered SQL fragment."""
39
+ return isinstance(value, dict) and "sql" in value and "params" in value
40
+
41
+
42
+ def is_query(node: Any) -> bool:
43
+ """True if *node* is a query construct (``$select``/set-op/``$values``)."""
44
+ return isinstance(node, dict) and any(k in node for k in _QUERY_KEYS)
45
+
46
+
47
+ 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
50
+
51
+
52
+ def render_construct(node: Any, ctx) -> dict:
53
+ """Render *node*, asserting it produced a fragment."""
54
+ result = render(node, ctx)
55
+ if not is_fragment(result):
56
+ raise ValueError(f"expected a SQL construct, got {node!r}")
57
+ return result
58
+
59
+
60
+ def render_subquery(node: Any, ctx) -> dict:
61
+ """Render a query construct and wrap it in parentheses."""
62
+ frag = render_construct(node, ctx)
63
+ return fragment(f"({frag['sql']})", frag["params"])
64
+
65
+
66
+ def render_operand(node: Any, ctx, opts: RenderOptions) -> dict:
67
+ """Render a value/expression operand.
68
+
69
+ * ``str`` → a (possibly dotted, possibly ``*``) column reference.
70
+ * query construct → a parenthesised scalar subquery.
71
+ * other construct dict → rendered fragment.
72
+ * anything else → error (use ``$val`` to bind a data value).
73
+ """
74
+ if isinstance(node, str):
75
+ return fragment(opts.quote_ref(node))
76
+ if isinstance(node, dict):
77
+ if is_query(node):
78
+ return render_subquery(node, ctx)
79
+ return render_construct(node, ctx)
80
+ raise TypeError(f"invalid SQL operand: {node!r}; use {{'$val': ...}} for data values")
81
+
82
+
83
+ def join_fragments(frags: list[dict], sep: str) -> dict:
84
+ """Join rendered fragments' SQL with *sep*, concatenating their params."""
85
+ sql = sep.join(f["sql"] for f in frags)
86
+ params = [p for f in frags for p in f["params"]]
87
+ return fragment(sql, params)
88
+
89
+
90
+ def render_operands(items: list, ctx, opts: RenderOptions, sep: str = ", ") -> dict:
91
+ """Render a list of operands and join them with *sep*."""
92
+ return join_fragments([render_operand(it, ctx, opts) for it in items], sep)
93
+
94
+
95
+ def bind_value(value: Any) -> dict:
96
+ """Bind a concrete Python value as a single placeholder."""
97
+ return fragment(PLACEHOLDER, [value])
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: j-perm-sql
3
+ Version: 0.1.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.9.0
14
+
15
+ # j-perm-sql
16
+
17
+ A [j-perm](https://github.com/kuschanow/j-perm) plugin that builds and executes
18
+ **SQL `SELECT` queries** from j-perm constructs.
19
+
20
+ * SQL is described with a tree of `$`-constructs (`$select`, `$col`, `$val`,
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.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install j-perm-sql
35
+ ```
36
+
37
+ Requires `j-perm >= 1.9.0` (the version that made `run_pipeline` a passthrough
38
+ invoker, which this plugin relies on).
39
+
40
+ ## Quick start
41
+
42
+ ```python
43
+ from j_perm import build_default_engine
44
+ from j_perm_sql import install_sql
45
+
46
+ def run_sql(sql, params):
47
+ # any ORM's raw execute: cursor.execute(sql, params); return rows
48
+ ...
49
+
50
+ engine = build_default_engine()
51
+ install_sql(engine, run_sql, paramstyle="qmark")
52
+
53
+ engine.apply(
54
+ {"op": "sql", "to": "/rows", "query": {"$select": {
55
+ "columns": [{"$col": {"name": "id"}}, {"$col": {"name": "name"}}],
56
+ "from": {"table": "users"},
57
+ "where": {"$gte": [{"$col": {"name": "age"}}, {"$val": 18}]},
58
+ "order_by": [{"expr": {"$col": {"name": "name"}}}],
59
+ "limit": 50,
60
+ }}},
61
+ source={}, dest={},
62
+ )
63
+ # run_sql receives: ('SELECT "id", "name" FROM "users" WHERE "age" >= ? ORDER BY "name" LIMIT 50', [18])
64
+ # result is written to dest at /rows
65
+ ```
66
+
67
+ `install_sql` **patches an existing engine** — it registers the isolated SQL
68
+ pipeline and the `op: sql` operation. It composes with any engine and any other
69
+ plugins.
70
+
71
+ ## The `op: sql` operation
72
+
73
+ ```js
74
+ {"op": "sql", "query": <SQL construct tree>, "to": "/dest/path"}
75
+ ```
76
+
77
+ * `query` — the SQL construct tree.
78
+ * `to` — optional destination pointer (template-expanded); the executor's
79
+ result is written there. If omitted, the result is discarded.
80
+
81
+ ## Parameterization & injection safety
82
+
83
+ Data values are **always bound as parameters**, never interpolated:
84
+
85
+ * `$val` (and the data sides of `$in`, `$between`, `$values`) emit a placeholder
86
+ and add the value to `params`.
87
+ * Identifiers (table/column/alias names) are validated against a conservative
88
+ charset and quoted.
89
+ * Function names, CAST types, join types, sort directions, etc. are validated
90
+ against whitelists.
91
+
92
+ ```python
93
+ {"$eq": [{"$col": {"name": "name"}}, {"$val": {"$ref": "/user_input"}}]}
94
+ # → '"name" = ?' with the (possibly malicious) value safely in params
95
+ ```
96
+
97
+ Inside `$val`, the value expression is resolved with j-perm's normal value
98
+ pipeline, so `$ref`, `${…}` templates, and `@:` dest-pointers all work.
99
+
100
+ ## Dialect / `RenderOptions`
101
+
102
+ Everything that genuinely differs between databases is configurable:
103
+
104
+ ```python
105
+ from j_perm_sql import RenderOptions
106
+
107
+ install_sql(engine, run_sql, dialect=RenderOptions(
108
+ paramstyle="numeric", # qmark (?) | format (%s) | numeric ($1) | named (:p1)
109
+ identifier_quote='"', # e.g. "`" for MySQL
110
+ pagination="fetch", # "limit" (LIMIT n OFFSET m) | "fetch" (OFFSET m ROWS FETCH FIRST n ROWS ONLY)
111
+ concat_operator="||", # "||" or "+"
112
+ ))
113
+ ```
114
+
115
+ ## Sync & async
116
+
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`).
120
+
121
+ ```python
122
+ async def run_sql(sql, params): ...
123
+ install_sql(engine, run_sql) # async
124
+ await engine.apply_async(spec, source=…, dest=…)
125
+ ```
126
+
127
+ ## Construct reference
128
+
129
+ **Query**
130
+
131
+ | Construct | Form |
132
+ |---|---|
133
+ | `$select` | `{with?, distinct?, columns?, from?, joins?, where?, group_by?, having?, order_by?, limit?/offset? \| fetch?}` |
134
+ | `$union` / `$union_all` / `$intersect` / `$except` | `{"$union": [q1, q2, …], order_by?, limit?…}` |
135
+ | `$values` | `{"$values": [[…row…], …]}` (table source or `IN`) |
136
+
137
+ **Projection / expressions**
138
+
139
+ | Construct | Renders |
140
+ |---|---|
141
+ | `$col` | `"t"."name" [AS "alias"]`; `"id"`; `*`; `"t".*` |
142
+ | `$val` | a bound parameter |
143
+ | `$func` / `$call` | `NAME([DISTINCT ]args)[ OVER (…)][ AS "alias"]` (use `"*"` for `COUNT(*)`) |
144
+ | `$cast` | `CAST(expr AS TYPE)` |
145
+ | `$case` | searched `CASE WHEN … THEN … [ELSE …] END` |
146
+ | `$concat` | `(a || b …)` |
147
+ | `$add` `$sub` `$mul` `$div` `$mod` | `(a op b …)` |
148
+
149
+ Projection items may also be `{"expr": <operand>, "as": "alias"}`.
150
+
151
+ **Predicates** (WHERE / HAVING / ON)
152
+
153
+ `$and` `$or` `$not` · `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` ·
154
+ `$in`/`$not_in` (list or subquery) · `$between`/`$not_between` ·
155
+ `$like`/`$not_like` (+ `escape`) · `$is_null`/`$is_not_null` ·
156
+ `$exists`/`$not_exists` · `$any`/`$all`/`$some` (quantified).
157
+
158
+ **FROM / JOIN** — *table source* = a table name (string), a
159
+ `{table, as?, schema?}` dict, a nested `$select`/`$values` (derived table, needs
160
+ `as`), or `lateral: true`. `$join`: `{type, table, as?, on? | using?, natural?}`
161
+ with type `inner`/`left`/`right`/`full`/`cross`.
162
+
163
+ **Windows** — `over` on `$func`: `{partition_by?, order_by?, frame?}` where
164
+ `frame` is `{type: "rows"|"range", start, end?}` and a bound is
165
+ `"unbounded preceding" | "unbounded following" | "current row" |
166
+ {preceding: n} | {following: n}`.
167
+
168
+ **GROUP BY** — a list of expressions, or `{"$rollup": […]}` /
169
+ `{"$cube": […]}` / `{"$grouping_sets": [[…], …]}`.
170
+
171
+ **CTE** — `with`: `[{name, columns?, recursive?, query: $select}]`.
172
+
173
+ See `tests/` for end-to-end examples.
174
+
175
+ ## Portability caveats
176
+
177
+ The DSL renders standard SQL and does **not** validate that a target database
178
+ supports every feature — portability is the query author's responsibility:
179
+
180
+ * `LIMIT/OFFSET` vs `OFFSET/FETCH`, `RIGHT/FULL JOIN`, `INTERSECT/EXCEPT`,
181
+ `NATURAL JOIN`, `NULLS FIRST/LAST`, `LATERAL`, `GROUPING SETS/ROLLUP/CUBE`,
182
+ and the concatenation operator (`||` vs `+`) are not universal.
183
+ * CTEs and window functions are standard but require recent versions
184
+ (e.g. MySQL ≥ 8, SQLite ≥ 3.25 for windows / ≥ 3.8.3 for CTEs).
185
+
186
+ Use `RenderOptions` to match the target dialect's placeholder style, identifier
187
+ quoting, pagination form, and concatenation operator.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,11 @@
1
+ j_perm_sql/__init__.py,sha256=I1crEuewdx0qeKD3PSBroaF1mgmCvqDU3m1_mO-duq8,1597
2
+ j_perm_sql/constructs.py,sha256=qcJ77Dn3wYPhTO5na1SyCoRVZZlE9J0ymxyBff9CQkE,23996
3
+ j_perm_sql/dialect.py,sha256=a-YtIQbUVeZ2Q5uSywVYOP1IVh7dEPTEEZVQCmTyb4I,4922
4
+ j_perm_sql/handler.py,sha256=NvRg4RST2LH9aLYHZgZOkZ0uTi-gtPiNwmYxZ-edMxM,2830
5
+ j_perm_sql/install.py,sha256=pXpXRnv2WobBm0YWhX7UAb5KijniY5rSENK23BvVSB4,1899
6
+ j_perm_sql/pipeline.py,sha256=G5ymliR8CdQpYBbuJDhf3nDWIRHIncEX44JpGLW382c,1429
7
+ j_perm_sql/render.py,sha256=GfwgQXRuhZmrW3UwzAdS-xsit-rvIPb83ZYYSP0grJA,3586
8
+ j_perm_sql-0.1.0.dist-info/METADATA,sha256=H3V3VY23T5_HH6RcKEiLG4PcEvb9Co15SxGr4doF_vA,6887
9
+ j_perm_sql-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ j_perm_sql-0.1.0.dist-info/top_level.txt,sha256=kO4wcHOQQ9US4Kqf_eZlZP4yrTPfc7bQO0zistK00Fg,11
11
+ j_perm_sql-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ j_perm_sql