datasentry-core 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.
- datasentry_core/__init__.py +111 -0
- datasentry_core/connectors/__init__.py +49 -0
- datasentry_core/connectors/base.py +108 -0
- datasentry_core/connectors/csv.py +312 -0
- datasentry_core/connectors/duckdb.py +105 -0
- datasentry_core/connectors/errors.py +19 -0
- datasentry_core/connectors/file_based.py +209 -0
- datasentry_core/connectors/jsonl.py +71 -0
- datasentry_core/connectors/parquet.py +64 -0
- datasentry_core/connectors/registry.py +55 -0
- datasentry_core/connectors/spec.py +37 -0
- datasentry_core/connectors/xlsx.py +109 -0
- datasentry_core/contracts/__init__.py +24 -0
- datasentry_core/contracts/exporters.py +330 -0
- datasentry_core/detectors/__init__.py +15 -0
- datasentry_core/detectors/base.py +120 -0
- datasentry_core/detectors/common.py +156 -0
- datasentry_core/detectors/cross_field.py +157 -0
- datasentry_core/detectors/cross_table.py +220 -0
- datasentry_core/detectors/datetime.py +343 -0
- datasentry_core/detectors/initial/__init__.py +117 -0
- datasentry_core/detectors/initial/anomaly_ml.py +204 -0
- datasentry_core/detectors/initial/categorical.py +252 -0
- datasentry_core/detectors/initial/formula.py +66 -0
- datasentry_core/detectors/initial/missing.py +150 -0
- datasentry_core/detectors/initial/numeric.py +302 -0
- datasentry_core/detectors/initial/textual.py +407 -0
- datasentry_core/detectors/initial/uniqueness.py +183 -0
- datasentry_core/detectors/missingness.py +424 -0
- datasentry_core/detectors/runner.py +228 -0
- datasentry_core/detectors/safe_eval.py +255 -0
- datasentry_core/detectors/textual_variants.py +300 -0
- datasentry_core/drift/__init__.py +8 -0
- datasentry_core/drift/engine.py +182 -0
- datasentry_core/engine/__init__.py +16 -0
- datasentry_core/engine/base.py +34 -0
- datasentry_core/engine/duckdb.py +79 -0
- datasentry_core/engine/fusion.py +193 -0
- datasentry_core/engine/profiler.py +165 -0
- datasentry_core/engine/sql_guard.py +23 -0
- datasentry_core/llm/provider.py +66 -0
- datasentry_core/models/__init__.py +118 -0
- datasentry_core/models/audit.py +46 -0
- datasentry_core/models/contract.py +89 -0
- datasentry_core/models/detector.py +66 -0
- datasentry_core/models/drift.py +47 -0
- datasentry_core/models/enums.py +126 -0
- datasentry_core/models/evidence.py +39 -0
- datasentry_core/models/feedback.py +30 -0
- datasentry_core/models/fingerprint.py +24 -0
- datasentry_core/models/issue.py +49 -0
- datasentry_core/models/llm.py +69 -0
- datasentry_core/models/profile.py +79 -0
- datasentry_core/models/quality.py +16 -0
- datasentry_core/models/repair.py +94 -0
- datasentry_core/models/rules.py +82 -0
- datasentry_core/models/scan.py +112 -0
- datasentry_core/models/validation.py +22 -0
- datasentry_core/plugins.py +91 -0
- datasentry_core/privacy/redactor.py +155 -0
- datasentry_core/repair/__init__.py +5 -0
- datasentry_core/repair/engine.py +455 -0
- datasentry_core/reporting/__init__.py +100 -0
- datasentry_core/reporting/html.py +230 -0
- datasentry_core/reporting/junit.py +80 -0
- datasentry_core/reporting/markdown.py +83 -0
- datasentry_core/reporting/sarif.py +92 -0
- datasentry_core/rules/engine.py +133 -0
- datasentry_core/scoring/__init__.py +30 -0
- datasentry_core/scoring/engine.py +138 -0
- datasentry_core/scoring/gate.py +96 -0
- datasentry_core/scoring/quality.py +137 -0
- datasentry_core/scoring/weights.py +33 -0
- datasentry_core/storage/__init__.py +15 -0
- datasentry_core/storage/paths.py +47 -0
- datasentry_core/storage/schema.py +343 -0
- datasentry_core/storage/store.py +675 -0
- datasentry_core-0.1.0.dist-info/METADATA +59 -0
- datasentry_core-0.1.0.dist-info/RECORD +80 -0
- datasentry_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""跨字段规则检测器(11.10):安全表达式求值(读操作子集,ADR-015)。
|
|
2
|
+
|
|
3
|
+
内置规则按列名语义对自动绑定(MVP 确定性子集,ADR-004 契约 DSL 归 V1):
|
|
4
|
+
- {name}_start / {name}_begin / {name}_from <= {name}_end / {name}_finish / {name}_to
|
|
5
|
+
- {name}_min / {name}_lower <= {name}_max / {name}_upper
|
|
6
|
+
仅同类型(数值/日期)配对;表达式须通过 AST 白名单校验。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
16
|
+
from datasentry_core.detectors.common import (
|
|
17
|
+
DetectorBase,
|
|
18
|
+
make_candidate,
|
|
19
|
+
make_evidence,
|
|
20
|
+
quote_ident,
|
|
21
|
+
)
|
|
22
|
+
from datasentry_core.detectors.safe_eval import ExpressionSecurityError, SafeExpressionEvaluator
|
|
23
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
24
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
25
|
+
|
|
26
|
+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
27
|
+
_PREFIX_PAIRS: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
|
|
28
|
+
(("start", "begin", "from"), ("end", "finish", "to")),
|
|
29
|
+
(("min", "lower"), ("max", "upper")),
|
|
30
|
+
)
|
|
31
|
+
_MAX_EXAMPLES = 20
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BoundRule:
|
|
36
|
+
"""绑定到具体列对的一条规则。"""
|
|
37
|
+
|
|
38
|
+
rule_id: str
|
|
39
|
+
expression: str
|
|
40
|
+
left: str
|
|
41
|
+
right: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CrossFieldRuleDetector(DetectorBase):
|
|
45
|
+
"""跨字段规则(11.10):start<=end / min<=max 等内置语义对,行级安全求值。"""
|
|
46
|
+
|
|
47
|
+
detector_id = "cross_field_rule"
|
|
48
|
+
display_name = "Cross-Field Rule"
|
|
49
|
+
description = "Evaluates whitelisted cross-column expressions (e.g. start <= end)."
|
|
50
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
51
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
52
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"timeout_s": 10.0}
|
|
53
|
+
|
|
54
|
+
def __init__(self, timeout_s: float = 10.0) -> None:
|
|
55
|
+
self._evaluator = SafeExpressionEvaluator(timeout_s=timeout_s)
|
|
56
|
+
|
|
57
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
58
|
+
return bool(self._bind_rules(context))
|
|
59
|
+
|
|
60
|
+
def _bind_rules(self, context: DetectionContext) -> list[BoundRule]:
|
|
61
|
+
rules: list[BoundRule] = []
|
|
62
|
+
physical = {c.name: c.physical_type.upper() for c in context.handle.schema().columns}
|
|
63
|
+
for left_prefixes, right_prefixes in _PREFIX_PAIRS:
|
|
64
|
+
for left in context.columns:
|
|
65
|
+
m = re.match(rf"^({'|'.join(left_prefixes)})_(.+)$", left)
|
|
66
|
+
if not m or left not in physical:
|
|
67
|
+
continue
|
|
68
|
+
name = m.group(2)
|
|
69
|
+
for right in context.columns:
|
|
70
|
+
r = re.match(rf"^({'|'.join(right_prefixes)})_{name}$", right)
|
|
71
|
+
if not r or right == left:
|
|
72
|
+
continue
|
|
73
|
+
if not (_IDENT_RE.fullmatch(left) and _IDENT_RE.fullmatch(right)):
|
|
74
|
+
continue
|
|
75
|
+
if self._same_family(physical.get(left), physical.get(right)):
|
|
76
|
+
rules.append(
|
|
77
|
+
BoundRule(
|
|
78
|
+
rule_id=f"{name}_range_order",
|
|
79
|
+
expression=f"{left} <= {right}",
|
|
80
|
+
left=left,
|
|
81
|
+
right=right,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return rules
|
|
85
|
+
|
|
86
|
+
def _same_family(self, left: str | None, right: str | None) -> bool:
|
|
87
|
+
if left is None or right is None:
|
|
88
|
+
return False
|
|
89
|
+
if left == right:
|
|
90
|
+
return True
|
|
91
|
+
numeric = {
|
|
92
|
+
"INTEGER",
|
|
93
|
+
"BIGINT",
|
|
94
|
+
"SMALLINT",
|
|
95
|
+
"TINYINT",
|
|
96
|
+
"FLOAT",
|
|
97
|
+
"DOUBLE",
|
|
98
|
+
"DECIMAL",
|
|
99
|
+
"HUGEINT",
|
|
100
|
+
}
|
|
101
|
+
temporal = {"DATE", "TIMESTAMP", "TIME"}
|
|
102
|
+
return (left in numeric and right in numeric) or (left in temporal and right in temporal)
|
|
103
|
+
|
|
104
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
105
|
+
candidates: list[IssueCandidate] = []
|
|
106
|
+
for rule in self._bind_rules(context):
|
|
107
|
+
try:
|
|
108
|
+
self._evaluator.validate(rule.expression)
|
|
109
|
+
except ExpressionSecurityError:
|
|
110
|
+
# 内置规则不应失败;失败视为规则无效并跳过(不中断扫描)
|
|
111
|
+
continue
|
|
112
|
+
rows = self._load_rows(context, rule)
|
|
113
|
+
result = self._evaluator.evaluate(
|
|
114
|
+
rule.expression, [rule.left, rule.right], [r[:2] for r in rows]
|
|
115
|
+
)
|
|
116
|
+
if result.timed_out:
|
|
117
|
+
continue
|
|
118
|
+
bad_rows = [r[2] for r, ok in zip(rows, result.values, strict=True) if ok is False]
|
|
119
|
+
if not bad_rows:
|
|
120
|
+
continue
|
|
121
|
+
candidates.append(
|
|
122
|
+
make_candidate(
|
|
123
|
+
detector_id=self.detector_id,
|
|
124
|
+
detector_version=self.detector_version,
|
|
125
|
+
context=context,
|
|
126
|
+
issue_type="cross_field_violation",
|
|
127
|
+
columns=[rule.left, rule.right],
|
|
128
|
+
affected_count=len(bad_rows),
|
|
129
|
+
affected_rows=[str(i) for i in bad_rows[:_MAX_EXAMPLES]],
|
|
130
|
+
evidence=[
|
|
131
|
+
make_evidence(
|
|
132
|
+
detector_id=self.detector_id,
|
|
133
|
+
detector_version=self.detector_version,
|
|
134
|
+
evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
|
|
135
|
+
description=(f"{len(bad_rows)} rows violate {rule.expression}"),
|
|
136
|
+
data={"rule_id": rule.rule_id, "expression": rule.expression},
|
|
137
|
+
)
|
|
138
|
+
],
|
|
139
|
+
raw_score=len(bad_rows),
|
|
140
|
+
confidence=0.95,
|
|
141
|
+
severity=Severity.MEDIUM,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return candidates
|
|
145
|
+
|
|
146
|
+
def _load_rows(
|
|
147
|
+
self, context: DetectionContext, rule: BoundRule
|
|
148
|
+
) -> list[tuple[object, object, int]]:
|
|
149
|
+
"""取左右列值 + 行号(1 基,ROW_NUMBER 稳定序)。"""
|
|
150
|
+
ql, qr = quote_ident(rule.left), quote_ident(rule.right)
|
|
151
|
+
table = context.handle.sql_aggregate(
|
|
152
|
+
f"SELECT {ql} AS l, {qr} AS r, ROW_NUMBER() OVER () AS rn FROM data"
|
|
153
|
+
).table
|
|
154
|
+
lefts = table.column("l").to_pylist()
|
|
155
|
+
rights = table.column("r").to_pylist()
|
|
156
|
+
rns = table.column("rn").to_pylist()
|
|
157
|
+
return list(zip(lefts, rights, rns, strict=True))
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""跨表检测器(Step 40,integrity 维度第一个真实检测器)。
|
|
2
|
+
|
|
3
|
+
外键完整性:主表列值必须存在于引用表对应列。对每个契约声明
|
|
4
|
+
的 TableReference 计算孤儿行(主表非 NULL 但引用表无匹配的行)。
|
|
5
|
+
|
|
6
|
+
实现说明:DetectionContext 只持有主表句柄,引用源由契约显式声明
|
|
7
|
+
(调用方信任域),因此本检测器自建 DuckDBExecutor 并把主表与所有
|
|
8
|
+
引用表建成只读视图后执行 LEFT JOIN。支持主表/引用文件为
|
|
9
|
+
CSV/Parquet/JSONL/DuckDB(DuckDB 需 table 名;XLSX 引用文件 MVP
|
|
10
|
+
不支持,supports 会排除)。路径与标识符一律转义,防注入。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import ClassVar
|
|
17
|
+
|
|
18
|
+
from datasentry_core.connectors.spec import DataSourceType
|
|
19
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
20
|
+
from datasentry_core.detectors.common import DetectorBase, make_candidate, make_evidence
|
|
21
|
+
from datasentry_core.engine import DuckDBExecutor
|
|
22
|
+
from datasentry_core.models.contract import TableReference
|
|
23
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
24
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sql_string_literal(value: str) -> str:
|
|
28
|
+
return "'" + value.replace("'", "''") + "'"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ident(value: str) -> str:
|
|
32
|
+
return '"' + value.replace('"', '""') + '"'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _source_type_for_path(path: str) -> DataSourceType | None:
|
|
36
|
+
suffix = Path(path).suffix.lower()
|
|
37
|
+
mapping: dict[str, DataSourceType] = {
|
|
38
|
+
".csv": DataSourceType.CSV,
|
|
39
|
+
".tsv": DataSourceType.CSV,
|
|
40
|
+
".parquet": DataSourceType.PARQUET,
|
|
41
|
+
".jsonl": DataSourceType.JSONL,
|
|
42
|
+
".ndjson": DataSourceType.JSONL,
|
|
43
|
+
".duckdb": DataSourceType.DUCKDB,
|
|
44
|
+
}
|
|
45
|
+
return mapping.get(suffix)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ForeignKeyViolationDetector(DetectorBase):
|
|
49
|
+
"""外键完整性(11 章 integrity 维度):契约 references 逐条比较。
|
|
50
|
+
|
|
51
|
+
无 references 时 supports=False,不影响单表扫描。孤儿行按行统计,
|
|
52
|
+
issue 细化为列级(每个主表列一条)。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
detector_id = "foreign_key_violation"
|
|
56
|
+
display_name = "Foreign-Key Violation"
|
|
57
|
+
description = (
|
|
58
|
+
"Compares contract-declared cross-table references: rows whose key "
|
|
59
|
+
"has no matching value in the referenced table are orphaned."
|
|
60
|
+
)
|
|
61
|
+
quality_dimension = QualityDimension.INTEGRITY
|
|
62
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(
|
|
63
|
+
supports_sql_pushdown=True,
|
|
64
|
+
requires_row_materialization=False,
|
|
65
|
+
)
|
|
66
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_orphan_ratio": 0.0}
|
|
67
|
+
|
|
68
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
69
|
+
if not context.references:
|
|
70
|
+
return False
|
|
71
|
+
main_type = context.handle.source_type
|
|
72
|
+
if main_type not in (DataSourceType.CSV, DataSourceType.PARQUET, DataSourceType.DUCKDB):
|
|
73
|
+
return False
|
|
74
|
+
return any(
|
|
75
|
+
_source_type_for_path(ref.path) is not None
|
|
76
|
+
and not (_source_type_for_path(ref.path) == DataSourceType.DUCKDB and not ref.table)
|
|
77
|
+
for ref in context.references
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _main_view(self, context: DetectionContext, alias: str) -> str | None:
|
|
81
|
+
"""主表 → CREATE VIEW alias AS ...;不支持的类型返回 None。"""
|
|
82
|
+
source_type = context.handle.source_type
|
|
83
|
+
path = context.handle.source_path
|
|
84
|
+
if path is None:
|
|
85
|
+
return None
|
|
86
|
+
if source_type == DataSourceType.CSV:
|
|
87
|
+
return (
|
|
88
|
+
f"CREATE OR REPLACE VIEW {alias} AS "
|
|
89
|
+
f"SELECT * FROM read_csv_auto({_sql_string_literal(str(path))})"
|
|
90
|
+
)
|
|
91
|
+
if source_type == DataSourceType.PARQUET:
|
|
92
|
+
return (
|
|
93
|
+
f"CREATE OR REPLACE VIEW {alias} AS "
|
|
94
|
+
f"SELECT * FROM read_parquet({_sql_string_literal(str(path))})"
|
|
95
|
+
)
|
|
96
|
+
if source_type == DataSourceType.DUCKDB:
|
|
97
|
+
table_name = context.handle.table_name
|
|
98
|
+
if table_name is None:
|
|
99
|
+
return None
|
|
100
|
+
attach = f"ATTACH {_sql_string_literal(str(path))} AS _main_db (READ_ONLY)"
|
|
101
|
+
return (
|
|
102
|
+
attach + ";" + f"CREATE OR REPLACE VIEW {alias} AS "
|
|
103
|
+
f"SELECT * FROM _main_db.{_ident(table_name)}"
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def _ref_view(self, executor: DuckDBExecutor, ref: TableReference, alias: str) -> bool:
|
|
108
|
+
"""引用表 → CREATE VIEW alias AS ...;成功返回 True。"""
|
|
109
|
+
ref_type = _source_type_for_path(ref.path)
|
|
110
|
+
if ref_type is None:
|
|
111
|
+
return False
|
|
112
|
+
if ref_type == DataSourceType.DUCKDB:
|
|
113
|
+
if ref.table is None:
|
|
114
|
+
return False
|
|
115
|
+
qualified = (
|
|
116
|
+
f"{_ident(str(ref.schema_name))}.{_ident(ref.table)}"
|
|
117
|
+
if ref.schema_name
|
|
118
|
+
else _ident(ref.table)
|
|
119
|
+
)
|
|
120
|
+
executor.execute_setup(f"ATTACH {_sql_string_literal(ref.path)} AS {alias} (READ_ONLY)")
|
|
121
|
+
executor.execute_setup(
|
|
122
|
+
f"CREATE OR REPLACE VIEW {alias}_tbl AS SELECT * FROM {alias}.{qualified}"
|
|
123
|
+
)
|
|
124
|
+
return True
|
|
125
|
+
if ref_type == DataSourceType.CSV:
|
|
126
|
+
sql = f"SELECT * FROM read_csv_auto({_sql_string_literal(ref.path)})"
|
|
127
|
+
elif ref_type == DataSourceType.PARQUET:
|
|
128
|
+
sql = f"SELECT * FROM read_parquet({_sql_string_literal(ref.path)})"
|
|
129
|
+
else:
|
|
130
|
+
sql = (
|
|
131
|
+
"SELECT * FROM read_json_auto("
|
|
132
|
+
f"{_sql_string_literal(ref.path)}, format='newline_delimited')"
|
|
133
|
+
)
|
|
134
|
+
executor.execute_setup(f"CREATE OR REPLACE VIEW {alias}_tbl AS {sql}")
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
138
|
+
candidates: list[IssueCandidate] = []
|
|
139
|
+
main_sql = self._main_view(context, "_main")
|
|
140
|
+
if main_sql is None:
|
|
141
|
+
return candidates
|
|
142
|
+
executor = DuckDBExecutor()
|
|
143
|
+
try:
|
|
144
|
+
for stmt in main_sql.split(";"):
|
|
145
|
+
if stmt.strip():
|
|
146
|
+
executor.execute_setup(stmt)
|
|
147
|
+
for ref in context.references:
|
|
148
|
+
alias = f"_ref_{len(candidates)}"
|
|
149
|
+
if not self._ref_view(executor, ref, alias):
|
|
150
|
+
continue
|
|
151
|
+
ref_table = f"{alias}_tbl"
|
|
152
|
+
for main_col, ref_col in ref.columns.items():
|
|
153
|
+
if main_col not in context.columns:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
row = executor.execute(
|
|
157
|
+
"SELECT "
|
|
158
|
+
"(SELECT count(*) FROM _main WHERE _main."
|
|
159
|
+
+ _ident(main_col)
|
|
160
|
+
+ " IS NOT NULL) AS total, count(*) AS orphan_count "
|
|
161
|
+
"FROM _main LEFT JOIN "
|
|
162
|
+
+ ref_table
|
|
163
|
+
+ " ON _main."
|
|
164
|
+
+ _ident(main_col)
|
|
165
|
+
+ " = "
|
|
166
|
+
+ ref_table
|
|
167
|
+
+ "."
|
|
168
|
+
+ _ident(ref_col)
|
|
169
|
+
+ " AND "
|
|
170
|
+
+ ref_table
|
|
171
|
+
+ "."
|
|
172
|
+
+ _ident(ref_col)
|
|
173
|
+
+ " IS NOT NULL WHERE _main."
|
|
174
|
+
+ _ident(main_col)
|
|
175
|
+
+ " IS NOT NULL AND "
|
|
176
|
+
+ ref_table
|
|
177
|
+
+ "."
|
|
178
|
+
+ _ident(ref_col)
|
|
179
|
+
+ " IS NULL"
|
|
180
|
+
).to_pylist()[0]
|
|
181
|
+
except Exception:
|
|
182
|
+
continue
|
|
183
|
+
total = int(row["total"]) if row["total"] is not None else 0
|
|
184
|
+
orphan_count = (
|
|
185
|
+
int(row["orphan_count"]) if row["orphan_count"] is not None else 0
|
|
186
|
+
)
|
|
187
|
+
orphan_ratio = orphan_count / total if total > 0 else 0.0
|
|
188
|
+
if orphan_count == 0:
|
|
189
|
+
continue
|
|
190
|
+
evidence = make_evidence(
|
|
191
|
+
detector_id=self.detector_id,
|
|
192
|
+
detector_version=self.detector_version,
|
|
193
|
+
evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
|
|
194
|
+
description=(f"orphan rows for {main_col} -> {ref.name}.{ref_col}"),
|
|
195
|
+
data={
|
|
196
|
+
"reference": ref.name,
|
|
197
|
+
"reference_column": ref_col,
|
|
198
|
+
"orphan_count": orphan_count,
|
|
199
|
+
"orphan_ratio": orphan_ratio,
|
|
200
|
+
"main_total_non_null": total,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
candidates.append(
|
|
204
|
+
make_candidate(
|
|
205
|
+
detector_id=self.detector_id,
|
|
206
|
+
detector_version=self.detector_version,
|
|
207
|
+
context=context,
|
|
208
|
+
issue_type="foreign_key_violation",
|
|
209
|
+
columns=[main_col],
|
|
210
|
+
affected_count=orphan_count,
|
|
211
|
+
evidence=[evidence],
|
|
212
|
+
raw_score=min(orphan_ratio * 100.0, 100.0),
|
|
213
|
+
confidence=0.95,
|
|
214
|
+
severity=Severity.HIGH,
|
|
215
|
+
fpr=0.05,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
finally:
|
|
219
|
+
executor.close()
|
|
220
|
+
return candidates
|