From 5bab0073c0784b5697273c959dbfc4bcbb2c5f70 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Thu, 16 Oct 2025 18:27:43 +0200 Subject: [PATCH 1/6] fix patch object creation --- engine/verifier/src/orm/add_remove_triples.rs | 15 +- .../verifier/src/orm/handle_backend_update.rs | 194 ++++-- sdk/rust/src/tests/orm_patches.rs | 637 +++++++++--------- 3 files changed, 449 insertions(+), 397 deletions(-) diff --git a/engine/verifier/src/orm/add_remove_triples.rs b/engine/verifier/src/orm/add_remove_triples.rs index 7adc46b..e7fe05b 100644 --- a/engine/verifier/src/orm/add_remove_triples.rs +++ b/engine/verifier/src/orm/add_remove_triples.rs @@ -92,7 +92,7 @@ pub fn add_remove_triples( // log_debug!("lock acquired on tracked_predicate"); tracked_predicate.current_cardinality += 1; - // Keep track of the changed values too. + // Keep track of the added values here. let pred_changes: &mut OrmTrackedPredicateChanges = subject_changes .predicates .entry(predicate_schema.iri.clone()) @@ -163,6 +163,7 @@ pub fn add_remove_triples( } } } + // Process removed triples. for triple in triples_removed { let pred_iri = triple.predicate.as_str(); @@ -181,9 +182,15 @@ pub fn add_remove_triples( tracked_predicate.current_cardinality = tracked_predicate.current_cardinality.saturating_sub(1); - let Some(pred_changes) = subject_changes.predicates.get_mut(pred_iri) else { - continue; - }; + // Keep track of removed values here. + let pred_changes: &mut OrmTrackedPredicateChanges = subject_changes + .predicates + .entry(tracked_predicate.schema.iri.clone()) + .or_insert_with(|| OrmTrackedPredicateChanges { + tracked_predicate: tracked_predicate_rc.clone(), + values_added: Vec::new(), + values_removed: Vec::new(), + }); let val_removed = oxrdf_term_to_orm_basic_type(&triple.object); pred_changes.values_removed.push(val_removed.clone()); diff --git a/engine/verifier/src/orm/handle_backend_update.rs b/engine/verifier/src/orm/handle_backend_update.rs index b8a2b0e..ce35c19 100644 --- a/engine/verifier/src/orm/handle_backend_update.rs +++ b/engine/verifier/src/orm/handle_backend_update.rs @@ -101,7 +101,7 @@ impl Verifier { } log_debug!( - "[orm_backend_update], creating patch objects for scopes:\n{}", + "[orm_backend_update], creating patch objects for #scopes {}", scopes.len() ); for (scope, shapes) in scopes { @@ -131,9 +131,9 @@ impl Verifier { // The JSON patches to send to JS land. let mut patches: Vec = vec![]; - // Keep track of created objects by path and if they need an id. - // Later we created patches from them to ensure the objects exist. - let mut paths_of_objects_to_create: HashSet<(Vec, Option)> = + // Keep track of objects to create: (path, Option) + // The IRI is Some for real subjects, None for intermediate objects (e.g., multi-valued predicate containers) + let mut objects_to_create: HashSet<(Vec, Option)> = HashSet::new(); // We construct object patches from a change (which is associated with a shape type). {op: add, valType: object, value: Null, path: ...} @@ -193,7 +193,7 @@ impl Verifier { &mut path, (OrmDiffOpType::remove, Some(OrmDiffType::object), None, None), &mut patches, - &mut paths_of_objects_to_create, + &mut objects_to_create, ); } else { // The subject is valid or has become valid. @@ -218,7 +218,7 @@ impl Verifier { &mut path, diff_op, &mut patches, - &mut paths_of_objects_to_create, + &mut objects_to_create, ); } } @@ -229,26 +229,29 @@ impl Verifier { // Create patches for objects that need to be created // These are patches with {op: add, valType: object, value: Null, path: ...} // Sort by path length (shorter first) to ensure parent objects are created before children - let mut sorted_object_paths: Vec<_> = paths_of_objects_to_create.iter().collect(); - sorted_object_paths.sort_by_key(|(path_segments, _)| path_segments.len()); + let mut sorted_objects: Vec<_> = objects_to_create.iter().collect(); + sorted_objects.sort_by_key(|(path_segments, _)| path_segments.len()); - for (path_segments, maybe_iri) in sorted_object_paths { + for (path_segments, maybe_iri) in sorted_objects { let escaped_path: Vec = path_segments .iter() .map(|seg| escape_json_pointer(seg)) .collect(); let json_pointer = format!("/{}", escaped_path.join("/")); + // Always create the object itself patches.push(OrmDiffOp { op: OrmDiffOpType::add, valType: Some(OrmDiffType::object), path: json_pointer.clone(), value: None, }); + + // If this object has an IRI (it's a real subject), add the id field if let Some(iri) = maybe_iri { patches.push(OrmDiffOp { op: OrmDiffOpType::add, - valType: Some(OrmDiffType::object), + valType: None, path: format!("{}/id", json_pointer), value: Some(json!(iri)), }); @@ -266,6 +269,108 @@ impl Verifier { } } +/// Queue patches for a newly valid tracked subject. +/// This handles creating object patches and id field patches for subjects that have become valid. +fn queue_patches_for_newly_valid_subject( + tracked_subject: &OrmTrackedSubject, + tracked_subjects: &HashMap>>>, + root_shape: &String, + path: &[String], + patches: &mut Vec, + objects_to_create: &mut HashSet<(Vec, Option)>, +) { + // Check if we're at a root subject or need to traverse to parents + if tracked_subject.parents.is_empty() || tracked_subject.shape.iri == *root_shape { + // Register object for creation. + // Path to object consists of this subject's iri and the path except for the last element. + let mut path_to_subject = vec![tracked_subject.subject_iri.clone()]; + if path.len() > 0 { + path_to_subject.extend_from_slice(&path[1..]); + } + + log_debug!("Queuing object creation for path: {:?}", path_to_subject); + + // Always create the object itself with its IRI + objects_to_create.insert(( + path_to_subject.clone(), + Some(tracked_subject.subject_iri.clone()), + )); + } else { + // Not at root: traverse to parents and create object patches along the way + for (_parent_iri, parent_tracked_subject) in tracked_subject.parents.iter() { + let parent_ts = parent_tracked_subject.read().unwrap(); + + if let Some(new_path) = build_path_segment_for_parent(tracked_subject, &parent_ts, path) + { + // Check if the parent's predicate is multi-valued and if no siblings were previously valid + let should_create_parent_predicate_object = + check_should_create_parent_predicate_object(tracked_subject, &parent_ts); + + if should_create_parent_predicate_object { + // Need to create an intermediate object for the multi-valued predicate + // This is the case for Person -> hasAddress -> (object) -> AddressIri -> AddressObject + // The intermediate (object) doesn't have an IRI + let mut intermediate_path = new_path.clone(); + intermediate_path.pop(); // Remove the subject IRI that was added for multi predicates + objects_to_create.insert((intermediate_path, None)); + } + + // Recurse to the parent first + queue_patches_for_newly_valid_subject( + &parent_ts, + tracked_subjects, + root_shape, + &new_path, + patches, + objects_to_create, + ); + + // Register this object for creation with its IRI + objects_to_create + .insert((new_path.clone(), Some(tracked_subject.subject_iri.clone()))); + } + } + } +} + +/// Check if we should create an intermediate object for a multi-valued predicate. +/// Returns true if the parent's predicate is multi-valued and no siblings were previously valid. +fn check_should_create_parent_predicate_object( + tracked_subject: &OrmTrackedSubject, + parent_ts: &OrmTrackedSubject, +) -> bool { + // Find the predicate schema linking parent to this subject + for pred_arc in &parent_ts.shape.predicates { + if let Some(tracked_pred) = parent_ts.tracked_predicates.get(&pred_arc.iri) { + let tp = tracked_pred.read().unwrap(); + + // Check if this tracked subject is a child of this predicate + let is_child = tp.tracked_children.iter().any(|child| { + let child_read = child.read().unwrap(); + child_read.subject_iri == tracked_subject.subject_iri + }); + + if is_child { + let is_multi = pred_arc.maxCardinality > 1 || pred_arc.maxCardinality == -1; + + if is_multi { + // Check if any siblings were previously valid + let any_sibling_was_valid = tp.tracked_children.iter().any(|child| { + let child_read = child.read().unwrap(); + child_read.subject_iri != tracked_subject.subject_iri + && child_read.prev_valid == OrmTrackedSubjectValidity::Valid + }); + + return !any_sibling_was_valid; + } + + return false; + } + } + } + false +} + /// Find the predicate schema linking a parent to a child tracked subject and build the path segment. /// Returns the updated path if a linking predicate is found. fn build_path_segment_for_parent( @@ -321,7 +426,7 @@ fn build_path_to_root_and_create_patches( Option, // The IRI, if change is an added / removed object. ), patches: &mut Vec, - paths_of_objects_to_create: &mut HashSet<(Vec, Option)>, + objects_to_create: &mut HashSet<(Vec, Option)>, ) { // If the tracked subject is not valid, we don't create patches for it if tracked_subject.valid != OrmTrackedSubjectValidity::Valid { @@ -331,53 +436,14 @@ fn build_path_to_root_and_create_patches( // If the tracked subject is newly valid (was not valid before but is now), // we need to ensure the object is created with an "add object" patch if tracked_subject.prev_valid != OrmTrackedSubjectValidity::Valid { - // Check if we're at a root subject or need to traverse to parents - if tracked_subject.parents.is_empty() || tracked_subject.shape.iri == *root_shape { - // At root: build the path with the subject IRI - let escaped_path: Vec = - path.iter().map(|seg| escape_json_pointer(seg)).collect(); - let json_pointer = format!( - "/{}/{}", - escape_json_pointer(&tracked_subject.subject_iri), - escaped_path.join("/") - ); - - // Create an "add object" patch to ensure the object exists - patches.push(OrmDiffOp { - op: OrmDiffOpType::add, - valType: Some(OrmDiffType::object), - path: json_pointer.clone(), - value: None, - }); - - // Also add the id field for the object - patches.push(OrmDiffOp { - op: OrmDiffOpType::add, - valType: None, - path: format!("{}/id", json_pointer), - value: Some(json!(tracked_subject.subject_iri)), - }); - } else { - // Not at root: traverse to parents and create object patches along the way - for (_parent_iri, parent_tracked_subject) in tracked_subject.parents.iter() { - let parent_ts = parent_tracked_subject.read().unwrap(); - - if let Some(new_path) = - build_path_segment_for_parent(tracked_subject, &parent_ts, path) - { - // Recurse to the parent first - build_path_to_root_and_create_patches( - &parent_ts, - tracked_subjects, - root_shape, - &mut new_path.clone(), - (OrmDiffOpType::add, Some(OrmDiffType::object), None, None), - patches, - paths_of_objects_to_create, - ); - } - } - } + queue_patches_for_newly_valid_subject( + tracked_subject, + tracked_subjects, + root_shape, + path, + patches, + objects_to_create, + ); } // If this subject has no parents or its shape matches the root shape, we've reached the root @@ -399,16 +465,6 @@ fn build_path_to_root_and_create_patches( value: diff_op.2.clone(), }); - // // If a new object is created on a predicate where multiple ones are allowed, create IRI path too. - // if let Some(added_obj_iri) = diff_op.3 { - // patches.push(OrmDiffOp { - // op: diff_op.0.clone(), - // valType: diff_op.1.clone(), - // path: format!("{}/{}", json_pointer, escape_json_pointer(&added_obj_iri)), - // value: diff_op.2.clone(), - // }); - // } - return; } @@ -426,7 +482,7 @@ fn build_path_to_root_and_create_patches( &mut new_path.clone(), diff_op.clone(), patches, - paths_of_objects_to_create, + objects_to_create, ); } } diff --git a/sdk/rust/src/tests/orm_patches.rs b/sdk/rust/src/tests/orm_patches.rs index 1cb7938..a96367b 100644 --- a/sdk/rust/src/tests/orm_patches.rs +++ b/sdk/rust/src/tests/orm_patches.rs @@ -23,7 +23,7 @@ use serde_json::Value; use std::collections::HashMap; #[async_std::test] -async fn test_orm_path_creation() { +async fn test_orm_patch_creation() { // Setup wallet and document let (_wallet, session_id) = create_or_open_wallet().await; @@ -34,7 +34,10 @@ async fn test_orm_path_creation() { test_patch_remove_array(session_id).await; // // === - // test_orm_with_optional(session_id).await; + // test_patch_add_nested_1(session_id).await; + + // === + test_patch_nested_house_inhabitants(session_id).await; // // === // test_orm_literal(session_id).await; @@ -153,6 +156,7 @@ INSERT DATA { ex:arr 3 . + a ex:TestObject ; ex:arr 0 . } "# @@ -196,7 +200,6 @@ INSERT DATA { "value": [3.0], "path": "/urn:test:numArrayObj3/numArray", }, - // TODO: The two below are not added. { "op": "add", "valType": "object", @@ -209,6 +212,12 @@ INSERT DATA { "path": "/urn:test:numArrayObj4/id", "valType": Value::Null, }, + { + "op": "add", + "value": "http://example.org/TestObject", + "path": "/urn:test:numArrayObj4/type", + "valType": Value::Null, + }, { "op": "add", "valType": "set", @@ -353,6 +362,9 @@ DELETE DATA { } } +/// Tests edge case that is an open TODO about a modified nested object +/// that changes so that another allowed shape becomes valid. +/// See handle_backend_update's TODO comment. async fn test_patch_add_nested_1(session_id: u64) { let doc_nuri = create_doc_with_data( session_id, @@ -592,39 +604,32 @@ INSERT DATA { } } -/* +// Temporary file - content to be appended to orm_patches.rs - -Old things - -*/ -async fn test_orm_nested_2(session_id: u64) { +/// Test nested modifications with House -> Person -> Cat hierarchy +async fn test_patch_nested_house_inhabitants(session_id: u64) { let doc_nuri = create_doc_with_data( session_id, r#" PREFIX ex: INSERT DATA { - # Valid - - ex:knows , ; - ex:name "Alice" . - - ex:knows ; - ex:name "Bob" . - - ex:name "Claire" . - - # Invalid because claire2 is invalid - - ex:knows , ; - ex:name "Alice" . - # Invalid because claire2 is invalid - - ex:knows ; + + a ex:House ; + ex:rootColor "blue" ; + ex:inhabitants , . + + + a ex:Person ; + ex:name "Alice" ; + ex:hasCat . + + + a ex:Person ; ex:name "Bob" . - # Invalid because name is missing. - - ex:missingName "Claire missing" . + + + a ex:Cat ; + ex:catName "Whiskers" . } "# .to_string(), @@ -633,17 +638,34 @@ INSERT DATA { // Define the ORM schema let mut schema = HashMap::new(); + + // House shape schema.insert( - "http://example.org/PersonShape".to_string(), + "http://example.org/HouseShape".to_string(), OrmSchemaShape { - iri: "http://example.org/PersonShape".to_string(), + iri: "http://example.org/HouseShape".to_string(), predicates: vec![ OrmSchemaPredicate { - iri: "http://example.org/name".to_string(), - extra: None, + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), maxCardinality: 1, minCardinality: 1, - readablePredicate: "name".to_string(), + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaLiteralType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/House".to_string(), + )]), + shape: None, + }], + } + .into(), + OrmSchemaPredicate { + iri: "http://example.org/rootColor".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 0, + readablePredicate: "rootColor".to_string(), dataTypes: vec![OrmSchemaDataType { valType: OrmSchemaLiteralType::string, literals: None, @@ -652,11 +674,11 @@ INSERT DATA { } .into(), OrmSchemaPredicate { - iri: "http://example.org/knows".to_string(), + iri: "http://example.org/inhabitants".to_string(), extra: Some(false), maxCardinality: -1, - minCardinality: 0, - readablePredicate: "knows".to_string(), + minCardinality: 1, + readablePredicate: "inhabitants".to_string(), dataTypes: vec![OrmSchemaDataType { valType: OrmSchemaLiteralType::shape, literals: None, @@ -669,188 +691,87 @@ INSERT DATA { .into(), ); - let shape_type = OrmShapeType { - schema, - shape: "http://example.org/PersonShape".to_string(), - }; - - let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); - let (mut receiver, cancel_fn) = orm_start(nuri, shape_type, session_id) - .await - .expect("orm_start"); - - while let Some(app_response) = receiver.next().await { - let orm_json = match app_response { - AppResponse::V0(v) => match v { - AppResponseV0::OrmInitial(json) => Some(json), - _ => None, - }, - } - .unwrap(); - - log_info!( - "ORM JSON arrived for nested2 (person) test\n: {:?}", - orm_json - ); - - // Expected: alice and bob with their nested knows relationships - // claire2 is invalid (missing name), so alice2's knows chain is incomplete - let mut expected = json!([ - { - "id": "urn:test:alice", - "name": "Alice", - "knows": { - "urn:test:bob": { - "name": "Bob", - "knows": { - "urn:test:claire": { - "name": "Claire", - "knows": {} - } - } - }, - "urn:test:claire": { - "name": "Claire", - "knows": {} - } - } - }, - { - "id": "urn:test:bob", - "name": "Bob", - "knows": { - "urn:test:claire": { - "name": "Claire", - "knows": {} - } - } - }, - { - "id": "urn:test:claire", - "name": "Claire", - "knows": {} - } - ]); - - let mut actual_mut = orm_json.clone(); - log_info!( - "JSON for nested2\n{}", - serde_json::to_string(&actual_mut).unwrap() - ); - assert_json_eq(&mut expected, &mut actual_mut); - - break; - } - cancel_fn(); -} - -async fn test_orm_nested_3(session_id: u64) { - let doc_nuri = create_doc_with_data( - session_id, - r#" -PREFIX ex: -INSERT DATA { - # Valid - - a ex:Alice ; - ex:knows , . - - a ex:Bob ; - ex:knows . - - a ex:Claire . - - # Invalid because claire is invalid - - a ex:Alice ; - ex:knows , . - # Invalid because claire is invalid - - a ex:Bob ; - ex:knows . - # Invalid, wrong type. - - a ex:Claire2 . -} -"# - .to_string(), - ) - .await; - - // Define the ORM schema - let mut schema = HashMap::new(); + // Person shape schema.insert( - "http://example.org/AliceShape".to_string(), + "http://example.org/PersonShape".to_string(), OrmSchemaShape { - iri: "http://example.org/AliceShape".to_string(), + iri: "http://example.org/PersonShape".to_string(), predicates: vec![ OrmSchemaPredicate { iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), - extra: None, + extra: Some(false), maxCardinality: 1, minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { valType: OrmSchemaLiteralType::literal, literals: Some(vec![BasicType::Str( - "http://example.org/Alice".to_string(), + "http://example.org/Person".to_string(), )]), shape: None, }], } .into(), OrmSchemaPredicate { - iri: "http://example.org/knows".to_string(), + iri: "http://example.org/name".to_string(), extra: Some(false), - maxCardinality: -1, + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "name".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaLiteralType::string, + literals: None, + shape: None, + }], + } + .into(), + OrmSchemaPredicate { + iri: "http://example.org/hasCat".to_string(), + extra: Some(false), + maxCardinality: 1, minCardinality: 0, - readablePredicate: "knows".to_string(), - dataTypes: vec![ - OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, - literals: None, - shape: Some("http://example.org/BobShape".to_string()), - }, - OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, - literals: None, - shape: Some("http://example.org/ClaireShape".to_string()), - }, - ], + readablePredicate: "cat".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaLiteralType::shape, + literals: None, + shape: Some("http://example.org/CatShape".to_string()), + }], } .into(), ], } .into(), ); + + // Cat shape schema.insert( - "http://example.org/BobShape".to_string(), + "http://example.org/CatShape".to_string(), OrmSchemaShape { - iri: "http://example.org/BobShape".to_string(), + iri: "http://example.org/CatShape".to_string(), predicates: vec![ OrmSchemaPredicate { iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), - extra: Some(true), + extra: Some(false), maxCardinality: 1, minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { valType: OrmSchemaLiteralType::literal, - literals: Some(vec![BasicType::Str("http://example.org/Bob".to_string())]), + literals: Some(vec![BasicType::Str("http://example.org/Cat".to_string())]), shape: None, }], } .into(), OrmSchemaPredicate { - iri: "http://example.org/knows".to_string(), + iri: "http://example.org/catName".to_string(), extra: Some(false), - maxCardinality: -1, - minCardinality: 0, - readablePredicate: "knows".to_string(), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "name".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaLiteralType::string, literals: None, - shape: Some("http://example.org/ClaireShape".to_string()), + shape: None, }], } .into(), @@ -858,32 +779,10 @@ INSERT DATA { } .into(), ); - schema.insert( - "http://example.org/ClaireShape".to_string(), - OrmSchemaShape { - iri: "http://example.org/ClaireShape".to_string(), - predicates: vec![OrmSchemaPredicate { - iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), - extra: None, - maxCardinality: 1, - minCardinality: 1, - readablePredicate: "type".to_string(), - dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, - literals: Some(vec![BasicType::Str( - "http://example.org/Claire".to_string(), - )]), - shape: None, - }], - } - .into()], - } - .into(), - ); let shape_type = OrmShapeType { schema, - shape: "http://example.org/AliceShape".to_string(), + shape: "http://example.org/HouseShape".to_string(), }; let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); @@ -891,8 +790,9 @@ INSERT DATA { .await .expect("orm_start"); + // Get initial state while let Some(app_response) = receiver.next().await { - let orm_json = match app_response { + let _ = match app_response { AppResponse::V0(v) => match v { AppResponseV0::OrmInitial(json) => Some(json), _ => None, @@ -900,160 +800,249 @@ INSERT DATA { } .unwrap(); - log_info!( - "ORM JSON arrived for nested3 (person) test\n: {:?}", - serde_json::to_string(&orm_json).unwrap() - ); - - // Expected: alice with knows relationships to bob and claire - // alice2 is incomplete because claire2 has wrong type - let mut expected = json!([ - { - "id": "urn:test:alice", - "type": "http://example.org/Alice", - "knows": { - "urn:test:bob": { - "type": "http://example.org/Bob", - "knows": { - "urn:test:claire": { - "type": "http://example.org/Claire" - } - } - }, - "urn:test:claire": { - "type": "http://example.org/Claire" - } - } - } - ]); - - let mut actual_mut = orm_json.clone(); - assert_json_eq(&mut expected, &mut actual_mut); - break; } - cancel_fn(); -} -async fn test_orm_nested_4(session_id: u64) { - let doc_nuri = create_doc_with_data( + log_info!( + "\n=== TEST 1: INSERT - Adding new person with cat, modifying existing properties ===\n" + ); + + // INSERT: Add a new person with a cat, modify house color, modify existing person's name, add cat to Bob + doc_sparql_update( session_id, r#" PREFIX ex: +DELETE DATA { + ex:rootColor "blue" . + ex:name "Alice" . +} +; INSERT DATA { - # Valid - + + ex:rootColor "red" ; + ex:inhabitants . + + + ex:name "Alicia" . + + + ex:hasCat . + + a ex:Person ; - ex:hasCat , . - - a ex:Cat . - - a ex:Cat . + ex:name "Charlie" ; + ex:hasCat . + + + a ex:Cat ; + ex:catName "Mittens" . + + + a ex:Cat ; + ex:catName "Fluffy" . } "# .to_string(), + Some(doc_nuri.clone()), ) - .await; + .await + .expect("INSERT SPARQL update failed"); - // Define the ORM schema - let mut schema = HashMap::new(); - schema.insert( - "http://example.org/PersonShape".to_string(), - OrmSchemaShape { - iri: "http://example.org/PersonShape".to_string(), - predicates: vec![ - OrmSchemaPredicate { - iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), - extra: None, - maxCardinality: 1, - minCardinality: 1, - readablePredicate: "type".to_string(), - dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, - literals: Some(vec![BasicType::Str( - "http://example.org/Person".to_string(), - )]), - shape: None, - }], - } - .into(), - OrmSchemaPredicate { - iri: "http://example.org/hasCat".to_string(), - extra: Some(false), - maxCardinality: -1, - minCardinality: 0, - readablePredicate: "cats".to_string(), - dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, - literals: None, - shape: Some("http://example.org/CatShape".to_string()), - }], - } - .into(), - ], + while let Some(app_response) = receiver.next().await { + let patches = match app_response { + AppResponse::V0(v) => match v { + AppResponseV0::OrmUpdate(json) => Some(json), + _ => None, + }, } - .into(), - ); - schema.insert( - "http://example.org/CatShape".to_string(), - OrmSchemaShape { - iri: "http://example.org/CatShape".to_string(), - predicates: vec![OrmSchemaPredicate { - iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), - extra: Some(true), - maxCardinality: 1, - minCardinality: 1, - readablePredicate: "type".to_string(), - dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, - literals: Some(vec![BasicType::Str("http://example.org/Cat".to_string())]), - shape: None, - }], - } - .into()], + .unwrap(); + + log_info!("INSERT patches arrived:\n"); + for patch in patches.iter() { + log_info!("{:?}", patch); } - .into(), - ); - let shape_type = OrmShapeType { - schema, - shape: "http://example.org/PersonShape".to_string(), - }; + let mut expected = json!([ + // Modified house color + { + "op": "remove", + "path": "/urn:test:house1/rootColor", + }, + { + "op": "add", + "value": "red", + "path": "/urn:test:house1/rootColor", + }, + // Modified Alice's name + { + "op": "remove", + "path": "/urn:test:house1/inhabitants/urn:test:person1/name", + }, + { + "op": "add", + "value": "Alicia", + "path": "/urn:test:house1/inhabitants/urn:test:person1/name", + }, + // Bob gets a cat + { + "op": "add", + "valType": "object", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat", + }, + { + "op": "add", + "value": "urn:test:cat2", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat/id", + }, + { + "op": "add", + "value": "http://example.org/Cat", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat/type", + }, + { + "op": "add", + "value": "Mittens", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat/name", + }, + // New person Charlie with cat + { + "op": "add", + "valType": "object", + "path": "/urn:test:house1/inhabitants/urn:test:person3", + }, + { + "op": "add", + "value": "urn:test:person3", + "path": "/urn:test:house1/inhabitants/urn:test:person3/id", + }, + { + "op": "add", + "value": "http://example.org/Person", + "path": "/urn:test:house1/inhabitants/urn:test:person3/type", + }, + { + "op": "add", + "value": "Charlie", + "path": "/urn:test:house1/inhabitants/urn:test:person3/name", + }, + { + "op": "add", + "valType": "object", + "path": "/urn:test:house1/inhabitants/urn:test:person3/cat", + }, + { + "op": "add", + "value": "urn:test:cat3", + "path": "/urn:test:house1/inhabitants/urn:test:person3/cat/id", + }, + { + "op": "add", + "value": "http://example.org/Cat", + "path": "/urn:test:house1/inhabitants/urn:test:person3/cat/type", + }, + { + "op": "add", + "value": "Fluffy", + "path": "/urn:test:house1/inhabitants/urn:test:person3/cat/name", + }, + ]); - let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); - let (mut receiver, cancel_fn) = orm_start(nuri, shape_type, session_id) - .await - .expect("orm_start"); + let mut actual = json!(patches); + assert_json_eq(&mut expected, &mut actual); + + break; + } + + log_info!("\n=== TEST 2: DELETE - Removing cat, person, and modifying properties ===\n"); + + // DELETE: Remove Whiskers, remove Charlie and his cat, modify cat name, remove house color + doc_sparql_update( + session_id, + r#" +PREFIX ex: +DELETE DATA { + + ex:rootColor "red" ; + ex:inhabitants . + + + ex:hasCat . + + + a ex:Person ; + ex:name "Charlie" ; + ex:hasCat . + + + a ex:Cat ; + ex:catName "Whiskers" . + + + ex:catName "Mittens" . + + + a ex:Cat ; + ex:catName "Fluffy" . +} +; +INSERT DATA { + + ex:catName "Mr. Mittens" . +} +"# + .to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("DELETE SPARQL update failed"); while let Some(app_response) = receiver.next().await { - let orm_json = match app_response { + let patches = match app_response { AppResponse::V0(v) => match v { - AppResponseV0::OrmInitial(json) => Some(json), + AppResponseV0::OrmUpdate(json) => Some(json), _ => None, }, } .unwrap(); + log_info!("DELETE patches arrived:\n"); + for patch in patches.iter() { + log_info!("{:?}", patch); + } + let mut expected = json!([ + // Remove house color { - "id": "urn:test:alice", - "type": "http://example.org/Person", - "cats": { - "urn:test:kitten1": { - "type": "http://example.org/Cat" - }, - "urn:test:kitten2": { - "type": "http://example.org/Cat" - } - }, - } + "op": "remove", + "path": "/urn:test:house1/rootColor", + }, + // Alice loses her cat + { + "op": "remove", + "valType": "object", + "path": "/urn:test:house1/inhabitants/urn:test:person1/cat", + }, + // Bob's cat name changes + { + "op": "remove", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat/name", + }, + { + "op": "add", + "value": "Mr. Mittens", + "path": "/urn:test:house1/inhabitants/urn:test:person2/cat/name", + }, + // Charlie and his cat are removed + { + "op": "remove", + "valType": "object", + "path": "/urn:test:house1/inhabitants/urn:test:person3", + }, ]); - let mut actual_mut = orm_json.clone(); - - assert_json_eq(&mut expected, &mut actual_mut); + let mut actual = json!(patches); + assert_json_eq(&mut expected, &mut actual); break; } - cancel_fn(); } From 952dce50d91a80c87331e33d1b8cb619463b727e Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Thu, 16 Oct 2025 18:28:39 +0200 Subject: [PATCH 2/6] fix nested fetching and processing of objects becoming valid --- .../verifier/src/orm/handle_backend_update.rs | 4 +- engine/verifier/src/orm/process_changes.rs | 164 +++++++++--------- 2 files changed, 87 insertions(+), 81 deletions(-) diff --git a/engine/verifier/src/orm/handle_backend_update.rs b/engine/verifier/src/orm/handle_backend_update.rs index ce35c19..d7e9653 100644 --- a/engine/verifier/src/orm/handle_backend_update.rs +++ b/engine/verifier/src/orm/handle_backend_update.rs @@ -109,8 +109,10 @@ impl Verifier { // Apply the changes to tracked subjects. for shape_arc in shapes { + let shape_iri = shape_arc.iri.clone(); let _ = self.process_changes_for_shape_and_session( &scope, + &shape_iri, shape_arc, session_id, &triple_inserts, @@ -352,7 +354,7 @@ fn check_should_create_parent_predicate_object( if is_child { let is_multi = pred_arc.maxCardinality > 1 || pred_arc.maxCardinality == -1; - + if is_multi { // Check if any siblings were previously valid let any_sibling_was_valid = tp.tracked_children.iter().any(|child| { diff --git a/engine/verifier/src/orm/process_changes.rs b/engine/verifier/src/orm/process_changes.rs index b3f698f..6445771 100644 --- a/engine/verifier/src/orm/process_changes.rs +++ b/engine/verifier/src/orm/process_changes.rs @@ -81,54 +81,15 @@ impl Verifier { Ok(merged) } - /// Helper to call process_changes_for_shape for all subscriptions on nuri's document. - fn process_changes_for_nuri_and_session( - self: &mut Self, - nuri: &NuriV0, - session_id: u64, - triples_added: &[Triple], - triples_removed: &[Triple], - data_already_fetched: bool, - ) -> Result { - let mut orm_changes = HashMap::new(); - - let shapes: Vec<_> = self - .orm_subscriptions - .get(nuri) - .unwrap() - .iter() - .map(|sub| { - sub.shape_type - .schema - .get(&sub.shape_type.shape) - .unwrap() - .clone() - }) - .collect(); - - for root_shape in shapes { - self.process_changes_for_shape_and_session( - nuri, - root_shape, - session_id, - triples_added, - triples_removed, - &mut orm_changes, - data_already_fetched, - )?; - } - - Ok(orm_changes) - } - /// Add and remove the triples from the tracked subjects, /// re-validate, and update `changes` containing the updated data. /// Works by queuing changes by shape and subjects on a stack. /// Nested objects are added to the stack pub(crate) fn process_changes_for_shape_and_session( - self: &mut Self, + &mut self, nuri: &NuriV0, - root_shape: Arc, + root_shape_iri: &String, + shape: Arc, session_id: u64, triples_added: &[Triple], triples_removed: &[Triple], @@ -140,8 +101,7 @@ impl Verifier { // Track (shape_iri, subject_iri) pairs currently being validated to prevent cycles and double evaluation. let mut currently_validating: HashSet<(String, String)> = HashSet::new(); // Add root shape for first validation run. - let root_shape_iri = root_shape.iri.clone(); - shape_validation_stack.push((root_shape, vec![])); + shape_validation_stack.push((shape, vec![])); // Process queue of shapes and subjects to validate. // For a given shape, we evaluate every subject against that shape. @@ -156,14 +116,6 @@ impl Verifier { .chain(removed_triples_by_subject.keys()) .collect(); - let mut orm_subscription = self - .orm_subscriptions - .get_mut(nuri) - .unwrap() - .iter_mut() - .find(|sub| sub.session_id == session_id && sub.shape_type.shape == root_shape_iri) - .unwrap(); - // Variable to collect nested objects that need validation. let mut nested_objects_to_eval: HashMap> = HashMap::new(); @@ -185,8 +137,13 @@ impl Verifier { subject_iri, shape.iri ); - // Mark as invalid due to cycle - // TODO: We could handle this by handling nested references as IRIs. + + // Find tracked and mark as invalid. + let orm_subscription = &mut self.get_first_orm_subscription_for( + nuri, + Some(&root_shape_iri), + Some(&session_id), + ); if let Some(tracked_shapes) = orm_subscription.tracked_subjects.get(*subject_iri) { @@ -231,12 +188,23 @@ impl Verifier { "Adding triples to change tracker for subject {}", subject_iri ); + + let orm_subscription = self + .orm_subscriptions + .get_mut(nuri) + .unwrap() + .iter_mut() + .find(|sub| { + sub.shape_type.shape == shape.iri && sub.session_id == session_id + }) + .unwrap(); + if let Err(e) = add_remove_triples( shape.clone(), subject_iri, triples_added_for_subj, triples_removed_for_subj, - &mut orm_subscription, + orm_subscription, change, ) { log_err!("apply_changes_from_triples add/remove error: {:?}", e); @@ -247,9 +215,18 @@ impl Verifier { log_debug!("not applying triples again for subject {subject_iri}"); } + let orm_subscription = self + .orm_subscriptions + .get_mut(nuri) + .unwrap() + .iter_mut() + .find(|sub| { + sub.shape_type.shape == shape.iri && sub.session_id == session_id + }) + .unwrap(); + // Validate the subject. - let need_eval = - Self::update_subject_validity(change, &shape, &mut orm_subscription); + let need_eval = Self::update_subject_validity(change, &shape, orm_subscription); // We add the need_eval to be processed next after loop. // Filter out subjects already in the validation stack to prevent double evaluation. @@ -268,13 +245,15 @@ impl Verifier { // Now, we queue all non-evaluated objects for (shape_iri, objects_to_eval) in &nested_objects_to_eval { - let orm_subscription = self.get_first_orm_subscription_for( - nuri, - Some(&root_shape_iri), - Some(&session_id), - ); - // Extract schema and shape Arc before mutable borrow - let schema = orm_subscription.shape_type.schema.clone(); + // Extract schema and shape Arc first (before any borrows) + let schema = { + let orm_sub = self.get_first_orm_subscription_for( + nuri, + Some(&root_shape_iri), + Some(&session_id), + ); + orm_sub.shape_type.schema.clone() + }; let shape_arc = schema.get(shape_iri).unwrap().clone(); // Data might need to be fetched (if it has not been during initialization or nested shape fetch). @@ -294,6 +273,7 @@ impl Verifier { // Recursively process nested objects. self.process_changes_for_shape_and_session( nuri, + &root_shape_iri, shape_arc.clone(), session_id, &new_triples, @@ -323,23 +303,47 @@ impl Verifier { Ok(()) } - /// Helper to get orm subscriptions for nuri, shapes and sessions. - pub fn get_orm_subscriptions_for( - &self, + /// Helper to call process_changes_for_shape for all subscriptions on nuri's document. + fn process_changes_for_nuri_and_session( + self: &mut Self, nuri: &NuriV0, - shape: Option<&ShapeIri>, - session_id: Option<&u64>, - ) -> Vec<&OrmSubscription> { - self.orm_subscriptions.get(nuri).unwrap(). - // Filter shapes, if present. - iter().filter(|s| match shape { - Some(sh) => *sh == s.shape_type.shape, - None => true - // Filter session ids if present. - }).filter(|s| match session_id { - Some(id) => *id == s.session_id, - None => true - }).collect() + session_id: u64, + triples_added: &[Triple], + triples_removed: &[Triple], + data_already_fetched: bool, + ) -> Result { + let mut orm_changes = HashMap::new(); + + let shapes: Vec<_> = self + .orm_subscriptions + .get(nuri) + .unwrap() + .iter() + .map(|sub| { + sub.shape_type + .schema + .get(&sub.shape_type.shape) + .unwrap() + .clone() + }) + .collect(); + + for root_shape in shapes { + let shape_iri = root_shape.iri.clone(); + // Now we can safely call the method with self + self.process_changes_for_shape_and_session( + nuri, + &shape_iri, + root_shape, + session_id, + triples_added, + triples_removed, + &mut orm_changes, + data_already_fetched, + )?; + } + + Ok(orm_changes) } pub fn get_first_orm_subscription_for( From e63941054aea25ca64ce98cb42cc4d41fa40a3fc Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Thu, 16 Oct 2025 22:33:46 +0200 Subject: [PATCH 3/6] wip --- .../verifier/src/orm/handle_backend_update.rs | 6 +- engine/verifier/src/orm/process_changes.rs | 64 +++++++++++++------ engine/verifier/src/orm/shape_validation.rs | 8 ++- sdk/rust/src/tests/orm_patches.rs | 6 +- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/engine/verifier/src/orm/handle_backend_update.rs b/engine/verifier/src/orm/handle_backend_update.rs index d7e9653..0c9f5b6 100644 --- a/engine/verifier/src/orm/handle_backend_update.rs +++ b/engine/verifier/src/orm/handle_backend_update.rs @@ -286,11 +286,11 @@ fn queue_patches_for_newly_valid_subject( // Register object for creation. // Path to object consists of this subject's iri and the path except for the last element. let mut path_to_subject = vec![tracked_subject.subject_iri.clone()]; - if path.len() > 0 { - path_to_subject.extend_from_slice(&path[1..]); + if path.len() > 1 { + path_to_subject.extend_from_slice(&path[..path.len() - 1]); } - log_debug!("Queuing object creation for path: {:?}", path_to_subject); + // log_debug!("Queuing object creation for path: {:?}", path_to_subject); // Always create the object itself with its IRI objects_to_create.insert(( diff --git a/engine/verifier/src/orm/process_changes.rs b/engine/verifier/src/orm/process_changes.rs index 6445771..011c228 100644 --- a/engine/verifier/src/orm/process_changes.rs +++ b/engine/verifier/src/orm/process_changes.rs @@ -183,22 +183,23 @@ impl Verifier { // Apply all triples for that subject to the tracked (shape, subject) pair. // Record the changes. { + let orm_subscription = self + .orm_subscriptions + .get_mut(nuri) + .unwrap() + .iter_mut() + .find(|sub| { + sub.shape_type.shape == *root_shape_iri && sub.session_id == session_id + }) + .unwrap(); + + // Update tracked subjects and modify change objects. if !change.data_applied { log_debug!( "Adding triples to change tracker for subject {}", subject_iri ); - let orm_subscription = self - .orm_subscriptions - .get_mut(nuri) - .unwrap() - .iter_mut() - .find(|sub| { - sub.shape_type.shape == shape.iri && sub.session_id == session_id - }) - .unwrap(); - if let Err(e) = add_remove_triples( shape.clone(), subject_iri, @@ -211,19 +212,40 @@ impl Verifier { panic!(); } change.data_applied = true; - } else { - log_debug!("not applying triples again for subject {subject_iri}"); } - let orm_subscription = self - .orm_subscriptions - .get_mut(nuri) - .unwrap() - .iter_mut() - .find(|sub| { - sub.shape_type.shape == shape.iri && sub.session_id == session_id - }) - .unwrap(); + // Check if this is the first evaluation round - In that case, set old validity to new one. + // if the object was already validated, don't do so again. + { + let tracked_subject = &mut orm_subscription + .tracked_subjects + .get(*subject_iri) + .unwrap() + .get(&shape.iri) + .unwrap() + .write() + .unwrap(); + + // First run + if !change.data_applied + && tracked_subject.valid != OrmTrackedSubjectValidity::Pending + { + tracked_subject.prev_valid = tracked_subject.valid.clone(); + } + + if change.data_applied { + log_debug!("not applying triples again for subject {subject_iri}"); + + // Has this subject already been validated? + if change.data_applied + && tracked_subject.valid != OrmTrackedSubjectValidity::Pending + { + log_debug!("Not evaluating subject again {subject_iri}"); + + continue; + } + } + } // Validate the subject. let need_eval = Self::update_subject_validity(change, &shape, orm_subscription); diff --git a/engine/verifier/src/orm/shape_validation.rs b/engine/verifier/src/orm/shape_validation.rs index 041cf58..acfb2b4 100644 --- a/engine/verifier/src/orm/shape_validation.rs +++ b/engine/verifier/src/orm/shape_validation.rs @@ -33,7 +33,6 @@ impl Verifier { }; let mut tracked_subject = tracked_subject.write().unwrap(); let previous_validity = tracked_subject.prev_valid.clone(); - tracked_subject.prev_valid = tracked_subject.valid.clone(); // Keep track of objects that need to be validated against a shape to fetch and validate. let mut need_evaluation: Vec<(String, String, bool)> = vec![]; @@ -45,7 +44,10 @@ impl Verifier { ); // Check 1) Check if this object is untracked and we need to remove children and ourselves. - if previous_validity == OrmTrackedSubjectValidity::Untracked { + if previous_validity == OrmTrackedSubjectValidity::Untracked + // If .valid is pending, this part was executed before in this validation round. + && tracked_subject.valid != OrmTrackedSubjectValidity::Pending + { // 1.1) Schedule children for deletion // 1.1.1) Set all children to `untracked` that don't have other parents. for tracked_predicate in tracked_subject.tracked_predicates.values() { @@ -342,6 +344,8 @@ impl Verifier { }; } + // == End of validation part. Next, process side-effects == + tracked_subject.valid = new_validity.clone(); if new_validity == OrmTrackedSubjectValidity::Invalid { diff --git a/sdk/rust/src/tests/orm_patches.rs b/sdk/rust/src/tests/orm_patches.rs index a96367b..63438fb 100644 --- a/sdk/rust/src/tests/orm_patches.rs +++ b/sdk/rust/src/tests/orm_patches.rs @@ -29,9 +29,9 @@ async fn test_orm_patch_creation() { // Tests below all in this test, to prevent waiting times through wallet creation. - // === - test_patch_add_array(session_id).await; - test_patch_remove_array(session_id).await; + // // === + // test_patch_add_array(session_id).await; + // test_patch_remove_array(session_id).await; // // === // test_patch_add_nested_1(session_id).await; From 197e010034a3457d8e5292ab36e30aa67035c061 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Fri, 17 Oct 2025 01:58:03 +0200 Subject: [PATCH 4/6] handle_backend_update generates valid patches --- .../verifier/src/orm/handle_backend_update.rs | 90 +++++++++++++------ engine/verifier/src/orm/materialize.rs | 7 +- engine/verifier/src/orm/process_changes.rs | 13 ++- sdk/rust/src/tests/mod.rs | 2 +- sdk/rust/src/tests/orm_patches.rs | 10 +-- 5 files changed, 74 insertions(+), 48 deletions(-) diff --git a/engine/verifier/src/orm/handle_backend_update.rs b/engine/verifier/src/orm/handle_backend_update.rs index 0c9f5b6..1ac9b9f 100644 --- a/engine/verifier/src/orm/handle_backend_update.rs +++ b/engine/verifier/src/orm/handle_backend_update.rs @@ -68,8 +68,6 @@ impl Verifier { }) .collect(); - // let mut updates = Vec::new(); - let mut scopes = vec![]; for (scope, subs) in self.orm_subscriptions.iter_mut() { // Remove old subscriptions @@ -86,34 +84,37 @@ impl Verifier { } // prepare to apply updates to tracked subjects and record the changes. - let root_shapes = subs + let root_shapes_and_tracked_subjects = subs .iter() .map(|sub| { - sub.shape_type - .schema - .get(&sub.shape_type.shape) - .unwrap() - .clone() + ( + sub.shape_type + .schema + .get(&sub.shape_type.shape) + .unwrap() + .clone(), + shapes_in_tracked_subjects(&sub.tracked_subjects), + ) }) .collect::>(); - scopes.push((scope.clone(), root_shapes)); + scopes.push((scope.clone(), root_shapes_and_tracked_subjects)); } log_debug!( "[orm_backend_update], creating patch objects for #scopes {}", scopes.len() ); - for (scope, shapes) in scopes { + for (scope, shapes_zip) in scopes { let mut orm_changes: OrmChanges = HashMap::new(); // Apply the changes to tracked subjects. - for shape_arc in shapes { - let shape_iri = shape_arc.iri.clone(); + for (root_shape_arc, all_shapes) in shapes_zip { + let shape_iri = root_shape_arc.iri.clone(); let _ = self.process_changes_for_shape_and_session( &scope, &shape_iri, - shape_arc, + all_shapes, session_id, &triple_inserts, &triple_removes, @@ -159,6 +160,11 @@ impl Verifier { // Iterate over all changes and create patches for (shape_iri, subject_changes) in &orm_changes { for (subject_iri, change) in subject_changes { + log_debug!( + "Patch creating for subject change {}. #changed preds: {}", + subject_iri, + change.predicates.len() + ); // Get the tracked subject for this (subject, shape) pair let tracked_subject = sub .tracked_subjects @@ -201,6 +207,13 @@ impl Verifier { // The subject is valid or has become valid. // Process each predicate change for (_pred_iri, pred_change) in &change.predicates { + log_debug!( + " - Predicate changes: {}; #Adds: {}; #Removes {}", + _pred_iri, + pred_change.values_added.len(), + pred_change.values_removed.len() + ); + let tracked_predicate = pred_change.tracked_predicate.read().unwrap(); let pred_name = tracked_predicate.schema.readablePredicate.clone(); @@ -430,24 +443,16 @@ fn build_path_to_root_and_create_patches( patches: &mut Vec, objects_to_create: &mut HashSet<(Vec, Option)>, ) { + log_debug!( + " - build path, ts: {}, path {:?}", + tracked_subject.subject_iri, + path + ); // If the tracked subject is not valid, we don't create patches for it if tracked_subject.valid != OrmTrackedSubjectValidity::Valid { return; } - // If the tracked subject is newly valid (was not valid before but is now), - // we need to ensure the object is created with an "add object" patch - if tracked_subject.prev_valid != OrmTrackedSubjectValidity::Valid { - queue_patches_for_newly_valid_subject( - tracked_subject, - tracked_subjects, - root_shape, - path, - patches, - objects_to_create, - ); - } - // If this subject has no parents or its shape matches the root shape, we've reached the root if tracked_subject.parents.is_empty() || tracked_subject.shape.iri == *root_shape { // Build the final JSON Pointer path @@ -459,7 +464,7 @@ fn build_path_to_root_and_create_patches( escaped_path.join("/") ); - // Create the patch + // Create the patch for the actual value change patches.push(OrmDiffOp { op: diff_op.0.clone(), valType: diff_op.1.clone(), @@ -467,6 +472,20 @@ fn build_path_to_root_and_create_patches( value: diff_op.2.clone(), }); + // If the subject is newly valid, now we have the full path to queue its creation. + if tracked_subject.prev_valid != OrmTrackedSubjectValidity::Valid { + let mut final_path = vec![tracked_subject.subject_iri.clone()]; + final_path.extend_from_slice(path); + queue_patches_for_newly_valid_subject( + tracked_subject, + tracked_subjects, + root_shape, + &final_path, + patches, + objects_to_create, + ); + } + return; } @@ -475,13 +494,14 @@ fn build_path_to_root_and_create_patches( let parent_ts = parent_tracked_subject.read().unwrap(); // Build the path segment for this parent - if let Some(new_path) = build_path_segment_for_parent(tracked_subject, &parent_ts, path) { + if let Some(mut new_path) = build_path_segment_for_parent(tracked_subject, &parent_ts, path) + { // Recurse to the parent build_path_to_root_and_create_patches( &parent_ts, tracked_subjects, root_shape, - &mut new_path.clone(), + &mut new_path, diff_op.clone(), patches, objects_to_create, @@ -584,3 +604,15 @@ fn create_diff_ops_from_predicate_change( // } return ops; } + +fn shapes_in_tracked_subjects( + tracked_subjects: &HashMap>>>, +) -> Vec> { + let mut shapes = vec![]; + for (_subject_iri, tss) in tracked_subjects.iter() { + for (_shape_iri, ts) in tss.iter() { + shapes.push(ts.read().unwrap().shape.clone()); + } + } + shapes +} diff --git a/engine/verifier/src/orm/materialize.rs b/engine/verifier/src/orm/materialize.rs index 00efe18..d55017a 100644 --- a/engine/verifier/src/orm/materialize.rs +++ b/engine/verifier/src/orm/materialize.rs @@ -103,16 +103,13 @@ impl Verifier { let mut return_vals: Value = Value::Array(vec![]); let return_val_vec = return_vals.as_array_mut().unwrap(); - // log_debug!( - // "Tracked subjects:\n{:?}\n", - // orm_subscription.tracked_subjects, - // ); + log_debug!("\nMaterializing: {}", shape_type.shape); // For each valid change struct, we build an orm object. // The way we get the changes from the tracked subjects is a bit hacky, sorry. for (subject_iri, tracked_subjects_by_shape) in &orm_subscription.tracked_subjects { if let Some(tracked_subject) = tracked_subjects_by_shape.get(&shape_type.shape) { let ts = tracked_subject.read().unwrap(); - log_info!("changes for: {:?} valid: {:?}\n", ts.subject_iri, ts.valid); + log_info!(" - changes for: {:?} valid: {:?}", ts.subject_iri, ts.valid); if ts.valid == OrmTrackedSubjectValidity::Valid { if let Some(change) = changes diff --git a/engine/verifier/src/orm/process_changes.rs b/engine/verifier/src/orm/process_changes.rs index 011c228..aa36b8b 100644 --- a/engine/verifier/src/orm/process_changes.rs +++ b/engine/verifier/src/orm/process_changes.rs @@ -89,7 +89,7 @@ impl Verifier { &mut self, nuri: &NuriV0, root_shape_iri: &String, - shape: Arc, + shapes: Vec>, session_id: u64, triples_added: &[Triple], triples_removed: &[Triple], @@ -101,7 +101,9 @@ impl Verifier { // Track (shape_iri, subject_iri) pairs currently being validated to prevent cycles and double evaluation. let mut currently_validating: HashSet<(String, String)> = HashSet::new(); // Add root shape for first validation run. - shape_validation_stack.push((shape, vec![])); + for shape in shapes { + shape_validation_stack.push((shape, vec![])); + } // Process queue of shapes and subjects to validate. // For a given shape, we evaluate every subject against that shape. @@ -127,6 +129,7 @@ impl Verifier { shape.iri ); + // For each modified subject, apply changes to tracked subjects and validate. for subject_iri in &modified_subject_iris { let validation_key = (shape.iri.clone(), subject_iri.to_string()); @@ -248,6 +251,8 @@ impl Verifier { } // Validate the subject. + // need_eval contains elements in reverse priority (last element to be validated first) + // TODO: Improve order by distinguishing between parents, children and self to be re-evaluated. let need_eval = Self::update_subject_validity(change, &shape, orm_subscription); // We add the need_eval to be processed next after loop. @@ -296,7 +301,7 @@ impl Verifier { self.process_changes_for_shape_and_session( nuri, &root_shape_iri, - shape_arc.clone(), + [shape_arc.clone()].to_vec(), session_id, &new_triples, &vec![], @@ -356,7 +361,7 @@ impl Verifier { self.process_changes_for_shape_and_session( nuri, &shape_iri, - root_shape, + [root_shape].to_vec(), session_id, triples_added, triples_removed, diff --git a/sdk/rust/src/tests/mod.rs b/sdk/rust/src/tests/mod.rs index caac2a7..37b948a 100644 --- a/sdk/rust/src/tests/mod.rs +++ b/sdk/rust/src/tests/mod.rs @@ -51,7 +51,7 @@ pub(crate) fn assert_json_eq(expected: &mut Value, actual: &mut Value) { let diff = serde_json_diff::values(expected.clone(), actual.clone()); if let Some(diff_) = diff { log_err!( - "Expected and actual ORM JSON mismatch.\nDiff: {:?}\nExpected: {}\nActual: {}", + "Expected and actual JSON mismatch.\nDiff: {:?}\nExpected: {}\nActual: {}", diff_, expected, actual diff --git a/sdk/rust/src/tests/orm_patches.rs b/sdk/rust/src/tests/orm_patches.rs index 63438fb..5f66665 100644 --- a/sdk/rust/src/tests/orm_patches.rs +++ b/sdk/rust/src/tests/orm_patches.rs @@ -7,7 +7,7 @@ // notice may not be copied, modified, or distributed except // according to those terms. -use crate::local_broker::{doc_create, doc_sparql_construct, doc_sparql_update, orm_start}; +use crate::local_broker::{doc_sparql_update, orm_start}; use crate::tests::create_or_open_wallet::create_or_open_wallet; use crate::tests::{assert_json_eq, create_doc_with_data}; use async_std::stream::StreamExt; @@ -864,20 +864,12 @@ INSERT DATA { let mut expected = json!([ // Modified house color - { - "op": "remove", - "path": "/urn:test:house1/rootColor", - }, { "op": "add", "value": "red", "path": "/urn:test:house1/rootColor", }, // Modified Alice's name - { - "op": "remove", - "path": "/urn:test:house1/inhabitants/urn:test:person1/name", - }, { "op": "add", "value": "Alicia", From 318fcf562411fe201a033e3d350b71726ce0cb67 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Fri, 17 Oct 2025 19:34:28 +0200 Subject: [PATCH 5/6] WIP JSON patch > SPARQL --- engine/net/src/orm.rs | 18 +- engine/verifier/src/orm/add_remove_triples.rs | 10 +- .../src/orm/handle_frontend_update.rs | 225 +++++++++++++++++- engine/verifier/src/orm/materialize.rs | 2 +- engine/verifier/src/orm/query.rs | 29 +-- engine/verifier/src/orm/shape_validation.rs | 12 +- engine/verifier/src/orm/utils.rs | 50 +++- sdk/rust/src/tests/orm_creation.rs | 88 +++---- sdk/rust/src/tests/orm_patches.rs | 38 +-- 9 files changed, 361 insertions(+), 111 deletions(-) diff --git a/engine/net/src/orm.rs b/engine/net/src/orm.rs index afbd2f2..f156f04 100644 --- a/engine/net/src/orm.rs +++ b/engine/net/src/orm.rs @@ -24,7 +24,7 @@ pub struct OrmShapeType { } /* == Diff Types == */ -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[allow(non_camel_case_types)] pub enum OrmDiffOpType { add, @@ -66,7 +66,7 @@ pub struct OrmSchemaShape { #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[allow(non_camel_case_types)] -pub enum OrmSchemaLiteralType { +pub enum OrmSchemaValType { number, string, boolean, @@ -85,7 +85,7 @@ pub enum BasicType { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct OrmSchemaDataType { - pub valType: OrmSchemaLiteralType, + pub valType: OrmSchemaValType, pub literals: Option>, pub shape: Option, } @@ -100,13 +100,23 @@ pub struct OrmSchemaPredicate { pub minCardinality: i32, pub extra: Option, } +impl OrmSchemaPredicate { + pub fn is_multi(&self) -> bool { + self.maxCardinality > 1 || self.maxCardinality == -1 + } + pub fn is_object(&self) -> bool { + self.dataTypes + .iter() + .any(|dt| dt.valType == OrmSchemaValType::shape) + } +} impl Default for OrmSchemaDataType { fn default() -> Self { Self { literals: None, shape: None, - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, } } } diff --git a/engine/verifier/src/orm/add_remove_triples.rs b/engine/verifier/src/orm/add_remove_triples.rs index e7fe05b..4823816 100644 --- a/engine/verifier/src/orm/add_remove_triples.rs +++ b/engine/verifier/src/orm/add_remove_triples.rs @@ -109,7 +109,7 @@ pub fn add_remove_triples( .schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::literal) + .any(|dt| dt.valType == OrmSchemaValType::literal) { match &mut tracked_predicate.current_literals { Some(lits) => lits.push(obj_term.clone()), @@ -122,7 +122,7 @@ pub fn add_remove_triples( // If predicate is of type shape, register // "parent (predicate) -> child subject" and `child_subject.parents`. for shape_iri in predicate_schema.dataTypes.iter().filter_map(|dt| { - if dt.valType == OrmSchemaLiteralType::shape { + if dt.valType == OrmSchemaValType::shape { dt.shape.clone() } else { None @@ -200,7 +200,7 @@ pub fn add_remove_triples( .schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::literal) + .any(|dt| dt.valType == OrmSchemaValType::literal) { if let Some(current_literals) = &mut tracked_predicate.current_literals { // Remove obj_val from current_literals in-place @@ -212,7 +212,7 @@ pub fn add_remove_triples( .schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::shape) + .any(|dt| dt.valType == OrmSchemaValType::shape) { // Remove parent from child and child from tracked children. // If predicate is of type shape, register (parent -> child) links so that @@ -222,7 +222,7 @@ pub fn add_remove_triples( .dataTypes .iter() .filter_map(|dt| { - if dt.valType == OrmSchemaLiteralType::shape { + if dt.valType == OrmSchemaValType::shape { dt.shape.clone() } else { None diff --git a/engine/verifier/src/orm/handle_frontend_update.rs b/engine/verifier/src/orm/handle_frontend_update.rs index 4d49be7..d87b884 100644 --- a/engine/verifier/src/orm/handle_frontend_update.rs +++ b/engine/verifier/src/orm/handle_frontend_update.rs @@ -7,9 +7,13 @@ // notice may not be copied, modified, or distributed except // according to those terms. +use ng_net::orm::{OrmDiffOp, OrmDiffOpType, OrmDiffType, OrmSchemaPredicate, OrmSchemaShape}; use ng_oxigraph::oxrdf::Quad; use ng_repo::errors::VerifierError; +use std::cmp::Ordering; +use std::fmt::format; +use std::sync::{Arc, RwLock}; use std::u64; use futures::SinkExt; @@ -18,6 +22,7 @@ pub use ng_net::orm::{OrmDiff, OrmShapeType}; use ng_repo::log::*; use crate::orm::types::*; +use crate::orm::utils::{decode_json_pointer, json_to_sparql_val}; use crate::verifier::*; impl Verifier { @@ -73,11 +78,10 @@ impl Verifier { let (doc_nuri, sparql_update) = { let orm_subscription = self.get_first_orm_subscription_for(scope, Some(&shape_iri), Some(&session_id)); + let doc_nuri = orm_subscription.nuri.clone(); + + let sparql_update = create_sparql_update_query_for_diff(orm_subscription, diff); - // use orm_subscription as needed - // do the magic, then, find the doc where the query should start and generate the sparql update - let doc_nuri = NuriV0::new_empty(); - let sparql_update: String = String::new(); (doc_nuri, sparql_update) }; @@ -113,3 +117,216 @@ impl Verifier { } } } + +fn create_sparql_update_query_for_diff( + orm_subscription: &OrmSubscription, + diff: OrmDiff, +) -> String { + // First sort patches. + // - Process delete patches first. + // - Process object creation add operations before rest, to ensure potential blank nodes are created. + let mut delete_patches: Vec<_> = diff + .iter() + .filter(|patch| patch.op == OrmDiffOpType::remove) + .collect(); + let mut add_patches: Vec<_> = diff + .iter() + .filter(|patch| patch.op == OrmDiffOpType::add) + .collect(); + + // Put Object creations first and... + add_patches.sort_by(|patch1, patch2| match patch1.valType { + Some(OrmDiffType::object) => Ordering::Less, + _ => Ordering::Equal, + }); + // ...shorter paths first + add_patches.sort_by(|patch1, patch2| { + patch1 + .path + .split("/") + .count() + .cmp(&patch2.path.split("/").count()) + }); + + // Use a counter to generate unique variable names. + fn get_new_var_name(counter: &mut i32) -> String { + let name = format!("v{}", counter); + *counter += 1; + name + } + + // For each diff op, we create a separate INSERT or DELETE block. + let sparql_sub_queries: Vec = vec![]; + + // Create delete statements. + let delete_statements: Vec = vec![]; // The parts in the Delete block. + for del_patch in delete_patches.iter() { + let mut var_counter: i32 = 0; + + let (where_statements, target) = + create_where_statements_for_patch(&del_patch, &mut var_counter, &orm_subscription); + let (subject_var, target_predicate, target_object) = target; + + let delete_statement; + if let Some(target_object) = target_object { + delete_statement = format!( + " {} <{}> <{}> .", + subject_var, target_predicate, target_object + ) + } else { + let delete_val = match del_patch.value { + None => { + let val = format!("?{}", var_counter); + var_counter += 1; + val + } + Some(val) => json_to_sparql_val(&val), + }; + delete_statement = format!(" {} <{}> {} .", subject_var, target_predicate, delete_val) + } + + sparql_sub_queries.push(format!( + "DELETE DATA {{\n{}\nWHERE\n{{\n{}\n}}", + delete_statement, + where_statements.join("\n ") + )); + } + + return "None"; +} + +fn get_tracked_subject_from_diff_op( + subject_iri: &String, + orm_subscription: &OrmSubscription, +) -> Arc> { + let tracked_subject = orm_subscription + .tracked_subjects + .get(subject_iri) + .unwrap() + .get(&orm_subscription.shape_type.shape) + .unwrap(); + + return tracked_subject.clone(); +} + +/// Removes the current predicate from the path stack and returns the corresponding IRI. +/// If the +fn find_pred_schema_by_name( + readable_predicate: &String, + subject_schema: &OrmSchemaShape, +) -> Arc { + // Find predicate by readable name in subject schema. + for pred_schema in subject_schema.predicates.iter() { + if pred_schema.readablePredicate == *readable_predicate { + return pred_schema.clone(); + } + } + panic!("No predicate found in schema for name"); +} + +/// Creates sparql WHERE statements to navigate to the JSON pointer path in our ORM mapping. +/// Returns the statements as Vec +/// and the subject, predicate, Option of the path's ending (to be used for DELETE / DELETE). +fn create_where_statements_for_patch( + patch: &OrmDiffOp, + var_counter: &mut i32, + orm_subscription: &OrmSubscription, +) -> (Vec, (String, String, Option)) { + let mut body_statements: Vec = vec![]; + let mut where_statements: Vec = vec![]; + + let mut path: Vec = patch + .path + .split("/") + .map(|s| decode_json_pointer(&s.to_string())) + .collect(); + + // Handle special case: The whole object is deleted. + if path.len() == 0 { + let mut root_iri = path.remove(0); + body_statements.push(format!("<{}> ?p ?o .", root_iri)); + where_statements.push(format!("<{}> ?p ?o .", root_iri)); + } + + let mut subj_schema: &Arc = orm_subscription + .shape_type + .schema + .get(&orm_subscription.shape_type.shape) + .unwrap(); + + let mut current_subj_schema: Arc = subj_schema.clone(); + + // The root IRI might change, if the parent path segment was an IRI. + let root_iri = path.remove(0); + let mut subject_ref = format!("<{}>", root_iri); + + while path.len() > 0 { + let pred_name = path.remove(0); + let pred_schema = find_pred_schema_by_name(&pred_name, ¤t_subj_schema); + + where_statements.push(format!( + "{} <{}> ?o{} .", + subject_ref, pred_schema.iri, var_counter, + )); + subject_ref = format!("?o{}", var_counter); + *var_counter = *var_counter + 1; + + if pred_schema.is_multi() && pred_schema.is_object() { + let object_iri = path.remove(0); + // Path ends on an object IRI, which we return here as well. + if path.len() == 0 { + return ( + where_statements, + (subject_ref, pred_schema.iri.clone(), Some(object_iri)), + ); + } + + current_subj_schema = + get_first_valid_subject_schema(&object_iri, &pred_schema, &orm_subscription); + + // Since we have new IRI that we can use as root, we replace the current one with it. + subject_ref = format!("<{object_iri}>"); + // And can clear all now unnecessary where statements. + where_statements.clear(); + } + + if path.len() == 0 { + return ( + where_statements, + (subject_ref, pred_schema.iri.clone(), None), + ); + } + } + // Can't happen. + panic!(); +} + +fn get_first_valid_subject_schema( + subject_iri: &String, + pred_schema: &OrmSchemaPredicate, + orm_subscription: &OrmSubscription, +) -> Arc { + for data_type in pred_schema.dataTypes.iter() { + let Some(schema_shape) = data_type.shape.as_ref() else { + continue; + }; + + let tracked_subject = orm_subscription + .tracked_subjects + .get(subject_iri) + .unwrap() + .get(schema_shape) + .unwrap(); + + if tracked_subject.read().unwrap().valid == OrmTrackedSubjectValidity::Valid { + return orm_subscription + .shape_type + .schema + .get(schema_shape) + .unwrap() + .clone(); + } + } + // TODO: Panicking might be too aggressive. + panic!(); +} diff --git a/engine/verifier/src/orm/materialize.rs b/engine/verifier/src/orm/materialize.rs index d55017a..69d14da 100644 --- a/engine/verifier/src/orm/materialize.rs +++ b/engine/verifier/src/orm/materialize.rs @@ -161,7 +161,7 @@ pub(crate) fn materialize_orm_object( if pred_schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::shape) + .any(|dt| dt.valType == OrmSchemaValType::shape) { // We have a nested type. diff --git a/engine/verifier/src/orm/query.rs b/engine/verifier/src/orm/query.rs index 46aec7c..70d893d 100644 --- a/engine/verifier/src/orm/query.rs +++ b/engine/verifier/src/orm/query.rs @@ -7,15 +7,14 @@ // notice may not be copied, modified, or distributed except // according to those terms. -use lazy_static::lazy_static; use ng_repo::errors::VerifierError; -use regex::Regex; use std::collections::HashSet; pub use ng_net::orm::{OrmDiff, OrmShapeType}; use crate::orm::types::*; +use crate::orm::utils::{escape_literal, is_iri}; use crate::verifier::*; use ng_net::orm::*; use ng_oxigraph::oxigraph::sparql::{Query, QueryResults}; @@ -67,15 +66,6 @@ impl Verifier { } } -/// Heuristic: -/// Consider a string an IRI if it contains alphanumeric characters and then a colon within the first 13 characters -pub fn is_iri(s: &str) -> bool { - lazy_static! { - static ref IRI_REGEX: Regex = Regex::new(r"^[A-Za-z][A-Za-z0-9+\.\-]{1,12}:").unwrap(); - } - IRI_REGEX.is_match(s) -} - pub fn literal_to_sparql_str(var: OrmSchemaDataType) -> Vec { match var.literals { None => [].to_vec(), @@ -155,7 +145,7 @@ pub fn shape_type_to_sparql( // Predicate constraints might have more than one acceptable nested shape. Traverse each. for datatype in &predicate.dataTypes { - if datatype.valType == OrmSchemaLiteralType::shape { + if datatype.valType == OrmSchemaValType::shape { let shape_iri = &datatype.shape.clone().unwrap(); let nested_shape = schema.get(shape_iri).unwrap(); @@ -305,18 +295,3 @@ pub fn shape_type_to_sparql( construct_body, where_body )) } -/// SPARQL literal escape: backslash, quotes, newlines, tabs. -fn escape_literal(lit: &str) -> String { - let mut out = String::with_capacity(lit.len() + 4); - for c in lit.chars() { - match c { - '\\' => out.push_str("\\\\"), - '\"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - return out; -} diff --git a/engine/verifier/src/orm/shape_validation.rs b/engine/verifier/src/orm/shape_validation.rs index acfb2b4..6465f00 100644 --- a/engine/verifier/src/orm/shape_validation.rs +++ b/engine/verifier/src/orm/shape_validation.rs @@ -154,7 +154,7 @@ impl Verifier { } else if p_schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::literal) + .any(|dt| dt.valType == OrmSchemaValType::literal) { // If we have literals, check if all required literals are present. // At least one datatype must match. @@ -198,7 +198,7 @@ impl Verifier { } else if p_schema .dataTypes .iter() - .any(|dt| dt.valType == OrmSchemaLiteralType::shape) + .any(|dt| dt.valType == OrmSchemaValType::shape) { // If we have a nested shape, we need to check if the nested objects are tracked and valid. let tracked_children = tracked_pred.as_ref().map(|tp| { @@ -309,19 +309,19 @@ impl Verifier { // Check 3.5) Data types correct. } else { // Check if the data type is correct. - let allowed_types: Vec<&OrmSchemaLiteralType> = + let allowed_types: Vec<&OrmSchemaValType> = p_schema.dataTypes.iter().map(|dt| &dt.valType).collect(); // For each new value, check that data type is in allowed_types. for val_added in p_change.iter().map(|pc| &pc.values_added).flatten() { let matches = match val_added { BasicType::Bool(_) => allowed_types .iter() - .any(|t| **t == OrmSchemaLiteralType::boolean), + .any(|t| **t == OrmSchemaValType::boolean), BasicType::Num(_) => allowed_types .iter() - .any(|t| **t == OrmSchemaLiteralType::number), + .any(|t| **t == OrmSchemaValType::number), BasicType::Str(_) => allowed_types.iter().any(|t| { - **t == OrmSchemaLiteralType::string || **t == OrmSchemaLiteralType::iri + **t == OrmSchemaValType::string || **t == OrmSchemaValType::iri }), }; if !matches { diff --git a/engine/verifier/src/orm/utils.rs b/engine/verifier/src/orm/utils.rs index 80b804d..f5e1ce8 100644 --- a/engine/verifier/src/orm/utils.rs +++ b/engine/verifier/src/orm/utils.rs @@ -13,6 +13,9 @@ use ng_repo::types::OverlayId; use std::collections::HashMap; use std::collections::HashSet; +use lazy_static::lazy_static; +use regex::Regex; + pub use ng_net::orm::{OrmDiff, OrmShapeType}; use ng_net::{app_protocol::*, orm::*}; use ng_oxigraph::oxrdf::Triple; @@ -65,6 +68,51 @@ pub fn escape_json_pointer(path_segment: &String) -> String { path_segment.replace("~", "~0").replace("/", "~1") } -pub fn decode_join_pointer(path: &String) -> String { +pub fn decode_json_pointer(path: &String) -> String { path.replace("~1", "/").replace("~0", "~") } + +/// SPARQL literal escape: backslash, quotes, newlines, tabs. +pub fn escape_literal(lit: &str) -> String { + let mut out = String::with_capacity(lit.len() + 4); + for c in lit.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + return out; +} + +pub fn json_to_sparql_val(json: &serde_json::Value) -> String { + match json { + serde_json::Value::Array(arr) => arr + .iter() + .map(|val| json_to_sparql_val(val)) + .collect::>() + .join(", "), + serde_json::Value::Bool(bool) => match bool { + true => "true".to_string(), + false => "false".to_string(), + }, + serde_json::Value::Number(num) => num.to_string(), + serde_json::Value::String(str) => match is_iri(str) { + true => format!("<{}>", str), + false => format!("\"{}\"", str), + }, + _ => panic!(), + } +} + +/// Heuristic: +/// Consider a string an IRI if it contains alphanumeric characters and then a colon within the first 13 characters +pub fn is_iri(s: &str) -> bool { + lazy_static! { + static ref IRI_REGEX: Regex = Regex::new(r"^[A-Za-z][A-Za-z0-9+\.\-]{1,12}:").unwrap(); + } + IRI_REGEX.is_match(s) +} diff --git a/sdk/rust/src/tests/orm_creation.rs b/sdk/rust/src/tests/orm_creation.rs index 7e29b96..9d2efec 100644 --- a/sdk/rust/src/tests/orm_creation.rs +++ b/sdk/rust/src/tests/orm_creation.rs @@ -13,8 +13,8 @@ use crate::tests::{assert_json_eq, create_doc_with_data}; use async_std::stream::StreamExt; use ng_net::app_protocol::{AppResponse, AppResponseV0, NuriV0}; use ng_net::orm::{ - BasicType, OrmSchema, OrmSchemaDataType, OrmSchemaLiteralType, OrmSchemaPredicate, - OrmSchemaShape, OrmShapeType, + BasicType, OrmSchema, OrmSchemaDataType, OrmSchemaPredicate, OrmSchemaShape, OrmSchemaValType, + OrmShapeType, }; use ng_repo::log_info; @@ -304,7 +304,7 @@ INSERT DATA { minCardinality: 0, maxCardinality: -1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, shape: Some("http://example.org/Person".to_string()), literals: None, }], @@ -555,7 +555,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/TestObject".to_string(), )]), @@ -566,7 +566,7 @@ INSERT DATA { OrmSchemaPredicate { iri: "http://example.org/arr".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -658,7 +658,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "opt".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::boolean, + valType: OrmSchemaValType::boolean, literals: None, shape: None, }], @@ -741,7 +741,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "lit1".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("lit 1".to_string())]), shape: None, }], @@ -754,7 +754,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "lit2".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("lit 2".to_string())]), shape: None, }], @@ -841,12 +841,12 @@ INSERT DATA { readablePredicate: "strOrNum".to_string(), dataTypes: vec![ OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }, OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }, @@ -951,7 +951,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "str".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -964,7 +964,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "nestedWithExtra".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/NestedShapeWithExtra".to_string()), }], @@ -977,7 +977,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "nestedWithoutExtra".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/NestedShapeWithoutExtra".to_string()), }], @@ -999,7 +999,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1012,7 +1012,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1034,7 +1034,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1047,7 +1047,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1147,7 +1147,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "name".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1160,7 +1160,7 @@ INSERT DATA { minCardinality: 0, readablePredicate: "knows".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/PersonShape".to_string()), }], @@ -1293,7 +1293,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/Alice".to_string(), )]), @@ -1309,12 +1309,12 @@ INSERT DATA { readablePredicate: "knows".to_string(), dataTypes: vec![ OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/BobShape".to_string()), }, OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/ClaireShape".to_string()), }, @@ -1337,7 +1337,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("http://example.org/Bob".to_string())]), shape: None, }], @@ -1350,7 +1350,7 @@ INSERT DATA { minCardinality: 0, readablePredicate: "knows".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/ClaireShape".to_string()), }], @@ -1371,7 +1371,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/Claire".to_string(), )]), @@ -1471,7 +1471,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/Person".to_string(), )]), @@ -1486,7 +1486,7 @@ INSERT DATA { minCardinality: 0, readablePredicate: "cats".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/CatShape".to_string()), }], @@ -1507,7 +1507,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("http://example.org/Cat".to_string())]), shape: None, }], @@ -1574,7 +1574,7 @@ fn create_big_schema() -> OrmSchema { predicates: vec![ Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/TestObject".to_string(), )]), @@ -1588,7 +1588,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1600,7 +1600,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1612,7 +1612,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::boolean, + valType: OrmSchemaValType::boolean, literals: None, shape: None, }], @@ -1624,7 +1624,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1636,7 +1636,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some( "http://example.org/TestObject||http://example.org/objectValue" @@ -1651,7 +1651,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some( "http://example.org/TestObject||http://example.org/anotherObject" @@ -1667,12 +1667,12 @@ fn create_big_schema() -> OrmSchema { Arc::new(OrmSchemaPredicate { dataTypes: vec![ OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }, OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }, @@ -1686,12 +1686,12 @@ fn create_big_schema() -> OrmSchema { Arc::new(OrmSchemaPredicate { dataTypes: vec![ OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("lit1".to_string())]), shape: None, }, OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("lit2".to_string())]), shape: None, }, @@ -1714,7 +1714,7 @@ fn create_big_schema() -> OrmSchema { predicates: vec![ Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1726,7 +1726,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1748,7 +1748,7 @@ fn create_big_schema() -> OrmSchema { predicates: vec![ Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -1760,7 +1760,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -1772,7 +1772,7 @@ fn create_big_schema() -> OrmSchema { }), Arc::new(OrmSchemaPredicate { dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], diff --git a/sdk/rust/src/tests/orm_patches.rs b/sdk/rust/src/tests/orm_patches.rs index 5f66665..53ad33d 100644 --- a/sdk/rust/src/tests/orm_patches.rs +++ b/sdk/rust/src/tests/orm_patches.rs @@ -13,7 +13,7 @@ use crate::tests::{assert_json_eq, create_doc_with_data}; use async_std::stream::StreamExt; use ng_net::app_protocol::{AppResponse, AppResponseV0, NuriV0}; use ng_net::orm::{ - BasicType, OrmSchemaDataType, OrmSchemaLiteralType, OrmSchemaPredicate, OrmSchemaShape, + BasicType, OrmSchemaDataType, OrmSchemaValType, OrmSchemaPredicate, OrmSchemaShape, OrmShapeType, }; @@ -92,7 +92,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/TestObject".to_string(), )]), @@ -103,7 +103,7 @@ INSERT DATA { OrmSchemaPredicate { iri: "http://example.org/arr".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -267,7 +267,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/TestObject".to_string(), )]), @@ -278,7 +278,7 @@ INSERT DATA { OrmSchemaPredicate { iri: "http://example.org/arr".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::number, + valType: OrmSchemaValType::number, literals: None, shape: None, }], @@ -404,12 +404,12 @@ INSERT DATA { readablePredicate: "multiNest".to_string(), dataTypes: vec![ OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/MultiNestShape1".to_string()), }, OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/MultiNestShape2".to_string()), }, @@ -423,7 +423,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "singleNest".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/SingleNestShape".to_string()), }], @@ -444,7 +444,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -464,7 +464,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -484,7 +484,7 @@ INSERT DATA { maxCardinality: 1, minCardinality: 1, dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -652,7 +652,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/House".to_string(), )]), @@ -667,7 +667,7 @@ INSERT DATA { minCardinality: 0, readablePredicate: "rootColor".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -680,7 +680,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "inhabitants".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/PersonShape".to_string()), }], @@ -704,7 +704,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str( "http://example.org/Person".to_string(), )]), @@ -719,7 +719,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "name".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], @@ -732,7 +732,7 @@ INSERT DATA { minCardinality: 0, readablePredicate: "cat".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::shape, + valType: OrmSchemaValType::shape, literals: None, shape: Some("http://example.org/CatShape".to_string()), }], @@ -756,7 +756,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "type".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::literal, + valType: OrmSchemaValType::literal, literals: Some(vec![BasicType::Str("http://example.org/Cat".to_string())]), shape: None, }], @@ -769,7 +769,7 @@ INSERT DATA { minCardinality: 1, readablePredicate: "name".to_string(), dataTypes: vec![OrmSchemaDataType { - valType: OrmSchemaLiteralType::string, + valType: OrmSchemaValType::string, literals: None, shape: None, }], From 1d3730497bf1688da32d8f2ce3cda5e036f36bf4 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sat, 18 Oct 2025 01:43:24 +0200 Subject: [PATCH 6/6] working JSON Patch -> SPARQL --- engine/net/src/orm.rs | 2 +- .../src/orm/handle_frontend_update.rs | 229 +++-- sdk/rust/src/local_broker.rs | 14 +- sdk/rust/src/tests/mod.rs | 3 +- sdk/rust/src/tests/orm_apply_patches.rs | 959 ++++++++++++++++++ .../{orm_patches.rs => orm_create_patches.rs} | 8 +- 6 files changed, 1141 insertions(+), 74 deletions(-) create mode 100644 sdk/rust/src/tests/orm_apply_patches.rs rename sdk/rust/src/tests/{orm_patches.rs => orm_create_patches.rs} (98%) diff --git a/engine/net/src/orm.rs b/engine/net/src/orm.rs index f156f04..d0d1fe5 100644 --- a/engine/net/src/orm.rs +++ b/engine/net/src/orm.rs @@ -31,7 +31,7 @@ pub enum OrmDiffOpType { remove, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[allow(non_camel_case_types)] pub enum OrmDiffType { set, diff --git a/engine/verifier/src/orm/handle_frontend_update.rs b/engine/verifier/src/orm/handle_frontend_update.rs index d87b884..fac4388 100644 --- a/engine/verifier/src/orm/handle_frontend_update.rs +++ b/engine/verifier/src/orm/handle_frontend_update.rs @@ -11,8 +11,6 @@ use ng_net::orm::{OrmDiffOp, OrmDiffOpType, OrmDiffType, OrmSchemaPredicate, Orm use ng_oxigraph::oxrdf::Quad; use ng_repo::errors::VerifierError; -use std::cmp::Ordering; -use std::fmt::format; use std::sync::{Arc, RwLock}; use std::u64; @@ -35,11 +33,11 @@ impl Verifier { scope: &NuriV0, shape_iri: ShapeIri, session_id: u64, - skolemnized_blank_nodes: Vec, - revert_inserts: Vec, - revert_removes: Vec, + _skolemnized_blank_nodes: Vec, + _revert_inserts: Vec, + _revert_removes: Vec, ) -> Result<(), VerifierError> { - let (mut sender, orm_subscription) = + let (mut sender, _orm_subscription) = self.get_first_orm_subscription_sender_for(scope, Some(&shape_iri), Some(&session_id))?; // TODO prepare OrmUpdateBlankNodeIds with skolemnized_blank_nodes @@ -68,9 +66,8 @@ impl Verifier { diff: OrmDiff, ) -> Result<(), String> { log_info!( - "frontend_update_orm session={} scope={:?} shape={} diff={:?}", + "frontend_update_orm session={} shape={} diff={:?}", session_id, - scope, shape_iri, diff ); @@ -85,6 +82,7 @@ impl Verifier { (doc_nuri, sparql_update) }; + log_debug!("Created SPARQL query for patches:\n{}", sparql_update); match self .process_sparql_update( &doc_nuri, @@ -125,77 +123,138 @@ fn create_sparql_update_query_for_diff( // First sort patches. // - Process delete patches first. // - Process object creation add operations before rest, to ensure potential blank nodes are created. - let mut delete_patches: Vec<_> = diff + let delete_patches: Vec<_> = diff .iter() .filter(|patch| patch.op == OrmDiffOpType::remove) .collect(); - let mut add_patches: Vec<_> = diff + let add_object_patches: Vec<_> = diff .iter() - .filter(|patch| patch.op == OrmDiffOpType::add) + .filter(|patch| { + patch.op == OrmDiffOpType::add + && match &patch.valType { + Some(vt) => *vt == OrmDiffType::object, + _ => false, + } + }) + .collect(); + let add_literal_patches: Vec<_> = diff + .iter() + .filter(|patch| { + patch.op == OrmDiffOpType::add + && match &patch.valType { + Some(vt) => *vt != OrmDiffType::object, + _ => true, + } + }) .collect(); - - // Put Object creations first and... - add_patches.sort_by(|patch1, patch2| match patch1.valType { - Some(OrmDiffType::object) => Ordering::Less, - _ => Ordering::Equal, - }); - // ...shorter paths first - add_patches.sort_by(|patch1, patch2| { - patch1 - .path - .split("/") - .count() - .cmp(&patch2.path.split("/").count()) - }); - - // Use a counter to generate unique variable names. - fn get_new_var_name(counter: &mut i32) -> String { - let name = format!("v{}", counter); - *counter += 1; - name - } // For each diff op, we create a separate INSERT or DELETE block. - let sparql_sub_queries: Vec = vec![]; + let mut sparql_sub_queries: Vec = vec![]; // Create delete statements. - let delete_statements: Vec = vec![]; // The parts in the Delete block. + // for del_patch in delete_patches.iter() { let mut var_counter: i32 = 0; - let (where_statements, target) = + let (where_statements, target, _pred_schema) = create_where_statements_for_patch(&del_patch, &mut var_counter, &orm_subscription); let (subject_var, target_predicate, target_object) = target; let delete_statement; if let Some(target_object) = target_object { + // Delete the link to exactly one object (IRI referenced in path, i.e. target_object) delete_statement = format!( " {} <{}> <{}> .", subject_var, target_predicate, target_object ) } else { - let delete_val = match del_patch.value { + // Delete object or literal referenced by property name. + let delete_val = match &del_patch.value { + // No value specified, that means we are deleting all values for the given subject and predicate (multi-value scenario). None => { - let val = format!("?{}", var_counter); - var_counter += 1; - val + format!("?{}", var_counter) + // Note: var_counter is not incremented here as it's only used locally } - Some(val) => json_to_sparql_val(&val), + // Delete the specific values only. + Some(val) => json_to_sparql_val(&val), // Can be one or more (joined with ", "). }; - delete_statement = format!(" {} <{}> {} .", subject_var, target_predicate, delete_val) + delete_statement = format!(" {} <{}> {} .", subject_var, target_predicate, delete_val); } sparql_sub_queries.push(format!( - "DELETE DATA {{\n{}\nWHERE\n{{\n{}\n}}", + "DELETE {{\n{}\n}}\nWHERE\n{{\n {}\n}}", delete_statement, - where_statements.join("\n ") + where_statements.join(" .\n ") )); } - return "None"; + // Process add object patches (might need blank nodes) + // + for _add_obj_patch in add_object_patches { + // Creating objects without an id field is only supported in one circumstance: + // An object is added to a property which has a max cardinality of one, e.g. `painting.artist`. + // In that case, we create a blank node. + // TODO: We need to set up a list of created blank nodes and where they belong to. + } + + // Process literal add patches + // + for add_patch in add_literal_patches { + let mut var_counter: i32 = 0; + + // Create WHERE statements from path. + let (where_statements, target, pred_schema) = + create_where_statements_for_patch(&add_patch, &mut var_counter, &orm_subscription); + let (subject_var, target_predicate, target_object) = target; + + if let Some(_target_object) = target_object { + // Reference to exactly one object found. This is invalid when inserting literals. + // TODO: Return error? + continue; + } else { + // Add value(s) to + let add_val = match &add_patch.value { + // Delete the specific values only. + Some(val) => json_to_sparql_val(&val), // Can be one or more (joined with ", "). + None => { + // A value must be set. This patch is invalid. + // TODO: Return error? + continue; + } + }; + + // Add SPARQL statement. + + // If the schema only has max one value, + // then `add` can also overwrite values, so we need to delete the previous one + if !pred_schema.unwrap().is_multi() { + let remove_statement = + format!(" {} <{}> ?o{}", subject_var, target_predicate, var_counter); + + let mut wheres = where_statements.clone(); + wheres.push(remove_statement.clone()); + + sparql_sub_queries.push(format!( + "DELETE {{\n{}\n}} WHERE {{\n {}\n}}", + remove_statement, + wheres.join(" .\n ") + )); + // var_counter += 1; // Not necessary because not used afterwards. + } + // The actual INSERT. + let add_statement = format!(" {} <{}> {} .", subject_var, target_predicate, add_val); + sparql_sub_queries.push(format!( + "INSERT {{\n{}\n}} WHERE {{\n {}\n}}", + add_statement, + where_statements.join(". \n ") + )); + } + } + + return sparql_sub_queries.join(";\n"); } -fn get_tracked_subject_from_diff_op( +fn _get_tracked_subject_from_diff_op( subject_iri: &String, orm_subscription: &OrmSubscription, ) -> Arc> { @@ -225,13 +284,19 @@ fn find_pred_schema_by_name( } /// Creates sparql WHERE statements to navigate to the JSON pointer path in our ORM mapping. -/// Returns the statements as Vec -/// and the subject, predicate, Option of the path's ending (to be used for DELETE / DELETE). +/// Returns tuple of +/// - The WHERE statements as Vec +/// - The Option subject, predicate, Option of the path's ending (to be used for DELETE) +/// - The Option predicate schema of the tail of the target property. fn create_where_statements_for_patch( patch: &OrmDiffOp, var_counter: &mut i32, orm_subscription: &OrmSubscription, -) -> (Vec, (String, String, Option)) { +) -> ( + Vec, + (String, String, Option), + Option>, +) { let mut body_statements: Vec = vec![]; let mut where_statements: Vec = vec![]; @@ -242,13 +307,18 @@ fn create_where_statements_for_patch( .collect(); // Handle special case: The whole object is deleted. - if path.len() == 0 { - let mut root_iri = path.remove(0); - body_statements.push(format!("<{}> ?p ?o .", root_iri)); - where_statements.push(format!("<{}> ?p ?o .", root_iri)); + if path.len() == 1 { + let root_iri = &path[0]; + body_statements.push(format!("<{}> ?p ?o", root_iri)); + where_statements.push(format!("<{}> ?p ?o", root_iri)); + return ( + where_statements, + (format!("<{}>", root_iri), "?p".to_string(), None), + None, + ); } - let mut subj_schema: &Arc = orm_subscription + let subj_schema: &Arc = orm_subscription .shape_type .schema .get(&orm_subscription.shape_type.shape) @@ -264,44 +334,59 @@ fn create_where_statements_for_patch( let pred_name = path.remove(0); let pred_schema = find_pred_schema_by_name(&pred_name, ¤t_subj_schema); + if path.len() == 0 { + return ( + where_statements, + (subject_ref, pred_schema.iri.clone(), None), + Some(pred_schema), + ); + } + where_statements.push(format!( - "{} <{}> ?o{} .", + "{} <{}> ?o{}", subject_ref, pred_schema.iri, var_counter, )); + + // Update the subject_ref for traversal (e.g. ?o1 . ?o1 Cat); subject_ref = format!("?o{}", var_counter); *var_counter = *var_counter + 1; - if pred_schema.is_multi() && pred_schema.is_object() { + if !pred_schema.is_object() { + panic!( + "Predicate schema is not of type shape. Schema: {}, subject_ref: {}", + pred_schema.iri, subject_ref + ); + } + if pred_schema.is_multi() { let object_iri = path.remove(0); // Path ends on an object IRI, which we return here as well. if path.len() == 0 { return ( where_statements, (subject_ref, pred_schema.iri.clone(), Some(object_iri)), + Some(pred_schema), ); } current_subj_schema = - get_first_valid_subject_schema(&object_iri, &pred_schema, &orm_subscription); + get_first_valid_child_schema(&object_iri, &pred_schema, &orm_subscription); // Since we have new IRI that we can use as root, we replace the current one with it. subject_ref = format!("<{object_iri}>"); - // And can clear all now unnecessary where statements. + // And can clear all, now unnecessary where statements. where_statements.clear(); - } - - if path.len() == 0 { - return ( - where_statements, - (subject_ref, pred_schema.iri.clone(), None), - ); + } else { + // Set to child subject schema. + // TODO: Actually, we should get the tracked subject and check for the correct shape there. + // As long as there is only one allowed shape or the first one is valid, this is fine. + current_subj_schema = get_first_child_schema(&pred_schema, &orm_subscription); } } // Can't happen. panic!(); } -fn get_first_valid_subject_schema( +fn get_first_valid_child_schema( subject_iri: &String, pred_schema: &OrmSchemaPredicate, orm_subscription: &OrmSubscription, @@ -328,5 +413,17 @@ fn get_first_valid_subject_schema( } } // TODO: Panicking might be too aggressive. - panic!(); + panic!("No valid child schema found."); +} + +fn get_first_child_schema( + pred_schema: &OrmSchemaPredicate, + orm_subscription: &OrmSubscription, +) -> Arc { + return orm_subscription + .shape_type + .schema + .get(pred_schema.dataTypes[0].shape.as_ref().unwrap()) + .unwrap() + .clone(); } diff --git a/sdk/rust/src/local_broker.rs b/sdk/rust/src/local_broker.rs index d43a849..f1d2877 100644 --- a/sdk/rust/src/local_broker.rs +++ b/sdk/rust/src/local_broker.rs @@ -17,7 +17,7 @@ use async_std::sync::{Arc, Condvar, Mutex, RwLock}; use futures::channel::mpsc; use futures::{SinkExt, StreamExt}; use lazy_static::lazy_static; -use ng_net::orm::OrmShapeType; +use ng_net::orm::{OrmDiff, OrmShapeType}; use ng_oxigraph::oxrdf::Triple; use once_cell::sync::Lazy; use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, Str}; @@ -2764,6 +2764,18 @@ pub async fn orm_start( app_request_stream(request).await } +pub async fn orm_update( + scope: NuriV0, + shape_type_name: String, + diff: OrmDiff, + session_id: u64, +) -> Result<(), NgError> { + let mut request = AppRequest::new_orm_update(scope, shape_type_name, diff); + request.set_session_id(session_id); + app_request(request).await?; + Ok(()) +} + pub async fn doc_sparql_construct( session_id: u64, sparql: String, diff --git a/sdk/rust/src/tests/mod.rs b/sdk/rust/src/tests/mod.rs index 37b948a..a389234 100644 --- a/sdk/rust/src/tests/mod.rs +++ b/sdk/rust/src/tests/mod.rs @@ -15,8 +15,9 @@ use crate::local_broker::{doc_create, doc_sparql_update}; #[doc(hidden)] pub mod orm_creation; +pub mod orm_apply_patches; #[doc(hidden)] -pub mod orm_patches; +pub mod orm_create_patches; #[doc(hidden)] pub mod create_or_open_wallet; diff --git a/sdk/rust/src/tests/orm_apply_patches.rs b/sdk/rust/src/tests/orm_apply_patches.rs new file mode 100644 index 0000000..1392355 --- /dev/null +++ b/sdk/rust/src/tests/orm_apply_patches.rs @@ -0,0 +1,959 @@ +// Copyright (c) 2022-2025 Niko Bonnieure, Par le Peuple, NextGraph.org developers +// All rights reserved. +// Licensed under the Apache License, Version 2.0 +// +// or the MIT license , +// at your option. All files in the project carrying such +// notice may not be copied, modified, or distributed except +// according to those terms. + +use crate::local_broker::{doc_sparql_construct, orm_start, orm_update}; +use crate::tests::create_doc_with_data; +use crate::tests::create_or_open_wallet::create_or_open_wallet; +use async_std::stream::StreamExt; +use ng_net::app_protocol::{AppResponse, AppResponseV0, NuriV0}; +use ng_net::orm::{ + BasicType, OrmDiffOp, OrmDiffOpType, OrmDiffType, OrmSchemaDataType, OrmSchemaPredicate, + OrmSchemaShape, OrmSchemaValType, OrmShapeType, +}; + +use ng_repo::log_info; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Arc; + +#[async_std::test] +async fn test_orm_apply_patches() { + // Setup wallet and document + let (_wallet, session_id) = create_or_open_wallet().await; + + // Tests below all in this test, to prevent waiting times through wallet creation. + + // Test 1: Add single literal value + test_patch_add_single_literal(session_id).await; + + // Test 2: Remove single literal value + test_patch_remove_single_literal(session_id).await; + + // Test 3: Replace single literal value + test_patch_replace_single_literal(session_id).await; + + // Test 4: Add to multi-value literal array + test_patch_add_to_array(session_id).await; + + // Test 5: Remove from multi-value literal array + test_patch_remove_from_array(session_id).await; + + // // Test 6: Nested object - modify nested literal + test_patch_nested_literal(session_id).await; + + // Test 7: Multi-level nesting + test_patch_multilevel_nested(session_id).await; +} + +/// Test adding a single literal value via ORM patch +async fn test_patch_add_single_literal(session_id: u64) { + log_info!("\n\n=== TEST: Add Single Literal ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person . +} +"# + .to_string(), + ) + .await; + + // Define the ORM schema + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + extra: Some(false), + iri: "http://example.org/name".to_string(), + readablePredicate: "name".to_string(), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state (person without name) + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Add name + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::add, + path: "urn:test:person1/name".to_string(), + valType: None, + value: Some(json!("Alice")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let has_name = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/name" && t.object.to_string().contains("Alice") + }); + assert!(has_name, "Name was not added to the graph"); + + log_info!("✓ Test passed: Add single literal"); +} + +/// Test removing a single literal value via ORM patch +async fn test_patch_remove_single_literal(session_id: u64) { + log_info!("\n\n=== TEST: Remove Single Literal ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:name "Bob" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/name".to_string(), + extra: Some(false), + readablePredicate: "name".to_string(), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state (person without name) + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Remove name + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::remove, + path: "urn:test:person2/name".to_string(), + valType: None, + value: Some(json!("Bob")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let has_name = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/name" && t.object.to_string().contains("Bob") + }); + assert!(!has_name, "Name was not removed from the graph"); + + log_info!("✓ Test passed: Remove single literal"); +} + +/// Test replacing a single literal value via ORM patch (remove + add) +async fn test_patch_replace_single_literal(session_id: u64) { + log_info!("\n\n=== TEST: Replace Single Literal ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:name "Charlie" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/name".to_string(), + extra: Some(false), + readablePredicate: "name".to_string(), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state (person without name) + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Replace name (remove old, add new) + let diff = vec![ + // OrmDiffOp { + // op: OrmDiffOpType::remove, + // path: "urn:test:person3/name".to_string(), + // valType: None, + // value: Some(json!("Charlie")), + // }, + OrmDiffOp { + op: OrmDiffOpType::add, + path: "urn:test:person3/name".to_string(), + valType: None, + value: Some(json!("Charles")), + }, + ]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let has_old_name = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/name" + && t.object.to_string().contains("Charlie") + }); + let has_new_name = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/name" + && t.object.to_string().contains("Charles") + }); + + assert!(!has_old_name, "Old name was not removed"); + assert!(has_new_name, "New name was not added"); + + log_info!("✓ Test passed: Replace single literal"); +} + +/// Test adding to a multi-value array via ORM patch +async fn test_patch_add_to_array(session_id: u64) { + log_info!("\n\n=== TEST: Add to Array ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:hobby "Reading" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/hobby".to_string(), + extra: Some(false), + readablePredicate: "hobby".to_string(), + minCardinality: 0, + maxCardinality: -1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state (person without name) + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Add hobby + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::add, + valType: Some(OrmDiffType::set), + path: "urn:test:person4/hobby".to_string(), + value: Some(json!("Swimming")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let hobby_count = triples + .iter() + .filter(|t| t.predicate.as_str() == "http://example.org/hobby") + .count(); + + assert_eq!(hobby_count, 2, "Should have 2 hobbies"); + + log_info!("✓ Test passed: Add to array"); +} + +/// Test removing from a multi-value array via ORM patch +async fn test_patch_remove_from_array(session_id: u64) { + log_info!("\n\n=== TEST: Remove from Array ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:hobby "Reading", "Swimming", "Cooking" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/hobby".to_string(), + readablePredicate: "hobby".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: -1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Remove hobby + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::remove, + path: "urn:test:person5/hobby".to_string(), + valType: None, + value: Some(json!("Swimming")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let hobby_count = triples + .iter() + .filter(|t| t.predicate.as_str() == "http://example.org/hobby") + .count(); + let has_swimming = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/hobby" + && t.object.to_string().contains("Swimming") + }); + + assert_eq!(hobby_count, 2, "Should have 2 hobbies left"); + assert!(!has_swimming, "Swimming should be removed"); + + log_info!("✓ Test passed: Remove from array"); +} + +/// Test modifying a nested object's literal via ORM patch +async fn test_patch_nested_literal(session_id: u64) { + log_info!("\n\n=== TEST: Nested Literal Modification ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:name "Dave" ; + ex:address . + + a ex:Address ; + ex:street "Main St" ; + ex:city "Springfield" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/name".to_string(), + readablePredicate: "name".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/address".to_string(), + readablePredicate: "address".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::shape, + shape: Some("http://example.org/Address".to_string()), + literals: None, + }], + }), + ], + }), + ); + schema.insert( + "http://example.org/Address".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Address".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Address".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/street".to_string(), + extra: Some(false), + readablePredicate: "street".to_string(), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/city".to_string(), + readablePredicate: "city".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Change city in nested address + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::add, + path: "urn:test:person6/address/city".to_string(), + valType: None, + value: Some(json!("Shelbyville")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let has_old_city = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/city" + && t.object.to_string().contains("Springfield") + }); + let has_new_city = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/city" + && t.object.to_string().contains("Shelbyville") + }); + + assert!(!has_old_city, "Old city should be removed"); + assert!(has_new_city, "New city should be added"); + + log_info!("✓ Test passed: Nested literal modification"); +} + +/// Test multi-level nested object modifications via ORM patch +async fn test_patch_multilevel_nested(session_id: u64) { + log_info!("\n\n=== TEST: Multi-level Nested Modification ===\n"); + + let doc_nuri = create_doc_with_data( + session_id, + r#" +PREFIX ex: +INSERT DATA { + a ex:Person ; + ex:name "Eve" ; + ex:company . + + a ex:Company ; + ex:companyName "Acme Corp" ; + ex:headquarter . + + a ex:Address ; + ex:street "Business Blvd" ; + ex:city "Metropolis" . +} +"# + .to_string(), + ) + .await; + + let mut schema = HashMap::new(); + schema.insert( + "http://example.org/Person".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Person".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Person".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/name".to_string(), + extra: Some(false), + readablePredicate: "name".to_string(), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/company".to_string(), + extra: Some(false), + readablePredicate: "company".to_string(), + minCardinality: 0, + maxCardinality: -1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::shape, + shape: Some("http://example.org/Company".to_string()), + literals: None, + }], + }), + ], + }), + ); + schema.insert( + "http://example.org/Company".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Company".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Company".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/companyName".to_string(), + readablePredicate: "companyName".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/headquarter".to_string(), + readablePredicate: "headquarter".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::shape, + shape: Some("http://example.org/Address".to_string()), + literals: None, + }], + }), + ], + }), + ); + schema.insert( + "http://example.org/Address".to_string(), + Arc::new(OrmSchemaShape { + iri: "http://example.org/Address".to_string(), + predicates: vec![ + Arc::new(OrmSchemaPredicate { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(), + extra: Some(false), + maxCardinality: 1, + minCardinality: 1, + readablePredicate: "type".to_string(), + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::literal, + literals: Some(vec![BasicType::Str( + "http://example.org/Address".to_string(), + )]), + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/street".to_string(), + readablePredicate: "street".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + Arc::new(OrmSchemaPredicate { + iri: "http://example.org/city".to_string(), + readablePredicate: "city".to_string(), + extra: Some(false), + minCardinality: 0, + maxCardinality: 1, + dataTypes: vec![OrmSchemaDataType { + valType: OrmSchemaValType::string, + literals: None, + shape: None, + }], + }), + ], + }), + ); + + let shape_type = OrmShapeType { + shape: "http://example.org/Person".to_string(), + schema, + }; + + let nuri = NuriV0::new_from(&doc_nuri).expect("parse nuri"); + let (mut receiver, _cancel_fn) = orm_start(nuri.clone(), shape_type.clone(), session_id) + .await + .expect("orm_start failed"); + + // Get initial state + while let Some(app_response) = receiver.next().await { + if let AppResponse::V0(AppResponseV0::OrmInitial(initial)) = app_response { + break; + } + } + + // Apply ORM patch: Change street in company's headquarter address (3 levels deep) + let diff = vec![OrmDiffOp { + op: OrmDiffOpType::add, + path: "urn:test:person7/company/urn:test:company1/headquarter/street".to_string(), + valType: None, + value: Some(json!("Rich Street")), + }]; + + orm_update(nuri.clone(), shape_type.shape.clone(), diff, session_id) + .await + .expect("orm_update failed"); + + // Verify the change was applied + let triples = doc_sparql_construct( + session_id, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }".to_string(), + Some(doc_nuri.clone()), + ) + .await + .expect("SPARQL query failed"); + + let has_old_street = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/street" + && t.object.to_string().contains("Business Blvd") + }); + let has_new_street = triples.iter().any(|t| { + t.predicate.as_str() == "http://example.org/street" + && t.object.to_string().contains("Rich Street") + }); + + assert!(!has_old_street, "Old street should be removed"); + assert!(has_new_street, "New street should be added"); + + log_info!("✓ Test passed: Multi-level nested modification"); +} diff --git a/sdk/rust/src/tests/orm_patches.rs b/sdk/rust/src/tests/orm_create_patches.rs similarity index 98% rename from sdk/rust/src/tests/orm_patches.rs rename to sdk/rust/src/tests/orm_create_patches.rs index 53ad33d..6c93847 100644 --- a/sdk/rust/src/tests/orm_patches.rs +++ b/sdk/rust/src/tests/orm_create_patches.rs @@ -13,7 +13,7 @@ use crate::tests::{assert_json_eq, create_doc_with_data}; use async_std::stream::StreamExt; use ng_net::app_protocol::{AppResponse, AppResponseV0, NuriV0}; use ng_net::orm::{ - BasicType, OrmSchemaDataType, OrmSchemaValType, OrmSchemaPredicate, OrmSchemaShape, + BasicType, OrmSchemaDataType, OrmSchemaPredicate, OrmSchemaShape, OrmSchemaValType, OrmShapeType, }; @@ -604,8 +604,6 @@ INSERT DATA { } } -// Temporary file - content to be appended to orm_patches.rs - /// Test nested modifications with House -> Person -> Cat hierarchy async fn test_patch_nested_house_inhabitants(session_id: u64) { let doc_nuri = create_doc_with_data( @@ -804,7 +802,7 @@ INSERT DATA { } log_info!( - "\n=== TEST 1: INSERT - Adding new person with cat, modifying existing properties ===\n" + "\n\n=== TEST 1: INSERT - Adding new person with cat, modifying existing properties ===\n" ); // INSERT: Add a new person with a cat, modify house color, modify existing person's name, add cat to Bob @@ -945,7 +943,7 @@ INSERT DATA { break; } - log_info!("\n=== TEST 2: DELETE - Removing cat, person, and modifying properties ===\n"); + log_info!("\n\n=== TEST 2: DELETE - Removing cat, person, and modifying properties ===\n"); // DELETE: Remove Whiskers, remove Charlie and his cat, modify cat name, remove house color doc_sparql_update(