massively improves the jspg validator by removing mathmatical functions like allOf, anyOf, ref, etc to effectively use discriminators and OOP with types to determine valid pathing an nno intersections, unions, or guesswork; added cases to replace the former conditionals
This commit is contained in:
@ -242,12 +242,14 @@ impl Database {
|
||||
if !visited.insert(current_id.clone()) {
|
||||
break; // Cycle detected
|
||||
}
|
||||
if let Some(ref_str) = &schema.obj.r#ref {
|
||||
current_id = ref_str.clone();
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
current_id = t.clone();
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
depths.insert(id, depth);
|
||||
}
|
||||
@ -257,11 +259,13 @@ impl Database {
|
||||
fn collect_descendants(&mut self) {
|
||||
let mut direct_refs: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (id, schema) in &self.schemas {
|
||||
if let Some(ref_str) = &schema.obj.r#ref {
|
||||
direct_refs
|
||||
.entry(ref_str.clone())
|
||||
.or_default()
|
||||
.push(id.clone());
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
direct_refs
|
||||
.entry(t.clone())
|
||||
.or_default()
|
||||
.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,15 +33,30 @@ where
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
pub fn is_primitive_type(t: &str) -> bool {
|
||||
matches!(
|
||||
t,
|
||||
"string" | "number" | "integer" | "boolean" | "object" | "array" | "null"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Case {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<Arc<Schema>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub then: Option<Arc<Schema>>,
|
||||
#[serde(rename = "else")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub else_: Option<Arc<Schema>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SchemaObject {
|
||||
// Core Schema Keywords
|
||||
#[serde(rename = "$id")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "$ref")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -150,24 +165,14 @@ pub struct SchemaObject {
|
||||
pub exclusive_maximum: Option<f64>,
|
||||
|
||||
// Combining Keywords
|
||||
#[serde(rename = "allOf")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub all_of: Option<Vec<Arc<Schema>>>,
|
||||
pub cases: Option<Vec<Case>>,
|
||||
#[serde(rename = "oneOf")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub one_of: Option<Vec<Arc<Schema>>>,
|
||||
#[serde(rename = "not")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub not: Option<Arc<Schema>>,
|
||||
#[serde(rename = "if")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub if_: Option<Arc<Schema>>,
|
||||
#[serde(rename = "then")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub then_: Option<Arc<Schema>>,
|
||||
#[serde(rename = "else")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub else_: Option<Arc<Schema>>,
|
||||
|
||||
// Custom Vocabularies
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -300,35 +305,45 @@ impl Schema {
|
||||
let mut props = std::collections::BTreeMap::new();
|
||||
|
||||
// 1. Resolve INHERITANCE dependencies first
|
||||
if let Some(ref_id) = &self.obj.r#ref {
|
||||
if let Some(parent) = db.schemas.get(ref_id) {
|
||||
parent.compile(db, visited, errors);
|
||||
if let Some(p_props) = parent.obj.compiled_properties.get() {
|
||||
props.extend(p_props.clone());
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
if let Some(parent) = db.schemas.get(t) {
|
||||
parent.compile(db, visited, errors);
|
||||
if let Some(p_props) = parent.obj.compiled_properties.get() {
|
||||
props.extend(p_props.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(all_of) = &self.obj.all_of {
|
||||
for ao in all_of {
|
||||
ao.compile(db, visited, errors);
|
||||
if let Some(ao_props) = ao.obj.compiled_properties.get() {
|
||||
props.extend(ao_props.clone());
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Multiple(types)) = &self.obj.type_ {
|
||||
let mut custom_type_count = 0;
|
||||
for t in types {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
custom_type_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(then_schema) = &self.obj.then_ {
|
||||
then_schema.compile(db, visited, errors);
|
||||
if let Some(t_props) = then_schema.obj.compiled_properties.get() {
|
||||
props.extend(t_props.clone());
|
||||
if custom_type_count > 1 {
|
||||
errors.push(crate::drop::Error {
|
||||
code: "MULTIPLE_INHERITANCE_PROHIBITED".to_string(),
|
||||
message: format!(
|
||||
"Schema '{}' attempts to extend multiple custom object pointers in its type array. Use 'oneOf' for polymorphism and tagged unions.",
|
||||
self.obj.identifier().unwrap_or("unknown".to_string())
|
||||
),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: self.obj.identifier().unwrap_or("unknown".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(else_schema) = &self.obj.else_ {
|
||||
else_schema.compile(db, visited, errors);
|
||||
if let Some(e_props) = else_schema.obj.compiled_properties.get() {
|
||||
props.extend(e_props.clone());
|
||||
for t in types {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
if let Some(parent) = db.schemas.get(t) {
|
||||
parent.compile(db, visited, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -382,11 +397,18 @@ impl Schema {
|
||||
if let Some(child) = &self.obj.contains {
|
||||
child.compile(db, visited, errors);
|
||||
}
|
||||
if let Some(child) = &self.obj.property_names {
|
||||
child.compile(db, visited, errors);
|
||||
}
|
||||
if let Some(child) = &self.obj.if_ {
|
||||
child.compile(db, visited, errors);
|
||||
if let Some(cases) = &self.obj.cases {
|
||||
for c in cases {
|
||||
if let Some(child) = &c.when {
|
||||
child.compile(db, visited, errors);
|
||||
}
|
||||
if let Some(child) = &c.then {
|
||||
child.compile(db, visited, errors);
|
||||
}
|
||||
if let Some(child) = &c.else_ {
|
||||
child.compile(db, visited, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = &self.obj.id {
|
||||
@ -422,8 +444,10 @@ impl Schema {
|
||||
Self::validate_identifier(id, "$id", errors);
|
||||
to_insert.push((id.clone(), self.clone()));
|
||||
}
|
||||
if let Some(r#ref) = &self.obj.r#ref {
|
||||
Self::validate_identifier(r#ref, "$ref", errors);
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
Self::validate_identifier(t, "type", errors);
|
||||
}
|
||||
}
|
||||
if let Some(family) = &self.obj.family {
|
||||
Self::validate_identifier(family, "$family", errors);
|
||||
@ -431,9 +455,13 @@ impl Schema {
|
||||
|
||||
// Is this schema an inline ad-hoc composition?
|
||||
// Meaning it has a tracking context, lacks an explicit $id, but extends an Entity ref with explicit properties!
|
||||
if self.obj.id.is_none() && self.obj.r#ref.is_some() && self.obj.properties.is_some() {
|
||||
if let Some(ref path) = tracking_path {
|
||||
to_insert.push((path.clone(), self.clone()));
|
||||
if self.obj.id.is_none() && self.obj.properties.is_some() {
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
if let Some(ref path) = tracking_path {
|
||||
to_insert.push((path.clone(), self.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -478,9 +506,7 @@ impl Schema {
|
||||
if let Some(arr) = &mut self.obj.prefix_items {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.all_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
|
||||
if let Some(arr) = &mut self.obj.one_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
@ -503,9 +529,25 @@ impl Schema {
|
||||
map_opt(&mut self.obj.not, false);
|
||||
map_opt(&mut self.obj.contains, false);
|
||||
map_opt(&mut self.obj.property_names, false);
|
||||
map_opt(&mut self.obj.if_, false);
|
||||
map_opt(&mut self.obj.then_, false);
|
||||
map_opt(&mut self.obj.else_, false);
|
||||
if let Some(cases) = &mut self.obj.cases {
|
||||
for c in cases.iter_mut() {
|
||||
if let Some(when) = &mut c.when {
|
||||
let mut inner = (**when).clone();
|
||||
inner.collect_schemas(origin_path.clone(), to_insert, errors);
|
||||
*when = Arc::new(inner);
|
||||
}
|
||||
if let Some(then) = &mut c.then {
|
||||
let mut inner = (**then).clone();
|
||||
inner.collect_schemas(origin_path.clone(), to_insert, errors);
|
||||
*then = Arc::new(inner);
|
||||
}
|
||||
if let Some(else_) = &mut c.else_ {
|
||||
let mut inner = (**else_).clone();
|
||||
inner.collect_schemas(origin_path.clone(), to_insert, errors);
|
||||
*else_ = Arc::new(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamically infers and compiles all structural database relationships between this Schema
|
||||
@ -823,13 +865,9 @@ impl<'de> Deserialize<'de> for Schema {
|
||||
&& obj.format.is_none()
|
||||
&& obj.enum_.is_none()
|
||||
&& obj.const_.is_none()
|
||||
&& obj.all_of.is_none()
|
||||
&& obj.cases.is_none()
|
||||
&& obj.one_of.is_none()
|
||||
&& obj.not.is_none()
|
||||
&& obj.if_.is_none()
|
||||
&& obj.then_.is_none()
|
||||
&& obj.else_.is_none()
|
||||
&& obj.r#ref.is_none()
|
||||
&& obj.family.is_none();
|
||||
|
||||
if is_empty && obj.extensible.is_none() {
|
||||
@ -845,11 +883,15 @@ impl<'de> Deserialize<'de> for Schema {
|
||||
|
||||
impl SchemaObject {
|
||||
pub fn identifier(&self) -> Option<String> {
|
||||
if let Some(lookup_key) = self.id.as_ref().or(self.r#ref.as_ref()) {
|
||||
Some(lookup_key.split('.').next_back().unwrap_or("").to_string())
|
||||
} else {
|
||||
None
|
||||
if let Some(id) = &self.id {
|
||||
return Some(id.split('.').next_back().unwrap_or("").to_string());
|
||||
}
|
||||
if let Some(SchemaTypeOrArray::Single(t)) = &self.type_ {
|
||||
if !is_primitive_type(t) {
|
||||
return Some(t.split('.').next_back().unwrap_or("").to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@ impl<'a> Compiler<'a> {
|
||||
};
|
||||
|
||||
let (sql, _) = compiler.compile_node(node)?;
|
||||
Ok(sql)
|
||||
Ok(format!("(SELECT jsonb_strip_nulls({}))", sql))
|
||||
}
|
||||
|
||||
/// Recursively walks the schema AST emitting native PostgreSQL jsonb mapping
|
||||
@ -63,7 +63,7 @@ impl<'a> Compiler<'a> {
|
||||
}
|
||||
|
||||
fn compile_array(&mut self, node: Node<'a>) -> Result<(String, String), String> {
|
||||
// 1. Array of DB Entities (`$ref` or `$family` pointing to a table limit)
|
||||
// 1. Array of DB Entities (`type` or `$family` pointing to a table limit)
|
||||
if let Some(items) = &node.schema.obj.items {
|
||||
let mut resolved_type = None;
|
||||
if let Some(family_target) = items.obj.family.as_ref() {
|
||||
@ -112,15 +112,17 @@ impl<'a> Compiler<'a> {
|
||||
return self.compile_entity(type_def, node.clone(), false);
|
||||
}
|
||||
|
||||
// Handle Direct Refs
|
||||
if let Some(ref_id) = &node.schema.obj.r#ref {
|
||||
// If it's just an ad-hoc struct ref, we should resolve it
|
||||
if let Some(target_schema) = self.db.schemas.get(ref_id) {
|
||||
let mut ref_node = node.clone();
|
||||
ref_node.schema = std::sync::Arc::new(target_schema.clone());
|
||||
return self.compile_node(ref_node);
|
||||
// Handle Direct Refs via type pointer
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &node.schema.obj.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
// If it's just an ad-hoc struct ref, we should resolve it
|
||||
if let Some(target_schema) = self.db.schemas.get(t) {
|
||||
let mut ref_node = node.clone();
|
||||
ref_node.schema = std::sync::Arc::new(target_schema.clone());
|
||||
return self.compile_node(ref_node);
|
||||
}
|
||||
return Err(format!("Unresolved schema type pointer: {}", t));
|
||||
}
|
||||
return Err(format!("Unresolved $ref: {}", ref_id));
|
||||
}
|
||||
// Handle $family Polymorphism fallbacks for relations
|
||||
if let Some(family_target) = &node.schema.obj.family {
|
||||
@ -131,7 +133,7 @@ impl<'a> Compiler<'a> {
|
||||
|
||||
if all_targets.len() == 1 {
|
||||
let mut bypass_schema = crate::database::schema::Schema::default();
|
||||
bypass_schema.obj.r#ref = Some(all_targets[0].clone());
|
||||
bypass_schema.obj.type_ = Some(crate::database::schema::SchemaTypeOrArray::Single(all_targets[0].clone()));
|
||||
let mut bypass_node = node.clone();
|
||||
bypass_node.schema = std::sync::Arc::new(bypass_schema);
|
||||
return self.compile_node(bypass_node);
|
||||
@ -141,7 +143,7 @@ impl<'a> Compiler<'a> {
|
||||
let mut family_schemas = Vec::new();
|
||||
for variation in &all_targets {
|
||||
let mut ref_schema = crate::database::schema::Schema::default();
|
||||
ref_schema.obj.r#ref = Some(variation.clone());
|
||||
ref_schema.obj.type_ = Some(crate::database::schema::SchemaTypeOrArray::Single(variation.clone()));
|
||||
family_schemas.push(std::sync::Arc::new(ref_schema));
|
||||
}
|
||||
|
||||
@ -185,9 +187,9 @@ impl<'a> Compiler<'a> {
|
||||
select_args.append(&mut poly_args);
|
||||
|
||||
let jsonb_obj_sql = if select_args.is_empty() {
|
||||
"jsonb_strip_nulls(jsonb_build_object())".to_string()
|
||||
"jsonb_build_object()".to_string()
|
||||
} else {
|
||||
format!("jsonb_strip_nulls(jsonb_build_object({}))", select_args.join(", "))
|
||||
format!("jsonb_build_object({})", select_args.join(", "))
|
||||
};
|
||||
|
||||
// 3. Build WHERE clauses
|
||||
@ -325,7 +327,7 @@ impl<'a> Compiler<'a> {
|
||||
}
|
||||
build_args.push(format!("'{}', {}", k, child_sql));
|
||||
}
|
||||
let combined = format!("jsonb_strip_nulls(jsonb_build_object({}))", build_args.join(", "));
|
||||
let combined = format!("jsonb_build_object({})", build_args.join(", "));
|
||||
Ok((combined, "object".to_string()))
|
||||
}
|
||||
|
||||
@ -416,7 +418,14 @@ impl<'a> Compiler<'a> {
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let is_primitive = prop_schema.obj.r#ref.is_none()
|
||||
let is_custom_object_pointer = match &prop_schema.obj.type_ {
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Single(s)) => {
|
||||
!crate::database::schema::is_primitive_type(s)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let is_primitive = !is_custom_object_pointer
|
||||
&& !is_object_or_array
|
||||
&& prop_schema.obj.family.is_none()
|
||||
&& prop_schema.obj.one_of.is_none();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -42,19 +42,21 @@ fn test_library_api() {
|
||||
"types": [
|
||||
{
|
||||
"name": "source_schema",
|
||||
"variations": ["source_schema"],
|
||||
"hierarchy": ["source_schema", "entity"],
|
||||
"schemas": [{
|
||||
"$id": "source_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"target": { "$ref": "target_schema" }
|
||||
"target": { "type": "target_schema" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"name": "target_schema",
|
||||
"variations": ["target_schema"],
|
||||
"hierarchy": ["target_schema", "entity"],
|
||||
"schemas": [{
|
||||
"$id": "target_schema",
|
||||
@ -89,7 +91,7 @@ fn test_library_api() {
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"target": {
|
||||
"$ref": "target_schema",
|
||||
"type": "target_schema",
|
||||
"compiledProperties": ["value"]
|
||||
}
|
||||
},
|
||||
@ -115,7 +117,7 @@ fn test_library_api() {
|
||||
);
|
||||
|
||||
// 4. Validate Happy Path
|
||||
let happy_drop = jspg_validate("source_schema", JsonB(json!({"name": "Neo"})));
|
||||
let happy_drop = jspg_validate("source_schema", JsonB(json!({"type": "source_schema", "name": "Neo"})));
|
||||
assert_eq!(
|
||||
happy_drop.0,
|
||||
json!({
|
||||
@ -125,7 +127,7 @@ fn test_library_api() {
|
||||
);
|
||||
|
||||
// 5. Validate Unhappy Path
|
||||
let unhappy_drop = jspg_validate("source_schema", JsonB(json!({"wrong": "data"})));
|
||||
let unhappy_drop = jspg_validate("source_schema", JsonB(json!({"type": "source_schema", "wrong": "data"})));
|
||||
assert_eq!(
|
||||
unhappy_drop.0,
|
||||
json!({
|
||||
|
||||
@ -19,6 +19,16 @@ impl Expect {
|
||||
.map(|e| serde_json::to_value(e).unwrap())
|
||||
.collect();
|
||||
|
||||
if expected_errors.len() != actual_values.len() {
|
||||
return Err(format!(
|
||||
"Expected {} errors, but got {}.\nExpected subset: {:?}\nActual full errors: {:?}",
|
||||
expected_errors.len(),
|
||||
actual_values.len(),
|
||||
expected_errors,
|
||||
drop.errors
|
||||
));
|
||||
}
|
||||
|
||||
for (i, expected_val) in expected_errors.iter().enumerate() {
|
||||
let mut matched = false;
|
||||
|
||||
|
||||
45
src/validator/rules/cases.rs
Normal file
45
src/validator/rules/cases.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use crate::validator::context::ValidationContext;
|
||||
use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
|
||||
impl<'a> ValidationContext<'a> {
|
||||
pub(crate) fn validate_cases(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
if let Some(cases) = &self.schema.cases {
|
||||
for case in cases {
|
||||
if let Some(ref when_schema) = case.when {
|
||||
let derived_when = self.derive_for_schema(when_schema, true);
|
||||
let when_res = derived_when.validate()?;
|
||||
|
||||
// Evaluates all cases independently.
|
||||
if when_res.is_valid() {
|
||||
result
|
||||
.evaluated_keys
|
||||
.extend(when_res.evaluated_keys.clone());
|
||||
result
|
||||
.evaluated_indices
|
||||
.extend(when_res.evaluated_indices.clone());
|
||||
|
||||
if let Some(ref then_schema) = case.then {
|
||||
let derived_then = self.derive_for_schema(then_schema, true);
|
||||
result.merge(derived_then.validate()?);
|
||||
}
|
||||
} else {
|
||||
if let Some(ref else_schema) = case.else_ {
|
||||
let derived_else = self.derive_for_schema(else_schema, true);
|
||||
result.merge(derived_else.validate()?);
|
||||
}
|
||||
}
|
||||
} else if let Some(ref else_schema) = case.else_ {
|
||||
// A rule with a missing `when` fires the `else` indiscriminately
|
||||
let derived_else = self.derive_for_schema(else_schema, true);
|
||||
result.merge(derived_else.validate()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
use crate::validator::context::ValidationContext;
|
||||
use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
|
||||
impl<'a> ValidationContext<'a> {
|
||||
pub(crate) fn validate_combinators(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
if let Some(ref all_of) = self.schema.all_of {
|
||||
for sub in all_of {
|
||||
let derived = self.derive_for_schema(sub, true);
|
||||
let res = derived.validate()?;
|
||||
result.merge(res);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref one_of) = self.schema.one_of {
|
||||
let mut passed_candidates: Vec<(Option<String>, usize, ValidationResult)> = Vec::new();
|
||||
let mut failed_errors: Vec<ValidationError> = Vec::new();
|
||||
|
||||
for sub in one_of {
|
||||
let derived = self.derive_for_schema(sub, true);
|
||||
let sub_res = derived.validate()?;
|
||||
if sub_res.is_valid() {
|
||||
let child_id = sub.id.clone();
|
||||
let depth = child_id
|
||||
.as_ref()
|
||||
.and_then(|id| self.db.depths.get(id).copied())
|
||||
.unwrap_or(0);
|
||||
passed_candidates.push((child_id, depth, sub_res));
|
||||
} else {
|
||||
failed_errors.extend(sub_res.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if passed_candidates.len() == 1 {
|
||||
result.merge(passed_candidates.pop().unwrap().2);
|
||||
} else if passed_candidates.is_empty() {
|
||||
result.errors.push(ValidationError {
|
||||
code: "NO_ONEOF_MATCH".to_string(),
|
||||
message: "Matches none of oneOf schemas".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
result.errors.extend(failed_errors);
|
||||
} else {
|
||||
// Apply depth heuristic tie-breaker
|
||||
let mut best_depth: Option<usize> = None;
|
||||
let mut ambiguous = false;
|
||||
let mut best_res = None;
|
||||
|
||||
for (_, depth, res) in passed_candidates.into_iter() {
|
||||
if let Some(current_best) = best_depth {
|
||||
if depth > current_best {
|
||||
best_depth = Some(depth);
|
||||
best_res = Some(res);
|
||||
ambiguous = false;
|
||||
} else if depth == current_best {
|
||||
ambiguous = true;
|
||||
}
|
||||
} else {
|
||||
best_depth = Some(depth);
|
||||
best_res = Some(res);
|
||||
}
|
||||
}
|
||||
|
||||
if !ambiguous {
|
||||
if let Some(res) = best_res {
|
||||
result.merge(res);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
result.errors.push(ValidationError {
|
||||
code: "AMBIGUOUS_ONEOF_MATCH".to_string(),
|
||||
message: "Matches multiple oneOf schemas without a clear depth winner".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref not_schema) = self.schema.not {
|
||||
let derived = self.derive_for_schema(not_schema, true);
|
||||
let sub_res = derived.validate()?;
|
||||
if sub_res.is_valid() {
|
||||
result.errors.push(ValidationError {
|
||||
code: "NOT_VIOLATED".to_string(),
|
||||
message: "Matched 'not' schema".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -3,30 +3,14 @@ use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
|
||||
impl<'a> ValidationContext<'a> {
|
||||
pub(crate) fn validate_conditionals(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
if let Some(ref if_schema) = self.schema.if_ {
|
||||
let derived_if = self.derive_for_schema(if_schema, true);
|
||||
let if_res = derived_if.validate()?;
|
||||
|
||||
result.evaluated_keys.extend(if_res.evaluated_keys.clone());
|
||||
result
|
||||
.evaluated_indices
|
||||
.extend(if_res.evaluated_indices.clone());
|
||||
|
||||
if if_res.is_valid() {
|
||||
if let Some(ref then_schema) = self.schema.then_ {
|
||||
let derived_then = self.derive_for_schema(then_schema, true);
|
||||
result.merge(derived_then.validate()?);
|
||||
}
|
||||
} else if let Some(ref else_schema) = self.schema.else_ {
|
||||
let derived_else = self.derive_for_schema(else_schema, true);
|
||||
result.merge(derived_else.validate()?);
|
||||
pub(crate) fn validate_extensible(&self, result: &mut ValidationResult) -> Result<bool, ValidationError> {
|
||||
if self.extensible {
|
||||
if let Some(obj) = self.instance.as_object() {
|
||||
result.evaluated_keys.extend(obj.keys().cloned());
|
||||
} else if let Some(arr) = self.instance.as_array() {
|
||||
result.evaluated_indices.extend(0..arr.len());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@ -40,6 +24,9 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
if let Some(obj) = self.instance.as_object() {
|
||||
for key in obj.keys() {
|
||||
if key == "type" || key == "kind" {
|
||||
continue; // Reserved keywords implicitly allowed
|
||||
}
|
||||
if !result.evaluated_keys.contains(key) && !self.overrides.contains(key) {
|
||||
result.errors.push(ValidationError {
|
||||
code: "STRICT_PROPERTY_VIOLATION".to_string(),
|
||||
@ -3,10 +3,11 @@ use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
|
||||
pub mod array;
|
||||
pub mod combinators;
|
||||
pub mod conditionals;
|
||||
pub mod cases;
|
||||
pub mod core;
|
||||
pub mod extensible;
|
||||
pub mod format;
|
||||
pub mod not;
|
||||
pub mod numeric;
|
||||
pub mod object;
|
||||
pub mod polymorphism;
|
||||
@ -27,7 +28,7 @@ impl<'a> ValidationContext<'a> {
|
||||
if !self.validate_family(&mut result)? {
|
||||
return Ok(result);
|
||||
}
|
||||
if !self.validate_refs(&mut result)? {
|
||||
if !self.validate_type_inheritance(&mut result)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@ -42,8 +43,11 @@ impl<'a> ValidationContext<'a> {
|
||||
self.validate_array(&mut result)?;
|
||||
|
||||
// Multipliers & Conditionals
|
||||
self.validate_combinators(&mut result)?;
|
||||
self.validate_conditionals(&mut result)?;
|
||||
if !self.validate_one_of(&mut result)? {
|
||||
return Ok(result);
|
||||
}
|
||||
self.validate_not(&mut result)?;
|
||||
self.validate_cases(&mut result)?;
|
||||
|
||||
// State Tracking
|
||||
self.validate_extensible(&mut result)?;
|
||||
@ -77,15 +81,4 @@ impl<'a> ValidationContext<'a> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_extensible(&self, result: &mut ValidationResult) -> Result<bool, ValidationError> {
|
||||
if self.extensible {
|
||||
if let Some(obj) = self.instance.as_object() {
|
||||
result.evaluated_keys.extend(obj.keys().cloned());
|
||||
} else if let Some(arr) = self.instance.as_array() {
|
||||
result.evaluated_indices.extend(0..arr.len());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
24
src/validator/rules/not.rs
Normal file
24
src/validator/rules/not.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use crate::validator::context::ValidationContext;
|
||||
use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
|
||||
impl<'a> ValidationContext<'a> {
|
||||
pub(crate) fn validate_not(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
if let Some(ref not_schema) = self.schema.not {
|
||||
let derived = self.derive_for_schema(not_schema, true);
|
||||
let sub_res = derived.validate()?;
|
||||
if sub_res.is_valid() {
|
||||
result.errors.push(ValidationError {
|
||||
code: "NOT_VIOLATED".to_string(),
|
||||
message: "Matched 'not' schema".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -15,32 +15,68 @@ impl<'a> ValidationContext<'a> {
|
||||
if let Some(obj) = current.as_object() {
|
||||
// Entity implicit type validation
|
||||
if let Some(schema_identifier) = self.schema.identifier() {
|
||||
// Kick in if the data object has a type field
|
||||
if let Some(type_val) = obj.get("type")
|
||||
&& let Some(type_str) = type_val.as_str()
|
||||
{
|
||||
// Check if the identifier is a global type name
|
||||
if let Some(type_def) = self.db.types.get(&schema_identifier) {
|
||||
// Ensure the instance type is a variation of the global type
|
||||
if type_def.variations.contains(type_str) {
|
||||
// Ensure it passes strict mode
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "CONST_VIOLATED".to_string(), // Aligning with original const override errors
|
||||
message: format!(
|
||||
"Type '{}' is not a valid descendant for this entity bound schema",
|
||||
type_str
|
||||
),
|
||||
path: self.join_path("type"),
|
||||
});
|
||||
// We decompose identity string routing inherently
|
||||
let expected_type = schema_identifier.split('.').last().unwrap_or(&schema_identifier);
|
||||
|
||||
// Check if the identifier represents a registered global database entity boundary mathematically
|
||||
if let Some(type_def) = self.db.types.get(expected_type) {
|
||||
if let Some(type_val) = obj.get("type") {
|
||||
if let Some(type_str) = type_val.as_str() {
|
||||
if type_def.variations.contains(type_str) {
|
||||
// The instance is validly declaring a known structural descent
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "CONST_VIOLATED".to_string(), // Aligning with original const override errors natively
|
||||
message: format!(
|
||||
"Type '{}' is not a valid descendant for this entity bound schema",
|
||||
type_str
|
||||
),
|
||||
path: self.join_path("type"),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ad-Hoc schemas natively use strict schema discriminator strings instead of variation inheritance
|
||||
if type_str == schema_identifier.as_str() {
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
// Because it's a global entity target, the payload must structurally provide a discriminator natively
|
||||
result.errors.push(ValidationError {
|
||||
code: "MISSING_TYPE".to_string(),
|
||||
message: format!("Schema mechanically requires type discrimination '{}'", expected_type),
|
||||
path: self.path.clone(), // Empty boundary
|
||||
});
|
||||
}
|
||||
|
||||
// If the target mathematically declares a horizontal structural STI variation natively
|
||||
if schema_identifier.contains('.') {
|
||||
if obj.get("kind").is_none() {
|
||||
result.errors.push(ValidationError {
|
||||
code: "MISSING_KIND".to_string(),
|
||||
message: "Schema mechanically requires horizontal kind discrimination".to_string(),
|
||||
path: self.path.clone(),
|
||||
});
|
||||
} else {
|
||||
result.evaluated_keys.insert("kind".to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it isn't registered globally, it might be a nested Ad-Hoc candidate running via O(1) union routers.
|
||||
// Because they lack manual type property descriptors, we natively shield "type" and "kind" keys from
|
||||
// triggering additionalProperty violations natively IF they precisely correspond to their fast-path boundaries
|
||||
if let Some(type_val) = obj.get("type") {
|
||||
if let Some(type_str) = type_val.as_str() {
|
||||
if type_str == expected_type {
|
||||
result.evaluated_keys.insert("type".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(kind_val) = obj.get("kind") {
|
||||
if let Some((kind_str, _)) = schema_identifier.rsplit_once('.') {
|
||||
if let Some(actual_kind) = kind_val.as_str() {
|
||||
if actual_kind == kind_str {
|
||||
result.evaluated_keys.insert("kind".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,11 +103,19 @@ impl<'a> ValidationContext<'a> {
|
||||
if let Some(ref req) = self.schema.required {
|
||||
for field in req {
|
||||
if !obj.contains_key(field) {
|
||||
result.errors.push(ValidationError {
|
||||
code: "REQUIRED_FIELD_MISSING".to_string(),
|
||||
message: format!("Missing {}", field),
|
||||
path: self.join_path(field),
|
||||
});
|
||||
if field == "type" {
|
||||
result.errors.push(ValidationError {
|
||||
code: "MISSING_TYPE".to_string(),
|
||||
message: "Missing type discriminator".to_string(),
|
||||
path: self.join_path(field),
|
||||
});
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "REQUIRED_FIELD_MISSING".to_string(),
|
||||
message: format!("Missing {}", field),
|
||||
path: self.join_path(field),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -110,7 +154,10 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
if let Some(child_instance) = obj.get(key) {
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = sub_schema.r#ref.is_some();
|
||||
let is_ref = match &sub_schema.type_ {
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t),
|
||||
_ => false,
|
||||
};
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
let derived = self.derive(
|
||||
@ -121,21 +168,9 @@ impl<'a> ValidationContext<'a> {
|
||||
next_extensible,
|
||||
false,
|
||||
);
|
||||
let mut item_res = derived.validate()?;
|
||||
let item_res = derived.validate()?;
|
||||
|
||||
|
||||
// Entity Bound Implicit Type Interception
|
||||
if key == "type"
|
||||
&& let Some(schema_bound) = sub_schema.identifier()
|
||||
{
|
||||
if let Some(type_def) = self.db.types.get(&schema_bound)
|
||||
&& let Some(instance_type) = child_instance.as_str()
|
||||
&& type_def.variations.contains(instance_type)
|
||||
{
|
||||
item_res
|
||||
.errors
|
||||
.retain(|e| e.code != "CONST_VIOLATED" && e.code != "ENUM_VIOLATED");
|
||||
}
|
||||
}
|
||||
|
||||
result.merge(item_res);
|
||||
result.evaluated_keys.insert(key.to_string());
|
||||
@ -148,7 +183,10 @@ impl<'a> ValidationContext<'a> {
|
||||
for (key, child_instance) in obj {
|
||||
if compiled_re.0.is_match(key) {
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = sub_schema.r#ref.is_some();
|
||||
let is_ref = match &sub_schema.type_ {
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t),
|
||||
_ => false,
|
||||
};
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
let derived = self.derive(
|
||||
@ -187,7 +225,10 @@ impl<'a> ValidationContext<'a> {
|
||||
|
||||
if !locally_matched {
|
||||
let new_path = self.join_path(key);
|
||||
let is_ref = additional_schema.r#ref.is_some();
|
||||
let is_ref = match &additional_schema.type_ {
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t),
|
||||
_ => false,
|
||||
};
|
||||
let next_extensible = if is_ref { false } else { self.extensible };
|
||||
|
||||
let derived = self.derive(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::database::schema::Schema;
|
||||
use crate::validator::context::ValidationContext;
|
||||
use crate::validator::error::ValidationError;
|
||||
use crate::validator::result::ValidationResult;
|
||||
@ -13,9 +14,8 @@ impl<'a> ValidationContext<'a> {
|
||||
|| self.schema.required.is_some()
|
||||
|| self.schema.additional_properties.is_some()
|
||||
|| self.schema.items.is_some()
|
||||
|| self.schema.r#ref.is_some()
|
||||
|| self.schema.cases.is_some()
|
||||
|| self.schema.one_of.is_some()
|
||||
|| self.schema.all_of.is_some()
|
||||
|| self.schema.enum_.is_some()
|
||||
|| self.schema.const_.is_some();
|
||||
|
||||
@ -25,102 +25,325 @@ impl<'a> ValidationContext<'a> {
|
||||
message: "$family must be used exclusively without other constraints".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
// Short-circuit: the schema formulation is broken
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(family_target) = &self.schema.family {
|
||||
if let Some(descendants) = self.db.descendants.get(family_target) {
|
||||
// Validate against all descendants simulating strict oneOf logic
|
||||
let mut passed_candidates: Vec<(String, usize, ValidationResult)> = Vec::new();
|
||||
|
||||
// The target itself is also an implicitly valid candidate
|
||||
let mut all_targets = vec![family_target.clone()];
|
||||
all_targets.extend(descendants.clone());
|
||||
|
||||
for child_id in &all_targets {
|
||||
if let Some(child_schema) = self.db.schemas.get(child_id) {
|
||||
let derived = self.derive(
|
||||
child_schema,
|
||||
self.instance,
|
||||
&self.path,
|
||||
self.overrides.clone(),
|
||||
self.extensible,
|
||||
self.reporter, // Inherit parent reporter flag, do not bypass strictness!
|
||||
);
|
||||
|
||||
// Explicitly run validate_scoped to accurately test candidates with strictness checks enabled
|
||||
let res = derived.validate_scoped()?;
|
||||
|
||||
if res.is_valid() {
|
||||
let depth = self.db.depths.get(child_id).copied().unwrap_or(0);
|
||||
passed_candidates.push((child_id.clone(), depth, res));
|
||||
}
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
// Add the target base schema itself
|
||||
if let Some(base_schema) = self.db.schemas.get(family_target) {
|
||||
candidates.push(base_schema);
|
||||
}
|
||||
|
||||
if passed_candidates.len() == 1 {
|
||||
result.merge(passed_candidates.pop().unwrap().2);
|
||||
} else if passed_candidates.is_empty() {
|
||||
result.errors.push(ValidationError {
|
||||
code: "NO_FAMILY_MATCH".to_string(),
|
||||
message: format!(
|
||||
"Payload did not match any descendants of family '{}'",
|
||||
family_target
|
||||
),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
} else {
|
||||
// Apply depth heuristic tie-breaker
|
||||
let mut best_depth: Option<usize> = None;
|
||||
let mut ambiguous = false;
|
||||
let mut best_res = None;
|
||||
|
||||
for (_, depth, res) in passed_candidates.into_iter() {
|
||||
if let Some(current_best) = best_depth {
|
||||
if depth > current_best {
|
||||
best_depth = Some(depth);
|
||||
best_res = Some(res);
|
||||
ambiguous = false; // Broke the tie
|
||||
} else if depth == current_best {
|
||||
ambiguous = true; // Tie at the highest level
|
||||
}
|
||||
} else {
|
||||
best_depth = Some(depth);
|
||||
best_res = Some(res);
|
||||
}
|
||||
// Add all descendants
|
||||
for child_id in descendants {
|
||||
if let Some(child_schema) = self.db.schemas.get(child_id) {
|
||||
candidates.push(child_schema);
|
||||
}
|
||||
}
|
||||
|
||||
// Use prefix from family string (e.g. `light.`)
|
||||
let prefix = family_target
|
||||
.rsplit_once('.')
|
||||
.map(|(p, _)| format!("{}.", p))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !ambiguous {
|
||||
if let Some(res) = best_res {
|
||||
result.merge(res);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
result.errors.push(ValidationError {
|
||||
code: "AMBIGUOUS_FAMILY_MATCH".to_string(),
|
||||
message: format!(
|
||||
"Payload matched multiple descendants of family '{}' without a clear depth winner",
|
||||
family_target
|
||||
),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
if !self.validate_polymorph(&candidates, Some(&prefix), result)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_refs(
|
||||
|
||||
pub(crate) fn validate_one_of(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
// 1. Core $ref logic relies on the fast O(1) map to allow cycles and proper nesting
|
||||
if let Some(ref_str) = &self.schema.r#ref {
|
||||
if let Some(global_schema) = self.db.schemas.get(ref_str) {
|
||||
if let Some(ref one_of) = self.schema.one_of {
|
||||
let mut candidates = Vec::new();
|
||||
for schema in one_of {
|
||||
candidates.push(schema.as_ref());
|
||||
}
|
||||
if !self.validate_polymorph(&candidates, None, result)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_polymorph(
|
||||
&self,
|
||||
candidates: &[&Schema],
|
||||
family_prefix: Option<&str>,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
let mut passed_candidates: Vec<(Option<String>, ValidationResult)> = Vec::new();
|
||||
let mut failed_candidates: Vec<ValidationResult> = Vec::new();
|
||||
|
||||
// 1. O(1) Fast-Path Router & Extractor
|
||||
let instance_type = self.instance.as_object().and_then(|o| o.get("type")).and_then(|t| t.as_str());
|
||||
let instance_kind = self.instance.as_object().and_then(|o| o.get("kind")).and_then(|k| k.as_str());
|
||||
|
||||
let mut viable_candidates = Vec::new();
|
||||
|
||||
for sub in candidates {
|
||||
let _child_id = sub.identifier().unwrap_or_default();
|
||||
let mut can_match = true;
|
||||
|
||||
if let Some(t) = instance_type {
|
||||
// Fast Path 1: Pure Ad-Hoc Match (schema identifier == type)
|
||||
// If it matches exactly, it's our golden candidate. Make all others non-viable manually?
|
||||
// Wait, we loop through all and filter down. If exact match is found, we should ideally break and use ONLY that.
|
||||
// Let's implement the logic safely.
|
||||
|
||||
let mut exact_match_found = false;
|
||||
|
||||
if let Some(schema_id) = &sub.id {
|
||||
// Compute Vertical Exact Target (e.g. "person" or "light.person")
|
||||
let exact_target = if let Some(prefix) = family_prefix {
|
||||
format!("{}{}", prefix, t)
|
||||
} else {
|
||||
t.to_string()
|
||||
};
|
||||
|
||||
// Fast Path 1 & 2: Vertical Exact Match
|
||||
if schema_id == &exact_target {
|
||||
if instance_kind.is_none() {
|
||||
exact_match_found = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fast Path 3: Horizontal Sibling Match (kind + . + type)
|
||||
if let Some(k) = instance_kind {
|
||||
let sibling_target = format!("{}.{}", k, t);
|
||||
if schema_id == &sibling_target {
|
||||
exact_match_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exact_match_found {
|
||||
// We found an exact literal structural identity match!
|
||||
// Wipe the existing viable_candidates and only yield this guy!
|
||||
viable_candidates.clear();
|
||||
viable_candidates.push(*sub);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fast Path 4: Vertical Inheritance Fallback (Physical DB constraint)
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t_ptr)) = &sub.type_ {
|
||||
if !crate::database::schema::is_primitive_type(t_ptr) {
|
||||
if let Some(base_type) = t_ptr.split('.').last() {
|
||||
if let Some(type_def) = self.db.types.get(base_type) {
|
||||
if !type_def.variations.contains(&t.to_string()) {
|
||||
can_match = false;
|
||||
}
|
||||
} else {
|
||||
if t_ptr != t {
|
||||
can_match = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fast Path 5: Explicit Schema JSON `const` values check
|
||||
if can_match {
|
||||
if let Some(props) = &sub.properties {
|
||||
if let Some(type_prop) = props.get("type") {
|
||||
if let Some(const_val) = &type_prop.const_ {
|
||||
if let Some(const_str) = const_val.as_str() {
|
||||
if const_str != t {
|
||||
can_match = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if can_match {
|
||||
viable_candidates.push(*sub);
|
||||
}
|
||||
}
|
||||
|
||||
println!("DEBUG VIABLE: {:?}", viable_candidates.iter().map(|s| s.id.clone()).collect::<Vec<_>>());
|
||||
// 2. Evaluate Viable Candidates
|
||||
// 2. Evaluate Viable Candidates
|
||||
// Composition validation is natively handled directly via type compilation.
|
||||
// The deprecated allOf JSON structure is no longer supported nor traversed.
|
||||
for sub in viable_candidates.clone() {
|
||||
let derived = self.derive_for_schema(sub, false);
|
||||
let sub_res = derived.validate()?;
|
||||
if sub_res.is_valid() {
|
||||
passed_candidates.push((sub.id.clone(), sub_res));
|
||||
} else {
|
||||
failed_candidates.push(sub_res);
|
||||
}
|
||||
}
|
||||
for f in &failed_candidates {
|
||||
println!(" - Failed candidate errors: {:?}", f.errors.iter().map(|e| e.code.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if passed_candidates.len() == 1 {
|
||||
result.merge(passed_candidates.pop().unwrap().1);
|
||||
} else if passed_candidates.is_empty() {
|
||||
// 3. Discriminator Pathing (Failure Analytics)
|
||||
let type_path = self.join_path("type");
|
||||
|
||||
if instance_type.is_some() {
|
||||
// Filter to candidates that didn't explicitly throw a CONST violation on `type`
|
||||
let mut genuinely_failed = Vec::new();
|
||||
for res in &failed_candidates {
|
||||
let rejected_type = res.errors.iter().any(|e| {
|
||||
(e.code == "CONST_VIOLATED" || e.code == "ENUM_VIOLATED") && e.path == type_path
|
||||
});
|
||||
if !rejected_type {
|
||||
genuinely_failed.push(res.clone());
|
||||
}
|
||||
}
|
||||
|
||||
println!("DEBUG genuinely_failed len: {}", genuinely_failed.len());
|
||||
|
||||
if genuinely_failed.len() == 1 {
|
||||
// Golden Type Match (1 candidate was structurally possible but failed property validation)
|
||||
let sub_res = genuinely_failed.pop().unwrap();
|
||||
result.errors.extend(sub_res.errors);
|
||||
result.evaluated_keys.extend(sub_res.evaluated_keys);
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Pure Ad-Hoc Union
|
||||
result.errors.push(ValidationError {
|
||||
code: if self.schema.family.is_some() { "NO_FAMILY_MATCH".to_string() } else { "NO_ONEOF_MATCH".to_string() },
|
||||
message: "Payload matches none of the required candidate sub-schemas".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
|
||||
for sub_res in &failed_candidates {
|
||||
result.evaluated_keys.extend(sub_res.evaluated_keys.clone());
|
||||
}
|
||||
println!("DEBUG ELSE NO_FAMILY_MATCH RUNNING. Genuinely Failed len: {}", genuinely_failed.len());
|
||||
if viable_candidates.is_empty() {
|
||||
if let Some(obj) = self.instance.as_object() {
|
||||
result.evaluated_keys.extend(obj.keys().cloned());
|
||||
}
|
||||
}
|
||||
for sub_res in genuinely_failed {
|
||||
for e in sub_res.errors {
|
||||
if !result.errors.iter().any(|existing| existing.code == e.code && existing.path == e.path) {
|
||||
result.errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
// Instance missing type
|
||||
// Instance missing type
|
||||
let expects_type = viable_candidates.iter().any(|c| {
|
||||
c.compiled_property_names.get().map_or(false, |props| props.contains(&"type".to_string()))
|
||||
});
|
||||
|
||||
if expects_type {
|
||||
result.errors.push(ValidationError {
|
||||
code: "MISSING_TYPE".to_string(),
|
||||
message: "Missing type discriminator. Unable to resolve polymorphic boundaries".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
|
||||
for sub_res in failed_candidates {
|
||||
result.evaluated_keys.extend(sub_res.evaluated_keys);
|
||||
}
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Pure Ad-Hoc Union
|
||||
result.errors.push(ValidationError {
|
||||
code: if self.schema.family.is_some() { "NO_FAMILY_MATCH".to_string() } else { "NO_ONEOF_MATCH".to_string() },
|
||||
message: "Payload matches none of the required candidate sub-schemas".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
|
||||
if let Some(first) = failed_candidates.first() {
|
||||
let mut shared_errors = first.errors.clone();
|
||||
for sub_res in failed_candidates.iter().skip(1) {
|
||||
shared_errors.retain(|e1| {
|
||||
sub_res.errors.iter().any(|e2| e1.code == e2.code && e1.path == e2.path)
|
||||
});
|
||||
}
|
||||
for e in shared_errors {
|
||||
if !result.errors.iter().any(|existing| existing.code == e.code && existing.path == e.path) {
|
||||
result.errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for sub_res in failed_candidates {
|
||||
result.evaluated_keys.extend(sub_res.evaluated_keys);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "AMBIGUOUS_POLYMORPHIC_MATCH".to_string(),
|
||||
message: "Matches multiple polymorphic candidates inextricably".to_string(),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_type_inheritance(
|
||||
&self,
|
||||
result: &mut ValidationResult,
|
||||
) -> Result<bool, ValidationError> {
|
||||
// Core inheritance logic replaces legacy routing
|
||||
let payload_primitive = match self.instance {
|
||||
serde_json::Value::Null => "null",
|
||||
serde_json::Value::Bool(_) => "boolean",
|
||||
serde_json::Value::Number(n) => {
|
||||
if n.is_i64() || n.is_u64() {
|
||||
"integer"
|
||||
} else {
|
||||
"number"
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(_) => "string",
|
||||
serde_json::Value::Array(_) => "array",
|
||||
serde_json::Value::Object(_) => "object",
|
||||
};
|
||||
|
||||
let mut custom_types = Vec::new();
|
||||
match &self.schema.type_ {
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
custom_types.push(t.clone());
|
||||
}
|
||||
}
|
||||
Some(crate::database::schema::SchemaTypeOrArray::Multiple(arr)) => {
|
||||
if arr.contains(&payload_primitive.to_string()) || (payload_primitive == "integer" && arr.contains(&"number".to_string())) {
|
||||
// It natively matched a primitive in the array options, skip forcing custom proxy fallback
|
||||
} else {
|
||||
for t in arr {
|
||||
if !crate::database::schema::is_primitive_type(t) {
|
||||
custom_types.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
for t in custom_types {
|
||||
if let Some(global_schema) = self.db.schemas.get(&t) {
|
||||
let mut new_overrides = self.overrides.clone();
|
||||
if let Some(props) = &self.schema.properties {
|
||||
new_overrides.extend(props.keys().map(|k| k.to_string()));
|
||||
@ -132,16 +355,16 @@ impl<'a> ValidationContext<'a> {
|
||||
&self.path,
|
||||
new_overrides,
|
||||
self.extensible,
|
||||
true,
|
||||
true, // Reporter mode
|
||||
);
|
||||
shadow.root = global_schema;
|
||||
result.merge(shadow.validate()?);
|
||||
} else {
|
||||
result.errors.push(ValidationError {
|
||||
code: "REF_RESOLUTION_FAILED".to_string(),
|
||||
code: "INHERITANCE_RESOLUTION_FAILED".to_string(),
|
||||
message: format!(
|
||||
"Reference pointer to '{}' was not found in schema registry",
|
||||
ref_str
|
||||
"Inherited entity pointer '{}' was not found in schema registry",
|
||||
t
|
||||
),
|
||||
path: self.path.to_string(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user