|
27 | 27 | from sqlalchemy.orm import InstrumentedAttribute |
28 | 28 | from sqlalchemy.sql.expression import case, literal |
29 | 29 |
|
| 30 | +from testgen.common.enums import MonitorType |
30 | 31 | from testgen.common.models import Base, get_current_session |
31 | 32 | from testgen.common.models.custom_types import NullIfEmptyString, YNString, ZeroIfEmptyInteger |
32 | 33 | from testgen.common.models.entity import Entity, EntityMinimal |
@@ -270,6 +271,97 @@ class TestDefinitionMinimal(EntityMinimal): |
270 | 271 | test_name_short: str |
271 | 272 |
|
272 | 273 |
|
| 274 | +class ThresholdMode(StrEnum): |
| 275 | + """How a monitor's bounds are determined — derived from which fields on the |
| 276 | + definition are populated. See ``TestDefinition._derive_threshold_mode``.""" |
| 277 | + PREDICTION = "Prediction Model" |
| 278 | + HISTORICAL = "Historical Calculation" |
| 279 | + STATIC = "Static" |
| 280 | + NONE = "N/A" |
| 281 | + |
| 282 | + |
| 283 | +@dataclass |
| 284 | +class MonitorForecastPoint(EntityMinimal): |
| 285 | + """One future forecast point read from a Prediction-Model monitor's stored |
| 286 | + ``prediction`` JSONB. Each point is a ``(test_time, lower_bound, upper_bound)`` |
| 287 | + triple at a specific upcoming timestamp; collectively they extend the |
| 288 | + historical event series forward under the suite's active prediction |
| 289 | + sensitivity. Surfaced as a separate forecast section on |
| 290 | + ``list_monitor_events``, never as an event.""" |
| 291 | + test_time: datetime |
| 292 | + lower_bound: float | None |
| 293 | + upper_bound: float | None |
| 294 | + |
| 295 | + |
| 296 | +def forecast_points_from_prediction( |
| 297 | + prediction: dict | None, |
| 298 | + sensitivity: str, |
| 299 | +) -> list[MonitorForecastPoint]: |
| 300 | + """Extract forecast points for a sensitivity from a monitor's stored |
| 301 | + ``prediction`` JSONB, sorted by time ascending (nearest future point |
| 302 | + first). |
| 303 | +
|
| 304 | + The JSONB is keyed as ``"lower_tolerance|<sensitivity>" → {epoch_ms: value}`` |
| 305 | + and ``"upper_tolerance|<sensitivity>" → {epoch_ms: value}`` — matches the |
| 306 | + format the dashboard reads at ``monitors_dashboard.py`` via |
| 307 | + ``datetime.fromtimestamp(int(timestamp) / 1000.0, UTC)``. Returns ``[]`` |
| 308 | + when the monitor isn't in Prediction Model mode (no JSONB) or when the |
| 309 | + sensitivity has no stored series. Standalone so it works against either |
| 310 | + the ``TestDefinition`` ORM row or the ``TestDefinitionSummary`` dataclass |
| 311 | + — both carry the same ``prediction`` field shape. |
| 312 | + """ |
| 313 | + if not prediction: |
| 314 | + return [] |
| 315 | + lower_series = prediction.get(f"lower_tolerance|{sensitivity}") or {} |
| 316 | + upper_series = prediction.get(f"upper_tolerance|{sensitivity}") or {} |
| 317 | + if not lower_series and not upper_series: |
| 318 | + return [] |
| 319 | + |
| 320 | + all_keys = sorted(set(lower_series) | set(upper_series), key=int) |
| 321 | + points: list[MonitorForecastPoint] = [] |
| 322 | + for k in all_keys: |
| 323 | + ts = datetime.fromtimestamp(int(k) / 1000.0, UTC) |
| 324 | + lower = lower_series.get(k) |
| 325 | + upper = upper_series.get(k) |
| 326 | + points.append(MonitorForecastPoint( |
| 327 | + test_time=ts, |
| 328 | + lower_bound=float(lower) if lower is not None else None, |
| 329 | + upper_bound=float(upper) if upper is not None else None, |
| 330 | + )) |
| 331 | + return points |
| 332 | + |
| 333 | + |
| 334 | +@dataclass |
| 335 | +class MonitorConfig(EntityMinimal): |
| 336 | + """One configured monitor — produced by ``TestDefinition.list_monitor_configs_for_table``. |
| 337 | +
|
| 338 | + ``threshold_mode`` is derived from the underlying definition's |
| 339 | + ``history_calculation``: ``"PREDICT"`` flags Prediction Model; any other |
| 340 | + non-empty value flags Historical Calculation (not available for Freshness); |
| 341 | + empty falls through to Static — the default for Freshness, Volume, and |
| 342 | + Metric. Schema_Drift is presence-only and reports N/A. |
| 343 | +
|
| 344 | + ``threshold_lower`` / ``threshold_upper`` carry the bounds active under |
| 345 | + the current mode — static tolerances for Static, history-calc expressions |
| 346 | + (e.g. ``"Minimum"`` / ``"Maximum"``) for Historical, ``None`` for |
| 347 | + Prediction (the runtime bands live on each event, not the configuration). |
| 348 | + For Freshness in Static mode only the upper bound applies. |
| 349 | +
|
| 350 | + ``metric_name`` is the user-defined name for a Metric monitor (stored on |
| 351 | + the underlying definition's ``column_name`` column, but it is the metric's |
| 352 | + name rather than a column reference). ``custom_query`` is the metric's SQL |
| 353 | + expression. Both are only set for ``Metric_Trend``. |
| 354 | + """ |
| 355 | + monitor_id: UUID |
| 356 | + test_type: str |
| 357 | + table_name: str |
| 358 | + metric_name: str | None |
| 359 | + threshold_mode: ThresholdMode |
| 360 | + threshold_lower: str | None |
| 361 | + threshold_upper: str | None |
| 362 | + custom_query: str | None |
| 363 | + |
| 364 | + |
273 | 365 | class QueryString(TypeDecorator): |
274 | 366 | impl = String |
275 | 367 | cache_ok = True |
@@ -557,6 +649,95 @@ def list_for_suite( |
557 | 649 | query = query.order_by(*cls._default_order_by) |
558 | 650 | return cls._paginate(query, page=page, limit=limit, data_class=TestDefinitionSummary) |
559 | 651 |
|
| 652 | + @classmethod |
| 653 | + def get_singleton_monitor( |
| 654 | + cls, |
| 655 | + test_suite_id: str | UUID, |
| 656 | + table_name: str, |
| 657 | + test_type: str, |
| 658 | + ) -> "TestDefinition | None": |
| 659 | + """Return the single ``TestDefinition`` row for a singleton monitor |
| 660 | + type (Freshness / Volume / Schema) on a given table. Metric is |
| 661 | + multi-instance and should be looked up by ``id`` instead — this |
| 662 | + helper would silently pick the first match. Returns ``None`` when no |
| 663 | + monitor is configured.""" |
| 664 | + session = get_current_session() |
| 665 | + return session.execute( |
| 666 | + select(cls) |
| 667 | + .where(cls.test_suite_id == test_suite_id) |
| 668 | + .where(cls.table_name == table_name) |
| 669 | + .where(cls.test_type == test_type) |
| 670 | + .limit(1) |
| 671 | + ).scalars().first() |
| 672 | + |
| 673 | + @classmethod |
| 674 | + def list_monitor_configs_for_table( |
| 675 | + cls, |
| 676 | + test_suite_id: str | UUID, |
| 677 | + table_name: str, |
| 678 | + ) -> list[MonitorConfig]: |
| 679 | + """List configured monitors for a single table within a monitor suite. |
| 680 | +
|
| 681 | + Returns one entry per ``test_definition`` row whose ``test_type`` is one |
| 682 | + of the four monitor types. Typically 3-4 rows per table; Metric_Trend |
| 683 | + contributes one entry per metric, so a table may have more. |
| 684 | + """ |
| 685 | + monitor_codes = [m.value for m in MonitorType] |
| 686 | + defs = cls.select_where( |
| 687 | + cls.test_suite_id == test_suite_id, |
| 688 | + cls.table_name == table_name, |
| 689 | + cls.test_type.in_(monitor_codes), |
| 690 | + ) |
| 691 | + return [cls._build_monitor_config(td) for td in defs] |
| 692 | + |
| 693 | + @classmethod |
| 694 | + def _build_monitor_config(cls, td: "TestDefinition") -> MonitorConfig: |
| 695 | + mode, threshold_lower, threshold_upper = cls._derive_threshold_mode(td) |
| 696 | + return MonitorConfig( |
| 697 | + monitor_id=td.id, |
| 698 | + test_type=td.test_type, |
| 699 | + table_name=td.table_name, |
| 700 | + metric_name=td.column_name or None if td.test_type == MonitorType.METRIC.value else None, |
| 701 | + threshold_mode=mode, |
| 702 | + threshold_lower=threshold_lower, |
| 703 | + threshold_upper=threshold_upper, |
| 704 | + custom_query=td.custom_query if td.test_type == MonitorType.METRIC.value else None, |
| 705 | + ) |
| 706 | + |
| 707 | + @classmethod |
| 708 | + def _derive_threshold_mode( |
| 709 | + cls, td: "TestDefinition", |
| 710 | + ) -> tuple[ThresholdMode, str | None, str | None]: |
| 711 | + """Pick a mode and the bounds tuple that applies under that mode. |
| 712 | +
|
| 713 | + Detection mirrors the UI form (``test_definition_form.js``): a |
| 714 | + ``history_calculation`` of exactly ``"PREDICT"`` flags Prediction |
| 715 | + mode; any other non-empty value flags Historical (not available for |
| 716 | + Freshness); empty falls through to Static — the default for |
| 717 | + Freshness / Volume / Metric. Schema never has thresholds. |
| 718 | +
|
| 719 | + Bounds returned per mode: |
| 720 | +
|
| 721 | + * Prediction: ``None, None``. Runtime bounds live in the per-run |
| 722 | + prediction JSONB; they are not configuration and do not surface |
| 723 | + here. |
| 724 | + * Historical: ``(history_calculation, history_calculation_upper)`` — |
| 725 | + stored as expressions like ``"Minimum"`` / ``"Maximum"`` that the |
| 726 | + execution layer evaluates against the lookback window. |
| 727 | + * Static for Freshness: ``(None, upper_tolerance)``. Only an upper |
| 728 | + bound applies. |
| 729 | + * Static (Volume / Metric): ``(lower_tolerance, upper_tolerance)``. |
| 730 | + """ |
| 731 | + if td.test_type == MonitorType.SCHEMA.value: |
| 732 | + return ThresholdMode.NONE, None, None |
| 733 | + if td.history_calculation == "PREDICT": |
| 734 | + return ThresholdMode.PREDICTION, None, None |
| 735 | + if td.history_calculation and td.test_type != MonitorType.FRESHNESS.value: |
| 736 | + return ThresholdMode.HISTORICAL, td.history_calculation, td.history_calculation_upper |
| 737 | + if td.test_type == MonitorType.FRESHNESS.value: |
| 738 | + return ThresholdMode.STATIC, None, td.upper_tolerance |
| 739 | + return ThresholdMode.STATIC, td.lower_tolerance, td.upper_tolerance |
| 740 | + |
560 | 741 | @classmethod |
561 | 742 | def select_page( |
562 | 743 | cls, |
|
0 commit comments