diff --git a/fixtures/properties.json b/fixtures/properties.json index ceb7b3f..f7637f5 100644 --- a/fixtures/properties.json +++ b/fixtures/properties.json @@ -873,5 +873,77 @@ } } ] + }, + { + "description": "immutable property validation in request vs response context", + "database": { + "types": [ + { + "name": "item", + "schemas": { + "save_item.request": { + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "immutable": true + } + } + }, + "get_item.response": { + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "immutable": true + } + } + } + } + } + ] + }, + "tests": [ + { + "description": "immutable property in request context causes IMMUTABLE_PROPERTY_VIOLATION", + "data": { + "id": "123", + "created_at": "2026-07-21T00:00:00Z" + }, + "schema_id": "save_item.request", + "action": "validate", + "expect": { + "success": false, + "errors": [ + { + "code": "IMMUTABLE_PROPERTY_VIOLATION", + "values": { + "property_name": "created_at" + }, + "details": { + "path": "created_at", + "schema": "save_item.request" + } + } + ] + } + }, + { + "description": "immutable property in response context is allowed", + "data": { + "id": "123", + "created_at": "2026-07-21T00:00:00Z" + }, + "schema_id": "get_item.response", + "action": "validate", + "expect": { + "success": true + } + } + ] } ] \ No newline at end of file diff --git a/src/database/object.rs b/src/database/object.rs index 5540ca1..fcb883a 100644 --- a/src/database/object.rs +++ b/src/database/object.rs @@ -149,6 +149,9 @@ pub struct SchemaObject { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub extensible: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub immutable: Option, // Contains ALL structural fields perfectly flattened from the ENTIRE Database inheritance tree (e.g. `entity` fields like `id`) as well as local fields hidden inside conditional `cases` blocks. // This JSON exported array gives clients absolute deterministic visibility to O(1) validation and masking bounds without duplicating structural memory. @@ -306,4 +309,8 @@ impl SchemaObject { false } + + pub fn is_immutable(&self) -> bool { + self.immutable == Some(true) + } } diff --git a/src/merger/mod.rs b/src/merger/mod.rs index 027e72a..4e5ae68 100644 --- a/src/merger/mod.rs +++ b/src/merger/mod.rs @@ -48,47 +48,7 @@ impl Merger { let val_resolved = match result { Ok(val) => val, - Err(msg) => { - let mut final_code = "MERGE_FAILED".to_string(); - let mut final_message = msg.clone(); - let mut final_cause = None; - - if let Ok(Value::Object(map)) = serde_json::from_str::(&msg) { - if let (Some(Value::String(e_msg)), Some(Value::String(e_code))) = - (map.get("error"), map.get("code")) - { - final_message = e_msg.clone(); - final_code = e_code.clone(); - let mut cause_parts = Vec::new(); - if let Some(Value::String(d)) = map.get("detail") { - if !d.is_empty() { - cause_parts.push(d.clone()); - } - } - if let Some(Value::String(h)) = map.get("hint") { - if !h.is_empty() { - cause_parts.push(h.clone()); - } - } - if !cause_parts.is_empty() { - final_cause = Some(cause_parts.join("\n")); - } - } - } - - return Drop::with_errors(vec![Error { - code: final_code, - values: Some(IndexMap::from([ - ("error".to_string(), final_message), - ])), - details: ErrorDetails { - path: None, - cause: final_cause, - context: None, - schema: None, - }, - }]); - } + Err(err) => return Drop::with_errors(vec![err]), }; // Execute the globally collected, pre-ordered notifications last! @@ -97,11 +57,11 @@ impl Merger { return Drop::with_errors(vec![Error { code: "MERGE_FAILED".to_string(), values: Some(IndexMap::from([ - ("error".to_string(), e.to_string()), + ("error".to_string(), e.clone()), ])), details: ErrorDetails { path: None, - cause: None, + cause: Some(e), context: None, schema: None, }, @@ -144,7 +104,7 @@ impl Merger { notifications: &mut Vec, parent_org_id: Option, is_child: bool, - ) -> Result { + ) -> Result { match data { Value::Array(items) => { self.merge_array(schema, items, notifications, parent_org_id, is_child) @@ -159,10 +119,14 @@ impl Merger { if let Some(target_schema) = self.db.schemas.get(target_id) { schema = target_schema.clone(); } else { - return Err(format!( - "Polymorphic mapped target '{}' not found in database registry", - target_id - )); + return Err(Error { + code: "TARGET_SCHEMA_NOT_FOUND".to_string(), + values: Some(IndexMap::from([("target_id".to_string(), target_id.clone())])), + details: ErrorDetails { + cause: Some(format!("Polymorphic mapped target '{}' not found in database registry", target_id)), + ..Default::default() + }, + }); } } else if let Some(idx) = idx_opt { if let Some(target_schema) = schema @@ -173,31 +137,60 @@ impl Merger { { schema = Arc::clone(target_schema); } else { - return Err(format!( - "Polymorphic index target '{}' not found in local oneOf array", - idx - )); + return Err(Error { + code: "ONE_OF_INDEX_NOT_FOUND".to_string(), + values: Some(IndexMap::from([("index".to_string(), idx.to_string())])), + details: ErrorDetails { + cause: Some(format!("Polymorphic index target '{}' not found in local oneOf array", idx)), + ..Default::default() + }, + }); } } else { - return Err(format!("Polymorphic mapped target has no path")); + return Err(Error { + code: "INVALID_POLYMORPHIC_TARGET".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Polymorphic mapped target has no path".to_string()), + ..Default::default() + }, + }); } } else { - return Err(format!( - "Polymorphic discriminator {}='{}' matched no compiled options", - disc, v - )); + return Err(Error { + code: "DISCRIMINATOR_MISMATCH".to_string(), + values: Some(IndexMap::from([ + ("discriminator".to_string(), disc.to_string()), + ("value".to_string(), v.to_string()), + ])), + details: ErrorDetails { + cause: Some(format!("Polymorphic discriminator {}='{}' matched no compiled options", disc, v)), + ..Default::default() + }, + }); } } else { - return Err(format!( - "Polymorphic merging failed: missing required discriminator '{}'", - disc - )); + return Err(Error { + code: "MISSING_DISCRIMINATOR".to_string(), + values: Some(IndexMap::from([("discriminator".to_string(), disc.to_string())])), + details: ErrorDetails { + cause: Some(format!("Polymorphic merging failed: missing required discriminator '{}'", disc)), + ..Default::default() + }, + }); } } } self.merge_object(schema, map, notifications, parent_org_id, is_child) } - _ => Err("Invalid merge payload: root must be an Object or Array".to_string()), + _ => Err(Error { + code: "INVALID_MERGE_PAYLOAD".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Invalid merge payload: root must be an Object or Array".to_string()), + ..Default::default() + }, + }), } } @@ -208,7 +201,7 @@ impl Merger { notifications: &mut Vec, parent_org_id: Option, is_child: bool, - ) -> Result { + ) -> Result { let mut item_schema = schema.clone(); if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ { if t == "array" { @@ -239,22 +232,49 @@ impl Merger { notifications: &mut Vec, parent_org_id: Option, is_child: bool, - ) -> Result { + ) -> Result { let queue_start = notifications.len(); let type_name = match obj.get("type").and_then(|v| v.as_str()) { Some(t) => t.to_string(), - None => return Err("Missing required 'type' field on object".to_string()), + None => { + return Err(Error { + code: "MISSING_TYPE".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Missing required 'type' field on object".to_string()), + ..Default::default() + }, + }); + } }; let type_def = match self.db.types.get(&type_name) { Some(t) => t, - None => return Err(format!("Unknown entity type: {}", type_name)), + None => { + return Err(Error { + code: "UNKNOWN_ENTITY_TYPE".to_string(), + values: Some(IndexMap::from([("type".to_string(), type_name.clone())])), + details: ErrorDetails { + cause: Some(format!("Unknown entity type: {}", type_name)), + ..Default::default() + }, + }); + } }; let compiled_props = match schema.obj.compiled_properties.get() { Some(props) => props, - None => return Err("Schema has no compiled properties for merging".to_string()), + None => { + return Err(Error { + code: "UNCOMPILED_SCHEMA".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Schema has no compiled properties for merging".to_string()), + ..Default::default() + }, + }); + } }; let mut entity_fields = serde_json::Map::new(); @@ -269,6 +289,10 @@ impl Merger { } if let Some(prop_schema) = compiled_props.get(&k) { + if prop_schema.is_immutable() { + continue; + } + let mut is_edge = false; if let Some(edges) = schema.obj.compiled_edges.get() { if edges.contains_key(&k) { @@ -312,8 +336,22 @@ impl Merger { current_org_id = parent_org_id.clone(); } - let user_id = self.db.auth_user_id()?; - let timestamp = self.db.timestamp()?; + let user_id = self.db.auth_user_id().map_err(|e| Error { + code: "AUTH_USER_FAILED".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(e), + ..Default::default() + }, + })?; + let timestamp = self.db.timestamp().map_err(|e| Error { + code: "TIMESTAMP_FAILED".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(e), + ..Default::default() + }, + })?; let mut entity_change_kind = None; let mut entity_fetched = None; @@ -328,6 +366,27 @@ impl Merger { entity_replaces = replaces; if entity_change_kind.as_deref() == Some("create") { + if let Some(deps) = &schema.obj.dependencies { + if let Some(crate::database::object::Dependency::Props(req_props)) = deps.get("created") { + for req in req_props { + if !entity_fields.contains_key(req) && !entity_objects.contains_key(req) && !entity_arrays.contains_key(req) { + return Err(Error { + code: "REQUIRED_FIELD_MISSING".to_string(), + values: Some(IndexMap::from([ + ("property_name".to_string(), req.to_string()), + ("entity_type".to_string(), type_name.clone()), + ])), + details: ErrorDetails { + path: Some(req.to_string()), + cause: Some(format!("Missing required creation field '{}' for entity {}", req, type_name)), + ..Default::default() + }, + }); + } + } + } + } + if is_child { if !entity_fields.contains_key("organization_id") { if let Some(ref org_id) = current_org_id { @@ -538,7 +597,7 @@ impl Merger { Option>, Option, ), - String, + Error, > { let type_name = type_def.name.as_str(); @@ -673,7 +732,7 @@ impl Merger { &self, entity_fields: &serde_json::Map, entity_type: &crate::database::r#type::Type, - ) -> Result>, String> { + ) -> Result>, Error> { let id_val = entity_fields.get("id"); let entity_type_name = entity_type.name.as_str(); @@ -764,22 +823,47 @@ impl Merger { let fetched = match self.db.query(&final_sql, None) { Ok(Value::Array(table)) => { if table.len() > 1 { - Err(format!( - "TOO_MANY_LOOKUP_ROWS: Lookup for {} found too many existing rows", - entity_type_name - )) + Err(Error { + code: "TOO_MANY_LOOKUP_ROWS".to_string(), + values: Some(IndexMap::from([("entity_type".to_string(), entity_type_name.to_string())])), + details: ErrorDetails { + cause: Some(format!("Lookup for {} found too many existing rows", entity_type_name)), + ..Default::default() + }, + }) } else if table.is_empty() { Ok(None) } else { let row = table.first().unwrap(); match row { Value::Object(map) => Ok(Some(map.clone())), - other => Err(format!("Expected JSON object, got: {:?}", other)), + other => Err(Error { + code: "UNEXPECTED_QUERY_RESULT".to_string(), + values: None, + details: ErrorDetails { + cause: Some(format!("Expected JSON object, got: {:?}", other)), + ..Default::default() + }, + }), } } } - Ok(_) => Err("Expected array from query in fetch_entity".to_string()), - Err(e) => Err(format!("SPI error in fetch_entity: {:?}", e)), + Ok(_) => Err(Error { + code: "UNEXPECTED_QUERY_RESULT".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Expected array from query in fetch_entity".to_string()), + ..Default::default() + }, + }), + Err(e) => Err(Error { + code: "DATABASE_SPI_ERROR".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(format!("SPI error in fetch_entity: {:?}", e)), + ..Default::default() + }, + }), }?; Ok(fetched) @@ -792,23 +876,36 @@ impl Merger { entity_type: &crate::database::r#type::Type, entity_fields: &serde_json::Map, _entity_fetched: Option<&serde_json::Map>, - ) -> Result<(), String> { + ) -> Result<(), Error> { if change_kind.is_empty() { return Ok(()); } let id_str = match entity_fields.get("id").and_then(|v| v.as_str()) { Some(id) => id, - None => return Err("Missing 'id' for merge execution".to_string()), + None => { + return Err(Error { + code: "MISSING_ENTITY_ID".to_string(), + values: None, + details: ErrorDetails { + cause: Some("Missing 'id' for merge execution".to_string()), + ..Default::default() + }, + }); + } }; let grouped_fields = match &entity_type.grouped_fields { Some(Value::Object(map)) => map, _ => { - return Err(format!( - "Grouped fields missing for type {}", - entity_type_name - )); + return Err(Error { + code: "MISSING_GROUPED_FIELDS".to_string(), + values: Some(IndexMap::from([("type".to_string(), entity_type_name.to_string())])), + details: ErrorDetails { + cause: Some(format!("Grouped fields missing for type {}", entity_type_name)), + ..Default::default() + }, + }); } }; @@ -861,7 +958,16 @@ impl Merger { columns.join(", "), values.join(", ") ); - self.db.execute(&sql, None)?; + if let Err(e) = self.db.execute(&sql, None) { + return Err(Error { + code: "DATABASE_SPI_ERROR".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(e), + ..Default::default() + }, + }); + } } else if change_kind == "update" || change_kind == "delete" { entity_pairs.remove("id"); entity_pairs.remove("type"); @@ -893,7 +999,16 @@ impl Merger { set_clauses.join(", "), Self::quote_literal(&Value::String(id_str.to_string())) ); - self.db.execute(&sql, None)?; + if let Err(e) = self.db.execute(&sql, None) { + return Err(Error { + code: "DATABASE_SPI_ERROR".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(e), + ..Default::default() + }, + }); + } } } @@ -909,7 +1024,7 @@ impl Merger { user_id: &str, timestamp: &str, replaces_id: Option<&str>, - ) -> Result, String> { + ) -> Result, Error> { let change_kind = match entity_change_kind { Some(k) => k, None => return Ok(None), @@ -1002,7 +1117,16 @@ impl Merger { Self::quote_literal(&Value::String(user_id.to_string())) ); - self.db.execute(&change_sql, None)?; + if let Err(e) = self.db.execute(&change_sql, None) { + return Err(Error { + code: "DATABASE_SPI_ERROR".to_string(), + values: Some(IndexMap::from([("error".to_string(), e.clone())])), + details: ErrorDetails { + cause: Some(e), + ..Default::default() + }, + }); + } } if type_obj.notify { diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index 649ffbb..a0efae1 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -2519,6 +2519,18 @@ fn test_properties_12_0() { crate::tests::runner::run_test_case(&path, 12, 0).unwrap(); } +#[test] +fn test_properties_13_0() { + let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 13, 0).unwrap(); +} + +#[test] +fn test_properties_13_1() { + let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 13, 1).unwrap(); +} + #[test] fn test_max_contains_0_0() { let path = format!("{}/fixtures/maxContains.json", env!("CARGO_MANIFEST_DIR")); diff --git a/src/validator/context.rs b/src/validator/context.rs index 94fa39a..4991b65 100644 --- a/src/validator/context.rs +++ b/src/validator/context.rs @@ -16,6 +16,7 @@ pub struct ValidationContext<'a> { pub reporter: bool, pub overrides: HashSet, pub parents: Vec<&'a serde_json::Value>, + pub response: bool, } impl<'a> ValidationContext<'a> { @@ -27,6 +28,7 @@ impl<'a> ValidationContext<'a> { overrides: HashSet, extensible: bool, reporter: bool, + response: bool, ) -> Self { let effective_extensible = schema.extensible.unwrap_or(extensible); Self { @@ -40,6 +42,7 @@ impl<'a> ValidationContext<'a> { reporter, overrides, parents: Vec::new(), + response, } } @@ -79,6 +82,7 @@ impl<'a> ValidationContext<'a> { reporter, overrides, parents, + response: self.response, } } diff --git a/src/validator/mod.rs b/src/validator/mod.rs index 3e82064..58b682b 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -48,6 +48,7 @@ impl Validator { let schema_opt = self.db.schemas.get(schema_id); if let Some(schema) = schema_opt { + let response = schema_id.ends_with(".response"); let ctx = ValidationContext::new( &self.db, &schema, @@ -56,6 +57,7 @@ impl Validator { HashSet::new(), false, false, + response, ); match ctx.validate_scoped() { Ok(result) => { diff --git a/src/validator/rules/object.rs b/src/validator/rules/object.rs index e7d1675..08ea9fe 100644 --- a/src/validator/rules/object.rs +++ b/src/validator/rules/object.rs @@ -179,6 +179,34 @@ impl<'a> ValidationContext<'a> { } } + if !self.response { + if let Some(compiled_props) = self.schema.compiled_properties.get() { + for (key, sub_schema) in compiled_props { + if sub_schema.is_immutable() && obj.contains_key(key) { + result.errors.push(ValidationError { + code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(), + values: Some(IndexMap::from([ + ("property_name".to_string(), key.to_string()), + ])), + path: self.join_path(key), + }); + } + } + } else if let Some(props) = &self.schema.properties { + for (key, sub_schema) in props { + if sub_schema.is_immutable() && obj.contains_key(key) { + result.errors.push(ValidationError { + code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(), + values: Some(IndexMap::from([ + ("property_name".to_string(), key.to_string()), + ])), + path: self.join_path(key), + }); + } + } + } + } + if let Some(props) = &self.schema.properties { for (key, sub_schema) in props { if self.overrides.contains(key) { @@ -228,6 +256,7 @@ impl<'a> ValidationContext<'a> { HashSet::new(), self.extensible, self.reporter, + self.response, ); result.merge(ctx.validate()?);