From 1d3730497bf1688da32d8f2ce3cda5e036f36bf4 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sat, 18 Oct 2025 01:43:24 +0200 Subject: [PATCH] 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(