///|
/// Validate field declarations in addition to the explicit contract rules.
///
/// This opt-in entry point keeps `validate_rows` compatible with the original
/// rule-only API while making the field metadata executable for applications
/// that want a complete data-contract gate.
pub fn validate_rows_with_schema(
contract : Contract,
rows : Array[DataRow],
) -> ValidationReport {
let base = validate_rows(contract, rows)
let field_results = contract.fields.map(fn(field) {
validate_field(field, rows)
})
let mut schema_failure_count = 0
for result in field_results {
if !result.passed {
schema_failure_count += result.failure_rows.length()
}
}
let results = field_results
for result in base.rule_results {
results.push(result)
}
{
dataset_name: base.dataset_name,
row_count: base.row_count,
passed: base.passed && schema_failure_count == 0,
failure_count: base.failure_count + schema_failure_count,
warning_count: base.warning_count,
rule_results: results,
}
}
///|
fn validate_field(field : FieldSpec, rows : Array[DataRow]) -> RuleResult {
let failures = []
for index, row in rows {
if field_fails(field, row) {
failures.push(index)
}
}
{
name: "field(\{field.name})",
severity: Error,
passed: failures.is_empty(),
failure_rows: failures,
message: field_message(field),
}
}
///|
fn field_fails(field : FieldSpec, row : DataRow) -> Bool {
match row.get(field.name) {
None => field.required
Some(raw) => {
let value = raw.trim().to_owned()
if value == "" {
!field.nullable
} else {
!field_value_matches(field, value)
}
}
}
}
///|
fn field_value_matches(field : FieldSpec, value : String) -> Bool {
let kind_matches = match field.kind {
String => true
Int => parse_int_option(Some(value)) is Some(_)
Bool => value.to_lower() == "true" || value.to_lower() == "false"
Date => valid_date(value)
}
let allowed_matches = field.allowed_values.is_empty() ||
field.allowed_values.contains(value)
let min_matches = match field.min_int {
Some(min) =>
match parse_int_option(Some(value)) {
Some(number) => number >= min
None => false
}
None => true
}
let max_matches = match field.max_int {
Some(max) =>
match parse_int_option(Some(value)) {
Some(number) => number <= max
None => false
}
None => true
}
let pattern_matches = match field.pattern {
Some(pattern) => match_pattern(value, pattern)
None => true
}
kind_matches &&
allowed_matches &&
min_matches &&
max_matches &&
pattern_matches
}
///|
fn field_message(field : FieldSpec) -> String {
if field.description == "" {
"field does not match its declared schema"
} else {
field.description
}
}
///|
fn valid_date(value : String) -> Bool {
let parts = value.split("-").to_array()
if parts.length() != 3 {
false
} else {
let year_text = parts[0].to_owned()
let month_text = parts[1].to_owned()
let day_text = parts[2].to_owned()
if year_text.length() != 4 ||
month_text.length() != 2 ||
day_text.length() != 2 {
false
} else if !digits_only(year_text) ||
!digits_only(month_text) ||
!digits_only(day_text) {
false
} else {
match
(
parse_int_option(Some(year_text)),
parse_int_option(Some(month_text)),
parse_int_option(Some(day_text)),
) {
(Some(year), Some(month), Some(day)) =>
year > 0 &&
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= days_in_month(year, month)
_ => false
}
}
}
}
///|
fn digits_only(value : String) -> Bool {
if value == "" {
false
} else {
for ch in value {
if ch < '0' || ch > '9' {
return false
}
}
true
}
}
///|
fn days_in_month(year : Int, month : Int) -> Int {
match month {
2 => if is_leap_year(year) { 29 } else { 28 }
4 | 6 | 9 | 11 => 30
_ => 31
}
}
///|
fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}