Add datasource quality and schema gates
This commit is contained in:
@@ -23,6 +23,12 @@ Producer modules can append a bounded tabular result or create a new static
|
|||||||
datasource through an idempotent capability. The publication ledger retains the
|
datasource through an idempotent capability. The publication ledger retains the
|
||||||
producer run, output materialization, provenance, and replay identity.
|
producer run, output materialization, provenance, and replay identity.
|
||||||
|
|
||||||
|
Tabular staging also evaluates governed quality rules and classifies schema
|
||||||
|
changes before promotion. Blocking stages remain inspectable, and successful
|
||||||
|
promotion preserves the exact policy hash and validation result in immutable
|
||||||
|
materialization provenance. The supported contract is documented in
|
||||||
|
[docs/QUALITY_POLICY.md](docs/QUALITY_POLICY.md).
|
||||||
|
|
||||||
The contracts already model database, HTTP/REST, directory, file, feed,
|
The contracts already model database, HTTP/REST, directory, file, feed,
|
||||||
document, binary, directory, and stream sources so providers can be added
|
document, binary, directory, and stream sources so providers can be added
|
||||||
without changing consumers. Larger durable artifact-backed publications remain
|
without changing consumers. Larger durable artifact-backed publications remain
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ A stage records schema, row and byte counts, fingerprint, validation result,
|
|||||||
provenance, and intended target. Promotion either creates a datasource or adds
|
provenance, and intended target. Promotion either creates a datasource or adds
|
||||||
a new immutable materialization to an existing compatible datasource.
|
a new immutable materialization to an existing compatible datasource.
|
||||||
|
|
||||||
|
For bounded tabular stages, the Datasource quality policy now enforces row,
|
||||||
|
field, nullability, uniqueness, numeric range, and embedded referential-set
|
||||||
|
rules. Updates receive a deterministic compatible, warning, or breaking schema
|
||||||
|
classification. Errors and breaking changes keep a stage inspectable but block
|
||||||
|
promotion; warnings remain visible and promotable. The policy hash, diagnostics,
|
||||||
|
and schema diff are retained with the promoted materialization evidence. See
|
||||||
|
[QUALITY_POLICY.md](QUALITY_POLICY.md) for the contract and its privacy bounds.
|
||||||
|
|
||||||
The first slice stores bounded tabular JSON/CSV stages. Future providers may
|
The first slice stores bounded tabular JSON/CSV stages. Future providers may
|
||||||
stage file references, object-store blobs, directory snapshots, or streaming
|
stage file references, object-store blobs, directory snapshots, or streaming
|
||||||
checkpoints through the same lifecycle contract.
|
checkpoints through the same lifecycle contract.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ editor, previews, and immutable materialization history.
|
|||||||
| Surface | Archetype | Consequence class | Contract |
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `/datasources` catalogue | Governed directory | Select, register, refresh, freeze, govern, or retire | Shared loading/empty/error, permission, disabled-reason, contextual-help, and read-only states |
|
| `/datasources` catalogue | Governed directory | Select, register, refresh, freeze, govern, or retire | Shared loading/empty/error, permission, disabled-reason, contextual-help, and read-only states |
|
||||||
| Staging | Review/preflight queue | Upload, validate, inspect, and promote | Non-consumable bounded stage, explicit promotion confirmation, immutable resulting revision |
|
| Staging | Review/preflight queue | Upload, validate, inspect, and promote | Non-consumable bounded stage with privacy-safe quality/schema diagnostics, explicit promotion confirmation, and immutable validation evidence on the resulting revision |
|
||||||
| Connector origins | Optional-provider directory | Register a live or cached source | Provider availability and supported modes without hard Connectors dependency |
|
| Connector origins | Optional-provider directory | Register a live or cached source | Provider availability and supported modes without hard Connectors dependency |
|
||||||
| Governance editor | Effective authority/provenance editor | Change institutional data context | Guarded draft, authority/source/owner/purpose/quality/freshness semantics |
|
| Governance editor | Effective authority/provenance editor | Change institutional data context | Guarded draft, authority/source/owner/purpose/quality/freshness semantics |
|
||||||
| Preview/materializations | Evidence register | Inspect current sample and immutable revisions | Row/schema bounds, freshness, provenance, fingerprints, hashes, and frozen labels |
|
| Preview/materializations | Evidence register | Inspect current sample and immutable revisions | Row/schema bounds, freshness, provenance, fingerprints, hashes, and frozen labels |
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Datasource Stage Quality Policy
|
||||||
|
|
||||||
|
Datasource quality policy is a deterministic JSON contract stored in
|
||||||
|
`governance.quality_policy`. A tabular stage inherits the current target
|
||||||
|
Datasource policy unless the stage has its own governed definition. Validation
|
||||||
|
runs before the stage is stored, but a failed stage remains available for
|
||||||
|
inspection and correction through a new stage.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "monthly-import-v1",
|
||||||
|
"rules": [
|
||||||
|
{"id": "non-empty", "type": "row_count", "minimum": 1},
|
||||||
|
{"id": "columns", "type": "required_fields", "fields": ["id", "status"]},
|
||||||
|
{"id": "id-shape", "type": "field", "field": "id", "data_type": "integer", "nullable": false},
|
||||||
|
{"id": "id-present", "type": "not_null", "fields": ["id"]},
|
||||||
|
{"id": "id-unique", "type": "unique", "fields": ["id"]},
|
||||||
|
{"id": "amount-range", "type": "range", "field": "amount", "minimum": 0},
|
||||||
|
{"id": "known-status", "type": "referential", "field": "status", "allowed_values": ["new", "closed"]}
|
||||||
|
],
|
||||||
|
"schema_policy": {
|
||||||
|
"field_added_required": "warning",
|
||||||
|
"field_removed": "breaking"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every rule has a stable `id` and may set `severity` to `error` or `warning`.
|
||||||
|
Errors block promotion; warnings require review but leave the stage ready. The
|
||||||
|
supported rules are:
|
||||||
|
|
||||||
|
- `row_count`: optional non-negative `minimum` and `maximum`;
|
||||||
|
- `required_fields`: fields that must exist in the detected schema;
|
||||||
|
- `field`: expected `data_type` and/or `nullable` contract for one field;
|
||||||
|
- `not_null`: one `field` or a `fields` list that must contain no nulls;
|
||||||
|
- `unique`: one `field` or a composite `fields` list, with optional
|
||||||
|
`ignore_nulls`;
|
||||||
|
- `range`: numeric `minimum` and/or `maximum`, with optional `allow_null`;
|
||||||
|
- `referential`: a field and a bounded `allowed_values` set, with optional
|
||||||
|
`allow_null`.
|
||||||
|
|
||||||
|
The bounded referential rule is intentionally local and reproducible. A future
|
||||||
|
cross-Datasource reference rule must freeze the referenced materialization and
|
||||||
|
perform an independent read-authorization check; it must not silently read the
|
||||||
|
current state of another protected Datasource.
|
||||||
|
|
||||||
|
Use embedded values only for non-sensitive code sets. Quality policy is
|
||||||
|
catalogue governance metadata and can be visible to actors who are not allowed
|
||||||
|
to read protected rows.
|
||||||
|
|
||||||
|
## Schema Classification
|
||||||
|
|
||||||
|
When a stage targets an existing Datasource, fields are compared by stable
|
||||||
|
name. Defaults are:
|
||||||
|
|
||||||
|
| Change | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| nullable field added | compatible |
|
||||||
|
| required field added | warning |
|
||||||
|
| field removed | breaking |
|
||||||
|
| integer widened to number, or unknown resolved | compatible |
|
||||||
|
| other type change | breaking |
|
||||||
|
| nullability relaxed | breaking |
|
||||||
|
| nullability tightened | compatible |
|
||||||
|
| existing field order changed | warning |
|
||||||
|
|
||||||
|
Each key may be overridden in `schema_policy` with `compatible`, `warning`, or
|
||||||
|
`breaking`. The highest resulting classification is the stage classification.
|
||||||
|
A breaking classification blocks promotion.
|
||||||
|
|
||||||
|
## Evidence And Privacy
|
||||||
|
|
||||||
|
Validation records the policy contract version, a SHA-256 hash of the complete
|
||||||
|
policy, rule counts, diagnostics, and the complete schema diff. Row diagnostics
|
||||||
|
contain only affected counts and at most 25 one-based row numbers; they never
|
||||||
|
copy field values. Promotion copies this validation object into the immutable
|
||||||
|
materialization provenance and records the policy hash in the audit event.
|
||||||
|
|
||||||
|
Approval authority, approval expiry, and retention/deletion execution remain
|
||||||
|
separate work under `govoplan-datasources#2`. Until those contracts are added,
|
||||||
|
no JSON flag is treated as an approval and no stage is deleted automatically.
|
||||||
@@ -17,6 +17,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
@@ -381,7 +382,8 @@ manifest = ModuleManifest(
|
|||||||
"controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy "
|
"controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy "
|
||||||
"profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and "
|
"profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and "
|
||||||
"known limits provide discoverable institutional context. Freshness and quality policies are typed JSON contracts retained "
|
"known limits provide discoverable institutional context. Freshness and quality policies are typed JSON contracts retained "
|
||||||
"with materialization evidence; enforcement remains with the provider or consuming control that declares support. Metadata "
|
"with materialization evidence. Datasources enforces the declared bounded tabular stage rules and schema policy; origin-specific "
|
||||||
|
"or consumer-specific controls still remain with the provider or consuming control that declares support. Metadata "
|
||||||
"visibility never grants row access."
|
"visibility never grants row access."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
@@ -402,6 +404,44 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="datasources.quality-gates",
|
||||||
|
title="Validate a Datasource stage",
|
||||||
|
summary="Apply deterministic quality rules and schema-change policy before staged data can become consumable.",
|
||||||
|
body=(
|
||||||
|
"Tabular stages evaluate configured row count, required-field, field-shape, nullability, uniqueness, numeric-range, and bounded "
|
||||||
|
"referential-set rules. Errors keep the stage inspectable but block promotion; warnings stay visible and permit an explicit "
|
||||||
|
"promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or "
|
||||||
|
"breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and "
|
||||||
|
"schema diff are copied into immutable materialization provenance when promotion succeeds. Approval and retention execution are "
|
||||||
|
"not inferred from arbitrary JSON flags and remain separate governed lifecycle work."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "data_steward", "power_user"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Datasource quality policy",
|
||||||
|
href="govoplan-datasources/docs/QUALITY_POLICY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("policy", "approvals", "audit", "dataflow", "workflow_engine"),
|
||||||
|
order=72,
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"datasources.staging",
|
||||||
|
"datasources.staging.validation",
|
||||||
|
"datasources.field.quality-policy",
|
||||||
|
"datasources.action.promote",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Referential rules currently use a bounded embedded value set rather than reading another protected Datasource.",
|
||||||
|
"Approval authority and automatic retention execution are not part of the current stage contract.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="datasources.reference.fields-and-consequences",
|
id="datasources.reference.fields-and-consequences",
|
||||||
title="Datasource fields and lifecycle consequences",
|
title="Datasource fields and lifecycle consequences",
|
||||||
@@ -419,7 +459,7 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||||
related_modules=("connectors", "dataflow", "workflow_engine", "reporting", "audit"),
|
related_modules=("connectors", "dataflow", "workflow_engine", "reporting", "audit"),
|
||||||
order=72,
|
order=73,
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import DatasourceField
|
||||||
|
|
||||||
|
|
||||||
|
DiagnosticSeverity = Literal["error", "warning"]
|
||||||
|
SchemaClassification = Literal["compatible", "warning", "breaking"]
|
||||||
|
|
||||||
|
QUALITY_POLICY_VERSION = 1
|
||||||
|
MAX_DIAGNOSTIC_ROWS = 25
|
||||||
|
MAX_REFERENCE_VALUES = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QualityDiagnostic:
|
||||||
|
severity: DiagnosticSeverity
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
rule_id: str | None = None
|
||||||
|
affected_rows: int | None = None
|
||||||
|
row_numbers: tuple[int, ...] = ()
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"severity": self.severity,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
if self.rule_id:
|
||||||
|
payload["rule_id"] = self.rule_id
|
||||||
|
if self.affected_rows is not None:
|
||||||
|
payload["affected_rows"] = self.affected_rows
|
||||||
|
if self.row_numbers:
|
||||||
|
payload["row_numbers"] = list(self.row_numbers)
|
||||||
|
payload["row_numbers_truncated"] = self.affected_rows is not None and self.affected_rows > len(self.row_numbers)
|
||||||
|
if self.details:
|
||||||
|
payload["details"] = dict(self.details)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchemaChange:
|
||||||
|
code: str
|
||||||
|
classification: SchemaClassification
|
||||||
|
message: str
|
||||||
|
field_name: str | None = None
|
||||||
|
before: Mapping[str, object] | None = None
|
||||||
|
after: Mapping[str, object] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"code": self.code,
|
||||||
|
"classification": self.classification,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
if self.field_name:
|
||||||
|
payload["field"] = self.field_name
|
||||||
|
if self.before is not None:
|
||||||
|
payload["before"] = dict(self.before)
|
||||||
|
if self.after is not None:
|
||||||
|
payload["after"] = dict(self.after)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA_DEFAULTS: dict[str, SchemaClassification] = {
|
||||||
|
"field_added_nullable": "compatible",
|
||||||
|
"field_added_required": "warning",
|
||||||
|
"field_removed": "breaking",
|
||||||
|
"type_widened": "compatible",
|
||||||
|
"type_changed": "breaking",
|
||||||
|
"nullability_relaxed": "breaking",
|
||||||
|
"nullability_tightened": "compatible",
|
||||||
|
"field_order_changed": "warning",
|
||||||
|
}
|
||||||
|
_CLASSIFICATION_RANK = {"compatible": 0, "warning": 1, "breaking": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stage(
|
||||||
|
*,
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
quality_policy: Mapping[str, object] | None,
|
||||||
|
baseline_schema: Sequence[DatasourceField] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
policy = dict(quality_policy or {})
|
||||||
|
diagnostics: list[QualityDiagnostic] = []
|
||||||
|
evaluated_rule_ids: list[str] = []
|
||||||
|
failed_rule_ids: set[str] = set()
|
||||||
|
|
||||||
|
raw_rules = policy.get("rules", [])
|
||||||
|
if not _is_sequence(raw_rules):
|
||||||
|
diagnostics.append(_policy_error("Quality policy rules must be a list."))
|
||||||
|
raw_rules = []
|
||||||
|
|
||||||
|
known_rule_ids: set[str] = set()
|
||||||
|
for index, raw_rule in enumerate(raw_rules):
|
||||||
|
fallback_rule_id = f"rule-{index + 1}"
|
||||||
|
if not isinstance(raw_rule, Mapping):
|
||||||
|
diagnostics.append(_policy_error("Each quality rule must be an object.", fallback_rule_id))
|
||||||
|
failed_rule_ids.add(fallback_rule_id)
|
||||||
|
continue
|
||||||
|
rule_id = str(raw_rule.get("id") or fallback_rule_id).strip()
|
||||||
|
if not rule_id:
|
||||||
|
rule_id = fallback_rule_id
|
||||||
|
if rule_id in known_rule_ids:
|
||||||
|
diagnostics.append(_policy_error(f"Quality rule id {rule_id!r} is duplicated.", rule_id))
|
||||||
|
failed_rule_ids.add(rule_id)
|
||||||
|
continue
|
||||||
|
known_rule_ids.add(rule_id)
|
||||||
|
evaluated_rule_ids.append(rule_id)
|
||||||
|
rule_diagnostics = _evaluate_rule(
|
||||||
|
rule_id=rule_id,
|
||||||
|
rule=raw_rule,
|
||||||
|
rows=rows,
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
diagnostics.extend(rule_diagnostics)
|
||||||
|
if rule_diagnostics:
|
||||||
|
failed_rule_ids.add(rule_id)
|
||||||
|
|
||||||
|
schema_policy, schema_policy_diagnostics = _schema_policy(policy.get("schema_policy"))
|
||||||
|
diagnostics.extend(schema_policy_diagnostics)
|
||||||
|
schema_changes = (
|
||||||
|
_compare_schemas(baseline_schema, schema, schema_policy)
|
||||||
|
if baseline_schema is not None
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
schema_classification: str = "new"
|
||||||
|
if baseline_schema is not None:
|
||||||
|
schema_classification = _aggregate_schema_classification(schema_changes)
|
||||||
|
for change in schema_changes:
|
||||||
|
if change.classification == "warning":
|
||||||
|
diagnostics.append(
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code=change.code,
|
||||||
|
message=change.message,
|
||||||
|
details={"field": change.field_name} if change.field_name else {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif change.classification == "breaking":
|
||||||
|
diagnostics.append(
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code=change.code,
|
||||||
|
message=change.message,
|
||||||
|
details={"field": change.field_name} if change.field_name else {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = [item.to_dict() for item in diagnostics if item.severity == "error"]
|
||||||
|
warnings = [item.to_dict() for item in diagnostics if item.severity == "warning"]
|
||||||
|
return {
|
||||||
|
"version": QUALITY_POLICY_VERSION,
|
||||||
|
"policy_version": str(policy.get("version") or QUALITY_POLICY_VERSION),
|
||||||
|
"policy_hash": _policy_hash(policy),
|
||||||
|
"valid": not errors,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"quality": {
|
||||||
|
"rules_evaluated": len(evaluated_rule_ids),
|
||||||
|
"rules_passed": len(evaluated_rule_ids) - len(failed_rule_ids.intersection(evaluated_rule_ids)),
|
||||||
|
"rules_failed": len(failed_rule_ids),
|
||||||
|
},
|
||||||
|
"schema_change": {
|
||||||
|
"classification": schema_classification,
|
||||||
|
"changes": [change.to_dict() for change in schema_changes],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_rule(
|
||||||
|
*,
|
||||||
|
rule_id: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
kind = str(rule.get("type") or "").strip()
|
||||||
|
severity = str(rule.get("severity") or "error").strip()
|
||||||
|
if severity not in {"error", "warning"}:
|
||||||
|
return (_policy_error("Quality rule severity must be error or warning.", rule_id),)
|
||||||
|
if kind == "row_count":
|
||||||
|
return _row_count_rule(rule_id, severity, rule, len(rows))
|
||||||
|
if kind == "required_fields":
|
||||||
|
return _required_fields_rule(rule_id, severity, rule, schema)
|
||||||
|
if kind == "field":
|
||||||
|
return _field_rule(rule_id, severity, rule, schema)
|
||||||
|
if kind == "not_null":
|
||||||
|
return _not_null_rule(rule_id, severity, rule, rows, schema)
|
||||||
|
if kind == "unique":
|
||||||
|
return _unique_rule(rule_id, severity, rule, rows, schema)
|
||||||
|
if kind == "range":
|
||||||
|
return _range_rule(rule_id, severity, rule, rows, schema)
|
||||||
|
if kind == "referential":
|
||||||
|
return _referential_rule(rule_id, severity, rule, rows, schema)
|
||||||
|
return (_policy_error(f"Unsupported quality rule type {kind!r}.", rule_id),)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_count_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
row_count: int,
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
minimum = _optional_non_negative_int(rule.get("minimum"))
|
||||||
|
maximum = _optional_non_negative_int(rule.get("maximum"))
|
||||||
|
if rule.get("minimum") is not None and minimum is None:
|
||||||
|
return (_policy_error("A row_count rule minimum must be a non-negative integer.", rule_id),)
|
||||||
|
if rule.get("maximum") is not None and maximum is None:
|
||||||
|
return (_policy_error("A row_count rule maximum must be a non-negative integer.", rule_id),)
|
||||||
|
if minimum is None and maximum is None:
|
||||||
|
return (_policy_error("A row_count rule requires minimum or maximum.", rule_id),)
|
||||||
|
if minimum is not None and maximum is not None and minimum > maximum:
|
||||||
|
return (_policy_error("A row_count rule minimum cannot exceed maximum.", rule_id),)
|
||||||
|
if (minimum is not None and row_count < minimum) or (maximum is not None and row_count > maximum):
|
||||||
|
return (
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code="quality.row_count",
|
||||||
|
message=f"Row count {row_count} is outside the configured bounds.",
|
||||||
|
rule_id=rule_id,
|
||||||
|
details={"actual": row_count, "minimum": minimum, "maximum": maximum},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_fields_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
fields, error = _fields_option(rule, "fields")
|
||||||
|
if error:
|
||||||
|
return (_policy_error(error, rule_id),)
|
||||||
|
known = {field.name for field in schema}
|
||||||
|
missing = [name for name in fields if name not in known]
|
||||||
|
if not missing:
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code="quality.required_fields",
|
||||||
|
message=f"Required fields are missing: {', '.join(missing)}.",
|
||||||
|
rule_id=rule_id,
|
||||||
|
details={"fields": missing},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
field_name = _field_option(rule)
|
||||||
|
if not field_name:
|
||||||
|
return (_policy_error("A field rule requires field.", rule_id),)
|
||||||
|
field_value = next((item for item in schema if item.name == field_name), None)
|
||||||
|
if field_value is None:
|
||||||
|
return (
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code="quality.field_missing",
|
||||||
|
message=f"Required field {field_name!r} is missing.",
|
||||||
|
rule_id=rule_id,
|
||||||
|
details={"field": field_name},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
diagnostics: list[QualityDiagnostic] = []
|
||||||
|
expected_type = str(rule.get("data_type") or "").strip()
|
||||||
|
expected_nullable = rule.get("nullable")
|
||||||
|
if not expected_type and expected_nullable is None:
|
||||||
|
return (_policy_error("A field rule requires data_type or nullable.", rule_id),)
|
||||||
|
if expected_type and field_value.data_type != expected_type:
|
||||||
|
diagnostics.append(
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code="quality.field_type",
|
||||||
|
message=f"Field {field_name!r} has type {field_value.data_type!r}; expected {expected_type!r}.",
|
||||||
|
rule_id=rule_id,
|
||||||
|
details={"field": field_name, "actual": field_value.data_type, "expected": expected_type},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if expected_nullable is not None and not isinstance(expected_nullable, bool):
|
||||||
|
diagnostics.append(_policy_error("A field rule nullable value must be boolean.", rule_id))
|
||||||
|
elif isinstance(expected_nullable, bool) and field_value.nullable != expected_nullable:
|
||||||
|
diagnostics.append(
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code="quality.field_nullability",
|
||||||
|
message=f"Field {field_name!r} nullability does not match the configured contract.",
|
||||||
|
rule_id=rule_id,
|
||||||
|
details={"field": field_name, "actual": field_value.nullable, "expected": expected_nullable},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_null_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
fields, error = _fields_option(rule, "fields", allow_single_field=True)
|
||||||
|
if error:
|
||||||
|
return (_policy_error(error, rule_id),)
|
||||||
|
missing = _missing_fields(fields, schema)
|
||||||
|
if missing:
|
||||||
|
return (_policy_error(f"not_null references unknown fields: {', '.join(missing)}.", rule_id),)
|
||||||
|
affected = [index + 1 for index, row in enumerate(rows) if any(row.get(name) is None for name in fields)]
|
||||||
|
return _row_diagnostic(
|
||||||
|
severity,
|
||||||
|
"quality.not_null",
|
||||||
|
f"Rows contain null values in required fields: {', '.join(fields)}.",
|
||||||
|
rule_id,
|
||||||
|
affected,
|
||||||
|
{"fields": list(fields)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
fields, error = _fields_option(rule, "fields", allow_single_field=True)
|
||||||
|
if error:
|
||||||
|
return (_policy_error(error, rule_id),)
|
||||||
|
missing = _missing_fields(fields, schema)
|
||||||
|
if missing:
|
||||||
|
return (_policy_error(f"unique references unknown fields: {', '.join(missing)}.", rule_id),)
|
||||||
|
ignore_nulls = rule.get("ignore_nulls", False)
|
||||||
|
if not isinstance(ignore_nulls, bool):
|
||||||
|
return (_policy_error("A unique rule ignore_nulls value must be boolean.", rule_id),)
|
||||||
|
occurrences: dict[str, list[int]] = {}
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
values = tuple(row.get(name) for name in fields)
|
||||||
|
if ignore_nulls and any(value is None for value in values):
|
||||||
|
continue
|
||||||
|
key = json.dumps(values, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
occurrences.setdefault(key, []).append(index)
|
||||||
|
affected = sorted(index for indexes in occurrences.values() if len(indexes) > 1 for index in indexes)
|
||||||
|
return _row_diagnostic(
|
||||||
|
severity,
|
||||||
|
"quality.unique",
|
||||||
|
f"Rows violate uniqueness for fields: {', '.join(fields)}.",
|
||||||
|
rule_id,
|
||||||
|
affected,
|
||||||
|
{"fields": list(fields)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _range_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
field_name = _field_option(rule)
|
||||||
|
if not field_name:
|
||||||
|
return (_policy_error("A range rule requires field.", rule_id),)
|
||||||
|
if field_name in _missing_fields((field_name,), schema):
|
||||||
|
return (_policy_error(f"range references unknown field {field_name!r}.", rule_id),)
|
||||||
|
minimum = _optional_number(rule.get("minimum"))
|
||||||
|
maximum = _optional_number(rule.get("maximum"))
|
||||||
|
if rule.get("minimum") is not None and minimum is None:
|
||||||
|
return (_policy_error("A range rule minimum must be numeric.", rule_id),)
|
||||||
|
if rule.get("maximum") is not None and maximum is None:
|
||||||
|
return (_policy_error("A range rule maximum must be numeric.", rule_id),)
|
||||||
|
if minimum is None and maximum is None:
|
||||||
|
return (_policy_error("A range rule requires minimum or maximum.", rule_id),)
|
||||||
|
if minimum is not None and maximum is not None and minimum > maximum:
|
||||||
|
return (_policy_error("A range rule minimum cannot exceed maximum.", rule_id),)
|
||||||
|
allow_null = rule.get("allow_null", True)
|
||||||
|
if not isinstance(allow_null, bool):
|
||||||
|
return (_policy_error("A range rule allow_null value must be boolean.", rule_id),)
|
||||||
|
affected: list[int] = []
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
value = row.get(field_name)
|
||||||
|
if value is None and allow_null:
|
||||||
|
continue
|
||||||
|
if not _is_number(value) or (minimum is not None and value < minimum) or (maximum is not None and value > maximum):
|
||||||
|
affected.append(index)
|
||||||
|
return _row_diagnostic(
|
||||||
|
severity,
|
||||||
|
"quality.range",
|
||||||
|
f"Rows contain values outside the configured range for {field_name!r}.",
|
||||||
|
rule_id,
|
||||||
|
affected,
|
||||||
|
{"field": field_name, "minimum": minimum, "maximum": maximum},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _referential_rule(
|
||||||
|
rule_id: str,
|
||||||
|
severity: str,
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
rows: Sequence[Mapping[str, object]],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
field_name = _field_option(rule)
|
||||||
|
if not field_name:
|
||||||
|
return (_policy_error("A referential rule requires field.", rule_id),)
|
||||||
|
if field_name in _missing_fields((field_name,), schema):
|
||||||
|
return (_policy_error(f"referential references unknown field {field_name!r}.", rule_id),)
|
||||||
|
allowed_values = rule.get("allowed_values")
|
||||||
|
if not _is_sequence(allowed_values):
|
||||||
|
return (_policy_error("A bounded referential rule requires allowed_values.", rule_id),)
|
||||||
|
if len(allowed_values) > MAX_REFERENCE_VALUES:
|
||||||
|
return (_policy_error(f"A bounded referential rule supports at most {MAX_REFERENCE_VALUES:,} values.", rule_id),)
|
||||||
|
allow_null = rule.get("allow_null", False)
|
||||||
|
if not isinstance(allow_null, bool):
|
||||||
|
return (_policy_error("A referential rule allow_null value must be boolean.", rule_id),)
|
||||||
|
allowed = {_canonical_value(value) for value in allowed_values}
|
||||||
|
affected = [
|
||||||
|
index
|
||||||
|
for index, row in enumerate(rows, start=1)
|
||||||
|
if not (allow_null and row.get(field_name) is None)
|
||||||
|
and _canonical_value(row.get(field_name)) not in allowed
|
||||||
|
]
|
||||||
|
return _row_diagnostic(
|
||||||
|
severity,
|
||||||
|
"quality.referential",
|
||||||
|
f"Rows contain unrecognized references in {field_name!r}.",
|
||||||
|
rule_id,
|
||||||
|
affected,
|
||||||
|
{"field": field_name, "allowed_value_count": len(allowed)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_schemas(
|
||||||
|
baseline: Sequence[DatasourceField],
|
||||||
|
candidate: Sequence[DatasourceField],
|
||||||
|
policy: Mapping[str, SchemaClassification],
|
||||||
|
) -> tuple[SchemaChange, ...]:
|
||||||
|
before_by_name = {field.name: field for field in baseline}
|
||||||
|
after_by_name = {field.name: field for field in candidate}
|
||||||
|
changes: list[SchemaChange] = []
|
||||||
|
for field_name, before in before_by_name.items():
|
||||||
|
after = after_by_name.get(field_name)
|
||||||
|
if after is None:
|
||||||
|
changes.append(_schema_change("field_removed", field_name, before, None, policy))
|
||||||
|
continue
|
||||||
|
if before.data_type != after.data_type:
|
||||||
|
kind = "type_widened" if (before.data_type, after.data_type) in {("integer", "number"), ("unknown", after.data_type)} else "type_changed"
|
||||||
|
changes.append(_schema_change(kind, field_name, before, after, policy))
|
||||||
|
if before.nullable != after.nullable:
|
||||||
|
kind = "nullability_relaxed" if not before.nullable and after.nullable else "nullability_tightened"
|
||||||
|
changes.append(_schema_change(kind, field_name, before, after, policy))
|
||||||
|
for field_name, after in after_by_name.items():
|
||||||
|
if field_name in before_by_name:
|
||||||
|
continue
|
||||||
|
kind = "field_added_nullable" if after.nullable else "field_added_required"
|
||||||
|
changes.append(_schema_change(kind, field_name, None, after, policy))
|
||||||
|
shared_before = [field.name for field in baseline if field.name in after_by_name]
|
||||||
|
shared_after = [field.name for field in candidate if field.name in before_by_name]
|
||||||
|
if shared_before != shared_after:
|
||||||
|
changes.append(
|
||||||
|
SchemaChange(
|
||||||
|
code="schema.field_order_changed",
|
||||||
|
classification=policy["field_order_changed"],
|
||||||
|
message="The order of existing fields changed.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(changes)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_change(
|
||||||
|
kind: str,
|
||||||
|
field_name: str,
|
||||||
|
before: DatasourceField | None,
|
||||||
|
after: DatasourceField | None,
|
||||||
|
policy: Mapping[str, SchemaClassification],
|
||||||
|
) -> SchemaChange:
|
||||||
|
before_type = before.data_type if before is not None else "unknown"
|
||||||
|
after_type = after.data_type if after is not None else "unknown"
|
||||||
|
messages = {
|
||||||
|
"field_added_nullable": f"Nullable field {field_name!r} was added.",
|
||||||
|
"field_added_required": f"Required field {field_name!r} was added.",
|
||||||
|
"field_removed": f"Field {field_name!r} was removed.",
|
||||||
|
"type_widened": f"Field {field_name!r} widened from {before_type!r} to {after_type!r}.",
|
||||||
|
"type_changed": f"Field {field_name!r} changed type from {before_type!r} to {after_type!r}.",
|
||||||
|
"nullability_relaxed": f"Field {field_name!r} now permits null values.",
|
||||||
|
"nullability_tightened": f"Field {field_name!r} no longer contains null values.",
|
||||||
|
}
|
||||||
|
return SchemaChange(
|
||||||
|
code=f"schema.{kind}",
|
||||||
|
classification=policy[kind],
|
||||||
|
message=messages[kind],
|
||||||
|
field_name=field_name,
|
||||||
|
before=_field_dict(before),
|
||||||
|
after=_field_dict(after),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_policy(
|
||||||
|
raw_policy: object,
|
||||||
|
) -> tuple[dict[str, SchemaClassification], tuple[QualityDiagnostic, ...]]:
|
||||||
|
result = dict(_SCHEMA_DEFAULTS)
|
||||||
|
if raw_policy is None:
|
||||||
|
return result, ()
|
||||||
|
if not isinstance(raw_policy, Mapping):
|
||||||
|
return result, (_policy_error("schema_policy must be an object."),)
|
||||||
|
diagnostics: list[QualityDiagnostic] = []
|
||||||
|
for key, raw_value in raw_policy.items():
|
||||||
|
if key not in result:
|
||||||
|
diagnostics.append(_policy_error(f"Unsupported schema policy change {key!r}."))
|
||||||
|
continue
|
||||||
|
value = str(raw_value).strip()
|
||||||
|
if value not in _CLASSIFICATION_RANK:
|
||||||
|
diagnostics.append(_policy_error(f"Schema policy {key!r} must be compatible, warning, or breaking."))
|
||||||
|
continue
|
||||||
|
result[str(key)] = value # type: ignore[assignment]
|
||||||
|
return result, tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_schema_classification(changes: Sequence[SchemaChange]) -> SchemaClassification:
|
||||||
|
if not changes:
|
||||||
|
return "compatible"
|
||||||
|
return max((change.classification for change in changes), key=_CLASSIFICATION_RANK.__getitem__)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_diagnostic(
|
||||||
|
severity: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
rule_id: str,
|
||||||
|
affected: Sequence[int],
|
||||||
|
details: Mapping[str, object],
|
||||||
|
) -> tuple[QualityDiagnostic, ...]:
|
||||||
|
if not affected:
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
QualityDiagnostic(
|
||||||
|
severity=_severity(severity),
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
rule_id=rule_id,
|
||||||
|
affected_rows=len(affected),
|
||||||
|
row_numbers=tuple(affected[:MAX_DIAGNOSTIC_ROWS]),
|
||||||
|
details=details,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_error(message: str, rule_id: str | None = None) -> QualityDiagnostic:
|
||||||
|
return QualityDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="quality.policy_invalid",
|
||||||
|
message=message,
|
||||||
|
rule_id=rule_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fields_option(
|
||||||
|
rule: Mapping[str, object],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
allow_single_field: bool = False,
|
||||||
|
) -> tuple[tuple[str, ...], str | None]:
|
||||||
|
raw_fields = rule.get(key)
|
||||||
|
if raw_fields is None and allow_single_field:
|
||||||
|
raw_fields = [rule.get("field")] if rule.get("field") is not None else None
|
||||||
|
if not _is_sequence(raw_fields):
|
||||||
|
return (), f"Quality rule {key} must be a non-empty list."
|
||||||
|
fields = tuple(str(value).strip() for value in raw_fields)
|
||||||
|
if not fields or any(not value for value in fields):
|
||||||
|
return (), f"Quality rule {key} must be a non-empty list."
|
||||||
|
if len(fields) != len(set(fields)):
|
||||||
|
return (), f"Quality rule {key} cannot contain duplicates."
|
||||||
|
return fields, None
|
||||||
|
|
||||||
|
|
||||||
|
def _field_option(rule: Mapping[str, object]) -> str:
|
||||||
|
return str(rule.get("field") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_fields(
|
||||||
|
fields: Sequence[str],
|
||||||
|
schema: Sequence[DatasourceField],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
known = {field.name for field in schema}
|
||||||
|
return tuple(name for name in fields if name not in known)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_dict(value: DatasourceField | None) -> dict[str, object] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"name": value.name,
|
||||||
|
"data_type": value.data_type,
|
||||||
|
"nullable": value.nullable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_hash(policy: Mapping[str, object]) -> str:
|
||||||
|
encoded = json.dumps(policy, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_value(value: object) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_non_negative_int(value: object) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_number(value: object) -> int | float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value if _is_number(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_number(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sequence(value: object) -> bool:
|
||||||
|
return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
|
||||||
|
|
||||||
|
|
||||||
|
def _severity(value: str) -> DiagnosticSeverity:
|
||||||
|
return "warning" if value == "warning" else "error"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_DIAGNOSTIC_ROWS",
|
||||||
|
"MAX_REFERENCE_VALUES",
|
||||||
|
"QUALITY_POLICY_VERSION",
|
||||||
|
"QualityDiagnostic",
|
||||||
|
"SchemaChange",
|
||||||
|
"validate_stage",
|
||||||
|
]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -224,6 +226,11 @@ def api_create_stage(
|
|||||||
"mode": stage.mode,
|
"mode": stage.mode,
|
||||||
"row_count": stage.row_count,
|
"row_count": stage.row_count,
|
||||||
"fingerprint": stage.fingerprint,
|
"fingerprint": stage.fingerprint,
|
||||||
|
"validation_valid": stage.validation.get("valid"),
|
||||||
|
"quality_policy_hash": stage.validation.get("policy_hash"),
|
||||||
|
"schema_classification": _safe_mapping(
|
||||||
|
stage.validation.get("schema_change")
|
||||||
|
).get("classification"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -262,6 +269,9 @@ def api_promote_stage(
|
|||||||
"materialization_ref": materialization.ref,
|
"materialization_ref": materialization.ref,
|
||||||
"revision": materialization.revision,
|
"revision": materialization.revision,
|
||||||
"frozen": materialization.frozen_at is not None,
|
"frozen": materialization.frozen_at is not None,
|
||||||
|
"quality_policy_hash": _safe_mapping(
|
||||||
|
materialization.provenance.get("stage_validation")
|
||||||
|
).get("policy_hash"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -663,6 +673,10 @@ def _governance(
|
|||||||
return DatasourceGovernance.from_mapping(payload.model_dump())
|
return DatasourceGovernance.from_mapping(payload.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: object) -> Mapping[str, object]:
|
||||||
|
return value if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
def _audit(
|
def _audit(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
|
|||||||
@@ -121,6 +121,48 @@ class DatasourceMaterializationListResponse(BaseModel):
|
|||||||
materializations: list[DatasourceMaterializationResponse]
|
materializations: list[DatasourceMaterializationResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceValidationDiagnosticResponse(BaseModel):
|
||||||
|
severity: Literal["error", "warning"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
rule_id: str | None = None
|
||||||
|
affected_rows: int | None = None
|
||||||
|
row_numbers: list[int] = Field(default_factory=list)
|
||||||
|
row_numbers_truncated: bool = False
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceSchemaChangeResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
classification: Literal["compatible", "warning", "breaking"]
|
||||||
|
message: str
|
||||||
|
field: str | None = None
|
||||||
|
before: DatasourceFieldResponse | None = None
|
||||||
|
after: DatasourceFieldResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceQualitySummaryResponse(BaseModel):
|
||||||
|
rules_evaluated: int = 0
|
||||||
|
rules_passed: int = 0
|
||||||
|
rules_failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceSchemaChangeSummaryResponse(BaseModel):
|
||||||
|
classification: Literal["new", "compatible", "warning", "breaking"] = "new"
|
||||||
|
changes: list[DatasourceSchemaChangeResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceStageValidationResponse(BaseModel):
|
||||||
|
version: int = 1
|
||||||
|
policy_version: str = "1"
|
||||||
|
policy_hash: str = ""
|
||||||
|
valid: bool = True
|
||||||
|
errors: list[DatasourceValidationDiagnosticResponse] = Field(default_factory=list)
|
||||||
|
warnings: list[DatasourceValidationDiagnosticResponse] = Field(default_factory=list)
|
||||||
|
quality: DatasourceQualitySummaryResponse = Field(default_factory=DatasourceQualitySummaryResponse)
|
||||||
|
schema_change: DatasourceSchemaChangeSummaryResponse = Field(default_factory=DatasourceSchemaChangeSummaryResponse)
|
||||||
|
|
||||||
|
|
||||||
class DatasourceStageResponse(BaseModel):
|
class DatasourceStageResponse(BaseModel):
|
||||||
ref: str
|
ref: str
|
||||||
name: str
|
name: str
|
||||||
@@ -134,7 +176,7 @@ class DatasourceStageResponse(BaseModel):
|
|||||||
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
fields: list[DatasourceFieldResponse] = Field(alias="schema")
|
||||||
row_count: int | None
|
row_count: int | None
|
||||||
byte_count: int | None
|
byte_count: int | None
|
||||||
validation: dict[str, Any]
|
validation: DatasourceStageValidationResponse
|
||||||
created_at: str | None
|
created_at: str | None
|
||||||
promoted_at: str | None
|
promoted_at: str | None
|
||||||
promoted_materialization_ref: str | None
|
promoted_materialization_ref: str | None
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from govoplan_datasources.backend.payloads import (
|
|||||||
payload_for_materialization,
|
payload_for_materialization,
|
||||||
validate_payload_size,
|
validate_payload_size,
|
||||||
)
|
)
|
||||||
|
from govoplan_datasources.backend.quality import validate_stage
|
||||||
from govoplan_datasources.backend.tabular import (
|
from govoplan_datasources.backend.tabular import (
|
||||||
MAX_READ_ROWS,
|
MAX_READ_ROWS,
|
||||||
MAX_STAGE_ROWS,
|
MAX_STAGE_ROWS,
|
||||||
@@ -427,6 +428,12 @@ class SqlDatasourceProvider:
|
|||||||
rows = normalize_rows(stage.rows)
|
rows = normalize_rows(stage.rows)
|
||||||
schema = infer_schema(rows)
|
schema = infer_schema(rows)
|
||||||
fingerprint = fingerprint_rows(rows, schema)
|
fingerprint = fingerprint_rows(rows, schema)
|
||||||
|
validation = validate_stage(
|
||||||
|
rows=rows,
|
||||||
|
schema=schema,
|
||||||
|
quality_policy=governance.quality_policy,
|
||||||
|
baseline_schema=_fields(target.schema_) if target is not None else None,
|
||||||
|
)
|
||||||
item = DatasourceStageRecord(
|
item = DatasourceStageRecord(
|
||||||
tenant_id=api_principal.tenant_id,
|
tenant_id=api_principal.tenant_id,
|
||||||
target_datasource_id=target.id if target else None,
|
target_datasource_id=target.id if target else None,
|
||||||
@@ -436,7 +443,7 @@ class SqlDatasourceProvider:
|
|||||||
kind=stage.kind,
|
kind=stage.kind,
|
||||||
mode=stage.mode,
|
mode=stage.mode,
|
||||||
shape=stage.shape,
|
shape=stage.shape,
|
||||||
state="ready",
|
state="ready" if validation["valid"] else "invalid",
|
||||||
provider=_clean_optional(stage.provider),
|
provider=_clean_optional(stage.provider),
|
||||||
provider_ref=_clean_optional(stage.provider_ref),
|
provider_ref=_clean_optional(stage.provider_ref),
|
||||||
schema_=[field_payload(field) for field in schema],
|
schema_=[field_payload(field) for field in schema],
|
||||||
@@ -444,7 +451,7 @@ class SqlDatasourceProvider:
|
|||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
row_count=len(rows),
|
row_count=len(rows),
|
||||||
byte_count=encoded_size(rows),
|
byte_count=encoded_size(rows),
|
||||||
validation_={"valid": True, "errors": [], "warnings": []},
|
validation_=validation,
|
||||||
provenance_=dict(stage.provenance),
|
provenance_=dict(stage.provenance),
|
||||||
metadata_=dict(stage.metadata),
|
metadata_=dict(stage.metadata),
|
||||||
governance_=governance.to_dict(),
|
governance_=governance.to_dict(),
|
||||||
@@ -469,6 +476,10 @@ class SqlDatasourceProvider:
|
|||||||
tenant_id=api_principal.tenant_id,
|
tenant_id=api_principal.tenant_id,
|
||||||
stage_ref=stage_ref,
|
stage_ref=stage_ref,
|
||||||
)
|
)
|
||||||
|
if stage.validation_.get("valid") is not True:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
"The stage has blocking quality or schema diagnostics and cannot be promoted."
|
||||||
|
)
|
||||||
if stage.state != "ready":
|
if stage.state != "ready":
|
||||||
raise DatasourceValidationError("Only ready stages can be promoted.")
|
raise DatasourceValidationError("Only ready stages can be promoted.")
|
||||||
datasource = (
|
datasource = (
|
||||||
@@ -538,6 +549,7 @@ class SqlDatasourceProvider:
|
|||||||
provenance={
|
provenance={
|
||||||
**dict(stage.provenance_),
|
**dict(stage.provenance_),
|
||||||
"stage_ref": _stage_ref(stage.id),
|
"stage_ref": _stage_ref(stage.id),
|
||||||
|
"stage_validation": dict(stage.validation_),
|
||||||
},
|
},
|
||||||
metadata=dict(stage.metadata_),
|
metadata=dict(stage.metadata_),
|
||||||
set_current=True,
|
set_current=True,
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ class DatasourcesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
topics = {topic.id: topic for topic in manifest.documentation}
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
lifecycle = topics["datasources.lifecycle"]
|
lifecycle = topics["datasources.lifecycle"]
|
||||||
governance = topics["datasources.governance"]
|
governance = topics["datasources.governance"]
|
||||||
|
quality = topics["datasources.quality-gates"]
|
||||||
reference = topics["datasources.reference.fields-and-consequences"]
|
reference = topics["datasources.reference.fields-and-consequences"]
|
||||||
|
|
||||||
self.assertIn("datasources.staging", lifecycle.metadata["help_contexts"])
|
self.assertIn("datasources.staging", lifecycle.metadata["help_contexts"])
|
||||||
self.assertIn("datasources.field.authority-mode", governance.metadata["help_contexts"])
|
self.assertIn("datasources.field.authority-mode", governance.metadata["help_contexts"])
|
||||||
|
self.assertIn("datasources.staging.validation", quality.metadata["help_contexts"])
|
||||||
|
self.assertIn("policy version and hash", quality.body)
|
||||||
|
self.assertTrue(quality.metadata["limitations"])
|
||||||
self.assertIn("datasources.action.promote", reference.metadata["help_contexts"])
|
self.assertIn("datasources.action.promote", reference.metadata["help_contexts"])
|
||||||
self.assertIn("freeze", reference.metadata["consequence_classes"])
|
self.assertIn("freeze", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("retire", reference.metadata["consequence_classes"])
|
self.assertIn("retire", reference.metadata["consequence_classes"])
|
||||||
|
|||||||
@@ -265,6 +265,96 @@ class DatasourceLifecycleTests(unittest.TestCase):
|
|||||||
self.assertEqual(first_record.payload_id, frozen_record.payload_id)
|
self.assertEqual(first_record.payload_id, frozen_record.payload_id)
|
||||||
self.assertEqual([], first_record.rows)
|
self.assertEqual([], first_record.rows)
|
||||||
|
|
||||||
|
def test_stage_quality_and_schema_gates_block_only_error_diagnostics(self) -> None:
|
||||||
|
governance = DatasourceGovernance(
|
||||||
|
authority_mode="native_authoritative",
|
||||||
|
publication_state="internal",
|
||||||
|
quality_policy={
|
||||||
|
"version": "monthly-cases-v1",
|
||||||
|
"rules": [
|
||||||
|
{"id": "unique-id", "type": "unique", "fields": ["id"]},
|
||||||
|
],
|
||||||
|
"schema_policy": {"field_added_required": "warning"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
first_stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_quality_cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
rows=({"id": 1},),
|
||||||
|
governance=governance,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datasource, _first = self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=first_stage.ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
blocked_stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_quality_cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
target_datasource_ref=datasource.ref,
|
||||||
|
rows=({"id": 2, "name": "Ada"}, {"id": 2, "name": "Lin"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("invalid", blocked_stage.state)
|
||||||
|
self.assertFalse(blocked_stage.validation["valid"])
|
||||||
|
self.assertEqual(
|
||||||
|
"quality.unique",
|
||||||
|
blocked_stage.validation["errors"][0]["code"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"warning",
|
||||||
|
blocked_stage.validation["schema_change"]["classification"],
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
DatasourceValidationError,
|
||||||
|
"blocking quality or schema diagnostics",
|
||||||
|
):
|
||||||
|
self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=blocked_stage.ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
ready_stage = self.provider.create_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage=DatasourceStageInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_quality_cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
target_datasource_ref=datasource.ref,
|
||||||
|
rows=({"id": 2, "name": "Ada"}, {"id": 3, "name": "Lin"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("ready", ready_stage.state)
|
||||||
|
self.assertTrue(ready_stage.validation["valid"])
|
||||||
|
self.assertEqual("schema.field_added_required", ready_stage.validation["warnings"][0]["code"])
|
||||||
|
_updated, materialization = self.provider.promote_stage(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
stage_ref=ready_stage.ref,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
ready_stage.validation["policy_hash"],
|
||||||
|
materialization.provenance["stage_validation"]["policy_hash"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_governance_is_queryable_and_snapshotted_per_materialization(self) -> None:
|
def test_governance_is_queryable_and_snapshotted_per_materialization(self) -> None:
|
||||||
stage = self.provider.create_stage(
|
stage = self.provider.create_stage(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import DatasourceField
|
||||||
|
from govoplan_datasources.backend.quality import MAX_DIAGNOSTIC_ROWS, validate_stage
|
||||||
|
from govoplan_datasources.backend.schemas import DatasourceStageValidationResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceQualityTests(unittest.TestCase):
|
||||||
|
def test_quality_rules_report_counts_without_exposing_values(self) -> None:
|
||||||
|
rows = (
|
||||||
|
{"id": 1, "name": "Ada", "amount": 10, "status": "new"},
|
||||||
|
{"id": 1, "name": None, "amount": -2, "status": "unknown"},
|
||||||
|
)
|
||||||
|
schema = (
|
||||||
|
DatasourceField("id", "integer", False),
|
||||||
|
DatasourceField("name", "string", True),
|
||||||
|
DatasourceField("amount", "integer", False),
|
||||||
|
DatasourceField("status", "string", False),
|
||||||
|
)
|
||||||
|
result = validate_stage(
|
||||||
|
rows=rows,
|
||||||
|
schema=schema,
|
||||||
|
quality_policy={
|
||||||
|
"version": "case-import-v2",
|
||||||
|
"rules": [
|
||||||
|
{"id": "rows", "type": "row_count", "minimum": 3},
|
||||||
|
{"id": "columns", "type": "required_fields", "fields": ["id", "missing"]},
|
||||||
|
{"id": "id-shape", "type": "field", "field": "id", "data_type": "string"},
|
||||||
|
{"id": "names", "type": "not_null", "fields": ["name"]},
|
||||||
|
{"id": "ids", "type": "unique", "fields": ["id"]},
|
||||||
|
{"id": "amount", "type": "range", "field": "amount", "minimum": 0},
|
||||||
|
{
|
||||||
|
"id": "status",
|
||||||
|
"type": "referential",
|
||||||
|
"field": "status",
|
||||||
|
"allowed_values": ["new", "closed"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertEqual("case-import-v2", result["policy_version"])
|
||||||
|
errors = result["errors"]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"quality.row_count",
|
||||||
|
"quality.required_fields",
|
||||||
|
"quality.field_type",
|
||||||
|
"quality.not_null",
|
||||||
|
"quality.unique",
|
||||||
|
"quality.range",
|
||||||
|
"quality.referential",
|
||||||
|
},
|
||||||
|
{item["code"] for item in errors},
|
||||||
|
)
|
||||||
|
unique = next(item for item in errors if item["code"] == "quality.unique")
|
||||||
|
self.assertEqual(2, unique["affected_rows"])
|
||||||
|
self.assertEqual([1, 2], unique["row_numbers"])
|
||||||
|
self.assertNotIn("unknown", str(errors))
|
||||||
|
contract = DatasourceStageValidationResponse.model_validate(result)
|
||||||
|
self.assertEqual("case-import-v2", contract.policy_version)
|
||||||
|
self.assertEqual(7, contract.quality.rules_failed)
|
||||||
|
|
||||||
|
def test_warning_rules_do_not_block_promotion_readiness(self) -> None:
|
||||||
|
result = validate_stage(
|
||||||
|
rows=({"id": 1},),
|
||||||
|
schema=(DatasourceField("id", "integer", False),),
|
||||||
|
quality_policy={
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "large-batch",
|
||||||
|
"type": "row_count",
|
||||||
|
"minimum": 2,
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
self.assertEqual([], result["errors"])
|
||||||
|
self.assertEqual("quality.row_count", result["warnings"][0]["code"])
|
||||||
|
|
||||||
|
def test_schema_changes_are_classified_and_can_be_governed(self) -> None:
|
||||||
|
baseline = (
|
||||||
|
DatasourceField("id", "integer", False),
|
||||||
|
DatasourceField("label", "string", False),
|
||||||
|
)
|
||||||
|
candidate = (
|
||||||
|
DatasourceField("id", "number", False),
|
||||||
|
DatasourceField("note", "string", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
blocked = validate_stage(
|
||||||
|
rows=(),
|
||||||
|
schema=candidate,
|
||||||
|
baseline_schema=baseline,
|
||||||
|
quality_policy={},
|
||||||
|
)
|
||||||
|
self.assertFalse(blocked["valid"])
|
||||||
|
self.assertEqual("breaking", blocked["schema_change"]["classification"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"schema.type_widened", "schema.field_removed", "schema.field_added_nullable"},
|
||||||
|
{item["code"] for item in blocked["schema_change"]["changes"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
governed = validate_stage(
|
||||||
|
rows=(),
|
||||||
|
schema=candidate,
|
||||||
|
baseline_schema=baseline,
|
||||||
|
quality_policy={"schema_policy": {"field_removed": "warning"}},
|
||||||
|
)
|
||||||
|
self.assertTrue(governed["valid"])
|
||||||
|
self.assertEqual("warning", governed["schema_change"]["classification"])
|
||||||
|
|
||||||
|
def test_malformed_policy_fails_closed_and_hash_is_stable(self) -> None:
|
||||||
|
policy = {"rules": [{"id": "bad", "type": "unique", "fields": []}]}
|
||||||
|
first = validate_stage(rows=(), schema=(), quality_policy=policy)
|
||||||
|
second = validate_stage(rows=(), schema=(), quality_policy=policy)
|
||||||
|
|
||||||
|
self.assertFalse(first["valid"])
|
||||||
|
self.assertEqual("quality.policy_invalid", first["errors"][0]["code"])
|
||||||
|
self.assertEqual(first["policy_hash"], second["policy_hash"])
|
||||||
|
|
||||||
|
def test_diagnostic_row_numbers_are_bounded(self) -> None:
|
||||||
|
rows = tuple({"id": None} for _ in range(MAX_DIAGNOSTIC_ROWS + 10))
|
||||||
|
result = validate_stage(
|
||||||
|
rows=rows,
|
||||||
|
schema=(DatasourceField("id", "unknown", True),),
|
||||||
|
quality_policy={"rules": [{"id": "id", "type": "not_null", "field": "id"}]},
|
||||||
|
)
|
||||||
|
error = result["errors"][0]
|
||||||
|
self.assertEqual(MAX_DIAGNOSTIC_ROWS + 10, error["affected_rows"])
|
||||||
|
self.assertEqual(MAX_DIAGNOSTIC_ROWS, len(error["row_numbers"]))
|
||||||
|
self.assertTrue(error["row_numbers_truncated"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -87,6 +87,44 @@ export type DatasourceMaterialization = {
|
|||||||
governance: DatasourceGovernance;
|
governance: DatasourceGovernance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DatasourceValidationDiagnostic = {
|
||||||
|
severity: "error" | "warning";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
rule_id?: string;
|
||||||
|
affected_rows?: number;
|
||||||
|
row_numbers?: number[];
|
||||||
|
row_numbers_truncated?: boolean;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourceSchemaChange = {
|
||||||
|
code: string;
|
||||||
|
classification: "compatible" | "warning" | "breaking";
|
||||||
|
message: string;
|
||||||
|
field?: string;
|
||||||
|
before?: DatasourceField;
|
||||||
|
after?: DatasourceField;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatasourceStageValidation = {
|
||||||
|
version?: number;
|
||||||
|
policy_version?: string;
|
||||||
|
policy_hash?: string;
|
||||||
|
valid?: boolean;
|
||||||
|
errors?: DatasourceValidationDiagnostic[];
|
||||||
|
warnings?: DatasourceValidationDiagnostic[];
|
||||||
|
quality?: {
|
||||||
|
rules_evaluated?: number;
|
||||||
|
rules_passed?: number;
|
||||||
|
rules_failed?: number;
|
||||||
|
};
|
||||||
|
schema_change?: {
|
||||||
|
classification?: "new" | "compatible" | "warning" | "breaking";
|
||||||
|
changes?: DatasourceSchemaChange[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type DatasourceStage = {
|
export type DatasourceStage = {
|
||||||
ref: string;
|
ref: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -100,7 +138,7 @@ export type DatasourceStage = {
|
|||||||
schema: DatasourceField[];
|
schema: DatasourceField[];
|
||||||
row_count?: number | null;
|
row_count?: number | null;
|
||||||
byte_count?: number | null;
|
byte_count?: number | null;
|
||||||
validation: Record<string, unknown>;
|
validation: DatasourceStageValidation;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
promoted_at?: string | null;
|
promoted_at?: string | null;
|
||||||
promoted_materialization_ref?: string | null;
|
promoted_materialization_ref?: string | null;
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ import {
|
|||||||
type DatasourceMaterialization,
|
type DatasourceMaterialization,
|
||||||
type DatasourceOrigin,
|
type DatasourceOrigin,
|
||||||
type DatasourcePreview,
|
type DatasourcePreview,
|
||||||
type DatasourceStage
|
type DatasourceSchemaChange,
|
||||||
|
type DatasourceStage,
|
||||||
|
type DatasourceValidationDiagnostic
|
||||||
} from "../../api/datasources";
|
} from "../../api/datasources";
|
||||||
import {
|
import {
|
||||||
DATASOURCE_FIELDS_DOCUMENTATION,
|
DATASOURCE_FIELDS_DOCUMENTATION,
|
||||||
@@ -717,6 +719,10 @@ function DatasourceDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StageDetail({ stage }: { stage: DatasourceStage }) {
|
function StageDetail({ stage }: { stage: DatasourceStage }) {
|
||||||
|
const errors = stage.validation.errors ?? [];
|
||||||
|
const warnings = stage.validation.warnings ?? [];
|
||||||
|
const schemaChanges = stage.validation.schema_change?.changes ?? [];
|
||||||
|
const schemaClassification = stage.validation.schema_change?.classification ?? "new";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="datasources-metrics">
|
<div className="datasources-metrics">
|
||||||
@@ -738,8 +744,44 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
|||||||
<span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span>
|
<span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span>
|
||||||
<span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span>
|
<span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span>
|
||||||
<span><small>Promoted revision</small><strong>{stage.promoted_materialization_ref || "Not promoted"}</strong></span>
|
<span><small>Promoted revision</small><strong>{stage.promoted_materialization_ref || "Not promoted"}</strong></span>
|
||||||
|
<span><small>Quality policy</small><strong>{stage.validation.policy_version || "Local default"}</strong></span>
|
||||||
|
<span><small>Policy hash</small><strong>{shortFingerprint(stage.validation.policy_hash || "")}</strong></span>
|
||||||
|
<span><small>Schema change</small><strong>{readableToken(schemaClassification)}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.length || warnings.length ? (
|
||||||
|
<div className="datasources-validation-alerts">
|
||||||
|
{errors.length ? (
|
||||||
|
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||||
|
<strong>Promotion blockers</strong>
|
||||||
|
<ValidationDiagnosticList diagnostics={errors} />
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
{warnings.length ? (
|
||||||
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||||
|
<strong>Review warnings</strong>
|
||||||
|
<ValidationDiagnosticList diagnostics={warnings} />
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="datasources-validation-ok">
|
||||||
|
All configured quality rules passed and no blocking schema change was detected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{schemaChanges.length ? (
|
||||||
|
<section className="datasources-detail-section">
|
||||||
|
<div className="datasources-section-heading">
|
||||||
|
<span>Schema comparison</span>
|
||||||
|
<StatusBadge status={schemaClassification} label={readableToken(schemaClassification)} />
|
||||||
|
</div>
|
||||||
|
<ul className="datasources-schema-changes">
|
||||||
|
{schemaChanges.map((change, index) => (
|
||||||
|
<SchemaChangeItem key={`${change.code}-${change.field ?? index}`} change={change} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
<section className="datasources-detail-section">
|
<section className="datasources-detail-section">
|
||||||
<div className="datasources-section-heading">
|
<div className="datasources-section-heading">
|
||||||
<span>Detected schema</span>
|
<span>Detected schema</span>
|
||||||
@@ -751,6 +793,42 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ValidationDiagnosticList({
|
||||||
|
diagnostics
|
||||||
|
}: {
|
||||||
|
diagnostics: DatasourceValidationDiagnostic[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="datasources-diagnostic-list">
|
||||||
|
{diagnostics.map((diagnostic, index) => {
|
||||||
|
const details = [
|
||||||
|
diagnostic.rule_id ? `Rule ${diagnostic.rule_id}` : "",
|
||||||
|
diagnostic.affected_rows !== undefined ? `${diagnostic.affected_rows} affected row${diagnostic.affected_rows === 1 ? "" : "s"}` : "",
|
||||||
|
diagnostic.row_numbers?.length ? `Rows ${diagnostic.row_numbers.join(", ")}${diagnostic.row_numbers_truncated ? ", …" : ""}` : ""
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<li key={`${diagnostic.code}-${diagnostic.rule_id ?? index}`}>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
{details.length ? <small>{details.join(" · ")}</small> : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaChangeItem({ change }: { change: DatasourceSchemaChange }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<StatusBadge status={change.classification} label={readableToken(change.classification)} />
|
||||||
|
<span>
|
||||||
|
<strong>{change.message}</strong>
|
||||||
|
{change.field ? <small>{change.field}</small> : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
|
function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -62,7 +62,15 @@ const en = {
|
|||||||
"Publication state": "Publication state",
|
"Publication state": "Publication state",
|
||||||
"Semantic definition": "Semantic definition",
|
"Semantic definition": "Semantic definition",
|
||||||
"Freshness policy (JSON)": "Freshness policy (JSON)",
|
"Freshness policy (JSON)": "Freshness policy (JSON)",
|
||||||
"Quality policy (JSON)": "Quality policy (JSON)"
|
"Quality policy (JSON)": "Quality policy (JSON)",
|
||||||
|
"Quality policy": "Quality policy",
|
||||||
|
"Policy hash": "Policy hash",
|
||||||
|
"Schema change": "Schema change",
|
||||||
|
"Local default": "Local default",
|
||||||
|
"Promotion blockers": "Promotion blockers",
|
||||||
|
"Review warnings": "Review warnings",
|
||||||
|
"All configured quality rules passed and no blocking schema change was detected.": "All configured quality rules passed and no blocking schema change was detected.",
|
||||||
|
"Schema comparison": "Schema comparison"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const de: Record<keyof typeof en, string> = {
|
const de: Record<keyof typeof en, string> = {
|
||||||
@@ -127,7 +135,15 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
"Publication state": "Veröffentlichungsstatus",
|
"Publication state": "Veröffentlichungsstatus",
|
||||||
"Semantic definition": "Semantische Definition",
|
"Semantic definition": "Semantische Definition",
|
||||||
"Freshness policy (JSON)": "Aktualitätsrichtlinie (JSON)",
|
"Freshness policy (JSON)": "Aktualitätsrichtlinie (JSON)",
|
||||||
"Quality policy (JSON)": "Qualitätsrichtlinie (JSON)"
|
"Quality policy (JSON)": "Qualitätsrichtlinie (JSON)",
|
||||||
|
"Quality policy": "Qualitätsrichtlinie",
|
||||||
|
"Policy hash": "Richtlinien-Hash",
|
||||||
|
"Schema change": "Schemaänderung",
|
||||||
|
"Local default": "Lokaler Standard",
|
||||||
|
"Promotion blockers": "Übernahmehindernisse",
|
||||||
|
"Review warnings": "Prüfhinweise",
|
||||||
|
"All configured quality rules passed and no blocking schema change was detected.": "Alle konfigurierten Qualitätsregeln wurden erfüllt und es wurde keine blockierende Schemaänderung erkannt.",
|
||||||
|
"Schema comparison": "Schemavergleich"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
|
|||||||
@@ -369,6 +369,74 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.datasources-validation-alerts {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-validation-alerts .alert {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-diagnostic-list,
|
||||||
|
.datasources-schema-changes {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-diagnostic-list li,
|
||||||
|
.datasources-diagnostic-list span,
|
||||||
|
.datasources-diagnostic-list small,
|
||||||
|
.datasources-schema-changes li,
|
||||||
|
.datasources-schema-changes span,
|
||||||
|
.datasources-schema-changes strong,
|
||||||
|
.datasources-schema-changes small {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-diagnostic-list small,
|
||||||
|
.datasources-schema-changes small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-validation-ok {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-schema-changes {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-schema-changes li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-schema-changes li + li {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datasources-schema-changes strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.datasources-workspace-empty {
|
.datasources-workspace-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user