diff --git a/GEMINI.md b/GEMINI.md index 1c9127e..edaabe6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -240,7 +240,7 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, desig * **Type Casting**: Safely resolves dynamic combinations by casting values instantly into the physical database types mapped in the schema (e.g. parsing `uuid` bindings to `::uuid`, formatting DateTimes to `::timestamptz`, and numbers to `::numeric`). * **Polymorphic SQL Generation (`$family`)**: Compiles `$family` properties by analyzing the **Physical Database Variations**, *not* the schema descendants. * **The Dot Convention**: When a schema requests `$family: "target.schema"`, the compiler extracts the base type (e.g. `schema`) and looks up its Physical Table definition. - * **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into `JOIN`s for each variation. + * **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products. * **Single-Table Bypass**: If the Physical Table is a leaf node with only one variation (e.g. `person` has variations `["person"]`), the compiler cleanly bypasses `CASE` generation and compiles a simple `SELECT` across the base table, as all schema extensions (e.g. `light.person`, `full.person`) are guaranteed to reside in the exact same physical row. --- diff --git a/fixtures/queryer.json b/fixtures/queryer.json index e6e440b..71c2a8f 100644 --- a/fixtures/queryer.json +++ b/fixtures/queryer.json @@ -25,19 +25,19 @@ ] }, { - "name": "get_light_organizations", + "name": "get_light_organization", "schemas": [ { - "$id": "get_light_organizations.response", + "$id": "get_light_organization.response", "$family": "light.organization" } ] }, { - "name": "get_full_organizations", + "name": "get_full_organization", "schemas": [ { - "$id": "get_full_organizations.response", + "$id": "get_full_organization.response", "$family": "full.organization" } ] @@ -897,11 +897,27 @@ }, { "name": "widget", - "hierarchy": ["widget", "entity"], - "fields": ["id", "type", "kind", "archived", "created_at"], + "hierarchy": [ + "widget", + "entity" + ], + "fields": [ + "id", + "type", + "kind", + "archived", + "created_at" + ], "grouped_fields": { - "entity": ["id", "type", "archived", "created_at"], - "widget": ["kind"] + "entity": [ + "id", + "type", + "archived", + "created_at" + ], + "widget": [ + "kind" + ] }, "field_types": { "id": "uuid", @@ -910,27 +926,35 @@ "archived": "boolean", "created_at": "timestamptz" }, - "variations": ["widget"], + "variations": [ + "widget" + ], "schemas": [ { "$id": "widget", "type": "entity", "properties": { - "kind": { "type": "string" } + "kind": { + "type": "string" + } } }, { "$id": "stock.widget", "type": "widget", "properties": { - "kind": { "const": "stock" } + "kind": { + "const": "stock" + } } }, { "$id": "tasks.widget", "type": "widget", "properties": { - "kind": { "const": "tasks" } + "kind": { + "const": "tasks" + } } } ] @@ -1597,50 +1621,60 @@ "success": true, "sql": [ [ - "(SELECT jsonb_strip_nulls((SELECT COALESCE(jsonb_agg(jsonb_build_object(", - " 'id', organization_1.id,", - " 'type', CASE", - " WHEN organization_1.type = 'bot' THEN", - " ((SELECT jsonb_build_object(", - " 'archived', entity_5.archived,", - " 'created_at', entity_5.created_at,", - " 'id', entity_5.id,", - " 'name', organization_4.name,", - " 'token', bot_3.token,", - " 'type', entity_5.type", - " )", - " FROM agreego.bot bot_3", - " JOIN agreego.organization organization_4 ON organization_4.id = bot_3.id", - " JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id", - " WHERE NOT entity_5.archived))", - " WHEN organization_1.type = 'organization' THEN", - " ((SELECT jsonb_build_object(", - " 'archived', entity_7.archived,", - " 'created_at', entity_7.created_at,", - " 'id', entity_7.id,", - " 'name', organization_6.name,", - " 'type', entity_7.type", - " )", - " FROM agreego.organization organization_6", - " JOIN agreego.entity entity_7 ON entity_7.id = organization_6.id", - " WHERE NOT entity_7.archived))", - " WHEN organization_1.type = 'person' THEN", - " ((SELECT jsonb_build_object(", - " 'age', person_8.age,", - " 'archived', entity_10.archived,", - " 'created_at', entity_10.created_at,", - " 'first_name', person_8.first_name,", - " 'id', entity_10.id,", - " 'last_name', person_8.last_name,", - " 'name', organization_9.name,", - " 'type', entity_10.type", - " )", - " FROM agreego.person person_8", - " JOIN agreego.organization organization_9 ON organization_9.id = person_8.id", - " JOIN agreego.entity entity_10 ON entity_10.id = organization_9.id", - " WHERE NOT entity_10.archived))", - " ELSE NULL END", - ")), '[]'::jsonb)", + "(SELECT jsonb_strip_nulls((SELECT COALESCE(jsonb_agg(", + " CASE", + " WHEN organization_1.type = 'bot' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_5.archived,", + " 'created_at', entity_5.created_at,", + " 'id', entity_5.id,", + " 'name', organization_4.name,", + " 'role', bot_3.role,", + " 'token', bot_3.token,", + " 'type', entity_5.type", + " )", + " FROM agreego.bot bot_3", + " JOIN agreego.organization organization_4 ON organization_4.id = bot_3.id", + " JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id", + " WHERE", + " NOT entity_5.archived", + " AND entity_5.id = entity_2.id)", + " )", + " WHEN organization_1.type = 'organization' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_7.archived,", + " 'created_at', entity_7.created_at,", + " 'id', entity_7.id,", + " 'name', organization_6.name,", + " 'type', entity_7.type", + " )", + " FROM agreego.organization organization_6", + " JOIN agreego.entity entity_7 ON entity_7.id = organization_6.id", + " WHERE", + " NOT entity_7.archived", + " AND entity_7.id = entity_2.id)", + " )", + " WHEN organization_1.type = 'person' THEN (", + " (SELECT jsonb_build_object(", + " 'age', person_8.age,", + " 'archived', entity_10.archived,", + " 'created_at', entity_10.created_at,", + " 'first_name', person_8.first_name,", + " 'id', entity_10.id,", + " 'last_name', person_8.last_name,", + " 'name', organization_9.name,", + " 'type', entity_10.type", + " )", + " FROM agreego.person person_8", + " JOIN agreego.organization organization_9 ON organization_9.id = person_8.id", + " JOIN agreego.entity entity_10 ON entity_10.id = organization_9.id", + " WHERE", + " NOT entity_10.archived", + " AND entity_10.id = entity_2.id)", + " )", + " ELSE NULL", + " END", + "), '[]'::jsonb)", "FROM agreego.organization organization_1", "JOIN agreego.entity entity_2 ON entity_2.id = organization_1.id", "WHERE NOT entity_2.archived)))" @@ -1651,12 +1685,47 @@ { "description": "Light organizations select via a punc response with family", "action": "query", - "schema_id": "get_light_organizations.response", + "schema_id": "get_light_organization.response", "expect": { "success": true, "sql": [ [ - "FIX ME" + "(SELECT jsonb_strip_nulls((SELECT ", + " CASE", + " WHEN organization_1.type = 'bot' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_5.archived,", + " 'created_at', entity_5.created_at,", + " 'id', entity_5.id,", + " 'name', organization_4.name,", + " 'token', bot_3.token,", + " 'type', entity_5.type", + " )", + " FROM agreego.bot bot_3", + " JOIN agreego.organization organization_4 ON organization_4.id = bot_3.id", + " JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id", + " WHERE NOT entity_5.archived AND entity_5.id = entity_2.id)", + " )", + " WHEN organization_1.type = 'person' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_8.archived,", + " 'created_at', entity_8.created_at,", + " 'first_name', person_6.first_name,", + " 'id', entity_8.id,", + " 'last_name', person_6.last_name,", + " 'name', organization_7.name,", + " 'type', entity_8.type", + " )", + " FROM agreego.person person_6", + " JOIN agreego.organization organization_7 ON organization_7.id = person_6.id", + " JOIN agreego.entity entity_8 ON entity_8.id = organization_7.id", + " WHERE NOT entity_8.archived AND entity_8.id = entity_2.id)", + " )", + " ELSE NULL", + " END", + "FROM agreego.organization organization_1", + "JOIN agreego.entity entity_2 ON entity_2.id = organization_1.id", + "WHERE NOT entity_2.archived)))" ] ] } @@ -1664,123 +1733,176 @@ { "description": "Full organizations select via a punc response with family", "action": "query", - "schema_id": "get_full_organizations.response", + "schema_id": "get_full_organization.response", "expect": { "success": true, "sql": [ [ - "(SELECT jsonb_strip_nulls((SELECT jsonb_build_object(", - " 'addresses', (SELECT COALESCE(jsonb_agg(jsonb_build_object(", + "(SELECT jsonb_strip_nulls((SELECT CASE", + " WHEN organization_1.type = 'person' THEN (", + " (SELECT jsonb_build_object(", + " 'addresses',", + " (SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'archived', entity_8.archived,", + " 'created_at', entity_8.created_at,", + " 'id', entity_8.id,", + " 'is_primary', contact_6.is_primary,", + " 'target',", + " (SELECT jsonb_build_object(", + " 'archived', entity_10.archived,", + " 'city', address_9.city,", + " 'created_at', entity_10.created_at,", + " 'id', entity_10.id,", + " 'type', entity_10.type", + " )", + " FROM agreego.address address_9", + " JOIN agreego.entity entity_10 ON entity_10.id = address_9.id", + " WHERE", + " NOT entity_10.archived", + " AND relationship_7.target_id = entity_10.id),", + " 'type', entity_8.type", + " )), '[]'::jsonb)", + " FROM agreego.contact contact_6", + " JOIN agreego.relationship relationship_7 ON relationship_7.id = contact_6.id", + " JOIN agreego.entity entity_8 ON entity_8.id = relationship_7.id", + " WHERE", + " NOT entity_8.archived", + " AND relationship_7.target_type = 'address'", + " AND relationship_7.source_id = entity_5.id),", + " 'age', person_3.age,", " 'archived', entity_5.archived,", + " 'contacts',", + " (SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'archived', entity_13.archived,", + " 'created_at', entity_13.created_at,", + " 'id', entity_13.id,", + " 'is_primary', contact_11.is_primary,", + " 'target',", + " CASE", + " WHEN entity_13.target_type = 'address' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_15.archived,", + " 'city', address_14.city,", + " 'created_at', entity_15.created_at,", + " 'id', entity_15.id,", + " 'type', entity_15.type", + " )", + " FROM agreego.address address_14", + " JOIN agreego.entity entity_15 ON entity_15.id = address_14.id", + " WHERE", + " NOT entity_15.archived", + " AND relationship_12.target_id = entity_15.id)", + " )", + " WHEN entity_13.target_type = 'email_address' THEN (", + " (SELECT jsonb_build_object(", + " 'address', email_address_16.address,", + " 'archived', entity_17.archived,", + " 'created_at', entity_17.created_at,", + " 'id', entity_17.id,", + " 'type', entity_17.type", + " )", + " FROM agreego.email_address email_address_16", + " JOIN agreego.entity entity_17 ON entity_17.id = email_address_16.id", + " WHERE", + " NOT entity_17.archived", + " AND relationship_12.target_id = entity_17.id)", + " )", + " WHEN entity_13.target_type = 'phone_number' THEN (", + " (SELECT jsonb_build_object(", + " 'archived', entity_19.archived,", + " 'created_at', entity_19.created_at,", + " 'id', entity_19.id,", + " 'number', phone_number_18.number,", + " 'type', entity_19.type", + " )", + " FROM agreego.phone_number phone_number_18", + " JOIN agreego.entity entity_19 ON entity_19.id = phone_number_18.id", + " WHERE", + " NOT entity_19.archived", + " AND relationship_12.target_id = entity_19.id)", + " )", + " ELSE NULL", + " END,", + " 'type', entity_13.type", + " )), '[]'::jsonb)", + " FROM agreego.contact contact_11", + " JOIN agreego.relationship relationship_12 ON relationship_12.id = contact_11.id", + " JOIN agreego.entity entity_13 ON entity_13.id = relationship_12.id", + " WHERE", + " NOT entity_13.archived", + " AND relationship_12.source_id = entity_5.id),", " 'created_at', entity_5.created_at,", + " 'email_addresses',", + " (SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'archived', entity_22.archived,", + " 'created_at', entity_22.created_at,", + " 'id', entity_22.id,", + " 'is_primary', contact_20.is_primary,", + " 'target',", + " (SELECT jsonb_build_object(", + " 'address', email_address_23.address,", + " 'archived', entity_24.archived,", + " 'created_at', entity_24.created_at,", + " 'id', entity_24.id,", + " 'type', entity_24.type", + " )", + " FROM agreego.email_address email_address_23", + " JOIN agreego.entity entity_24 ON entity_24.id = email_address_23.id", + " WHERE", + " NOT entity_24.archived", + " AND relationship_21.target_id = entity_24.id),", + " 'type', entity_22.type", + " )), '[]'::jsonb)", + " FROM agreego.contact contact_20", + " JOIN agreego.relationship relationship_21 ON relationship_21.id = contact_20.id", + " JOIN agreego.entity entity_22 ON entity_22.id = relationship_21.id", + " WHERE", + " NOT entity_22.archived", + " AND relationship_21.target_type = 'email_address'", + " AND relationship_21.source_id = entity_5.id),", + " 'first_name', person_3.first_name,", " 'id', entity_5.id,", - " 'is_primary', contact_3.is_primary,", - " 'target', (SELECT jsonb_build_object(", - " 'archived', entity_7.archived,", - " 'city', address_6.city,", - " 'created_at', entity_7.created_at,", - " 'id', entity_7.id,", - " 'type', entity_7.type", - " )", - " FROM agreego.address address_6", - " JOIN agreego.entity entity_7 ON entity_7.id = address_6.id", - " WHERE NOT entity_7.archived AND relationship_4.target_id = entity_7.id),", + " 'last_name', person_3.last_name,", + " 'name', organization_4.name,", + " 'phone_numbers',", + " (SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'archived', entity_27.archived,", + " 'created_at', entity_27.created_at,", + " 'id', entity_27.id,", + " 'is_primary', contact_25.is_primary,", + " 'target',", + " (SELECT jsonb_build_object(", + " 'archived', entity_29.archived,", + " 'created_at', entity_29.created_at,", + " 'id', entity_29.id,", + " 'number', phone_number_28.number,", + " 'type', entity_29.type", + " )", + " FROM agreego.phone_number phone_number_28", + " JOIN agreego.entity entity_29 ON entity_29.id = phone_number_28.id", + " WHERE", + " NOT entity_29.archived", + " AND relationship_26.target_id = entity_29.id),", + " 'type', entity_27.type", + " )), '[]'::jsonb)", + " FROM agreego.contact contact_25", + " JOIN agreego.relationship relationship_26 ON relationship_26.id = contact_25.id", + " JOIN agreego.entity entity_27 ON entity_27.id = relationship_26.id", + " WHERE", + " NOT entity_27.archived", + " AND relationship_26.target_type = 'phone_number'", + " AND relationship_26.source_id = entity_5.id),", " 'type', entity_5.type", - " )), '[]'::jsonb)", - " FROM agreego.contact contact_3", - " JOIN agreego.relationship relationship_4 ON relationship_4.id = contact_3.id", - " JOIN agreego.entity entity_5 ON entity_5.id = relationship_4.id", - " WHERE NOT entity_5.archived AND relationship_4.target_type = 'address' AND relationship_4.source_id = entity_2.id),", - " 'archived', entity_2.archived,", - " 'contacts', (SELECT COALESCE(jsonb_agg(jsonb_build_object(", - " 'archived', entity_10.archived,", - " 'created_at', entity_10.created_at,", - " 'id', entity_10.id,", - " 'is_primary', contact_8.is_primary,", - " 'target', CASE WHEN entity_10.target_type = 'address' THEN ((SELECT jsonb_build_object(", - " 'archived', entity_12.archived,", - " 'city', address_11.city,", - " 'created_at', entity_12.created_at,", - " 'id', entity_12.id,", - " 'type', entity_12.type", - " )", - " FROM agreego.address address_11", - " JOIN agreego.entity entity_12 ON entity_12.id = address_11.id", - " WHERE NOT entity_12.archived AND relationship_9.target_id = entity_12.id)) WHEN entity_10.target_type = 'email_address' THEN ((SELECT jsonb_build_object(", - " 'address', email_address_13.address,", - " 'archived', entity_14.archived,", - " 'created_at', entity_14.created_at,", - " 'id', entity_14.id,", - " 'type', entity_14.type", - " )", - " FROM agreego.email_address email_address_13", - " JOIN agreego.entity entity_14 ON entity_14.id = email_address_13.id", - " WHERE NOT entity_14.archived AND relationship_9.target_id = entity_14.id)) WHEN entity_10.target_type = 'phone_number' THEN ((SELECT jsonb_build_object(", - " 'archived', entity_16.archived,", - " 'created_at', entity_16.created_at,", - " 'id', entity_16.id,", - " 'number', phone_number_15.number,", - " 'type', entity_16.type", - " )", - " FROM agreego.phone_number phone_number_15", - " JOIN agreego.entity entity_16 ON entity_16.id = phone_number_15.id", - " WHERE NOT entity_16.archived AND relationship_9.target_id = entity_16.id)) ELSE NULL END,", - " 'type', entity_10.type", - " )), '[]'::jsonb)", - " FROM agreego.contact contact_8", - " JOIN agreego.relationship relationship_9 ON relationship_9.id = contact_8.id", - " JOIN agreego.entity entity_10 ON entity_10.id = relationship_9.id", - " WHERE NOT entity_10.archived AND relationship_9.source_id = entity_2.id),", - " 'created_at', entity_2.created_at,", - " 'email_addresses', (SELECT COALESCE(jsonb_agg(jsonb_build_object(", - " 'archived', entity_19.archived,", - " 'created_at', entity_19.created_at,", - " 'id', entity_19.id,", - " 'is_primary', contact_17.is_primary,", - " 'target', (SELECT jsonb_build_object(", - " 'address', email_address_20.address,", - " 'archived', entity_21.archived,", - " 'created_at', entity_21.created_at,", - " 'id', entity_21.id,", - " 'type', entity_21.type", - " )", - " FROM agreego.email_address email_address_20", - " JOIN agreego.entity entity_21 ON entity_21.id = email_address_20.id", - " WHERE NOT entity_21.archived AND relationship_18.target_id = entity_21.id),", - " 'type', entity_19.type", - " )), '[]'::jsonb)", - " FROM agreego.contact contact_17", - " JOIN agreego.relationship relationship_18 ON relationship_18.id = contact_17.id", - " JOIN agreego.entity entity_19 ON entity_19.id = relationship_18.id", - " WHERE NOT entity_19.archived AND relationship_18.target_type = 'email_address' AND relationship_18.source_id = entity_2.id),", - " 'id', entity_2.id,", - " 'name', organization_1.name,", - " 'phone_numbers', (SELECT COALESCE(jsonb_agg(jsonb_build_object(", - " 'archived', entity_24.archived,", - " 'created_at', entity_24.created_at,", - " 'id', entity_24.id,", - " 'is_primary', contact_22.is_primary,", - " 'target', (SELECT jsonb_build_object(", - " 'archived', entity_26.archived,", - " 'created_at', entity_26.created_at,", - " 'id', entity_26.id,", - " 'number', phone_number_25.number,", - " 'type', entity_26.type", - " )", - " FROM agreego.phone_number phone_number_25", - " JOIN agreego.entity entity_26 ON entity_26.id = phone_number_25.id", - " WHERE NOT entity_26.archived AND relationship_23.target_id = entity_26.id),", - " 'type', entity_24.type", - " )), '[]'::jsonb)", - " FROM agreego.contact contact_22", - " JOIN agreego.relationship relationship_23 ON relationship_23.id = contact_22.id", - " JOIN agreego.entity entity_24 ON entity_24.id = relationship_23.id", - " WHERE NOT entity_24.archived AND relationship_23.target_type = 'phone_number' AND relationship_23.source_id = entity_2.id),", - " 'type', entity_2.type", - ")))", + " )", + " FROM agreego.person person_3", + " JOIN agreego.organization organization_4 ON organization_4.id = person_3.id", + " JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id", + " WHERE NOT entity_5.archived AND entity_5.id = entity_2.id))", + " ELSE NULL", + "END", "FROM agreego.organization organization_1", "JOIN agreego.entity entity_2 ON entity_2.id = organization_1.id", - "WHERE NOT entity_2.archived)" + "WHERE NOT entity_2.archived)))" ] ] } @@ -1833,7 +1955,32 @@ "success": true, "sql": [ [ - "FIX ME" + "(SELECT jsonb_strip_nulls((SELECT COALESCE(jsonb_agg(", + " CASE", + " WHEN widget_1.kind = 'stock' THEN (", + " jsonb_build_object(", + " 'archived', entity_2.archived,", + " 'created_at', entity_2.created_at,", + " 'id', entity_2.id,", + " 'kind', widget_1.kind,", + " 'type', entity_2.type", + " )", + " )", + " WHEN widget_1.kind = 'tasks' THEN (", + " jsonb_build_object(", + " 'archived', entity_2.archived,", + " 'created_at', entity_2.created_at,", + " 'id', entity_2.id,", + " 'kind', widget_1.kind,", + " 'type', entity_2.type", + " )", + " )", + " ELSE NULL", + " END", + "), '[]'::jsonb)", + "FROM agreego.widget widget_1", + "JOIN agreego.entity entity_2 ON entity_2.id = widget_1.id", + "WHERE NOT entity_2.archived)))" ] ] } diff --git a/src/database/mod.rs b/src/database/mod.rs index d2cf48c..fc881f3 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -4,6 +4,7 @@ pub mod executors; pub mod formats; pub mod page; pub mod punc; +pub mod object; pub mod relation; pub mod schema; pub mod r#type; @@ -23,7 +24,8 @@ use punc::Punc; use relation::Relation; use schema::Schema; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +use std::sync::Arc; use r#type::Type; pub struct Database { @@ -31,8 +33,7 @@ pub struct Database { pub types: HashMap, pub puncs: HashMap, pub relations: HashMap, - pub schemas: HashMap, - pub depths: HashMap, + pub schemas: HashMap>, pub executor: Box, } @@ -44,7 +45,6 @@ impl Database { relations: HashMap::new(), puncs: HashMap::new(), schemas: HashMap::new(), - depths: HashMap::new(), #[cfg(not(test))] executor: Box::new(SpiExecutor::new()), #[cfg(test)] @@ -135,7 +135,7 @@ impl Database { .clone() .unwrap_or_else(|| format!("schema_{}", i)); schema.obj.id = Some(id.clone()); - db.schemas.insert(id, schema); + db.schemas.insert(id, Arc::new(schema)); } Err(e) => { errors.push(crate::drop::Error { @@ -185,18 +185,21 @@ impl Database { pub fn compile(&mut self, errors: &mut Vec) { let mut harvested = Vec::new(); - for schema in self.schemas.values_mut() { - schema.collect_schemas(None, &mut harvested, errors); + for schema_arc in self.schemas.values_mut() { + if let Some(s) = std::sync::Arc::get_mut(schema_arc) { + s.collect_schemas(None, &mut harvested, errors); + } + } + for (id, schema) in harvested { + self.schemas.insert(id, Arc::new(schema)); } - self.schemas.extend(harvested); self.collect_schemas(errors); - self.collect_depths(); // Mathematically evaluate all property inheritances, formats, schemas, and foreign key edges topographically over OnceLocks let mut visited = std::collections::HashSet::new(); - for schema in self.schemas.values() { - schema.compile(self, &mut visited, errors); + for schema_arc in self.schemas.values() { + schema_arc.as_ref().compile(self, &mut visited, errors); } } @@ -222,35 +225,185 @@ impl Database { } for (id, schema) in to_insert { - self.schemas.insert(id, schema); + self.schemas.insert(id, Arc::new(schema)); } } - fn collect_depths(&mut self) { - let mut depths: HashMap = HashMap::new(); - let schema_ids: Vec = self.schemas.keys().cloned().collect(); + /// Inspects the Postgres pg_constraint relations catalog to securely identify + /// the precise Foreign Key connecting a parent and child hierarchy path. + pub fn resolve_relation<'a>( + &'a self, + parent_type: &str, + child_type: &str, + prop_name: &str, + relative_keys: Option<&Vec>, + is_array: bool, + schema_id: Option<&str>, + path: &str, + errors: &mut Vec, + ) -> Option<(&'a crate::database::relation::Relation, bool)> { + // Enforce graph locality by ensuring we don't accidentally crawl to pure structural entity boundaries + if parent_type == "entity" && child_type == "entity" { + return None; + } - for id in schema_ids { - let mut current_id = id.clone(); - let mut depth = 0; - let mut visited = HashSet::new(); + let p_def = self.types.get(parent_type)?; + let c_def = self.types.get(child_type)?; - while let Some(schema) = self.schemas.get(¤t_id) { - if !visited.insert(current_id.clone()) { - break; // Cycle detected + let mut matching_rels = Vec::new(); + let mut directions = Vec::new(); + + // Scour the complete catalog for any Edge matching the inheritance scope of the two objects + // This automatically binds polymorphic structures (e.g. recognizing a relationship targeting User + // also natively binds instances specifically typed as Person). + let mut all_rels: Vec<&crate::database::relation::Relation> = self.relations.values().collect(); + all_rels.sort_by(|a, b| a.constraint.cmp(&b.constraint)); + + for rel in all_rels { + let mut is_forward = + p_def.hierarchy.contains(&rel.source_type) && c_def.hierarchy.contains(&rel.destination_type); + let is_reverse = + p_def.hierarchy.contains(&rel.destination_type) && c_def.hierarchy.contains(&rel.source_type); + + // Structural Cardinality Filtration: + // If the schema requires a collection (Array), it is mathematically impossible for a pure + // Forward scalar edge (where the parent holds exactly one UUID pointer) to fulfill a One-to-Many request. + // Thus, if it's an array, we fully reject pure Forward edges and only accept Reverse edges (or Junction edges). + if is_array && is_forward && !is_reverse { + is_forward = false; + } + + if is_forward { + matching_rels.push(rel); + directions.push(true); + } else if is_reverse { + matching_rels.push(rel); + directions.push(false); + } + } + + // Abort relation discovery early if no hierarchical inheritance match was found + if matching_rels.is_empty() { + let mut details = crate::drop::ErrorDetails { + path: path.to_string(), + ..Default::default() + }; + if let Some(sid) = schema_id { + details.schema = Some(sid.to_string()); + } + + errors.push(crate::drop::Error { + code: "EDGE_MISSING".to_string(), + message: format!( + "No database relation exists between '{}' and '{}' for property '{}'", + parent_type, child_type, prop_name + ), + details, + }); + return None; + } + + // Ideal State: The objects only share a solitary structural relation, resolving ambiguity instantly. + if matching_rels.len() == 1 { + return Some((matching_rels[0], directions[0])); + } + + let mut chosen_idx = 0; + let mut resolved = false; + + // Exact Prefix Disambiguation: Determine if the database specifically names this constraint + // directly mapping to the JSON Schema property name (e.g., `fk_{child}_{property_name}`) + for (i, rel) in matching_rels.iter().enumerate() { + if let Some(prefix) = &rel.prefix { + if prop_name.starts_with(prefix) + || prefix.starts_with(prop_name) + || prefix.replace("_", "") == prop_name.replace("_", "") + { + chosen_idx = i; + resolved = true; + 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; + } + } + + // Complex Subgraph Resolution: The database contains multiple equally explicit foreign key constraints + // linking these objects (such as pointing to `source` and `target` in Many-to-Many junction models). + if !resolved && relative_keys.is_some() { + // Twin Deduction Pass 1: We inspect the exact properties structurally defined inside the compiled payload + // to observe which explicit relation arrow the child payload natively consumes. + let keys = relative_keys.unwrap(); + let mut consumed_rel_idx = None; + for (i, rel) in matching_rels.iter().enumerate() { + if let Some(prefix) = &rel.prefix { + if keys.contains(prefix) { + consumed_rel_idx = Some(i); + break; // Found the routing edge explicitly consumed by the schema payload } } - break; } - depths.insert(id, depth); - } - self.depths = depths; - } + // Twin Deduction Pass 2: Knowing which arrow points outbound, we can mathematically deduce its twin + // providing the reverse ownership on the same junction boundary must be the incoming Edge to the parent. + if let Some(used_idx) = consumed_rel_idx { + let used_rel = matching_rels[used_idx]; + let mut twin_ids = Vec::new(); + for (i, rel) in matching_rels.iter().enumerate() { + if i != used_idx + && rel.source_type == used_rel.source_type + && rel.destination_type == used_rel.destination_type + && rel.prefix.is_some() + { + twin_ids.push(i); + } + } + + if twin_ids.len() == 1 { + chosen_idx = twin_ids[0]; + resolved = true; + } + } + } + + // Implicit Base Fallback: If no complex explicit paths resolve, but exactly one relation + // sits entirely naked (without a constraint prefix), it must be the core structural parent ownership. + if !resolved { + let mut null_prefix_ids = Vec::new(); + for (i, rel) in matching_rels.iter().enumerate() { + if rel.prefix.is_none() { + null_prefix_ids.push(i); + } + } + if null_prefix_ids.len() == 1 { + chosen_idx = null_prefix_ids[0]; + resolved = true; + } + } + + // If we exhausted all mathematical deduction pathways and STILL cannot isolate a single edge, + // we must abort rather than silently guessing. Returning None prevents arbitrary SQL generation + // and forces a clean structural error for the architect. + if !resolved { + let mut details = crate::drop::ErrorDetails { + path: path.to_string(), + context: serde_json::to_value(&matching_rels).ok(), + cause: Some("Multiple conflicting constraints found matching prefixes".to_string()), + ..Default::default() + }; + if let Some(sid) = schema_id { + details.schema = Some(sid.to_string()); + } + + errors.push(crate::drop::Error { + code: "AMBIGUOUS_TYPE_RELATIONS".to_string(), + message: format!( + "Ambiguous database relation between '{}' and '{}' for property '{}'", + parent_type, child_type, prop_name + ), + details, + }); + return None; + } + + Some((matching_rels[chosen_idx], directions[chosen_idx])) + } } diff --git a/src/database/object.rs b/src/database/object.rs new file mode 100644 index 0000000..4d26214 --- /dev/null +++ b/src/database/object.rs @@ -0,0 +1,367 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::OnceLock; +use crate::database::schema::Schema; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Case { + #[serde(skip_serializing_if = "Option::is_none")] + pub when: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub then: Option>, + #[serde(rename = "else")] + #[serde(skip_serializing_if = "Option::is_none")] + pub else_: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default)] // Allow missing type + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub type_: Option, // Handles string or array of strings + + // Object Keywords + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>>, + #[serde(rename = "patternProperties")] + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern_properties: Option>>, + #[serde(rename = "additionalProperties")] + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + #[serde(rename = "$family")] + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + + // dependencies can be schema dependencies or property dependencies + #[serde(skip_serializing_if = "Option::is_none")] + pub dependencies: Option>, + + // Array Keywords + #[serde(rename = "items")] + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(rename = "prefixItems")] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix_items: Option>>, + + // String Validation + #[serde(rename = "minLength")] + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + #[serde(rename = "maxLength")] + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + + // Array Validation + #[serde(rename = "minItems")] + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(rename = "maxItems")] + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + #[serde(rename = "uniqueItems")] + #[serde(skip_serializing_if = "Option::is_none")] + pub unique_items: Option, + #[serde(rename = "contains")] + #[serde(skip_serializing_if = "Option::is_none")] + pub contains: Option>, + #[serde(rename = "minContains")] + #[serde(skip_serializing_if = "Option::is_none")] + pub min_contains: Option, + #[serde(rename = "maxContains")] + #[serde(skip_serializing_if = "Option::is_none")] + pub max_contains: Option, + + // Object Validation + #[serde(rename = "minProperties")] + #[serde(skip_serializing_if = "Option::is_none")] + pub min_properties: Option, + #[serde(rename = "maxProperties")] + #[serde(skip_serializing_if = "Option::is_none")] + pub max_properties: Option, + #[serde(rename = "propertyNames")] + #[serde(skip_serializing_if = "Option::is_none")] + pub property_names: Option>, + + // Numeric Validation + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(rename = "enum")] + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_: Option>, // `enum` is a reserved keyword in Rust + #[serde( + default, + rename = "const", + deserialize_with = "crate::database::object::deserialize_some" + )] + #[serde(skip_serializing_if = "Option::is_none")] + pub const_: Option, + + // Numeric Validation + #[serde(rename = "multipleOf")] + #[serde(skip_serializing_if = "Option::is_none")] + pub multiple_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + #[serde(rename = "exclusiveMinimum")] + #[serde(skip_serializing_if = "Option::is_none")] + pub exclusive_minimum: Option, + #[serde(rename = "exclusiveMaximum")] + #[serde(skip_serializing_if = "Option::is_none")] + pub exclusive_maximum: Option, + + // Combining Keywords + #[serde(skip_serializing_if = "Option::is_none")] + pub cases: Option>, + #[serde(rename = "oneOf")] + #[serde(skip_serializing_if = "Option::is_none")] + pub one_of: Option>>, + #[serde(rename = "not")] + #[serde(skip_serializing_if = "Option::is_none")] + pub not: Option>, + + // Custom Vocabularies + #[serde(skip_serializing_if = "Option::is_none")] + pub form: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub display: Option>, + #[serde(rename = "enumNames")] + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub control: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub computer: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub extensible: Option, + + #[serde(rename = "compiledProperties")] + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "crate::database::object::is_once_lock_vec_empty")] + #[serde(serialize_with = "crate::database::object::serialize_once_lock")] + pub compiled_property_names: OnceLock>, + + #[serde(skip)] + pub compiled_properties: OnceLock>>, + + #[serde(rename = "compiledDiscriminator")] + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "crate::database::object::is_once_lock_string_empty")] + #[serde(serialize_with = "crate::database::object::serialize_once_lock")] + pub compiled_discriminator: OnceLock, + + #[serde(rename = "compiledOptions")] + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")] + #[serde(serialize_with = "crate::database::object::serialize_once_lock")] + pub compiled_options: OnceLock>, + + #[serde(rename = "compiledEdges")] + #[serde(skip_deserializing)] + #[serde(skip_serializing_if = "crate::database::object::is_once_lock_map_empty")] + #[serde(serialize_with = "crate::database::object::serialize_once_lock")] + pub compiled_edges: OnceLock>, + + #[serde(skip)] + pub compiled_format: OnceLock, + #[serde(skip)] + pub compiled_pattern: OnceLock, + #[serde(skip)] + pub compiled_pattern_properties: OnceLock)>>, +} + +/// Represents a compiled format validator +#[derive(Clone)] +pub enum CompiledFormat { + Func(fn(&serde_json::Value) -> Result<(), Box>), + Regex(regex::Regex), +} + +impl std::fmt::Debug for CompiledFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompiledFormat::Func(_) => write!(f, "CompiledFormat::Func(...)"), + CompiledFormat::Regex(r) => write!(f, "CompiledFormat::Regex({:?})", r), + } + } +} + +/// A wrapper for compiled regex patterns +#[derive(Debug, Clone)] +pub struct CompiledRegex(pub regex::Regex); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SchemaTypeOrArray { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + #[serde(skip_serializing_if = "Option::is_none")] + pub navigate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub punc: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Dependency { + Props(Vec), + Schema(Arc), +} + +pub fn serialize_once_lock( + lock: &OnceLock, + serializer: S, +) -> Result { + if let Some(val) = lock.get() { + val.serialize(serializer) + } else { + serializer.serialize_none() + } +} + +pub fn is_once_lock_map_empty(lock: &OnceLock>) -> bool { + lock.get().map_or(true, |m| m.is_empty()) +} + +pub fn is_once_lock_vec_empty(lock: &OnceLock>) -> bool { + lock.get().map_or(true, |v| v.is_empty()) +} + +pub fn is_once_lock_string_empty(lock: &OnceLock) -> bool { + lock.get().map_or(true, |s| s.is_empty()) +} + +// Schema mirrors the Go Punc Generator's schema struct for consistency. +// It is an order-preserving representation of a JSON Schema. +pub fn deserialize_some<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v = Value::deserialize(deserializer)?; + Ok(Some(v)) +} + +pub fn is_primitive_type(t: &str) -> bool { + matches!( + t, + "string" | "number" | "integer" | "boolean" | "object" | "array" | "null" + ) +} + +impl SchemaObject { + pub fn identifier(&self) -> Option { + 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 + } + + pub fn get_discriminator_value(&self, dim: &str) -> Option { + let is_split = self + .compiled_properties + .get() + .map_or(false, |p| p.contains_key("kind")); + if let Some(id) = &self.id { + if id.contains("light.person") || id.contains("light.organization") { + println!( + "[DEBUG SPLIT] ID: {}, dim: {}, is_split: {:?}, props: {:?}", + id, + dim, + is_split, + self + .compiled_properties + .get() + .map(|p| p.keys().cloned().collect::>()) + ); + } + } + + if let Some(props) = self.compiled_properties.get() { + if let Some(prop_schema) = props.get(dim) { + if let Some(c) = &prop_schema.obj.const_ { + if let Some(s) = c.as_str() { + return Some(s.to_string()); + } + } + if let Some(e) = &prop_schema.obj.enum_ { + if e.len() == 1 { + if let Some(s) = e[0].as_str() { + return Some(s.to_string()); + } + } + } + } + } + + if dim == "kind" { + if let Some(id) = &self.id { + let base = id.split('/').last().unwrap_or(id); + if let Some(idx) = base.rfind('.') { + return Some(base[..idx].to_string()); + } + } + if let Some(SchemaTypeOrArray::Single(t)) = &self.type_ { + if !is_primitive_type(t) { + let base = t.split('/').last().unwrap_or(t); + if let Some(idx) = base.rfind('.') { + return Some(base[..idx].to_string()); + } + } + } + } + + if dim == "type" { + if let Some(id) = &self.id { + let base = id.split('/').last().unwrap_or(id); + if is_split { + return Some(base.split('.').next_back().unwrap_or(base).to_string()); + } else { + return Some(base.to_string()); + } + } + if let Some(SchemaTypeOrArray::Single(t)) = &self.type_ { + if !is_primitive_type(t) { + let base = t.split('/').last().unwrap_or(t); + if is_split { + return Some(base.split('.').next_back().unwrap_or(base).to_string()); + } else { + return Some(base.to_string()); + } + } + } + } + + None + } +} diff --git a/src/database/schema.rs b/src/database/schema.rs index 44e410a..f664c1f 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -1,256 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::OnceLock; - -pub fn serialize_once_lock( - lock: &OnceLock, - serializer: S, -) -> Result { - if let Some(val) = lock.get() { - val.serialize(serializer) - } else { - serializer.serialize_none() - } -} - -pub fn is_once_lock_map_empty(lock: &OnceLock>) -> bool { - lock.get().map_or(true, |m| m.is_empty()) -} - -pub fn is_once_lock_vec_empty(lock: &OnceLock>) -> bool { - lock.get().map_or(true, |v| v.is_empty()) -} - -pub fn is_once_lock_string_empty(lock: &OnceLock) -> bool { - lock.get().map_or(true, |s| s.is_empty()) -} - -// Schema mirrors the Go Punc Generator's schema struct for consistency. -// It is an order-preserving representation of a JSON Schema. -pub fn deserialize_some<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let v = Value::deserialize(deserializer)?; - 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>, - #[serde(skip_serializing_if = "Option::is_none")] - pub then: Option>, - #[serde(rename = "else")] - #[serde(skip_serializing_if = "Option::is_none")] - pub else_: Option>, -} - -#[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, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(default)] // Allow missing type - #[serde(rename = "type")] - #[serde(skip_serializing_if = "Option::is_none")] - pub type_: Option, // Handles string or array of strings - - // Object Keywords - #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>>, - #[serde(rename = "patternProperties")] - #[serde(skip_serializing_if = "Option::is_none")] - pub pattern_properties: Option>>, - #[serde(rename = "additionalProperties")] - #[serde(skip_serializing_if = "Option::is_none")] - pub additional_properties: Option>, - #[serde(rename = "$family")] - #[serde(skip_serializing_if = "Option::is_none")] - pub family: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, - - // dependencies can be schema dependencies or property dependencies - #[serde(skip_serializing_if = "Option::is_none")] - pub dependencies: Option>, - - // Array Keywords - #[serde(rename = "items")] - #[serde(skip_serializing_if = "Option::is_none")] - pub items: Option>, - #[serde(rename = "prefixItems")] - #[serde(skip_serializing_if = "Option::is_none")] - pub prefix_items: Option>>, - - // String Validation - #[serde(rename = "minLength")] - #[serde(skip_serializing_if = "Option::is_none")] - pub min_length: Option, - #[serde(rename = "maxLength")] - #[serde(skip_serializing_if = "Option::is_none")] - pub max_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pattern: Option, - - // Array Validation - #[serde(rename = "minItems")] - #[serde(skip_serializing_if = "Option::is_none")] - pub min_items: Option, - #[serde(rename = "maxItems")] - #[serde(skip_serializing_if = "Option::is_none")] - pub max_items: Option, - #[serde(rename = "uniqueItems")] - #[serde(skip_serializing_if = "Option::is_none")] - pub unique_items: Option, - #[serde(rename = "contains")] - #[serde(skip_serializing_if = "Option::is_none")] - pub contains: Option>, - #[serde(rename = "minContains")] - #[serde(skip_serializing_if = "Option::is_none")] - pub min_contains: Option, - #[serde(rename = "maxContains")] - #[serde(skip_serializing_if = "Option::is_none")] - pub max_contains: Option, - - // Object Validation - #[serde(rename = "minProperties")] - #[serde(skip_serializing_if = "Option::is_none")] - pub min_properties: Option, - #[serde(rename = "maxProperties")] - #[serde(skip_serializing_if = "Option::is_none")] - pub max_properties: Option, - #[serde(rename = "propertyNames")] - #[serde(skip_serializing_if = "Option::is_none")] - pub property_names: Option>, - - // Numeric Validation - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(rename = "enum")] - #[serde(skip_serializing_if = "Option::is_none")] - pub enum_: Option>, // `enum` is a reserved keyword in Rust - #[serde( - default, - rename = "const", - deserialize_with = "crate::database::schema::deserialize_some" - )] - #[serde(skip_serializing_if = "Option::is_none")] - pub const_: Option, - - // Numeric Validation - #[serde(rename = "multipleOf")] - #[serde(skip_serializing_if = "Option::is_none")] - pub multiple_of: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub minimum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub maximum: Option, - #[serde(rename = "exclusiveMinimum")] - #[serde(skip_serializing_if = "Option::is_none")] - pub exclusive_minimum: Option, - #[serde(rename = "exclusiveMaximum")] - #[serde(skip_serializing_if = "Option::is_none")] - pub exclusive_maximum: Option, - - // Combining Keywords - #[serde(skip_serializing_if = "Option::is_none")] - pub cases: Option>, - #[serde(rename = "oneOf")] - #[serde(skip_serializing_if = "Option::is_none")] - pub one_of: Option>>, - #[serde(rename = "not")] - #[serde(skip_serializing_if = "Option::is_none")] - pub not: Option>, - - // Custom Vocabularies - #[serde(skip_serializing_if = "Option::is_none")] - pub form: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub display: Option>, - #[serde(rename = "enumNames")] - #[serde(skip_serializing_if = "Option::is_none")] - pub enum_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub control: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub actions: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub computer: Option, - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub extensible: Option, - - #[serde(rename = "compiledProperties")] - #[serde(skip_deserializing)] - #[serde(skip_serializing_if = "crate::database::schema::is_once_lock_vec_empty")] - #[serde(serialize_with = "crate::database::schema::serialize_once_lock")] - pub compiled_property_names: OnceLock>, - - #[serde(skip)] - pub compiled_properties: OnceLock>>, - - #[serde(rename = "compiledDiscriminator")] - #[serde(skip_deserializing)] - #[serde(skip_serializing_if = "crate::database::schema::is_once_lock_string_empty")] - #[serde(serialize_with = "crate::database::schema::serialize_once_lock")] - pub compiled_discriminator: OnceLock, - - #[serde(rename = "compiledOptions")] - #[serde(skip_deserializing)] - #[serde(skip_serializing_if = "crate::database::schema::is_once_lock_map_empty")] - #[serde(serialize_with = "crate::database::schema::serialize_once_lock")] - pub compiled_options: OnceLock>, - - #[serde(rename = "compiledEdges")] - #[serde(skip_deserializing)] - #[serde(skip_serializing_if = "crate::database::schema::is_once_lock_map_empty")] - #[serde(serialize_with = "crate::database::schema::serialize_once_lock")] - pub compiled_edges: OnceLock>, - - #[serde(skip)] - pub compiled_format: OnceLock, - #[serde(skip)] - pub compiled_pattern: OnceLock, - #[serde(skip)] - pub compiled_pattern_properties: OnceLock)>>, -} - -/// Represents a compiled format validator -#[derive(Clone)] -pub enum CompiledFormat { - Func(fn(&serde_json::Value) -> Result<(), Box>), - Regex(regex::Regex), -} - -impl std::fmt::Debug for CompiledFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CompiledFormat::Func(_) => write!(f, "CompiledFormat::Func(...)"), - CompiledFormat::Regex(r) => write!(f, "CompiledFormat::Regex({:?})", r), - } - } -} - -/// A wrapper for compiled regex patterns -#[derive(Debug, Clone)] -pub struct CompiledRegex(pub regex::Regex); - +use crate::database::object::*; #[derive(Debug, Clone, Serialize, Default)] pub struct Schema { #[serde(flatten)] @@ -293,7 +44,7 @@ impl Schema { let _ = self .obj .compiled_format - .set(crate::database::schema::CompiledFormat::Func(fmt.func)); + .set(crate::database::object::CompiledFormat::Func(fmt.func)); } } @@ -302,7 +53,7 @@ impl Schema { let _ = self .obj .compiled_pattern - .set(crate::database::schema::CompiledRegex(re)); + .set(crate::database::object::CompiledRegex(re)); } } @@ -310,7 +61,7 @@ impl Schema { let mut compiled = Vec::new(); for (k, v) in pattern_props { if let Ok(re) = regex::Regex::new(k) { - compiled.push((crate::database::schema::CompiledRegex(re), v.clone())); + compiled.push((crate::database::object::CompiledRegex(re), v.clone())); } } if !compiled.is_empty() { @@ -321,10 +72,10 @@ impl Schema { let mut props = std::collections::BTreeMap::new(); // 1. Resolve INHERITANCE dependencies first - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { - if !crate::database::schema::is_primitive_type(t) { + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { + if !crate::database::object::is_primitive_type(t) { if let Some(parent) = db.schemas.get(t) { - parent.compile(db, visited, errors); + parent.as_ref().compile(db, visited, errors); if let Some(p_props) = parent.obj.compiled_properties.get() { props.extend(p_props.clone()); } @@ -332,10 +83,10 @@ impl Schema { } } - if let Some(crate::database::schema::SchemaTypeOrArray::Multiple(types)) = &self.obj.type_ { + if let Some(crate::database::object::SchemaTypeOrArray::Multiple(types)) = &self.obj.type_ { let mut custom_type_count = 0; for t in types { - if !crate::database::schema::is_primitive_type(t) { + if !crate::database::object::is_primitive_type(t) { custom_type_count += 1; } } @@ -355,9 +106,9 @@ impl Schema { } for t in types { - if !crate::database::schema::is_primitive_type(t) { + if !crate::database::object::is_primitive_type(t) { if let Some(parent) = db.schemas.get(t) { - parent.compile(db, visited, errors); + parent.as_ref().compile(db, visited, errors); } } } @@ -434,6 +185,106 @@ impl Schema { } } + /// Dynamically infers and compiles all structural database relationships between this Schema + /// and its nested children. This functions recursively traverses the JSON Schema abstract syntax + /// tree, identifies physical PostgreSQL table boundaries, and locks the resulting relation + /// constraint paths directly onto the `compiled_edges` map in O(1) memory. + pub fn compile_edges( + &self, + db: &crate::database::Database, + visited: &mut std::collections::HashSet, + props: &std::collections::BTreeMap>, + errors: &mut Vec, + ) -> std::collections::BTreeMap { + let mut schema_edges = std::collections::BTreeMap::new(); + + // Determine the physical Database Table Name this schema structurally represents + // Plucks the polymorphic discriminator via dot-notation (e.g. extracting "person" from "full.person") + let mut parent_type_name = None; + if let Some(family) = &self.obj.family { + parent_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string()); + } else if let Some(identifier) = self.obj.identifier() { + parent_type_name = Some( + identifier + .split('.') + .next_back() + .unwrap_or(&identifier) + .to_string(), + ); + } + + if let Some(p_type) = parent_type_name { + // Proceed only if the resolved table physically exists within the Postgres Type hierarchy + if db.types.contains_key(&p_type) { + // Iterate over all discovered schema boundaries mapped inside the object + for (prop_name, prop_schema) in props { + let mut child_type_name = None; + let mut target_schema = prop_schema.clone(); + let mut is_array = false; + + // Structurally unpack the inner target entity if the object maps to an array list + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = + &prop_schema.obj.type_ + { + if t == "array" { + is_array = true; + if let Some(items) = &prop_schema.obj.items { + target_schema = items.clone(); + } + } + } + + // Determine the physical Postgres table backing the nested child schema recursively + if let Some(family) = &target_schema.obj.family { + child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string()); + } else if let Some(ref_id) = target_schema.obj.identifier() { + child_type_name = Some(ref_id.split('.').next_back().unwrap_or(&ref_id).to_string()); + } else if let Some(arr) = &target_schema.obj.one_of { + if let Some(first) = arr.first() { + if let Some(ref_id) = first.obj.identifier() { + child_type_name = + Some(ref_id.split('.').next_back().unwrap_or(&ref_id).to_string()); + } + } + } + + if let Some(c_type) = child_type_name { + if db.types.contains_key(&c_type) { + // Ensure the child Schema's AST has accurately compiled its own physical property keys so we can + // inject them securely for Many-to-Many Twin Deduction disambiguation matching. + target_schema.compile(db, visited, errors); + if let Some(compiled_target_props) = target_schema.obj.compiled_properties.get() { + let keys_for_ambiguity: Vec = + compiled_target_props.keys().cloned().collect(); + + // Interrogate the Database catalog graph to discover the exact Foreign Key Constraint connecting the components + if let Some((relation, is_forward)) = db.resolve_relation( + &p_type, + &c_type, + prop_name, + Some(&keys_for_ambiguity), + is_array, + self.id.as_deref(), + &format!("/{}", prop_name), + errors, + ) { + schema_edges.insert( + prop_name.clone(), + crate::database::edge::Edge { + constraint: relation.constraint.clone(), + forward: is_forward, + }, + ); + } + } + } + } + } + } + } + schema_edges + } + pub fn compile_polymorphism( &self, db: &crate::database::Database, @@ -444,8 +295,11 @@ impl Schema { if let Some(family) = &self.obj.family { let family_base = family.split('.').next_back().unwrap_or(family).to_string(); - let family_prefix = family.strip_suffix(&family_base).unwrap_or("").trim_end_matches('.'); - + let family_prefix = family + .strip_suffix(&family_base) + .unwrap_or("") + .trim_end_matches('.'); + if let Some(type_def) = db.types.get(&family_base) { if type_def.variations.len() > 1 && type_def.variations.iter().any(|v| v != &family_base) { // Scenario A / B: Table Variations @@ -456,7 +310,7 @@ impl Schema { } else { format!("{}.{}", family_prefix, var) }; - + if db.schemas.contains_key(&target_id) { options.insert(var.to_string(), target_id); } @@ -464,36 +318,17 @@ impl Schema { } else { // Scenario C: Single Table Inheritance (Horizontal) strategy = "kind".to_string(); - - let mut target_family_ids = std::collections::HashSet::new(); - target_family_ids.insert(family.clone()); - // Iteratively build local descendants since db.descendants is removed natively - let mut added = true; - while added { - added = false; - for schema in &type_def.schemas { - if let Some(id) = &schema.obj.id { - if !target_family_ids.contains(id) { - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ { - if target_family_ids.contains(t) { - target_family_ids.insert(id.clone()); - added = true; - } - } - } - } - } - } + let suffix = format!(".{}", family_base); for schema in &type_def.schemas { - if let Some(id) = &schema.obj.id { - if target_family_ids.contains(id) { - if let Some(kind_val) = schema.obj.get_discriminator_value("kind") { - options.insert(kind_val, id.to_string()); - } - } - } + if let Some(id) = &schema.obj.id { + if id.ends_with(&suffix) || id == &family_base { + if let Some(kind_val) = schema.obj.get_discriminator_value("kind") { + options.insert(kind_val, id.to_string()); + } + } + } } } } @@ -535,8 +370,8 @@ impl Schema { let mut target_id = c.obj.id.clone(); if target_id.is_none() { - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &c.obj.type_ { - if !crate::database::schema::is_primitive_type(t) { + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &c.obj.type_ { + if !crate::database::object::is_primitive_type(t) { target_id = Some(t.clone()); } } @@ -552,8 +387,8 @@ impl Schema { } if !options.is_empty() { - let _ = self.obj.compiled_discriminator.set(strategy); - let _ = self.obj.compiled_options.set(options); + let _ = self.obj.compiled_discriminator.set(strategy); + let _ = self.obj.compiled_options.set(options); } } @@ -585,8 +420,8 @@ impl Schema { Self::validate_identifier(id, "$id", errors); to_insert.push((id.clone(), self.clone())); } - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { - if !crate::database::schema::is_primitive_type(t) { + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { + if !crate::database::object::is_primitive_type(t) { Self::validate_identifier(t, "type", errors); } } @@ -597,8 +432,8 @@ 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.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(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { + if !crate::database::object::is_primitive_type(t) { if let Some(ref path) = tracking_path { to_insert.push((path.clone(), self.clone())); } @@ -690,285 +525,6 @@ impl Schema { } } } - - /// Dynamically infers and compiles all structural database relationships between this Schema - /// and its nested children. This functions recursively traverses the JSON Schema abstract syntax - /// tree, identifies physical PostgreSQL table boundaries, and locks the resulting relation - /// constraint paths directly onto the `compiled_edges` map in O(1) memory. - pub fn compile_edges( - &self, - db: &crate::database::Database, - visited: &mut std::collections::HashSet, - props: &std::collections::BTreeMap>, - errors: &mut Vec, - ) -> std::collections::BTreeMap { - let mut schema_edges = std::collections::BTreeMap::new(); - - // Determine the physical Database Table Name this schema structurally represents - // Plucks the polymorphic discriminator via dot-notation (e.g. extracting "person" from "full.person") - let mut parent_type_name = None; - if let Some(family) = &self.obj.family { - parent_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string()); - } else if let Some(identifier) = self.obj.identifier() { - parent_type_name = Some( - identifier - .split('.') - .next_back() - .unwrap_or(&identifier) - .to_string(), - ); - } - - if let Some(p_type) = parent_type_name { - // Proceed only if the resolved table physically exists within the Postgres Type hierarchy - if db.types.contains_key(&p_type) { - // Iterate over all discovered schema boundaries mapped inside the object - for (prop_name, prop_schema) in props { - let mut child_type_name = None; - let mut target_schema = prop_schema.clone(); - let mut is_array = false; - - // Structurally unpack the inner target entity if the object maps to an array list - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = - &prop_schema.obj.type_ - { - if t == "array" { - is_array = true; - if let Some(items) = &prop_schema.obj.items { - target_schema = items.clone(); - } - } - } - - // Determine the physical Postgres table backing the nested child schema recursively - if let Some(family) = &target_schema.obj.family { - child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string()); - } else if let Some(ref_id) = target_schema.obj.identifier() { - child_type_name = Some(ref_id.split('.').next_back().unwrap_or(&ref_id).to_string()); - } else if let Some(arr) = &target_schema.obj.one_of { - if let Some(first) = arr.first() { - if let Some(ref_id) = first.obj.identifier() { - child_type_name = - Some(ref_id.split('.').next_back().unwrap_or(&ref_id).to_string()); - } - } - } - - if let Some(c_type) = child_type_name { - if db.types.contains_key(&c_type) { - // Ensure the child Schema's AST has accurately compiled its own physical property keys so we can - // inject them securely for Many-to-Many Twin Deduction disambiguation matching. - target_schema.compile(db, visited, errors); - if let Some(compiled_target_props) = target_schema.obj.compiled_properties.get() { - let keys_for_ambiguity: Vec = - compiled_target_props.keys().cloned().collect(); - - // Interrogate the Database catalog graph to discover the exact Foreign Key Constraint connecting the components - if let Some((relation, is_forward)) = resolve_relation( - db, - &p_type, - &c_type, - prop_name, - Some(&keys_for_ambiguity), - is_array, - self.id.as_deref(), - &format!("/{}", prop_name), - errors, - ) { - schema_edges.insert( - prop_name.clone(), - crate::database::edge::Edge { - constraint: relation.constraint.clone(), - forward: is_forward, - }, - ); - } - } - } - } - } - } - } - schema_edges - } -} - -/// Inspects the Postgres pg_constraint relations catalog to securely identify -/// the precise Foreign Key connecting a parent and child hierarchy path. -pub(crate) fn resolve_relation<'a>( - db: &'a crate::database::Database, - parent_type: &str, - child_type: &str, - prop_name: &str, - relative_keys: Option<&Vec>, - is_array: bool, - schema_id: Option<&str>, - path: &str, - errors: &mut Vec, -) -> Option<(&'a crate::database::relation::Relation, bool)> { - // Enforce graph locality by ensuring we don't accidentally crawl to pure structural entity boundaries - if parent_type == "entity" && child_type == "entity" { - return None; - } - - let p_def = db.types.get(parent_type)?; - let c_def = db.types.get(child_type)?; - - let mut matching_rels = Vec::new(); - let mut directions = Vec::new(); - - // Scour the complete catalog for any Edge matching the inheritance scope of the two objects - // This automatically binds polymorphic structures (e.g. recognizing a relationship targeting User - // also natively binds instances specifically typed as Person). - let mut all_rels: Vec<&crate::database::relation::Relation> = db.relations.values().collect(); - all_rels.sort_by(|a, b| a.constraint.cmp(&b.constraint)); - - for rel in all_rels { - let mut is_forward = - p_def.hierarchy.contains(&rel.source_type) && c_def.hierarchy.contains(&rel.destination_type); - let is_reverse = - p_def.hierarchy.contains(&rel.destination_type) && c_def.hierarchy.contains(&rel.source_type); - - // Structural Cardinality Filtration: - // If the schema requires a collection (Array), it is mathematically impossible for a pure - // Forward scalar edge (where the parent holds exactly one UUID pointer) to fulfill a One-to-Many request. - // Thus, if it's an array, we fully reject pure Forward edges and only accept Reverse edges (or Junction edges). - if is_array && is_forward && !is_reverse { - is_forward = false; - } - - if is_forward { - matching_rels.push(rel); - directions.push(true); - } else if is_reverse { - matching_rels.push(rel); - directions.push(false); - } - } - - // Abort relation discovery early if no hierarchical inheritance match was found - if matching_rels.is_empty() { - let mut details = crate::drop::ErrorDetails { - path: path.to_string(), - ..Default::default() - }; - if let Some(sid) = schema_id { - details.schema = Some(sid.to_string()); - } - - errors.push(crate::drop::Error { - code: "EDGE_MISSING".to_string(), - message: format!( - "No database relation exists between '{}' and '{}' for property '{}'", - parent_type, child_type, prop_name - ), - details, - }); - return None; - } - - // Ideal State: The objects only share a solitary structural relation, resolving ambiguity instantly. - if matching_rels.len() == 1 { - return Some((matching_rels[0], directions[0])); - } - - let mut chosen_idx = 0; - let mut resolved = false; - - // Exact Prefix Disambiguation: Determine if the database specifically names this constraint - // directly mapping to the JSON Schema property name (e.g., `fk_{child}_{property_name}`) - for (i, rel) in matching_rels.iter().enumerate() { - if let Some(prefix) = &rel.prefix { - if prop_name.starts_with(prefix) - || prefix.starts_with(prop_name) - || prefix.replace("_", "") == prop_name.replace("_", "") - { - chosen_idx = i; - resolved = true; - break; - } - } - } - - // Complex Subgraph Resolution: The database contains multiple equally explicit foreign key constraints - // linking these objects (such as pointing to `source` and `target` in Many-to-Many junction models). - if !resolved && relative_keys.is_some() { - // Twin Deduction Pass 1: We inspect the exact properties structurally defined inside the compiled payload - // to observe which explicit relation arrow the child payload natively consumes. - let keys = relative_keys.unwrap(); - let mut consumed_rel_idx = None; - for (i, rel) in matching_rels.iter().enumerate() { - if let Some(prefix) = &rel.prefix { - if keys.contains(prefix) { - consumed_rel_idx = Some(i); - break; // Found the routing edge explicitly consumed by the schema payload - } - } - } - - // Twin Deduction Pass 2: Knowing which arrow points outbound, we can mathematically deduce its twin - // providing the reverse ownership on the same junction boundary must be the incoming Edge to the parent. - if let Some(used_idx) = consumed_rel_idx { - let used_rel = matching_rels[used_idx]; - let mut twin_ids = Vec::new(); - for (i, rel) in matching_rels.iter().enumerate() { - if i != used_idx - && rel.source_type == used_rel.source_type - && rel.destination_type == used_rel.destination_type - && rel.prefix.is_some() - { - twin_ids.push(i); - } - } - - if twin_ids.len() == 1 { - chosen_idx = twin_ids[0]; - resolved = true; - } - } - } - - // Implicit Base Fallback: If no complex explicit paths resolve, but exactly one relation - // sits entirely naked (without a constraint prefix), it must be the core structural parent ownership. - if !resolved { - let mut null_prefix_ids = Vec::new(); - for (i, rel) in matching_rels.iter().enumerate() { - if rel.prefix.is_none() { - null_prefix_ids.push(i); - } - } - if null_prefix_ids.len() == 1 { - chosen_idx = null_prefix_ids[0]; - resolved = true; - } - } - - // If we exhausted all mathematical deduction pathways and STILL cannot isolate a single edge, - // we must abort rather than silently guessing. Returning None prevents arbitrary SQL generation - // and forces a clean structural error for the architect. - if !resolved { - let mut details = crate::drop::ErrorDetails { - path: path.to_string(), - context: serde_json::to_value(&matching_rels).ok(), - cause: Some("Multiple conflicting constraints found matching prefixes".to_string()), - ..Default::default() - }; - if let Some(sid) = schema_id { - details.schema = Some(sid.to_string()); - } - - errors.push(crate::drop::Error { - code: "AMBIGUOUS_TYPE_RELATIONS".to_string(), - message: format!( - "Ambiguous database relation between '{}' and '{}' for property '{}'", - parent_type, child_type, prop_name - ), - details, - }); - return None; - } - - Some((matching_rels[chosen_idx], directions[chosen_idx])) } impl<'de> Deserialize<'de> for Schema { @@ -1021,104 +577,3 @@ impl<'de> Deserialize<'de> for Schema { }) } } - -impl SchemaObject { - pub fn identifier(&self) -> Option { - 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 - } - - pub fn get_discriminator_value(&self, dim: &str) -> Option { - let is_split = self.compiled_properties.get().map_or(false, |p| p.contains_key("kind")); - if let Some(id) = &self.id { - if id.contains("light.person") || id.contains("light.organization") { - println!("[DEBUG SPLIT] ID: {}, dim: {}, is_split: {:?}, props: {:?}", id, dim, is_split, self.compiled_properties.get().map(|p| p.keys().cloned().collect::>())); - } - } - - if let Some(props) = self.compiled_properties.get() { - if let Some(prop_schema) = props.get(dim) { - if let Some(c) = &prop_schema.obj.const_ { - if let Some(s) = c.as_str() { - return Some(s.to_string()); - } - } - if let Some(e) = &prop_schema.obj.enum_ { - if e.len() == 1 { - if let Some(s) = e[0].as_str() { - return Some(s.to_string()); - } - } - } - } - } - - if dim == "kind" { - if let Some(id) = &self.id { - let base = id.split('/').last().unwrap_or(id); - if let Some(idx) = base.rfind('.') { - return Some(base[..idx].to_string()); - } - } - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.type_ { - if !crate::database::schema::is_primitive_type(t) { - let base = t.split('/').last().unwrap_or(t); - if let Some(idx) = base.rfind('.') { - return Some(base[..idx].to_string()); - } - } - } - } - - if dim == "type" { - if let Some(id) = &self.id { - let base = id.split('/').last().unwrap_or(id); - if is_split { - return Some(base.split('.').next_back().unwrap_or(base).to_string()); - } else { - return Some(base.to_string()); - } - } - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &self.type_ { - if !crate::database::schema::is_primitive_type(t) { - let base = t.split('/').last().unwrap_or(t); - if is_split { - return Some(base.split('.').next_back().unwrap_or(base).to_string()); - } else { - return Some(base.to_string()); - } - } - } - } - - None - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum SchemaTypeOrArray { - Single(String), - Multiple(Vec), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Action { - #[serde(skip_serializing_if = "Option::is_none")] - pub navigate: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub punc: Option, -} -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum Dependency { - Props(Vec), - Schema(Arc), -} diff --git a/src/merger/mod.rs b/src/merger/mod.rs index 0d65bd4..28b64bb 100644 --- a/src/merger/mod.rs +++ b/src/merger/mod.rs @@ -25,7 +25,7 @@ impl Merger { let mut notifications_queue = Vec::new(); let target_schema = match self.db.schemas.get(schema_id) { - Some(s) => Arc::new(s.clone()), + Some(s) => Arc::clone(s), None => { return crate::drop::Drop::with_errors(vec![crate::drop::Error { code: "MERGE_FAILED".to_string(), @@ -144,7 +144,7 @@ impl Merger { if let Some(v) = val { if let Some(target_id) = options.get(v) { if let Some(target_schema) = self.db.schemas.get(target_id) { - schema = Arc::new(target_schema.clone()); + schema = Arc::clone(target_schema); } else { return Err(format!("Polymorphic mapped target '{}' not found in database registry", target_id)); } @@ -169,7 +169,7 @@ impl Merger { notifications: &mut Vec, ) -> Result { let mut item_schema = schema.clone(); - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ { + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ { if t == "array" { if let Some(items_def) = &schema.obj.items { item_schema = items_def.clone(); @@ -389,7 +389,7 @@ impl Merger { ); let mut item_schema = rel_schema.clone(); - if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = + if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &rel_schema.obj.type_ { if t == "array" { diff --git a/src/queryer/compiler.rs b/src/queryer/compiler.rs index 6873eda..6ea1056 100644 --- a/src/queryer/compiler.rs +++ b/src/queryer/compiler.rs @@ -1,4 +1,6 @@ use crate::database::Database; +use std::sync::Arc; + pub struct Compiler<'a> { pub db: &'a Database, pub filter_keys: &'a [String], @@ -15,6 +17,7 @@ pub struct Node<'a> { pub property_name: Option, pub depth: usize, pub ast_path: String, + pub is_polymorphic_branch: bool, } impl<'a> Compiler<'a> { @@ -26,7 +29,7 @@ impl<'a> Compiler<'a> { .get(schema_id) .ok_or_else(|| format!("Schema not found: {}", schema_id))?; - let target_schema = std::sync::Arc::new(schema.clone()); + let target_schema = std::sync::Arc::clone(schema); let mut compiler = Compiler { db: &self.db, @@ -43,6 +46,7 @@ impl<'a> Compiler<'a> { property_name: None, depth: 0, ast_path: String::new(), + is_polymorphic_branch: false, }; let (sql, _) = compiler.compile_node(node)?; @@ -54,7 +58,7 @@ impl<'a> Compiler<'a> { fn compile_node(&mut self, node: Node<'a>) -> Result<(String, String), String> { // Determine the base schema type (could be an array, object, or literal) match &node.schema.obj.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(t)) if t == "array" => { + Some(crate::database::object::SchemaTypeOrArray::Single(t)) if t == "array" => { self.compile_array(node) } _ => self.compile_reference(node), @@ -116,12 +120,12 @@ impl<'a> Compiler<'a> { } // 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 let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &node.schema.obj.type_ { + if !crate::database::object::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()); + ref_node.schema = Arc::clone(target_schema); return self.compile_node(ref_node); } return Err(format!("Unresolved schema type pointer: {}", t)); @@ -133,7 +137,7 @@ impl<'a> Compiler<'a> { if options.len() == 1 { let target_id = options.values().next().unwrap(); let mut bypass_schema = crate::database::schema::Schema::default(); - bypass_schema.obj.type_ = Some(crate::database::schema::SchemaTypeOrArray::Single(target_id.clone())); + bypass_schema.obj.type_ = Some(crate::database::object::SchemaTypeOrArray::Single(target_id.clone())); let mut bypass_node = node.clone(); bypass_node.schema = std::sync::Arc::new(bypass_schema); return self.compile_node(bypass_node); @@ -166,17 +170,28 @@ impl<'a> Compiler<'a> { ) -> Result<(String, String), String> { let (table_aliases, from_clauses) = self.compile_from_clause(r#type); - // 2. Map properties and build jsonb_build_object args - let mut select_args = self.compile_select_clause(r#type, &table_aliases, node.clone())?; + let jsonb_obj_sql = if node.schema.obj.family.is_some() || node.schema.obj.one_of.is_some() { + let base_alias = table_aliases + .get(&r#type.name) + .cloned() + .unwrap_or_else(|| node.parent_alias.to_string()); + + let mut case_node = node.clone(); + case_node.parent_alias = base_alias.clone(); + let arc_aliases = std::sync::Arc::new(table_aliases.clone()); + case_node.parent_type_aliases = Some(arc_aliases); + case_node.parent_type = Some(r#type); - // 2.5 Inject polymorphism directly into the query object - let mut poly_args = self.compile_polymorphism_select(r#type, &table_aliases, node.clone())?; - select_args.append(&mut poly_args); - - let jsonb_obj_sql = if select_args.is_empty() { - "jsonb_build_object()".to_string() + let (case_sql, _) = self.compile_one_of(case_node)?; + case_sql } else { - format!("jsonb_build_object({})", select_args.join(", ")) + let select_args = self.compile_select_clause(r#type, &table_aliases, node.clone())?; + + if select_args.is_empty() { + "jsonb_build_object()".to_string() + } else { + format!("jsonb_build_object({})", select_args.join(", ")) + } }; // 3. Build WHERE clauses @@ -205,55 +220,6 @@ impl<'a> Compiler<'a> { )) } - fn compile_polymorphism_select( - &mut self, - r#type: &'a crate::database::r#type::Type, - table_aliases: &std::collections::HashMap, - node: Node<'a>, - ) -> Result, String> { - let mut select_args = Vec::new(); - - if node.schema.obj.family.is_some() || node.schema.obj.one_of.is_some() { - let base_alias = table_aliases - .get(&r#type.name) - .cloned() - .unwrap_or_else(|| node.parent_alias.to_string()); - - let disc = node.schema.obj.compiled_discriminator.get(); - if disc.is_none() { - return Ok(select_args); - } - - let options = node.schema.obj.compiled_options.get(); - if options.is_none() { - return Ok(select_args); - } - let options = options.unwrap(); - - if options.len() == 1 { - let target_id = options.values().next().unwrap(); - if let Some(target_schema) = self.db.schemas.get(target_id) { - let mut bypass_node = node.clone(); - bypass_node.schema = std::sync::Arc::new(target_schema.clone()); - let mut bypassed_args = self.compile_select_clause(r#type, table_aliases, bypass_node)?; - select_args.append(&mut bypassed_args); - return Ok(select_args); - } - } - - select_args.push(format!("'id', {}.id", base_alias)); - let mut case_node = node.clone(); - case_node.parent_alias = base_alias.clone(); - let arc_aliases = std::sync::Arc::new(table_aliases.clone()); - case_node.parent_type_aliases = Some(arc_aliases); - - let (case_sql, _) = self.compile_one_of(case_node)?; - select_args.push(format!("'{}', {}", disc.unwrap(), case_sql)); - } - - Ok(select_args) - } - fn compile_object( &mut self, props: &std::collections::BTreeMap>, @@ -301,8 +267,25 @@ impl<'a> Compiler<'a> { for (disc_val, target_id) in options { if let Some(target_schema) = self.db.schemas.get(target_id) { let mut child_node = node.clone(); - child_node.schema = std::sync::Arc::new(target_schema.clone()); - let (val_sql, _) = self.compile_node(child_node)?; + child_node.schema = Arc::clone(target_schema); + child_node.is_polymorphic_branch = true; + + let val_sql = if disc == "kind" && node.parent_type.is_some() && node.parent_type_aliases.is_some() { + let aliases_arc = node.parent_type_aliases.as_ref().unwrap(); + let aliases = aliases_arc.as_ref(); + let p_type = node.parent_type.unwrap(); + + let select_args = self.compile_select_clause(p_type, aliases, child_node.clone())?; + + if select_args.is_empty() { + "jsonb_build_object()".to_string() + } else { + format!("jsonb_build_object({})", select_args.join(", ")) + } + } else { + let (sql, _) = self.compile_node(child_node)?; + sql + }; case_statements.push(format!( "WHEN {}.{} = '{}' THEN ({})", @@ -365,18 +348,18 @@ impl<'a> Compiler<'a> { let prop_schema = &merged_props[prop_key]; let is_object_or_array = match &prop_schema.obj.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(s)) => { + Some(crate::database::object::SchemaTypeOrArray::Single(s)) => { s == "object" || s == "array" } - Some(crate::database::schema::SchemaTypeOrArray::Multiple(v)) => { + Some(crate::database::object::SchemaTypeOrArray::Multiple(v)) => { v.contains(&"object".to_string()) || v.contains(&"array".to_string()) } _ => false, }; let is_custom_object_pointer = match &prop_schema.obj.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(s)) => { - !crate::database::schema::is_primitive_type(s) + Some(crate::database::object::SchemaTypeOrArray::Single(s)) => { + !crate::database::object::is_primitive_type(s) } _ => false, }; @@ -426,6 +409,7 @@ impl<'a> Compiler<'a> { } else { format!("{}/{}", node.ast_path, prop_key) }, + is_polymorphic_branch: false, }; let (val_sql, val_type) = self.compile_node(child_node)?; @@ -465,6 +449,8 @@ impl<'a> Compiler<'a> { self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses); self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses); + + let start_len = where_clauses.len(); self.compile_relation_conditions( r#type, type_aliases, @@ -473,6 +459,14 @@ impl<'a> Compiler<'a> { &mut where_clauses, )?; + if node.is_polymorphic_branch && where_clauses.len() == start_len { + if let Some(parent_aliases) = &node.parent_type_aliases { + if let Some(outer_entity_alias) = parent_aliases.get("entity") { + where_clauses.push(format!("{}.id = {}.id", entity_alias, outer_entity_alias)); + } + } + } + Ok(where_clauses) } diff --git a/src/validator/rules/core.rs b/src/validator/rules/core.rs index 8bd650a..c8ed6fe 100644 --- a/src/validator/rules/core.rs +++ b/src/validator/rules/core.rs @@ -13,7 +13,7 @@ impl<'a> ValidationContext<'a> { if let Some(ref type_) = self.schema.type_ { match type_ { - crate::database::schema::SchemaTypeOrArray::Single(t) => { + crate::database::object::SchemaTypeOrArray::Single(t) => { if !Validator::check_type(t, current) { result.errors.push(ValidationError { code: "INVALID_TYPE".to_string(), @@ -22,7 +22,7 @@ impl<'a> ValidationContext<'a> { }); } } - crate::database::schema::SchemaTypeOrArray::Multiple(types) => { + crate::database::object::SchemaTypeOrArray::Multiple(types) => { let mut valid = false; for t in types { if Validator::check_type(t, current) { diff --git a/src/validator/rules/format.rs b/src/validator/rules/format.rs index 03d3cf3..4d169f2 100644 --- a/src/validator/rules/format.rs +++ b/src/validator/rules/format.rs @@ -10,7 +10,7 @@ impl<'a> ValidationContext<'a> { let current = self.instance; if let Some(compiled_fmt) = self.schema.compiled_format.get() { match compiled_fmt { - crate::database::schema::CompiledFormat::Func(f) => { + crate::database::object::CompiledFormat::Func(f) => { let should = if let Some(s) = current.as_str() { !s.is_empty() } else { @@ -24,7 +24,7 @@ impl<'a> ValidationContext<'a> { }); } } - crate::database::schema::CompiledFormat::Regex(re) => { + crate::database::object::CompiledFormat::Regex(re) => { if let Some(s) = current.as_str() && !re.is_match(s) { diff --git a/src/validator/rules/object.rs b/src/validator/rules/object.rs index 7765f21..c888673 100644 --- a/src/validator/rules/object.rs +++ b/src/validator/rules/object.rs @@ -124,7 +124,7 @@ impl<'a> ValidationContext<'a> { for (prop, dep) in deps { if obj.contains_key(prop) { match dep { - crate::database::schema::Dependency::Props(required_props) => { + crate::database::object::Dependency::Props(required_props) => { for req_prop in required_props { if !obj.contains_key(req_prop) { result.errors.push(ValidationError { @@ -135,7 +135,7 @@ impl<'a> ValidationContext<'a> { } } } - crate::database::schema::Dependency::Schema(dep_schema) => { + crate::database::object::Dependency::Schema(dep_schema) => { let derived = self.derive_for_schema(dep_schema, false); let dep_res = derived.validate()?; result.evaluated_keys.extend(dep_res.evaluated_keys.clone()); @@ -155,7 +155,7 @@ impl<'a> ValidationContext<'a> { if let Some(child_instance) = obj.get(key) { let new_path = self.join_path(key); let is_ref = match &sub_schema.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t), + Some(crate::database::object::SchemaTypeOrArray::Single(t)) => !crate::database::object::is_primitive_type(t), _ => false, }; let next_extensible = if is_ref { false } else { self.extensible }; @@ -184,7 +184,7 @@ impl<'a> ValidationContext<'a> { if compiled_re.0.is_match(key) { let new_path = self.join_path(key); let is_ref = match &sub_schema.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t), + Some(crate::database::object::SchemaTypeOrArray::Single(t)) => !crate::database::object::is_primitive_type(t), _ => false, }; let next_extensible = if is_ref { false } else { self.extensible }; @@ -226,7 +226,7 @@ impl<'a> ValidationContext<'a> { if !locally_matched { let new_path = self.join_path(key); let is_ref = match &additional_schema.type_ { - Some(crate::database::schema::SchemaTypeOrArray::Single(t)) => !crate::database::schema::is_primitive_type(t), + Some(crate::database::object::SchemaTypeOrArray::Single(t)) => !crate::database::object::is_primitive_type(t), _ => false, }; let next_extensible = if is_ref { false } else { self.extensible }; diff --git a/src/validator/rules/polymorphism.rs b/src/validator/rules/polymorphism.rs index 80deddd..45bc723 100644 --- a/src/validator/rules/polymorphism.rs +++ b/src/validator/rules/polymorphism.rs @@ -120,7 +120,7 @@ impl<'a> ValidationContext<'a> { if let Some(target_id) = options.get(val) { if let Some(target_schema) = self.db.schemas.get(target_id) { - let derived = self.derive_for_schema(target_schema, false); + let derived = self.derive_for_schema(target_schema.as_ref(), false); let sub_res = derived.validate()?; let is_valid = sub_res.is_valid(); result.merge(sub_res); @@ -173,17 +173,17 @@ impl<'a> ValidationContext<'a> { 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) { + Some(crate::database::object::SchemaTypeOrArray::Single(t)) => { + if !crate::database::object::is_primitive_type(t) { custom_types.push(t.clone()); } } - Some(crate::database::schema::SchemaTypeOrArray::Multiple(arr)) => { + Some(crate::database::object::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) { + if !crate::database::object::is_primitive_type(t) { custom_types.push(t.clone()); } }