Repository navigation
feat: SPARQL parameters, fulltext() in SPARQL, structured SHACL rejections - #2024
Conversation
671cbbb to
f7e5b8b
Compare
aaj3f
left a comment
There was a problem hiding this comment.
@bplatz the parameter design is nice here and makes sense to me. I think really only two headline notes, one of which is covered in detail below: (1) the claim "a parameter fixes a variable to one RDF term for the whole request" (params.rs:3) and its guard against a parameter that "would otherwise leave its variable unbound and silently widen the match" doesn't seem to hold for blank-node values and the parameter becomes a wildcard, which would be particularly risky for DELETE WHERE, and (2) it seems in this PR Value::parse re-implements "JSON-LD term --> RDF term" with its own rules while we already have a parser for those same forms in fluree-db-query/src/parse/values.rs:142-262. Regardless of duplication redundancy, they disagree on input forms. I think in #2025 this gets a bit worse as the Python layer re-encodes vector arrays as JSON text because the params parser refuses them, which is just an example of a consumer adapting to and extending the divergence. I'll defer to you how much we address this in this PR rather than getting them to align. It makes me wonder (now or later) if we should have one shared term parser in fluree-graph-jsonl-d which then fluree-db-query and fluree-db-sparql can reach) and which take the IRI-expansion function as an argument (e.g. @context for JSON-LD, PREFIXes for SPARQL). Full review below:
✅ Approving to unblock you — with 1 thing that needs to be addressed before this merges.
The parameter design is really nice: substituting on the AST before lowering, with exhaustive matches so a new AST variant can't slip past it, mirrors what Cypher already does, and a_filter_inside_optional_sees_the_value pins exactly why a VALUES join would have been the wrong tool. fulltext() lowering to the same Function::Fulltext makes the JSON-LD parity structural rather than something the tests have to hold up, and the SHACL change shares one resolution with validate() instead of building a second.
Must address before merge:
- A blank-node parameter is a wildcard, not a value (
fluree-db-sparql/src/params.rs:192). Any{"@id": "_:label"}whose label isn't a stored_:fdb-…id lowers to a non-distinguished variable, and since substitution runs aftervalidate(),DELETE WHERE { $s ?p ?o }withs = {"@id": "_:x"}commits and deletes every triple in the ledger. I ran it on this branch. Fix: accept onlyfdb-labels inparse_objectand refuse the rest. Details inline.
Should address (small, same file): {"@value": iri, "@type": "@id"} is read as a literal typed <@id> and silently matches nothing (params.rs:217), where the JSON-LD values clause reads it as an IRI. Nits inline: "name"/"$name" collapsing silently (params.rs:271), a ref value in the unique-constraint message (tx.rs:2049), the sync docs (admin.rs:159), and a release-notes line plus one assertion for the ShaclViolation change (error.rs:160).
What I checked and found clean, since these were the parts I was most worried about going in:
- No parameter position widens what a query reads.
FROM/FROM NAMEDtake IRIs only and substitution never touches the dataset clause;GRAPH $gresolves exactly as a boundGRAPH ?gdoes (system graphs stay private either way);SERVICE $svconly resolves ledgers already in the dataset, and pointing it at another ledger errors with "unknown ledger". The paths inview/dataset_query.rsthat reuse a parsed AST only ever receive one that's already substituted. - The
ShaclViolationchange doesn't move the HTTP error body: the server never matches on the variant, and the body is itsDisplay, which is the unchanged message. Solo'sorigin/maindoesn't touch the payload,TxnOperationorSyncGraphOpts. fulltext()gave identical scores and order in every SPARQL shape I tried on an indexed ledger (top-levelBIND, a projection, inside a sub-SELECT, anORDER BYexpression, and with the query string as a parameter), and your tests already tie theBINDform to the JSON-LD query on indexed docs, novelty and a configured property.- JSON numbers come through as
xsd:double(as in JSON-LD) and matched the same rows as an inline2.5against both decimal and double values.
One broader note, more context than ask: Value::parse is now the third place that turns a JSON-LD term object into an RDF term (fluree-db-query/src/parse/values.rs, and the where-pattern parser 40c14ba73 just brought into line with it, are the others), and the @type: "@id", vector and blank-node differences are that divergence; #2025 already re-encodes vectors as JSON text to get past this parser. A shared term parser that takes the IRI expander as an argument would close the class, but that's a cross-crate change and not for this PR.
Adherence to repo commitments:
- Patterns/abstractions: ✔ substitution before lowering mirrors
fluree_db_cypher::substitute_params;fulltext()reusesFunction::Fulltext;report_resultsis shared withvalidate().⚠️ the term parser is a third copy (above). - Performance (speed first, memory second): ✔ no engine change. A query without parameters pays one
Optioncheck per entry point; the extra IRI resolution in the SHACL path runs only on rejection. No performance-degradation risk. - Deployment targets: ✔
params.rsis pure and wasm-safe; nothing in the diff adds a background task or a process-lifetime cache; solo is unaffected at its current pin and on a bump. - Testing:
⚠️ the new tests are wired (grp_query_sparql,grp_query,grp_ledger,grp_transact, thefluree-db-sparqlandfluree-db-apiunit suites), CI's--all-featuresnextest runs every one of them by name (13,849 passed), and they go red when I break the code under them. The blank-node and@type: "@id"cases aren't covered, and the "branch op keeps the results" claim isn't asserted. - Conventions: ✔ thorough multi-line commit bodies; fmt, clippy
--all-features -D warnings, wasm32 and the W3Ctestsuite-sparqljob are all green in CI.
Verified at f7e5b8beb: CI is green on this head; locally, the new tests pass by name (it_sparql_params 7, params::tests 13, the fulltext/SHACL/sync tests), breaking finish(), the fulltext keyword, the substitution seam and the SHACL results each turns its test red, and the findings above come from scratch probes run against this branch.
Cross-PR notes: Two cross-PR things. violation_carries_resolved_results upserts a shape onto a plain create_ledger and expects enforcement, which is the shapes-exist heuristic my #2009 removes (SHACL runs only where config sets f:shaclEnabled). Nothing to change here: I'll move the test to create_shacl_ledger when #2009 rebases, and #2009's branch-validation outcome will carry your structured ShaclViolations in its SHACL arm. The other is downstream: solo's fluree-lambda-transact tests build TransactError::ShaclViolation("…".into()) from a &str (handler.rs:2940, :2968), and ShaclViolations only has From<String>, so a solo pin past this stops compiling. An impl From<&str> for ShaclViolations would keep that a no-op; I recognize it's tiny, but I'd rather see it folded in here than discovered at the bump.
Approving now so you can merge without waiting on another pass from me — just be sure the blank-node fix is in before you do.
| }; | ||
| return match id.strip_prefix("_:") { | ||
| Some("") => Err(ParamError::new(name, "has an empty blank node label")), | ||
| Some(label) => Ok(Value::Blank(Arc::from(label))), |
There was a problem hiding this comment.
🔴 Must address before merge — a blank-node parameter is a wildcard, not a value, and under DELETE WHERE it deletes every match.
Value::Blank puts BlankNode::labeled(label) into the AST, and lowering turns any label that doesn't start with fdb- into a non-distinguished variable (fluree-db-sparql/src/lower/term.rs:84-89 and :140-145). That's right for a blank node written inline, but it means this parameter fixes nothing, which is the same "unbound variable widens the match" hole finish() closes for misspelt names. Substitution also runs after validate() (fluree-db-api/src/query/helpers.rs:514-517, tx_builder.rs:132-138), and DELETE WHERE deliberately keeps Fluree's existential blank-node semantics (fluree-db-transact/src/lower_sparql_update.rs:589-591, with the API waiving the validator's §19.8 check for it at tx_builder.rs:123), so nothing that would refuse an inline _:x ever sees this one. DELETE templates are fine: the lowering backstop refuses them.
I ran these against this branch on a two-person ledger:
DELETE WHERE { $s ?p ?o }(the shapeupdate_templates_and_where_are_substituteduses) withs = {"@id": "_:x"}commits, and the ledger is empty afterwards:COUNT(*)over?s ?p ?ogoes from 11 to 0.DELETE WHERE { $s ex:name ?n }with the same value removes both people's names.DELETE { ?s ex:age ?a } WHERE { ?s ex:age ?a ; ex:knows $o }witho = {"@id": "_:x"}deletes the age of everyone who knows anyone.SELECT ?n WHERE { $s ex:name ?n }withs = {"@id": "_:x"}returns every name.
A stored id works as intended: {"@id": "_:fdb-…"} taken from a query result addresses exactly that node. But an app that wraps a caller-supplied id as {"@id": id}, which is exactly what parameters exist to make safe, hands that caller a wildcard. positions_constrain_the_term passes with {"@id": "_:b"} as a subject only because the AST no longer holds a Var; it is still a variable once lowered. (An INSERT template $b = _:t also mints a different node per solution, not "one RDF term for the whole request".)
Fix: accept a blank node only when its label is a stored id and refuse the rest. Illustrative (the fdb- prefix is already mirrored as a constant in validate/mod.rs:55, since fluree_db_core sits behind lowering):
return match id.strip_prefix("_:") {
Some(label) if label.starts_with(STABLE_BLANK_NODE_LABEL_PREFIX) => {
Ok(Value::Blank(Arc::from(label)))
}
Some(_) => Err(ParamError::new(
name,
"is a blank node label, which matches any node in a query; \
only a stored node's `_:fdb-…` id can be a parameter",
)),
None => Ok(Value::Iri(Arc::from(id))),
};…plus the "_:label" wording at params.rs:20 and docs/getting-started/rust-api.md:397, and a test that DELETE WHERE { $s ?p ?o } with _:x is refused.
| "has a language tag, so `@value` and `@language` must be strings", | ||
| )), | ||
| }, | ||
| (Some(datatype), None) => { |
There was a problem hiding this comment.
🟠 Should address — {"@value": iri, "@type": "@id"} becomes a literal typed <@id> and silently matches nothing.
This arm takes any @type string as a datatype IRI, so the JSON-LD "IRI as a value object" form turns into a literal no stored value can equal. On this branch, SELECT ?s WHERE { ?s ex:knows $o } returns nothing with o = {"@value": "http://example.org/bob", "@type": "@id"} and returns Alice with o = {"@id": "http://example.org/bob"}. The JSON-LD values clause reads the first form as an IRI (fluree-db-query/src/parse/values.rs:216-227), so a value copied out of a JSON-LD query changes meaning here, and it's the one odd-looking input this parser accepts without a word when it refuses null, lists and unknown keys.
Minimally we could refuse "@id" and "@vocab" as a @type; reading "@id" as Value::Iri (through the same blank-node rule as above) would match values. Compact forms like {"@id": "ex:alice"} or "@type": "xsd:date" do fail loudly, which is good, though the error talks about angle brackets the caller never wrote; a sentence in the docs that parameter IRIs are always full IRIs would help. This is in the same file as the blocker, so if you agree it's right I'd rather see it folded in now than lost in the backlog.
| let params = params | ||
| .iter() | ||
| .map(|(name, json)| { | ||
| let name = name.trim_start_matches(['?', '$']); |
There was a problem hiding this comment.
🟡 Optional / nit — "name" and "$name" in one map silently become one parameter, and the last one wins.
trim_start_matches(['?', '$']) maps both keys to name, and collecting into the HashMap keeps whichever comes last; with {"name": "Alice", "$name": "Bob"} the query on this branch matched Bob. It's an unlikely input, but it's the kind of ambiguity the rest of this module refuses. Building the map in a loop and returning a ParamError ("is given twice") when insert returns Some would close it. Minor and non-blocking, but if you agree it's right, I'd rather see it in this PR than lost in the backlog.
| .unwrap_or_else(|| format!("g_id={g_id}")) | ||
| }; | ||
| let value_str = format!("{o:?}"); | ||
| let value_str = match o { |
There was a problem hiding this comment.
🟡 Optional / nit — a ref value still doesn't read as text here.
FlakeValue's Display renders a ref as ref:[<ns code>:<name>] (fluree-db-core/src/value.rs:1022, sid.rs:183), so a unique constraint on a ref-valued property reports something like ref:[101:alice] instead of the IRI. The subjects a few lines down already go through snapshot.decode_sid, so one more arm would finish what the commit sets out to do:
let value_str = match o {
fluree_db_core::FlakeValue::String(s) => s.clone(),
fluree_db_core::FlakeValue::Ref(sid) => {
snapshot.decode_sid(sid).unwrap_or_else(|| sid.to_string())
}
other => other.to_string(),
};Nothing pins the new message text either; asserting the reported value in one of the existing UniqueConstraintViolation tests (e.g. it_config_graph.rs:2488) would. Minor, and if you agree I'd rather see it folded in than lost in the backlog.
| /// cannot silently wipe the graph. | ||
| pub allow_empty: bool, | ||
| /// Recorded as the commit's `f:message`. | ||
| pub message: Option<String>, |
There was a problem hiding this comment.
🟡 Optional / nit — the sync docs don't mention the new option or the commit ids.
docs/transactions/sync.md:126 still says "SyncGraphOpts { dry_run, allow_empty } mirror the query parameters", and nothing in the sync or drop docs mentions message or the new commit_id on SyncGraphReport/DropNamedGraphReport. A sentence there (that message is recorded as the commit's f:message, Rust API only for now) would keep the reference honest. Minor; fold-in close as above.
| #[cfg(feature = "shacl")] | ||
| #[error("{0}")] | ||
| ShaclViolation(String), | ||
| ShaclViolation(ShaclViolations), |
There was a problem hiding this comment.
🟡 Optional / nit — this is a breaking change to a public variant, and the body undersells it a little.
Matching or searching the payload as text still works through Display/Deref, as the body says, but anything that builds ShaclViolation(String) or binds the payload as a String no longer compiles; shacl_tests.rs:31 in this PR needed .to_string() for exactly that reason. Downstream is fine today: solo's origin/main never touches the payload (its only mention is a doc comment in fluree-lambda-router/src/routes/transactions.rs), and the HTTP error body is the unchanged Display. Since PR titles feed the release notes, a line in the body flagging it for embedders seems worth it.
Separately, the commit says a branch operation the shapes reject "keeps the results too", but it_branch_shacl.rs::assert_shacl_violation (:91-96) only matches the variant. Asserting !violations.results().is_empty() there would pin it across every branch-op test at once. Minor; fold-in close.
There was a problem hiding this comment.
Addressed in 1a7cf28; the PR body now flags the breaking change for embedders.
| Ok(()) | ||
| } | ||
|
|
||
| fn pattern(&mut self, pattern: &mut GraphPattern) -> Result<()> { |
There was a problem hiding this comment.
👍 Substituting on the AST before lowering, with exhaustive matches (no _ arms) over QueryBody, UpdateOperation, GraphPattern and Expression, means a new AST variant is a compile error here rather than a silently unsubstituted position, and it lines up with how fluree_db_cypher::substitute_params already works. The remote-SERVICE refusal is right too: remote execution sends the captured body text (fluree-db-query/src/service.rs:354-357), so a substituted value would never reach the endpoint.
f7e5b8b to
639f395
Compare
d48e861 to
7c97e5b
Compare
8134eac to
fbb9e29
Compare
`substitute_params(ast, params)` replaces every variable named in a
parameter map (`?name` or `$name`, the same variable) with its value,
throughout the request: subqueries, OPTIONAL, FILTER and EXISTS bodies,
CONSTRUCT and update templates, DESCRIBE targets and GRAPH names. The
lowering and planner only ever see constants, so a parameterized query
plans exactly as the inline one, and a request without parameters is
never touched.
- A projected parameter stays a column, as `(value AS ?name)`, or through
its GROUP BY key when grouped on.
- A parameter the request never mentions is an error: its misspelt
variable would otherwise be unbound and match everything. So is one the
request assigns itself (BIND, VALUES, AS), a literal in an IRI-only
position, and one inside a remote SERVICE, whose body is sent to the
endpoint as written. Local `fluree:ledger:` services take parameters.
- Values use the JSON-LD forms: JSON scalars, `{"@id": ...}` (`_:label`
for a blank node), `{"@value", "@type"}`, `{"@value", "@language"}`.
Parameters travel on `QueryExecutionOptions::with_params` (and a `.params()` on every query builder) and are substituted into the parsed query at each place a query is parsed for execution: single-graph, within-ledger and cross-ledger datasets, tracked and R2RML variants, and stream planning. Parses that only read the FROM clause take none, since parameters never change it. A JSON-LD query with parameters is refused. - `explain_sparql_with_params` explains the substituted query. - SPARQL UPDATE takes them through `sparql_update_with_params` on both transact builders and `TxnOperation::SparqlUpdate(sparql, params)` in a `Transaction`, substituted after validation and before lowering. - Without parameters no query is rewritten; the only cost is the check. Tests pin that a parameterized query returns and plans the same as the inline one, that a FILTER inside OPTIONAL sees the value (which a VALUES join would not), projection and grouping, misspelt names, updates through both write paths, and a connection-level FROM query.
`BIND(fulltext(?body, "graph databases") AS ?score)` scores a full-text value against a query string in SPARQL, as `fulltext` already did in JSON-LD: it lowers to the same function, so it reads the same BM25 arenas over indexed and unindexed documents, for `@fulltext` values and `f:fullTextDefaults` properties alike. Like the vector functions it is case-insensitive with an underscore variant (`full_text`), and the namespaced `f:fulltext(...)` names it too. It is a keyword only before a call: `?fulltext` and a `fulltext:` prefix still parse. Tests pair SPARQL with the JSON-LD query: equal scores and order on indexed documents, after an unindexed commit, and on a configured property, plus every spelling. Docs: the full-text reference drops its "JSON-LD only, SPARQL planned" section for the SPARQL form, the vector reference no longer labels its functions JSON-LD-only, the SPARQL reference gains a search and similarity functions section, and the search overview and cookbook show SPARQL beside JSON-LD.
TransactError::ShaclViolation carried only the formatted report, so a
caller that wanted to act on a rejection (show which property failed)
had to parse text. It now carries ShaclViolations: the same message, plus
each violation resolved to full IRIs as validate() reports them. The
payload displays and derefs as the message, so matching and searching it
as text is unchanged. ReportResult moves to fluree-db-transact (re-exported
from fluree_db_api::validate), and the result conversion is shared with
validate(). A branch operation the shapes reject keeps the results too.
A unique-constraint violation now reports the clashing value as its text
rather than its debug form (String("x")).
DropNamedGraphReport carries the drop commit's id when it committed, so a caller can name the commit without reading the log.
…it id SyncGraphOpts gains an optional commit message, recorded as the commit's f:message, and SyncGraphReport the new commit's id, so a caller can name the commit a sync wrote.
fbb9e29 to
ec0a234
Compare
A parameter `{"@id": "_:x"}` became a labeled blank node, and lowering
reads any label other than a stored `_:fdb-…` id as a non-distinguished
variable. Substitution runs after validation, so nothing refused it:
`DELETE WHERE { $s ?p ?o }` with `s = {"@id": "_:x"}` deleted every
triple, and a SELECT matched every node. A blank-node parameter must now
be a stored node's `_:fdb-…` id; any other label is refused.
`{"@value": iri, "@type": "@id"}` was read as a literal typed `<@id>`,
which matches nothing. It is now an IRI (or a stored blank node, by the
same rule), as the JSON-LD `values` clause reads it; any other keyword
`@type` is refused. The docs say parameter IRIs are full IRIs.
`name`, `?name` and `$name` name one variable, so a map holding two of them collapsed to whichever came last. It is now an error.
`{"@value": [0.1, 0.2], "@type": f:embeddingVector}` is the form the
JSON-LD `values` clause and writes take, but a parameter refused a list
`@value`, so a caller had to send the vector as its JSON text instead. A
list is now accepted for an f:embeddingVector, and refused for any other
type or when an item is not a number.
The clashing value was rendered with FlakeValue's Display, which writes a reference as its encoded id (`ref:[13:badge-7]`). It is now decoded to its IRI, as the subjects in the same message already are. Tests pin the reported value for a string and for a reference.
…he results
TransactError::ShaclViolation now carries ShaclViolations, so code that
built the variant from a string literal (`ShaclViolation("…".into())`)
stopped compiling: only From<String> existed. From<&str> keeps that a
no-op for embedders.
The branch-operation SHACL tests matched only the variant; they now also
assert the rejection carries its resolved results, across every branch
operation they cover.
…e-config-integrity Conflicts: - parse/jsonld.rs: kept this branch's graph scope over #2022's narrower nested-node fix (7d894b7), whose tests (it_materialize_named_graph, it_query_fulltext) pass on it. Ported #2008's ill-typed DELETE literal (ill_typed_as_stored). where_reads_ledger_default calls main's names_default_graph, so a where reading urn:default (#2005) counts as reading the ledger's default graph. - branch_validation.rs, merge/revert previews: the rejection stays a TransactError; a SHACL rejection keeps #2024's resolved results through describe(). - tx.rs: inline_shapes_policy beside #2024's violation_namespaces; dropped the convert_named_graphs_to_templates tests that #2008 removed with the function. - stage.rs: the per-graph policy stays required (explicit-only SHACL) alongside main's sh:sparql constraint-failure handling. - it_config_graph.rs, override_control_identity.rs, update docs: both sides kept. shacl_tests.rs: main's twelve new SHACL tests create their ledgers through create_shacl_ledger, as the rest of the file does; with SHACL explicit-only they otherwise enforced nothing and failed.
Engine features the Python package uses that stand on their own for the server and CLI.
fluree_db_sparql::substitute_paramsbinds variables to values on the AST before lowering:?xand$xare one variable; a projected parameter becomes(value AS ?x). Unknown names, a name given twice (xand$x), variables the query assigns (BIND,VALUES,AS), and parameters inside a remoteSERVICEare refused, so a misspelt parameter cannot silently match everything. Values use JSON-LD term forms, with full IRIs;"@type": "@id"reads as an IRI, and anf:embeddingVectortakes its numbers as a JSON array. A blank node must be a stored node's_:fdb-…id: any other label would lower to a variable and match every node, so it is refused. Wired through queries, explain, streams and updates (QueryExecutionOptions::with_params, builders'.params(),TxnOperation::SparqlUpdate(sparql, params)); a query without parameters takes no new work.fulltext()in SPARQL, the twin of the JSON-LD function, with the docs updated to show full-text and vector scoring side by side and the stale "not supported in SPARQL" notes removed.TransactError::ShaclViolationcarries the violations resolved to full IRIs (theReportResultthatvalidate()returns, now influree-db-transact) alongside the unchanged message, so callers can act on a rejection without parsing text. Its payload displays and derefs as the message, so existing matching on the text is unchanged. A unique-constraint violation now reports the clashing value as text rather than its debug form (a reference as its IRI).ShaclViolation's payload is nowShaclViolations, notString. Building it with.into()from aStringor&strstill compiles; code that binds the payload as aStringneeds.to_string()or.message().f:message) and reports the commit id it wrote.Follow-up: #2033