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 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