Validation Engine¶
The standalone package includes a five-layer validation engine (craft_easy_file_import.validation). Layers run in a fixed order; later layers can assume earlier ones have already passed.
Layer 1 — Schema (JSON Schema draft 2020-12, per row)
Layer 2 — Row (domain-specific checks: IBAN, BIC, regex, etc.)
Layer 3 — Business (read-only API lookups with caching)
Layer 4 — Plugin (entry-point-registered Python callables)
Layer 5 — Cross-row (two-pass: accumulate per row, validate after all rows)
Layers 1–4 run per row via ValidationEngine.validate_row(). Layer 5 runs once via ValidationEngine.finalize() after all rows have been processed.
All layers produce ValidationError objects — no layer raises exceptions in the happy path. The caller decides what to do based on severity:
| Severity | Effect |
|---|---|
error |
Row becomes a RejectedRecord |
warning |
Row is committed; warning attached for later review |
info |
Logged; no impact on the row |
Wiring the engine¶
The engine is configured through the validation dict on ImportTemplate:
from craft_easy_file_import.models import ImportTemplate
from craft_easy_file_import.validation.engine import ValidationEngine
template = ImportTemplate(
id="my-template",
validation={
"schema": {...}, # Layer 1
"row_rules": [...], # Layer 2
"business_rules": [...], # Layer 3
"plugins": [...], # Layer 4
"aggregates": [...], # Layer 5 — aggregate definitions
"cross_row_rules": [...], # Layer 5 — rules against aggregates
"abort_on_first_error": False, # stop per-row after first ERROR
"locale": "sv", # message translation locale
},
)
engine = ValidationEngine(template, api_client=api)
for i, row in enumerate(rows, start=1):
errors = await engine.validate_row(row, row_number=i)
# handle errors...
cross_row_errors = await engine.finalize()
Layer 1: Schema (JSON Schema draft 2020-12)¶
Validates each row dict against a JSON Schema definition. Any keyword supported by jsonschema's Draft202012Validator works.
"schema": {
"type": "object",
"required": ["org_nr", "amount", "currency"],
"properties": {
"org_nr": {"type": "string", "minLength": 10},
"amount": {"type": "number", "minimum": 0},
"currency": {"type": "string", "enum": ["SEK", "EUR", "NOK"]},
"due_date": {"type": "string", "format": "date"},
},
"additionalProperties": True,
}
Schema errors carry layer="schema", rule="json_schema.<keyword>", and a field derived from the JSON Pointer path to the failing node.
Layer 2: Row rules¶
Declarative row-level checks for domain formats that JSON Schema cannot express concisely. Rules are evaluated in order; all failures are collected unless abort_on_first_error is set.
Every rule has type, field, optional severity (error by default), and optional allow_empty (skip the rule when the field is empty).
regex¶
luhn¶
Validates a Luhn (mod-10) check digit — card numbers, some ID formats:
iban¶
Full IBAN validation: country prefix check, length-by-country, and mod-97:
Accepts IBANs with spaces or hyphens (SE35 5000 0000 0549 1000 0003 and SE3550000000054910000003 both pass).
bic¶
BIC/SWIFT format validation (8 or 11 characters):
orgnr_se¶
Swedish organisation number (10 digits, third digit ≥ 2, Luhn check). Accepts 10- and 12-digit forms with or without hyphen:
personnr_se¶
Swedish personal identity number (YYYYMMDD-XXXX or YYMMDD-XXXX, Luhn check on the 10-digit form):
date_range¶
Validates that a date falls within a range. min/max accept ISO dates or the string "today". Parsed formats: YYYY-MM-DD, YYYYMMDD, DD/MM/YYYY, DD.MM.YYYY.
# Payments must not be in the future
{"type": "date_range", "field": "payment_date", "max": "today"}
# Invoice dates must be within the last 5 years
{"type": "date_range", "field": "invoice_date", "min": "2020-01-01"}
# Custom format
{"type": "date_range", "field": "val_dt", "format": "%Y%m%d", "min": "today"}
one_of¶
Exact membership check, with optional case-insensitive mode:
{"type": "one_of", "field": "status", "values": ["active", "suspended", "closed"]}
{"type": "one_of", "field": "country", "values": ["SE", "NO", "DK"], "case_sensitive": False}
Layer 3: Business rules¶
Business rules make read-only API calls to enforce referential integrity. Results are cached per import run in ValidationContext to avoid N+1 queries.
counterparty_exists¶
Checks that a field value exists in another collection:
{
"type": "counterparty_exists",
"field": "supplier_org_nr",
"collection": "suppliers",
"lookup_field": "org_nr",
"severity": "error",
}
Calls api_client.exists("suppliers", {"org_nr": value}). Repeated lookups for the same value within a run hit the cache.
unique_field¶
Checks that a field value is unique across all rows in the current run:
Uniqueness is checked in-memory within the run — not across previous runs.
Layer 4: Plugins¶
Plugins are Python callables registered via pyproject.toml entry points. Templates reference them by name; arbitrary module paths are not allowed (security model matches mapper plugins).
Registering a plugin¶
# pyproject.toml
[project.entry-points."craft_easy_file_import.validators"]
swedish_parking_fee = "mypackage.import_validators:validate_parking_fee"
The callable signature:
from craft_easy_file_import.validation.context import ValidationContext
from craft_easy_file_import.validation.errors import ValidationError
async def validate_parking_fee(
row: dict,
context: ValidationContext,
api_client: Any,
) -> list[ValidationError]:
# return [] for pass, or [ValidationError(...)] for failures
...
Sync functions are also accepted — the engine detects and awaits accordingly.
Using a plugin in a template¶
Plugin load failures (import errors) are logged at WARNING level but do not prevent other plugins from running. A plugin that raises at runtime produces a single ValidationError with severity=error and rule="plugin.{name}.error".
Programmatic registration (testing)¶
from craft_easy_file_import.validation.plugin import register_plugin
register_plugin("my_validator", my_validate_fn)
Layer 5: Cross-row validation¶
Two-pass validation that computes aggregates across all rows, then runs rules against those aggregates.
Pass 1 (accumulate) runs during row iteration and updates running aggregates.
Pass 2 (finalize) runs once after all rows and checks the rules.
Aggregates¶
Each aggregate has a name, an op, and (for field-based ops) a field:
op |
What it computes |
|---|---|
sum |
Running sum of the field (numeric coercion) |
count |
Total number of rows |
distinct |
Unique values of the field (stored as a set; resolved to count in rules) |
min |
Minimum value of the field |
max |
Maximum value of the field |
"aggregates": [
{"name": "total_amount", "op": "sum", "field": "amount"},
{"name": "row_count", "op": "count"},
{"name": "unique_orgnrs", "op": "distinct", "field": "org_nr"},
]
Cross-row rules¶
Rules compare two expressions using a comparison operator. Expressions can reference aggregates (aggregates.name), file header fields (header.name), or literals.
type |
Check |
|---|---|
equals |
left == right |
less_than |
left < right |
less_than_or_equal |
left <= right |
greater_than |
left > right |
greater_than_or_equal |
left >= right |
"cross_row_rules": [
# Total amount in the file must match a control total in the file header
{
"type": "equals",
"left": "aggregates.total_amount",
"right": "header.control_total",
"severity": "error",
},
# There must be at least 1 row
{
"type": "greater_than_or_equal",
"left": "aggregates.row_count",
"right": "1",
},
# At most 500 unique org numbers
{
"type": "less_than_or_equal",
"left": "aggregates.unique_orgnrs",
"right": "500",
"severity": "warning",
},
]
The header dict is passed to finalize() — typically populated from file-level metadata by the parser or pipeline:
Complete example¶
template = ImportTemplate(
id="supplier-payments",
validation={
"schema": {
"type": "object",
"required": ["org_nr", "amount", "due_date"],
"properties": {
"org_nr": {"type": "string"},
"amount": {"type": "number"},
"due_date": {"type": "string"},
},
},
"row_rules": [
{"type": "orgnr_se", "field": "org_nr"},
{"type": "date_range", "field": "due_date", "min": "today"},
],
"business_rules": [
{
"type": "counterparty_exists",
"field": "org_nr",
"collection": "suppliers",
"lookup_field": "org_nr",
},
],
"aggregates": [
{"name": "total", "op": "sum", "field": "amount"},
{"name": "row_count", "op": "count"},
],
"cross_row_rules": [
{
"type": "greater_than",
"left": "aggregates.row_count",
"right": "0",
},
],
},
)