p6flow 0.1.2__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.
p6flow/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """p6flow: Primavera P6 XER files to typed Snowflake-loadable Parquet."""
2
+
3
+ __version__ = "0.1.0"
p6flow/calendar.py ADDED
@@ -0,0 +1,363 @@
1
+ """Parser for P6's CALENDAR.clndr_data paren-tree blob.
2
+
3
+ The format is a small custom tree language. Each node looks like:
4
+
5
+ (<flags>||<name>(<attrs>)(<children>))
6
+
7
+ where <flags> is a fixed prefix we ignore (`0`), <name> identifies the
8
+ node type or index, <attrs> is pipe-separated key|value pairs, and
9
+ <children> contains zero or more sibling nodes. Inter-node whitespace
10
+ is `\\x7f\\x7f` plus regular ASCII whitespace, all stripped.
11
+
12
+ Top-level layout:
13
+
14
+ CalendarData
15
+ DaysOfWeek
16
+ 1, 2, 3, 4, 5, 6, 7 (1=Sunday, 7=Saturday)
17
+ 0, 1, ... (segment index, attrs s|HH:MM|f|HH:MM)
18
+ VIEW (display metadata, ignored)
19
+ Exceptions
20
+ 0, 1, ... (exception index, attrs d|<epoch_days>)
21
+ 0, 1, ... (optional segments overriding the day)
22
+
23
+ P6 epoch is Excel's 1899-12-30, validated empirically against
24
+ calendars whose exception sets contained recognizable US holidays
25
+ (Independence Day, Christmas, Memorial Day, Labor Day): every
26
+ candidate origin except 1899-12-30 placed those dates a day or
27
+ more off the actual calendar.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from datetime import date, time, timedelta
34
+
35
+ import duckdb
36
+ import pyarrow as pa
37
+
38
+ P6_EPOCH = date(1899, 12, 30)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class WeeklySegment:
43
+ day_of_week: int # 1=Sunday, 7=Saturday
44
+ segment_idx: int
45
+ start_time: time
46
+ end_time: time
47
+ hours: float
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ExceptionDay:
52
+ exception_date: date
53
+ segments: tuple[WeeklySegment, ...] # may be empty (full non-work day)
54
+ hours_worked: float
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CalendarPayload:
59
+ weekly: tuple[WeeklySegment, ...]
60
+ exceptions: tuple[ExceptionDay, ...]
61
+
62
+
63
+ # ---- Tree parsing -------------------------------------------------
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class _Node:
68
+ name: str
69
+ attrs: dict[str, str]
70
+ children: tuple[_Node, ...]
71
+
72
+
73
+ def _strip_whitespace(s: str) -> str:
74
+ out = []
75
+ for ch in s:
76
+ if ch in "\x7f \t\r\n":
77
+ continue
78
+ out.append(ch)
79
+ return "".join(out)
80
+
81
+
82
+ def _read_balanced(s: str, i: int) -> tuple[str, int]:
83
+ """Read the body of a balanced (...) starting at s[i]=='('.
84
+ Return (body_without_outer_parens, index_after_closing_paren)."""
85
+ if s[i] != "(":
86
+ raise ValueError(f"expected '(' at {i}, got {s[i]!r}")
87
+ depth = 1
88
+ j = i + 1
89
+ while depth > 0:
90
+ if j >= len(s):
91
+ raise ValueError(f"unbalanced parens starting at {i}")
92
+ c = s[j]
93
+ if c == "(":
94
+ depth += 1
95
+ elif c == ")":
96
+ depth -= 1
97
+ if depth > 0:
98
+ j += 1
99
+ return s[i + 1 : j], j + 1
100
+
101
+
102
+ def _parse_attrs(body: str) -> dict[str, str]:
103
+ """Parse `k1|v1|k2|v2|...` into a dict. Empty body -> empty dict."""
104
+ if not body:
105
+ return {}
106
+ parts = body.split("|")
107
+ if len(parts) % 2 != 0:
108
+ raise ValueError(f"odd number of attr fields: {body!r}")
109
+ return dict(zip(parts[0::2], parts[1::2], strict=True))
110
+
111
+
112
+ # Real P6 clndr_data nests at most ~5 levels (week / day / exception).
113
+ # A hard cap defangs maliciously deep blobs that would otherwise blow
114
+ # Python's default recursion limit (~1000) and could segfault on builds
115
+ # with smaller stacks.
116
+ _MAX_NESTING_DEPTH = 64
117
+
118
+
119
+ def _parse_node(s: str, i: int, depth: int = 0) -> tuple[_Node, int]:
120
+ """Parse one node starting at s[i]=='('."""
121
+ if depth > _MAX_NESTING_DEPTH:
122
+ raise ValueError(
123
+ f"clndr_data nesting exceeds depth limit ({_MAX_NESTING_DEPTH})"
124
+ )
125
+ if s[i] != "(":
126
+ raise ValueError(f"expected '(' at {i}")
127
+ i += 1
128
+ # Header runs to the first inner '('.
129
+ j = s.index("(", i)
130
+ header = s[i:j]
131
+ # Header format: "<flags>||<name>". We tolerate missing prefix.
132
+ name = header.split("||", 1)[-1]
133
+ i = j
134
+ attrs_body, i = _read_balanced(s, i)
135
+ children_body, i = _read_balanced(s, i)
136
+ if s[i] != ")":
137
+ raise ValueError(f"expected ')' at {i}, got {s[i]!r}")
138
+ i += 1
139
+
140
+ children: list[_Node] = []
141
+ k = 0
142
+ while k < len(children_body):
143
+ if children_body[k] == "(":
144
+ child, k = _parse_node(children_body, k, depth + 1)
145
+ children.append(child)
146
+ else:
147
+ raise ValueError(
148
+ f"unexpected character {children_body[k]!r} between sibling nodes"
149
+ )
150
+ return _Node(name, _parse_attrs(attrs_body), tuple(children)), i
151
+
152
+
153
+ def _parse_root(s: str) -> _Node:
154
+ s = _strip_whitespace(s)
155
+ if not s:
156
+ raise ValueError("empty clndr_data after whitespace strip")
157
+ root, end = _parse_node(s, 0)
158
+ if end != len(s):
159
+ raise ValueError(f"trailing data after root: {s[end:end+20]!r}")
160
+ return root
161
+
162
+
163
+ # ---- Domain extraction --------------------------------------------
164
+
165
+
166
+ def _parse_time(hhmm: str) -> time:
167
+ """`08:00` -> time(8, 0). P6 also emits `24:00` for midnight-rollover
168
+ (a full-day segment finishing at end of day); normalize that to
169
+ 23:59:59.999 since `time` doesn't accept hour=24."""
170
+ hh, mm = hhmm.split(":")
171
+ h, m = int(hh), int(mm)
172
+ if h == 24 and m == 0:
173
+ return time(23, 59, 59, 999000)
174
+ return time(h, m)
175
+
176
+
177
+ def _hours_between(start: time, end: time) -> float:
178
+ """Decimal hours from start to end on the same day."""
179
+
180
+ def _to_seconds(t: time) -> int:
181
+ return t.hour * 3600 + t.minute * 60 + t.second + t.microsecond // 1000
182
+
183
+ return (_to_seconds(end) - _to_seconds(start)) / 3600.0
184
+
185
+
186
+ def _extract_segments(parent: _Node) -> list[WeeklySegment]:
187
+ """Walk a day or exception node's children to pull work segments.
188
+
189
+ Caller fills in `day_of_week` after; we return per-segment tuples
190
+ with day_of_week=0 as a placeholder.
191
+ """
192
+ out: list[WeeklySegment] = []
193
+ for seg in parent.children:
194
+ if "s" not in seg.attrs or "f" not in seg.attrs:
195
+ continue
196
+ start = _parse_time(seg.attrs["s"])
197
+ end = _parse_time(seg.attrs["f"])
198
+ out.append(
199
+ WeeklySegment(
200
+ day_of_week=0,
201
+ segment_idx=int(seg.name),
202
+ start_time=start,
203
+ end_time=end,
204
+ hours=_hours_between(start, end),
205
+ )
206
+ )
207
+ return out
208
+
209
+
210
+ def _find_child(parent: _Node, name: str) -> _Node | None:
211
+ for c in parent.children:
212
+ if c.name == name:
213
+ return c
214
+ return None
215
+
216
+
217
+ def parse_clndr_data(blob: bytes | str) -> CalendarPayload:
218
+ """Parse a CALENDAR.clndr_data blob into typed weekly + exception rows.
219
+
220
+ Accepts bytes (decoded as latin-1 since the format is ASCII-only)
221
+ or a pre-decoded string.
222
+ """
223
+ text = blob.decode("latin-1") if isinstance(blob, bytes) else blob
224
+ root = _parse_root(text)
225
+
226
+ weekly: list[WeeklySegment] = []
227
+ days = _find_child(root, "DaysOfWeek")
228
+ if days is not None:
229
+ for day_node in days.children:
230
+ day_of_week = int(day_node.name)
231
+ for seg in _extract_segments(day_node):
232
+ weekly.append(
233
+ WeeklySegment(
234
+ day_of_week=day_of_week,
235
+ segment_idx=seg.segment_idx,
236
+ start_time=seg.start_time,
237
+ end_time=seg.end_time,
238
+ hours=seg.hours,
239
+ )
240
+ )
241
+
242
+ exceptions: list[ExceptionDay] = []
243
+ exc = _find_child(root, "Exceptions")
244
+ if exc is not None:
245
+ for ex_node in exc.children:
246
+ if "d" not in ex_node.attrs:
247
+ continue
248
+ epoch_days = int(ex_node.attrs["d"])
249
+ ex_date = P6_EPOCH + timedelta(days=epoch_days)
250
+ segs = _extract_segments(ex_node)
251
+ hours_worked = sum(s.hours for s in segs)
252
+ exceptions.append(
253
+ ExceptionDay(
254
+ exception_date=ex_date,
255
+ segments=tuple(segs),
256
+ hours_worked=hours_worked,
257
+ )
258
+ )
259
+
260
+ return CalendarPayload(weekly=tuple(weekly), exceptions=tuple(exceptions))
261
+
262
+
263
+ # ---- DuckDB materialization ---------------------------------------
264
+
265
+
266
+ def materialize_calendar_derived(
267
+ con: duckdb.DuckDBPyConnection,
268
+ ) -> dict[str, int]:
269
+ """Parse every CALENDAR.clndr_data blob and create two derived tables:
270
+
271
+ CALENDAR_WEEKLY (clndr_id, day_of_week, segment_idx, start_time,
272
+ end_time, hours)
273
+ CALENDAR_EXCEPTION (clndr_id, exception_date, segment_idx, start_time,
274
+ end_time, hours_worked, is_full_segment)
275
+
276
+ Exception rows mirror weekly: one row per work segment, plus one
277
+ summary row per exception day with segment_idx=NULL when the day
278
+ is a pure non-work day (so consumers can quickly filter holidays).
279
+
280
+ Returns row counts for both tables. Skips silently if CALENDAR
281
+ isn't loaded (legacy XER without it).
282
+ """
283
+ loaded = {r[0] for r in con.execute("SHOW TABLES").fetchall()}
284
+ if "CALENDAR" not in loaded:
285
+ return {}
286
+
287
+ weekly_rows: list[dict] = []
288
+ exception_rows: list[dict] = []
289
+
290
+ for clndr_id, blob in con.execute(
291
+ "SELECT clndr_id, clndr_data FROM CALENDAR WHERE clndr_data IS NOT NULL"
292
+ ).fetchall():
293
+ payload = parse_clndr_data(blob)
294
+ for seg in payload.weekly:
295
+ weekly_rows.append({
296
+ "clndr_id": clndr_id,
297
+ "day_of_week": seg.day_of_week,
298
+ "segment_idx": seg.segment_idx,
299
+ "start_time": seg.start_time,
300
+ "end_time": seg.end_time,
301
+ "hours": seg.hours,
302
+ })
303
+ for ex in payload.exceptions:
304
+ if not ex.segments:
305
+ # Pure non-work day. Single row with NULL segment fields.
306
+ exception_rows.append({
307
+ "clndr_id": clndr_id,
308
+ "exception_date": ex.exception_date,
309
+ "segment_idx": None,
310
+ "start_time": None,
311
+ "end_time": None,
312
+ "hours_worked": 0.0,
313
+ })
314
+ else:
315
+ for seg in ex.segments:
316
+ exception_rows.append({
317
+ "clndr_id": clndr_id,
318
+ "exception_date": ex.exception_date,
319
+ "segment_idx": seg.segment_idx,
320
+ "start_time": seg.start_time,
321
+ "end_time": seg.end_time,
322
+ "hours_worked": seg.hours,
323
+ })
324
+
325
+ weekly_schema = pa.schema([
326
+ ("clndr_id", pa.int64()),
327
+ ("day_of_week", pa.int32()),
328
+ ("segment_idx", pa.int32()),
329
+ ("start_time", pa.time64("us")),
330
+ ("end_time", pa.time64("us")),
331
+ ("hours", pa.float64()),
332
+ ])
333
+ exception_schema = pa.schema([
334
+ ("clndr_id", pa.int64()),
335
+ ("exception_date", pa.date32()),
336
+ ("segment_idx", pa.int32()),
337
+ ("start_time", pa.time64("us")),
338
+ ("end_time", pa.time64("us")),
339
+ ("hours_worked", pa.float64()),
340
+ ])
341
+
342
+ weekly_table = pa.Table.from_pylist(weekly_rows, schema=weekly_schema)
343
+ exception_table = pa.Table.from_pylist(exception_rows, schema=exception_schema)
344
+
345
+ con.register("calendar_weekly_raw", weekly_table)
346
+ con.register("calendar_exception_raw", exception_table)
347
+ try:
348
+ con.execute(
349
+ "CREATE OR REPLACE TABLE CALENDAR_WEEKLY AS "
350
+ "SELECT * FROM calendar_weekly_raw"
351
+ )
352
+ con.execute(
353
+ "CREATE OR REPLACE TABLE CALENDAR_EXCEPTION AS "
354
+ "SELECT * FROM calendar_exception_raw"
355
+ )
356
+ finally:
357
+ con.unregister("calendar_weekly_raw")
358
+ con.unregister("calendar_exception_raw")
359
+
360
+ return {
361
+ "CALENDAR_WEEKLY": len(weekly_rows),
362
+ "CALENDAR_EXCEPTION": len(exception_rows),
363
+ }
p6flow/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """p6flow: command-line entry point.
2
+
3
+ p6flow INPUT [INPUT ...] --out DIR
4
+
5
+ Each input XER becomes a subdirectory under --out containing one Parquet
6
+ per table plus manifest.json. Multi-input runs do not merge: PKs collide
7
+ across XERs since separate projects can reuse internal numeric ID spaces.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import click
15
+ import duckdb
16
+
17
+ from .calendar import materialize_calendar_derived
18
+ from .loader import load_xer
19
+ from .output import write_parquet
20
+ from .tokenizer import parse_tables
21
+ from .udf import materialize_udf_wide
22
+ from .validate import FkViolation, validate_fks
23
+
24
+
25
+ def _print_violations(violations: list[FkViolation]) -> None:
26
+ if not violations:
27
+ click.echo(" fk-validation: clean")
28
+ return
29
+ click.echo(f" fk-validation: {len(violations)} violation(s)")
30
+ for v in violations:
31
+ local = ".".join(v.local_fields)
32
+ target = ".".join(v.target_fields)
33
+ sample = ", ".join("(" + ",".join(k) + ")" for k in v.sample_keys[:3])
34
+ click.echo(
35
+ f" [{v.kind}] {v.child_table}.{local} -> "
36
+ f"{v.target_table}.{target}: {v.violating_rows} row(s); "
37
+ f"sample: {sample}"
38
+ )
39
+
40
+
41
+ @click.command(name="p6flow")
42
+ @click.argument(
43
+ "inputs",
44
+ nargs=-1,
45
+ required=True,
46
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
47
+ )
48
+ @click.option(
49
+ "--out",
50
+ "out_dir",
51
+ required=True,
52
+ type=click.Path(file_okay=False, path_type=Path),
53
+ help="Output directory; one subdirectory per input XER.",
54
+ )
55
+ @click.option(
56
+ "--validate/--no-validate",
57
+ default=True,
58
+ help="Run FK validation after load and persist into manifest.json.",
59
+ )
60
+ @click.option(
61
+ "--add-dw-columns/--no-add-dw-columns",
62
+ default=False,
63
+ help="Append warehouse columns to every emitted parquet: "
64
+ "source_xer, source_sha256, flow_published_at, created_at, _raw. "
65
+ "Use for cross-XER loads where P6 IDs collide between exports.",
66
+ )
67
+ @click.option(
68
+ "--quiet/--no-quiet",
69
+ default=False,
70
+ help="Suppress per-table progress output.",
71
+ )
72
+ def parse(
73
+ inputs: tuple[Path, ...],
74
+ out_dir: Path,
75
+ validate: bool,
76
+ add_dw_columns: bool,
77
+ quiet: bool,
78
+ ) -> None:
79
+ """Parse XER files into typed Parquet."""
80
+ out_dir.mkdir(parents=True, exist_ok=True)
81
+ for src in inputs:
82
+ sub = out_dir / src.stem
83
+ if not quiet:
84
+ click.echo(f"parsing {src.name}")
85
+ tables = list(parse_tables(src))
86
+ con = duckdb.connect(":memory:")
87
+ counts = load_xer(con, tables, add_dw_columns=add_dw_columns)
88
+ counts.update(materialize_calendar_derived(con))
89
+ udf_counts, udf_unknown = materialize_udf_wide(con)
90
+ counts.update(udf_counts)
91
+ violations = validate_fks(con) if validate else None
92
+ manifest = write_parquet(
93
+ con,
94
+ sub,
95
+ src,
96
+ counts,
97
+ violations=violations,
98
+ udf_unknown_types=udf_unknown,
99
+ add_dw_columns=add_dw_columns,
100
+ )
101
+ if not quiet:
102
+ total = sum(counts.values())
103
+ click.echo(
104
+ f" -> {sub} ({len(counts)} tables, {total} rows, "
105
+ f"P6 v{manifest['p6_version']})"
106
+ )
107
+ if udf_unknown:
108
+ click.echo(
109
+ f" udf-unknown-types: {len(udf_unknown)} "
110
+ f"(see manifest.json#udf_unknown_types)"
111
+ )
112
+ if violations is not None:
113
+ _print_violations(violations)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ parse()
p6flow/ddl.py ADDED
@@ -0,0 +1,182 @@
1
+ """Runtime DDL generation.
2
+
3
+ The codegen module p6flow.schema provides every column EPPM knows about
4
+ at a given version. This module reads the actual %F field list from a parsed
5
+ XER block and emits DDL using only those columns, with types looked up from
6
+ COLUMN_TYPES and a VARCHAR fallback for anything the schema doesn't know about
7
+ (legacy P6 columns, Oracle-removed columns, etc.).
8
+
9
+ Two flavors:
10
+
11
+ duckdb_ddl(table, fields) for the local staging DB
12
+ snowflake_ddl(table, fields) for the remote target
13
+
14
+ Both return a single SQL string. PRIMARY KEY constraints are included when
15
+ every PK column is present in the field list. FOREIGN KEY constraints are
16
+ included when both the local and target columns are present. Self-FKs are
17
+ skipped because DuckDB enforces FKs at INSERT and offers no deferred mode.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .schema import COLUMN_TYPES, FOREIGN_KEYS, PRIMARY_KEYS, ColumnSpec
23
+
24
+ VARCHAR_FALLBACK = ColumnSpec(
25
+ duckdb_type="VARCHAR",
26
+ snowflake_type="VARCHAR",
27
+ nullable=True,
28
+ description="",
29
+ )
30
+
31
+ OverrideMap = dict[tuple[str, str], ColumnSpec]
32
+
33
+
34
+ def _spec(table: str, field: str, overrides: OverrideMap | None = None) -> ColumnSpec:
35
+ if overrides and (table, field) in overrides:
36
+ return overrides[(table, field)]
37
+ return COLUMN_TYPES.get(table, {}).get(field, VARCHAR_FALLBACK)
38
+
39
+
40
+ def _sql_quote(s: str) -> str:
41
+ return s.replace("\\", "\\\\").replace("'", "''")
42
+
43
+
44
+ def _common_constraints(
45
+ table: str,
46
+ fields: list[str],
47
+ available_tables: set[str] | None = None,
48
+ ) -> list[str]:
49
+ """Constraints both DDL flavors agree on (text only).
50
+
51
+ `available_tables` filters FK references: only emit a FK if its target
52
+ table is actually being loaded. Without this filter, DDL would reference
53
+ parent tables that don't exist in DuckDB and the CREATE would fail.
54
+ """
55
+ field_set = set(fields)
56
+ out: list[str] = []
57
+ pk = PRIMARY_KEYS.get(table)
58
+ if pk and all(c in field_set for c in pk):
59
+ out.append(
60
+ f"CONSTRAINT pk_{table.lower()} PRIMARY KEY ({', '.join(pk)})"
61
+ )
62
+ for fk in FOREIGN_KEYS.get(table, []):
63
+ if fk.target_table == table:
64
+ continue # skip self-FKs
65
+ if available_tables is not None and fk.target_table not in available_tables:
66
+ continue
67
+ if not all(c in field_set for c in fk.local_fields):
68
+ continue
69
+ target_cols = COLUMN_TYPES.get(fk.target_table, {})
70
+ if not all(c in target_cols for c in fk.target_fields):
71
+ continue
72
+ out.append(
73
+ f"CONSTRAINT {fk.name} FOREIGN KEY "
74
+ f"({', '.join(fk.local_fields)}) REFERENCES "
75
+ f"{fk.target_table} ({', '.join(fk.target_fields)})"
76
+ )
77
+ return out
78
+
79
+
80
+ def duckdb_ddl(
81
+ table: str,
82
+ fields: list[str],
83
+ overrides: OverrideMap | None = None,
84
+ available_tables: set[str] | None = None,
85
+ enforce_fks: bool = False,
86
+ enforce_not_null: bool = False,
87
+ ) -> str:
88
+ """CREATE TABLE + COMMENT ON statements for DuckDB staging.
89
+
90
+ Defaults are permissive because XER is a partial export, not a self-
91
+ contained snapshot. Real XERs routinely reference projects, calendars,
92
+ and resources that aren't included in the export. NOT NULL is also
93
+ relaxed because some columns marked NOT NULL in the v26 schema are
94
+ optional in older P6 versions and arrive empty.
95
+
96
+ FK validation belongs in a downstream SELECT-based step that reports
97
+ violations as warnings, not as DDL constraints that block load.
98
+ """
99
+ col_lines: list[str] = []
100
+ for f in fields:
101
+ s = _spec(table, f, overrides)
102
+ null_clause = " NOT NULL" if (enforce_not_null and not s.nullable) else ""
103
+ # Quote every column name. Real XERs occasionally have malformed
104
+ # %F lines where two field names get joined with a space (e.g.
105
+ # "create_user update_user"); without quoting, that emits invalid
106
+ # SQL. Quoting also future-proofs against names that collide with
107
+ # SQL reserved words.
108
+ col_lines.append(f' "{f}" {s.duckdb_type}{null_clause}')
109
+ constraints = _common_constraints(table, fields, available_tables)
110
+ if not enforce_fks:
111
+ constraints = [c for c in constraints if c.startswith("CONSTRAINT pk_")]
112
+ col_lines.extend(f" {c}" for c in constraints)
113
+
114
+ out: list[str] = [
115
+ f"CREATE OR REPLACE TABLE {table} (",
116
+ ",\n".join(col_lines),
117
+ ");",
118
+ ]
119
+ for f in fields:
120
+ s = _spec(table, f, overrides)
121
+ if s.description:
122
+ out.append(
123
+ f"COMMENT ON COLUMN {table}.\"{f}\" IS "
124
+ f"'{_sql_quote(s.description)}';"
125
+ )
126
+ return "\n".join(out)
127
+
128
+
129
+ def snowflake_ddl(
130
+ table: str,
131
+ fields: list[str],
132
+ overrides: OverrideMap | None = None,
133
+ available_tables: set[str] | None = None,
134
+ ) -> str:
135
+ """CREATE TABLE with inline COMMENT clauses for Snowflake."""
136
+ col_lines: list[str] = []
137
+ for f in fields:
138
+ s = _spec(table, f, overrides)
139
+ null_clause = "" if s.nullable else " NOT NULL"
140
+ comment = (
141
+ f" COMMENT '{_sql_quote(s.description)}'" if s.description else ""
142
+ )
143
+ col_lines.append(f' "{f}" {s.snowflake_type}{null_clause}{comment}')
144
+ col_lines.extend(
145
+ f" {c}" for c in _common_constraints(table, fields, available_tables)
146
+ )
147
+ return "\n".join(
148
+ [
149
+ f"CREATE OR REPLACE TABLE {table} (",
150
+ ",\n".join(col_lines),
151
+ ");",
152
+ ]
153
+ )
154
+
155
+
156
+ def topo_sort_tables(blocks: list[tuple[str, list[str]]]) -> list[tuple[str, list[str]]]:
157
+ """Order parsed blocks so every FK parent appears before its children.
158
+
159
+ DuckDB needs this since it enforces FKs at INSERT time. blocks is a list
160
+ of (table_name, %F field list). Returns the same items reordered.
161
+ """
162
+ by_name = {t: (t, fs) for t, fs in blocks}
163
+ seen: set[str] = set()
164
+ visiting: set[str] = set()
165
+ out: list[tuple[str, list[str]]] = []
166
+
167
+ def visit(name: str) -> None:
168
+ if name in seen or name not in by_name:
169
+ return
170
+ if name in visiting:
171
+ return # cycle; let caller proceed
172
+ visiting.add(name)
173
+ for fk in FOREIGN_KEYS.get(name, []):
174
+ if fk.target_table != name:
175
+ visit(fk.target_table)
176
+ visiting.discard(name)
177
+ seen.add(name)
178
+ out.append(by_name[name])
179
+
180
+ for t, _ in blocks:
181
+ visit(t)
182
+ return out