diff --git a/fixtures/database.json b/fixtures/database.json index 7b49053..823f6d3 100644 --- a/fixtures/database.json +++ b/fixtures/database.json @@ -651,16 +651,21 @@ "action": "compile", "expect": { "success": true, - "schemas": [ - "full.contact", - "full.person", - "full.person/ad_hoc_bubble", - "full.person/extended_relations", - "full.person/extended_relations/target", - "light.email_address", - "some_bubble", - "student.person" - ] + "schemas": { + "full.contact": {}, + "full.contact.filter": {}, + "full.person": {}, + "full.person.filter": {}, + "full.person/ad_hoc_bubble": {}, + "full.person/extended_relations": {}, + "full.person/extended_relations/target": {}, + "light.email_address": {}, + "light.email_address.filter": {}, + "some_bubble": {}, + "some_bubble.filter": {}, + "student.person": {}, + "student.person.filter": {} + } } } ] @@ -919,11 +924,14 @@ "action": "compile", "expect": { "success": true, - "schemas": [ - "entity", - "invoice", - "invoice_line" - ] + "schemas": { + "entity": {}, + "entity.filter": {}, + "invoice": {}, + "invoice.filter": {}, + "invoice_line": {}, + "invoice_line.filter": {} + } } } ] diff --git a/fixtures/filter.json b/fixtures/filter.json new file mode 100644 index 0000000..11f5d5d --- /dev/null +++ b/fixtures/filter.json @@ -0,0 +1,222 @@ +[ + { + "description": "Filter Synthesis Object-Oriented Composition", + "database": { + "puncs": [], + "enums": [], + "relations": [ + { + "id": "rel1", + "type": "relation", + "constraint": "fk_person_billing_address", + "source_type": "person", + "source_columns": [ + "billing_address_id" + ], + "destination_type": "address", + "destination_columns": [ + "id" + ], + "prefix": "billing_address" + } + ], + "types": [ + { + "id": "type1", + "type": "type", + "name": "person", + "module": "core", + "source": "person", + "hierarchy": [ + "person" + ], + "variations": [ + "person" + ], + "schemas": { + "person": { + "type": "object", + "properties": { + "first_name": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "billing_address": { + "type": "address" + }, + "birth_date": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "ad_hoc": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + } + } + } + }, + { + "id": "type2", + "type": "type", + "name": "address", + "module": "core", + "source": "address", + "hierarchy": [ + "address" + ], + "variations": [ + "address" + ], + "schemas": { + "address": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + } + } + } + }, + { + "id": "type3", + "type": "type", + "name": "filter", + "module": "core", + "source": "filter", + "hierarchy": [ + "filter" + ], + "variations": [ + "filter", + "string.condition", + "integer.condition", + "date.condition" + ], + "schemas": { + "condition": { + "type": "object", + "properties": { + "kind": { + "type": "string" + } + } + }, + "string.condition": { + "type": "condition", + "properties": { + "$eq": { + "type": [ + "string", + "null" + ] + } + } + }, + "integer.condition": { + "type": "condition", + "properties": { + "$eq": { + "type": [ + "integer", + "null" + ] + } + } + }, + "date.condition": { + "type": "condition", + "properties": { + "$eq": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + ] + }, + "tests": [ + { + "description": "Assert filter generation map accurately represents strongly typed conditions natively.", + "action": "compile", + "expect": { + "success": true, + "schemas": { + "person": {}, + "person.filter": { + "type": "object", + "compiledPropertyNames": [ + "age", + "billing_address", + "birth_date", + "first_name" + ], + "properties": { + "first_name": { + "type": [ + "string.condition", + "null" + ] + }, + "age": { + "type": [ + "integer.condition", + "null" + ] + }, + "billing_address": { + "type": [ + "address.filter", + "null" + ] + }, + "birth_date": { + "type": [ + "date.condition", + "null" + ] + } + } + }, + "address": {}, + "address.filter": { + "type": "object", + "compiledPropertyNames": [ + "city" + ], + "properties": { + "city": { + "type": [ + "string.condition", + "null" + ] + } + } + }, + "condition": {}, + "string.condition": {}, + "integer.condition": {}, + "date.condition": {} + } + } + } + ] + } +] \ No newline at end of file diff --git a/src/database/compile/filters.rs b/src/database/compile/filters.rs new file mode 100644 index 0000000..5a38c85 --- /dev/null +++ b/src/database/compile/filters.rs @@ -0,0 +1,78 @@ +use crate::database::object::{SchemaObject, SchemaTypeOrArray}; +use crate::database::schema::Schema; +use crate::database::Database; +use std::collections::BTreeMap; +use std::sync::Arc; + +impl Schema { + pub fn compile_filter( + &self, + _db: &Database, + _root_id: &str, + _errors: &mut Vec, + ) -> Option { + if let Some(props) = self.obj.compiled_properties.get() { + let mut filter_props = BTreeMap::new(); + for (key, child) in props { + if let Some(mut filter_type) = Self::resolve_filter_type(child) { + filter_type.push("null".to_string()); + + let mut child_obj = SchemaObject::default(); + child_obj.type_ = Some(SchemaTypeOrArray::Multiple(filter_type)); + + filter_props.insert(key.clone(), Arc::new(Schema { obj: child_obj, always_fail: false })); + } + } + + if !filter_props.is_empty() { + let mut wrapper_obj = SchemaObject::default(); + wrapper_obj.type_ = Some(SchemaTypeOrArray::Single("object".to_string())); + wrapper_obj.properties = Some(filter_props); + + return Some(Schema { obj: wrapper_obj, always_fail: false }); + } + } + None + } + + fn resolve_filter_type(schema: &Arc) -> Option> { + if let Some(type_) = &schema.obj.type_ { + match type_ { + SchemaTypeOrArray::Single(t) => { + return Self::map_filter_string(t, schema); + } + SchemaTypeOrArray::Multiple(types) => { + for t in types { + if t != "null" { + return Self::map_filter_string(t, schema); + } + } + } + } + } + None + } + + fn map_filter_string(t: &str, schema: &Arc) -> Option> { + match t { + "string" => { + if let Some(fmt) = &schema.obj.format { + if fmt == "date-time" { + return Some(vec!["date.condition".to_string()]); + } + } + Some(vec!["string.condition".to_string()]) + } + "integer" => Some(vec!["integer.condition".to_string()]), + "number" => Some(vec!["number.condition".to_string()]), + "boolean" => Some(vec!["boolean.condition".to_string()]), + "object" => None, // Inline structures are ignored in Composed References + "array" => None, // We don't filter primitive arrays or map complex arrays yet + "null" => None, + custom => { + // Assume anything else is a Relational cross-boundary that already has its own .filter dynamically built + Some(vec![format!("{}.filter", custom)]) + } + } + } +} diff --git a/src/database/compile/mod.rs b/src/database/compile/mod.rs index a3661c0..d54fc0f 100644 --- a/src/database/compile/mod.rs +++ b/src/database/compile/mod.rs @@ -1,5 +1,6 @@ pub mod collection; pub mod edges; +pub mod filters; pub mod polymorphism; use crate::database::schema::Schema; diff --git a/src/database/mod.rs b/src/database/mod.rs index 9ee7de4..0696433 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,4 @@ +pub mod compile; pub mod edge; pub mod r#enum; pub mod executors; @@ -8,7 +9,6 @@ pub mod punc; pub mod relation; pub mod schema; pub mod r#type; -pub mod compile; // External mock exports inside the executor sub-folder @@ -210,6 +210,7 @@ impl Database { } pub fn compile(&mut self, errors: &mut Vec) { + // Collect existing schemas patched in the databse let mut harvested = Vec::new(); for (id, schema_arc) in &self.schemas { crate::database::schema::Schema::collect_schemas( @@ -234,6 +235,37 @@ impl Database { .as_ref() .compile(self, root_id, id.clone(), errors); } + + // Phase 2: Synthesize Composed Filter References + let mut filter_schemas = Vec::new(); + for type_def in self.types.values() { + for (id, schema_arc) in &type_def.schemas { + // Only run synthesis on actual structured, table-backed boundaries. Exclude subschemas! + let base_name = id.split('.').last().unwrap_or(id); + let is_table_backed = base_name == type_def.name; + if is_table_backed && !id.contains('/') { + if let Some(filter_schema) = schema_arc.compile_filter(self, id, errors) { + filter_schemas.push((format!("{}.filter", id), Arc::new(filter_schema))); + } + } + } + } + + let mut filter_ids = Vec::new(); + for (id, filter_arc) in filter_schemas { + filter_ids.push(id.clone()); + self.schemas.insert(id, filter_arc); + } + + // Now actively compile the newly injected filters to lock all nested compose references natively + for id in filter_ids { + if let Some(filter_arc) = self.schemas.get(&id).cloned() { + let root_id = id.split('/').next().unwrap_or(&id); + filter_arc + .as_ref() + .compile(self, root_id, id.clone(), errors); + } + } } fn collect_schemas(&mut self, errors: &mut Vec) { diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index 727f2c6..8899f3c 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -533,6 +533,12 @@ fn test_unique_items_6_1() { crate::tests::runner::run_test_case(&path, 6, 1).unwrap(); } +#[test] +fn test_filter_0_0() { + let path = format!("{}/fixtures/filter.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 0).unwrap(); +} + #[test] fn test_min_items_0_0() { let path = format!("{}/fixtures/minItems.json", env!("CARGO_MANIFEST_DIR")); diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 58499cb..d911e2c 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -107,12 +107,28 @@ fn test_library_api() { } } }, + "source_schema.filter": { + "type": "object", + "properties": { + "type": { "type": ["string.condition", "null"] }, + "name": { "type": ["string.condition", "null"] }, + "target": { "type": ["target_schema.filter", "null"] } + }, + "compiledPropertyNames": ["name", "target", "type"] + }, "target_schema": { "type": "object", "properties": { "value": { "type": "number" } }, "compiledPropertyNames": ["value"] + }, + "target_schema.filter": { + "type": "object", + "properties": { + "value": { "type": ["number.condition", "null"] } + }, + "compiledPropertyNames": ["value"] } } }) diff --git a/src/tests/types/expect/mod.rs b/src/tests/types/expect/mod.rs index 744e43a..c093a49 100644 --- a/src/tests/types/expect/mod.rs +++ b/src/tests/types/expect/mod.rs @@ -20,5 +20,5 @@ pub struct Expect { #[serde(default)] pub sql: Option>, #[serde(default)] - pub schemas: Option>, + pub schemas: Option>, } diff --git a/src/tests/types/expect/schema.rs b/src/tests/types/expect/schema.rs index 807c17d..1e74183 100644 --- a/src/tests/types/expect/schema.rs +++ b/src/tests/types/expect/schema.rs @@ -3,13 +3,13 @@ use std::sync::Arc; impl Expect { pub fn assert_schemas(&self, db: &Arc) -> Result<(), String> { - if let Some(expected_schemas) = &self.schemas { + if let Some(expected_map) = &self.schemas { // Collect actual schemas and sort let mut actual: Vec = db.schemas.keys().cloned().collect(); actual.sort(); // Collect expected schemas and sort - let mut expected: Vec = expected_schemas.clone(); + let mut expected: Vec = expected_map.keys().cloned().collect(); expected.sort(); if actual != expected { @@ -21,6 +21,23 @@ impl Expect { actual )); } + + for (key, expected_val) in expected_map { + if expected_val.is_object() && expected_val.as_object().unwrap().is_empty() { + continue; // A `{}` means we just wanted to test it was collected/promoted, skip deep match + } + let actual_ast = db.schemas.get(key).unwrap(); + let actual_val = serde_json::to_value(actual_ast).unwrap(); + + if actual_val != *expected_val { + return Err(format!( + "Detailed Schema Match Failure for '{}'!\n\nExpected:\n{}\n\nActual:\n{}", + key, + serde_json::to_string_pretty(expected_val).unwrap(), + serde_json::to_string_pretty(&actual_val).unwrap() + )); + } + } } Ok(()) }