|
9 | 9 | from sqlalchemy import ( |
10 | 10 | Boolean, |
11 | 11 | Column, |
| 12 | + Enum, |
12 | 13 | ForeignKey, |
13 | 14 | String, |
14 | 15 | Text, |
@@ -40,6 +41,87 @@ class Severity(StrEnum): |
40 | 41 | WARNING = "Warning" |
41 | 42 |
|
42 | 43 |
|
| 44 | +class TestAlgorithm(StrEnum): |
| 45 | + """SQL-derived algorithm family for a test type (faceted picker axis).""" |
| 46 | + |
| 47 | + BOUNDARY_CHECK = "Boundary check" |
| 48 | + COUNTING = "Counting" |
| 49 | + PATTERN_REGEX = "Pattern / regex" |
| 50 | + SET_LOOKUP = "Set / lookup" |
| 51 | + STATISTICAL_DRIFT = "Statistical drift" |
| 52 | + AGGREGATE_RECONCILIATION = "Aggregate reconciliation" |
| 53 | + FRESHNESS_TIME = "Freshness / time" |
| 54 | + SCHEMA_METADATA = "Schema / metadata" |
| 55 | + CUSTOM_SQL = "Custom SQL" |
| 56 | + |
| 57 | + |
| 58 | +class StatisticalTechnique(StrEnum): |
| 59 | + """Named statistical technique a test type uses to evaluate its measure.""" |
| 60 | + |
| 61 | + COHENS_D = "Cohen's D" |
| 62 | + COHENS_H = "Cohen's H" |
| 63 | + OUTLIER_DETECTION = "Outlier Detection" |
| 64 | + SD_SHIFT = "SD Shift" |
| 65 | + JENSEN_SHANNON_DIVERGENCE = "Jensen-Shannon Divergence" |
| 66 | + PREDICTIVE_MODEL = "Predictive Model" |
| 67 | + |
| 68 | + |
| 69 | +class TestCriteria(StrEnum): |
| 70 | + """What a test type needs to be set up (faceted picker axis). |
| 71 | +
|
| 72 | + Derived, not stored: ``derive_test_criteria`` is the single source of truth, shared by the |
| 73 | + UI lookup and MCP so the value never drifts between surfaces. |
| 74 | + """ |
| 75 | + |
| 76 | + DEFINED_RULE = "Defined Rule" |
| 77 | + DEFINED_THRESHOLD = "Defined Threshold" |
| 78 | + DEFINED_VALUE = "Defined Value" |
| 79 | + LIST_OF_VALUES = "List of Values" |
| 80 | + REFERENCE_DATASET = "Reference Dataset" |
| 81 | + CUSTOM_CRITERIA = "Custom Criteria" |
| 82 | + |
| 83 | + |
| 84 | +# Predefined validity/integrity rules — the user just enables them; the rule itself is fixed |
| 85 | +# (dedup/uniqueness and standard format validators). Not separable from structural attributes |
| 86 | +# alone: e.g. Valid_Month is structurally identical to the Defined-Value test Pattern_Match |
| 87 | +# (both column-scoped, Pattern / regex, with baseline_value + threshold_value params). |
| 88 | +_DEFINED_RULE_TESTS = frozenset({ |
| 89 | + "Dupe_Rows", "Unique", "Email_Format", "Street_Addr_Pattern", |
| 90 | + "Valid_Characters", "Valid_Month", "Valid_US_Zip", "Valid_US_Zip3", |
| 91 | +}) |
| 92 | +# The user asserts the expected value/pattern the column should hold (a constant, a regex |
| 93 | +# baseline, or simply that a value is present). Enumerated for the same reason as above. |
| 94 | +_DEFINED_VALUE_TESTS = frozenset({ |
| 95 | + "Constant", "Pattern_Match", "Required", |
| 96 | +}) |
| 97 | + |
| 98 | + |
| 99 | +def derive_test_criteria( |
| 100 | + test_type: str, |
| 101 | + test_scope: str | None, |
| 102 | + algorithm: str | None, |
| 103 | +) -> TestCriteria: |
| 104 | + """Classify a test type by the kind of setup it requires. |
| 105 | +
|
| 106 | + Single source of truth for the Criteria facet — call from both the UI lookup and MCP rather |
| 107 | + than reproducing the rules. Scope and algorithm resolve the cleanly-typed buckets (referential |
| 108 | + is checked before Set / lookup so Combo_Match stays Reference Dataset). Defined Rule, Defined |
| 109 | + Value, and Defined Threshold can't be told apart from structural attributes, so the first two |
| 110 | + are enumerated and Defined Threshold is the fallthrough. |
| 111 | + """ |
| 112 | + if test_scope == "custom": |
| 113 | + return TestCriteria.CUSTOM_CRITERIA |
| 114 | + if test_scope == "referential": |
| 115 | + return TestCriteria.REFERENCE_DATASET |
| 116 | + if algorithm == TestAlgorithm.SET_LOOKUP: |
| 117 | + return TestCriteria.LIST_OF_VALUES |
| 118 | + if test_type in _DEFINED_RULE_TESTS: |
| 119 | + return TestCriteria.DEFINED_RULE |
| 120 | + if test_type in _DEFINED_VALUE_TESTS: |
| 121 | + return TestCriteria.DEFINED_VALUE |
| 122 | + return TestCriteria.DEFINED_THRESHOLD |
| 123 | + |
| 124 | + |
43 | 125 | class InvalidTestDefinitionFields(ValueError): |
44 | 126 | """Aggregated field-level validation errors. ``errors``: ``dict[field_name, reason]``.""" |
45 | 127 |
|
@@ -175,6 +257,15 @@ def process_bind_param(self, value: str | None, _dialect) -> str | None: |
175 | 257 | return value or None |
176 | 258 |
|
177 | 259 |
|
| 260 | +def _enum_by_value(enum_cls: type[StrEnum]) -> Enum: |
| 261 | + """Map a StrEnum to its VARCHAR column by member value (not name) so reads return enum members. |
| 262 | +
|
| 263 | + These columns store the display value (e.g. ``"Boundary check"``), which differs from the enum |
| 264 | + member name, so the default name-based mapping would not round-trip. |
| 265 | + """ |
| 266 | + return Enum(enum_cls, native_enum=False, values_callable=lambda cls: [member.value for member in cls]) |
| 267 | + |
| 268 | + |
178 | 269 | class TestType(ParamFieldsMixin, Entity): |
179 | 270 | __tablename__ = "test_types" |
180 | 271 |
|
@@ -204,12 +295,19 @@ class TestType(ParamFieldsMixin, Entity): |
204 | 295 | dq_dimension: str = Column(String) |
205 | 296 | impact_dimension: str = Column(String) |
206 | 297 | health_dimension: str = Column(String) |
| 298 | + algorithm: TestAlgorithm | None = Column(_enum_by_value(TestAlgorithm)) |
| 299 | + statistical_technique: StatisticalTechnique | None = Column(_enum_by_value(StatisticalTechnique)) |
207 | 300 | threshold_description: str = Column(String) |
208 | 301 | usage_notes: str = Column(String) |
209 | 302 | active: str = Column(String) |
210 | 303 |
|
211 | 304 | # Unmapped columns: generation_template, result_visualization, result_visualization_params |
212 | 305 |
|
| 306 | + @property |
| 307 | + def criteria(self) -> TestCriteria: |
| 308 | + """Setup-kind facet, derived via the shared classifier (see ``derive_test_criteria``).""" |
| 309 | + return derive_test_criteria(self.test_type, self.test_scope, self.algorithm) |
| 310 | + |
213 | 311 | _summary_columns = ( |
214 | 312 | *[key for key in TestTypeSummary.__annotations__.keys() if key not in ("default_test_description", "default_impact_dimension")], |
215 | 313 | test_description.label("default_test_description"), |
|
0 commit comments