Skip to content

Nested Data

Chris Michael edited this page Sep 29, 2026 · 7 revisions

Pudu

Nested Data

Records nest, and validators nest with them. Child.setValidator runs a validator for one nested record. Child.setOptionalValidator skips an absent Option value. Child.forEachValidator runs a validator per array element. Every failure keeps its full path, with collection indices in brackets:

module Ex04Nested

import Std.Io as Io
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Rules.Child as ChildRule
import PuduLangValidator.Rules.Text as TextRule
import PuduLangValidator.Validator as Validator

type Address = { street: Str, zip: Str }

type Order = { id: Str, address: Address, items: Array[OrderItem] }

type OrderItem = { sku: Str, quantity: Int }

fn addressValidator() -> Validator.Validator[Address] {
  let street = Rule.build(TextRule.notEmpty(Rule.ruleFor("street", |address: Address| address.street)))
  let zip = Rule.build(TextRule.length(Rule.ruleFor("zip", |address: Address| address.zip), 5, 5))
  Validator.add(Validator.add(Validator.create(), street), zip)
}

fn itemValidator() -> Validator.Validator[OrderItem] {
  let sku = Rule.build(TextRule.notEmpty(Rule.ruleFor("sku", |item: OrderItem| item.sku)))
  let quantity = Rule.build(Rule.must(Rule.ruleFor("quantity", |item: OrderItem| item.quantity),
    |quantity: Int| quantity > 0))
  Validator.add(Validator.add(Validator.create(), sku), quantity)
}

fn orderValidator() -> Validator.Validator[Order] {
  let id = Rule.build(TextRule.notEmpty(Rule.ruleFor("id", |order: Order| order.id)))
  let address = ChildRule.setValidator("address", |order: Order| order.address, addressValidator())
  let items = ChildRule.forEachValidator("items", |order: Order| order.items, itemValidator())
  Validator.add(Validator.add(Validator.add(Validator.create(), id), address), items)
}

export fn main() -> Int {
  let validator = orderValidator()
  let bad = Order{id: "o1", address: Address{street: "", zip: "12345"},
    items: [OrderItem{sku: "a", quantity: 0}, OrderItem{sku: "b", quantity: 2}]}
  let outcome = Validator.validate(&validator, bad)
  let first = if outcome.errors.isEmpty() { "none" } else { outcome.errors[0].propertyName }
  let line = "failures=" + show(outcome.errors.length()) + " first=" + first
  match Io.writeLine(line) {
    case Ok(_) => if outcome.errors.length() == 2 && first == "address.street" { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

failures=2 first=address.street

The empty street fails as address.street; the zero quantity fails as items[0].quantity. Validation can also run over a subset of properties: Validator.includeProperties(Validator.defaults(), ["items[].sku"]) selects sku from every order item while failures still name their original indices.

Case.forCase dispatches on one variant of an Option value with its own validator, which is how a record with alternative shapes stays typed.

Selecting what runs

Selection.covers asks whether a request reaches a concrete path or its descendants; Selection.overlaps asks whether a rule and a request meet at a path boundary; Selection.relative strips a child prefix back off. Validator.includeProperties runs only overlapping rules, Validator.onlyRuleSets runs exactly the named sets, and Validator.includeRulesNotInRuleSet adds the default rules back to a named selection:

module Ex12Selection

import Std.Io as Io
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Rules.Collection as CollectionRule
import PuduLangValidator.Rules.Text as TextRule
import PuduLangValidator.Selection as Selection
import PuduLangValidator.Validator as Validator

type Order = { id: Str, tags: Array[Str], note: Str }

fn orderValidator() -> Validator.Validator[Order] {
  let id = Rule.build(TextRule.notEmpty(Rule.ruleFor("id", |order: Order| order.id)))
  let tags = Rule.build(CollectionRule.countBetween(Rule.ruleFor("tags", |order: Order| order.tags), 1, 2))
  let note = Rule.build(Rule.inRuleSet(
    TextRule.notEmpty(Rule.ruleFor("note", |order: Order| order.note)), "strict"))
  Validator.add(Validator.add(Validator.add(Validator.create(), id), tags), note)
}

export fn main() -> Int {
  let validator = orderValidator()
  let bad = Order{id: "", tags: [], note: ""}
  let full = Validator.validate(&validator, bad)
  let subset = Validator.validateWith(&validator, bad,
    Validator.includeProperties(Validator.defaults(), ["tags"]))
  let strict = Validator.validateWith(&validator, bad, Validator.onlyRuleSets(["strict"]))
  let all = Validator.validateWith(&validator, bad,
    Validator.includeRulesNotInRuleSet(Validator.onlyRuleSets(["strict"])))
  let open: Array[Str] = []
  let line = "covers=" + show(Selection.covers("items[]", "items[0]"))
    + " overlaps=" + show(Selection.overlaps("items", "items[0]"))
    + " relative=" + show(Selection.relative("address.street", "address"))
    + " full=" + show(full.errors.length())
    + " subset=" + show(subset.errors[0].propertyName)
    + " strict=" + show(strict.errors[0].propertyName)
    + " all=" + show(all.errors.length())
    + " accepts=" + show(Validator.accepts("tags", &open, &Validator.defaults()))
  match Io.writeLine(line) {
    case Ok(_) => if Selection.covers("items[]", "items[0]")
      && Selection.overlaps("items", "items[0]")
      && Selection.relative("address.street", "address") == Some("street")
      && full.errors.length() == 2 && subset.errors.length() == 1
      && strict.errors.length() == 1 && all.errors.length() == 3
      && Validator.accepts("tags", &open, &Validator.defaults()) { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

covers=true overlaps=true relative=Some("street") full=2 subset="tags" strict="note" all=3 accepts=true

The default run skips the named note rule and reports the two unnamed failures. Selecting tags keeps only it; selecting the strict set keeps only the note; adding the default rules back runs all three. Validator.validateWithData threads the same options alongside immutable context data, as Rules And Validators shows.

Indexed collection paths

Collection.forEachWhere checks only the elements its filter keeps, and Collection.forEachWhereIndexed names each failure with a caller-supplied index key instead of the raw position:

module Ex22Collection

import Std.Io as Io
import PuduLangValidator.Result as Validation
import PuduLangValidator.Rules.Collection as Collection
import PuduLangValidator.Validator as Validator

type Basket = { lines: Array[Str] }

fn whereValidator() -> Validator.Validator[Basket] {
  Validator.add(Validator.create(), Collection.forEachWhere("lines",
    |basket: Basket| basket.lines, |line: Str| line != "skip",
    |line: Str| line.length() > 3, "Bad line."))
}

fn indexedValidator() -> Validator.Validator[Basket] {
  Validator.add(Validator.create(), Collection.forEachWhereIndexed("lines",
    |basket: Basket| basket.lines, |line: Str| line != "skip",
    |line: Str| line.length() > 3, "Bad line at \{CollectionIndex\}.",
    |index: Int, _line: Str| "at-" + show(index)))
}

export fn main() -> Int {
  let basket = Basket{lines: ["skip", "no", "skip", "yes!"]}
  let kept = Validator.validate(&whereValidator(), basket)
  let indexed = Validator.validate(&indexedValidator(), basket)
  let chained = Collection.stopOnFirst(Collection.must(
    Collection.ruleForEach("lines", |basket: Basket| basket.lines),
    |line: Str| !line.isEmpty()))
  let stopping = Collection.withMessage(Collection.must(chained, |line: Str| line.length() >= 3),
    "Line is short.")
  let stopped = Validator.validate(&Validator.add(Validator.create(),
    Collection.buildEach(stopping)), Basket{lines: ["", "ok"]})
  let tagged = Collection.inRuleSet(Collection.must(
    Collection.ruleForEach("lines", |basket: Basket| basket.lines),
    |line: Str| line.length() > 2), "long")
  let namedValidator = Validator.add(Validator.create(), Collection.buildEach(tagged))
  let stocked = Basket{lines: ["", "ok", "longer"]}
  let namedSkip = Validator.validate(&namedValidator, stocked)
  let namedRun = Validator.validateWith(&namedValidator, stocked, Validator.onlyRuleSets(["long"]))
  let line = "kept=" + show(kept.errors.map(|error: Validation.Failure| error.propertyName))
    + " indexed=" + show(indexed.errors.map(|error: Validation.Failure| error.propertyName))
    + " message=" + indexed.errors[0].errorMessage
    + " stopped=" + show(stopped.errors.length())
    + " namedSkip=" + show(namedSkip.errors.length())
    + " namedRun=" + show(namedRun.errors.length())
  match Io.writeLine(line) {
    case Ok(_) => if kept.errors.length() == 1 && kept.errors[0].propertyName == "lines[1]"
      && indexed.errors.length() == 1 && indexed.errors[0].propertyName == "lines[at-1]"
      && indexed.errors[0].errorMessage == "Bad line at 1."
      && stopped.errors.length() == 2
      && namedSkip.errors.length() == 0 && namedRun.errors.length() == 2 { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

kept=["lines[1]"] indexed=["lines[at-1]"] message=Bad line at 1. stopped=2 namedSkip=0 namedRun=2

"skip" never reaches the predicate in either validator. "no" fails at its source index 1; the indexed validator reports the same element as lines[at-1] while CollectionIndex still renders the numeric position in the message. The chained rule (ruleForEach, must, stopOnFirst, buildEach) stops each element at its first failure: the empty line reports once instead of twice, so the two lines yield two failures. Filing the chain under a set with inRuleSet keeps it out of the default run and selects it by name.

Related

Clone this wiki locally