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 +3 -0
- p6flow/calendar.py +363 -0
- p6flow/cli.py +117 -0
- p6flow/ddl.py +182 -0
- p6flow/inference.py +204 -0
- p6flow/loader.py +189 -0
- p6flow/output.py +173 -0
- p6flow/schema.py +6876 -0
- p6flow/tokenizer.py +276 -0
- p6flow/udf.py +303 -0
- p6flow/validate.py +212 -0
- p6flow-0.1.2.dist-info/METADATA +69 -0
- p6flow-0.1.2.dist-info/RECORD +17 -0
- p6flow-0.1.2.dist-info/WHEEL +4 -0
- p6flow-0.1.2.dist-info/entry_points.txt +2 -0
- p6flow-0.1.2.dist-info/licenses/LICENSE +165 -0
- p6flow-0.1.2.dist-info/licenses/LICENSE-GPL +674 -0
p6flow/inference.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Type resolution for XER columns.
|
|
2
|
+
|
|
3
|
+
Three-tier fallback:
|
|
4
|
+
|
|
5
|
+
1. pmSchema.xml (via p6flow.schema.COLUMN_TYPES). If known, use that.
|
|
6
|
+
2. Naming heuristic + value sniffing must agree. Raise on mismatch.
|
|
7
|
+
3. If neither has an opinion, fall back to VARCHAR.
|
|
8
|
+
|
|
9
|
+
Tier 2 only runs for columns absent from pmSchema.xml. The heuristic alone
|
|
10
|
+
is fast but wrong for the long tail; the sniffer alone has small-sample
|
|
11
|
+
ambiguity (an all-zeros column could be int or double). Requiring both to
|
|
12
|
+
agree catches confidently-wrong inferences and surfaces them as errors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from .schema import COLUMN_TYPES, ColumnSpec
|
|
21
|
+
|
|
22
|
+
# Order matters: longer/more specific suffixes first.
|
|
23
|
+
_SUFFIX_BUCKETS: tuple[tuple[str, str], ...] = (
|
|
24
|
+
# Compound suffixes that override their tail
|
|
25
|
+
("_pct_type", "string"),
|
|
26
|
+
("_hr_cnt", "double"),
|
|
27
|
+
# Integers
|
|
28
|
+
("_id", "integer"),
|
|
29
|
+
("_cnt", "integer"),
|
|
30
|
+
("_num", "integer"),
|
|
31
|
+
("_seq", "integer"),
|
|
32
|
+
# Doubles
|
|
33
|
+
("_pct", "double"),
|
|
34
|
+
("_qty", "double"),
|
|
35
|
+
("_rate", "double"),
|
|
36
|
+
("_wt", "double"),
|
|
37
|
+
("_value", "double"),
|
|
38
|
+
("_cost", "double"),
|
|
39
|
+
("_drtn", "double"), # duration
|
|
40
|
+
# Dates
|
|
41
|
+
("_date", "date"),
|
|
42
|
+
# Flags / single-char codes
|
|
43
|
+
("_flag", "flag"),
|
|
44
|
+
# Strings
|
|
45
|
+
("_type", "string"),
|
|
46
|
+
("_code", "string"),
|
|
47
|
+
("_name", "string"),
|
|
48
|
+
("_user", "string"),
|
|
49
|
+
("_short_name", "string"),
|
|
50
|
+
("_path", "string"),
|
|
51
|
+
("_state", "string"),
|
|
52
|
+
("_guid", "string"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$")
|
|
56
|
+
|
|
57
|
+
_BUCKET_TO_DUCKDB = {
|
|
58
|
+
"integer": "BIGINT",
|
|
59
|
+
"double": "DOUBLE",
|
|
60
|
+
"date": "TIMESTAMP",
|
|
61
|
+
"flag": "VARCHAR(1)",
|
|
62
|
+
"string": "VARCHAR",
|
|
63
|
+
}
|
|
64
|
+
_BUCKET_TO_SNOWFLAKE = {
|
|
65
|
+
"integer": "NUMBER(19)",
|
|
66
|
+
"double": "FLOAT",
|
|
67
|
+
"date": "TIMESTAMP_NTZ",
|
|
68
|
+
"flag": "VARCHAR(1)",
|
|
69
|
+
"string": "VARCHAR",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TypeMismatch(Exception):
|
|
74
|
+
"""Naming heuristic and value sniffing disagree on a column's type."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def heuristic_bucket(field: str) -> str | None:
|
|
78
|
+
"""Return a type bucket from the column name suffix, or None."""
|
|
79
|
+
for suffix, bucket in _SUFFIX_BUCKETS:
|
|
80
|
+
if field == suffix.lstrip("_") or field.endswith(suffix):
|
|
81
|
+
return bucket
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sniff_bucket(values: Iterable[str], max_samples: int = 1000) -> str | None:
|
|
86
|
+
"""Return a type bucket from observed values, or None if all empty.
|
|
87
|
+
|
|
88
|
+
Samples up to max_samples non-empty values to avoid O(n) scans of huge tables.
|
|
89
|
+
"""
|
|
90
|
+
non_empty: list[str] = []
|
|
91
|
+
for v in values:
|
|
92
|
+
if v != "" and v is not None:
|
|
93
|
+
non_empty.append(v)
|
|
94
|
+
if len(non_empty) >= max_samples:
|
|
95
|
+
break
|
|
96
|
+
if not non_empty:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Date check first; "2025-01-01" parses as nothing else useful.
|
|
100
|
+
if all(_DATE_RE.match(v) for v in non_empty):
|
|
101
|
+
return "date"
|
|
102
|
+
# Y/N flags
|
|
103
|
+
if all(v in ("Y", "N") for v in non_empty):
|
|
104
|
+
return "flag"
|
|
105
|
+
# Integers (no decimal point, parseable)
|
|
106
|
+
if all(_is_int(v) for v in non_empty):
|
|
107
|
+
return "integer"
|
|
108
|
+
# Floats (parseable as float, possibly with decimal)
|
|
109
|
+
if all(_is_float(v) for v in non_empty):
|
|
110
|
+
return "double"
|
|
111
|
+
return "string"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_int(v: str) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
int(v)
|
|
117
|
+
return True
|
|
118
|
+
except ValueError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_float(v: str) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
float(v)
|
|
125
|
+
return True
|
|
126
|
+
except ValueError:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# What each predicted bucket can cleanly hold. Asymmetric: a column predicted
|
|
131
|
+
# `double` accepts integer-valued data (ints fit in doubles), but a column
|
|
132
|
+
# predicted `integer` does NOT accept floating-point data (would lose precision).
|
|
133
|
+
_ABSORBS: dict[str, frozenset[str]] = {
|
|
134
|
+
"string": frozenset({"string", "flag", "integer", "double", "date"}),
|
|
135
|
+
"double": frozenset({"double", "integer"}),
|
|
136
|
+
"integer": frozenset({"integer"}),
|
|
137
|
+
"flag": frozenset({"flag"}),
|
|
138
|
+
"date": frozenset({"date"}),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def infer_unknown(table: str, field: str, values: Iterable[str]) -> ColumnSpec:
|
|
143
|
+
"""Resolve type for a column NOT in COLUMN_TYPES.
|
|
144
|
+
|
|
145
|
+
Raises TypeMismatch if the naming heuristic and value sniffing disagree
|
|
146
|
+
in a way that would lose information. Compatible disagreements (naming
|
|
147
|
+
says double but data is integer) resolve to the wider type. If neither
|
|
148
|
+
has an opinion (no suffix match, all values empty), default to string.
|
|
149
|
+
"""
|
|
150
|
+
h = heuristic_bucket(field)
|
|
151
|
+
s = sniff_bucket(values)
|
|
152
|
+
if h is None and s is None:
|
|
153
|
+
bucket = "string"
|
|
154
|
+
elif h is None:
|
|
155
|
+
bucket = s
|
|
156
|
+
elif s is None:
|
|
157
|
+
bucket = h
|
|
158
|
+
elif s in _ABSORBS.get(h, frozenset()):
|
|
159
|
+
bucket = h
|
|
160
|
+
else:
|
|
161
|
+
raise TypeMismatch(
|
|
162
|
+
f"{table}.{field}: naming says {h!r}, sniffing says {s!r}"
|
|
163
|
+
)
|
|
164
|
+
return ColumnSpec(
|
|
165
|
+
duckdb_type=_BUCKET_TO_DUCKDB[bucket],
|
|
166
|
+
snowflake_type=_BUCKET_TO_SNOWFLAKE[bucket],
|
|
167
|
+
nullable=True,
|
|
168
|
+
description="",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def resolve_overrides(
|
|
173
|
+
blocks: Iterable[tuple[str, list[str], Iterable[list[str]]]],
|
|
174
|
+
) -> dict[tuple[str, str], ColumnSpec]:
|
|
175
|
+
"""Build per-XER type overrides for every column not in COLUMN_TYPES.
|
|
176
|
+
|
|
177
|
+
blocks: iterable of (table_name, field_names, row_iter) where each row is
|
|
178
|
+
a list of string cell values aligned with field_names.
|
|
179
|
+
|
|
180
|
+
Returns: {(table, field): ColumnSpec} for every unknown column. Raises
|
|
181
|
+
TypeMismatch on the first disagreement and stops; this is intentional
|
|
182
|
+
fail-fast behavior so the caller learns about new ambiguous columns
|
|
183
|
+
instead of silently mis-typing them.
|
|
184
|
+
"""
|
|
185
|
+
overrides: dict[tuple[str, str], ColumnSpec] = {}
|
|
186
|
+
for table, fields, rows in blocks:
|
|
187
|
+
unknown_idx = [
|
|
188
|
+
(i, f)
|
|
189
|
+
for i, f in enumerate(fields)
|
|
190
|
+
if f not in COLUMN_TYPES.get(table, {})
|
|
191
|
+
]
|
|
192
|
+
if not unknown_idx:
|
|
193
|
+
continue
|
|
194
|
+
# Materialize columns we care about.
|
|
195
|
+
cols: dict[int, list[str]] = {i: [] for i, _ in unknown_idx}
|
|
196
|
+
for row in rows:
|
|
197
|
+
for i in cols:
|
|
198
|
+
if i < len(row):
|
|
199
|
+
cols[i].append(row[i])
|
|
200
|
+
for i, f in unknown_idx:
|
|
201
|
+
if (table, f) in overrides:
|
|
202
|
+
continue
|
|
203
|
+
overrides[(table, f)] = infer_unknown(table, f, cols[i])
|
|
204
|
+
return overrides
|
p6flow/loader.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Load parsed XER tables into a DuckDB connection.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Materialize all tables (rows in memory, since type inference and
|
|
5
|
+
Arrow registration both need the full row set).
|
|
6
|
+
2. Resolve type overrides for unknown columns via p6flow.inference.
|
|
7
|
+
Fail fast if naming heuristic and value sniffing disagree.
|
|
8
|
+
3. Topo-sort tables so FK parents are inserted before children.
|
|
9
|
+
4. CREATE OR REPLACE TABLE for each one with constraints from schema.py.
|
|
10
|
+
5. INSERT data with per-column CAST(NULLIF(col, '') AS <type>) so empty
|
|
11
|
+
XER cells become SQL NULL.
|
|
12
|
+
|
|
13
|
+
Returns a per-table row count map for downstream validation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import duckdb
|
|
19
|
+
import pyarrow as pa
|
|
20
|
+
|
|
21
|
+
from .ddl import duckdb_ddl, topo_sort_tables
|
|
22
|
+
from .inference import resolve_overrides
|
|
23
|
+
from .schema import COLUMN_TYPES
|
|
24
|
+
from .tokenizer import Table # parse_tables imported lazily by callers
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RowArityMismatch(ValueError):
|
|
28
|
+
"""A `%R` row had MORE fields than `%F` declared. Format-level violation."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_rows(
|
|
32
|
+
table: str,
|
|
33
|
+
fields: list[str],
|
|
34
|
+
rows: list[list[str]],
|
|
35
|
+
) -> list[list[str]]:
|
|
36
|
+
"""Force every row to len(fields). Pad short rows with '', drop
|
|
37
|
+
trailing empty cells before checking arity, error on real overflow.
|
|
38
|
+
|
|
39
|
+
Short rows are common in P6 exports (trailing optional fields dropped).
|
|
40
|
+
Some exporters also emit a trailing tab on every row, producing an
|
|
41
|
+
extra empty cell that's not a real field. We tolerate that quietly
|
|
42
|
+
and only raise when extra cells carry actual data.
|
|
43
|
+
"""
|
|
44
|
+
n = len(fields)
|
|
45
|
+
out: list[list[str]] = []
|
|
46
|
+
for i, row in enumerate(rows):
|
|
47
|
+
# Drop trailing empties before arity check; they're an exporter
|
|
48
|
+
# artifact, not real data.
|
|
49
|
+
while len(row) > n and row[-1] == "":
|
|
50
|
+
row = row[:-1]
|
|
51
|
+
if len(row) > n:
|
|
52
|
+
raise RowArityMismatch(
|
|
53
|
+
f"{table} row {i}: {len(row)} fields but %F declared {n}; "
|
|
54
|
+
f"extra cells: {row[n:]!r}"
|
|
55
|
+
)
|
|
56
|
+
if len(row) < n:
|
|
57
|
+
row = row + [""] * (n - len(row))
|
|
58
|
+
out.append(row)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _materialize(
|
|
63
|
+
tables: list[Table],
|
|
64
|
+
) -> list[tuple[str, list[str], list[list[str]], list[str]]]:
|
|
65
|
+
"""Snapshot each parsed table into (name, fields, rows, raws) tuples
|
|
66
|
+
used by downstream type inference, Arrow registration, and the DW
|
|
67
|
+
column path. Trailing empty cells are dropped from rows but raws is
|
|
68
|
+
left untouched (its job is byte-faithful capture)."""
|
|
69
|
+
out = []
|
|
70
|
+
for t in tables:
|
|
71
|
+
rows = _normalize_rows(t.name, t.fields, list(t.rows))
|
|
72
|
+
out.append((t.name, t.fields, rows, list(t.raws)))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cast_select(table: str, fields: list[str], overrides) -> str:
|
|
77
|
+
"""Build a SELECT list that casts each string column to its target type.
|
|
78
|
+
|
|
79
|
+
Uses NULLIF(col, '') to convert XER's empty-string convention to SQL NULL.
|
|
80
|
+
The cast type comes from per-XER overrides first, then schema.py, with
|
|
81
|
+
VARCHAR as final fallback.
|
|
82
|
+
"""
|
|
83
|
+
parts: list[str] = []
|
|
84
|
+
for f in fields:
|
|
85
|
+
spec = None
|
|
86
|
+
if overrides and (table, f) in overrides:
|
|
87
|
+
spec = overrides[(table, f)]
|
|
88
|
+
if spec is None:
|
|
89
|
+
spec = COLUMN_TYPES.get(table, {}).get(f)
|
|
90
|
+
ddb_type = spec.duckdb_type if spec else "VARCHAR"
|
|
91
|
+
if ddb_type == "VARCHAR" or ddb_type.startswith("VARCHAR("):
|
|
92
|
+
# No cast needed; just NULLIF empty strings. Don't NULLIF
|
|
93
|
+
# 'null' because a VARCHAR column might legitimately hold
|
|
94
|
+
# the literal string "null" (e.g. notes/descriptions).
|
|
95
|
+
parts.append(f'NULLIF("{f}", \'\') AS "{f}"')
|
|
96
|
+
elif ddb_type == "BLOB":
|
|
97
|
+
# CAST(varchar AS BLOB) interprets the string as hex-escaped
|
|
98
|
+
# bytes and rejects raw non-ASCII (e.g. UTF-8 BOM survivors,
|
|
99
|
+
# curly quotes in description fields). encode() takes the
|
|
100
|
+
# string's UTF-8 byte representation directly.
|
|
101
|
+
parts.append(f'encode(NULLIF("{f}", \'\')) AS "{f}"')
|
|
102
|
+
else:
|
|
103
|
+
# Typed casts (BIGINT/DOUBLE/TIMESTAMP/etc.) must treat both
|
|
104
|
+
# '' and the literal string 'null'/'NULL' as SQL NULL. Some
|
|
105
|
+
# P6 exporters emit the string "null" in lieu of empty cells
|
|
106
|
+
# for missing numerics; without this, the cast fails.
|
|
107
|
+
parts.append(
|
|
108
|
+
f"CAST(NULLIF(NULLIF(LOWER(\"{f}\"), 'null'), '') "
|
|
109
|
+
f"AS {ddb_type}) AS \"{f}\""
|
|
110
|
+
)
|
|
111
|
+
return ", ".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_xer(
|
|
115
|
+
con: duckdb.DuckDBPyConnection,
|
|
116
|
+
tables: list[Table],
|
|
117
|
+
add_dw_columns: bool = False,
|
|
118
|
+
) -> dict[str, int]:
|
|
119
|
+
"""Load every parsed table into the DuckDB connection. Return row counts per table.
|
|
120
|
+
|
|
121
|
+
When add_dw_columns=True, every loaded table gains an `_raw` VARCHAR
|
|
122
|
+
column containing the byte-faithful `%R\\t...` line captured by the
|
|
123
|
+
tokenizer (newline-stripped). Used by the warehouse path so
|
|
124
|
+
consumers can recover the exact source line of any parsed row for
|
|
125
|
+
debugging. Other DW columns (source_xer, source_sha256,
|
|
126
|
+
flow_published_at, exported_at) are constants per file and get
|
|
127
|
+
added at COPY time in output.write_parquet.
|
|
128
|
+
"""
|
|
129
|
+
materialized = _materialize(tables)
|
|
130
|
+
rows_by_name = {t: (fs, rs, raws) for t, fs, rs, raws in materialized}
|
|
131
|
+
available = {t for t, _, _, _ in materialized}
|
|
132
|
+
|
|
133
|
+
# Type inference for unknown columns. Fails fast on conflicting evidence.
|
|
134
|
+
# resolve_overrides only needs (name, fields, row_iter); raws are unused.
|
|
135
|
+
overrides = resolve_overrides(
|
|
136
|
+
[(t, fs, rs) for t, fs, rs, _ in materialized]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Topo sort by FK dependencies (parents first; DuckDB enforces FKs at INSERT).
|
|
140
|
+
ordered_pairs = topo_sort_tables([(t, fs) for t, fs, _, _ in materialized])
|
|
141
|
+
|
|
142
|
+
counts: dict[str, int] = {}
|
|
143
|
+
for table, fields in ordered_pairs:
|
|
144
|
+
_, rows, raws = rows_by_name[table]
|
|
145
|
+
|
|
146
|
+
# An empty %T block (zero columns, zero rows) is a real XER
|
|
147
|
+
# quirk; treat it as "not present in this export" rather than
|
|
148
|
+
# creating a column-less table that DuckDB rejects.
|
|
149
|
+
if not fields:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
emit_fields = list(fields)
|
|
153
|
+
emit_rows = rows
|
|
154
|
+
if add_dw_columns:
|
|
155
|
+
# Append _raw alongside the parsed cells using the raw line
|
|
156
|
+
# captured at tokenization time. VARCHAR by default since
|
|
157
|
+
# it isn't in COLUMN_TYPES.
|
|
158
|
+
#
|
|
159
|
+
# Defensive: if for some reason raws is shorter than rows
|
|
160
|
+
# (shouldn't happen in well-formed XERs, but guard against
|
|
161
|
+
# future tokenizer changes), fall back to empty string.
|
|
162
|
+
emit_fields = [*fields, "_raw"]
|
|
163
|
+
emit_rows = [
|
|
164
|
+
[*r, raws[i] if i < len(raws) else ""]
|
|
165
|
+
for i, r in enumerate(rows)
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# CREATE OR REPLACE the staging table; only emit FKs to tables we'll create.
|
|
169
|
+
con.execute(
|
|
170
|
+
duckdb_ddl(table, emit_fields, overrides, available_tables=available)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if not emit_rows:
|
|
174
|
+
counts[table] = 0
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
# Pivot rows into columns, build an Arrow table of all-string cells,
|
|
178
|
+
# register it, INSERT with casts.
|
|
179
|
+
cols = list(zip(*emit_rows, strict=True))
|
|
180
|
+
arrow_table = pa.table({f: list(cols[i]) for i, f in enumerate(emit_fields)})
|
|
181
|
+
con.register("xer_raw", arrow_table)
|
|
182
|
+
try:
|
|
183
|
+
select_list = _cast_select(table, emit_fields, overrides)
|
|
184
|
+
con.execute(f"INSERT INTO {table} SELECT {select_list} FROM xer_raw")
|
|
185
|
+
finally:
|
|
186
|
+
con.unregister("xer_raw")
|
|
187
|
+
|
|
188
|
+
counts[table] = len(emit_rows)
|
|
189
|
+
return counts
|
p6flow/output.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Parquet writer and manifest builder.
|
|
2
|
+
|
|
3
|
+
Writes one Parquet file per loaded DuckDB table plus a manifest.json
|
|
4
|
+
that captures provenance, row counts, and schema fingerprints. The
|
|
5
|
+
manifest is the cheap "did anything change" diff target across runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import duckdb
|
|
17
|
+
|
|
18
|
+
from .tokenizer import Header, read_header
|
|
19
|
+
from .validate import FkViolation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sha256(path: Path, chunk_size: int = 1 << 20) -> str:
|
|
23
|
+
h = hashlib.sha256()
|
|
24
|
+
with path.open("rb") as f:
|
|
25
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
26
|
+
h.update(chunk)
|
|
27
|
+
return h.hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _schema_fingerprint(con: duckdb.DuckDBPyConnection, table: str) -> str:
|
|
31
|
+
"""Hash of (column_name, type) tuples in declaration order. Stable
|
|
32
|
+
across runs as long as DDL doesn't drift."""
|
|
33
|
+
rows = con.execute(f'DESCRIBE "{table}"').fetchall()
|
|
34
|
+
payload = "|".join(f"{r[0]}:{r[1]}" for r in rows)
|
|
35
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _violation_to_dict(v: FkViolation) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"child_table": v.child_table,
|
|
41
|
+
"fk_name": v.fk_name,
|
|
42
|
+
"target_table": v.target_table,
|
|
43
|
+
"local_fields": list(v.local_fields),
|
|
44
|
+
"target_fields": list(v.target_fields),
|
|
45
|
+
"kind": v.kind,
|
|
46
|
+
"violating_rows": v.violating_rows,
|
|
47
|
+
"sample_keys": [list(k) for k in v.sample_keys],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sql_str_lit(s: str) -> str:
|
|
52
|
+
"""Single-quote a value for safe inlining into a DuckDB SQL literal."""
|
|
53
|
+
return "'" + s.replace("'", "''") + "'"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dw_select(
|
|
57
|
+
table: str,
|
|
58
|
+
cols_in_table: set[str],
|
|
59
|
+
src_tag: str,
|
|
60
|
+
sha: str,
|
|
61
|
+
export_date_lit: str,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Build the SELECT projection that adds DW columns to one table.
|
|
64
|
+
|
|
65
|
+
Source tables (parsed from %T blocks) already carry `_raw` from
|
|
66
|
+
load_xer. Derived tables (CALENDAR_WEEKLY, *_UDF, UDF_COLUMN_MAP)
|
|
67
|
+
don't, so we emit `_raw = NULL` to keep every parquet's DW column
|
|
68
|
+
shape uniform across the corpus.
|
|
69
|
+
"""
|
|
70
|
+
raw_clause = (
|
|
71
|
+
'"_raw"' if "_raw" in cols_in_table else "CAST(NULL AS VARCHAR) AS _raw"
|
|
72
|
+
)
|
|
73
|
+
base = (
|
|
74
|
+
'SELECT * EXCLUDE ("_raw")'
|
|
75
|
+
if "_raw" in cols_in_table
|
|
76
|
+
else "SELECT *"
|
|
77
|
+
)
|
|
78
|
+
return (
|
|
79
|
+
f'{base}, '
|
|
80
|
+
f'{_sql_str_lit(src_tag)} AS source_xer, '
|
|
81
|
+
f'{_sql_str_lit(sha)} AS source_sha256, '
|
|
82
|
+
f'CAST((CURRENT_TIMESTAMP AT TIME ZONE \'UTC\') AS TIMESTAMP) '
|
|
83
|
+
f'AS flow_published_at, '
|
|
84
|
+
f'{export_date_lit} AS exported_at, '
|
|
85
|
+
f'{raw_clause} '
|
|
86
|
+
f'FROM "{table}"'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_parquet(
|
|
91
|
+
con: duckdb.DuckDBPyConnection,
|
|
92
|
+
out_dir: Path,
|
|
93
|
+
source_xer: Path,
|
|
94
|
+
counts: dict[str, int],
|
|
95
|
+
violations: list[FkViolation] | None = None,
|
|
96
|
+
udf_unknown_types: list[dict] | None = None,
|
|
97
|
+
add_dw_columns: bool = False,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Write each DuckDB table to a Parquet file in out_dir, plus manifest.json.
|
|
100
|
+
|
|
101
|
+
Returns the manifest dict for inspection in callers/tests. If
|
|
102
|
+
violations is None, the manifest's "fk_violations" key is omitted
|
|
103
|
+
(validation was not requested for this run). udf_unknown_types is
|
|
104
|
+
only emitted when non-empty.
|
|
105
|
+
|
|
106
|
+
When add_dw_columns is True, every emitted parquet gets data-
|
|
107
|
+
warehouse columns suitable for cross-XER joins:
|
|
108
|
+
|
|
109
|
+
source_xer basename of the input file (no extension)
|
|
110
|
+
source_sha256 hex digest matching the manifest entry
|
|
111
|
+
flow_published_at TIMESTAMP at COPY time (ingestion clock)
|
|
112
|
+
exported_at TIMESTAMP from ERMHDR export_date
|
|
113
|
+
_raw reconstructed `%R\\t...` line per row
|
|
114
|
+
(added in load_xer; only on source tables —
|
|
115
|
+
derived tables like CALENDAR_WEEKLY do not
|
|
116
|
+
have an upstream %R line).
|
|
117
|
+
|
|
118
|
+
The natural cross-XER join key becomes (source_xer, <p6_id>) since
|
|
119
|
+
P6 IDs are stable inside one export but collide across exports.
|
|
120
|
+
"""
|
|
121
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
header: Header = read_header(source_xer)
|
|
123
|
+
sha = _sha256(source_xer)
|
|
124
|
+
src_tag = source_xer.stem
|
|
125
|
+
export_date_lit = (
|
|
126
|
+
f"CAST({_sql_str_lit(header.export_date)} AS TIMESTAMP)"
|
|
127
|
+
if header.export_date
|
|
128
|
+
else "CAST(NULL AS TIMESTAMP)"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
table_entries: list[dict[str, Any]] = []
|
|
132
|
+
for table in sorted(counts):
|
|
133
|
+
parquet_path = out_dir / f"{table}.parquet"
|
|
134
|
+
if add_dw_columns:
|
|
135
|
+
cols_in_table = {
|
|
136
|
+
r[0] for r in con.execute(f'DESCRIBE "{table}"').fetchall()
|
|
137
|
+
}
|
|
138
|
+
select_sql = _dw_select(
|
|
139
|
+
table, cols_in_table, src_tag, sha, export_date_lit
|
|
140
|
+
)
|
|
141
|
+
con.execute(
|
|
142
|
+
f"COPY ({select_sql}) TO '{parquet_path}' "
|
|
143
|
+
"(FORMAT PARQUET, COMPRESSION ZSTD)"
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
con.execute(
|
|
147
|
+
f"COPY \"{table}\" TO '{parquet_path}' "
|
|
148
|
+
"(FORMAT PARQUET, COMPRESSION ZSTD)"
|
|
149
|
+
)
|
|
150
|
+
table_entries.append(
|
|
151
|
+
{
|
|
152
|
+
"name": table,
|
|
153
|
+
"rows": counts[table],
|
|
154
|
+
"parquet": parquet_path.name,
|
|
155
|
+
"schema_fingerprint": _schema_fingerprint(con, table),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
manifest: dict[str, Any] = {
|
|
160
|
+
"source_xer": str(source_xer.resolve()),
|
|
161
|
+
"source_sha256": _sha256(source_xer),
|
|
162
|
+
"parsed_at": datetime.now(tz=UTC).isoformat(),
|
|
163
|
+
"p6_version": header.version,
|
|
164
|
+
"export_date": header.export_date,
|
|
165
|
+
"project_label": header.project_label,
|
|
166
|
+
"tables": table_entries,
|
|
167
|
+
}
|
|
168
|
+
if violations is not None:
|
|
169
|
+
manifest["fk_violations"] = [_violation_to_dict(v) for v in violations]
|
|
170
|
+
if udf_unknown_types:
|
|
171
|
+
manifest["udf_unknown_types"] = udf_unknown_types
|
|
172
|
+
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
173
|
+
return manifest
|