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