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 +48 -0
- j_perm_sql/constructs.py +591 -0
- j_perm_sql/dialect.py +120 -0
- j_perm_sql/handler.py +75 -0
- j_perm_sql/install.py +47 -0
- j_perm_sql/pipeline.py +47 -0
- j_perm_sql/render.py +97 -0
- j_perm_sql-0.1.0.dist-info/METADATA +191 -0
- j_perm_sql-0.1.0.dist-info/RECORD +11 -0
- j_perm_sql-0.1.0.dist-info/WHEEL +5 -0
- j_perm_sql-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
j_perm_sql/constructs.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
j_perm_sql
|