Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-validator

Typed fluent validation rules for Pudu

0.1.1Apache-2.01

InstallClose

Rule.pudu

Pudu314 lines11.8 KB

GitHub ↗
1/** @Validation.Rule.Engine — evaluates typed property checks */2module PuduLangValidator.Rule34import Std.Json as Json5import Std.List as List6import Std.Text as Text7import PuduLangValidator.Result as Validation8import PuduLangValidator.Selection as Selection910/** @Validation.Rule.ContextData — shares immutable application data */11export type ContextData = Map[Str, Json.Json]1213/** @Validation.Rule.Check — binds predicate and failure metadata */14export type Check[T, V] = {15  test: fn(T, V, ContextData) -> Option[Array[(Str, Str)]],16  condition: fn(T) -> Bool,17  message: Str,18  messageFactory: Option[fn(T, V) -> Str],19  arguments: Array[(Str, fn(T, V) -> Str)],20  code: Str,21  severity: Validation.Severity,22  severityFactory: Option[fn(T, V) -> Validation.Severity],23  state: Str,24  stateFactory: Option[fn(T, V) -> Str]25}2627/** @Validation.Rule.RuleBuilder — keeps property checks typed until build */28export type RuleBuilder[T, V] = {29  name: Str,30  displayName: Str,31  select: fn(T) -> V,32  checks: Array[Check[T, V]],33  ruleSets: Array[Str],34  stop: Bool35}3637/** @Validation.Rule.Rule — runs a property rule over its source */38export type Rule[T] = { name: Str, ruleSets: Array[Str], run: fn(T, Array[Str], Bool, Array[Str], ContextData) -> Array[Validation.Failure] }3940/// Start a rule with its public property path and typed selector.41export fn ruleFor[T, V](name: Str, select: fn(T) -> V) -> RuleBuilder[T, V] {42  RuleBuilder{name: name, displayName: name, select: select, checks: [], ruleSets: [], stop: false}43}4445/// Require a property predicate to hold.46export fn must[T, V](builder: RuleBuilder[T, V], test: fn(V) -> Bool) -> RuleBuilder[T, V] {47  mustWith(builder, fn(_root: T, value: V) -> Bool { test(value) })48}4950/// Require a predicate that may inspect the source and property.51export fn mustWith[T, V](builder: RuleBuilder[T, V], test: fn(T, V) -> Bool) -> RuleBuilder[T, V] {52  mustWithContext(builder, fn(root: T, value: V, _data: ContextData) -> Bool { test(root, value) })53}5455/// Require a predicate that may read immutable root context data.56export fn mustWithContext[T, V](57  builder: RuleBuilder[T, V], test: fn(T, V, ContextData) -> Bool58) -> RuleBuilder[T, V] {59  addCheck(builder, fn(root: T, value: V, data: ContextData) -> Option[Array[(Str, Str)]] {60      if test(root, value, data) { None } else { Some([]) }61    })62}6364/// Capture message arguments while deciding whether a check fails.65export fn mustWithArguments[T, V](66  builder: RuleBuilder[T, V], test: fn(T, V) -> Option[Array[(Str, Str)]]67) -> RuleBuilder[T, V] {68  addCheck(builder, fn(root: T, value: V, _data: ContextData) -> Option[Array[(Str, Str)]] {69      test(root, value)70    })71}7273fn addCheck[T, V](74  builder: RuleBuilder[T, V], test: fn(T, V, ContextData) -> Option[Array[(Str, Str)]]75) -> RuleBuilder[T, V] {76  let check = Check {77    test: test,78    condition: fn(_root: T) -> Bool { true },79    message: "The value of \{PropertyName\} did not satisfy its rule.",80    messageFactory: None,81    arguments: [],82    code: "Predicate",83    severity: Validation.Error,84    severityFactory: None,85    state: "",86    stateFactory: None87  }88  RuleBuilder{..builder, checks: builder.checks.push(check)}89}9091/// Replace the most recent check without changing its position.92fn replaceLast[T, V](builder: RuleBuilder[T, V], replacement: Check[T, V]) -> RuleBuilder[T, V] {93  var checks: Array[Check[T, V]] = []94  var index = 095  let last = builder.checks.length() - 196  for check in builder.checks {97    checks = checks.push(if index == last { replacement } else { check })98    index = index + 199  }100  RuleBuilder{..builder, checks: checks}101}102103/// Use this message for the most recent failed check.104export fn withMessage[T, V](builder: RuleBuilder[T, V], message: Str) -> RuleBuilder[T, V] {105  match List.last(&builder.checks) {106    case Some(check) => replaceLast(builder, Check{..check, message: message, messageFactory: None})107    case None => builder108  }109}110111/// Compute the latest failure message from its source and property.112export fn withMessageFrom[T, V](builder: RuleBuilder[T, V], message: fn(T, V) -> Str) -> RuleBuilder[T, V] {113  match List.last(&builder.checks) {114    case Some(check) => replaceLast(builder, Check{..check, messageFactory: Some(message)})115    case None => builder116  }117}118119/// Set a named message value on the latest check.120export fn withArgument[T, V](builder: RuleBuilder[T, V], name: Str, value: Str) -> RuleBuilder[T, V] {121  withArgumentFrom(builder, name, fn(_root: T, _property: V) -> Str { value })122}123124/// Compute a named message value only when the latest check fails.125export fn withArgumentFrom[T, V](126  builder: RuleBuilder[T, V], name: Str, value: fn(T, V) -> Str127) -> RuleBuilder[T, V] {128  if name.isEmpty() || name == "PropertyName" || name == "PropertyPath" || name == "PropertyValue" { return builder }129  match List.last(&builder.checks) {130    case Some(check) => {131      var arguments: Array[(Str, fn(T, V) -> Str)] = []132      for entry in check.arguments {133        if entry[0] != name { arguments = arguments.push(entry) }134      }135      replaceLast(builder, Check{..check, arguments: arguments.push((name, value))})136    }137    case None => builder138  }139}140141/// Use a readable name in message placeholders without changing the failure path.142export fn withName[T, V](builder: RuleBuilder[T, V], name: Str) -> RuleBuilder[T, V] {143  RuleBuilder{..builder, displayName: name}144}145146/// Report failures at this path instead of the rule's original path.147export fn overridePropertyName[T, V](builder: RuleBuilder[T, V], path: Str) -> RuleBuilder[T, V] {148  RuleBuilder{..builder, name: path}149}150151/// Use this stable error code for the most recent check.152export fn withCode[T, V](builder: RuleBuilder[T, V], code: Str) -> RuleBuilder[T, V] {153  match List.last(&builder.checks) {154    case Some(check) => replaceLast(builder, Check{..check, code: code})155    case None => builder156  }157}158159/// Classify the most recent failure.160export fn withSeverity[T, V](builder: RuleBuilder[T, V], severity: Validation.Severity) -> RuleBuilder[T, V] {161  match List.last(&builder.checks) {162    case Some(check) => replaceLast(builder, Check{..check, severity: severity, severityFactory: None})163    case None => builder164  }165}166167/// Compute the latest check's severity from its source and property.168export fn withSeverityFrom[T, V](169  builder: RuleBuilder[T, V], severity: fn(T, V) -> Validation.Severity170) -> RuleBuilder[T, V] {171  match List.last(&builder.checks) {172    case Some(check) => replaceLast(builder, Check{..check, severityFactory: Some(severity)})173    case None => builder174  }175}176177/// Attach application state to the most recent failure.178export fn withState[T, V](builder: RuleBuilder[T, V], state: Str) -> RuleBuilder[T, V] {179  match List.last(&builder.checks) {180    case Some(check) => replaceLast(builder, Check{..check, state: state, stateFactory: None})181    case None => builder182  }183}184185/// Compute the latest check's state from its source and property.186export fn withStateFrom[T, V](builder: RuleBuilder[T, V], state: fn(T, V) -> Str) -> RuleBuilder[T, V] {187  match List.last(&builder.checks) {188    case Some(check) => replaceLast(builder, Check{..check, stateFactory: Some(state)})189    case None => builder190  }191}192193/// Run every check in this chain only when the condition holds.194export fn when[T, V](builder: RuleBuilder[T, V], condition: fn(T) -> Bool) -> RuleBuilder[T, V] {195  RuleBuilder{..builder, checks: builder.checks.map(|check: Check[T, V]| Check {196        ..check, condition: fn(root: T) -> Bool { check.condition(root) && condition(root) }197      }) }198}199200/// Skip every check in this chain when the condition holds.201export fn unless[T, V](builder: RuleBuilder[T, V], condition: fn(T) -> Bool) -> RuleBuilder[T, V] {202  when(builder, fn(root: T) -> Bool { !condition(root) })203}204205/// Gate only the most recently added check.206export fn whenCurrent[T, V](builder: RuleBuilder[T, V], condition: fn(T) -> Bool) -> RuleBuilder[T, V] {207  match List.last(&builder.checks) {208    case Some(check) => replaceLast(builder, Check {209        ..check, condition: fn(root: T) -> Bool { check.condition(root) && condition(root) }210      })211    case None => builder212  }213}214215/// Skip only the most recent check when the condition holds.216export fn unlessCurrent[T, V](builder: RuleBuilder[T, V], condition: fn(T) -> Bool) -> RuleBuilder[T, V] {217  whenCurrent(builder, fn(root: T) -> Bool { !condition(root) })218}219220/// Stop this property's chain after its first failure.221export fn stopOnFirst[T, V](builder: RuleBuilder[T, V]) -> RuleBuilder[T, V] {222  RuleBuilder{..builder, stop: true}223}224225/// Run this rule when the named rule set is selected.226export fn inRuleSet[T, V](builder: RuleBuilder[T, V], name: Str) -> RuleBuilder[T, V] {227  if builder.ruleSets.contains(name) { builder } else { RuleBuilder{..builder, ruleSets: builder.ruleSets.push(name)} }228}229230/// Freeze a property rule for use in a validator.231export fn build[T, V](builder: RuleBuilder[T, V]) -> Rule[T] {232  let name = builder.name233  let displayName = builder.displayName234  let sets = builder.ruleSets235  Rule{name: name, ruleSets: sets, run: fn(root: T, _sets: Array[Str], _defaults: Bool, properties: Array[Str], data: ContextData) -> Array[Validation.Failure] {236      if !properties.isEmpty() && !List.any(&properties, |wanted: Str| Selection.covers(wanted, name)) { return [] }237      let value = builder.select(root)238      var failures: Array[Validation.Failure] = []239      for check in builder.checks {240        let outcome = if check.condition(root) { check.test(root, value, data) } else { None }241        if let Some(captured) = outcome {242          let template = match check.messageFactory {243            case Some(factory) => factory(root, value)244            case None => check.message245          }246          let message = render(template, displayName, name, root, value, &check.arguments, &captured)247          let severity = match check.severityFactory {248            case Some(factory) => factory(root, value)249            case None => check.severity250          }251          let state = match check.stateFactory {252            case Some(factory) => factory(root, value)253            case None => check.state254          }255          failures = failures.push(Validation.Failure {256              propertyName: name, errorMessage: message, errorCode: check.code,257              severity: severity, customState: state258            })259          if builder.stop { break }260        }261      }262      failures263    } }264}265266/// Render only placeholders present in the chosen template.267fn render[T, V](268  template: Str, displayName: Str, path: Str, root: T, value: V,269  arguments: &Array[(Str, fn(T, V) -> Str)], captured: &Array[(Str, Str)]270) -> Str {271  let chars = template.chars()272  var pieces: Array[Str] = []273  var literalStart = 0274  var index = 0275  while index < chars.length() {276    if chars[index] != '{' {277      index = index + 1278      continue279    }280    var close = index + 1281    while close < chars.length() && chars[close] != '}' { close = close + 1 }282    if close == chars.length() { break }283    let key = Text.take(Text.drop(template, index + 1), close - index - 1)284    match argument(key, displayName, path, root, value, arguments, captured) {285      case Some(replacement) => {286        pieces = pieces.push(Text.take(Text.drop(template, literalStart), index - literalStart))287        pieces = pieces.push(replacement)288        index = close + 1289        literalStart = index290      }291      case None => { index = close + 1 }292    }293  }294  pieces = pieces.push(Text.drop(template, literalStart))295  Text.join(&pieces, "")296}297298/// Resolve one placeholder without inspecting replacement text.299fn argument[T, V](300  name: Str, displayName: Str, path: Str, root: T, value: V,301  arguments: &Array[(Str, fn(T, V) -> Str)], captured: &Array[(Str, Str)]302) -> Option[Str] {303  if name == "PropertyName" { return Some(displayName) }304  if name == "PropertyPath" { return Some(path) }305  if name == "PropertyValue" { return Some(value.toText()) }306  for entry in *arguments {307    if entry[0] == name { return Some(entry[1](root, value)) }308  }309  for entry in *captured {310    if entry[0] == name { return Some(entry[1]) }311  }312  None313}314