Compare commits

..

5 Commits

8 changed files with 129 additions and 13 deletions

View File

@ -23,6 +23,12 @@ To support high-throughput operations while allowing for runtime updates (e.g.,
3. **Immutable AST Caching**: The `Validator` struct immutably owns the `Database` registry. Schemas themselves are frozen structurally, but utilize `OnceLock` interior mutability during the Compilation Phase to permanently cache resolved `$ref` inheritances, properties, and `compiled_edges` directly onto their AST nodes. This guarantees strict `O(1)` relationship and property validation execution at runtime without locking or recursive DB polling. 3. **Immutable AST Caching**: The `Validator` struct immutably owns the `Database` registry. Schemas themselves are frozen structurally, but utilize `OnceLock` interior mutability during the Compilation Phase to permanently cache resolved `$ref` inheritances, properties, and `compiled_edges` directly onto their AST nodes. This guarantees strict `O(1)` relationship and property validation execution at runtime without locking or recursive DB polling.
4. **Lock-Free Reads**: Incoming operations acquire a read lock just long enough to clone the `Arc` inside an `RwLock<Option<Arc<Validator>>>`, ensuring zero blocking during schema updates. 4. **Lock-Free Reads**: Incoming operations acquire a read lock just long enough to clone the `Arc` inside an `RwLock<Option<Arc<Validator>>>`, ensuring zero blocking during schema updates.
### Relational Edge Resolution
When compiling nested object graphs or arrays, the JSPG engine must dynamically infer which Postgres Foreign Key constraint correctly bridges the parent to the nested schema. It utilizes a strict 3-step hierarchical resolution:
1. **Direct Prefix Match**: If an explicitly prefixed Foreign Key (e.g. `fk_invoice_counterparty_entity` -> `prefix: "counterparty"`) matches the exact name of the requested schema property (e.g. `{"counterparty": {...}}`), it is instantly selected.
2. **Base Edge Fallback (1:M)**: If no explicit prefix directly matches the property name, the compiler filters for explicitly one remaining relation with a `null` prefix (e.g. `fk_invoice_line_invoice` -> `prefix: null`). A `null` prefix mathematically denotes the standard structural parent-child ownership edge (bypassing any M:M ambiguity) and is safely picked over explicit (but unmatched) property edges.
3. **Ambiguity Elimination (M:M)**: If multiple explicitly prefixed relations remain (which happens by design in Many-to-Many junction tables like `contact` utilizing `fk_relationship_source` and `fk_relationship_target`), the compiler uses a process of elimination. It checks which of the prefix names the child schema *natively consumes* as an outbound property (e.g. `contact` defines `{ "target": ... }`). It considers that prefix "used up" and mathematically deduces the *remaining* explicitly prefixed relation (`"source"`) must be the inbound link from the parent.
### Global API Reference ### Global API Reference
These functions operate on the global `GLOBAL_JSPG` engine instance and provide administrative boundaries: These functions operate on the global `GLOBAL_JSPG` engine instance and provide administrative boundaries:

0
agreego.sql Normal file
View File

View File

@ -1152,6 +1152,69 @@
] ]
} }
}, },
{
"description": "Replace existing person with id and no changes (lookup)",
"action": "merge",
"data": {
"id": "33333333-3333-3333-3333-333333333333",
"type": "person",
"first_name": "LookupFirst",
"last_name": "LookupLast",
"date_of_birth": "1990-01-01T00:00:00Z",
"pronouns": "they/them"
},
"mocks": [
{
"id": "22222222-2222-2222-2222-222222222222",
"type": "person",
"first_name": "LookupFirst",
"last_name": "LookupLast",
"date_of_birth": "1990-01-01T00:00:00Z",
"pronouns": "they/them",
"contact_id": "old-contact"
}
],
"schema_id": "person",
"expect": {
"success": true,
"sql": [
[
"SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"person\" t1",
"LEFT JOIN agreego.\"user\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"organization\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"entity\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '33333333-3333-3333-3333-333333333333'",
" OR (",
" \"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '1990-01-01T00:00:00Z'",
" AND \"pronouns\" = 'they/them'",
" )"
],
[
"SELECT pg_notify('entity', '{",
" \"complete\":{",
" \"contact_id\":\"old-contact\",",
" \"date_of_birth\":\"1990-01-01T00:00:00Z\",",
" \"first_name\":\"LookupFirst\",",
" \"id\":\"22222222-2222-2222-2222-222222222222\",",
" \"last_name\":\"LookupLast\",",
" \"modified_at\":\"2026-03-10T00:00:00Z\",",
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"pronouns\":\"they/them\",",
" \"type\":\"person\"",
" },",
" \"new\":{",
" \"type\":\"person\"",
" },",
" \"replaces\":\"33333333-3333-3333-3333-333333333333\"",
" }')"
]
]
}
},
{ {
"description": "Update existing person with id (no lookup)", "description": "Update existing person with id (no lookup)",
"action": "merge", "action": "merge",

View File

@ -27,7 +27,9 @@
{ {
"$id": "get_orders.response", "$id": "get_orders.response",
"type": "array", "type": "array",
"items": { "$ref": "light.order" } "items": {
"$ref": "light.order"
}
} }
] ]
} }
@ -77,8 +79,23 @@
"destination_type": "person", "destination_type": "person",
"destination_columns": [ "destination_columns": [
"id" "id"
]
},
{
"id": "22222222-2222-2222-2222-222222222227",
"type": "relation",
"constraint": "fk_order_counterparty_entity",
"source_type": "order",
"source_columns": [
"counterparty_id",
"counterparty_type"
], ],
"prefix": "customer" "destination_type": "entity",
"destination_columns": [
"id",
"type"
],
"prefix": "counterparty"
}, },
{ {
"id": "33333333-3333-3333-3333-333333333333", "id": "33333333-3333-3333-3333-333333333333",
@ -91,8 +108,7 @@
"destination_type": "order", "destination_type": "order",
"destination_columns": [ "destination_columns": [
"id" "id"
], ]
"prefix": "lines"
} }
], ],
"types": [ "types": [
@ -713,14 +729,18 @@
"created_by", "created_by",
"modified_at", "modified_at",
"modified_by", "modified_by",
"archived" "archived",
"counterparty_id",
"counterparty_type"
], ],
"grouped_fields": { "grouped_fields": {
"order": [ "order": [
"id", "id",
"type", "type",
"total", "total",
"customer_id" "customer_id",
"counterparty_id",
"counterparty_type"
], ],
"entity": [ "entity": [
"id", "id",
@ -748,7 +768,9 @@
"created_at": "timestamptz", "created_at": "timestamptz",
"created_by": "uuid", "created_by": "uuid",
"modified_at": "timestamptz", "modified_at": "timestamptz",
"modified_by": "uuid" "modified_by": "uuid",
"counterparty_id": "uuid",
"counterparty_type": "text"
}, },
"variations": [ "variations": [
"order" "order"

View File

@ -622,7 +622,23 @@ pub(crate) fn resolve_relation<'a>(
} }
} }
if !resolved {
// 1. If there's EXACTLY ONE relation with a null prefix, it's the base structural edge. Pick it.
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 !resolved && relative_keys.is_some() { if !resolved && relative_keys.is_some() {
// 2. M:M Disambiguation: The child schema will explicitly define an outbound property
// matching one of the relational prefixes (e.g. "target"). We use the OTHER one (e.g. "source").
let keys = relative_keys.unwrap(); let keys = relative_keys.unwrap();
let mut missing_prefix_ids = Vec::new(); let mut missing_prefix_ids = Vec::new();
for (i, rel) in matching_rels.iter().enumerate() { for (i, rel) in matching_rels.iter().enumerate() {
@ -634,6 +650,7 @@ pub(crate) fn resolve_relation<'a>(
} }
if missing_prefix_ids.len() == 1 { if missing_prefix_ids.len() == 1 {
chosen_idx = missing_prefix_ids[0]; chosen_idx = missing_prefix_ids[0];
// resolved = true;
} }
} }

View File

@ -508,7 +508,7 @@ impl Merger {
); );
entity_fields = new_fields; entity_fields = new_fields;
} else if changes.is_empty() { } else if changes.is_empty() && replaces_id.is_none() {
let mut new_fields = serde_json::Map::new(); let mut new_fields = serde_json::Map::new();
new_fields.insert( new_fields.insert(
"id".to_string(), "id".to_string(),
@ -524,6 +524,8 @@ impl Merger {
.unwrap_or(false); .unwrap_or(false);
entity_change_kind = if is_archived { entity_change_kind = if is_archived {
Some("delete".to_string()) Some("delete".to_string())
} else if changes.is_empty() && replaces_id.is_some() {
Some("replace".to_string())
} else { } else {
Some("update".to_string()) Some("update".to_string())
}; };
@ -796,9 +798,9 @@ impl Merger {
let mut old_vals = serde_json::Map::new(); let mut old_vals = serde_json::Map::new();
let mut new_vals = serde_json::Map::new(); let mut new_vals = serde_json::Map::new();
let is_update = change_kind == "update" || change_kind == "delete"; let exists = change_kind == "update" || change_kind == "delete" || change_kind == "replace";
if !is_update { if !exists {
let system_keys = vec![ let system_keys = vec![
"id".to_string(), "id".to_string(),
"created_by".to_string(), "created_by".to_string(),
@ -835,7 +837,7 @@ impl Merger {
} }
let mut complete = entity_fields.clone(); let mut complete = entity_fields.clone();
if is_update { if exists {
if let Some(fetched) = entity_fetched { if let Some(fetched) = entity_fetched {
let mut temp = fetched.clone(); let mut temp = fetched.clone();
for (k, v) in entity_fields { for (k, v) in entity_fields {
@ -865,7 +867,7 @@ impl Merger {
} }
let mut notify_sql = None; let mut notify_sql = None;
if type_obj.historical { if type_obj.historical && change_kind != "replace" {
let change_sql = format!( let change_sql = format!(
"INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {}, {})", "INSERT INTO agreego.change (\"old\", \"new\", entity_id, id, kind, modified_at, modified_by) VALUES ({}, {}, {}, {}, {}, {}, {})",
Self::quote_literal(&old_val_obj), Self::quote_literal(&old_val_obj),

View File

@ -8602,3 +8602,9 @@ fn test_merger_0_11() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR")); let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 11).unwrap(); crate::tests::runner::run_test_case(&path, 0, 11).unwrap();
} }
#[test]
fn test_merger_0_12() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 12).unwrap();
}

View File

@ -1 +1 @@
1.0.95 1.0.98