From c5e103c86712d1af7d9bd342bd4f5775e28ada81 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Mon, 3 Aug 2026 12:03:03 -0400 Subject: [PATCH] immutable tri-state --- fixtures/properties.json | 69 +++++++++++++++++++++++++++++++++- src/database/executors/mock.rs | 18 +++++++++ src/database/executors/mod.rs | 3 ++ src/database/executors/pgrx.rs | 22 +++++++++++ src/database/object.rs | 20 ++++++++-- src/merger/mod.rs | 6 ++- src/tests/fixtures.rs | 12 ++++++ src/validator/rules/object.rs | 7 +++- 8 files changed, 149 insertions(+), 8 deletions(-) diff --git a/fixtures/properties.json b/fixtures/properties.json index f7637f5..d003d1f 100644 --- a/fixtures/properties.json +++ b/fixtures/properties.json @@ -888,7 +888,7 @@ }, "created_at": { "type": "string", - "immutable": true + "immutable": "always" } } }, @@ -899,7 +899,7 @@ }, "created_at": { "type": "string", - "immutable": true + "immutable": "always" } } } @@ -945,5 +945,70 @@ } } ] + }, + { + "description": "immutable external vs always property validation", + "database": { + "types": [ + { + "name": "invoice", + "schemas": { + "save_invoice.request": { + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "immutable": "external" + }, + "created_at": { + "type": "string", + "immutable": "always" + } + } + } + } + } + ] + }, + "tests": [ + { + "description": "immutable external property in request context allowed when internal (punc.external = false)", + "data": { + "id": "123", + "status": "paid" + }, + "schema_id": "save_invoice.request", + "action": "validate", + "expect": { + "success": true + } + }, + { + "description": "immutable always property in request context rejected even when internal (punc.external = false)", + "data": { + "id": "123", + "created_at": "2026-07-21T00:00:00Z" + }, + "schema_id": "save_invoice.request", + "action": "validate", + "expect": { + "success": false, + "errors": [ + { + "code": "IMMUTABLE_PROPERTY_VIOLATION", + "values": { + "property_name": "created_at" + }, + "details": { + "path": "created_at", + "schema": "save_invoice.request" + } + } + ] + } + } + ] } ] \ No newline at end of file diff --git a/src/database/executors/mock.rs b/src/database/executors/mock.rs index 4a037b6..f044ed8 100644 --- a/src/database/executors/mock.rs +++ b/src/database/executors/mock.rs @@ -13,6 +13,7 @@ pub struct MockState { pub query_responses: Vec>, pub execute_responses: Vec>, pub mocks: Vec, + pub punc_external: bool, } #[cfg(test)] @@ -23,10 +24,12 @@ impl MockState { query_responses: Default::default(), execute_responses: Default::default(), mocks: Default::default(), + punc_external: false, } } } + #[cfg(test)] thread_local! { pub static MOCK_STATE: RefCell = RefCell::new(MockState::new()); @@ -85,6 +88,10 @@ impl DatabaseExecutor for MockExecutor { Ok("2026-03-10T00:00:00Z".to_string()) } + fn punc_external(&self) -> Result { + Ok(MOCK_STATE.with(|state| state.borrow().punc_external)) + } + #[cfg(test)] fn get_queries(&self) -> Vec { MOCK_STATE.with(|state| state.borrow().captured_queries.clone()) @@ -105,10 +112,21 @@ impl DatabaseExecutor for MockExecutor { s.query_responses.clear(); s.execute_responses.clear(); s.mocks.clear(); + s.punc_external = false; }); } } +#[cfg(test)] +impl MockExecutor { + pub fn set_punc_external(&self, external: bool) { + MOCK_STATE.with(|state| { + state.borrow_mut().punc_external = external; + }); + } +} + + #[cfg(test)] fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option> { let sql_upper = sql.to_uppercase(); diff --git a/src/database/executors/mod.rs b/src/database/executors/mod.rs index 3923d13..e7f9b88 100644 --- a/src/database/executors/mod.rs +++ b/src/database/executors/mod.rs @@ -20,6 +20,9 @@ pub trait DatabaseExecutor: Send + Sync { /// Returns the current transaction timestamp fn timestamp(&self) -> Result; + /// Returns true if the current execution context is marked as an external client API cue (punc.external = true) + fn punc_external(&self) -> Result; + #[cfg(test)] fn get_queries(&self) -> Vec; diff --git a/src/database/executors/pgrx.rs b/src/database/executors/pgrx.rs index e348bd5..1552003 100644 --- a/src/database/executors/pgrx.rs +++ b/src/database/executors/pgrx.rs @@ -150,4 +150,26 @@ impl DatabaseExecutor for SpiExecutor { }) }) } + + fn punc_external(&self) -> Result { + self.transact(|| { + Spi::connect(|client| { + let mut tup_table = client + .select( + "SELECT COALESCE(current_setting('punc.external', true), 'false')::boolean", + None, + &[], + ) + .map_err(|e| format!("SPI Select Error: {}", e))?; + + let row = tup_table + .next() + .ok_or("No setting returned from context".to_string())?; + let is_external: Option = row.get(1).map_err(|e| e.to_string())?; + + Ok(is_external.unwrap_or(false)) + }) + }) + } } + diff --git a/src/database/object.rs b/src/database/object.rs index fcb883a..89a5fa9 100644 --- a/src/database/object.rs +++ b/src/database/object.rs @@ -151,7 +151,8 @@ pub struct SchemaObject { pub extensible: Option, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub immutable: Option, + 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. @@ -263,6 +264,13 @@ pub fn is_primitive_type(t: &str) -> bool { ) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImmutableMode { + Always, + External, +} + impl SchemaObject { pub fn get_discriminator_value(&self, dim: &str, schema_id: &str) -> Option { let is_split = self @@ -310,7 +318,13 @@ impl SchemaObject { false } - pub fn is_immutable(&self) -> bool { - self.immutable == Some(true) + pub fn is_immutable(&self, is_external: bool) -> bool { + match self.immutable { + Some(ImmutableMode::Always) => true, + Some(ImmutableMode::External) => is_external, + None => false, + } } } + + diff --git a/src/merger/mod.rs b/src/merger/mod.rs index 4e5ae68..7631795 100644 --- a/src/merger/mod.rs +++ b/src/merger/mod.rs @@ -281,6 +281,8 @@ impl Merger { let mut entity_objects = std::collections::BTreeMap::new(); let mut entity_arrays = std::collections::BTreeMap::new(); + let is_external = self.db.executor.punc_external().unwrap_or(false); + for (k, v) in obj { // Always retain system and unmapped core fields natively implicitly mapped to the Postgres tables if k == "id" || k == "type" || k == "created" { @@ -289,10 +291,12 @@ impl Merger { } if let Some(prop_schema) = compiled_props.get(&k) { - if prop_schema.is_immutable() { + if prop_schema.is_immutable(is_external) { continue; } + + let mut is_edge = false; if let Some(edges) = schema.obj.compiled_edges.get() { if edges.contains_key(&k) { diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index a0efae1..a423ede 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -2531,6 +2531,18 @@ fn test_properties_13_1() { crate::tests::runner::run_test_case(&path, 13, 1).unwrap(); } +#[test] +fn test_properties_14_0() { + let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 14, 0).unwrap(); +} + +#[test] +fn test_properties_14_1() { + let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 14, 1).unwrap(); +} + #[test] fn test_max_contains_0_0() { let path = format!("{}/fixtures/maxContains.json", env!("CARGO_MANIFEST_DIR")); diff --git a/src/validator/rules/object.rs b/src/validator/rules/object.rs index 08ea9fe..cd056e6 100644 --- a/src/validator/rules/object.rs +++ b/src/validator/rules/object.rs @@ -180,9 +180,10 @@ impl<'a> ValidationContext<'a> { } if !self.response { + let is_external = self.db.executor.punc_external().unwrap_or(false); 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) { + if sub_schema.is_immutable(is_external) && obj.contains_key(key) { result.errors.push(ValidationError { code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(), values: Some(IndexMap::from([ @@ -194,7 +195,7 @@ impl<'a> ValidationContext<'a> { } } else if let Some(props) = &self.schema.properties { for (key, sub_schema) in props { - if sub_schema.is_immutable() && obj.contains_key(key) { + if sub_schema.is_immutable(is_external) && obj.contains_key(key) { result.errors.push(ValidationError { code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(), values: Some(IndexMap::from([ @@ -207,6 +208,8 @@ impl<'a> ValidationContext<'a> { } } + + if let Some(props) = &self.schema.properties { for (key, sub_schema) in props { if self.overrides.contains(key) {