Example Validation Scripts
Each script below is complete and runnable. Swap in your own column and field names, and combine them as needed. These are Content Validation scripts, so each one returns a pass-or-fail verdict rather than a transformed file.
Required and Exact Fields
Checking whether a CSV with headers includes a specific column just requires comparing the first row of the input with the expected column:
%files 1.0
---
namesOf(payload[0]) contains "account_id"
To require the header row to instead be an exact series of columns, in order, do a literal comparison of the names of the first row:
%files 1.0
---
if (namesOf(payload[0]) == ["account_id", "amount", "effective_date"]) true
else ({
success: false,
errors: [{
message: "First row does not match the expected header row",
field: "header",
expected: joinBy(["account_id", "amount", "effective_date"], " | "),
actual: joinBy(namesOf(payload[0]), " | ")
}]
})
Values in the Wrong Format
To check every row against a format rule and report the row a bad value came from, pair each record with its index before filtering:
%files 1.0
---
do {
var indexed = map(payload, (row, i) -> { line: i + 2, row: row })
var bad = filter(indexed, (e) -> not matches(e.row.effective_date, "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"))
var errors = map(bad, (e) -> {
message: "Effective date is not in YYYY-MM-DD form",
field: "effective_date",
row: e.line,
expected: "YYYY-MM-DD",
actual: e.row.effective_date
})
---
if (sizeOf(errors) == 0) ({ success: true }) else ({ success: false, errors: errors })
}
The i + 2 offset numbers rows the way someone looking at the file in a spreadsheet would, counting the header as row 1.
Rules That Span Fields
A value can be individually valid and still be wrong next to another field. Build the condition from both fields, and name both of them in the message so the reader knows which combination was rejected:
%files 1.0
---
do {
var indexed = map(payload, (row, i) -> { line: i + 2, row: row })
var bad = filter(indexed, (e) -> trim(e.row.discount default "") != "" and trim(e.row.promo_code default "") == "")
var errors = map(bad, (e) -> {
message: "Discount is set but no promotion code was supplied",
field: "promo_code",
row: e.line,
expected: "a promotion code whenever discount has a value",
actual: "empty"
})
---
if (sizeOf(errors) == 0) ({ success: true }) else ({ success: false, errors: errors })
}
Rules That Span Files
Some rules can only be answered by looking at the whole delivery at once, because every file in the batch is individually valid and the problem exists only in the relationship between them. Reconciling a manifest against the detail files it accompanies is the common case: the manifest declares what the batch should add up to, and nothing but the batch can confirm that it does.
In Whole batch mode, payload is the array of matched files. Each entry includes the file's name and path alongside its own parsed payload, so a script can tell the files apart by role and compare them:
%files 1.0
---
do {
fun isManifest(file) = endsWith(lower(file.name), "manifest.csv")
fun numeric(v) = matches(toString(v default ""), "^-?[0-9]+(\\.[0-9]+)?$")
var manifests = filter(payload, (file) -> isManifest(file))
var details = filter(payload, (file) -> not isManifest(file))
var detailRows = flatMap(details, (file) -> file.payload map ((row) -> { file: file.name, row: row }))
var readable = filter(detailRows, (e) -> numeric(e.row.amount))
var declaredTotal = (manifests[0].payload)[0].total_amount
var countedTotal = sum(readable, (e) -> toNumber(e.row.amount))
var manifestErrors =
if (sizeOf(manifests) == 1 and numeric(declaredTotal)) []
else [{
message: "The batch total could not be read from a manifest",
field: "total_amount",
expected: "exactly one manifest file declaring a numeric total",
actual: "$(sizeOf(manifests)) manifest files matched"
}]
var amountErrors = map(
filter(detailRows, (e) -> not numeric(e.row.amount)),
(e) -> {
message: "$(e.file): amount is not a number, so the batch total cannot be reconciled",
field: "amount",
expected: "a numeric amount on every row",
actual: toString(e.row.amount default "empty")
}
)
var totalErrors =
if (numeric(declaredTotal) and toNumber(declaredTotal) == countedTotal) []
else [{
message: "Manifest total does not match the sum of the detail files",
field: "total_amount",
expected: toString(declaredTotal default "missing"),
actual: countedTotal
}]
var errors =
if (sizeOf(manifestErrors) > 0) manifestErrors
else (if (sizeOf(amountErrors) > 0) amountErrors else totalErrors)
---
if (sizeOf(errors) == 0) ({ success: true }) else ({ success: false, errors: errors })
}
The ordering at the end matters more than the arithmetic. Checking the manifest first, then the amounts, then the total means each rule only reports when the rule it depends on has held, so a batch with an unreadable amount reports that amount rather than a total that is wrong because a row was skipped.
Note that countedTotal sums only the rows that passed numeric, and every toNumber sits behind that check. toNumber raises rather than returning null for a non-numeric value, and a var cannot be assumed to go unevaluated just because the branch that reads it wasn't taken. Guard the coercion where it happens instead of relying on the ordering to protect it.
Naming the file in each amount error is what makes this actionable in a batch of twenty deliveries, since the whole_batch prefix won't identify it for you.
Combining Rules in One Script
Real criteria are usually several rules at once: the columns have to be right, and the dates have to parse, and the amounts have to be positive. Give each rule its own array of errors, then combine them and return once. Rules of different kinds mix freely, because each one contributes an array regardless of whether it examined the whole file or every row.
%files 1.0
---
do {
var expectedHeader = ["account_id", "amount", "effective_date"]
var headerErrors =
if (namesOf(payload[0]) == expectedHeader) []
else [{
message: "First row does not match the expected header row",
field: "header",
expected: joinBy(expectedHeader, " | "),
actual: joinBy(namesOf(payload[0]), " | ")
}]
var indexed = map(payload, (row, i) -> { line: i + 2, row: row })
var dateErrors = map(
filter(indexed, (e) -> not matches(e.row.effective_date, "^[0-9]{4}-[0-9]{2}-[0-9]{2}$")),
(e) -> {
message: "Effective date is not in YYYY-MM-DD form",
field: "effective_date",
row: e.line,
expected: "YYYY-MM-DD",
actual: e.row.effective_date
}
)
var errors =
if (sizeOf(headerErrors) > 0) headerErrors
else dateErrors
---
if (sizeOf(errors) == 0) ({ success: true }) else ({ success: false, errors: errors })
}
Independent rules combine with ++, as in errors = headerErrors ++ dateErrors. This example instead returns the header errors alone when there are any, because a rule can make the rules after it meaningless. If the columns aren't the ones you expected, then effective_date isn't a field in these rows, and checking it produces a misleading complaint about every row in the file rather than the one problem worth reporting. Stopping at the rule that failed also protects the 100-error limit from being spent on consequences rather than causes.
Decide for each rule whether it stands on its own or depends on an earlier one holding true. Rules over separate fields are usually independent. Anything that reads a field by name depends on the header check that confirms the field exists.