///|
fn validation_error(
row : Int,
column : String,
code : String,
message : String,
) -> ValidationError {
{ row, column, code, message }
}
///|
fn kind_name(kind : FieldKind) -> String {
match kind {
Text => "text"
NonEmpty => "non_empty"
Integer => "integer"
Decimal => "decimal"
Boolean => "boolean"
}
}
///|
fn is_digit(c : Char) -> Bool {
c >= '0' && c <= '9'
}
///|
fn is_integer_text(text : String) -> Bool {
let s = text.trim().to_owned()
if s.is_empty() {
return false
}
let mut i = 0
if s.get_char(0) == Some('-') || s.get_char(0) == Some('+') {
if s.length() == 1 {
return false
}
i = 1
}
while i < s.length() {
match s.get_char(i) {
Some(c) => if !is_digit(c) { return false }
None => return false
}
i = i + 1
}
true
}
///|
fn is_decimal_text(text : String) -> Bool {
let s = text.trim().to_owned()
if s.is_empty() {
return false
}
let mut i = 0
let mut dots = 0
let mut digits = 0
if s.get_char(0) == Some('-') || s.get_char(0) == Some('+') {
if s.length() == 1 {
return false
}
i = 1
}
while i < s.length() {
match s.get_char(i) {
Some('.') => {
dots = dots + 1
if dots > 1 {
return false
}
}
Some(c) => if is_digit(c) { digits = digits + 1 } else { return false }
None => return false
}
i = i + 1
}
digits > 0
}
///|
fn is_boolean_text(text : String) -> Bool {
let s = text.trim().to_owned().to_lower()
s == "true" || s == "false" || s == "1" || s == "0" || s == "yes" || s == "no"
}
///|
fn matches_kind(text : String, kind : FieldKind) -> Bool {
match kind {
Text => true
NonEmpty => !text.trim().is_empty()
Integer => is_integer_text(text)
Decimal => is_decimal_text(text)
Boolean => is_boolean_text(text)
}
}
///|
fn has_rule(schema : Schema, name : String) -> Bool {
for rule in schema.columns {
if rule.name == name {
return true
}
}
false
}
///|
/// Validate a parsed table against a header-based schema.
pub fn validate(table : Table, schema : Schema) -> ValidationReport {
let errors : Array[ValidationError] = []
if table.header.length() == 0 {
errors.push(
validation_error(
0, "", "missing_header", "schema validation requires a header row",
),
)
return { errors, }
}
for rule in schema.columns {
match table.find_column(rule.name) {
Some(column) => {
let mut r = 0
while r < table.rows.length() {
let value = match table.rows[r].get(column) {
Some(v) => v
None => ""
}
if rule.required && value.trim().is_empty() {
let msg =
$|column '\{rule.name}' is required
errors.push(validation_error(r + 1, rule.name, "required", msg))
} else if !value.trim().is_empty() && !matches_kind(value, rule.kind) {
let msg =
$|column '\{rule.name}' expects \{kind_name(rule.kind)}
errors.push(validation_error(r + 1, rule.name, "type", msg))
}
r = r + 1
}
}
None =>
if rule.required {
let msg =
$|required column '\{rule.name}' is missing
errors.push(validation_error(0, rule.name, "missing_column", msg))
}
}
}
if !schema.allow_extra_columns {
for name in table.header {
if !has_rule(schema, name) {
let msg =
$|column '\{name}' is not declared in schema
errors.push(validation_error(0, name, "extra_column", msg))
}
}
}
{ errors, }
}
///|
/// Whether a validation report has no errors.
pub fn ValidationReport::is_valid(self : ValidationReport) -> Bool {
self.errors.is_empty()
}
///|
/// Return a compact summary for logs and examples.
pub fn ValidationReport::summary(self : ValidationReport) -> String {
if self.is_valid() {
"valid"
} else {
let text =
$|\{self.errors.length()} validation error(s)
text
}
}