sql2sqlx 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.
sql2sqlx/emitter.py ADDED
@@ -0,0 +1,275 @@
1
+ # Copyright (c) Soumyadip Sarkar.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the Apache-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Render Dataform ``.sqlx`` files.
8
+
9
+ The emitter's contract is *character fidelity for untouched SQL*:
10
+ generated bodies are produced by applying span edits (reference rewrites,
11
+ select-list aliases, ``${self()}`` substitutions) to slices of the
12
+ **original** source text - tokens are never re-serialized, so the user's
13
+ formatting, casing and inline comments survive after input decoding.
14
+
15
+ Two SQLX-specific safety rules live here:
16
+
17
+ * Any literal ``${`` in a SQL string or quoted identifier is emitted through
18
+ a constant JavaScript placeholder. Dataform does not provide a backslash
19
+ escape for SQL placeholders, so ``\\${`` is insufficient. Replacement text
20
+ inserted *by* sql2sqlx (``${ref(...)}``, ``${self()}``) stays active.
21
+ * Operations bodies have exactly one trailing semicolon stripped:
22
+ Dataform executes the body as a BigQuery script, and a trailing empty
23
+ statement is at best noise.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
31
+
32
+ from sql2sqlx.lexer import BACKTICK, PARAM, STRING, Token
33
+ from sql2sqlx.model import TableName
34
+
35
+ #: Canonical top-level config key order (unknown keys follow, insertion-ordered).
36
+ _KEY_ORDER = (
37
+ "type",
38
+ "database",
39
+ "schema",
40
+ "name",
41
+ "materialized",
42
+ "protected",
43
+ "hasOutput",
44
+ "uniqueKey",
45
+ "description",
46
+ "columns",
47
+ "dependencies",
48
+ "tags",
49
+ "bigquery",
50
+ "disabled",
51
+ )
52
+
53
+ #: Canonical key order inside the ``bigquery`` block.
54
+ _BQ_ORDER = (
55
+ "partitionBy",
56
+ "clusterBy",
57
+ "updatePartitionFilter",
58
+ "labels",
59
+ "partitionExpirationDays",
60
+ "requirePartitionFilter",
61
+ "additionalOptions",
62
+ )
63
+
64
+ _JS_IDENT_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
65
+
66
+
67
+ def _json(value: str) -> str:
68
+ """JSON-encode a string for embedding in JS (non-ASCII preserved)."""
69
+ return json.dumps(value, ensure_ascii=False)
70
+
71
+
72
+ def sqlx_constant(value: str) -> str:
73
+ """Return a SQLX placeholder that evaluates to the constant ``value``."""
74
+ return "${" + _json(value) + "}"
75
+
76
+
77
+ def sqlx_escape_edits(tokens: Sequence[Token]) -> List[Tuple[int, int, str]]:
78
+ """Build edits that preserve literal ``${`` through Dataform compile.
79
+
80
+ GoogleSQL only permits ``$`` in the relevant valid inputs inside string
81
+ literals or quoted identifiers. String occurrences can be replaced by a
82
+ constant placeholder for the two characters. A quoted identifier must be
83
+ replaced as a whole because a SQLX placeholder nested between BigQuery
84
+ backticks produces invalid generated JavaScript.
85
+ """
86
+ edits: List[Tuple[int, int, str]] = []
87
+ for token in tokens:
88
+ if "${" not in token.text:
89
+ continue
90
+ if token.kind == BACKTICK or (token.kind == PARAM and token.text.startswith("@`")):
91
+ edits.append((token.start, token.end, sqlx_constant(token.text)))
92
+ continue
93
+ if token.kind != STRING:
94
+ continue
95
+ offset = 0
96
+ while True:
97
+ offset = token.text.find("${", offset)
98
+ if offset < 0:
99
+ break
100
+ start = token.start + offset
101
+ edits.append((start, start + 2, sqlx_constant("${")))
102
+ offset += 2
103
+ return edits
104
+
105
+
106
+ def _js_key(key: str) -> str:
107
+ """Render an object key: bare when a valid JS identifier, else quoted."""
108
+ return key if _JS_IDENT_RE.match(key) else _json(key)
109
+
110
+
111
+ def _js_value(value: Any, indent: int) -> str:
112
+ """Render a Python value as JavaScript object-literal source.
113
+
114
+ Args:
115
+ value: ``str``/``bool``/``int``/``float``/``list``/``dict`` (nested).
116
+ indent: Current indentation level (2 spaces per level).
117
+
118
+ Returns:
119
+ JS source text for the value.
120
+ """
121
+ pad = " " * indent
122
+ if isinstance(value, bool):
123
+ return "true" if value else "false"
124
+ if isinstance(value, int):
125
+ return str(value)
126
+ if isinstance(value, float):
127
+ return str(int(value)) if value.is_integer() else repr(value)
128
+ if isinstance(value, str):
129
+ return _json(value)
130
+ if isinstance(value, (list, tuple)):
131
+ inner = [_js_value(v, indent + 1) for v in value]
132
+ one_line = "[" + ", ".join(inner) + "]"
133
+ if len(one_line) <= 72 and "\n" not in one_line:
134
+ return one_line
135
+ ip = " " * (indent + 1)
136
+ return "[\n" + ",\n".join(ip + v for v in inner) + "\n" + pad + "]"
137
+ if isinstance(value, dict):
138
+ if not value:
139
+ return "{}"
140
+ ip = " " * (indent + 1)
141
+ lines = [f"{ip}{_js_key(k)}: {_js_value(v, indent + 1)}" for k, v in value.items()]
142
+ return "{\n" + ",\n".join(lines) + "\n" + pad + "}"
143
+ return _json(str(value)) # pragma: no cover - defensive
144
+
145
+
146
+ def render_config(config: Dict[str, Any]) -> str:
147
+ """Render a Dataform ``config { ... }`` block.
148
+
149
+ Keys are emitted in canonical order (:data:`_KEY_ORDER`, then any
150
+ remaining keys in insertion order); the nested ``bigquery`` object is
151
+ ordered by :data:`_BQ_ORDER`. Ordering is purely cosmetic but makes
152
+ output deterministic and diff-friendly.
153
+
154
+ Args:
155
+ config: The config mapping.
156
+
157
+ Returns:
158
+ The complete ``config { ... }`` block, without a trailing newline.
159
+ """
160
+ ordered: Dict[str, Any] = {}
161
+ for key in _KEY_ORDER:
162
+ if key in config:
163
+ ordered[key] = config[key]
164
+ for key, val in config.items():
165
+ if key not in ordered:
166
+ ordered[key] = val
167
+ bq = ordered.get("bigquery")
168
+ if isinstance(bq, dict):
169
+ obq: Dict[str, Any] = {k: bq[k] for k in _BQ_ORDER if k in bq}
170
+ for k, v in bq.items():
171
+ if k not in obq:
172
+ obq[k] = v
173
+ ordered["bigquery"] = obq
174
+ lines = [f" {_js_key(k)}: {_js_value(v, 1)}" for k, v in ordered.items()]
175
+ return "config {\n" + ",\n".join(lines) + "\n}"
176
+
177
+
178
+ def apply_edits_escaped(
179
+ text: str, start: int, end: int, edits: Iterable[Tuple[int, int, str]]
180
+ ) -> str:
181
+ """Slice ``text[start:end]`` and apply non-overlapping span edits.
182
+
183
+ Edits use *absolute* offsets into ``text``. Edits outside the slice or
184
+ overlapping an already-applied edit are skipped defensively. Literal SQLX
185
+ interpolation is handled by :func:`sqlx_escape_edits`; replacements such
186
+ as ``ref()`` and ``self()`` are inserted verbatim and remain active.
187
+
188
+ Args:
189
+ text: Full original source text.
190
+ start: Slice start offset.
191
+ end: Slice end offset.
192
+ edits: ``(start, end, replacement)`` triples, any order.
193
+
194
+ Returns:
195
+ The edited, escaped slice.
196
+ """
197
+ segments: List[str] = []
198
+ pos = start
199
+ for a, b, replacement in sorted(edits, key=lambda e: (e[0], e[1])):
200
+ if a < pos or a < start or b > end or b < a:
201
+ continue
202
+ segments.append(text[pos:a])
203
+ segments.append(replacement)
204
+ pos = b
205
+ segments.append(text[pos:end])
206
+ return "".join(segments)
207
+
208
+
209
+ def ref_expr(name: TableName) -> str:
210
+ """Build the ``${ref(...)}`` expression for a produced table.
211
+
212
+ The reference carries exactly the qualification the *producer* was
213
+ declared with: ``${ref("name")}``, ``${ref("schema", "name")}``, or
214
+ the object form ``${ref({database: ..., schema: ..., name: ...})}``
215
+ when a project was explicit.
216
+
217
+ Args:
218
+ name: The producer's original (pre-resolution) table name.
219
+
220
+ Returns:
221
+ The interpolation expression text.
222
+ """
223
+ if name.project:
224
+ return "${ref({database: %s, schema: %s, name: %s})}" % (
225
+ _json(name.project),
226
+ _json(name.dataset or ""),
227
+ _json(name.table),
228
+ )
229
+ if name.dataset:
230
+ return "${ref(%s, %s)}" % (_json(name.dataset), _json(name.table))
231
+ return "${ref(%s)}" % _json(name.table)
232
+
233
+
234
+ def build_sqlx(
235
+ config: Dict[str, Any],
236
+ body: Optional[str] = None,
237
+ annotation: Optional[str] = None,
238
+ leading_comments: Optional[str] = None,
239
+ trailing_comments: Optional[str] = None,
240
+ ) -> str:
241
+ """Assemble a complete ``.sqlx`` file.
242
+
243
+ Layout: ``config`` block, blank line, then (in order) the provenance
244
+ annotation comment, the statement's original leading comments, and the
245
+ SQL body. One trailing semicolon is stripped from the body. When the
246
+ body is ``None`` (declarations) only the config block is emitted.
247
+
248
+ Args:
249
+ config: The config mapping (see :func:`render_config`).
250
+ body: Edited SQL body, or ``None``.
251
+ annotation: Provenance comment line, or ``None``.
252
+ leading_comments: Comments that preceded the statement, or ``None``.
253
+ trailing_comments: Final file comments after the statement's
254
+ terminating semicolon, or ``None``.
255
+
256
+ Returns:
257
+ The full file contents, newline-terminated.
258
+ """
259
+ head = render_config(config)
260
+ bits: List[str] = []
261
+ if annotation:
262
+ bits.append(annotation)
263
+ if leading_comments and leading_comments.strip():
264
+ bits.append(leading_comments.strip())
265
+ if body is not None:
266
+ cleaned = body.strip()
267
+ if cleaned.endswith(";"):
268
+ cleaned = cleaned[:-1].rstrip()
269
+ if cleaned:
270
+ bits.append(cleaned)
271
+ if trailing_comments and trailing_comments.strip():
272
+ bits.append(trailing_comments.strip())
273
+ if bits:
274
+ return head + "\n\n" + "\n".join(bits) + "\n"
275
+ return head + "\n"
sql2sqlx/errors.py ADDED
@@ -0,0 +1,93 @@
1
+ # Copyright (c) Soumyadip Sarkar.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the Apache-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Exception hierarchy for :mod:`sql2sqlx`.
8
+
9
+ All exceptions raised deliberately by this library derive from
10
+ :class:`Sql2SqlxError`, so callers can catch a single base class.
11
+ Location-aware errors (lexing/splitting problems) carry a 1-based
12
+ ``line`` and ``column`` pointing at the offending character in the
13
+ original SQL text.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+
21
+ class Sql2SqlxError(Exception):
22
+ """Base class for all errors raised by sql2sqlx."""
23
+
24
+
25
+ class LexError(Sql2SqlxError):
26
+ """Raised when the SQL text cannot be tokenized.
27
+
28
+ Typical causes are unterminated string literals, unterminated
29
+ backtick-quoted identifiers, or unterminated ``/* ... */`` block
30
+ comments.
31
+
32
+ Attributes:
33
+ message: Human-readable description of the problem.
34
+ line: 1-based line number of the offending character.
35
+ column: 1-based column number of the offending character.
36
+ """
37
+
38
+ def __init__(self, message: str, line: int, column: int) -> None:
39
+ """Initialize the error.
40
+
41
+ Args:
42
+ message: Human-readable description of the problem.
43
+ line: 1-based line number in the source text.
44
+ column: 1-based column number in the source text.
45
+ """
46
+ super().__init__(f"{message} (line {line}, column {column})")
47
+ self.message = message
48
+ self.line = line
49
+ self.column = column
50
+
51
+
52
+ class SplitError(Sql2SqlxError):
53
+ """Raised when statement splitting fails irrecoverably.
54
+
55
+ In practice the splitter is lenient (mismatched ``END`` markers are
56
+ tolerated), so this error is reserved for structural impossibilities
57
+ such as unbalanced parentheses at end of input.
58
+ """
59
+
60
+ def __init__(self, message: str, line: int = 0, column: int = 0) -> None:
61
+ """Initialize the error.
62
+
63
+ Args:
64
+ message: Human-readable description of the problem.
65
+ line: 1-based line number in the source text (0 if unknown).
66
+ column: 1-based column number in the source text (0 if unknown).
67
+ """
68
+ loc = f" (line {line}, column {column})" if line else ""
69
+ super().__init__(f"{message}{loc}")
70
+ self.message = message
71
+ self.line = line
72
+ self.column = column
73
+
74
+
75
+ class ConversionError(Sql2SqlxError):
76
+ """Raised when a statement or file cannot be converted at all.
77
+
78
+ Note that *unsupported-but-valid* SQL never raises: it falls back to
79
+ a Dataform ``operations`` action and a warning is recorded in the
80
+ :class:`~sql2sqlx.model.ConversionReport`. This exception is for
81
+ genuinely broken input (e.g. unreadable files, lexer failures).
82
+ """
83
+
84
+ def __init__(self, message: str, path: Optional[str] = None) -> None:
85
+ """Initialize the error.
86
+
87
+ Args:
88
+ message: Human-readable description of the problem.
89
+ path: Path of the file being converted, if applicable.
90
+ """
91
+ super().__init__(f"{path}: {message}" if path else message)
92
+ self.message = message
93
+ self.path = path
sql2sqlx/keywords.py ADDED
@@ -0,0 +1,169 @@
1
+ # Copyright (c) Soumyadip Sarkar.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the Apache-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """GoogleSQL reserved keywords and related token classification sets.
8
+
9
+ :data:`RESERVED` is the official GoogleSQL reserved-keyword list. It is
10
+ used to decide whether a bare identifier can be a table alias (reserved
11
+ words cannot be used as unquoted aliases), which keeps the reference
12
+ scanner from mistaking clause keywords for aliases.
13
+
14
+ :data:`FROM_CLAUSE_ENDERS` are keywords that terminate the table list of
15
+ a ``FROM`` clause at the same nesting depth; encountering one switches
16
+ the reference scanner out of "expecting a table path" mode.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import FrozenSet
22
+
23
+ #: Official GoogleSQL reserved keywords (case-insensitive).
24
+ RESERVED: FrozenSet[str] = frozenset(
25
+ {
26
+ "ALL",
27
+ "AND",
28
+ "ANY",
29
+ "ARRAY",
30
+ "AS",
31
+ "ASC",
32
+ "ASSERT_ROWS_MODIFIED",
33
+ "AT",
34
+ "BETWEEN",
35
+ "BY",
36
+ "CASE",
37
+ "CAST",
38
+ "COLLATE",
39
+ "CONTAINS",
40
+ "CREATE",
41
+ "CROSS",
42
+ "CUBE",
43
+ "CURRENT",
44
+ "DEFAULT",
45
+ "DEFINE",
46
+ "DESC",
47
+ "DISTINCT",
48
+ "ELSE",
49
+ "END",
50
+ "ENUM",
51
+ "ESCAPE",
52
+ "EXCEPT",
53
+ "EXCLUDE",
54
+ "EXISTS",
55
+ "EXTRACT",
56
+ "FALSE",
57
+ "FETCH",
58
+ "FOLLOWING",
59
+ "FOR",
60
+ "FROM",
61
+ "FULL",
62
+ "GRAPH_TABLE",
63
+ "GROUP",
64
+ "GROUPING",
65
+ "GROUPS",
66
+ "HASH",
67
+ "HAVING",
68
+ "IF",
69
+ "IGNORE",
70
+ "IN",
71
+ "INNER",
72
+ "INTERSECT",
73
+ "INTERVAL",
74
+ "INTO",
75
+ "IS",
76
+ "JOIN",
77
+ "LATERAL",
78
+ "LEFT",
79
+ "LIKE",
80
+ "LIMIT",
81
+ "LOOKUP",
82
+ "MERGE",
83
+ "NATURAL",
84
+ "NEW",
85
+ "NO",
86
+ "NOT",
87
+ "NULL",
88
+ "NULLS",
89
+ "OF",
90
+ "ON",
91
+ "OR",
92
+ "ORDER",
93
+ "OUTER",
94
+ "OVER",
95
+ "PARTITION",
96
+ "PRECEDING",
97
+ "PROTO",
98
+ "QUALIFY",
99
+ "RANGE",
100
+ "RECURSIVE",
101
+ "RESPECT",
102
+ "RIGHT",
103
+ "ROLLUP",
104
+ "ROWS",
105
+ "SELECT",
106
+ "SET",
107
+ "SOME",
108
+ "STRUCT",
109
+ "TABLESAMPLE",
110
+ "THEN",
111
+ "TO",
112
+ "TREAT",
113
+ "TRUE",
114
+ "UNBOUNDED",
115
+ "UNION",
116
+ "UNNEST",
117
+ "USING",
118
+ "WHEN",
119
+ "WHERE",
120
+ "WINDOW",
121
+ "WITH",
122
+ "WITHIN",
123
+ }
124
+ )
125
+
126
+ #: Keywords that end the table list of a FROM clause at the same depth.
127
+ FROM_CLAUSE_ENDERS: FrozenSet[str] = frozenset(
128
+ {
129
+ "WHERE",
130
+ "GROUP",
131
+ "HAVING",
132
+ "QUALIFY",
133
+ "WINDOW",
134
+ "ORDER",
135
+ "LIMIT",
136
+ "UNION",
137
+ "INTERSECT",
138
+ "EXCEPT",
139
+ "SET",
140
+ "WHEN",
141
+ "THEN",
142
+ "RETURNING",
143
+ }
144
+ )
145
+
146
+ #: Date/time part identifiers; presence at top level of a select-list item
147
+ #: makes implicit-alias detection ambiguous (``INTERVAL 1 DAY``), so items
148
+ #: containing INTERVAL trigger a safe fallback instead of a guess.
149
+ DATE_PARTS: FrozenSet[str] = frozenset(
150
+ {
151
+ "YEAR",
152
+ "QUARTER",
153
+ "MONTH",
154
+ "WEEK",
155
+ "ISOWEEK",
156
+ "DAY",
157
+ "DAYOFWEEK",
158
+ "DAYOFYEAR",
159
+ "HOUR",
160
+ "MINUTE",
161
+ "SECOND",
162
+ "MILLISECOND",
163
+ "MICROSECOND",
164
+ "ISOYEAR",
165
+ "DATE",
166
+ "TIME",
167
+ "DATETIME",
168
+ }
169
+ )