Validators¶
The validation system provides cross-field and cross-entity validation beyond what Pydantic handles at the field level.
Overview¶
Validation occurs in two layers:
- Pydantic validation - Field-level constraints (type, pattern, min/max) handled by generated models
- Rule-based validation - Cross-field logic, date ranges, conditional requirements
The validators module handles the second layer.
Quick Start¶
from metaseed.validators import validate
# Simple validation
errors = validate(
data={"unique_id": "STUDY001", "start_date": "2024-03-01"},
entity="Study",
profile="miappe",
version="1.1"
)
for error in errors:
print(f"{error.field}: {error.message}")
Validation Engine¶
The ValidationEngine collects rules and runs them against data.
from metaseed.validators.engine import ValidationEngine
from metaseed.validators.rules import DateRangeRule, RequiredFieldsRule
engine = ValidationEngine()
engine.add_rule(RequiredFieldsRule(fields=["unique_id", "title"]))
engine.add_rule(DateRangeRule(start_field="start_date", end_field="end_date"))
errors = engine.validate({
"unique_id": "STUDY001",
"title": "", # Error: empty required field
"start_date": "2024-03-15",
"end_date": "2024-03-01" # Error: before start
})
Factory Function¶
Create a pre-configured engine for an entity from its profile spec:
from metaseed.validators.engine import create_engine_for_entity
# Engine with all rules defined for the entity in the profile
engine = create_engine_for_entity("Study", version="1.1", profile="miappe")
errors = engine.validate({"unique_id": "STUDY001", "start_date": "2024-03-01"})
The engine loads the validation_rules declared for the entity in the profile
YAML and applies them alongside any rules you add manually.
Validation Rules¶
RequiredFieldsRule¶
Validates that fields are present and non-empty.
from metaseed.validators.rules import RequiredFieldsRule
rule = RequiredFieldsRule(fields=["unique_id", "title", "description"])
errors = rule.validate({"unique_id": "INV001", "title": ""})
# Error: Field 'title' is required
DateRangeRule¶
Validates that an end date is not before a start date.
from metaseed.validators.rules import DateRangeRule
rule = DateRangeRule(start_field="start_date", end_field="end_date")
errors = rule.validate({
"start_date": "2024-03-15",
"end_date": "2024-03-01"
})
# Error: end_date (2024-03-01) must not be before start_date (2024-03-15)
Accepts both date strings and datetime.date objects.
UniqueIdPatternRule¶
Validates that identifiers match expected patterns.
from metaseed.validators.rules import UniqueIdPatternRule
# Default pattern: alphanumeric, underscores, hyphens
rule = UniqueIdPatternRule(field="unique_id")
errors = rule.validate({"unique_id": "STUDY@001"})
# Error: Field 'unique_id' contains invalid characters
# Custom pattern
rule = UniqueIdPatternRule(field="code", pattern=r"^[A-Z]{3}[0-9]{3}$")
errors = rule.validate({"code": "ABC123"}) # Valid
ConditionalRule¶
Validates conditional field requirements using boolean expressions.
from metaseed.validators.rules import ConditionalRule
# At least one identifier required
rule = ConditionalRule(
condition="doi OR pubmed_id OR title",
rule_name="publication_identifier"
)
# Both or neither
rule = ConditionalRule(
condition="(latitude AND longitude) OR (NOT latitude AND NOT longitude)",
rule_name="coordinates_complete"
)
Supported operators:
| Operator | Description |
|---|---|
AND |
Both conditions must be true |
OR |
At least one must be true |
NOT |
Negates the condition |
() |
Groups conditions |
CoordinatePairRule¶
Validates that latitude and longitude are provided together.
from metaseed.validators.rules import CoordinatePairRule
rule = CoordinatePairRule(lat_field="latitude", lon_field="longitude")
errors = rule.validate({"latitude": 51.5})
# Error: 'longitude' is required when 'latitude' is provided
ListCardinalityRule¶
Validates list field item counts.
from metaseed.validators.rules import ListCardinalityRule
rule = ListCardinalityRule(
field="samples",
min_items=1,
max_items=100
)
errors = rule.validate({"samples": []})
# Error: 'samples' must have at least 1 item(s), but has 0
Rules on a single extracted record¶
ExtractionContext.validate_instance validates one row extracted from a source
file. A row is a flat record: its child entities are extracted separately, and
sibling rows are not visible to it. create_engine_for_extracted_record builds
an engine from a loaded ProfileSpec containing only the rules that such a
record can answer.
from metaseed.validators.engine import create_engine_for_extracted_record
from metaseed.specs.loader import SpecLoader
profile_spec = SpecLoader(profile="miappe").load_profile("1.2", "miappe")
engine = create_engine_for_extracted_record("ObservedVariable", profile_spec)
errors = engine.validate({"unique_id": "OV-1", "trait": "plant height"})
Which of the profile's validation_rules run:
| Rule type | On a single extracted record | Reason |
|---|---|---|
conditional |
Runs | Reads only the record's own fields. |
date_range |
Runs | Reads only the record's own fields. |
coordinate_pair |
Runs | Reads only the record's own fields. |
pattern on a uri / ontology_term field |
Runs | Single-value check. A pattern on a string field is merged onto the field's constraints at load and is applied by the field-level checks instead. |
minimum / maximum / enum |
Runs as a field constraint | Merged onto the field at load; applied by the field-level checks, not by an engine rule. |
cardinality over a list of scalars |
Runs | The list is a value of the record. A missing field counts as zero items, as it does on every other path. |
cardinality over a child collection |
Skipped | The children are extracted as their own records, so the parent record never holds them and the rule would report zero items for every row. |
uniqueness |
Not built by any engine | Needs the sibling records, which no engine sees. Enforced over the whole tree by DatasetValidator. |
reference |
Not built by any engine | Needs the identifiers held elsewhere in the dataset. Enforced over the whole tree by DatasetValidator. |
Rules derived from the entity spec rather than declared in the profile
(RequiredFieldsRule, UniqueIdPatternRule) are not added either:
validate_instance reports missing required fields itself.
uniqueness and reference remain valid rule types in a profile; they are
simply enforced somewhere other than the engine. create_engine_for_entity
does not build them either, so a rule of either type never reaches a
ValidationEngine.
ValidationError¶
All rules return ValidationError objects:
from metaseed.validators.base import ValidationError
error = ValidationError(
field="end_date",
message="Must not be before start_date",
rule="date_range"
)
print(error) # end_date: Must not be before start_date (rule: date_range)
| Attribute | Description |
|---|---|
field |
Name of the field that failed |
message |
Human-readable error description |
rule |
Name of the rule that generated the error |
Custom Rules¶
Create custom rules by subclassing ValidationRule:
from metaseed.validators.base import ValidationRule, ValidationError
from typing import Any
class EmailDomainRule(ValidationRule):
"""Validates email domain matches allowed list."""
def __init__(self, field: str, allowed_domains: list[str]):
self.field = field
self.allowed_domains = allowed_domains
@property
def name(self) -> str:
return "email_domain"
def validate(self, data: dict[str, Any]) -> list[ValidationError]:
email = data.get(self.field)
if not email:
return []
domain = email.split("@")[-1]
if domain not in self.allowed_domains:
return [ValidationError(
field=self.field,
message=f"Email domain must be one of: {self.allowed_domains}",
rule=self.name
)]
return []
Integration with Profile Specs¶
Validation rules defined in profile YAML specs are automatically loaded:
# profile.yaml
validation_rules:
- name: date_range_valid
applies_to: [Study]
condition: "end_date >= start_date"
- name: coordinates_complete
applies_to: [Location]
condition: "(latitude AND longitude) OR (NOT latitude AND NOT longitude)"
When using create_engine_for_entity(), these rules are converted to ValidationRule instances and added to the engine.
See Also¶
- Schema Specs - Defining validation rules in YAML
- Model Factory - How Pydantic handles field-level validation