Skip to content

Commit 6de8002

Browse files
authored
Merge pull request #284 from nibsbin/feat/card-field-coersion
feat/card field coersion
2 parents 19e08bb + 89355f2 commit 6de8002

File tree

1 file changed

+285
-37
lines changed

1 file changed

+285
-37
lines changed

crates/core/src/schema.rs

Lines changed: 285 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -436,10 +436,25 @@ pub fn validate_document(
436436
};
437437

438438
// Validate the document and collect errors immediately
439+
let mut all_errors = Vec::new();
440+
441+
// 1. Recursive card validation
442+
if let Some(cards) = doc_value.get("CARDS").and_then(|v| v.as_array()) {
443+
let card_errors = validate_cards_array(schema, cards);
444+
all_errors.extend(card_errors);
445+
}
446+
447+
// 2. Standard validation
439448
let validation_result = compiled.validate(&doc_value);
440449

441450
match validation_result {
442-
Ok(_) => Ok(()),
451+
Ok(_) => {
452+
if all_errors.is_empty() {
453+
Ok(())
454+
} else {
455+
Err(all_errors)
456+
}
457+
}
443458
Err(error) => {
444459
let path = error.instance_path().to_string();
445460
let path_display = if path.is_empty() {
@@ -448,57 +463,137 @@ pub fn validate_document(
448463
path.clone()
449464
};
450465

451-
// Check for potential invalid card type error
452-
if path.starts_with("/CARDS/") && error.to_string().contains("oneOf") {
453-
// Try to parse the index from path /CARDS/n
454-
if let Some(rest) = path.strip_prefix("/CARDS/") {
455-
// path might be just "/CARDS/0" or "/CARDS/0/some/field"
456-
// We only want to intervene if the error is about the card item itself failing oneOf
457-
let is_item_error = !rest.contains('/');
458-
459-
if is_item_error {
460-
if let Ok(idx) = rest.parse::<usize>() {
461-
if let Some(cards) = doc_value.get("CARDS").and_then(|v| v.as_array()) {
462-
if let Some(item) = cards.get(idx) {
463-
// Check if the item has a CARD field
464-
if let Some(card_type) =
465-
item.get("CARD").and_then(|v| v.as_str())
466-
{
467-
// Collect valid card types from schema definitions
468-
let mut valid_types = Vec::new();
469-
if let Some(defs) = schema
470-
.as_json()
471-
.get("$defs")
472-
.and_then(|v| v.as_object())
466+
// If we have specific card errors, we might want to skip generic CARDS errors
467+
// from the main schema validation to avoid noise.
468+
// But for now, we'll include everything unless it's a "oneOf" error on a card we already diagnosed.
469+
let is_generic_card_error = path.starts_with("/CARDS/")
470+
&& error.to_string().contains("oneOf")
471+
&& !all_errors.is_empty();
472+
473+
if !is_generic_card_error {
474+
// Check for potential invalid card type error (legacy check, but still useful)
475+
if path.starts_with("/CARDS/") && error.to_string().contains("oneOf") {
476+
// Try to parse the index from path /CARDS/n
477+
if let Some(rest) = path.strip_prefix("/CARDS/") {
478+
// path might be just "/CARDS/0" or "/CARDS/0/some/field"
479+
// We only want to intervene if the error is about the card item itself failing oneOf
480+
let is_item_error = !rest.contains('/');
481+
482+
if is_item_error {
483+
if let Ok(idx) = rest.parse::<usize>() {
484+
if let Some(cards) =
485+
doc_value.get("CARDS").and_then(|v| v.as_array())
486+
{
487+
if let Some(item) = cards.get(idx) {
488+
// Check if the item has a CARD field
489+
if let Some(card_type) =
490+
item.get("CARD").and_then(|v| v.as_str())
473491
{
474-
for key in defs.keys() {
475-
if let Some(name) = key.strip_suffix("_card") {
476-
valid_types.push(name.to_string());
492+
// Collect valid card types from schema definitions
493+
let mut valid_types = Vec::new();
494+
if let Some(defs) = schema
495+
.as_json()
496+
.get("$defs")
497+
.and_then(|v| v.as_object())
498+
{
499+
for key in defs.keys() {
500+
if let Some(name) = key.strip_suffix("_card") {
501+
valid_types.push(name.to_string());
502+
}
477503
}
478504
}
479-
}
480505

481-
// If we found valid types and the current type is NOT in the list
482-
if !valid_types.is_empty()
483-
&& !valid_types.contains(&card_type.to_string())
484-
{
485-
valid_types.sort();
486-
let valid_list = valid_types.join(", ");
487-
let message = format!("Validation error at {}: Invalid card type '{}'. Valid types are: [{}]", path_display, card_type, valid_list);
488-
return Err(vec![message]);
506+
// If we found valid types and the current type is NOT in the list
507+
if !valid_types.is_empty()
508+
&& !valid_types.contains(&card_type.to_string())
509+
{
510+
valid_types.sort();
511+
let valid_list = valid_types.join(", ");
512+
let message = format!("Validation error at {}: Invalid card type '{}'. Valid types are: [{}]", path_display, card_type, valid_list);
513+
all_errors.push(message);
514+
return Err(all_errors);
515+
}
489516
}
490517
}
491518
}
492519
}
493520
}
494521
}
495522
}
523+
524+
let message = format!("Validation error at {}: {}", path_display, error);
525+
all_errors.push(message);
496526
}
497527

498-
let message = format!("Validation error at {}: {}", path_display, error);
499-
Err(vec![message])
528+
Err(all_errors)
529+
}
530+
}
531+
}
532+
533+
/// Helper to recursively validate an array of card objects
534+
fn validate_cards_array(document_schema: &QuillValue, cards_array: &[Value]) -> Vec<String> {
535+
let mut errors = Vec::new();
536+
537+
// Get definitions for card schemas
538+
let defs = document_schema
539+
.as_json()
540+
.get("$defs")
541+
.and_then(|v| v.as_object());
542+
543+
for (idx, card) in cards_array.iter().enumerate() {
544+
// We only process objects that have a CARD discriminator
545+
if let Some(card_obj) = card.as_object() {
546+
if let Some(card_type) = card_obj.get("CARD").and_then(|v| v.as_str()) {
547+
// Construct the definition name: {type}_card
548+
let def_name = format!("{}_card", card_type);
549+
550+
// Look up the schema for this card type
551+
if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
552+
// Convert the card object to HashMap<String, QuillValue> for recursion
553+
let mut card_fields = HashMap::new();
554+
for (k, v) in card_obj {
555+
card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
556+
}
557+
558+
// Recursively validate this card's fields
559+
if let Err(card_errors) = validate_document(
560+
&QuillValue::from_json(card_schema_json.clone()),
561+
&card_fields,
562+
) {
563+
// Prefix errors with location
564+
for err in card_errors {
565+
// If the error already starts with "Validation error at ", insert the prefix
566+
// otherwise just prefix it.
567+
// Typical error: "Validation error at field: message"
568+
// We want: "Validation error at /CARDS/0/field: message"
569+
570+
let prefix = format!("/CARDS/{}", idx);
571+
let new_msg =
572+
if let Some(rest) = err.strip_prefix("Validation error at ") {
573+
if rest.starts_with("document") {
574+
// "Validation error at document: message" -> "Validation error at /CARDS/0: message"
575+
format!(
576+
"Validation error at {}:{}",
577+
prefix,
578+
rest.strip_prefix("document").unwrap_or(rest)
579+
)
580+
} else {
581+
// "Validation error at /field: message" -> "Validation error at /CARDS/0/field: message"
582+
format!("Validation error at {}{}", prefix, rest)
583+
}
584+
} else {
585+
format!("Validation error at {}: {}", prefix, err)
586+
};
587+
588+
errors.push(new_msg);
589+
}
590+
}
591+
}
592+
}
500593
}
501594
}
595+
596+
errors
502597
}
503598

504599
/// Coerce a single value to match the expected schema type
@@ -634,9 +729,70 @@ pub fn coerce_document(
634729
coerced_fields.insert(field_name.clone(), field_value.clone());
635730
}
636731

732+
// Recursively coerce cards if the CARDS field is present
733+
if let Some(cards_value) = coerced_fields.get("CARDS") {
734+
if let Some(cards_array) = cards_value.as_array() {
735+
let coerced_cards = coerce_cards_array(schema, cards_array);
736+
coerced_fields.insert(
737+
"CARDS".to_string(),
738+
QuillValue::from_json(Value::Array(coerced_cards)),
739+
);
740+
}
741+
}
742+
637743
coerced_fields
638744
}
639745

746+
/// Helper to recursively coerce an array of card objects
747+
fn coerce_cards_array(document_schema: &QuillValue, cards_array: &[Value]) -> Vec<Value> {
748+
let mut coerced_cards = Vec::new();
749+
750+
// Get definitions for card schemas
751+
let defs = document_schema
752+
.as_json()
753+
.get("$defs")
754+
.and_then(|v| v.as_object());
755+
756+
for card in cards_array {
757+
// We only process objects that have a CARD discriminator
758+
if let Some(card_obj) = card.as_object() {
759+
if let Some(card_type) = card_obj.get("CARD").and_then(|v| v.as_str()) {
760+
// Construct the definition name: {type}_card
761+
let def_name = format!("{}_card", card_type);
762+
763+
// Look up the schema for this card type
764+
if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
765+
// Convert the card object to HashMap<String, QuillValue> for coerce_document
766+
let mut card_fields = HashMap::new();
767+
for (k, v) in card_obj {
768+
card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
769+
}
770+
771+
// Recursively coerce this card's fields
772+
let coerced_card_fields = coerce_document(
773+
&QuillValue::from_json(card_schema_json.clone()),
774+
&card_fields,
775+
);
776+
777+
// Convert back to JSON Value
778+
let mut coerced_card_obj = Map::new();
779+
for (k, v) in coerced_card_fields {
780+
coerced_card_obj.insert(k, v.into_json());
781+
}
782+
783+
coerced_cards.push(Value::Object(coerced_card_obj));
784+
continue;
785+
}
786+
}
787+
}
788+
789+
// If not an object, no CARD type, or no matching schema, keep as-is
790+
coerced_cards.push(card.clone());
791+
}
792+
793+
coerced_cards
794+
}
795+
640796
#[cfg(test)]
641797
mod tests {
642798
use super::*;
@@ -1643,4 +1799,96 @@ mod tests {
16431799
assert!(err_msg.contains("Invalid card type 'invalid_type'"));
16441800
assert!(err_msg.contains("Valid types are: [valid_card]"));
16451801
}
1802+
1803+
#[test]
1804+
fn test_coerce_document_cards() {
1805+
let mut card_fields = HashMap::new();
1806+
let mut count_schema = FieldSchema::new("Count".to_string(), "A number".to_string());
1807+
count_schema.r#type = Some("number".to_string());
1808+
card_fields.insert("count".to_string(), count_schema);
1809+
1810+
let mut active_schema = FieldSchema::new("Active".to_string(), "A boolean".to_string());
1811+
active_schema.r#type = Some("boolean".to_string());
1812+
card_fields.insert("active".to_string(), active_schema);
1813+
1814+
let mut card_schemas = HashMap::new();
1815+
card_schemas.insert(
1816+
"test_card".to_string(),
1817+
CardSchema {
1818+
name: "test_card".to_string(),
1819+
title: None,
1820+
ui: None,
1821+
description: "Test card".to_string(),
1822+
fields: card_fields,
1823+
},
1824+
);
1825+
1826+
let schema = build_schema(&HashMap::new(), &card_schemas).unwrap();
1827+
1828+
let mut fields = HashMap::new();
1829+
let card_value = json!({
1830+
"CARD": "test_card",
1831+
"count": "42",
1832+
"active": "true"
1833+
});
1834+
fields.insert(
1835+
"CARDS".to_string(),
1836+
QuillValue::from_json(json!([card_value])),
1837+
);
1838+
1839+
let coerced_fields = coerce_document(&schema, &fields);
1840+
1841+
let cards_array = coerced_fields.get("CARDS").unwrap().as_array().unwrap();
1842+
let coerced_card = cards_array[0].as_object().unwrap();
1843+
1844+
assert_eq!(coerced_card.get("count").unwrap().as_i64(), Some(42));
1845+
assert_eq!(coerced_card.get("active").unwrap().as_bool(), Some(true));
1846+
}
1847+
1848+
#[test]
1849+
fn test_validate_document_card_fields() {
1850+
let mut card_fields = HashMap::new();
1851+
let mut count_schema = FieldSchema::new("Count".to_string(), "A number".to_string());
1852+
count_schema.r#type = Some("number".to_string());
1853+
card_fields.insert("count".to_string(), count_schema);
1854+
1855+
let mut card_schemas = HashMap::new();
1856+
card_schemas.insert(
1857+
"test_card".to_string(),
1858+
CardSchema {
1859+
name: "test_card".to_string(),
1860+
title: None,
1861+
ui: None,
1862+
description: "Test card".to_string(),
1863+
fields: card_fields,
1864+
},
1865+
);
1866+
1867+
let schema = build_schema(&HashMap::new(), &card_schemas).unwrap();
1868+
1869+
let mut fields = HashMap::new();
1870+
let card_value = json!({
1871+
"CARD": "test_card",
1872+
"count": "not a number" // Invalid type
1873+
});
1874+
fields.insert(
1875+
"CARDS".to_string(),
1876+
QuillValue::from_json(json!([card_value])),
1877+
);
1878+
1879+
let result = validate_document(&QuillValue::from_json(schema.as_json().clone()), &fields);
1880+
assert!(result.is_err());
1881+
let errs = result.unwrap_err();
1882+
1883+
// We expect a specific error from recursive validation
1884+
let found_specific_error = errs
1885+
.iter()
1886+
.any(|e| e.contains("/CARDS/0") && e.contains("not a number") && !e.contains("oneOf"));
1887+
1888+
assert!(
1889+
found_specific_error,
1890+
"Did not find specific error msg in: {:?}",
1891+
errs
1892+
);
1893+
}
16461894
}

0 commit comments

Comments
 (0)