edgesql 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.
- edgesql/__init__.py +251 -0
- edgesql/build.py +91 -0
- edgesql/dialects/__init__.py +14 -0
- edgesql/dialects/databricks.py +67 -0
- edgesql/dialects/maxrec.py +211 -0
- edgesql/dialects/postgres.py +53 -0
- edgesql/errors.py +124 -0
- edgesql/inspect.py +477 -0
- edgesql/locate.py +118 -0
- edgesql/parse.py +255 -0
- edgesql/preview.py +212 -0
- edgesql/py.typed +1 -0
- edgesql/scope.py +505 -0
- edgesql/transforms.py +357 -0
- edgesql-0.1.0.dist-info/METADATA +478 -0
- edgesql-0.1.0.dist-info/RECORD +20 -0
- edgesql-0.1.0.dist-info/WHEEL +5 -0
- edgesql-0.1.0.dist-info/licenses/LICENSE +202 -0
- edgesql-0.1.0.dist-info/licenses/NOTICE +52 -0
- edgesql-0.1.0.dist-info/top_level.txt +1 -0
edgesql/__init__.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""EdgeSQL. Callers depend on Query, Filter, Preview, and SqlCraftError."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from edgesql import preview as preview_mod
|
|
8
|
+
from edgesql import transforms as xforms
|
|
9
|
+
from edgesql.build import col, param, select_tree
|
|
10
|
+
from edgesql.dialects import get as get_dialect
|
|
11
|
+
from edgesql.errors import SqlCraftError, fail
|
|
12
|
+
from edgesql.inspect import build_product_ir, columns_of, ctes_of, filters_of, params_of, tables_of
|
|
13
|
+
from edgesql.parse import parse_statement
|
|
14
|
+
from edgesql.scope import cte_names
|
|
15
|
+
|
|
16
|
+
__all__ = ["Query", "SqlCraftError", "col", "param", "select"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Query:
|
|
20
|
+
"""A parsed statement. Transforms return a new Query."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
tree: exp.Expression,
|
|
25
|
+
*,
|
|
26
|
+
dialect: str | None,
|
|
27
|
+
schema: dict | None,
|
|
28
|
+
mode: str,
|
|
29
|
+
ops: list | None = None,
|
|
30
|
+
zero_hit: bool = False,
|
|
31
|
+
focus: str | None = None,
|
|
32
|
+
meta: dict | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._tree = tree
|
|
35
|
+
self._dialect = dialect
|
|
36
|
+
self._schema = None if schema is None else {key: list(value) for key, value in schema.items()}
|
|
37
|
+
self._mode = mode
|
|
38
|
+
self._ops = list(ops or [])
|
|
39
|
+
self._zero_hit = zero_hit
|
|
40
|
+
self._focus = focus
|
|
41
|
+
self._meta = dict(meta or {})
|
|
42
|
+
self.audit = None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def parse(cls, sql: str, *, dialect: str, schema: dict | None = None, mode: str = "library") -> Query:
|
|
46
|
+
tree, meta = parse_statement(sql, dialect=dialect, schema=schema, mode=mode)
|
|
47
|
+
return cls(tree, dialect=dialect, schema=schema, mode=mode, meta=meta)
|
|
48
|
+
|
|
49
|
+
def to_sql(self, dialect: str | None = None, pretty: bool = False) -> str:
|
|
50
|
+
target = self._dialect if dialect is None else dialect
|
|
51
|
+
if target not in {"postgres", "databricks"}:
|
|
52
|
+
raise fail(
|
|
53
|
+
"render",
|
|
54
|
+
"UNKNOWN_DIALECT",
|
|
55
|
+
"Name the dialect on emit.",
|
|
56
|
+
hint="Use `postgres` or `databricks`.",
|
|
57
|
+
)
|
|
58
|
+
if self._dialect is not None and target != self._dialect:
|
|
59
|
+
raise fail(
|
|
60
|
+
"render",
|
|
61
|
+
"CROSS_DIALECT",
|
|
62
|
+
f"Cannot emit {target} for a query parsed as {self._dialect}.",
|
|
63
|
+
hint="Use `migrate(target)`, then preview, then apply.",
|
|
64
|
+
)
|
|
65
|
+
module = get_dialect(target)
|
|
66
|
+
if self._dialect is None:
|
|
67
|
+
reason = module.reject(self._tree)
|
|
68
|
+
if reason:
|
|
69
|
+
raise fail(
|
|
70
|
+
"render",
|
|
71
|
+
"DIALECT_NODE",
|
|
72
|
+
reason,
|
|
73
|
+
hint="Use the dialect that accepts the clause, or rewrite it.",
|
|
74
|
+
)
|
|
75
|
+
sql = module.finish(self._tree.sql(dialect=target, pretty=pretty), self._meta)
|
|
76
|
+
if pretty:
|
|
77
|
+
return sql
|
|
78
|
+
return _stable_canonical(sql, target)
|
|
79
|
+
|
|
80
|
+
def tables(self) -> list[str]:
|
|
81
|
+
return tables_of(self._tree)
|
|
82
|
+
|
|
83
|
+
def ctes(self) -> list[str]:
|
|
84
|
+
return ctes_of(self._tree)
|
|
85
|
+
|
|
86
|
+
def columns(self):
|
|
87
|
+
return columns_of(self._tree, self._schema)
|
|
88
|
+
|
|
89
|
+
def filters(self):
|
|
90
|
+
return filters_of(self._tree, self._schema, self._dialect)
|
|
91
|
+
|
|
92
|
+
def params(self) -> list[dict]:
|
|
93
|
+
return params_of(self._tree)
|
|
94
|
+
|
|
95
|
+
def to_tree(self) -> dict:
|
|
96
|
+
document, _paths = build_product_ir(self._tree, self._dialect)
|
|
97
|
+
return document
|
|
98
|
+
|
|
99
|
+
def cte(self, name: str) -> Query:
|
|
100
|
+
if name not in cte_names(self._tree):
|
|
101
|
+
raise fail(
|
|
102
|
+
"transform",
|
|
103
|
+
"UNKNOWN_SCOPE",
|
|
104
|
+
f"CTE {name} is not in this query.",
|
|
105
|
+
hint=_cte_hint(self._tree),
|
|
106
|
+
)
|
|
107
|
+
return self._spawn(self._tree, focus=f"cte:{name}")
|
|
108
|
+
|
|
109
|
+
def rename_column(self, old: str, new: str, *, table: str, keep_output_names: bool = True) -> Query:
|
|
110
|
+
tree, hits = xforms.rename_column(
|
|
111
|
+
self._tree,
|
|
112
|
+
self._schema,
|
|
113
|
+
old,
|
|
114
|
+
new,
|
|
115
|
+
table=table,
|
|
116
|
+
keep_output_names=keep_output_names,
|
|
117
|
+
focus=self._focus,
|
|
118
|
+
)
|
|
119
|
+
return self._spawn(tree, op="rename_column", zero_hit=hits == 0)
|
|
120
|
+
|
|
121
|
+
def and_where(self, predicate, *, scope: str | None = None) -> Query:
|
|
122
|
+
chosen = scope or self._focus or "root"
|
|
123
|
+
tree = xforms.and_where(self._tree, predicate, scope=chosen, dialect=self._dialect)
|
|
124
|
+
return self._spawn(tree, op="and_where")
|
|
125
|
+
|
|
126
|
+
def where(self, predicate) -> Query:
|
|
127
|
+
return self.and_where(predicate)
|
|
128
|
+
|
|
129
|
+
def limit(self, value: int) -> Query:
|
|
130
|
+
tree = self._tree.copy()
|
|
131
|
+
limited = tree.limit(value, copy=False)
|
|
132
|
+
return self._spawn(limited if limited is not None else tree, op="limit")
|
|
133
|
+
|
|
134
|
+
def remove_filter(self, filt) -> Query:
|
|
135
|
+
self._require_focus_scope(getattr(filt, "scope", None))
|
|
136
|
+
tree = xforms.remove_filter(self._tree, filt)
|
|
137
|
+
return self._spawn(tree, op="remove_filter")
|
|
138
|
+
|
|
139
|
+
def replace_filter(self, filt, predicate) -> Query:
|
|
140
|
+
self._require_focus_scope(getattr(filt, "scope", None))
|
|
141
|
+
tree = xforms.replace_filter(self._tree, filt, predicate, self._dialect)
|
|
142
|
+
return self._spawn(tree, op="replace_filter")
|
|
143
|
+
|
|
144
|
+
def with_cte(self, name: str, query: Query) -> Query:
|
|
145
|
+
tree = xforms.with_cte(self._tree, name, query._tree, query._dialect, self._dialect)
|
|
146
|
+
spawned = self._spawn(tree, op="with_cte")
|
|
147
|
+
if spawned._dialect is None and query._dialect is not None:
|
|
148
|
+
spawned._dialect = query._dialect
|
|
149
|
+
return spawned
|
|
150
|
+
|
|
151
|
+
def rename_cte(self, old: str, new: str) -> Query:
|
|
152
|
+
tree = xforms.rename_cte(self._tree, old, new)
|
|
153
|
+
focus = self._focus
|
|
154
|
+
if focus == f"cte:{old}":
|
|
155
|
+
focus = f"cte:{new}"
|
|
156
|
+
return self._spawn(tree, op="rename_cte", focus=focus)
|
|
157
|
+
|
|
158
|
+
def drop_cte(self, name: str) -> Query:
|
|
159
|
+
tree = xforms.drop_cte(self._tree, name)
|
|
160
|
+
focus = None if self._focus == f"cte:{name}" else self._focus
|
|
161
|
+
return self._spawn(tree, op="drop_cte", focus=focus)
|
|
162
|
+
|
|
163
|
+
def inline_cte(self, name: str) -> Query:
|
|
164
|
+
raise fail(
|
|
165
|
+
"transform",
|
|
166
|
+
"NOT_IN_V1",
|
|
167
|
+
"inline_cte is not in v1.",
|
|
168
|
+
hint="The method is a fast-follow.",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def set_params(self, patches: list[dict]) -> Query:
|
|
172
|
+
tree = xforms.set_params(self._tree, self._dialect, patches)
|
|
173
|
+
return self._spawn(tree, op="set_params")
|
|
174
|
+
|
|
175
|
+
def rewrite(self, fn) -> Query:
|
|
176
|
+
raise fail(
|
|
177
|
+
"transform",
|
|
178
|
+
"NOT_IN_V1",
|
|
179
|
+
"rewrite is not in v1.",
|
|
180
|
+
hint="The method is a fast-follow.",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def migrate(self, target: str):
|
|
184
|
+
if target not in {"postgres", "databricks"}:
|
|
185
|
+
raise fail(
|
|
186
|
+
"render",
|
|
187
|
+
"UNKNOWN_DIALECT",
|
|
188
|
+
f"Dialect {target} is not known.",
|
|
189
|
+
hint="Use `postgres` or `databricks`.",
|
|
190
|
+
)
|
|
191
|
+
return preview_mod.blocked_preview(self, ["MIGRATE_STUB"], ops=["migrate"])
|
|
192
|
+
|
|
193
|
+
def preview(self, fn=None):
|
|
194
|
+
return preview_mod.build_preview(self, fn)
|
|
195
|
+
|
|
196
|
+
def _spawn(self, tree, *, op: str | None = None, zero_hit: bool = False, focus: str | None = None) -> Query:
|
|
197
|
+
if focus is None and op != "drop_cte":
|
|
198
|
+
focus = self._focus
|
|
199
|
+
return Query(
|
|
200
|
+
tree,
|
|
201
|
+
dialect=self._dialect,
|
|
202
|
+
schema=self._schema,
|
|
203
|
+
mode=self._mode,
|
|
204
|
+
ops=[*self._ops, *([op] if op else [])],
|
|
205
|
+
zero_hit=self._zero_hit or zero_hit,
|
|
206
|
+
focus=focus,
|
|
207
|
+
meta=self._meta,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _require_focus_scope(self, scope_name: str | None) -> None:
|
|
211
|
+
if self._focus and scope_name and scope_name != self._focus:
|
|
212
|
+
raise fail(
|
|
213
|
+
"transform",
|
|
214
|
+
"UNKNOWN_SCOPE",
|
|
215
|
+
f"Scope {scope_name} is outside {self._focus}.",
|
|
216
|
+
hint=_cte_hint(self._tree),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def select(table: str) -> Query:
|
|
221
|
+
return Query(select_tree(table), dialect=None, schema=None, mode="library")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _cte_hint(tree) -> str:
|
|
225
|
+
names = cte_names(tree)
|
|
226
|
+
listed = ", ".join(names) if names else "(none)"
|
|
227
|
+
return f"Known CTE names: {listed}."
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _stable_canonical(sql: str, dialect: str) -> str:
|
|
231
|
+
"""Emit the print that a second parse reproduces.
|
|
232
|
+
|
|
233
|
+
SQLGlot sometimes parks a comment on a node that the next parse drops.
|
|
234
|
+
Canonical SQL is the print that survives that second parse.
|
|
235
|
+
"""
|
|
236
|
+
module = get_dialect(dialect)
|
|
237
|
+
again = _reprint(sql, dialect, module)
|
|
238
|
+
if again is None or again == sql:
|
|
239
|
+
return sql
|
|
240
|
+
third = _reprint(again, dialect, module)
|
|
241
|
+
if third == again:
|
|
242
|
+
return again
|
|
243
|
+
return sql
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reprint(sql: str, dialect: str, module) -> str | None:
|
|
247
|
+
try:
|
|
248
|
+
tree, meta = parse_statement(sql, dialect=dialect, schema=None, mode="library")
|
|
249
|
+
except SqlCraftError:
|
|
250
|
+
return None
|
|
251
|
+
return module.finish(tree.sql(dialect=dialect, pretty=False), meta)
|
edgesql/build.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Expression builders. They never paste a user string into SQL text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from edgesql.errors import fail
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BindBuilder:
|
|
11
|
+
def __init__(self, name: str) -> None:
|
|
12
|
+
self.name = name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ColumnBuilder:
|
|
16
|
+
def __init__(self, name: str) -> None:
|
|
17
|
+
self.name = name
|
|
18
|
+
|
|
19
|
+
def eq(self, other):
|
|
20
|
+
return self._compare(exp.EQ, other)
|
|
21
|
+
|
|
22
|
+
def ne(self, other):
|
|
23
|
+
return self._compare(exp.NEQ, other)
|
|
24
|
+
|
|
25
|
+
def gt(self, other):
|
|
26
|
+
return self._compare(exp.GT, other)
|
|
27
|
+
|
|
28
|
+
def gte(self, other):
|
|
29
|
+
return self._compare(exp.GTE, other)
|
|
30
|
+
|
|
31
|
+
def lt(self, other):
|
|
32
|
+
return self._compare(exp.LT, other)
|
|
33
|
+
|
|
34
|
+
def lte(self, other):
|
|
35
|
+
return self._compare(exp.LTE, other)
|
|
36
|
+
|
|
37
|
+
def _compare(self, kind, other):
|
|
38
|
+
return kind(this=exp.column(self.name), expression=literal(other))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def col(name: str) -> ColumnBuilder:
|
|
42
|
+
return ColumnBuilder(name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def param(name: str) -> BindBuilder:
|
|
46
|
+
return BindBuilder(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def literal(value):
|
|
50
|
+
if isinstance(value, exp.Expression):
|
|
51
|
+
return value
|
|
52
|
+
if isinstance(value, BindBuilder):
|
|
53
|
+
return exp.Placeholder(this=value.name)
|
|
54
|
+
if isinstance(value, ColumnBuilder):
|
|
55
|
+
return exp.column(value.name)
|
|
56
|
+
if value is None:
|
|
57
|
+
return exp.Null()
|
|
58
|
+
if isinstance(value, bool):
|
|
59
|
+
return exp.Boolean(this=value)
|
|
60
|
+
if isinstance(value, int):
|
|
61
|
+
return exp.Literal.number(value)
|
|
62
|
+
if isinstance(value, float):
|
|
63
|
+
return exp.Literal.number(value)
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
return exp.Literal.string(value)
|
|
66
|
+
raise fail(
|
|
67
|
+
"transform",
|
|
68
|
+
"STRUCTURE_PATCH",
|
|
69
|
+
"Lane A can patch the right-hand value only.",
|
|
70
|
+
hint="Patch the right-hand value only.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def select_tree(table: str) -> exp.Select:
|
|
75
|
+
return exp.Select(expressions=[exp.Star()]).from_(table_expr(table))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def table_expr(name: str) -> exp.Table:
|
|
79
|
+
parts = [exp.to_identifier(part) for part in name.split(".") if part]
|
|
80
|
+
if len(parts) == 1:
|
|
81
|
+
return exp.Table(this=parts[0])
|
|
82
|
+
if len(parts) == 2:
|
|
83
|
+
return exp.Table(this=parts[1], db=parts[0])
|
|
84
|
+
if len(parts) == 3:
|
|
85
|
+
return exp.Table(this=parts[2], db=parts[1], catalog=parts[0])
|
|
86
|
+
raise fail(
|
|
87
|
+
"parse",
|
|
88
|
+
"UNKNOWN_SOURCE",
|
|
89
|
+
f"Table name {name} has too many parts.",
|
|
90
|
+
hint="Check aliases and CTE names.",
|
|
91
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Dialect registry. A new dialect adds a module and one entry here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgesql.dialects import databricks, postgres
|
|
6
|
+
|
|
7
|
+
DIALECTS = {
|
|
8
|
+
"postgres": postgres,
|
|
9
|
+
"databricks": databricks,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get(name: str):
|
|
14
|
+
return DIALECTS.get(name)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Databricks facts. Marker style, quotes, and nodes this dialect rejects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from edgesql.dialects.maxrec import insert_max_recursion, split_max_recursion
|
|
8
|
+
|
|
9
|
+
NAME = "databricks"
|
|
10
|
+
QUOTES = "`"
|
|
11
|
+
MARKERS = (":name", "?")
|
|
12
|
+
ALLOWS_MAX_RECURSION = True
|
|
13
|
+
|
|
14
|
+
_HINT = "Use the dialect that accepts the clause, or rewrite it."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def prepare(sql: str) -> tuple[str, dict]:
|
|
18
|
+
stripped, level = split_max_recursion(sql)
|
|
19
|
+
meta = {"max_recursion": level} if level is not None else {}
|
|
20
|
+
return stripped, meta
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def finish(sql: str, meta: dict) -> str:
|
|
24
|
+
level = (meta or {}).get("max_recursion")
|
|
25
|
+
if level is None:
|
|
26
|
+
return sql
|
|
27
|
+
return insert_max_recursion(sql, int(level))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reject(tree: exp.Expression) -> str | None:
|
|
31
|
+
for distinct in tree.find_all(exp.Distinct):
|
|
32
|
+
if distinct.args.get("on") is not None:
|
|
33
|
+
return (
|
|
34
|
+
"DISTINCT ON is accepted on Postgres, not on Databricks. "
|
|
35
|
+
"Use the same dialect, or migrate, preview, and apply."
|
|
36
|
+
)
|
|
37
|
+
if list(tree.find_all(exp.Fetch)):
|
|
38
|
+
return "FETCH is accepted on Postgres, not on Databricks. Use the same dialect, or migrate, preview, and apply."
|
|
39
|
+
for with_ in tree.find_all(exp.With):
|
|
40
|
+
if not with_.args.get("recursive"):
|
|
41
|
+
continue
|
|
42
|
+
for cte in with_.expressions:
|
|
43
|
+
if _recursive_step_is_plain_union(cte.this):
|
|
44
|
+
return "A recursive Databricks CTE must use UNION ALL."
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _recursive_step_is_plain_union(body: exp.Expression) -> bool:
|
|
49
|
+
node = body.this if isinstance(body, exp.Subquery) else body
|
|
50
|
+
if not isinstance(node, exp.SetOperation):
|
|
51
|
+
return False
|
|
52
|
+
return not (isinstance(node, exp.Union) and node.args.get("distinct") is False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mixed_markers(tree: exp.Expression) -> str | None:
|
|
56
|
+
named = False
|
|
57
|
+
unnamed = False
|
|
58
|
+
for node in tree.walk():
|
|
59
|
+
if not isinstance(node, exp.Placeholder):
|
|
60
|
+
continue
|
|
61
|
+
if node.args.get("jdbc") or node.args.get("this") is None:
|
|
62
|
+
unnamed = True
|
|
63
|
+
else:
|
|
64
|
+
named = True
|
|
65
|
+
if named and unnamed:
|
|
66
|
+
return "This statement mixes :name and ? markers."
|
|
67
|
+
return None
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Keep the Databricks MAX RECURSION LEVEL clause that SQLGlot does not model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _starts_ident(text: str, index: int) -> bool:
|
|
7
|
+
if index <= 0:
|
|
8
|
+
return True
|
|
9
|
+
return not (text[index - 1].isalnum() or text[index - 1] == "_")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _keyword_at(text: str, index: int, word: str) -> bool:
|
|
13
|
+
end = index + len(word)
|
|
14
|
+
if end > len(text) or not _starts_ident(text, index):
|
|
15
|
+
return False
|
|
16
|
+
if text[index:end].lower() != word:
|
|
17
|
+
return False
|
|
18
|
+
return not (end < len(text) and (text[end].isalnum() or text[end] == "_"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _skip_ignorable(text: str, index: int) -> int:
|
|
22
|
+
n = len(text)
|
|
23
|
+
while index < n:
|
|
24
|
+
if text[index].isspace():
|
|
25
|
+
index += 1
|
|
26
|
+
continue
|
|
27
|
+
if text.startswith("--", index):
|
|
28
|
+
nl = text.find("\n", index)
|
|
29
|
+
index = n if nl < 0 else nl + 1
|
|
30
|
+
continue
|
|
31
|
+
if text.startswith("/*", index):
|
|
32
|
+
index = _skip_block(text, index + 2, copy=False)[0]
|
|
33
|
+
continue
|
|
34
|
+
break
|
|
35
|
+
return index
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _skip_block(text: str, index: int, *, copy: bool) -> tuple[int, str]:
|
|
39
|
+
"""Skip a block comment. Databricks allows nested block comments."""
|
|
40
|
+
n = len(text)
|
|
41
|
+
depth = 1
|
|
42
|
+
out = ["/*"] if copy else []
|
|
43
|
+
while index < n and depth:
|
|
44
|
+
if text.startswith("/*", index):
|
|
45
|
+
depth += 1
|
|
46
|
+
if copy:
|
|
47
|
+
out.append("/*")
|
|
48
|
+
index += 2
|
|
49
|
+
continue
|
|
50
|
+
if text.startswith("*/", index):
|
|
51
|
+
depth -= 1
|
|
52
|
+
if copy:
|
|
53
|
+
out.append("*/")
|
|
54
|
+
index += 2
|
|
55
|
+
continue
|
|
56
|
+
if copy:
|
|
57
|
+
out.append(text[index])
|
|
58
|
+
index += 1
|
|
59
|
+
return index, "".join(out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def split_max_recursion(sql: str) -> tuple[str, int | None]:
|
|
63
|
+
"""Remove one MAX RECURSION LEVEL clause. Return the SQL and the level."""
|
|
64
|
+
n = len(sql)
|
|
65
|
+
index = 0
|
|
66
|
+
out: list[str] = []
|
|
67
|
+
level: int | None = None
|
|
68
|
+
quote: str | None = None
|
|
69
|
+
while index < n:
|
|
70
|
+
if quote:
|
|
71
|
+
out.append(sql[index])
|
|
72
|
+
if sql[index] == quote:
|
|
73
|
+
if quote == "'" and index + 1 < n and sql[index + 1] == "'":
|
|
74
|
+
out.append(sql[index + 1])
|
|
75
|
+
index += 2
|
|
76
|
+
continue
|
|
77
|
+
quote = None
|
|
78
|
+
index += 1
|
|
79
|
+
continue
|
|
80
|
+
if sql.startswith("--", index):
|
|
81
|
+
nl = sql.find("\n", index)
|
|
82
|
+
end = n if nl < 0 else nl
|
|
83
|
+
out.append(sql[index:end])
|
|
84
|
+
index = end
|
|
85
|
+
continue
|
|
86
|
+
if sql.startswith("/*", index):
|
|
87
|
+
index, chunk = _skip_block(sql, index + 2, copy=True)
|
|
88
|
+
out.append(chunk)
|
|
89
|
+
continue
|
|
90
|
+
if sql[index] in {"'", '"', "`"}:
|
|
91
|
+
quote = sql[index]
|
|
92
|
+
out.append(sql[index])
|
|
93
|
+
index += 1
|
|
94
|
+
continue
|
|
95
|
+
if _keyword_at(sql, index, "max"):
|
|
96
|
+
j = _skip_ignorable(sql, index + 3)
|
|
97
|
+
if _keyword_at(sql, j, "recursion"):
|
|
98
|
+
k = _skip_ignorable(sql, j + len("recursion"))
|
|
99
|
+
if _keyword_at(sql, k, "level"):
|
|
100
|
+
m = _skip_ignorable(sql, k + len("level"))
|
|
101
|
+
number, end = _read_int(sql, m)
|
|
102
|
+
if number is not None:
|
|
103
|
+
level = number
|
|
104
|
+
index = end
|
|
105
|
+
continue
|
|
106
|
+
out.append(sql[index])
|
|
107
|
+
index += 1
|
|
108
|
+
return "".join(out), level
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_int(text: str, index: int) -> tuple[int | None, int]:
|
|
112
|
+
if index >= len(text) or not text[index].isdigit():
|
|
113
|
+
return None, index
|
|
114
|
+
end = index
|
|
115
|
+
while end < len(text) and text[end].isdigit():
|
|
116
|
+
end += 1
|
|
117
|
+
return int(text[index:end]), end
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _match_paren(text: str, open_at: int) -> int:
|
|
121
|
+
"""Return the index just after the `)` that matches `text[open_at] == '('`."""
|
|
122
|
+
n = len(text)
|
|
123
|
+
depth = 0
|
|
124
|
+
index = open_at
|
|
125
|
+
quote: str | None = None
|
|
126
|
+
while index < n:
|
|
127
|
+
if quote:
|
|
128
|
+
if text[index] == quote:
|
|
129
|
+
if quote == "'" and index + 1 < n and text[index + 1] == "'":
|
|
130
|
+
index += 2
|
|
131
|
+
continue
|
|
132
|
+
quote = None
|
|
133
|
+
index += 1
|
|
134
|
+
continue
|
|
135
|
+
if text.startswith("--", index):
|
|
136
|
+
nl = text.find("\n", index)
|
|
137
|
+
index = n if nl < 0 else nl + 1
|
|
138
|
+
continue
|
|
139
|
+
if text.startswith("/*", index):
|
|
140
|
+
index = _skip_block(text, index + 2, copy=False)[0]
|
|
141
|
+
continue
|
|
142
|
+
char = text[index]
|
|
143
|
+
if char in {"'", '"', "`"}:
|
|
144
|
+
quote = char
|
|
145
|
+
index += 1
|
|
146
|
+
continue
|
|
147
|
+
if char == "(":
|
|
148
|
+
depth += 1
|
|
149
|
+
elif char == ")":
|
|
150
|
+
depth -= 1
|
|
151
|
+
if depth == 0:
|
|
152
|
+
return index + 1
|
|
153
|
+
index += 1
|
|
154
|
+
return n
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def insert_max_recursion(sql: str, level: int) -> str:
|
|
158
|
+
"""Put the clause after the WITH list and before the main query."""
|
|
159
|
+
index = _skip_ignorable(sql, 0)
|
|
160
|
+
if not _keyword_at(sql, index, "with"):
|
|
161
|
+
return f"MAX RECURSION LEVEL {level} {sql}"
|
|
162
|
+
index = _skip_ignorable(sql, index + len("with"))
|
|
163
|
+
if _keyword_at(sql, index, "recursive"):
|
|
164
|
+
index += len("recursive")
|
|
165
|
+
guard = 0
|
|
166
|
+
while guard < 100:
|
|
167
|
+
guard += 1
|
|
168
|
+
as_at = _find_keyword(sql, index, "as")
|
|
169
|
+
if as_at < 0:
|
|
170
|
+
break
|
|
171
|
+
after = _skip_ignorable(sql, as_at + 2)
|
|
172
|
+
if after >= len(sql) or sql[after] != "(":
|
|
173
|
+
index = as_at + 2
|
|
174
|
+
continue
|
|
175
|
+
close = _match_paren(sql, after)
|
|
176
|
+
nxt = _skip_ignorable(sql, close)
|
|
177
|
+
if nxt < len(sql) and sql[nxt] == ",":
|
|
178
|
+
index = nxt + 1
|
|
179
|
+
continue
|
|
180
|
+
clause = f" MAX RECURSION LEVEL {level}"
|
|
181
|
+
return sql[:close] + clause + sql[close:]
|
|
182
|
+
return sql
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _find_keyword(text: str, index: int, word: str) -> int:
|
|
186
|
+
n = len(text)
|
|
187
|
+
quote: str | None = None
|
|
188
|
+
while index < n:
|
|
189
|
+
if quote:
|
|
190
|
+
if text[index] == quote:
|
|
191
|
+
if quote == "'" and index + 1 < n and text[index + 1] == "'":
|
|
192
|
+
index += 2
|
|
193
|
+
continue
|
|
194
|
+
quote = None
|
|
195
|
+
index += 1
|
|
196
|
+
continue
|
|
197
|
+
if text.startswith("--", index):
|
|
198
|
+
nl = text.find("\n", index)
|
|
199
|
+
index = n if nl < 0 else nl + 1
|
|
200
|
+
continue
|
|
201
|
+
if text.startswith("/*", index):
|
|
202
|
+
index = _skip_block(text, index + 2, copy=False)[0]
|
|
203
|
+
continue
|
|
204
|
+
if text[index] in {"'", '"', "`"}:
|
|
205
|
+
quote = text[index]
|
|
206
|
+
index += 1
|
|
207
|
+
continue
|
|
208
|
+
if _keyword_at(text, index, word):
|
|
209
|
+
return index
|
|
210
|
+
index += 1
|
|
211
|
+
return -1
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Postgres facts. Marker style, quotes, and nodes this dialect rejects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from edgesql.dialects.maxrec import split_max_recursion
|
|
8
|
+
from edgesql.errors import fail
|
|
9
|
+
|
|
10
|
+
NAME = "postgres"
|
|
11
|
+
QUOTES = '"'
|
|
12
|
+
MARKERS = ("$n", "%(name)s")
|
|
13
|
+
ALLOWS_MAX_RECURSION = False
|
|
14
|
+
|
|
15
|
+
_QUALIFY = "QUALIFY is accepted on Databricks, not on Postgres. Use the same dialect, or migrate, preview, and apply."
|
|
16
|
+
_HINT = "Use the dialect that accepts the clause, or rewrite it."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def prepare(sql: str) -> tuple[str, dict]:
|
|
20
|
+
stripped, level = split_max_recursion(sql)
|
|
21
|
+
if level is not None:
|
|
22
|
+
raise fail(
|
|
23
|
+
"parse",
|
|
24
|
+
"DIALECT_NODE",
|
|
25
|
+
"MAX RECURSION LEVEL is accepted on Databricks, not on Postgres. "
|
|
26
|
+
"Use the same dialect, or migrate, preview, and apply.",
|
|
27
|
+
hint=_HINT,
|
|
28
|
+
)
|
|
29
|
+
return stripped, {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def finish(sql: str, meta: dict) -> str:
|
|
33
|
+
return sql
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reject(tree: exp.Expression) -> str | None:
|
|
37
|
+
if list(tree.find_all(exp.Qualify)):
|
|
38
|
+
return _QUALIFY
|
|
39
|
+
for join in tree.find_all(exp.Join):
|
|
40
|
+
kind = str(join.args.get("kind") or "").upper()
|
|
41
|
+
if kind == "SEMI":
|
|
42
|
+
return (
|
|
43
|
+
"SEMI is accepted on Databricks, not on Postgres. Use the same dialect, or migrate, preview, and apply."
|
|
44
|
+
)
|
|
45
|
+
if kind == "ANTI":
|
|
46
|
+
return (
|
|
47
|
+
"ANTI is accepted on Databricks, not on Postgres. Use the same dialect, or migrate, preview, and apply."
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mixed_markers(tree: exp.Expression) -> str | None:
|
|
53
|
+
return None
|