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/__init__.py +83 -0
- sql2sqlx/__main__.py +14 -0
- sql2sqlx/cli.py +323 -0
- sql2sqlx/converter.py +1241 -0
- sql2sqlx/emitter.py +275 -0
- sql2sqlx/errors.py +93 -0
- sql2sqlx/keywords.py +169 -0
- sql2sqlx/lexer.py +494 -0
- sql2sqlx/model.py +487 -0
- sql2sqlx/parser.py +2193 -0
- sql2sqlx/py.typed +2 -0
- sql2sqlx/refs.py +902 -0
- sql2sqlx/splitter.py +360 -0
- sql2sqlx/version.py +15 -0
- sql2sqlx-0.1.0.dist-info/METADATA +733 -0
- sql2sqlx-0.1.0.dist-info/RECORD +20 -0
- sql2sqlx-0.1.0.dist-info/WHEEL +5 -0
- sql2sqlx-0.1.0.dist-info/entry_points.txt +2 -0
- sql2sqlx-0.1.0.dist-info/licenses/LICENSE +202 -0
- sql2sqlx-0.1.0.dist-info/top_level.txt +1 -0
sql2sqlx/converter.py
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
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
|
+
"""The conversion pipeline: parse -> link -> emit.
|
|
8
|
+
|
|
9
|
+
Phase 1 (parallelizable, per file)
|
|
10
|
+
:func:`parse_source` lexes, splits and classifies one file into
|
|
11
|
+
:class:`~sql2sqlx.model.ActionDraft` objects. Files requiring shared
|
|
12
|
+
BigQuery context (transactions, temporary objects, variables or
|
|
13
|
+
procedural control flow) become a single whole-file ``operations``
|
|
14
|
+
draft; persistent reference sites and write targets are still harvested.
|
|
15
|
+
|
|
16
|
+
Phase 2 (single pass over metadata)
|
|
17
|
+
:class:`_Linker` builds the cross-file picture:
|
|
18
|
+
|
|
19
|
+
* one **creator** per produced table (later duplicate creators are
|
|
20
|
+
demoted back to verbatim operations - Dataform permits exactly one
|
|
21
|
+
owner per target);
|
|
22
|
+
* **writer chains** per table in corpus order (sorted file path, then
|
|
23
|
+
statement position): every writer depends on its predecessor, so
|
|
24
|
+
``DROP x; CREATE x; UPDATE x`` keeps its original order;
|
|
25
|
+
* **hasOutput election**: an ownerless operation is elected only when it
|
|
26
|
+
actually creates its target, satisfying Dataform's output contract;
|
|
27
|
+
* **reference rewriting**: every read of a produced table becomes
|
|
28
|
+
``${ref(...)}``; readers additionally depend on the latest preceding
|
|
29
|
+
writer, and cycle-producing edges are conservatively omitted;
|
|
30
|
+
* unique action naming and collision-free output paths.
|
|
31
|
+
|
|
32
|
+
Phase 3
|
|
33
|
+
:mod:`sql2sqlx.emitter` renders each draft to a ``.sqlx`` file.
|
|
34
|
+
|
|
35
|
+
The public API - :func:`convert_string`, :func:`convert_file`,
|
|
36
|
+
:func:`convert_directory` - wraps the pipeline; directory conversion
|
|
37
|
+
parallelizes phase 1 across a process pool (lexing dominates runtime).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import bisect
|
|
43
|
+
import os
|
|
44
|
+
import time
|
|
45
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
46
|
+
from pathlib import Path, PurePosixPath
|
|
47
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
48
|
+
|
|
49
|
+
from sql2sqlx.emitter import apply_edits_escaped, build_sqlx, ref_expr, sqlx_escape_edits
|
|
50
|
+
from sql2sqlx.errors import ConversionError, LexError
|
|
51
|
+
from sql2sqlx.lexer import IDENT, OP, LineIndex, Token, tokenize
|
|
52
|
+
from sql2sqlx.model import (
|
|
53
|
+
ActionDraft,
|
|
54
|
+
ActionType,
|
|
55
|
+
ConversionOptions,
|
|
56
|
+
ConversionReport,
|
|
57
|
+
ConversionResult,
|
|
58
|
+
Layout,
|
|
59
|
+
ParsedFile,
|
|
60
|
+
RefSite,
|
|
61
|
+
ReportWarning,
|
|
62
|
+
SqlxFile,
|
|
63
|
+
TableName,
|
|
64
|
+
sanitize_filename,
|
|
65
|
+
)
|
|
66
|
+
from sql2sqlx.parser import classify_statement
|
|
67
|
+
from sql2sqlx.refs import parse_table_path, scan_ref_sites
|
|
68
|
+
from sql2sqlx.splitter import split_statements
|
|
69
|
+
from sql2sqlx.version import __version__
|
|
70
|
+
|
|
71
|
+
#: Resolved table identity used throughout the linker.
|
|
72
|
+
TableKey = Tuple[Optional[str], Optional[str], str]
|
|
73
|
+
DependencyRef = str
|
|
74
|
+
|
|
75
|
+
#: Operations eligible for ``hasOutput``. Dataform requires these actions to
|
|
76
|
+
#: actually create their configured output; mutating DML is deliberately absent.
|
|
77
|
+
_ELECTABLE = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"CREATE TABLE",
|
|
80
|
+
"CREATE VIEW",
|
|
81
|
+
"CREATE MATERIALIZED VIEW",
|
|
82
|
+
"CREATE EXTERNAL TABLE",
|
|
83
|
+
"CREATE SNAPSHOT TABLE",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Statement heads that require BigQuery's shared script context. RETURN ends
|
|
88
|
+
# the script, while ASSERT is an execution guard whose failure must prevent
|
|
89
|
+
# later statements from running; splitting either into independent Dataform
|
|
90
|
+
# actions changes control-flow semantics.
|
|
91
|
+
_PROCEDURAL_HEADS = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"IF",
|
|
94
|
+
"LOOP",
|
|
95
|
+
"WHILE",
|
|
96
|
+
"REPEAT",
|
|
97
|
+
"FOR",
|
|
98
|
+
"CASE",
|
|
99
|
+
"RETURN",
|
|
100
|
+
"ASSERT",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _word(tokens: Sequence[Token], i: int) -> str:
|
|
106
|
+
"""Return an uppercased identifier at ``i``, or the empty string."""
|
|
107
|
+
if 0 <= i < len(tokens) and tokens[i].kind == IDENT:
|
|
108
|
+
return tokens[i].upper
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _statement_head(tokens: Sequence[Token]) -> str:
|
|
113
|
+
"""Return the first keyword of a non-empty statement token sequence."""
|
|
114
|
+
return _word(tokens, 0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _create_prefix(tokens: Sequence[Token], start: int = 0) -> Tuple[int, bool, str]:
|
|
118
|
+
"""Parse CREATE modifiers and return ``(entity_index, temporary, entity)``."""
|
|
119
|
+
i = start + 1
|
|
120
|
+
if _word(tokens, i) == "OR" and _word(tokens, i + 1) == "REPLACE":
|
|
121
|
+
i += 2
|
|
122
|
+
temporary = _word(tokens, i) in ("TEMP", "TEMPORARY")
|
|
123
|
+
if temporary:
|
|
124
|
+
i += 1
|
|
125
|
+
if _word(tokens, i) in ("MATERIALIZED", "EXTERNAL", "SNAPSHOT"):
|
|
126
|
+
i += 1
|
|
127
|
+
return i, temporary, _word(tokens, i)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _script_reasons(statements: Sequence[Any]) -> List[str]:
|
|
131
|
+
"""Return shared-context reasons requiring whole-file script emission."""
|
|
132
|
+
reasons: Set[str] = set()
|
|
133
|
+
for statement in statements:
|
|
134
|
+
tokens = statement.tokens
|
|
135
|
+
head = _statement_head(tokens)
|
|
136
|
+
if head == "DECLARE" or head == "SET":
|
|
137
|
+
reasons.add("variable state")
|
|
138
|
+
elif head == "CALL" or (head == "EXECUTE" and _word(tokens, 1) == "IMMEDIATE"):
|
|
139
|
+
reasons.add("dynamic side effects")
|
|
140
|
+
elif head in _PROCEDURAL_HEADS:
|
|
141
|
+
reasons.add("assertion sequencing" if head == "ASSERT" else "procedural control flow")
|
|
142
|
+
elif head == "BEGIN":
|
|
143
|
+
if len(tokens) == 1 or _word(tokens, 1) == "TRANSACTION":
|
|
144
|
+
reasons.add("transaction scope")
|
|
145
|
+
else:
|
|
146
|
+
reasons.add("procedural control flow")
|
|
147
|
+
elif head in ("COMMIT", "ROLLBACK"):
|
|
148
|
+
reasons.add("transaction scope")
|
|
149
|
+
elif head == "CREATE":
|
|
150
|
+
_, temporary, entity = _create_prefix(tokens)
|
|
151
|
+
if temporary and entity in ("TABLE", "FUNCTION", "AGGREGATE"):
|
|
152
|
+
reasons.add("temporary object scope")
|
|
153
|
+
return sorted(reasons)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _rename_target(
|
|
157
|
+
tokens: Sequence[Token],
|
|
158
|
+
i: int,
|
|
159
|
+
original: TableName,
|
|
160
|
+
) -> Optional[Tuple[TableName, Tuple[int, int]]]:
|
|
161
|
+
"""Parse ``RENAME TO new_name`` after an ALTER target."""
|
|
162
|
+
if _word(tokens, i) != "RENAME" or _word(tokens, i + 1) != "TO":
|
|
163
|
+
return None
|
|
164
|
+
match = parse_table_path(tokens, i + 2)
|
|
165
|
+
if match is None or not (1 <= len(match.parts) <= 3) or not all(match.parts):
|
|
166
|
+
return None
|
|
167
|
+
renamed = TableName.from_parts(list(match.parts))
|
|
168
|
+
if renamed.project is None and renamed.dataset is None:
|
|
169
|
+
renamed = TableName(original.project, original.dataset, renamed.table)
|
|
170
|
+
elif renamed.project is None and original.project is not None:
|
|
171
|
+
renamed = TableName(original.project, renamed.dataset, renamed.table)
|
|
172
|
+
return renamed, (match.start, match.end)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _scan_script_writes(
|
|
176
|
+
tokens: Sequence[Token],
|
|
177
|
+
statement_start_offsets: Set[int],
|
|
178
|
+
) -> List[Tuple[TableName, Tuple[int, int], bool]]:
|
|
179
|
+
"""Conservatively discover persistent and temporary writes in a script.
|
|
180
|
+
|
|
181
|
+
Nested procedural statements are present in the same significant token
|
|
182
|
+
stream, so scanning keyword-led target shapes is both more useful and safer
|
|
183
|
+
than treating an entire ``BEGIN`` block as an opaque operation.
|
|
184
|
+
"""
|
|
185
|
+
writes: List[Tuple[TableName, Tuple[int, int], bool]] = []
|
|
186
|
+
n = len(tokens)
|
|
187
|
+
i = 0
|
|
188
|
+
while i < n:
|
|
189
|
+
head = _word(tokens, i)
|
|
190
|
+
if not head or tokens[i].start not in statement_start_offsets:
|
|
191
|
+
i += 1
|
|
192
|
+
continue
|
|
193
|
+
target_i: Optional[int] = None
|
|
194
|
+
temporary = False
|
|
195
|
+
merge_statement = False
|
|
196
|
+
if head == "MERGE":
|
|
197
|
+
# Only a MERGE *statement* writes anything, and everything up to
|
|
198
|
+
# its terminator belongs to it: the INSERT/UPDATE/DELETE keywords
|
|
199
|
+
# inside its WHEN ... THEN branches are not independent writes
|
|
200
|
+
# (`THEN INSERT ROW` / `THEN INSERT VALUES (...)` would otherwise
|
|
201
|
+
# register phantom targets named ROW/VALUES).
|
|
202
|
+
merge_statement = True
|
|
203
|
+
target_i = i + 1
|
|
204
|
+
if _word(tokens, target_i) == "INTO":
|
|
205
|
+
target_i += 1
|
|
206
|
+
elif head == "CREATE":
|
|
207
|
+
entity_i, temporary, entity = _create_prefix(tokens, i)
|
|
208
|
+
if entity not in ("TABLE", "VIEW"):
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
if entity == "TABLE" and _word(tokens, entity_i + 1) == "FUNCTION":
|
|
212
|
+
i += 1
|
|
213
|
+
continue
|
|
214
|
+
target_i = entity_i + 1
|
|
215
|
+
if (
|
|
216
|
+
_word(tokens, target_i) == "IF"
|
|
217
|
+
and _word(tokens, target_i + 1) == "NOT"
|
|
218
|
+
and _word(tokens, target_i + 2) == "EXISTS"
|
|
219
|
+
):
|
|
220
|
+
target_i += 3
|
|
221
|
+
elif head == "INSERT":
|
|
222
|
+
target_i = i + 1
|
|
223
|
+
if _word(tokens, target_i) == "INTO":
|
|
224
|
+
target_i += 1
|
|
225
|
+
elif head == "UPDATE":
|
|
226
|
+
target_i = i + 1
|
|
227
|
+
elif head == "DELETE":
|
|
228
|
+
target_i = i + (2 if _word(tokens, i + 1) == "FROM" else 1)
|
|
229
|
+
elif head == "TRUNCATE":
|
|
230
|
+
target_i = i + (2 if _word(tokens, i + 1) == "TABLE" else 1)
|
|
231
|
+
elif head in ("DROP", "ALTER"):
|
|
232
|
+
j = i + 1
|
|
233
|
+
modifier = _word(tokens, j)
|
|
234
|
+
if modifier in ("MATERIALIZED", "EXTERNAL", "SNAPSHOT"):
|
|
235
|
+
j += 1
|
|
236
|
+
if _word(tokens, j) not in ("TABLE", "VIEW"):
|
|
237
|
+
i += 1
|
|
238
|
+
continue
|
|
239
|
+
j += 1
|
|
240
|
+
if _word(tokens, j) == "IF" and _word(tokens, j + 1) == "EXISTS":
|
|
241
|
+
j += 2
|
|
242
|
+
target_i = j
|
|
243
|
+
elif head == "LOAD":
|
|
244
|
+
j = i + 1
|
|
245
|
+
if _word(tokens, j) == "DATA":
|
|
246
|
+
j += 1
|
|
247
|
+
if _word(tokens, j) in ("INTO", "OVERWRITE"):
|
|
248
|
+
j += 1
|
|
249
|
+
target_i = j
|
|
250
|
+
if target_i is None or target_i >= n:
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
match = parse_table_path(tokens, target_i)
|
|
254
|
+
if match is not None and 1 <= len(match.parts) <= 3 and all(match.parts):
|
|
255
|
+
name = TableName.from_parts(list(match.parts))
|
|
256
|
+
is_temp = temporary or (name.dataset is not None and name.dataset.upper() == "_SESSION")
|
|
257
|
+
writes.append((name, (match.start, match.end), is_temp))
|
|
258
|
+
if head == "ALTER":
|
|
259
|
+
renamed = _rename_target(tokens, match.next_index, name)
|
|
260
|
+
if renamed is not None:
|
|
261
|
+
renamed_name, renamed_span = renamed
|
|
262
|
+
renamed_temp = temporary or (
|
|
263
|
+
renamed_name.dataset is not None
|
|
264
|
+
and renamed_name.dataset.upper() == "_SESSION"
|
|
265
|
+
)
|
|
266
|
+
writes.append((renamed_name, renamed_span, renamed_temp))
|
|
267
|
+
i = match.next_index
|
|
268
|
+
if merge_statement:
|
|
269
|
+
# Skip the WHEN ... THEN branches up to the terminator.
|
|
270
|
+
while i < n and not (tokens[i].kind == OP and tokens[i].text == ";"):
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
i += 1
|
|
274
|
+
return writes
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _scan_script_ddl_reads(
|
|
278
|
+
tokens: Sequence[Token],
|
|
279
|
+
statement_start_offsets: Set[int],
|
|
280
|
+
) -> List[RefSite]:
|
|
281
|
+
"""Discover table sources in copy/clone/replica DDL inside scripts."""
|
|
282
|
+
reads: List[RefSite] = []
|
|
283
|
+
i = 0
|
|
284
|
+
n = len(tokens)
|
|
285
|
+
while i < n:
|
|
286
|
+
if _word(tokens, i) != "CREATE" or tokens[i].start not in statement_start_offsets:
|
|
287
|
+
i += 1
|
|
288
|
+
continue
|
|
289
|
+
entity_i, _temporary, entity = _create_prefix(tokens, i)
|
|
290
|
+
if entity not in ("TABLE", "VIEW"):
|
|
291
|
+
i += 1
|
|
292
|
+
continue
|
|
293
|
+
target_i = entity_i + 1
|
|
294
|
+
if entity == "TABLE" and _word(tokens, target_i) == "FUNCTION":
|
|
295
|
+
i += 1
|
|
296
|
+
continue
|
|
297
|
+
if (
|
|
298
|
+
_word(tokens, target_i) == "IF"
|
|
299
|
+
and _word(tokens, target_i + 1) == "NOT"
|
|
300
|
+
and _word(tokens, target_i + 2) == "EXISTS"
|
|
301
|
+
):
|
|
302
|
+
target_i += 3
|
|
303
|
+
target = parse_table_path(tokens, target_i)
|
|
304
|
+
if target is None:
|
|
305
|
+
i += 1
|
|
306
|
+
continue
|
|
307
|
+
j = target.next_index
|
|
308
|
+
source_i: Optional[int] = None
|
|
309
|
+
if entity == "TABLE" and _word(tokens, j) in ("LIKE", "CLONE", "COPY"):
|
|
310
|
+
source_i = j + 1
|
|
311
|
+
elif (
|
|
312
|
+
entity == "VIEW"
|
|
313
|
+
and _word(tokens, j) == "AS"
|
|
314
|
+
and _word(tokens, j + 1) == "REPLICA"
|
|
315
|
+
and _word(tokens, j + 2) == "OF"
|
|
316
|
+
):
|
|
317
|
+
source_i = j + 3
|
|
318
|
+
if source_i is not None:
|
|
319
|
+
source = parse_table_path(tokens, source_i)
|
|
320
|
+
if source is not None and 1 <= len(source.parts) <= 3 and all(source.parts):
|
|
321
|
+
reads.append(
|
|
322
|
+
RefSite(
|
|
323
|
+
source.start,
|
|
324
|
+
source.end,
|
|
325
|
+
TableName.from_parts(list(source.parts)),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
i = target.next_index
|
|
329
|
+
return reads
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _temporary_name(name: TableName, temp_tables: Set[str]) -> bool:
|
|
333
|
+
"""Whether ``name`` resolves to a script-local temporary table."""
|
|
334
|
+
return name.table.upper() in temp_tables and (
|
|
335
|
+
name.dataset is None or name.dataset.upper() == "_SESSION"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _comment_fragment(value: str) -> str:
|
|
340
|
+
"""Escape characters that could terminate a generated line comment."""
|
|
341
|
+
out: List[str] = []
|
|
342
|
+
for character in value:
|
|
343
|
+
code = ord(character)
|
|
344
|
+
if code < 0x20 or 0x7F <= code <= 0x9F:
|
|
345
|
+
out.append(f"\\x{code:02x}")
|
|
346
|
+
elif code in (0x2028, 0x2029) or 0xD800 <= code <= 0xDFFF:
|
|
347
|
+
out.append(f"\\u{code:04x}")
|
|
348
|
+
else:
|
|
349
|
+
out.append(character)
|
|
350
|
+
return "".join(out)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Phase 1: per-file parsing
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def parse_source(path: str, relpath: str, text: str, opts: ConversionOptions) -> ParsedFile:
|
|
359
|
+
"""Parse one SQL source into action drafts.
|
|
360
|
+
|
|
361
|
+
Never raises for bad input: lexer failures are captured on the
|
|
362
|
+
returned :class:`~sql2sqlx.model.ParsedFile` as ``error`` so a single
|
|
363
|
+
broken file cannot abort a corpus conversion.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
path: Original path (reporting only).
|
|
367
|
+
relpath: Path relative to the conversion root (layout + ordering).
|
|
368
|
+
text: Decoded file contents.
|
|
369
|
+
opts: Conversion options.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The parsed file.
|
|
373
|
+
"""
|
|
374
|
+
input_bytes = len(text.encode("utf-8", "replace"))
|
|
375
|
+
if text.startswith("\ufeff"):
|
|
376
|
+
text = text[1:]
|
|
377
|
+
spans: List[Tuple[int, int]] = []
|
|
378
|
+
try:
|
|
379
|
+
tokens = tokenize(text, comment_spans_out=spans)
|
|
380
|
+
except LexError as exc:
|
|
381
|
+
return ParsedFile(path, relpath, text, error=str(exc), input_bytes=input_bytes)
|
|
382
|
+
statement_start_offsets: Set[int] = set()
|
|
383
|
+
stmts = split_statements(tokens, statement_start_offsets)
|
|
384
|
+
parsed = ParsedFile(
|
|
385
|
+
path, relpath, text, statements=len(stmts), comment_spans=spans, input_bytes=input_bytes
|
|
386
|
+
)
|
|
387
|
+
if not stmts:
|
|
388
|
+
return parsed
|
|
389
|
+
line_index = LineIndex(text)
|
|
390
|
+
script_reasons = _script_reasons(stmts)
|
|
391
|
+
if script_reasons:
|
|
392
|
+
scans: List[List[Tuple[TableName, Tuple[int, int], bool]]] = []
|
|
393
|
+
statement_warnings: List[Tuple[str, str, int]] = []
|
|
394
|
+
for statement in stmts:
|
|
395
|
+
entity = ""
|
|
396
|
+
entity_index = -1
|
|
397
|
+
temporary = False
|
|
398
|
+
if _statement_head(statement.tokens) == "CREATE":
|
|
399
|
+
entity_index, temporary, entity = _create_prefix(statement.tokens)
|
|
400
|
+
if (
|
|
401
|
+
temporary
|
|
402
|
+
and entity == "TABLE"
|
|
403
|
+
and _word(statement.tokens, entity_index + 1) != "FUNCTION"
|
|
404
|
+
):
|
|
405
|
+
statement_warnings.append(
|
|
406
|
+
(
|
|
407
|
+
"TEMP_TABLE",
|
|
408
|
+
"Temporary objects have no standalone Dataform action "
|
|
409
|
+
"equivalent; preserved inside the whole-file script.",
|
|
410
|
+
statement.start,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
if entity == "PROCEDURE":
|
|
414
|
+
statement_warnings.append(
|
|
415
|
+
(
|
|
416
|
+
"PROCEDURE_PRESERVED",
|
|
417
|
+
"Stored procedure definition kept verbatim; its body is "
|
|
418
|
+
"not linked as an immediately executed workflow "
|
|
419
|
+
"dependency.",
|
|
420
|
+
statement.start,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
for index, token in enumerate(statement.tokens):
|
|
425
|
+
is_dynamic = (
|
|
426
|
+
token.start in statement_start_offsets
|
|
427
|
+
and token.kind == IDENT
|
|
428
|
+
and (
|
|
429
|
+
token.upper == "CALL"
|
|
430
|
+
or (
|
|
431
|
+
token.upper == "EXECUTE"
|
|
432
|
+
and _word(statement.tokens, index + 1) == "IMMEDIATE"
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
if is_dynamic:
|
|
437
|
+
statement_warnings.append(
|
|
438
|
+
(
|
|
439
|
+
"DYNAMIC_SIDE_EFFECTS",
|
|
440
|
+
"Called procedures and dynamic SQL can hide table "
|
|
441
|
+
"reads or writes; kept verbatim, but review manual "
|
|
442
|
+
"dependencies.",
|
|
443
|
+
token.start,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
break
|
|
447
|
+
# A procedure body is defined, not executed, by CREATE PROCEDURE;
|
|
448
|
+
# its internal DML must not be registered as an immediate write.
|
|
449
|
+
scans.append(
|
|
450
|
+
[]
|
|
451
|
+
if entity == "PROCEDURE"
|
|
452
|
+
else _scan_script_writes(
|
|
453
|
+
statement.tokens,
|
|
454
|
+
statement_start_offsets,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
temp_tables = {
|
|
458
|
+
name.table.upper()
|
|
459
|
+
for statement_writes in scans
|
|
460
|
+
for name, _span, temporary in statement_writes
|
|
461
|
+
if temporary
|
|
462
|
+
}
|
|
463
|
+
sites: List[RefSite] = []
|
|
464
|
+
writes: List[TableName] = []
|
|
465
|
+
for statement, statement_writes in zip(stmts, scans):
|
|
466
|
+
spans_to_skip = [span for _name, span, _temporary in statement_writes]
|
|
467
|
+
entity = ""
|
|
468
|
+
if _statement_head(statement.tokens) == "CREATE":
|
|
469
|
+
_, _, entity = _create_prefix(statement.tokens)
|
|
470
|
+
if entity != "PROCEDURE":
|
|
471
|
+
found = scan_ref_sites(
|
|
472
|
+
statement.tokens,
|
|
473
|
+
_statement_head(statement.tokens) or "SCRIPT",
|
|
474
|
+
skip_spans=spans_to_skip,
|
|
475
|
+
statement_start_offsets=statement_start_offsets,
|
|
476
|
+
)
|
|
477
|
+
found.extend(
|
|
478
|
+
_scan_script_ddl_reads(
|
|
479
|
+
statement.tokens,
|
|
480
|
+
statement_start_offsets,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
seen_spans: Set[Tuple[int, int]] = set()
|
|
484
|
+
for site in found:
|
|
485
|
+
span = (site.start, site.end)
|
|
486
|
+
if _temporary_name(site.name, temp_tables) or span in seen_spans:
|
|
487
|
+
continue
|
|
488
|
+
seen_spans.add(span)
|
|
489
|
+
sites.append(site)
|
|
490
|
+
for name, _span, temporary in statement_writes:
|
|
491
|
+
if not temporary and not _temporary_name(name, temp_tables):
|
|
492
|
+
writes.append(name)
|
|
493
|
+
draft = ActionDraft(
|
|
494
|
+
action_type=ActionType.OPERATIONS,
|
|
495
|
+
target=None,
|
|
496
|
+
writes_target=False,
|
|
497
|
+
creates_target=False,
|
|
498
|
+
body_start=stmts[0].start,
|
|
499
|
+
body_end=stmts[-1].end,
|
|
500
|
+
stmt_start=stmts[0].start,
|
|
501
|
+
stmt_end=stmts[-1].terminator_end,
|
|
502
|
+
extra_write_targets=writes,
|
|
503
|
+
ref_sites=sites,
|
|
504
|
+
sqlx_escape_edits=[
|
|
505
|
+
edit for statement in stmts for edit in sqlx_escape_edits(statement.tokens)
|
|
506
|
+
],
|
|
507
|
+
source_line=line_index.locate(stmts[0].start)[0],
|
|
508
|
+
original_kind="SCRIPT",
|
|
509
|
+
script=True,
|
|
510
|
+
warnings=[
|
|
511
|
+
(
|
|
512
|
+
"SCRIPT_FILE",
|
|
513
|
+
"File requires shared BigQuery script context "
|
|
514
|
+
f"({', '.join(script_reasons)}); the whole file was kept "
|
|
515
|
+
"as one operations action so statement order and scope are "
|
|
516
|
+
"preserved.",
|
|
517
|
+
stmts[0].start,
|
|
518
|
+
)
|
|
519
|
+
]
|
|
520
|
+
+ statement_warnings,
|
|
521
|
+
)
|
|
522
|
+
if writes:
|
|
523
|
+
names = ", ".join(sorted({t.display() for t in writes}))
|
|
524
|
+
draft.warnings.append(
|
|
525
|
+
(
|
|
526
|
+
"SCRIPT_WRITES",
|
|
527
|
+
f"Script writes: {names}. Downstream readers are ordered "
|
|
528
|
+
"after this script; ordering between multiple writers of "
|
|
529
|
+
"the same table follows corpus order.",
|
|
530
|
+
stmts[0].start,
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
parsed.drafts.append(draft)
|
|
534
|
+
return parsed
|
|
535
|
+
for s in stmts:
|
|
536
|
+
draft = classify_statement(s, text, opts)
|
|
537
|
+
draft.sqlx_escape_edits = sqlx_escape_edits(s.tokens)
|
|
538
|
+
draft.source_line = line_index.locate(s.start)[0]
|
|
539
|
+
parsed.drafts.append(draft)
|
|
540
|
+
return parsed
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _parse_worker(job: Tuple[str, str, str, ConversionOptions]) -> ParsedFile:
|
|
544
|
+
"""Process-pool entry point: read and parse one file.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
job: ``(path, relpath, encoding, options)``.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
The :class:`~sql2sqlx.model.ParsedFile` (with ``error`` set on
|
|
551
|
+
read/decode failure).
|
|
552
|
+
"""
|
|
553
|
+
path, relpath, encoding, opts = job
|
|
554
|
+
input_bytes = 0
|
|
555
|
+
try:
|
|
556
|
+
with open(path, "r", encoding=encoding, newline="") as fh:
|
|
557
|
+
input_bytes = os.fstat(fh.fileno()).st_size
|
|
558
|
+
text = fh.read()
|
|
559
|
+
except (OSError, UnicodeError, LookupError) as exc:
|
|
560
|
+
return ParsedFile(path, relpath, "", error=str(exc), input_bytes=input_bytes)
|
|
561
|
+
parsed = parse_source(path, relpath, text, opts)
|
|
562
|
+
parsed.input_bytes = input_bytes
|
|
563
|
+
return parsed
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
# Phase 2 + 3: linking and emission
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class _Linker:
|
|
572
|
+
"""Cross-file resolution, dependency wiring, naming, and emission."""
|
|
573
|
+
|
|
574
|
+
def __init__(
|
|
575
|
+
self, files: List[ParsedFile], opts: ConversionOptions, report: ConversionReport
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Initialize with parsed files (failed files excluded upstream).
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
files: Successfully parsed files.
|
|
581
|
+
opts: Conversion options.
|
|
582
|
+
report: Report to accumulate findings into.
|
|
583
|
+
"""
|
|
584
|
+
self.files = sorted(files, key=lambda f: f.relpath)
|
|
585
|
+
self.opts = opts
|
|
586
|
+
self.report = report
|
|
587
|
+
self.ordered: List[Tuple[ParsedFile, ActionDraft]] = [
|
|
588
|
+
(f, d) for f in self.files for d in f.drafts
|
|
589
|
+
]
|
|
590
|
+
self.creators: Dict[TableKey, Tuple[ParsedFile, ActionDraft]] = {}
|
|
591
|
+
self.chains: Dict[TableKey, List[Tuple[ParsedFile, ActionDraft]]] = {}
|
|
592
|
+
self.names: Dict[int, str] = {}
|
|
593
|
+
self.dep_refs: Dict[int, DependencyRef] = {}
|
|
594
|
+
self.used_names: Set[str] = set()
|
|
595
|
+
self._next_suffix: Dict[str, int] = {}
|
|
596
|
+
self._next_path_suffix: Dict[str, int] = {}
|
|
597
|
+
self._line_cache: Dict[str, LineIndex] = {}
|
|
598
|
+
self._unresolved: Set[str] = set()
|
|
599
|
+
self._order_index = {id(d): i for i, (_f, d) in enumerate(self.ordered)}
|
|
600
|
+
self._dependency_graph: Dict[int, Set[int]] = {}
|
|
601
|
+
self._dependency_targets: Set[int] = set()
|
|
602
|
+
self._cycle_warnings: Set[Tuple[int, int]] = set()
|
|
603
|
+
self._chain_orders: Dict[TableKey, List[int]] = {}
|
|
604
|
+
self._chain_positions: Dict[Tuple[TableKey, int], int] = {}
|
|
605
|
+
self._access_relpath: Optional[str] = None
|
|
606
|
+
self._readers_since_write: Dict[TableKey, List[ActionDraft]] = {}
|
|
607
|
+
|
|
608
|
+
# -- helpers ------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
def _resolve(self, name: TableName) -> TableKey:
|
|
611
|
+
"""Resolve a table name against defaults; return its identity key."""
|
|
612
|
+
return name.resolve(self.opts.default_project, self.opts.default_dataset).key()
|
|
613
|
+
|
|
614
|
+
def _warn(self, f: ParsedFile, d: Optional[ActionDraft], code: str, message: str) -> None:
|
|
615
|
+
"""Record a linker-stage warning against a file/draft."""
|
|
616
|
+
line = d.source_line if d is not None else 0
|
|
617
|
+
self.report.warnings.append(ReportWarning(code, message, f.relpath, line))
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _key_display(key: TableKey) -> str:
|
|
621
|
+
"""Dotted display form of a resolved key."""
|
|
622
|
+
return ".".join(p for p in key if p)
|
|
623
|
+
|
|
624
|
+
def _lines(self, f: ParsedFile) -> LineIndex:
|
|
625
|
+
"""Cached :class:`LineIndex` for a file."""
|
|
626
|
+
idx = self._line_cache.get(f.relpath)
|
|
627
|
+
if idx is None:
|
|
628
|
+
idx = LineIndex(f.text)
|
|
629
|
+
self._line_cache[f.relpath] = idx
|
|
630
|
+
return idx
|
|
631
|
+
|
|
632
|
+
def _comments(self, f: ParsedFile) -> List[Tuple[int, int]]:
|
|
633
|
+
"""Comment spans captured during the file's single lexing pass."""
|
|
634
|
+
return f.comment_spans
|
|
635
|
+
|
|
636
|
+
def _add_dependency_edge(
|
|
637
|
+
self, f: ParsedFile, d: ActionDraft, dependency: ActionDraft, reason: str
|
|
638
|
+
) -> bool:
|
|
639
|
+
"""Add an action dependency unless it would create a DAG cycle."""
|
|
640
|
+
source_id = id(d)
|
|
641
|
+
dependency_id = id(dependency)
|
|
642
|
+
if source_id == dependency_id:
|
|
643
|
+
return False
|
|
644
|
+
edges = self._dependency_graph.setdefault(source_id, set())
|
|
645
|
+
if dependency_id in edges:
|
|
646
|
+
return True
|
|
647
|
+
# A path can reach the source only when some existing edge targets
|
|
648
|
+
# it. This O(1) guard keeps long writer chains linear while retaining
|
|
649
|
+
# a full reachability check for forward cross-file references.
|
|
650
|
+
if source_id in self._dependency_targets:
|
|
651
|
+
stack = [dependency_id]
|
|
652
|
+
seen: Set[int] = set()
|
|
653
|
+
while stack:
|
|
654
|
+
node = stack.pop()
|
|
655
|
+
if node == source_id:
|
|
656
|
+
pair = (source_id, dependency_id)
|
|
657
|
+
if pair not in self._cycle_warnings:
|
|
658
|
+
self._cycle_warnings.add(pair)
|
|
659
|
+
self._warn(
|
|
660
|
+
f,
|
|
661
|
+
d,
|
|
662
|
+
"DEPENDENCY_CYCLE",
|
|
663
|
+
f"{reason} was left literal/implicit because adding "
|
|
664
|
+
f"a dependency from {self.names[source_id]!r} to "
|
|
665
|
+
f"{self.names[dependency_id]!r} would create a "
|
|
666
|
+
"Dataform cycle.",
|
|
667
|
+
)
|
|
668
|
+
return False
|
|
669
|
+
if node in seen:
|
|
670
|
+
continue
|
|
671
|
+
seen.add(node)
|
|
672
|
+
stack.extend(self._dependency_graph.get(node, ()))
|
|
673
|
+
edges.add(dependency_id)
|
|
674
|
+
self._dependency_targets.add(dependency_id)
|
|
675
|
+
return True
|
|
676
|
+
|
|
677
|
+
def _latest_preceding_writer(
|
|
678
|
+
self,
|
|
679
|
+
key: TableKey,
|
|
680
|
+
current: ActionDraft,
|
|
681
|
+
) -> Optional[ActionDraft]:
|
|
682
|
+
"""Return the last writer before ``current`` in corpus order."""
|
|
683
|
+
current_index = self._order_index[id(current)]
|
|
684
|
+
orders = self._chain_orders.get(key, [])
|
|
685
|
+
position = bisect.bisect_left(orders, current_index) - 1
|
|
686
|
+
if position < 0:
|
|
687
|
+
return None
|
|
688
|
+
return self.chains[key][position][1]
|
|
689
|
+
|
|
690
|
+
# -- pipeline stages ------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
def run(self) -> List[SqlxFile]:
|
|
693
|
+
"""Execute all linking stages and emit every ``.sqlx`` file."""
|
|
694
|
+
self._collect_creators()
|
|
695
|
+
self._build_chains()
|
|
696
|
+
self._elect_outputs()
|
|
697
|
+
self._assign_names()
|
|
698
|
+
files = self._emit_all()
|
|
699
|
+
self.report.refs_unresolved = sorted(self._unresolved)
|
|
700
|
+
return files
|
|
701
|
+
|
|
702
|
+
def _collect_creators(self) -> None:
|
|
703
|
+
"""Register one creator per produced table; demote duplicates."""
|
|
704
|
+
for f, d in self.ordered:
|
|
705
|
+
if d.target is None or not d.creates_target:
|
|
706
|
+
continue
|
|
707
|
+
key = self._resolve(d.target)
|
|
708
|
+
if key in self.creators:
|
|
709
|
+
self._demote(f, d)
|
|
710
|
+
else:
|
|
711
|
+
self.creators[key] = (f, d)
|
|
712
|
+
|
|
713
|
+
def _demote(self, f: ParsedFile, d: ActionDraft) -> None:
|
|
714
|
+
"""Turn a duplicate creator back into a verbatim operations draft."""
|
|
715
|
+
assert d.target is not None
|
|
716
|
+
self._warn(
|
|
717
|
+
f,
|
|
718
|
+
d,
|
|
719
|
+
"DUPLICATE_TARGET",
|
|
720
|
+
f"{d.original_kind} for {d.target.display()} demoted to "
|
|
721
|
+
"operations: another statement already produces this "
|
|
722
|
+
"table and Dataform allows exactly one owner per target. "
|
|
723
|
+
"The statement runs verbatim, ordered after the owner.",
|
|
724
|
+
)
|
|
725
|
+
d.action_type = ActionType.OPERATIONS
|
|
726
|
+
d.creates_target = False
|
|
727
|
+
d.writes_target = True
|
|
728
|
+
d.body_start = d.stmt_start
|
|
729
|
+
d.body_end = d.stmt_end
|
|
730
|
+
d.edits = []
|
|
731
|
+
d.config = {}
|
|
732
|
+
|
|
733
|
+
def _build_chains(self) -> None:
|
|
734
|
+
"""Group creators and writers per table, in corpus order."""
|
|
735
|
+
for f, d in self.ordered:
|
|
736
|
+
keys: List[TableKey] = []
|
|
737
|
+
if d.target is not None and (d.creates_target or d.writes_target):
|
|
738
|
+
keys.append(self._resolve(d.target))
|
|
739
|
+
for extra in d.extra_write_targets:
|
|
740
|
+
key = self._resolve(extra)
|
|
741
|
+
if key not in keys:
|
|
742
|
+
keys.append(key)
|
|
743
|
+
for key in keys:
|
|
744
|
+
self.chains.setdefault(key, []).append((f, d))
|
|
745
|
+
for key, chain in self.chains.items():
|
|
746
|
+
self._chain_orders[key] = [self._order_index[id(draft)] for _file, draft in chain]
|
|
747
|
+
for position, (_file, draft) in enumerate(chain):
|
|
748
|
+
self._chain_positions[(key, id(draft))] = position
|
|
749
|
+
rels = {cf.relpath for cf, _ in chain}
|
|
750
|
+
if len(chain) > 1 and len(rels) > 1:
|
|
751
|
+
self.report.warnings.append(
|
|
752
|
+
ReportWarning(
|
|
753
|
+
"ORDER_ASSUMED",
|
|
754
|
+
f"Table {self._key_display(key)} is written by "
|
|
755
|
+
f"statements in multiple files ({', '.join(sorted(rels))}); "
|
|
756
|
+
"their relative execution order was inferred from sorted "
|
|
757
|
+
"file paths - verify the generated dependency chain.",
|
|
758
|
+
sorted(rels)[0],
|
|
759
|
+
0,
|
|
760
|
+
)
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def _elect_outputs(self) -> None:
|
|
764
|
+
"""Give ``hasOutput`` to the first eligible writer of ownerless tables."""
|
|
765
|
+
for key, chain in self.chains.items():
|
|
766
|
+
if key in self.creators:
|
|
767
|
+
continue
|
|
768
|
+
for f, d in chain:
|
|
769
|
+
if d.script or d.target is None:
|
|
770
|
+
continue
|
|
771
|
+
if self._resolve(d.target) != key:
|
|
772
|
+
continue
|
|
773
|
+
if d.original_kind in _ELECTABLE and d.primary_target_span is not None:
|
|
774
|
+
d.config["hasOutput"] = True
|
|
775
|
+
d.edits.append(
|
|
776
|
+
(d.primary_target_span[0], d.primary_target_span[1], "${self()}")
|
|
777
|
+
)
|
|
778
|
+
self.creators[key] = (f, d)
|
|
779
|
+
break
|
|
780
|
+
|
|
781
|
+
def _assign_names(self) -> None:
|
|
782
|
+
"""Assign unique action names and dependency strings."""
|
|
783
|
+
for _f, d in self.ordered:
|
|
784
|
+
if d.target is None or not (d.creates_target or d.config.get("hasOutput")):
|
|
785
|
+
continue
|
|
786
|
+
ident = id(d)
|
|
787
|
+
name = d.target.table
|
|
788
|
+
self.names[ident] = name
|
|
789
|
+
if d.target.project:
|
|
790
|
+
dep = ".".join((d.target.project, d.target.dataset or "", name))
|
|
791
|
+
else:
|
|
792
|
+
dep = f"{d.target.dataset}.{name}" if d.target.dataset else name
|
|
793
|
+
self.dep_refs[ident] = dep
|
|
794
|
+
self.used_names.add(name)
|
|
795
|
+
for f, d in self.ordered:
|
|
796
|
+
ident = id(d)
|
|
797
|
+
if ident in self.names:
|
|
798
|
+
continue
|
|
799
|
+
if d.script or d.target is None:
|
|
800
|
+
base = sanitize_filename(PurePosixPath(f.relpath).stem)
|
|
801
|
+
else:
|
|
802
|
+
verb = d.original_kind.split()[0].lower()
|
|
803
|
+
base = sanitize_filename(f"{d.target.table}_{verb}")
|
|
804
|
+
name = self._uniq(base)
|
|
805
|
+
self.names[ident] = name
|
|
806
|
+
self.dep_refs[ident] = name
|
|
807
|
+
|
|
808
|
+
def _uniq(self, base: str) -> str:
|
|
809
|
+
"""Return ``base`` (or ``base_N``) unused by any other action."""
|
|
810
|
+
if base not in self.used_names:
|
|
811
|
+
self.used_names.add(base)
|
|
812
|
+
self._next_suffix.setdefault(base, 2)
|
|
813
|
+
return base
|
|
814
|
+
counter = self._next_suffix.get(base, 2)
|
|
815
|
+
name = f"{base}_{counter}"
|
|
816
|
+
while name in self.used_names:
|
|
817
|
+
counter += 1
|
|
818
|
+
name = f"{base}_{counter}"
|
|
819
|
+
self._next_suffix[base] = counter + 1
|
|
820
|
+
self.used_names.add(name)
|
|
821
|
+
return name
|
|
822
|
+
|
|
823
|
+
# -- emission --------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
def _emit_all(self) -> List[SqlxFile]:
|
|
826
|
+
"""Emit every draft plus synthesized external declarations."""
|
|
827
|
+
out: List[SqlxFile] = []
|
|
828
|
+
declared: Dict[TableKey, TableName] = {}
|
|
829
|
+
paths: Set[str] = set()
|
|
830
|
+
last_drafts = {id(f.drafts[-1]) for f in self.files if f.drafts}
|
|
831
|
+
current_rel = None
|
|
832
|
+
prev_end = 0
|
|
833
|
+
for f, d in self.ordered:
|
|
834
|
+
if f.relpath != current_rel:
|
|
835
|
+
current_rel = f.relpath
|
|
836
|
+
prev_end = 0
|
|
837
|
+
out.append(self._emit_draft(f, d, declared, paths, prev_end, id(d) in last_drafts))
|
|
838
|
+
prev_end = d.stmt_end
|
|
839
|
+
tally = self.report.actions_by_type
|
|
840
|
+
tally[d.action_type.value] = tally.get(d.action_type.value, 0) + 1
|
|
841
|
+
for key in sorted(declared, key=lambda k: (k[0] or "", k[1] or "", k[2])):
|
|
842
|
+
name = declared[key]
|
|
843
|
+
config: Dict[str, Any] = {"type": ActionType.DECLARATION.value}
|
|
844
|
+
if name.project:
|
|
845
|
+
config["database"] = name.project
|
|
846
|
+
if name.dataset:
|
|
847
|
+
config["schema"] = name.dataset
|
|
848
|
+
config["name"] = name.table
|
|
849
|
+
rel = self._dedupe_path(f"sources/{sanitize_filename(name.table)}.sqlx", paths)
|
|
850
|
+
out.append(SqlxFile(rel, build_sqlx(config), ActionType.DECLARATION, name.table))
|
|
851
|
+
tally = self.report.actions_by_type
|
|
852
|
+
key_name = ActionType.DECLARATION.value
|
|
853
|
+
tally[key_name] = tally.get(key_name, 0) + 1
|
|
854
|
+
out.sort(key=lambda s: s.relpath)
|
|
855
|
+
return out
|
|
856
|
+
|
|
857
|
+
def _dedupe_path(self, rel: str, paths: Set[str]) -> str:
|
|
858
|
+
"""Ensure a portable output path, suffixing ``_N`` when needed."""
|
|
859
|
+
base_key = rel.casefold()
|
|
860
|
+
if base_key not in paths:
|
|
861
|
+
paths.add(base_key)
|
|
862
|
+
self._next_path_suffix.setdefault(base_key, 2)
|
|
863
|
+
return rel
|
|
864
|
+
counter = self._next_path_suffix.get(base_key, 2)
|
|
865
|
+
candidate = f"{rel[:-5]}_{counter}.sqlx"
|
|
866
|
+
while candidate.casefold() in paths:
|
|
867
|
+
counter += 1
|
|
868
|
+
candidate = f"{rel[:-5]}_{counter}.sqlx"
|
|
869
|
+
self._next_path_suffix[base_key] = counter + 1
|
|
870
|
+
paths.add(candidate.casefold())
|
|
871
|
+
return candidate
|
|
872
|
+
|
|
873
|
+
def _declarable(self, name: TableName) -> bool:
|
|
874
|
+
"""Whether an unresolved reference may become a declaration."""
|
|
875
|
+
if name.dataset is None:
|
|
876
|
+
return False
|
|
877
|
+
parts = [p for p in (name.project, name.dataset, name.table) if p]
|
|
878
|
+
if any(p.upper() == "INFORMATION_SCHEMA" for p in parts):
|
|
879
|
+
return False
|
|
880
|
+
if any(p.lower().startswith("region-") for p in parts):
|
|
881
|
+
return False
|
|
882
|
+
# Wildcard tables and partition/time decorators are table
|
|
883
|
+
# expressions, not standalone relations that Dataform can declare.
|
|
884
|
+
return not any(marker in name.table for marker in ("*", "$", "@"))
|
|
885
|
+
|
|
886
|
+
def _emit_draft(
|
|
887
|
+
self,
|
|
888
|
+
f: ParsedFile,
|
|
889
|
+
d: ActionDraft,
|
|
890
|
+
declared: Dict[TableKey, TableName],
|
|
891
|
+
paths: Set[str],
|
|
892
|
+
prev_end: int,
|
|
893
|
+
is_last_in_file: bool,
|
|
894
|
+
) -> SqlxFile:
|
|
895
|
+
"""Link and render one draft into a :class:`SqlxFile`."""
|
|
896
|
+
deps: List[DependencyRef] = []
|
|
897
|
+
if self._access_relpath != f.relpath:
|
|
898
|
+
self._access_relpath = f.relpath
|
|
899
|
+
self._readers_since_write.clear()
|
|
900
|
+
own_keys: Set[TableKey] = set()
|
|
901
|
+
if d.target is not None and (d.creates_target or d.writes_target):
|
|
902
|
+
own_keys.add(self._resolve(d.target))
|
|
903
|
+
for extra in d.extra_write_targets:
|
|
904
|
+
own_keys.add(self._resolve(extra))
|
|
905
|
+
read_keys = {self._resolve(site.name) for site in d.ref_sites} - own_keys
|
|
906
|
+
# Preserve read-before-write order within a source file. A reader
|
|
907
|
+
# already depends on its latest preceding writer (RAW); the inverse
|
|
908
|
+
# edge below prevents a later mutation from racing ahead of readers
|
|
909
|
+
# that occur between two writes (WAR).
|
|
910
|
+
for key in own_keys:
|
|
911
|
+
for reader in self._readers_since_write.get(key, []):
|
|
912
|
+
if self._add_dependency_edge(
|
|
913
|
+
f,
|
|
914
|
+
d,
|
|
915
|
+
reader,
|
|
916
|
+
f"Read-before-write ordering for {self._key_display(key)}",
|
|
917
|
+
):
|
|
918
|
+
deps.append(self.dep_refs[id(reader)])
|
|
919
|
+
self._readers_since_write[key] = []
|
|
920
|
+
# Chain predecessor dependencies (write ordering).
|
|
921
|
+
for key in own_keys:
|
|
922
|
+
chain = self.chains.get(key) or []
|
|
923
|
+
position = self._chain_positions.get((key, id(d)))
|
|
924
|
+
if position is not None and position > 0:
|
|
925
|
+
predecessor = chain[position - 1][1]
|
|
926
|
+
if self._add_dependency_edge(
|
|
927
|
+
f,
|
|
928
|
+
d,
|
|
929
|
+
predecessor,
|
|
930
|
+
f"Writer ordering for {self._key_display(key)}",
|
|
931
|
+
):
|
|
932
|
+
deps.append(self.dep_refs[id(predecessor)])
|
|
933
|
+
# Reference rewriting + latest-preceding-writer dependencies. A
|
|
934
|
+
# future mutation or creator must never be pulled before an earlier
|
|
935
|
+
# reader merely to satisfy a generated dependency.
|
|
936
|
+
self_warned = False
|
|
937
|
+
future_warned: Set[TableKey] = set()
|
|
938
|
+
for site in d.ref_sites:
|
|
939
|
+
key = self._resolve(site.name)
|
|
940
|
+
if key in own_keys:
|
|
941
|
+
if d.creates_target and not self_warned:
|
|
942
|
+
assert d.target is not None
|
|
943
|
+
self._warn(
|
|
944
|
+
f,
|
|
945
|
+
d,
|
|
946
|
+
"SELF_REFERENCE",
|
|
947
|
+
f"{d.target.display()} reads itself inside "
|
|
948
|
+
"its own defining query; the reference was "
|
|
949
|
+
"left as a literal (Dataform cannot ref an "
|
|
950
|
+
"action into itself).",
|
|
951
|
+
)
|
|
952
|
+
self_warned = True
|
|
953
|
+
continue
|
|
954
|
+
entry = self.creators.get(key)
|
|
955
|
+
preceding_writer = self._latest_preceding_writer(key, d)
|
|
956
|
+
if entry is None:
|
|
957
|
+
resolved_name = site.name.resolve(
|
|
958
|
+
self.opts.default_project, self.opts.default_dataset
|
|
959
|
+
)
|
|
960
|
+
if self.opts.declare_external and self._declarable(resolved_name):
|
|
961
|
+
declared.setdefault(key, resolved_name)
|
|
962
|
+
d.edits.append((site.start, site.end, ref_expr(declared[key])))
|
|
963
|
+
self.report.refs_rewritten += 1
|
|
964
|
+
elif resolved_name.dataset is not None:
|
|
965
|
+
self._unresolved.add(resolved_name.display())
|
|
966
|
+
# Even without a ref-able creator (for example an existing
|
|
967
|
+
# table mutated by a script), order the reader after the most
|
|
968
|
+
# recent write that precedes it in corpus order.
|
|
969
|
+
if preceding_writer is not None and self._add_dependency_edge(
|
|
970
|
+
f,
|
|
971
|
+
d,
|
|
972
|
+
preceding_writer,
|
|
973
|
+
f"Read ordering for {self._key_display(key)}",
|
|
974
|
+
):
|
|
975
|
+
deps.append(self.dep_refs[id(preceding_writer)])
|
|
976
|
+
continue
|
|
977
|
+
creator_file, creator = entry
|
|
978
|
+
if creator is d:
|
|
979
|
+
continue
|
|
980
|
+
assert creator.target is not None
|
|
981
|
+
if creator_file is f and self._order_index[id(creator)] > self._order_index[id(d)]:
|
|
982
|
+
if key not in future_warned:
|
|
983
|
+
future_warned.add(key)
|
|
984
|
+
self._warn(
|
|
985
|
+
f,
|
|
986
|
+
d,
|
|
987
|
+
"FUTURE_CREATOR",
|
|
988
|
+
f"Reference to {self._key_display(key)} was left "
|
|
989
|
+
"literal because its Dataform owner occurs later in "
|
|
990
|
+
"corpus order; generating ref() would move that future "
|
|
991
|
+
"creator ahead of this read.",
|
|
992
|
+
)
|
|
993
|
+
if preceding_writer is not None and self._add_dependency_edge(
|
|
994
|
+
f,
|
|
995
|
+
d,
|
|
996
|
+
preceding_writer,
|
|
997
|
+
f"Read ordering for {self._key_display(key)}",
|
|
998
|
+
):
|
|
999
|
+
deps.append(self.dep_refs[id(preceding_writer)])
|
|
1000
|
+
continue
|
|
1001
|
+
if not self._add_dependency_edge(
|
|
1002
|
+
f,
|
|
1003
|
+
d,
|
|
1004
|
+
creator,
|
|
1005
|
+
f"Reference to {self._key_display(key)}",
|
|
1006
|
+
):
|
|
1007
|
+
continue
|
|
1008
|
+
d.edits.append((site.start, site.end, ref_expr(creator.target)))
|
|
1009
|
+
self.report.refs_rewritten += 1
|
|
1010
|
+
if (
|
|
1011
|
+
preceding_writer is not None
|
|
1012
|
+
and preceding_writer is not creator
|
|
1013
|
+
and self._add_dependency_edge(
|
|
1014
|
+
f,
|
|
1015
|
+
d,
|
|
1016
|
+
preceding_writer,
|
|
1017
|
+
f"Read ordering for {self._key_display(key)}",
|
|
1018
|
+
)
|
|
1019
|
+
):
|
|
1020
|
+
deps.append(self.dep_refs[id(preceding_writer)])
|
|
1021
|
+
|
|
1022
|
+
for key in read_keys:
|
|
1023
|
+
readers = self._readers_since_write.setdefault(key, [])
|
|
1024
|
+
if not any(reader is d for reader in readers):
|
|
1025
|
+
readers.append(d)
|
|
1026
|
+
|
|
1027
|
+
config: Dict[str, Any] = {"type": d.action_type.value}
|
|
1028
|
+
named = d.target is not None and (d.creates_target or d.config.get("hasOutput"))
|
|
1029
|
+
if named:
|
|
1030
|
+
assert d.target is not None
|
|
1031
|
+
if d.target.project:
|
|
1032
|
+
config["database"] = d.target.project
|
|
1033
|
+
if d.target.dataset:
|
|
1034
|
+
config["schema"] = d.target.dataset
|
|
1035
|
+
config["name"] = d.target.table
|
|
1036
|
+
else:
|
|
1037
|
+
config["name"] = self.names[id(d)]
|
|
1038
|
+
config.update(d.config)
|
|
1039
|
+
unique_deps: List[DependencyRef] = []
|
|
1040
|
+
seen_deps: Set[DependencyRef] = set()
|
|
1041
|
+
for dep in deps:
|
|
1042
|
+
if dep in seen_deps:
|
|
1043
|
+
continue
|
|
1044
|
+
seen_deps.add(dep)
|
|
1045
|
+
unique_deps.append(dep)
|
|
1046
|
+
if unique_deps:
|
|
1047
|
+
config["dependencies"] = unique_deps
|
|
1048
|
+
if self.opts.tags:
|
|
1049
|
+
config["tags"] = list(self.opts.tags)
|
|
1050
|
+
|
|
1051
|
+
annotation = None
|
|
1052
|
+
if self.opts.annotate and d.action_type is not ActionType.DECLARATION:
|
|
1053
|
+
annotation = (
|
|
1054
|
+
f"-- source: {_comment_fragment(f.relpath)}:{d.source_line} "
|
|
1055
|
+
f"({d.original_kind} converted by sql2sqlx "
|
|
1056
|
+
f"v{__version__})"
|
|
1057
|
+
)
|
|
1058
|
+
leading = None
|
|
1059
|
+
spans = [(a, b) for a, b in self._comments(f) if prev_end <= a and b <= d.stmt_start]
|
|
1060
|
+
if spans:
|
|
1061
|
+
leading = "\n".join(f.text[a:b] for a, b in spans)
|
|
1062
|
+
trailing = None
|
|
1063
|
+
if is_last_in_file:
|
|
1064
|
+
tail_spans = [(a, b) for a, b in self._comments(f) if a >= d.stmt_end]
|
|
1065
|
+
if tail_spans:
|
|
1066
|
+
trailing = "\n".join(f.text[a:b] for a, b in tail_spans)
|
|
1067
|
+
body = None
|
|
1068
|
+
if d.action_type is not ActionType.DECLARATION:
|
|
1069
|
+
# Semantic rewrites own their complete source spans. In
|
|
1070
|
+
# particular, a ref()/self() edit can cover a multi-part path
|
|
1071
|
+
# whose first backtick token also needs literal-SQLX escaping;
|
|
1072
|
+
# applying that narrower escape first would suppress the ref.
|
|
1073
|
+
escape_edits = [
|
|
1074
|
+
escape
|
|
1075
|
+
for escape in d.sqlx_escape_edits
|
|
1076
|
+
if not any(
|
|
1077
|
+
semantic[0] < escape[1] and escape[0] < semantic[1] for semantic in d.edits
|
|
1078
|
+
)
|
|
1079
|
+
]
|
|
1080
|
+
body = apply_edits_escaped(
|
|
1081
|
+
f.text,
|
|
1082
|
+
d.body_start,
|
|
1083
|
+
d.body_end,
|
|
1084
|
+
d.edits + escape_edits,
|
|
1085
|
+
)
|
|
1086
|
+
content = build_sqlx(config, body, annotation, leading, trailing)
|
|
1087
|
+
|
|
1088
|
+
stem = sanitize_filename(
|
|
1089
|
+
d.target.table if named and d.target is not None else self.names[id(d)]
|
|
1090
|
+
)
|
|
1091
|
+
if self.opts.layout is Layout.MIRROR:
|
|
1092
|
+
parent = PurePosixPath(f.relpath).parent.as_posix()
|
|
1093
|
+
rel = f"{parent}/{stem}.sqlx" if parent != "." else f"{stem}.sqlx"
|
|
1094
|
+
else:
|
|
1095
|
+
rel = f"{stem}.sqlx"
|
|
1096
|
+
rel = self._dedupe_path(rel, paths)
|
|
1097
|
+
|
|
1098
|
+
lines = self._lines(f)
|
|
1099
|
+
for code, message, offset in d.warnings:
|
|
1100
|
+
self.report.warnings.append(
|
|
1101
|
+
ReportWarning(code, message, f.relpath, lines.locate(offset)[0])
|
|
1102
|
+
)
|
|
1103
|
+
return SqlxFile(rel, content, d.action_type, config["name"], f.relpath, d.source_line)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
# ---------------------------------------------------------------------------
|
|
1107
|
+
# Public API
|
|
1108
|
+
# ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _finalize(
|
|
1112
|
+
parsed: List[ParsedFile], opts: ConversionOptions, started: float
|
|
1113
|
+
) -> ConversionResult:
|
|
1114
|
+
"""Run the linker over parsed files and assemble the result."""
|
|
1115
|
+
report = ConversionReport()
|
|
1116
|
+
good: List[ParsedFile] = []
|
|
1117
|
+
for pf in parsed:
|
|
1118
|
+
report.files_read += 1
|
|
1119
|
+
report.statements += pf.statements
|
|
1120
|
+
report.input_bytes += pf.input_bytes
|
|
1121
|
+
if pf.error is not None:
|
|
1122
|
+
report.failures[pf.relpath] = pf.error
|
|
1123
|
+
else:
|
|
1124
|
+
good.append(pf)
|
|
1125
|
+
files = _Linker(good, opts, report).run()
|
|
1126
|
+
report.elapsed_seconds = round(time.perf_counter() - started, 3)
|
|
1127
|
+
return ConversionResult(files=files, report=report)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def convert_string(
|
|
1131
|
+
sql: str, options: Optional[ConversionOptions] = None, name: str = "input.sql"
|
|
1132
|
+
) -> ConversionResult:
|
|
1133
|
+
"""Convert a SQL string to Dataform SQLX files.
|
|
1134
|
+
|
|
1135
|
+
Args:
|
|
1136
|
+
sql: BigQuery SQL text (one or many statements).
|
|
1137
|
+
options: Conversion options (defaults used when ``None``).
|
|
1138
|
+
name: Virtual file name used for layout, ordering and reports.
|
|
1139
|
+
|
|
1140
|
+
Returns:
|
|
1141
|
+
The :class:`~sql2sqlx.model.ConversionResult`.
|
|
1142
|
+
|
|
1143
|
+
Example:
|
|
1144
|
+
>>> result = convert_string(
|
|
1145
|
+
... "CREATE TABLE ds.t AS SELECT 1 AS x;")
|
|
1146
|
+
>>> result.files[0].action_type.value
|
|
1147
|
+
'table'
|
|
1148
|
+
"""
|
|
1149
|
+
opts = options or ConversionOptions()
|
|
1150
|
+
started = time.perf_counter()
|
|
1151
|
+
parsed = [parse_source(name, name, sql, opts)]
|
|
1152
|
+
return _finalize(parsed, opts, started)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def convert_file(path: str, options: Optional[ConversionOptions] = None) -> ConversionResult:
|
|
1156
|
+
"""Convert a single ``.sql`` file.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
path: Path to the SQL file.
|
|
1160
|
+
options: Conversion options (defaults used when ``None``).
|
|
1161
|
+
|
|
1162
|
+
Returns:
|
|
1163
|
+
The :class:`~sql2sqlx.model.ConversionResult`.
|
|
1164
|
+
"""
|
|
1165
|
+
opts = options or ConversionOptions()
|
|
1166
|
+
started = time.perf_counter()
|
|
1167
|
+
p = Path(path)
|
|
1168
|
+
parsed = [_parse_worker((str(p), p.name, opts.encoding, opts))]
|
|
1169
|
+
return _finalize(parsed, opts, started)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def convert_directory(
|
|
1173
|
+
input_dir: str, output_dir: Optional[str] = None, options: Optional[ConversionOptions] = None
|
|
1174
|
+
) -> ConversionResult:
|
|
1175
|
+
"""Convert every matching SQL file under a directory tree.
|
|
1176
|
+
|
|
1177
|
+
Phase 1 (read + lex + split + classify) runs across a process pool
|
|
1178
|
+
sized by ``options.jobs`` (``0`` = one worker per CPU); linking and
|
|
1179
|
+
emission are a fast single-process metadata pass.
|
|
1180
|
+
|
|
1181
|
+
Args:
|
|
1182
|
+
input_dir: Root directory scanned recursively with
|
|
1183
|
+
``options.include_glob`` (default ``*.sql``).
|
|
1184
|
+
output_dir: When given, generated files are written beneath it
|
|
1185
|
+
(directories created as needed).
|
|
1186
|
+
options: Conversion options (defaults used when ``None``).
|
|
1187
|
+
|
|
1188
|
+
Returns:
|
|
1189
|
+
The :class:`~sql2sqlx.model.ConversionResult`; per-file failures
|
|
1190
|
+
are reported in ``result.report.failures`` without aborting.
|
|
1191
|
+
|
|
1192
|
+
Raises:
|
|
1193
|
+
NotADirectoryError: If ``input_dir`` is not a directory.
|
|
1194
|
+
"""
|
|
1195
|
+
opts = options or ConversionOptions()
|
|
1196
|
+
started = time.perf_counter()
|
|
1197
|
+
root = Path(input_dir)
|
|
1198
|
+
if not root.is_dir():
|
|
1199
|
+
raise NotADirectoryError(f"not a directory: {input_dir}")
|
|
1200
|
+
sql_files = sorted(p for p in root.rglob(opts.include_glob) if p.is_file())
|
|
1201
|
+
jobs = opts.jobs if opts.jobs > 0 else (os.cpu_count() or 1)
|
|
1202
|
+
jobs = max(1, min(jobs, len(sql_files) or 1))
|
|
1203
|
+
tasks = [(str(p), p.relative_to(root).as_posix(), opts.encoding, opts) for p in sql_files]
|
|
1204
|
+
if jobs > 1 and len(tasks) > 1:
|
|
1205
|
+
chunk = max(1, len(tasks) // (jobs * 4))
|
|
1206
|
+
with ProcessPoolExecutor(max_workers=jobs) as pool:
|
|
1207
|
+
parsed = list(pool.map(_parse_worker, tasks, chunksize=chunk))
|
|
1208
|
+
else:
|
|
1209
|
+
parsed = [_parse_worker(t) for t in tasks]
|
|
1210
|
+
result = _finalize(parsed, opts, started)
|
|
1211
|
+
if output_dir is not None:
|
|
1212
|
+
write_result(result, output_dir)
|
|
1213
|
+
return result
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def write_result(result: ConversionResult, output_dir: str) -> None:
|
|
1217
|
+
"""Write every generated file beneath ``output_dir``.
|
|
1218
|
+
|
|
1219
|
+
Args:
|
|
1220
|
+
result: A conversion result.
|
|
1221
|
+
output_dir: Destination root (created if missing).
|
|
1222
|
+
|
|
1223
|
+
Raises:
|
|
1224
|
+
ConversionError: If a generated relative path would escape the
|
|
1225
|
+
destination (including through an existing symlink).
|
|
1226
|
+
"""
|
|
1227
|
+
out_root = Path(output_dir).resolve()
|
|
1228
|
+
destinations: List[Tuple[Path, SqlxFile]] = []
|
|
1229
|
+
for sqlx in result.files:
|
|
1230
|
+
dest = (out_root / sqlx.relpath).resolve()
|
|
1231
|
+
try:
|
|
1232
|
+
dest.relative_to(out_root)
|
|
1233
|
+
except ValueError:
|
|
1234
|
+
raise ConversionError(
|
|
1235
|
+
f"generated output path escapes the destination: {sqlx.relpath!r}"
|
|
1236
|
+
) from None
|
|
1237
|
+
destinations.append((dest, sqlx))
|
|
1238
|
+
for dest, sqlx in destinations:
|
|
1239
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1240
|
+
with open(dest, "w", encoding="utf-8", newline="") as handle:
|
|
1241
|
+
handle.write(sqlx.content)
|