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/model.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
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
|
+
"""Core data model shared across all sql2sqlx stages.
|
|
8
|
+
|
|
9
|
+
Everything here is a plain, picklable dataclass so parsed results can be
|
|
10
|
+
shipped between worker processes during parallel directory conversion.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import codecs
|
|
16
|
+
import dataclasses
|
|
17
|
+
import enum
|
|
18
|
+
import hashlib
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import PurePath
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Table names
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TableName:
|
|
31
|
+
"""A (possibly partial) BigQuery table path.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
project: GCP project id, or ``None`` if the reference/target did
|
|
35
|
+
not qualify one.
|
|
36
|
+
dataset: Dataset id, or ``None`` if unqualified.
|
|
37
|
+
table: Table id (always present).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
project: Optional[str]
|
|
41
|
+
dataset: Optional[str]
|
|
42
|
+
table: str
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def from_parts(parts: List[str]) -> "TableName":
|
|
46
|
+
"""Build a :class:`TableName` from 1-3 decoded path parts.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
parts: ``[table]``, ``[dataset, table]`` or
|
|
50
|
+
``[project, dataset, table]``.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The corresponding :class:`TableName`.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If ``parts`` is empty or longer than 3.
|
|
57
|
+
"""
|
|
58
|
+
if not parts or len(parts) > 3:
|
|
59
|
+
raise ValueError(f"invalid table path parts: {parts!r}")
|
|
60
|
+
if len(parts) == 1:
|
|
61
|
+
return TableName(None, None, parts[0])
|
|
62
|
+
if len(parts) == 2:
|
|
63
|
+
return TableName(None, parts[0], parts[1])
|
|
64
|
+
return TableName(parts[0], parts[1], parts[2])
|
|
65
|
+
|
|
66
|
+
def resolve(
|
|
67
|
+
self, default_project: Optional[str], default_dataset: Optional[str]
|
|
68
|
+
) -> "TableName":
|
|
69
|
+
"""Fill missing qualifiers from defaults.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
default_project: Project to assume when unqualified.
|
|
73
|
+
default_dataset: Dataset to assume when unqualified.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A new :class:`TableName` with defaults applied (fields that
|
|
77
|
+
have no default remain ``None``).
|
|
78
|
+
"""
|
|
79
|
+
return TableName(
|
|
80
|
+
self.project or default_project,
|
|
81
|
+
self.dataset or default_dataset,
|
|
82
|
+
self.table,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def key(self) -> Tuple[Optional[str], Optional[str], str]:
|
|
86
|
+
"""Return a hashable identity key ``(project, dataset, table)``."""
|
|
87
|
+
return (self.project, self.dataset, self.table)
|
|
88
|
+
|
|
89
|
+
def display(self) -> str:
|
|
90
|
+
"""Human-readable dotted form, e.g. ``proj.ds.table`` or ``ds.table``."""
|
|
91
|
+
return ".".join(p for p in (self.project, self.dataset, self.table) if p)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Options
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class InsertStrategy(str, enum.Enum):
|
|
100
|
+
"""How ``INSERT INTO ... SELECT`` statements are converted."""
|
|
101
|
+
|
|
102
|
+
#: Convert to a Dataform ``type: "incremental"`` action (opt-in).
|
|
103
|
+
INCREMENTAL = "incremental"
|
|
104
|
+
#: Keep the statement verbatim as an ``operations`` action (default).
|
|
105
|
+
OPERATIONS = "operations"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MergeStrategy(str, enum.Enum):
|
|
109
|
+
"""How ``MERGE`` statements are converted."""
|
|
110
|
+
|
|
111
|
+
#: Keep the statement verbatim as an ``operations`` action (default;
|
|
112
|
+
#: exactly preserves semantics).
|
|
113
|
+
OPERATIONS = "operations"
|
|
114
|
+
#: Convert a shape-proven MERGE to ``type: "incremental"`` with
|
|
115
|
+
#: ``uniqueKey`` and report the required target-schema precondition;
|
|
116
|
+
#: otherwise fall back to operations.
|
|
117
|
+
INCREMENTAL_WHEN_SAFE = "incremental-when-safe"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PlainCreateStrategy(str, enum.Enum):
|
|
121
|
+
"""How ``CREATE TABLE`` *without* ``AS SELECT`` is converted."""
|
|
122
|
+
|
|
123
|
+
#: Keep the DDL verbatim as an ``operations`` action (default).
|
|
124
|
+
OPERATIONS = "operations"
|
|
125
|
+
#: Emit a Dataform ``type: "declaration"`` instead (drops the DDL;
|
|
126
|
+
#: use when such tables are externally managed sources).
|
|
127
|
+
DECLARATION = "declaration"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class IfNotExistsStrategy(str, enum.Enum):
|
|
131
|
+
"""How guarded ``CREATE TABLE/VIEW ... AS`` statements are converted."""
|
|
132
|
+
|
|
133
|
+
#: Convert to ``type: "table"`` and record a semantics warning
|
|
134
|
+
#: (Dataform always creates-or-replaces). Opt-in.
|
|
135
|
+
TABLE = "table"
|
|
136
|
+
#: Keep verbatim as ``operations`` (exact semantics; default).
|
|
137
|
+
OPERATIONS = "operations"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Layout(str, enum.Enum):
|
|
141
|
+
"""Output file layout for directory conversions."""
|
|
142
|
+
|
|
143
|
+
#: Mirror the input directory structure under the output root.
|
|
144
|
+
MIRROR = "mirror"
|
|
145
|
+
#: Write all ``.sqlx`` files directly into the output root.
|
|
146
|
+
FLAT = "flat"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class ConversionOptions:
|
|
151
|
+
"""All knobs controlling a conversion. Safe defaults preserve semantics.
|
|
152
|
+
|
|
153
|
+
Attributes:
|
|
154
|
+
default_project: Project assumed for unqualified table paths, used
|
|
155
|
+
for cross-file reference resolution and emitted as ``database``
|
|
156
|
+
only when the source SQL qualified it explicitly.
|
|
157
|
+
default_dataset: Dataset assumed for unqualified paths (also used
|
|
158
|
+
to resolve single-part references).
|
|
159
|
+
default_location: Dataform project location used only when scaffolding
|
|
160
|
+
``workflow_settings.yaml``.
|
|
161
|
+
insert_strategy: See :class:`InsertStrategy`. The semantics-preserving
|
|
162
|
+
default is ``operations``; typed incremental conversion is opt-in.
|
|
163
|
+
merge_strategy: See :class:`MergeStrategy`.
|
|
164
|
+
plain_create_strategy: See :class:`PlainCreateStrategy`.
|
|
165
|
+
if_not_exists_strategy: See :class:`IfNotExistsStrategy`. The default
|
|
166
|
+
keeps guarded table and view definitions verbatim as operations.
|
|
167
|
+
declare_external: If ``True``, generate ``type: "declaration"``
|
|
168
|
+
actions for tables that are *referenced* but never *produced*
|
|
169
|
+
by the corpus, and rewrite those references to ``ref()`` too.
|
|
170
|
+
(``INFORMATION_SCHEMA`` and ``region-*`` paths are excluded.)
|
|
171
|
+
protect_incrementals: Emit ``protected: true`` on incrementals
|
|
172
|
+
converted from ``INSERT``/``MERGE`` so an accidental
|
|
173
|
+
``--full-refresh`` cannot drop pre-existing rows.
|
|
174
|
+
annotate: Prepend a provenance comment (source file/line and the
|
|
175
|
+
original statement kind) to each generated body.
|
|
176
|
+
tags: Extra Dataform ``tags`` added to every generated action.
|
|
177
|
+
layout: See :class:`Layout`.
|
|
178
|
+
encoding: Text encoding used to read input files.
|
|
179
|
+
include_glob: Filename glob for directory scans.
|
|
180
|
+
jobs: Worker processes for directory conversion; ``0`` = auto
|
|
181
|
+
(``os.cpu_count()``), ``1`` = no multiprocessing.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
default_project: Optional[str] = None
|
|
185
|
+
default_dataset: Optional[str] = None
|
|
186
|
+
insert_strategy: InsertStrategy = InsertStrategy.OPERATIONS
|
|
187
|
+
merge_strategy: MergeStrategy = MergeStrategy.OPERATIONS
|
|
188
|
+
plain_create_strategy: PlainCreateStrategy = PlainCreateStrategy.OPERATIONS
|
|
189
|
+
if_not_exists_strategy: IfNotExistsStrategy = IfNotExistsStrategy.OPERATIONS
|
|
190
|
+
declare_external: bool = False
|
|
191
|
+
protect_incrementals: bool = True
|
|
192
|
+
annotate: bool = True
|
|
193
|
+
tags: List[str] = field(default_factory=list)
|
|
194
|
+
layout: Layout = Layout.MIRROR
|
|
195
|
+
encoding: str = "utf-8"
|
|
196
|
+
include_glob: str = "*.sql"
|
|
197
|
+
jobs: int = 0
|
|
198
|
+
default_location: str = "US"
|
|
199
|
+
|
|
200
|
+
def __post_init__(self) -> None:
|
|
201
|
+
"""Normalize enum-like values and reject invalid runtime options.
|
|
202
|
+
|
|
203
|
+
The public Python API is often populated from JSON or configuration
|
|
204
|
+
files, so accepting the documented string enum values is useful. A
|
|
205
|
+
silent string/enum mismatch is not: the classifier compares enum
|
|
206
|
+
members and would otherwise select the wrong strategy.
|
|
207
|
+
"""
|
|
208
|
+
self.insert_strategy = InsertStrategy(self.insert_strategy)
|
|
209
|
+
self.merge_strategy = MergeStrategy(self.merge_strategy)
|
|
210
|
+
self.plain_create_strategy = PlainCreateStrategy(self.plain_create_strategy)
|
|
211
|
+
self.if_not_exists_strategy = IfNotExistsStrategy(self.if_not_exists_strategy)
|
|
212
|
+
self.layout = Layout(self.layout)
|
|
213
|
+
for name in ("default_project", "default_dataset"):
|
|
214
|
+
value = getattr(self, name)
|
|
215
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
216
|
+
raise ValueError(f"{name} must be a non-empty string or None")
|
|
217
|
+
if not isinstance(self.default_location, str) or not self.default_location:
|
|
218
|
+
raise ValueError("default_location must be a non-empty string")
|
|
219
|
+
for name in ("declare_external", "protect_incrementals", "annotate"):
|
|
220
|
+
if not isinstance(getattr(self, name), bool):
|
|
221
|
+
raise ValueError(f"{name} must be a boolean")
|
|
222
|
+
if isinstance(self.tags, (str, bytes)):
|
|
223
|
+
raise ValueError("tags must be a sequence of non-empty strings")
|
|
224
|
+
try:
|
|
225
|
+
tags = list(self.tags)
|
|
226
|
+
except TypeError:
|
|
227
|
+
raise ValueError("tags must be a sequence of non-empty strings") from None
|
|
228
|
+
if any(not isinstance(tag, str) or not tag for tag in tags):
|
|
229
|
+
raise ValueError("tags must be a sequence of non-empty strings")
|
|
230
|
+
self.tags = tags
|
|
231
|
+
if not isinstance(self.jobs, int) or isinstance(self.jobs, bool):
|
|
232
|
+
raise ValueError("jobs must be an integer")
|
|
233
|
+
if self.jobs < 0:
|
|
234
|
+
raise ValueError("jobs must be zero (auto) or a positive integer")
|
|
235
|
+
if not isinstance(self.encoding, str) or not self.encoding:
|
|
236
|
+
raise ValueError("encoding must not be empty")
|
|
237
|
+
try:
|
|
238
|
+
codec = codecs.lookup(self.encoding)
|
|
239
|
+
except LookupError:
|
|
240
|
+
raise ValueError(f"unknown encoding: {self.encoding!r}") from None
|
|
241
|
+
if not getattr(codec, "_is_text_encoding", True):
|
|
242
|
+
raise ValueError(f"encoding is not a text codec: {self.encoding!r}")
|
|
243
|
+
if not isinstance(self.include_glob, str) or not self.include_glob:
|
|
244
|
+
raise ValueError("include_glob must not be empty")
|
|
245
|
+
glob_path = PurePath(self.include_glob)
|
|
246
|
+
if glob_path.is_absolute() or ".." in glob_path.parts:
|
|
247
|
+
raise ValueError("include_glob must stay within the input directory")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Actions
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ActionType(str, enum.Enum):
|
|
256
|
+
"""Dataform action types emitted by sql2sqlx."""
|
|
257
|
+
|
|
258
|
+
TABLE = "table"
|
|
259
|
+
VIEW = "view"
|
|
260
|
+
INCREMENTAL = "incremental"
|
|
261
|
+
OPERATIONS = "operations"
|
|
262
|
+
DECLARATION = "declaration"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class RefSite:
|
|
267
|
+
"""A rewritable table reference discovered inside a statement body.
|
|
268
|
+
|
|
269
|
+
Attributes:
|
|
270
|
+
start: Character offset (in the source file) where the path begins.
|
|
271
|
+
end: Character offset one past the path.
|
|
272
|
+
name: The referenced :class:`TableName` (decoded, unresolved).
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
start: int
|
|
276
|
+
end: int
|
|
277
|
+
name: TableName
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class ActionDraft:
|
|
282
|
+
"""A parsed statement lifted into a not-yet-linked Dataform action.
|
|
283
|
+
|
|
284
|
+
Drafts are produced per-file in parallel workers; the linker then
|
|
285
|
+
resolves cross-file references, assigns unique action names, wires
|
|
286
|
+
dependency chains and hands the result to the emitter.
|
|
287
|
+
|
|
288
|
+
Attributes:
|
|
289
|
+
action_type: The Dataform action type.
|
|
290
|
+
target: The table produced/written by this action (``None`` for
|
|
291
|
+
pure reads or unclassifiable scripts).
|
|
292
|
+
writes_target: True when this action *mutates* ``target`` without
|
|
293
|
+
being its creator (UPDATE/DELETE/MERGE/INSERT-as-operations...);
|
|
294
|
+
drives dependency chaining.
|
|
295
|
+
creates_target: True when this action creates/replaces ``target``
|
|
296
|
+
(CREATE TABLE/VIEW ``AS``, and INSERT converted to incremental).
|
|
297
|
+
body_start: Source offset where the emitted SQL body begins.
|
|
298
|
+
body_end: Source offset one past the emitted body.
|
|
299
|
+
stmt_start: Source offset of the full original statement (used when
|
|
300
|
+
a draft must be demoted back to a verbatim ``operations`` body).
|
|
301
|
+
stmt_end: Source offset one past the full original statement.
|
|
302
|
+
extra_write_targets: For whole-file script drafts, every table the
|
|
303
|
+
script writes (INSERT/UPDATE/MERGE/CREATE/... targets found in
|
|
304
|
+
its statements); the linker treats the script as a writer of
|
|
305
|
+
each so downstream readers are ordered after it.
|
|
306
|
+
edits: Pending span edits ``(start, end, replacement)`` on the
|
|
307
|
+
source text, absolute offsets; produced by the classifier
|
|
308
|
+
(e.g. select-list aliasing) and later by ref rewriting.
|
|
309
|
+
sqlx_escape_edits: Non-semantic edits that quote literal ``${``
|
|
310
|
+
sequences for the Dataform SQLX compiler. Kept separate so a
|
|
311
|
+
typed draft can be demoted without losing required escaping.
|
|
312
|
+
primary_target_span: Span of the statement's own target path, if
|
|
313
|
+
the emitter should replace it with ``${self()}`` (only used
|
|
314
|
+
for elected ``hasOutput`` operations).
|
|
315
|
+
ref_sites: Candidate table references inside the body.
|
|
316
|
+
config: Dataform ``config { ... }`` entries already known at parse
|
|
317
|
+
time (type/name/schema excluded - the linker fills those).
|
|
318
|
+
source_line: 1-based line of the statement in its source file.
|
|
319
|
+
original_kind: Uppercase leading keyword(s) of the original
|
|
320
|
+
statement (for annotations/reporting), e.g. ``"MERGE"``.
|
|
321
|
+
warnings: ``(code, message, offset)`` triples raised during
|
|
322
|
+
classification; the converter turns them into report entries.
|
|
323
|
+
script: True when the draft wraps an entire multi-statement file.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
action_type: ActionType
|
|
327
|
+
target: Optional[TableName]
|
|
328
|
+
writes_target: bool
|
|
329
|
+
creates_target: bool
|
|
330
|
+
body_start: int
|
|
331
|
+
body_end: int
|
|
332
|
+
stmt_start: int = 0
|
|
333
|
+
stmt_end: int = 0
|
|
334
|
+
extra_write_targets: List[TableName] = field(default_factory=list)
|
|
335
|
+
edits: List[Tuple[int, int, str]] = field(default_factory=list)
|
|
336
|
+
sqlx_escape_edits: List[Tuple[int, int, str]] = field(default_factory=list)
|
|
337
|
+
primary_target_span: Optional[Tuple[int, int]] = None
|
|
338
|
+
ref_sites: List[RefSite] = field(default_factory=list)
|
|
339
|
+
config: Dict[str, Any] = field(default_factory=dict)
|
|
340
|
+
source_line: int = 0
|
|
341
|
+
original_kind: str = ""
|
|
342
|
+
warnings: List[Tuple[str, str, int]] = field(default_factory=list)
|
|
343
|
+
script: bool = False
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class ParsedFile:
|
|
348
|
+
"""All drafts extracted from one source file, plus its raw text.
|
|
349
|
+
|
|
350
|
+
Attributes:
|
|
351
|
+
path: Path of the source file (as given; relative paths preserved).
|
|
352
|
+
relpath: Path relative to the conversion root (used for layout).
|
|
353
|
+
text: Full decoded file contents.
|
|
354
|
+
drafts: Statement drafts in source order.
|
|
355
|
+
error: Fatal per-file error message, or ``None`` on success. Files
|
|
356
|
+
with an error contribute no drafts and are listed as failures
|
|
357
|
+
in the report; other files are unaffected.
|
|
358
|
+
statements: Number of top-level statements found in the file
|
|
359
|
+
(counted before any whole-file script collapsing).
|
|
360
|
+
comment_spans: ``(start, end)`` spans of every comment, captured
|
|
361
|
+
during lexing so emission never re-lexes the file.
|
|
362
|
+
input_bytes: Exact source-file byte size when read from disk; UTF-8
|
|
363
|
+
size for in-memory input.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
path: str
|
|
367
|
+
relpath: str
|
|
368
|
+
text: str
|
|
369
|
+
drafts: List[ActionDraft] = field(default_factory=list)
|
|
370
|
+
error: Optional[str] = None
|
|
371
|
+
statements: int = 0
|
|
372
|
+
comment_spans: List[Tuple[int, int]] = field(default_factory=list)
|
|
373
|
+
input_bytes: int = 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Output & report
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass
|
|
382
|
+
class SqlxFile:
|
|
383
|
+
"""One generated ``.sqlx`` file.
|
|
384
|
+
|
|
385
|
+
Attributes:
|
|
386
|
+
relpath: Output path relative to the output root (POSIX separators).
|
|
387
|
+
content: Full file contents.
|
|
388
|
+
action_type: The Dataform action type of the file.
|
|
389
|
+
action_name: The Dataform action name (``config.name``).
|
|
390
|
+
source_path: Source file this action came from (``""`` for
|
|
391
|
+
synthesized declarations).
|
|
392
|
+
source_line: 1-based source line of the originating statement.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
relpath: str
|
|
396
|
+
content: str
|
|
397
|
+
action_type: ActionType
|
|
398
|
+
action_name: str
|
|
399
|
+
source_path: str = ""
|
|
400
|
+
source_line: int = 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass
|
|
404
|
+
class ReportWarning:
|
|
405
|
+
"""A single non-fatal finding.
|
|
406
|
+
|
|
407
|
+
Attributes:
|
|
408
|
+
code: Stable machine-readable code (e.g. ``FALLBACK_OPERATIONS``).
|
|
409
|
+
message: Human-readable explanation.
|
|
410
|
+
path: Source file the finding relates to.
|
|
411
|
+
line: 1-based line number (0 when not applicable).
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
code: str
|
|
415
|
+
message: str
|
|
416
|
+
path: str = ""
|
|
417
|
+
line: int = 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass
|
|
421
|
+
class ConversionReport:
|
|
422
|
+
"""Aggregate result of a conversion run.
|
|
423
|
+
|
|
424
|
+
Attributes:
|
|
425
|
+
files_read: Number of input files parsed (including failed ones).
|
|
426
|
+
statements: Number of top-level statements encountered.
|
|
427
|
+
actions_by_type: Count of generated actions per Dataform type.
|
|
428
|
+
refs_rewritten: Number of table references rewritten to ``ref()``.
|
|
429
|
+
refs_unresolved: Distinct referenced-but-not-produced table paths
|
|
430
|
+
that can be dataset-resolved, left as literals.
|
|
431
|
+
warnings: All non-fatal findings.
|
|
432
|
+
failures: ``path -> error`` for files that could not be converted.
|
|
433
|
+
elapsed_seconds: Wall-clock duration of the run.
|
|
434
|
+
input_bytes: Total size of the SQL read, in bytes.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
files_read: int = 0
|
|
438
|
+
statements: int = 0
|
|
439
|
+
actions_by_type: Dict[str, int] = field(default_factory=dict)
|
|
440
|
+
refs_rewritten: int = 0
|
|
441
|
+
refs_unresolved: List[str] = field(default_factory=list)
|
|
442
|
+
warnings: List[ReportWarning] = field(default_factory=list)
|
|
443
|
+
failures: Dict[str, str] = field(default_factory=dict)
|
|
444
|
+
elapsed_seconds: float = 0.0
|
|
445
|
+
input_bytes: int = 0
|
|
446
|
+
|
|
447
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
448
|
+
"""Return a JSON-serializable representation of the report."""
|
|
449
|
+
d = dataclasses.asdict(self)
|
|
450
|
+
d["warnings"] = [dataclasses.asdict(w) for w in self.warnings]
|
|
451
|
+
return d
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@dataclass
|
|
455
|
+
class ConversionResult:
|
|
456
|
+
"""Everything a conversion produces.
|
|
457
|
+
|
|
458
|
+
Attributes:
|
|
459
|
+
files: Generated ``.sqlx`` files (deterministic order: sorted by
|
|
460
|
+
output path).
|
|
461
|
+
report: The run's :class:`ConversionReport`.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
files: List[SqlxFile]
|
|
465
|
+
report: ConversionReport
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_NAME_SANITIZE_RE = re.compile(r"[^A-Za-z0-9_]+")
|
|
469
|
+
_WINDOWS_RESERVED_RE = re.compile(r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def sanitize_filename(name: str) -> str:
|
|
473
|
+
"""Make an arbitrary action/table name safe as a file stem.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
name: Proposed name (may contain spaces, dashes, unicode...).
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
A non-empty stem containing only ``[A-Za-z0-9_]``.
|
|
480
|
+
"""
|
|
481
|
+
out = _NAME_SANITIZE_RE.sub("_", name).strip("_") or "action"
|
|
482
|
+
if _WINDOWS_RESERVED_RE.fullmatch(out):
|
|
483
|
+
out = "_" + out
|
|
484
|
+
if len(out) > 120:
|
|
485
|
+
digest = hashlib.sha256(name.encode("utf-8", "surrogatepass")).hexdigest()[:12]
|
|
486
|
+
out = out[:107].rstrip("_") + "_" + digest
|
|
487
|
+
return out
|