pipeverify 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {pipeverify-0.1.0 → pipeverify-0.2.0}/PKG-INFO +41 -1
- {pipeverify-0.1.0 → pipeverify-0.2.0}/README.md +40 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/__init__.py +1 -1
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/checks.py +4 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/cli.py +16 -3
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/core.py +30 -15
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/report.py +10 -2
- pipeverify-0.2.0/pipeverify/source_checks.py +125 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pyproject.toml +1 -1
- {pipeverify-0.1.0 → pipeverify-0.2.0}/.gitignore +0 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/LICENSE +0 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/NOTICE +0 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/explain.py +0 -0
- {pipeverify-0.1.0 → pipeverify-0.2.0}/pipeverify/sources.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pipeverify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Check that a pipeline change left the data right, not just the job green.
|
|
5
5
|
Project-URL: Homepage, https://github.com/akshaysharma1088/pipeverify
|
|
6
6
|
Project-URL: Issues, https://github.com/akshaysharma1088/pipeverify/issues
|
|
@@ -37,6 +37,8 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
[](LICENSE)
|
|
38
38
|

|
|
39
39
|

|
|
40
|
+
[](https://pypi.org/project/pipeverify/)
|
|
41
|
+
[](https://doi.org/10.5281/zenodo.23542278)
|
|
40
42
|
|
|
41
43
|
A pipeline change can pass every test, finish on time and keep the schema intact while quietly
|
|
42
44
|
dropping rows, duplicating keys or changing what a column means. More of these changes are now
|
|
@@ -161,6 +163,44 @@ Exit code is `1` when the verdict reaches `--fail-on` (default `block`), otherwi
|
|
|
161
163
|
Columns listed in `expect_change` are reported but never raise the verdict. All thresholds can be
|
|
162
164
|
changed globally or per table; see [`pipeverify/checks.py`](pipeverify/checks.py) for every option.
|
|
163
165
|
|
|
166
|
+
## Source checks
|
|
167
|
+
|
|
168
|
+
A before/after diff only sees damage to rows that already existed. Errors in **new** data need
|
|
169
|
+
evidence the pipeline does not control: the raw source. Add a `source` block to a table:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
tables:
|
|
173
|
+
- name: silver.transactions
|
|
174
|
+
after: snapshots/pr/silver_transactions.parquet
|
|
175
|
+
before: snapshots/main/silver_transactions.parquet # optional
|
|
176
|
+
key: [txn_id]
|
|
177
|
+
source:
|
|
178
|
+
data: landing/raw_transactions.parquet
|
|
179
|
+
key: [txn_id]
|
|
180
|
+
columns: {amount: amount} # output column: source column
|
|
181
|
+
checks: [conflict] # default; also mismatch, missing, orphan
|
|
182
|
+
where: "status = 'POSTED'" # source rows that should reach the output
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Check | Catches | Default |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `conflict` | The source sent several values for one key and the output silently kept one | on, REVIEW |
|
|
188
|
+
| `mismatch` | An output value the source never sent (use only for pass-through columns) | opt-in |
|
|
189
|
+
| `missing` | Source keys that never reached the output (set `where` for intended filters) | opt-in |
|
|
190
|
+
| `orphan` | Output keys that are not in the source | opt-in |
|
|
191
|
+
|
|
192
|
+
From the command line:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
In a pilot on the 209 executed fixes in the PipeMend benchmark (25 harmful), production-style
|
|
199
|
+
monitors caught 16 harmful fixes, and the source conflict check caught the 9 that the monitors
|
|
200
|
+
approved. Together they caught all 25, at the cost of a REVIEW on 21 of 184 correct fixes,
|
|
201
|
+
all on incidents where the source data really was ambiguous. This is a small pilot on synthetic
|
|
202
|
+
data; a larger evaluation is in progress.
|
|
203
|
+
|
|
164
204
|
## GitHub Action
|
|
165
205
|
|
|
166
206
|
```yaml
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|

|
|
8
8
|

|
|
9
|
+
[](https://pypi.org/project/pipeverify/)
|
|
10
|
+
[](https://doi.org/10.5281/zenodo.23542278)
|
|
9
11
|
|
|
10
12
|
A pipeline change can pass every test, finish on time and keep the schema intact while quietly
|
|
11
13
|
dropping rows, duplicating keys or changing what a column means. More of these changes are now
|
|
@@ -130,6 +132,44 @@ Exit code is `1` when the verdict reaches `--fail-on` (default `block`), otherwi
|
|
|
130
132
|
Columns listed in `expect_change` are reported but never raise the verdict. All thresholds can be
|
|
131
133
|
changed globally or per table; see [`pipeverify/checks.py`](pipeverify/checks.py) for every option.
|
|
132
134
|
|
|
135
|
+
## Source checks
|
|
136
|
+
|
|
137
|
+
A before/after diff only sees damage to rows that already existed. Errors in **new** data need
|
|
138
|
+
evidence the pipeline does not control: the raw source. Add a `source` block to a table:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
tables:
|
|
142
|
+
- name: silver.transactions
|
|
143
|
+
after: snapshots/pr/silver_transactions.parquet
|
|
144
|
+
before: snapshots/main/silver_transactions.parquet # optional
|
|
145
|
+
key: [txn_id]
|
|
146
|
+
source:
|
|
147
|
+
data: landing/raw_transactions.parquet
|
|
148
|
+
key: [txn_id]
|
|
149
|
+
columns: {amount: amount} # output column: source column
|
|
150
|
+
checks: [conflict] # default; also mismatch, missing, orphan
|
|
151
|
+
where: "status = 'POSTED'" # source rows that should reach the output
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Check | Catches | Default |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `conflict` | The source sent several values for one key and the output silently kept one | on, REVIEW |
|
|
157
|
+
| `mismatch` | An output value the source never sent (use only for pass-through columns) | opt-in |
|
|
158
|
+
| `missing` | Source keys that never reached the output (set `where` for intended filters) | opt-in |
|
|
159
|
+
| `orphan` | Output keys that are not in the source | opt-in |
|
|
160
|
+
|
|
161
|
+
From the command line:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
In a pilot on the 209 executed fixes in the PipeMend benchmark (25 harmful), production-style
|
|
168
|
+
monitors caught 16 harmful fixes, and the source conflict check caught the 9 that the monitors
|
|
169
|
+
approved. Together they caught all 25, at the cost of a REVIEW on 21 of 184 correct fixes,
|
|
170
|
+
all on incidents where the source data really was ambiguous. This is a small pilot on synthetic
|
|
171
|
+
data; a larger evaluation is in progress.
|
|
172
|
+
|
|
133
173
|
## GitHub Action
|
|
134
174
|
|
|
135
175
|
```yaml
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""pipeverify: check that a pipeline change left the data right, not just the job green."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
5
|
from .checks import Finding, Thresholds # noqa: E402
|
|
6
6
|
from .core import RunResult, TableResult, run_config, verify # noqa: E402
|
|
@@ -38,6 +38,10 @@ class Thresholds:
|
|
|
38
38
|
sum_shift_block_pct: float = 5.0
|
|
39
39
|
category_max_distinct: int = 50 # treat text columns with few values as categories
|
|
40
40
|
float_tolerance: float = 1e-9
|
|
41
|
+
source_mismatch_review_pct: float = 0.0
|
|
42
|
+
source_mismatch_block_pct: float = 5.0
|
|
43
|
+
source_missing_review_pct: float = 0.0
|
|
44
|
+
source_missing_block_pct: float = 5.0
|
|
41
45
|
|
|
42
46
|
@classmethod
|
|
43
47
|
def from_dict(cls, data: dict[str, Any] | None) -> "Thresholds":
|
|
@@ -97,27 +97,40 @@ def main() -> None:
|
|
|
97
97
|
@main.command()
|
|
98
98
|
@click.argument("before")
|
|
99
99
|
@click.argument("after")
|
|
100
|
+
@click.option("--source", "source_data", help="Raw source table the output was built from (enables source checks).")
|
|
101
|
+
@click.option("--source-key", help="Key column(s) in the source, if named differently from --key.")
|
|
102
|
+
@click.option("--source-column", "source_columns", multiple=True,
|
|
103
|
+
help="Column to check against the source, as out=src or just name. Repeatable.")
|
|
104
|
+
@click.option("--source-where", help="SQL filter for source rows that should reach the output.")
|
|
100
105
|
@click.option("--key", "-k", help="Comma-separated key column(s) that identify a row.")
|
|
101
106
|
@click.option("--expect-change", "-e", multiple=True, help="Column this change is meant to modify. Repeatable.")
|
|
102
107
|
@click.option("--ignore", "-i", multiple=True, help="Column to skip, such as a load timestamp. Repeatable.")
|
|
103
108
|
@click.option("--expect-row-change", is_flag=True, help="Rows are expected to be added or removed.")
|
|
104
109
|
@click.option("--name", default=None, help="Name to show for the table.")
|
|
105
110
|
@with_output_options
|
|
106
|
-
def compare(before, after,
|
|
111
|
+
def compare(before, after, source_data, source_key, source_columns, source_where,
|
|
112
|
+
key, expect_change, ignore, expect_row_change, name,
|
|
107
113
|
fmt, output, summary, fail_on, explain, show_info, json_out, markdown_out):
|
|
108
114
|
"""Compare one table BEFORE and AFTER a change.
|
|
109
115
|
|
|
116
|
+
Use - as BEFORE to run only the source checks.
|
|
117
|
+
|
|
110
118
|
\b
|
|
111
119
|
Examples:
|
|
112
120
|
pipeverify compare before.parquet after.parquet --key order_id
|
|
121
|
+
pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
|
|
113
122
|
pipeverify compare duckdb:prod.db:gold.sales duckdb:dev.db:gold.sales -k id
|
|
114
123
|
"""
|
|
115
124
|
import time
|
|
116
125
|
start = time.perf_counter()
|
|
126
|
+
source = None
|
|
127
|
+
if source_data:
|
|
128
|
+
cols = dict((c.split("=", 1) if "=" in c else (c, c)) for c in source_columns)
|
|
129
|
+
source = {"data": source_data, "key": source_key, "columns": cols, "where": source_where}
|
|
117
130
|
try:
|
|
118
|
-
res = verify(before, after, name=name or os.path.basename(str(after)), key=key,
|
|
131
|
+
res = verify(None if before == "-" else before, after, name=name or os.path.basename(str(after)), key=key,
|
|
119
132
|
expect_change=list(expect_change), ignore=list(ignore),
|
|
120
|
-
expect_row_change=expect_row_change)
|
|
133
|
+
expect_row_change=expect_row_change, source=source)
|
|
121
134
|
except Exception as exc:
|
|
122
135
|
raise click.ClickException(str(exc))
|
|
123
136
|
result = RunResult(tables=[res], seconds=time.perf_counter() - start)
|
|
@@ -10,6 +10,7 @@ import duckdb
|
|
|
10
10
|
import yaml
|
|
11
11
|
|
|
12
12
|
from .checks import BLOCK, PASS, RANK, REVIEW, Finding, Thresholds, run_checks, verdict
|
|
13
|
+
from .source_checks import SourceSpec, run_source_checks
|
|
13
14
|
from .sources import load, parse_source
|
|
14
15
|
|
|
15
16
|
|
|
@@ -50,33 +51,47 @@ class RunResult:
|
|
|
50
51
|
def verify(before: Any, after: Any, *, name: str = "table", key: list[str] | str | None = None,
|
|
51
52
|
expect_change: list[str] | None = None, ignore: list[str] | None = None,
|
|
52
53
|
expect_row_change: bool = False, thresholds: Thresholds | dict | None = None,
|
|
53
|
-
base_dir: str = ".") -> TableResult:
|
|
54
|
-
"""Compare two snapshots of one table.
|
|
54
|
+
source: SourceSpec | dict | None = None, base_dir: str = ".") -> TableResult:
|
|
55
|
+
"""Compare two snapshots of one table, and optionally the output with its raw source.
|
|
55
56
|
|
|
56
|
-
`before` and `
|
|
57
|
-
config mappings, or pandas DataFrames.
|
|
57
|
+
`before`, `after` and `source["data"]` can be file paths, duckdb:<file>:<table>
|
|
58
|
+
strings, config mappings, or pandas DataFrames. `before` may be None when only
|
|
59
|
+
source checks are wanted (for example when no earlier snapshot exists).
|
|
58
60
|
"""
|
|
59
61
|
if isinstance(key, str):
|
|
60
62
|
key = [k.strip() for k in key.split(",") if k.strip()]
|
|
61
63
|
if not isinstance(thresholds, Thresholds):
|
|
62
64
|
thresholds = Thresholds.from_dict(thresholds)
|
|
63
|
-
|
|
65
|
+
if before is None and source is None:
|
|
66
|
+
raise ValueError("Give a before snapshot, a source, or both")
|
|
67
|
+
src_b = parse_source(before, base_dir) if before is not None else None
|
|
68
|
+
src_a = parse_source(after, base_dir)
|
|
64
69
|
con = duckdb.connect()
|
|
65
70
|
try:
|
|
66
|
-
load(con, src_b, "before")
|
|
67
71
|
load(con, src_a, "after")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
if src_b is not None:
|
|
73
|
+
load(con, src_b, "before")
|
|
74
|
+
findings, stats = run_checks(con, "before", "after", key=key, expect_change=expect_change,
|
|
75
|
+
ignore=ignore, expect_row_change=expect_row_change,
|
|
76
|
+
thresholds=thresholds)
|
|
77
|
+
if not key:
|
|
78
|
+
findings.append(Finding("keys", "info",
|
|
79
|
+
"No key given, so row-level changes were not compared. Add a key for stronger checks."))
|
|
80
|
+
else:
|
|
81
|
+
findings = []
|
|
82
|
+
stats = {"rows_after": con.execute('SELECT count(*) FROM "after"').fetchone()[0]}
|
|
83
|
+
if source is not None:
|
|
84
|
+
spec = SourceSpec.from_dict(source)
|
|
85
|
+
load(con, parse_source(spec.data, base_dir), "source")
|
|
86
|
+
sf, sstats = run_source_checks(con, "after", "source", spec, key or [], thresholds)
|
|
87
|
+
findings += sf
|
|
88
|
+
stats.update(sstats)
|
|
74
89
|
finally:
|
|
75
90
|
con.close()
|
|
76
91
|
order = {BLOCK: 0, REVIEW: 1, "info": 2, PASS: 3}
|
|
77
92
|
findings.sort(key=lambda f: order[f.severity])
|
|
78
93
|
return TableResult(name=name, verdict=verdict(findings), findings=findings, stats=stats,
|
|
79
|
-
before=src_b.describe(), after=src_a.describe())
|
|
94
|
+
before=src_b.describe() if src_b else "(none)", after=src_a.describe())
|
|
80
95
|
|
|
81
96
|
|
|
82
97
|
def load_config(path: str) -> dict[str, Any]:
|
|
@@ -99,10 +114,10 @@ def run_config(path: str, only: list[str] | None = None) -> RunResult:
|
|
|
99
114
|
continue
|
|
100
115
|
th = {**defaults, **(spec.get("thresholds") or {})}
|
|
101
116
|
try:
|
|
102
|
-
res = verify(spec
|
|
117
|
+
res = verify(spec.get("before"), spec["after"], name=str(name), key=spec.get("key"),
|
|
103
118
|
expect_change=spec.get("expect_change"), ignore=spec.get("ignore"),
|
|
104
119
|
expect_row_change=bool(spec.get("expect_row_change", False)),
|
|
105
|
-
thresholds=th, base_dir=base_dir)
|
|
120
|
+
thresholds=th, source=spec.get("source"), base_dir=base_dir)
|
|
106
121
|
except Exception as exc: # report the table as blocked, keep checking the rest
|
|
107
122
|
res = TableResult(name=str(name), verdict=BLOCK, stats={},
|
|
108
123
|
findings=[Finding("error", BLOCK, f"Could not check this table: {exc}")],
|
|
@@ -36,7 +36,10 @@ def to_text(result: RunResult, show_info: bool = False) -> str:
|
|
|
36
36
|
s = t.stats
|
|
37
37
|
if s:
|
|
38
38
|
extra = f", matched {s['matched_rows']:,}" if "matched_rows" in s else ""
|
|
39
|
-
|
|
39
|
+
if "rows_before" in s:
|
|
40
|
+
lines.append(f" rows {s['rows_before']:,} -> {s.get('rows_after', 0):,}{extra}")
|
|
41
|
+
else:
|
|
42
|
+
lines.append(f" rows {s.get('rows_after', 0):,} (checked against source)")
|
|
40
43
|
for f in t.findings:
|
|
41
44
|
if f.severity == "info" and not show_info:
|
|
42
45
|
continue
|
|
@@ -57,7 +60,12 @@ def to_markdown(result: RunResult) -> str:
|
|
|
57
60
|
for t in result.tables:
|
|
58
61
|
issues = sum(1 for f in t.findings if f.severity in ("review", "block"))
|
|
59
62
|
s = t.stats or {}
|
|
60
|
-
|
|
63
|
+
if "rows_before" in s:
|
|
64
|
+
rows = f"{s['rows_before']:,} → {s.get('rows_after', 0):,}"
|
|
65
|
+
elif "rows_after" in s:
|
|
66
|
+
rows = f"{s['rows_after']:,} (source check)"
|
|
67
|
+
else:
|
|
68
|
+
rows = "n/a"
|
|
61
69
|
out.append(f"| `{t.name}` | {ICON[t.verdict]} {LABEL[t.verdict]} | {rows} | {issues} |")
|
|
62
70
|
for t in result.tables:
|
|
63
71
|
notable = [f for f in t.findings if f.severity in ("review", "block")]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Checks against the source: compare an output table with the raw data it was built from.
|
|
2
|
+
|
|
3
|
+
A before/after diff can only see damage to rows that existed before. Errors in new data
|
|
4
|
+
need evidence the pipeline does not control. These checks use the raw source table:
|
|
5
|
+
|
|
6
|
+
source_conflict the source sent several different values for one key and the output
|
|
7
|
+
kept one of them, so a choice was made that a person should confirm
|
|
8
|
+
source_mismatch an output value matches none of the values the source sent for that key
|
|
9
|
+
source_missing keys in the source (after an optional filter) that never reached the output
|
|
10
|
+
source_orphan keys in the output that do not exist in the source
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import duckdb
|
|
18
|
+
|
|
19
|
+
from .checks import BLOCK, INFO, PASS, REVIEW, Finding, Thresholds, q
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SourceSpec:
|
|
24
|
+
data: Any
|
|
25
|
+
key: list[str] | None = None # key column(s) in the source, default: the table key
|
|
26
|
+
columns: dict[str, str] = field(default_factory=dict) # output column -> source column
|
|
27
|
+
where: str | None = None # SQL filter for source rows that should reach the output
|
|
28
|
+
checks: list[str] | None = None # default ["conflict"]; also mismatch, missing, orphan
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, d: Any) -> "SourceSpec":
|
|
32
|
+
if isinstance(d, SourceSpec):
|
|
33
|
+
return d
|
|
34
|
+
if not isinstance(d, dict) or "data" not in d:
|
|
35
|
+
raise ValueError("source needs at least 'data' (where the raw table is)")
|
|
36
|
+
cols = d.get("columns") or {}
|
|
37
|
+
if isinstance(cols, list):
|
|
38
|
+
cols = {c: c for c in cols}
|
|
39
|
+
key = d.get("key")
|
|
40
|
+
if isinstance(key, str):
|
|
41
|
+
key = [k.strip() for k in key.split(",") if k.strip()]
|
|
42
|
+
return cls(data=d["data"], key=key, columns=dict(cols), where=d.get("where"),
|
|
43
|
+
checks=d.get("checks"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _grade(pct: float, review: float, block: float) -> str:
|
|
47
|
+
if pct >= block and pct > 0:
|
|
48
|
+
return BLOCK
|
|
49
|
+
if pct > review or (review == 0 and pct > 0):
|
|
50
|
+
return REVIEW
|
|
51
|
+
return PASS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, spec: SourceSpec,
|
|
55
|
+
key: list[str], t: Thresholds) -> tuple[list[Finding], dict[str, Any]]:
|
|
56
|
+
if not key:
|
|
57
|
+
raise ValueError("Source checks need a key on the table")
|
|
58
|
+
skey = spec.key or key
|
|
59
|
+
if len(skey) != len(key):
|
|
60
|
+
raise ValueError("The source key must have as many columns as the table key")
|
|
61
|
+
wanted = set(spec.checks or ["conflict"]) # others are opt-in: they need a filter or pass-through columns
|
|
62
|
+
where = f"WHERE {spec.where}" if spec.where else ""
|
|
63
|
+
# normalized source view: key columns renamed to the output's key names, compared as text
|
|
64
|
+
sel = [f"CAST({q(s)} AS VARCHAR) AS {q('_k' + str(i))}" for i, s in enumerate(skey)]
|
|
65
|
+
sel += [f"{q(sc)} AS {q('_v_' + oc)}" for oc, sc in spec.columns.items()]
|
|
66
|
+
con.execute(f"CREATE OR REPLACE TEMP VIEW _pv_src AS SELECT {', '.join(sel)} FROM {q(source)} {where}")
|
|
67
|
+
akeys = ", ".join(f"CAST({q(k)} AS VARCHAR) AS {q('_k' + str(i))}" for i, k in enumerate(key))
|
|
68
|
+
con.execute(f"CREATE OR REPLACE TEMP VIEW _pv_out AS SELECT {akeys}, * FROM {q(after)}")
|
|
69
|
+
on = " AND ".join(f"o.{q('_k' + str(i))} IS NOT DISTINCT FROM s.{q('_k' + str(i))}" for i in range(len(key)))
|
|
70
|
+
kcols = ", ".join(q("_k" + str(i)) for i in range(len(key)))
|
|
71
|
+
|
|
72
|
+
findings: list[Finding] = []
|
|
73
|
+
n_out = con.execute("SELECT count(*) FROM _pv_out").fetchone()[0]
|
|
74
|
+
n_src = con.execute(f"SELECT count(DISTINCT ({kcols})) FROM _pv_src").fetchone()[0]
|
|
75
|
+
stats = {"source_keys": n_src, "output_rows": n_out}
|
|
76
|
+
|
|
77
|
+
for oc in spec.columns:
|
|
78
|
+
v = q("_v_" + oc)
|
|
79
|
+
if "conflict" in wanted:
|
|
80
|
+
n = con.execute(
|
|
81
|
+
f"SELECT count(*) FROM (SELECT {kcols} FROM _pv_src GROUP BY ALL "
|
|
82
|
+
f"HAVING count(DISTINCT CAST({v} AS VARCHAR)) > 1) s "
|
|
83
|
+
f"WHERE EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchone()[0]
|
|
84
|
+
if n:
|
|
85
|
+
ex = con.execute(
|
|
86
|
+
f"SELECT {kcols}, list(DISTINCT CAST({v} AS VARCHAR) ORDER BY CAST({v} AS VARCHAR)) FROM _pv_src "
|
|
87
|
+
f"GROUP BY ALL HAVING count(DISTINCT CAST({v} AS VARCHAR)) > 1 LIMIT 3").fetchall()
|
|
88
|
+
pct = n / max(n_out, 1) * 100
|
|
89
|
+
findings.append(Finding(
|
|
90
|
+
"source_conflict", REVIEW,
|
|
91
|
+
f"{n:,} key(s) arrived from the source with conflicting '{oc}' values and the output kept one "
|
|
92
|
+
f"({pct:.2f}% of rows). Confirm which value is right.",
|
|
93
|
+
oc, {"count": n, "pct": round(pct, 4), "examples": [[str(x) for x in r] for r in ex]}))
|
|
94
|
+
if "mismatch" in wanted:
|
|
95
|
+
num = (f"TRY_CAST(o.{q(oc)} AS DOUBLE) IS NOT NULL AND TRY_CAST(s.{v} AS DOUBLE) IS NOT NULL AND "
|
|
96
|
+
f"abs(TRY_CAST(o.{q(oc)} AS DOUBLE) - TRY_CAST(s.{v} AS DOUBLE)) <= "
|
|
97
|
+
f"{t.float_tolerance} * greatest(1.0, abs(TRY_CAST(s.{v} AS DOUBLE)))")
|
|
98
|
+
txt = f"trim(CAST(o.{q(oc)} AS VARCHAR)) = trim(CAST(s.{v} AS VARCHAR))"
|
|
99
|
+
n = con.execute(
|
|
100
|
+
f"SELECT count(*) FROM _pv_out o WHERE EXISTS (SELECT 1 FROM _pv_src s WHERE {on}) "
|
|
101
|
+
f"AND NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on} AND (({num}) OR {txt} "
|
|
102
|
+
f"OR (o.{q(oc)} IS NULL AND s.{v} IS NULL)))").fetchone()[0]
|
|
103
|
+
if n:
|
|
104
|
+
pct = n / max(n_out, 1) * 100
|
|
105
|
+
sev = _grade(pct, t.source_mismatch_review_pct, t.source_mismatch_block_pct)
|
|
106
|
+
findings.append(Finding("source_mismatch", sev,
|
|
107
|
+
f"{n:,} row(s) have a '{oc}' value the source never sent ({pct:.2f}%)",
|
|
108
|
+
oc, {"count": n, "pct": round(pct, 4)}))
|
|
109
|
+
if "missing" in wanted:
|
|
110
|
+
n = con.execute(f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM _pv_src) s "
|
|
111
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchone()[0]
|
|
112
|
+
if n:
|
|
113
|
+
pct = n / max(n_src, 1) * 100
|
|
114
|
+
findings.append(Finding("source_missing", _grade(pct, t.source_missing_review_pct, t.source_missing_block_pct),
|
|
115
|
+
f"{n:,} source key(s) never reached the output ({pct:.2f}%)", None,
|
|
116
|
+
{"count": n, "pct": round(pct, 4)}))
|
|
117
|
+
if "orphan" in wanted:
|
|
118
|
+
n = con.execute(f"SELECT count(*) FROM _pv_out o WHERE NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on})").fetchone()[0]
|
|
119
|
+
if n:
|
|
120
|
+
pct = n / max(n_out, 1) * 100
|
|
121
|
+
findings.append(Finding("source_orphan", REVIEW if pct > 0 else INFO,
|
|
122
|
+
f"{n:,} output row(s) have keys that are not in the source ({pct:.2f}%)", None,
|
|
123
|
+
{"count": n, "pct": round(pct, 4)}))
|
|
124
|
+
stats["source_checks"] = sorted(wanted)
|
|
125
|
+
return findings, stats
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|