diff --git a/docs/concepts/iri-and-context.md b/docs/concepts/iri-and-context.md index b0111ac7c..d0d40330f 100644 --- a/docs/concepts/iri-and-context.md +++ b/docs/concepts/iri-and-context.md @@ -256,9 +256,13 @@ For programmatic use from Rust, transactions can also set `TxnOpts.strict_compac ``` Blank nodes are: -- Local to a single transaction -- Cannot be referenced across transactions -- Useful for temporary or anonymous data +- Skolemized at insert time into the reserved `_:fdb-...` label space, which + queries return as the node's `@id` +- Addressable afterwards via that stable `_:fdb-...` id in queries and + transactions (see + [Editing blank-node structures](../transactions/update-where-delete-insert.md#editing-blank-node-structures-stable-_fdb--ids)) +- Client-authored labels (`_:b0`) stay local to a single transaction — reusing + the same label later mints a new node ## Best Practices diff --git a/docs/transactions/insert.md b/docs/transactions/insert.md index 59d0ccea4..240e7e20f 100644 --- a/docs/transactions/insert.md +++ b/docs/transactions/insert.md @@ -280,7 +280,10 @@ Create entities without explicit IRIs: } ``` -Fluree generates a unique IRI for the blank node address. +Fluree generates a unique IRI for the blank node address, in the reserved +`_:fdb-...` label space. Queries return that id, and it can be used later to +address the node directly — see +[Editing blank-node structures](update-where-delete-insert.md#editing-blank-node-structures-stable-_fdb--ids). ## Adding to Existing Entities diff --git a/docs/transactions/update-where-delete-insert.md b/docs/transactions/update-where-delete-insert.md index 75dba38cc..da6a02adf 100644 --- a/docs/transactions/update-where-delete-insert.md +++ b/docs/transactions/update-where-delete-insert.md @@ -556,6 +556,65 @@ Calculate new values based on old: } ``` +## Editing Blank-Node Structures (Stable `_:fdb-` Ids) + +Fluree skolemizes every blank node at insert time into the reserved +`_:fdb-...` label space, and queries return those labels as the node's `@id`. +These ids are **stable**: referencing an `_:fdb-...` id in a later query or +transaction denotes the existing stored node rather than minting a fresh one +(the blank-node-syntax equivalent of RDF 1.1 §3.5 skolem IRIs). This makes +blank-node-rooted structures — OWL restrictions, address objects, RDF lists — +editable in place, without retracting and re-asserting the whole subtree. + +Workflow: query for the node's id, then use it as an ordinary `@id`: + +```json +{ + "select": "?r", + "where": { "@id": "ex:ClassA", "ex:restriction": "?r" } +} +``` + +returns e.g. `"_:fdb-1751612345678901234-0-b0"`, which can then be edited +directly: + +```json +{ + "where": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": "?old" }, + "delete": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": "?old" }, + "insert": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": { "@id": "ex:Gadget" } } +} +``` + +The parent's reference to the node is untouched and the node keeps its +identity across the edit. + +The same ids work in SPARQL, in all of SELECT patterns, `DELETE`/`INSERT` +templates, `DELETE DATA`, `INSERT DATA`, and `DELETE WHERE`: + +```sparql +DELETE { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ?old } +INSERT { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ex:Gadget } +WHERE { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ?old } +``` + +Notes: + +- Only labels beginning with the reserved `_:fdb-` prefix behave this way. + Ordinary client-authored labels (`_:b0`) keep standard RDF semantics: a + fresh node per transaction on the write side, and an existential variable + in SPARQL WHERE patterns. Clients cannot accidentally collide with the + reserved space — transaction skolemization wraps client labels with a + transaction id before prefixing `fdb-`. +- Strictly per spec, SPARQL forbids blank nodes in `DELETE` templates and + treats WHERE-pattern labels as variables; accepting `_:fdb-` ids as + constants is a deliberate Fluree extension (the same one Virtuoso's + `nodeID://` refs and Jena's `<_:label>` syntax provide). +- Some stable ids minted by bulk import embed `:` characters (e.g. + `_:fdb-lubm:main-1-genid10`). These are addressable from JSON-LD, but the + SPARQL grammar does not allow `:` inside blank-node labels, so such ids + cannot be written in SPARQL syntax. + ## Error Handling ### No Match diff --git a/fluree-db-api/src/import.rs b/fluree-db-api/src/import.rs index 3eadddfc2..b49800118 100644 --- a/fluree-db-api/src/import.rs +++ b/fluree-db-api/src/import.rs @@ -4389,8 +4389,9 @@ where let result = if chunk_source.is_trig(i) || chunk_source.is_nquads(i) { // TriG (and N-Quads, converted to TriG) use the dedicated // commit function for named-graph handling. It allocates - // codes in state.ns_registry; sync them to shared_alloc - // afterward for subsequent chunks' spool writes. + // codes through spool_config.ns_alloc (spool prefix + // lookups happen mid-parse); the sync below is a + // belt-and-braces consistency check. let nq_converted; let trig_content: &str = if chunk_source.is_nquads(i) { nq_converted = fluree_db_transact::parse::nquads_to_trig(&content) diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 53db50ff2..0eace5b31 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -1648,6 +1648,11 @@ fn convert_named_graphs_to_templates( match term { RawTerm::Iri(iri) => { if let Some(local) = iri.strip_prefix("_:") { + // Stable Fluree blank-node ids address the existing node; + // other labels skolemize fresh at staging. + if let Some(sid) = fluree_db_transact::stable_blank_node_sid_from_label(local) { + return Ok(TemplateTerm::Sid(sid)); + } Ok(TemplateTerm::BlankNode(local.to_string())) } else { Ok(TemplateTerm::Sid(ns_registry.sid_for_iri(iri))) @@ -1670,6 +1675,11 @@ fn convert_named_graphs_to_templates( match obj { RawObject::Iri(iri) => { if let Some(local) = iri.strip_prefix("_:") { + // Stable Fluree blank-node ids resolve to the stored node + // (see convert_term). + if let Some(sid) = fluree_db_transact::stable_blank_node_sid_from_label(local) { + return Ok((TemplateTerm::Sid(sid), None)); + } Ok((TemplateTerm::BlankNode(local.to_string()), None)) } else { Ok((TemplateTerm::Sid(ns_registry.sid_for_iri(iri)), None)) @@ -3041,3 +3051,44 @@ impl crate::Fluree { fn _ensure_error_used(e: ApiError) -> ApiError { e } + +#[cfg(test)] +mod tests { + use super::*; + use fluree_db_transact::{RawObject, RawTerm, RawTriple}; + + /// TriG named-graph blocks (upsert/insert-turtle path): a stable + /// `_:fdb-...` id must resolve to the stored node's Sid, while ordinary + /// labels stay `TemplateTerm::BlankNode` for fresh skolemization at + /// staging. + #[test] + fn named_graph_stable_blank_node_resolves_to_sid() { + let block = NamedGraphBlock { + iri: "http://example.org/g1".to_string(), + triples: vec![RawTriple { + subject: Some(RawTerm::Iri("_:fdb-1234-0-b0".to_string())), + predicate: RawTerm::Iri("http://example.org/knows".to_string()), + objects: vec![RawObject::Iri("_:other".to_string())], + }], + prefixes: rustc_hash::FxHashMap::default(), + }; + let mut ns = NamespaceRegistry::new(); + let (templates, _delta) = + convert_named_graphs_to_templates(&[block], &mut ns).expect("convert"); + assert_eq!(templates.len(), 1); + + let expected = ns.blank_node_sid("1234-0-b0"); + match &templates[0].subject { + TemplateTerm::Sid(sid) => { + assert_eq!(sid, &expected, "stable id must address the stored node"); + } + other => panic!("stable id must resolve to a Sid, got {other:?}"), + } + match &templates[0].object { + TemplateTerm::BlankNode(label) => { + assert_eq!(label, "other", "ordinary labels keep fresh-mint semantics"); + } + other => panic!("ordinary label must stay BlankNode, got {other:?}"), + } + } +} diff --git a/fluree-db-api/tests/grp_transact.rs b/fluree-db-api/tests/grp_transact.rs index ba4888e0b..2a451e44a 100644 --- a/fluree-db-api/tests/grp_transact.rs +++ b/fluree-db-api/tests/grp_transact.rs @@ -7,6 +7,8 @@ mod it_concurrent_update_reconcile; mod it_enforce_unique_upsert_indexed; #[path = "it_raw_txn_parallel_upload.rs"] mod it_raw_txn_parallel_upload; +#[path = "it_stable_blank_nodes.rs"] +mod it_stable_blank_nodes; #[path = "it_transact.rs"] mod it_transact; #[path = "it_transact_conditional.rs"] diff --git a/fluree-db-api/tests/it_import.rs b/fluree-db-api/tests/it_import.rs index dee349ea5..5427f5507 100644 --- a/fluree-db-api/tests/it_import.rs +++ b/fluree-db-api/tests/it_import.rs @@ -1438,6 +1438,131 @@ ex:alice schema:name "Alice" . ); } +// TriG scopes blank-node labels to the whole document: a label shared between +// the default graph and a GRAPH block must skolemize to ONE node, and the same +// label in a different document (file/commit) must stay a DIFFERENT node. +// Regression: named-graph blanks used to mint from the bare label (`fdb-b0`) — +// diverging from the default graph within one document AND colliding across +// every document in the ledger. +#[tokio::test] +async fn import_trig_blank_label_document_scoped() { + let db_dir = tempfile::tempdir().expect("db tmpdir"); + let data_dir = tempfile::tempdir().expect("data tmpdir"); + + // File 1: `_:shared` referenced from the default graph AND a GRAPH block. + let trig1 = r#"@prefix ex: . +@prefix schema: . + +ex:alice ex:knows _:shared . +_:shared schema:name "Document-scoped node" . + +GRAPH { + ex:bob ex:knows _:shared . +} +"#; + // File 2: the same label in another document's GRAPH block. + let trig2 = r"@prefix ex: . + +GRAPH { + ex:carol ex:knows _:shared . +} +"; + std::fs::write(data_dir.path().join("a.trig"), trig1).expect("write trig1"); + std::fs::write(data_dir.path().join("b.trig"), trig2).expect("write trig2"); + + let fluree = FlureeBuilder::file(db_dir.path().to_string_lossy().to_string()) + .build() + .expect("build file-backed Fluree"); + + fluree + .create("test/trig-bnode-scope:main") + .import(data_dir.path()) + .threads(1) + .memory_budget_mb(256) + .cleanup(false) + .execute() + .await + .expect("trig import should succeed"); + + let ledger = fluree + .ledger("test/trig-bnode-scope:main") + .await + .expect("load ledger"); + + // Scans with a variable predicate and picks out the blank-node ref, so + // the assertions stay focused on node identity rather than predicate + // resolution (covered by import_trig_bound_predicate_queryable). + let knows_object = |json: &serde_json::Value, subject: &str| -> String { + let rows = json.as_array().expect("array result"); + let refs: Vec = rows + .iter() + .map(|r| r.as_array().expect("row")) + .filter(|r| r[0].as_str() == Some(subject)) + .filter_map(|r| r[2].as_str()) + .filter(|o| o.starts_with("_:fdb-")) + .map(str::to_string) + .collect(); + assert_eq!( + refs.len(), + 1, + "expected one _:fdb- ref for {subject}: {json}" + ); + refs.into_iter().next().unwrap() + }; + + // Default graph: the node alice knows. + let qr = support::query_jsonld( + &fluree, + &ledger, + &json!({ + "select": ["?s", "?p", "?o"], + "where": {"@id": "?s", "?p": "?o"} + }), + ) + .await + .expect("default-graph query"); + let default_node = knows_object( + &qr.to_jsonld(&ledger.snapshot).expect("jsonld"), + "http://example.org/alice", + ); + + // Named graph g1 (same document): must be the SAME node. + let qr = fluree + .query_connection(&json!({ + "from": "test/trig-bnode-scope:main#http://example.org/graphs/g1", + "select": ["?s", "?p", "?o"], + "where": {"@id": "?s", "?p": "?o"} + })) + .await + .expect("g1 query"); + let g1_node = knows_object( + &qr.to_jsonld(&ledger.snapshot).expect("jsonld"), + "http://example.org/bob", + ); + assert_eq!( + g1_node, default_node, + "TriG label scope spans default graph and GRAPH blocks of one document" + ); + + // Named graph g2 (different document): must be a DIFFERENT node. + let qr = fluree + .query_connection(&json!({ + "from": "test/trig-bnode-scope:main#http://example.org/graphs/g2", + "select": ["?s", "?p", "?o"], + "where": {"@id": "?s", "?p": "?o"} + })) + .await + .expect("g2 query"); + let g2_node = knows_object( + &qr.to_jsonld(&ledger.snapshot).expect("jsonld"), + "http://example.org/carol", + ); + assert_ne!( + g2_node, default_node, + "the same label in a different document must stay a distinct node" + ); +} + // ============================================================================ // N-Quads (.nq) import // @@ -2082,3 +2207,94 @@ async fn import_directory_splits_large_compressed_file() { assert!(objs.contains(&"name-0".to_string())); assert!(objs.contains(&"name-39999".to_string())); } + +// Regression: the serial TriG import path allocated namespace codes only in +// `state.ns_registry`, invisible to the SpoolContext (which resolves +// code->prefix via the SHARED allocator at record-push time, mid-parse). Its +// empty-prefix fallback wrote suffix-only predicate strings ("name" instead +// of "http://schema.org/name") into the index's predicate dict, so +// bound-predicate patterns matched nothing and results rendered bare +// suffixes. The TriG path now allocates through a shared-allocator-backed +// WorkerCache like the parallel Turtle path. +#[tokio::test] +async fn import_trig_bound_predicate_queryable() { + let db_dir = tempfile::tempdir().expect("db tmpdir"); + let data_dir = tempfile::tempdir().expect("data tmpdir"); + let trig = r#"@prefix ex: . +@prefix schema: . + +ex:alice schema:name "Alice" . +ex:bob schema:name "Bob" . + +GRAPH { + ex:event1 schema:description "login" . +} +"#; + let path = data_dir.path().join("data.trig"); + std::fs::write(&path, trig).expect("write trig"); + + let fluree = FlureeBuilder::file(db_dir.path().to_string_lossy().to_string()) + .build() + .expect("build"); + fluree + .create("test/bound-pred:main") + .import(&path) + .threads(1) + .memory_budget_mb(256) + .cleanup(false) + .execute() + .await + .expect("import"); + let ledger = fluree.ledger("test/bound-pred:main").await.expect("ledger"); + + // Full scan must render complete predicate IRIs (not bare suffixes). + let scan = support::query_jsonld( + &fluree, + &ledger, + &json!({"select": ["?s","?p","?o"], "where": {"@id": "?s", "?p": "?o"}}), + ) + .await + .expect("scan"); + let scan_rows = scan.to_jsonld(&ledger.snapshot).expect("jsonld"); + let preds: Vec<&str> = scan_rows + .as_array() + .expect("array") + .iter() + .filter_map(|r| r.as_array().and_then(|row| row[1].as_str())) + .collect(); + assert!( + preds.iter().all(|p| *p == "http://schema.org/name"), + "predicates must decode to full IRIs; got {scan_rows}" + ); + + // Bound-predicate pattern must match via the index predicate dict. + let bound = support::query_jsonld( + &fluree, + &ledger, + &json!({"select": ["?s","?o"], "where": {"@id": "?s", "http://schema.org/name": "?o"}}), + ) + .await + .expect("bound"); + let bound_rows = bound.to_jsonld(&ledger.snapshot).expect("jsonld"); + assert_eq!( + bound_rows.as_array().map(Vec::len), + Some(2), + "bound-predicate must match both subjects; got {bound_rows}" + ); + + // Named graph too: bound predicate against the GRAPH-block data. + let named = fluree + .query_connection(&json!({ + "from": "test/bound-pred:main#http://example.org/graphs/g1", + "select": ["?s","?o"], + "where": {"@id": "?s", "http://schema.org/description": "?o"} + })) + .await + .expect("named bound"); + let named_rows = named.to_jsonld(&ledger.snapshot).expect("jsonld"); + assert_eq!( + named_rows.as_array().map(Vec::len), + Some(1), + "named-graph bound-predicate must match; got {named_rows}" + ); +} diff --git a/fluree-db-api/tests/it_stable_blank_nodes.rs b/fluree-db-api/tests/it_stable_blank_nodes.rs new file mode 100644 index 000000000..bb6224c75 --- /dev/null +++ b/fluree-db-api/tests/it_stable_blank_nodes.rs @@ -0,0 +1,417 @@ +//! Stable Fluree blank-node identifier tests. +//! +//! Fluree skolemizes every blank node into the reserved `_:fdb-...` label +//! space at insert time. These ids are returned by queries and — as pinned +//! here — are *stable*: when a later query or transaction references an +//! `_:fdb-...` label, it denotes the existing stored node instead of minting +//! a fresh one (RDF 1.1 §3.5 skolemization, kept in blank-node syntax). This +//! makes blank-node-rooted structures (e.g. OWL restrictions) editable in +//! place, without retracting and re-asserting the whole subtree. +//! +//! Ordinary client-authored labels (`_:b0`) keep standard semantics: fresh +//! node per transaction on the write side, existential variable in SPARQL +//! WHERE patterns. + +use crate::support; +use fluree_db_api::{FlureeBuilder, LedgerState, Novelty}; +use fluree_db_core::LedgerSnapshot; +use serde_json::{json, Value as JsonValue}; + +fn ctx() -> JsonValue { + json!({ + "ex": "http://example.org/", + "owl": "http://www.w3.org/2002/07/owl#" + }) +} + +/// Seed a class with a single OWL-restriction-like structure rooted at an +/// anonymous blank node and return the fluree handle + ledger. +async fn seed_restriction(ledger_id: &str) -> (fluree_db_api::Fluree, LedgerState) { + let fluree = FlureeBuilder::memory().build_memory(); + let db0 = LedgerSnapshot::genesis(ledger_id); + let ledger0 = LedgerState::new(db0, Novelty::new(0)); + + let seeded = fluree + .update( + ledger0, + &json!({ + "@context": ctx(), + "insert": { + "@id": "ex:ClassA", + "ex:restriction": { + "owl:onProperty": {"@id": "ex:hasPart"}, + "owl:someValuesFrom": {"@id": "ex:Widget"} + } + } + }), + ) + .await + .expect("seed insert"); + (fluree, seeded.ledger) +} + +async fn select_strings( + fluree: &fluree_db_api::Fluree, + ledger: &LedgerState, + query: &JsonValue, +) -> Vec { + let result = support::query_jsonld(fluree, ledger, query) + .await + .expect("query"); + let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + let mut out: Vec = v + .as_array() + .expect("array result") + .iter() + .map(|x| x.as_str().expect("string binding").to_string()) + .collect(); + out.sort(); + out +} + +/// The `_:fdb-...` id of ex:ClassA's restriction node. +async fn restriction_id(fluree: &fluree_db_api::Fluree, ledger: &LedgerState) -> String { + let ids = select_strings( + fluree, + ledger, + &json!({ + "@context": ctx(), + "select": "?r", + "where": {"@id": "ex:ClassA", "ex:restriction": "?r"} + }), + ) + .await; + assert_eq!(ids.len(), 1, "exactly one restriction node: {ids:?}"); + let id = ids.into_iter().next().unwrap(); + assert!( + id.starts_with("_:fdb-"), + "restriction id should be a stable Fluree blank-node id, got {id}" + ); + id +} + +async fn run_sparql_update( + fluree: &fluree_db_api::Fluree, + ledger: LedgerState, + sparql: &str, +) -> fluree_db_api::TransactResult { + let parsed = fluree_db_sparql::parse_sparql(sparql); + assert!( + !parsed.has_errors(), + "SPARQL parse errors: {:?}", + parsed.diagnostics + ); + let ast = parsed.ast.expect("SPARQL AST"); + let mut ns = fluree_db_transact::NamespaceRegistry::from_db(&ledger.snapshot); + let txn = fluree_db_transact::lower_sparql_update_ast( + &ast, + &mut ns, + fluree_db_transact::TxnOpts::default(), + ) + .expect("lower SPARQL UPDATE"); + fluree + .stage_owned(ledger) + .txn(txn) + .execute() + .await + .expect("stage SPARQL UPDATE") +} + +// ============================================================================ +// JSON-LD transactions +// ============================================================================ + +/// Inserting with a stable id must extend the existing node, not mint a new +/// one. +#[tokio::test] +async fn jsonld_insert_extends_existing_blank_node() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:jsonld-insert").await; + let bnode = restriction_id(&fluree, &ledger).await; + + let ledger = fluree + .update( + ledger, + &json!({ + "@context": ctx(), + "insert": {"@id": bnode, "ex:note": "edited"} + }), + ) + .await + .expect("insert on stable id") + .ledger; + + // The note is reachable through the parent's ref — proof the triple + // landed on the same node. + let notes = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?note", + "where": {"@id": "ex:ClassA", "ex:restriction": {"ex:note": "?note"}} + }), + ) + .await; + assert_eq!(notes, vec!["edited"]); + + // Still exactly one restriction-shaped node in the ledger. + let restrictions = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?r", + "where": {"@id": "?r", "owl:onProperty": {"@id": "ex:hasPart"}} + }), + ) + .await; + assert_eq!(restrictions.len(), 1); +} + +/// where/delete/insert against a stable id edits the node in place: the +/// parent's reference is untouched and the node id survives the edit. +#[tokio::test] +async fn jsonld_delete_insert_edits_blank_node_in_place() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:jsonld-edit").await; + let bnode = restriction_id(&fluree, &ledger).await; + + let ledger = fluree + .update( + ledger, + &json!({ + "@context": ctx(), + "where": {"@id": bnode, "owl:someValuesFrom": "?old"}, + "delete": {"@id": bnode, "owl:someValuesFrom": "?old"}, + "insert": {"@id": bnode, "owl:someValuesFrom": {"@id": "ex:Gadget"}} + }), + ) + .await + .expect("edit restriction in place") + .ledger; + + let values = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?v", + "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}} + }), + ) + .await; + assert_eq!(values, vec!["ex:Gadget"]); + + // Node identity is stable across the edit. + assert_eq!(restriction_id(&fluree, &ledger).await, bnode); +} + +/// Ordinary blank-node labels keep fresh-mint semantics: the same label in +/// two transactions produces two distinct nodes. +#[tokio::test] +async fn jsonld_plain_blank_label_still_mints_fresh() { + let fluree = FlureeBuilder::memory().build_memory(); + let db0 = LedgerSnapshot::genesis("it/stable-bnode:fresh-mint"); + let mut ledger = LedgerState::new(db0, Novelty::new(0)); + + for tag in ["one", "two"] { + ledger = fluree + .update( + ledger, + &json!({ + "@context": ctx(), + "insert": {"@id": "_:b0", "ex:tag": tag} + }), + ) + .await + .expect("insert") + .ledger; + } + + let subjects = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?s", + "where": {"@id": "?s", "ex:tag": "?t"} + }), + ) + .await; + assert_eq!( + subjects.len(), + 2, + "same client label across transactions must mint distinct nodes: {subjects:?}" + ); +} + +// ============================================================================ +// SPARQL +// ============================================================================ + +/// A stable id in a SPARQL WHERE pattern is a constant pinned to the stored +/// node, while an ordinary label stays an existential variable. +#[tokio::test] +async fn sparql_select_stable_blank_node_is_constant() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-select").await; + + // Add a second restriction so a wildcard match would return two rows. + let ledger = fluree + .update( + ledger, + &json!({ + "@context": ctx(), + "insert": { + "@id": "ex:ClassB", + "ex:restriction": { + "owl:onProperty": {"@id": "ex:hasPart"}, + "owl:someValuesFrom": {"@id": "ex:Sprocket"} + } + } + }), + ) + .await + .expect("insert ClassB") + .ledger; + let bnode = restriction_id(&fluree, &ledger).await; + + // Constant: only the addressed node's value comes back. + let sparql = format!( + "PREFIX owl: \n\ + SELECT ?v WHERE {{ {bnode} owl:someValuesFrom ?v }}" + ); + let result = support::query_sparql(&fluree, &ledger, &sparql) + .await + .expect("sparql select"); + let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + let rows = v.as_array().expect("array"); + assert_eq!(rows.len(), 1, "stable id must pin one node: {rows:?}"); + + // Ordinary label: existential variable, matches both restrictions. + let sparql = "PREFIX owl: \n\ + SELECT ?v WHERE { _:b0 owl:someValuesFrom ?v }"; + let result = support::query_sparql(&fluree, &ledger, sparql) + .await + .expect("sparql select wildcard"); + let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + assert_eq!( + v.as_array().expect("array").len(), + 2, + "plain blank label must stay a variable" + ); +} + +/// SPARQL DELETE/INSERT WHERE addressing a stable id edits the node in place. +#[tokio::test] +async fn sparql_delete_insert_edits_blank_node() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-edit").await; + let bnode = restriction_id(&fluree, &ledger).await; + + let sparql = format!( + "PREFIX ex: \n\ + PREFIX owl: \n\ + DELETE {{ {bnode} owl:someValuesFrom ?old }}\n\ + INSERT {{ {bnode} owl:someValuesFrom ex:Gadget }}\n\ + WHERE {{ {bnode} owl:someValuesFrom ?old }}" + ); + let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger; + + let values = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?v", + "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}} + }), + ) + .await; + assert_eq!(values, vec!["ex:Gadget"]); + assert_eq!(restriction_id(&fluree, &ledger).await, bnode); +} + +/// SPARQL DELETE DATA / INSERT DATA with a stable id retract and assert +/// exact triples on the stored node. +#[tokio::test] +async fn sparql_delete_data_and_insert_data_stable_blank_node() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-data").await; + let bnode = restriction_id(&fluree, &ledger).await; + + let sparql = format!( + "PREFIX ex: \n\ + PREFIX owl: \n\ + DELETE DATA {{ {bnode} owl:someValuesFrom ex:Widget }}" + ); + let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger; + + let values = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?v", + "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}} + }), + ) + .await; + assert!(values.is_empty(), "DELETE DATA must retract: {values:?}"); + + let sparql = format!( + "PREFIX ex: \n\ + PREFIX owl: \n\ + INSERT DATA {{ {bnode} owl:someValuesFrom ex:Gadget }}" + ); + let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger; + + let values = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?v", + "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}} + }), + ) + .await; + assert_eq!( + values, + vec!["ex:Gadget"], + "INSERT DATA must extend the existing node" + ); +} + +/// DELETE WHERE with a stable-id subject retracts that node's matching +/// triples only. +#[tokio::test] +async fn sparql_delete_where_stable_blank_node() { + let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-delete-where").await; + let bnode = restriction_id(&fluree, &ledger).await; + + let sparql = format!("DELETE WHERE {{ {bnode} ?p ?o }}"); + let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger; + + let props = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?p", + "where": {"@id": bnode, "?p": "?o"} + }), + ) + .await; + assert!(props.is_empty(), "node must be emptied: {props:?}"); + + // The parent's ref to the (now-empty) node is a separate triple and + // survives — retract it explicitly if the whole subtree should go. + let refs = select_strings( + &fluree, + &ledger, + &json!({ + "@context": ctx(), + "select": "?r", + "where": {"@id": "ex:ClassA", "ex:restriction": "?r"} + }), + ) + .await; + assert_eq!(refs, vec![bnode]); +} diff --git a/fluree-db-core/src/ns_encoding.rs b/fluree-db-core/src/ns_encoding.rs index 618d9c840..eb39722ff 100644 --- a/fluree-db-core/src/ns_encoding.rs +++ b/fluree-db-core/src/ns_encoding.rs @@ -121,6 +121,27 @@ pub fn builtin_prefix_trie() -> &'static PrefixTrie { /// split at this boundary unconditionally, before any other splitting logic. pub const BLANK_NODE_PREFIX: &str = "_:"; +/// Label prefix reserved for Fluree-minted stable blank-node identifiers. +/// +/// Every skolemized blank node the system mints has a local name beginning +/// with `fdb-` (e.g. `fdb-`, `fdb--