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