implemented type match checking for types on schema id instead of type const

This commit is contained in:
2025-09-12 01:02:32 -04:00
parent 704770051c
commit bb84f9aa73
3 changed files with 163 additions and 9 deletions

View File

@ -9,7 +9,7 @@ use std::borrow::Cow;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::{collections::HashMap, sync::RwLock}; use std::{collections::HashMap, sync::RwLock};
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq)]
enum SchemaType { enum SchemaType {
Enum, Enum,
Type, Type,
@ -20,6 +20,7 @@ enum SchemaType {
struct BoonCache { struct BoonCache {
schemas: Schemas, schemas: Schemas,
id_to_index: HashMap<String, SchemaIndex>, id_to_index: HashMap<String, SchemaIndex>,
id_to_type: HashMap<String, SchemaType>,
} }
// Structure to hold error information without lifetimes // Structure to hold error information without lifetimes
@ -35,6 +36,7 @@ lazy_static! {
static ref SCHEMA_CACHE: RwLock<BoonCache> = RwLock::new(BoonCache { static ref SCHEMA_CACHE: RwLock<BoonCache> = RwLock::new(BoonCache {
schemas: Schemas::new(), schemas: Schemas::new(),
id_to_index: HashMap::new(), id_to_index: HashMap::new(),
id_to_type: HashMap::new(),
}); });
} }
@ -49,6 +51,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB {
*cache = BoonCache { *cache = BoonCache {
schemas: Schemas::new(), schemas: Schemas::new(),
id_to_index: HashMap::new(), id_to_index: HashMap::new(),
id_to_type: HashMap::new(),
}; };
// Create the boon compiler and enable format assertions // Create the boon compiler and enable format assertions
@ -85,6 +88,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB {
})); }));
} else { } else {
all_schema_ids.push(schema_id.to_string()); all_schema_ids.push(schema_id.to_string());
cache.id_to_type.insert(schema_id.to_string(), SchemaType::Enum);
} }
} }
} }
@ -119,6 +123,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB {
})); }));
} else { } else {
all_schema_ids.push(schema_id.to_string()); all_schema_ids.push(schema_id.to_string());
cache.id_to_type.insert(schema_id.to_string(), SchemaType::Type);
} }
} }
} }
@ -166,6 +171,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB {
})); }));
} else { } else {
all_schema_ids.push(schema_id.to_string()); all_schema_ids.push(schema_id.to_string());
cache.id_to_type.insert(schema_id.to_string(), schema_type_for_def);
} }
} }
} }
@ -322,6 +328,47 @@ fn apply_strict_validation_recursive(schema: &mut Value, inside_conditional: boo
} }
} }
fn validate_type_against_schema_id(instance: &Value, schema_id: &str) -> JsonB {
let expected_type = schema_id.split('.').next().unwrap_or(schema_id);
if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) {
if actual_type == expected_type {
return JsonB(json!({ "response": "success" }));
}
}
// If we reach here, validation failed. Now we build the specific error.
let (message, cause, context) =
if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) {
// This handles the case where the type is a string but doesn't match.
(
format!("Instance type '{}' does not match expected type '{}' derived from schema ID", actual_type, expected_type),
json!({ "expected": expected_type, "actual": actual_type }),
json!(actual_type)
)
} else {
// This handles the case where 'type' is missing or not a string.
(
"Instance 'type' property is missing or not a string".to_string(),
json!("The 'type' property must be a string and is required for this validation."),
instance.get("type").unwrap_or(&Value::Null).clone()
)
};
JsonB(json!({
"errors": [{
"code": "TYPE_MISMATCH",
"message": message,
"details": {
"path": "/type",
"context": context,
"cause": cause,
"schema": schema_id
}
}]
}))
}
#[pg_extern(strict, parallel_safe)] #[pg_extern(strict, parallel_safe)]
fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB { fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB {
let cache = SCHEMA_CACHE.read().unwrap(); let cache = SCHEMA_CACHE.read().unwrap();
@ -340,7 +387,16 @@ fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB {
Some(sch_index) => { Some(sch_index) => {
let instance_value: Value = instance.0; let instance_value: Value = instance.0;
match cache.schemas.validate(&instance_value, *sch_index) { match cache.schemas.validate(&instance_value, *sch_index) {
Ok(_) => JsonB(json!({ "response": "success" })), Ok(_) => {
// After standard validation, perform custom type check if it's a Type schema
if let Some(&schema_type) = cache.id_to_type.get(schema_id) {
if schema_type == SchemaType::Type {
return validate_type_against_schema_id(&instance_value, schema_id);
}
}
// For non-Type schemas, or if type not found (shouldn't happen), success.
JsonB(json!({ "response": "success" }))
}
Err(validation_error) => { Err(validation_error) => {
let mut error_list = Vec::new(); let mut error_list = Vec::new();
collect_errors(&validation_error, &mut error_list); collect_errors(&validation_error, &mut error_list);
@ -961,6 +1017,7 @@ fn clear_json_schemas() -> JsonB {
*cache = BoonCache { *cache = BoonCache {
schemas: Schemas::new(), schemas: Schemas::new(),
id_to_index: HashMap::new(), id_to_index: HashMap::new(),
id_to_type: HashMap::new(),
}; };
JsonB(json!({ "response": "success" })) JsonB(json!({ "response": "success" }))
} }

View File

@ -761,3 +761,45 @@ pub fn title_override_schemas() -> JsonB {
cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs))
} }
pub fn type_matching_schemas() -> JsonB {
let enums = json!([]);
let types = json!([
{
"name": "entity",
"schemas": [{
"$id": "entity",
"type": "object",
"properties": { "type": { "type": "string" }, "name": { "type": "string" } },
"required": ["type", "name"]
}]
},
{
"name": "job",
"schemas": [{
"$id": "job",
"$ref": "entity",
"properties": { "job_id": { "type": "string" } },
"required": ["job_id"]
}]
},
{
"name": "super_job",
"schemas": [
{
"$id": "super_job",
"$ref": "job",
"properties": { "manager_id": { "type": "string" } },
"required": ["manager_id"]
},
{
"$id": "super_job.short",
"$ref": "super_job",
"properties": { "name": { "maxLength": 10 } }
}
]
}
]);
let puncs = json!([]);
cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs))
}

View File

@ -506,6 +506,7 @@ fn test_validate_property_merging() {
// entity (id, name) + user (password) + person (first_name, last_name) // entity (id, name) + user (password) + person (first_name, last_name)
let valid_person_with_all_properties = json!({ let valid_person_with_all_properties = json!({
"type": "person", // Added to satisfy new type check
// From entity // From entity
"id": "550e8400-e29b-41d4-a716-446655440000", "id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe", "name": "John Doe",
@ -523,6 +524,7 @@ fn test_validate_property_merging() {
// Test that properties validate according to their schema definitions across the chain // Test that properties validate according to their schema definitions across the chain
let invalid_mixed_properties = json!({ let invalid_mixed_properties = json!({
"type": "person", // Added to satisfy new type check
"id": "550e8400-e29b-41d4-a716-446655440000", "id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe", "name": "John Doe",
"password": "short", // Too short from user schema "password": "short", // Too short from user schema
@ -546,15 +548,13 @@ fn test_validate_required_merging() {
// user: ["password"] (conditional when type=user) // user: ["password"] (conditional when type=user)
// person: ["first_name", "last_name"] (conditional when type=person) // person: ["first_name", "last_name"] (conditional when type=person)
let missing_all_required = json!({}); let missing_all_required = json!({ "type": "person" }); // Add type to pass initial check
let result = validate_json_schema("person", jsonb(missing_all_required)); let result = validate_json_schema("person", jsonb(missing_all_required));
// Should fail for all required fields across inheritance chain // Should fail for all required fields across inheritance chain, except for the conditional 'password'
assert_error_count(&result, 6); // id, type, created_by, password, first_name, last_name assert_error_count(&result, 4); // id, created_by, first_name, last_name
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/id"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/id");
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/type");
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/created_by"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/created_by");
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/password");
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/first_name"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/first_name");
assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/last_name"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/last_name");
@ -777,14 +777,69 @@ fn test_validate_title_override() {
// Test that a schema with an overridden title still inherits validation keywords correctly. // Test that a schema with an overridden title still inherits validation keywords correctly.
// This instance is valid because it provides the 'name' required by the base schema. // This instance is valid because it provides the 'name' required by the base schema.
let valid_instance = json!({ "name": "Test Name" }); let valid_instance = json!({ "type": "override_with_title", "name": "Test Name" });
let result_valid = validate_json_schema("override_with_title", jsonb(valid_instance)); let result_valid = validate_json_schema("override_with_title", jsonb(valid_instance));
assert_success(&result_valid); assert_success(&result_valid);
// This instance is invalid because it's missing the 'name' required by the base schema. // This instance is invalid because it's missing the 'name' required by the base schema.
// This proves that validation keywords are inherited even when metadata keywords are overridden. // This proves that validation keywords are inherited even when metadata keywords are overridden.
let invalid_instance = json!({}); let invalid_instance = json!({ "type": "override_with_title" });
let result_invalid = validate_json_schema("override_with_title", jsonb(invalid_instance)); let result_invalid = validate_json_schema("override_with_title", jsonb(invalid_instance));
assert_error_count(&result_invalid, 1); assert_error_count(&result_invalid, 1);
assert_has_error(&result_invalid, "REQUIRED_FIELD_MISSING", "/name"); assert_has_error(&result_invalid, "REQUIRED_FIELD_MISSING", "/name");
}
#[pg_test]
fn test_validate_type_matching() {
let cache_result = type_matching_schemas();
assert_success(&cache_result);
// 1. Test 'job' which extends 'entity'
let valid_job = json!({
"type": "job",
"name": "my job",
"job_id": "job123"
});
let result_valid_job = validate_json_schema("job", jsonb(valid_job));
assert_success(&result_valid_job);
let invalid_job = json!({
"type": "not_job",
"name": "my job",
"job_id": "job123"
});
let result_invalid_job = validate_json_schema("job", jsonb(invalid_job));
assert_error_count(&result_invalid_job, 1);
assert_has_error(&result_invalid_job, "TYPE_MISMATCH", "/type");
// 2. Test 'super_job' which extends 'job'
let valid_super_job = json!({
"type": "super_job",
"name": "my super job",
"job_id": "job123",
"manager_id": "mgr1"
});
let result_valid_super_job = validate_json_schema("super_job", jsonb(valid_super_job));
assert_success(&result_valid_super_job);
// 3. Test 'super_job.short' which should still expect type 'super_job'
let valid_short_super_job = json!({
"type": "super_job",
"name": "short", // maxLength: 10
"job_id": "job123",
"manager_id": "mgr1"
});
let result_valid_short = validate_json_schema("super_job.short", jsonb(valid_short_super_job));
assert_success(&result_valid_short);
let invalid_short_super_job = json!({
"type": "job", // Should be 'super_job'
"name": "short",
"job_id": "job123",
"manager_id": "mgr1"
});
let result_invalid_short = validate_json_schema("super_job.short", jsonb(invalid_short_super_job));
assert_error_count(&result_invalid_short, 1);
let error = find_error_with_code_and_path(&result_invalid_short, "TYPE_MISMATCH", "/type");
assert_error_message_contains(error, "Instance type 'job' does not match expected type 'super_job'");
} }