diff --git a/.gitmodules b/.gitmodules index a6dbd58b58..898f9c029d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "testsuite-sparql/rdf-tests"] path = testsuite-sparql/rdf-tests url = https://github.com/w3c/rdf-tests.git +[submodule "testsuite-shacl/data-shapes"] + path = testsuite-shacl/data-shapes + url = https://github.com/w3c/data-shapes diff --git a/Cargo.toml b/Cargo.toml index 998ad21f75..4bbc82df36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ members = [ "fluree-bench-support", "fluree-db-consensus", ] -exclude = ["testsuite-sparql", "scripts/local/load"] +exclude = ["testsuite-sparql", "testsuite-shacl", "scripts/local/load"] [workspace.package] version = "4.1.1" diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f60a6f52db..aa360f2118 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ - [upsert](cli/upsert.md) - [update](cli/update.md) - [query](cli/query.md) + - [validate](cli/validate.md) - [multi-query](cli/multi-query.md) - [history](cli/history.md) - [export](cli/export.md) @@ -226,5 +227,6 @@ - [Dev setup](contributing/dev-setup.md) - [Tests](contributing/tests.md) - [W3C SPARQL compliance suite](contributing/sparql-compliance.md) + - [W3C SHACL compliance suite](contributing/shacl-compliance.md) - [Adding tracing spans](contributing/tracing-guide.md) - [Releasing](contributing/releasing.md) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index d967744306..d01909e0f5 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1427,6 +1427,83 @@ curl -X POST http://localhost:8090/v1/fluree/explain/mydb \ -d '{"select":["?s"],"where":{"@id":"?s"}}' ``` +### GET/POST /validate/{ledger...} + +Validate the current state of a ledger (or one of its named graphs) against SHACL shapes and return a **validation report** — the HTTP surface of `fluree validate`. Unlike transaction-time enforcement, this never rejects anything; it reports every result it finds. Requires the `shacl` build feature (on by default). + +**URL:** +``` +GET /validate/{ledger...} +POST /validate/{ledger...} +``` + +**Request body** (POST; all fields optional — an empty body validates the default graph against the attached shapes): + +```json +{ + "graph": "http://example.org/graphs/data", + "shapes": { "...JSON-LD shapes document..." }, + "shapesGraph": "http://example.org/graphs/shapes", + "includeAttached": false +} +``` + +- `graph` — IRI of the named data graph to validate (default: the default graph). +- `shapes` — ad-hoc shapes: a JSON-LD object/array, or a **string containing Turtle**. Ad-hoc shapes **replace** the ledger's attached shapes ("does this data conform to *these* rules?"). +- `shapesGraph` — IRI of a named graph in this ledger holding the shapes (mutually exclusive with `shapes`). +- `includeAttached` — union ad-hoc shapes with the attached shapes instead of replacing them. + +**Response** (negotiated via `Accept`): + +- Default / `application/json` — summary envelope: + +```json +{ + "conforms": false, + "violations": 1, + "warnings": 0, + "infos": 0, + "shapesChecked": 1, + "results": [{ + "focus_node": "http://example.org/ns/bob", + "result_path": "http://schema.org/name", + "source_shape": "http://example.org/ns/UserShape", + "source_constraint": "http://example.org/ns/name-ps", + "constraint_component": "http://www.w3.org/ns/shacl#MinCountConstraintComponent", + "severity": "http://www.w3.org/ns/shacl#Violation", + "message": "Expected at least 1 value(s) but found 0" + }] +} +``` + +- `application/ld+json` — a W3C `sh:ValidationReport` JSON-LD document. +- `text/turtle` — the same report as Turtle. + +`result_path` is present only when the path is a single predicate; complex paths are omitted rather than misrepresented. + +**Auth:** same read gate as `/query` (Bearer `can_read` on the ledger). Note that validation reads the full graph — results are **not** policy-filtered, so access to this endpoint implies read access to the data it reports on. + +**Status Codes:** +- `200 OK` — report returned (conforming or not; non-conformance is not an error) +- `400 Bad Request` — malformed body, conflicting `shapes`/`shapesGraph`, unparseable shapes document +- `401 Unauthorized` — authentication required and missing +- `404 Not Found` — ledger or named graph not found (or not authorized) + +**Examples:** + +```bash +# Validate against the ledger's attached shapes +curl http://localhost:8090/v1/fluree/validate/mydb + +# Trial ad-hoc Turtle shapes (replaces attached shapes) +curl -X POST http://localhost:8090/v1/fluree/validate/mydb \ + -H "Content-Type: application/json" \ + -d '{"shapes": "@prefix sh: . ..."}' + +# W3C report as Turtle +curl http://localhost:8090/v1/fluree/validate/mydb -H "Accept: text/turtle" +``` + ## Nameservice Metadata The standalone server does not expose a general-purpose `POST /nameservice/query` diff --git a/docs/cli/README.md b/docs/cli/README.md index 35dc62629f..346d920b03 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -59,6 +59,7 @@ fluree query 'SELECT ?name WHERE { ?s ?name }' | [`upsert`](upsert.md) | Upsert data (insert or update existing) | | [`update`](update.md) | Update with WHERE/DELETE/INSERT patterns | | [`query`](query.md) | Query a ledger | +| [`validate`](validate.md) | Validate data against SHACL shapes (report) | | [`history`](history.md) | Show change history for an entity | | [`export`](export.md) | Export ledger data | | [`log`](log.md) | Show commit log | diff --git a/docs/cli/validate.md b/docs/cli/validate.md new file mode 100644 index 0000000000..068f859457 --- /dev/null +++ b/docs/cli/validate.md @@ -0,0 +1,107 @@ +# fluree validate + +Validate data against SHACL shapes and print a **validation report** — +instead of rejecting a transaction the way staging-time enforcement does, +`validate` inspects existing state (or a standalone file) and reports every +result it finds. + +```bash +fluree validate [ | ] [options] +``` + +Requires the `shacl` build feature (enabled by default). + +## Two modes + +**Ledger mode** validates the current state of a local ledger — indexed data +plus any commits not yet indexed — against its attached shapes (or ad-hoc +shapes you supply): + +```bash +fluree validate mydb # attached shapes +fluree validate mydb --shacl proposed.ttl # trial new shapes (replaces attached) +fluree validate mydb --shacl-graph http://example.org/graphs/shapes +``` + +**File mode** validates an RDF file with no ledger at all: the data loads +into an ephemeral in-memory ledger, the report prints, and nothing persists. +This is the recommended pre-flight for [bulk import](import.md), which +deliberately never runs SHACL: + +```bash +fluree validate data.ttl --shacl shapes.ttl +fluree validate dataset.jsonld --shacl shapes.jsonld --format jsonld +fluree validate data-with-embedded-shapes.ttl +``` + +A file that embeds its own shapes validates against them (staging-time +enforcement is disabled during the ephemeral load, so violating data can't +be rejected before the report is produced). + +## Options + +| Option | Description | +|--------|-------------| +| `--graph ` | Validate a named data graph instead of the default graph | +| `--shacl ` | Shapes file (Turtle or JSON-LD). **Replaces** the ledger's attached shapes by default | +| `--shacl-graph ` | Named graph in the target ledger holding the shapes (conflicts with `--shacl`) | +| `--include-attached` | Union ad-hoc shapes with the attached shapes instead of replacing them | +| `--format ` | `table` (default, human), `jsonld`, or `turtle` (W3C `sh:ValidationReport`) | +| `--fail-on ` | Exit non-zero when results at or above this severity exist: `violation` (default), `warning`, or `info` | + +## Replace vs union semantics + +`--shacl` answers "does this data conform to *these* rules?" — the ledger's +attached shapes are not evaluated. This makes trialing a stricter shape +predictable before you transact it. Pass `--include-attached` to evaluate +both sets together. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Report conforms under the `--fail-on` threshold | +| 1 | Validation results at or above the threshold exist (or an operational error) | +| 2 | Usage error (bad flags/arguments) | + +CI example: + +```bash +fluree validate export.ttl --shacl contracts.ttl --fail-on warning \ + || { echo "source data failed SHACL pre-flight"; exit 1; } +``` + +## Output + +`table` prints one block per result plus a summary line: + +``` +Violation: http://example.org/ns/bob + path: http://schema.org/name + component: MinCountConstraintComponent + message: Expected at least 1 value(s) but found 0 + +Conforms: false — 1 violation(s), 0 warning(s), 0 info (1 shape(s) checked) +``` + +`jsonld` and `turtle` emit a W3C-shaped `sh:ValidationReport` with +`sh:focusNode`, `sh:resultPath` (single-predicate paths only — complex paths +are omitted rather than misrepresented), `sh:resultSeverity`, +`sh:sourceShape`, `sh:sourceConstraintComponent`, `sh:resultMessage`, and +`sh:value`. + +If the shapes source produces no shapes, the report is vacuously conforming +and a warning is printed to stderr. + +## Current limitations + +- The CLI validates local ledgers; for a remote server, call the + [`/validate` HTTP endpoint](../api/endpoints.md#getpost-validateledger) + directly (CLI `--remote` wiring is not yet implemented). +- Shapes from a *different* ledger must be exported to a file first. + +## Related + +- [Cookbook: SHACL validation](../guides/cookbook-shacl.md) — shape authoring + and transaction-time enforcement +- [import](import.md) — bulk import (SHACL-exempt by design; validate first) diff --git a/docs/contributing/shacl-compliance.md b/docs/contributing/shacl-compliance.md new file mode 100644 index 0000000000..f4610c46fb --- /dev/null +++ b/docs/contributing/shacl-compliance.md @@ -0,0 +1,94 @@ +# W3C SHACL Compliance Testing + +The `testsuite-shacl/` crate runs the official [W3C data-shapes test +suite](https://github.com/w3c/data-shapes) (core section) against the SHACL +engine, through the same `fluree_db_api::validate` core that powers +`fluree validate` and the `/validate` HTTP endpoint. + +The crate is **excluded from the workspace** — `cd testsuite-shacl/` before +running any cargo or make commands. The W3C tests are vendored as a git +submodule; on a fresh clone run: + +```bash +git submodule update --init testsuite-shacl/data-shapes +``` + +## Quick commands (from testsuite-shacl/) + +| Command | Purpose | +|---------|---------| +| `make count` | Overall pass/fail numbers | +| `make summary` | Per-category breakdown table | +| `make test-cat CAT=node` | Run one category | +| `make test-one TEST=minLength-001` | Run matching tests with full output | +| `make failures` | List failing tests with mismatch reasons | +| `make report-json` | Machine-readable report → `report.json` | +| `make show TEST=node/minLength-001` | Print a W3C test file | + +To debug a single case interactively, the CLI runs the same pipeline: + +```bash +fluree validate .ttl --format turtle # most tests embed data+shapes +``` + +## How a test runs + +Each `sht:Validate` entry names a data graph and a shapes graph (usually the +test file itself). The runner loads the data into an ephemeral in-memory +ledger (staging-time SHACL disabled via the config graph, so violating data +can load), validates with `ShapesSource::InlineTurtle`, and compares the +produced report against the `sh:ValidationReport` embedded in the manifest. + +## Comparison semantics and deliberate leniency + +Results are compared as a multiset on `sh:focusNode`, `sh:resultPath`, +`sh:resultSeverity`, `sh:sourceConstraintComponent`, and `sh:value` +(`sh:resultMessage` is never compared, per the suite's own rules). Where +exact comparison needs machinery we don't have yet, the harness is lenient: + +- **Blank nodes** (focus nodes, values, complex `sh:resultPath` structures) + match as wildcards instead of by graph isomorphism, and `sh:sourceShape` + is not compared at all. +- **Fields absent from an expected result** accept any actual value. + +Tightening these is future work; a pass under leniency is still meaningful +because conformance flag, result count, component IRIs, and severities must +match exactly. + +## Known engine gaps the suite surfaces + +Failures are expected in these areas (honest gaps, not harness bugs): + +- Complex `sh:resultPath` serialization (sequence/inverse path structures) + is omitted from reports. +- `sh:equals` / `sh:lessThan` report one aggregate violation instead of one + per missing/extra value (`equals-001`, `lessThan-002`). +- `xsd:dateTime` range comparison does not implement the spec's + timezone partial order (`minInclusive-002/003`). +- `sh:property` nested on a *property* shape (validating each value against + child property shapes) is not implemented (`property-001`, + `validation-reports/shared`). +- Custom severity IRIs (`sh:severity ex:MySeverity`) collapse to + `sh:Violation` (`severity-002`). +- `sh:sparql` (the whole `sparql/` section of the suite is not wired in). +- `complex/shacl-shacl` (validating shapes against the SHACL-SHACL meta + shapes) depends on several of the above. + +A few expectations are unachievable by design — Fluree's value-centric +store differs from RDF term identity: + +- `4` and `4.0` are the same value in flake space, so they collapse into + one `sh:targetNode` target (`minExclusive-001` / `maxExclusive-001`); + likewise `"1"^^xsd:boolean` canonicalizes to `true`, so a spec-pedantic + `sh:uniqueLang "1"^^xsd:boolean` (which must be ignored — only the term + `true` activates the component) is indistinguishable from `true` and + fires (`uniqueLang-002`). +- Ill-formed typed literals (`"aldi"^^xsd:integer`, `"none"^^xsd:boolean`) + are rejected at ingest and can never be present to validate + (`datatype-001`, `datatype-ill-formed`, `or-datatypes-001`). +- Duplicate values in an RDF list collapse (`xone-duplicate`, + `path-complex-002`'s `( _:pinv _:pinv )` sequence). + +Track the current pass rate with `make count` before and after engine +changes; regressions in previously-passing categories are the signal to +watch. diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index 1bb94d0f87..eb2cd839a5 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -12,6 +12,7 @@ This guide covers: - [Predicate-target shapes](#predicate-target-shapes) — `sh:targetSubjectsOf` / `sh:targetObjectsOf` - [Per-graph enable/disable and warn vs reject](#per-graph-configuration) modes - [Storing shapes in a named graph](#storing-shapes-in-a-named-graph) with `f:shapesSource` +- [Validation reports](#validation-reports-fluree-validate) — `fluree validate` over a ledger or file - [What isn't enforced yet](#not-yet-supported) ## When SHACL runs @@ -25,9 +26,10 @@ This means you can start using SHACL **without writing any config** — just tra **Bulk import is deliberately exempt.** The bulk-import pipeline never runs SHACL — it is a trusted, high-throughput load path. If your source data must -conform, validate it *before* importing (e.g. run a SHACL report over the -source with your shapes) so the ledger starts clean; transaction-time -validation keeps it clean from there. +conform, validate it *before* importing — `fluree validate source.ttl +--shacl shapes.ttl` produces a full report (see +[Validation reports](#validation-reports-fluree-validate)) — so the ledger +starts clean; transaction-time validation keeps it clean from there. The `shacl` feature must be enabled at build time (it's on by default for the server and CLI binaries). See [Standards and feature flags](../reference/compatibility.md). @@ -371,7 +373,7 @@ ex:StrictPersonShape a sh:NodeShape ; sh:property [ sh:path schema:name ; sh:minCount 1 ] . ``` -A closed shape forbids any property not explicitly declared (or listed in `sh:ignoredProperties`). `rdf:type` is implicitly ignored per the SHACL spec. +A closed shape forbids any property not explicitly declared (or listed in `sh:ignoredProperties`). Per the SHACL spec, `rdf:type` is **not** implicitly ignored — a closed shape with `sh:targetClass` (whose instances necessarily carry `rdf:type`) must list it in `sh:ignoredProperties`, as above. ## RDFS subclass reasoning for `sh:class` @@ -593,9 +595,36 @@ SHACL validation runs consistently on every write surface: All three routes go through the same post-stage helper, so the ledger's configured SHACL posture (enable/disable, mode, per-graph, shapes source) applies uniformly. +## Validation reports (`fluree validate`) + +Transaction-time enforcement rejects (or warns about) *new* writes. +`fluree validate` answers the complementary questions: *is my existing data +clean?* and *is this source file clean before I import it?* It produces a +W3C-shaped `sh:ValidationReport` instead of an error: + +```bash +fluree validate mydb # ledger vs its attached shapes +fluree validate mydb --shacl proposed.ttl # trial stricter shapes (replaces attached) +fluree validate data.ttl --shacl shapes.ttl # standalone file, ephemeral in-memory ledger +fluree validate data.ttl --format turtle # W3C report as Turtle (also: jsonld) +``` + +Ad-hoc shapes (`--shacl`) **replace** the attached shapes by default so +"does this data conform to these rules?" is answered exactly; +`--include-attached` unions both sets. Exit codes make it CI-friendly: +0 = conforms, 1 = findings at/above `--fail-on` (default `violation`). +See the [`fluree validate` reference](../cli/validate.md). + +The same core is served over HTTP as +[`GET|POST /validate/{ledger}`](../api/endpoints.md#getpost-validateledger) +(JSON summary by default; `Accept: application/ld+json` or `text/turtle` +for the W3C report), and exposed in Rust as `fluree_db_api::validate` — +`Fluree::validate_ledger(alias, &ValidateOptions)` returns a `ValidateReport` +with per-result constraint-component IRIs, severities, and messages, plus +`to_jsonld()` / `to_turtle()` serializers. + ## Not yet supported -- `sh:targetNode` with a literal value — only IRI/blank-node targets are compiled. - `sh:sparql` (SPARQL-based constraints). These are tracked in the SHACL compliance effort. diff --git a/fluree-db-api/src/inline_shapes.rs b/fluree-db-api/src/inline_shapes.rs index fc1e7bc615..b5fa0036e4 100644 --- a/fluree-db-api/src/inline_shapes.rs +++ b/fluree-db-api/src/inline_shapes.rs @@ -57,6 +57,28 @@ pub(crate) fn parse_inline_shapes_to_bundle( TransactError::Parse(format!("inline shapes JSON-LD event conversion error: {e}")) })?; + bundle_from_sink(sink) +} + +/// Parse a Turtle inline shapes document into a `SchemaBundleFlakes`. +/// +/// Turtle counterpart of [`parse_inline_shapes_to_bundle`]; `@prefix` +/// declarations register through the sink's `on_prefix` events, so no +/// separate prefix pre-registration pass is needed. +pub(crate) fn parse_inline_shapes_turtle_to_bundle( + turtle: &str, + staged_ns: &mut NamespaceRegistry, + t: i64, + ledger_id: &str, +) -> Result>, TransactError> { + let txn_id = format!("inline-shapes-{ledger_id}-{t}"); + let mut sink = FlakeSink::new(staged_ns, t, txn_id); + fluree_graph_turtle::parse(turtle, &mut sink) + .map_err(|e| TransactError::Parse(format!("inline shapes Turtle parse error: {e}")))?; + bundle_from_sink(sink) +} + +fn bundle_from_sink(sink: FlakeSink<'_>) -> Result>, TransactError> { let flakes: Vec = sink.finish()?; if flakes.is_empty() { return Ok(None); diff --git a/fluree-db-api/src/lib.rs b/fluree-db-api/src/lib.rs index 423ac3442f..853aa830ee 100644 --- a/fluree-db-api/src/lib.rs +++ b/fluree-db-api/src/lib.rs @@ -83,6 +83,8 @@ pub mod server_defaults; mod time_resolve; pub mod tx; pub mod tx_builder; +#[cfg(feature = "shacl")] +pub mod validate; #[cfg(feature = "vector")] pub mod vector_worker; pub mod view; diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index e2c3d07204..9f7a676074 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -484,6 +484,7 @@ async fn shacl_closed_constraint() { "@type": "sh:NodeShape", "sh:targetClass": {"@id": "ex:Person"}, "sh:closed": true, + "sh:ignoredProperties": { "@list": [{"@id": "rdf:type"}] }, "sh:property": [ { "@id": "ex:pshape1", @@ -2921,12 +2922,13 @@ async fn shacl_ignored_properties_turtle_list() { let context = shacl_context(); let shapes_ttl = r" @prefix sh: . + @prefix rdf: . @prefix ex: . ex:AuditedShape a sh:NodeShape ; sh:targetClass ex:Audited ; sh:closed true ; - sh:ignoredProperties ( ex:internal ex:auditLog ) ; + sh:ignoredProperties ( rdf:type ex:internal ex:auditLog ) ; sh:property [ sh:path ex:label ] . "; @@ -3849,3 +3851,489 @@ async fn shacl_inverse_of_sequence_path() { .unwrap_err(); assert_shacl_violation(err, "at least 1"); } + +// ============================================================ +// Validation reports (fluree validate core — crate::validate) +// ============================================================ + +#[tokio::test] +async fn validate_report_attached_shapes() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-report:main") + .await + .unwrap(); + // Data first (no shapes yet, so staging enforcement doesn't run), then + // the shape — leaving the ledger in a non-conforming state that only a + // full-state validation pass can surface. + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:nameless", + "@type": "ex:User", + "schema:email": "nameless@example.org" + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vr-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-report:main", &options) + .await + .unwrap(); + + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert!(report.shape_count >= 1); + let result = &report.results[0]; + assert_eq!(result.focus_node, "http://example.org/ns/nameless"); + assert_eq!( + result.result_path.as_deref(), + Some("http://schema.org/name") + ); + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); + assert_eq!(result.severity, "http://www.w3.org/ns/shacl#Violation"); + + let doc = report.to_jsonld(); + assert_eq!(doc["sh:conforms"], json!(false)); + let results = doc["sh:result"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0]["sh:sourceConstraintComponent"]["@id"], + json!("sh:MinCountConstraintComponent") + ); + assert_eq!( + results[0]["sh:focusNode"]["@id"], + json!("http://example.org/ns/nameless") + ); +} + +#[tokio::test] +async fn validate_report_conforming_state() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-conforms:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vc-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:alice", + "@type": "ex:User", + "schema:name": "Alice" + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-conforms:main", &options) + .await + .unwrap(); + assert!(report.conforms); + assert!(report.results.is_empty()); + assert!(report.shape_count >= 1); +} + +#[tokio::test] +async fn validate_report_inline_turtle_replaces_attached() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-inline:main") + .await + .unwrap(); + // Non-conforming against the ATTACHED shape (missing schema:name), + // conforming against the inline shape (has schema:email). + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:bob", + "@type": "ex:User", + "schema:email": "bob@example.org" + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:NameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vi-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let email_shapes_turtle = r" + @prefix sh: . + @prefix schema: . + @prefix ex: . + ex:EmailShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:email ; sh:minCount 1 ] . + "; + + // Replace (default): only the inline shape runs — bob conforms. + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(email_shapes_turtle.to_string()), + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-inline:main", &options) + .await + .unwrap(); + assert!( + report.conforms, + "inline shapes must REPLACE attached shapes by default: {:?}", + report.results + ); + + // include_attached: union — the attached name shape now fires. + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(email_shapes_turtle.to_string()), + include_attached: true, + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-inline:main", &options) + .await + .unwrap(); + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert_eq!( + report.results[0].result_path.as_deref(), + Some("http://schema.org/name") + ); +} + +#[tokio::test] +async fn validate_report_inline_jsonld_shapes() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-inline-jsonld:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:carol", + "@type": "ex:User", + "schema:name": "Carol" + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_doc = json!({ + "@context": context.clone(), + "@id": "ex:EmailShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vj-email-ps", + "sh:path": {"@id": "schema:email"}, + "sh:minCount": 1 + }] + }); + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineJsonLd(shapes_doc), + ..Default::default() + }; + let report = + crate::validate::validate_view(&view, "shacl/validate-inline-jsonld:main", &options) + .await + .unwrap(); + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert_eq!(report.results[0].focus_node, "http://example.org/ns/carol"); + assert_eq!( + report.results[0].constraint_component, + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); +} + +#[tokio::test] +async fn validate_report_unknown_graph_is_not_found() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = fluree + .create_ledger("shacl/validate-nograph:main") + .await + .unwrap(); + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions { + graph: Some("http://example.org/graphs/missing".to_string()), + ..Default::default() + }; + let err = crate::validate::validate_view(&view, "shacl/validate-nograph:main", &options) + .await + .unwrap_err(); + assert!(matches!(err, ApiError::NotFound(_)), "got {err:?}"); +} + +#[tokio::test] +async fn validate_report_inline_shapes_carry_class_value_set() { + // An ad-hoc shapes doc may ship both a sh:class constraint AND the + // controlled vocabulary it refers to (`ex:CA rdf:type ex:State`) — + // matching f:shapesSource semantics where value-sets live with the + // shapes. Membership checks must see those bundle facts even though + // the bundle never touches the ledger. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-valueset:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@graph": [ + {"@id": "ex:addr1", "@type": "ex:Address", "ex:state": {"@id": "ex:CA"}}, + {"@id": "ex:addr2", "@type": "ex:Address", "ex:state": {"@id": "ex:XX"}} + ] + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_turtle = r" + @prefix sh: . + @prefix ex: . + ex:AddressShape a sh:NodeShape ; + sh:targetClass ex:Address ; + sh:property [ sh:path ex:state ; sh:class ex:State ] . + ex:CA a ex:State . + "; + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(shapes_turtle.to_string()), + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-valueset:main", &options) + .await + .unwrap(); + + // ex:CA is typed in the bundle -> conforms; ex:XX is typed nowhere -> violation. + assert_eq!(report.violation_count(), 1, "{:?}", report.results); + let result = &report.results[0]; + assert_eq!(result.focus_node, "http://example.org/ns/addr2"); + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#ClassConstraintComponent" + ); +} + +#[tokio::test] +async fn validate_report_value_term_fidelity() { + // sh:value must keep RDF term fidelity: language-tagged literals carry + // @language, non-native datatypes carry @type with the lexical form. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-fidelity:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:thing", + "@type": "ex:Thing", + "ex:label": {"@value": "trop long", "@language": "fr"}, + "ex:score": {"@value": "3.14", "@type": "xsd:decimal"} + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:ThingShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Thing"}, + "sh:property": [ + { + "@id": "ex:vf-label-ps", + "sh:path": {"@id": "ex:label"}, + "sh:maxLength": 4 + }, + { + "@id": "ex:vf-score-ps", + "sh:path": {"@id": "ex:score"}, + "sh:maxInclusive": 2 + } + ] + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-fidelity:main", &options) + .await + .unwrap(); + assert_eq!(report.violation_count(), 2, "{:?}", report.results); + + let label = report + .results + .iter() + .find(|r| r.result_path.as_deref() == Some("http://example.org/ns/label")) + .expect("label violation"); + assert_eq!( + label.value, + Some(json!({"@value": "trop long", "@language": "fr"})), + "language tag must survive into sh:value" + ); + + let score = report + .results + .iter() + .find(|r| r.result_path.as_deref() == Some("http://example.org/ns/score")) + .expect("score violation"); + let value = score.value.as_ref().expect("sh:value present"); + assert_eq!(value["@value"], json!("3.14")); + assert_eq!( + value["@type"], + json!("http://www.w3.org/2001/XMLSchema#decimal") + ); + + // Turtle report renders the same terms as typed / language literals. + let turtle = report.to_turtle(); + assert!(turtle.contains("\"trop long\"@fr"), "{turtle}"); + assert!( + turtle.contains("\"3.14\"^^"), + "{turtle}" + ); +} + +#[tokio::test] +async fn validate_report_literal_target_nodes() { + // sh:targetNode with literal targets: the focus is the literal itself, + // validated directly against the shape's value constraints and reported + // with a value-object focus in the report. + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = fluree + .create_ledger("shacl/validate-literal-target:main") + .await + .unwrap(); + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_turtle = r#" + @prefix sh: . + @prefix ex: . + ex:MinLengthShape a sh:NodeShape ; + sh:minLength 4 ; + sh:targetNode "Hel" ; + sh:targetNode "Hello" ; + sh:targetNode "Hell"@en ; + sh:targetNode 123 . + "#; + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(shapes_turtle.to_string()), + ..Default::default() + }; + let report = + crate::validate::validate_view(&view, "shacl/validate-literal-target:main", &options) + .await + .unwrap(); + + // "Hel" (3) and 123 (3) violate; "Hello", "Hell"@en conform. + assert_eq!(report.violation_count(), 2, "{:?}", report.results); + let focuses: Vec<&JsonValue> = report.results.iter().map(|r| &r.focus_node).collect(); + assert!( + focuses.contains(&&json!({"@value": "Hel"})), + "string literal focus reported as value object: {focuses:?}" + ); + assert!(focuses.contains(&&json!(123)), "{focuses:?}"); + // sh:value = the focus literal. Unlike the focus field (where a bare + // string would be ambiguous with an IRI), value strings stay native — + // IRIs are always {"@id"} objects there. + let values: Vec<&JsonValue> = report.results.iter().flat_map(|r| &r.value).collect(); + assert!(values.contains(&&json!("Hel")), "{values:?}"); + assert!(values.contains(&&json!(123)), "{values:?}"); + for result in &report.results { + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#MinLengthConstraintComponent" + ); + } + + // Turtle report renders literal focus nodes as literals. + let turtle = report.to_turtle(); + assert!(turtle.contains("sh:focusNode \"Hel\""), "{turtle}"); + assert!(turtle.contains("sh:focusNode 123"), "{turtle}"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index d4269a07f2..2609f63166 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -449,7 +449,7 @@ async fn resolve_cross_ledger_shapes_for_tx( /// same mechanism — schema, policy, and SHACL shapes can live in any graph /// the ledger knows about, including the config graph itself. #[cfg(feature = "shacl")] -fn resolve_shapes_source_g_ids( +pub(crate) fn resolve_shapes_source_g_ids( config: Option<&LedgerConfig>, snapshot: &fluree_db_core::LedgerSnapshot, ) -> std::result::Result, fluree_db_transact::TransactError> { @@ -719,11 +719,7 @@ fn format_violations(violations: &[fluree_db_shacl::ValidationResult]) -> String ); for (i, v) in violations.iter().enumerate() { let _ = writeln!(out, " {}. {}", i + 1, v.message); - let _ = writeln!( - out, - " Focus node: {}{}", - v.focus_node.namespace_code, v.focus_node.name - ); + let _ = writeln!(out, " Focus node: {}", v.focus_node); if let Some(path) = &v.result_path { let _ = writeln!(out, " Path: {}{}", path.namespace_code, path.name); } @@ -875,6 +871,7 @@ async fn stage_with_config_shacl( m_db.t, ), data_ns_map: ns_map, + same_term_space: false, }) } _ => None, diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs new file mode 100644 index 0000000000..f65eb919f5 --- /dev/null +++ b/fluree-db-api/src/validate.rs @@ -0,0 +1,738 @@ +//! SHACL validation reports over a ledger view. +//! +//! The shared core behind `fluree validate` (CLI) and the HTTP validate +//! endpoint: validate the *current state* of a ledger (or one of its named +//! graphs) against SHACL shapes and produce a W3C-shaped validation report, +//! instead of rejecting a transaction the way staging-time enforcement does. +//! +//! Shapes can come from four places (see [`ShapesSource`]): the ledger's own +//! attached shapes (honoring `f:shapesSource`), a same-ledger named graph, or +//! an ad-hoc JSON-LD / Turtle document. Ad-hoc documents ride the same +//! non-persisting inline-shapes overlay used by per-transaction `opts.shapes` +//! — nothing is written to the ledger. By default an ad-hoc source *replaces* +//! the attached shapes ("does this data conform to THESE rules?"); set +//! [`ValidateOptions::include_attached`] to union them instead. +//! +//! Validation always runs over the query-visible view — indexed snapshot plus +//! novelty overlay — so recently committed data is never silently skipped. + +use crate::error::{ApiError, Result}; +use crate::ledger_view::LedgerView; +use fluree_db_core::{FlakeValue, GraphDbRef, GraphId, LedgerSnapshot, NoOverlay, Sid}; +use fluree_db_shacl::compile::ShapeCompiler; +use fluree_db_shacl::{Severity, ShaclCache, ShaclCacheKey, ShaclEngine}; +use fluree_db_transact::namespace::NamespaceRegistry; +use fluree_db_transact::TransactError; +use fluree_vocab::config_iris; +use fluree_vocab::shacl as sh_vocab; +use serde_json::{json, Value as JsonValue}; + +/// Where the shapes used for validation come from. +#[derive(Debug, Clone)] +pub enum ShapesSource { + /// The ledger's own attached shapes: the default graph, or the graph(s) + /// configured via `f:shapesSource`. Mirrors what transactions enforce. + Attached, + /// A named graph of the ledger being validated, addressed by IRI. + Graph(String), + /// An ad-hoc JSON-LD shapes document (non-persisting overlay). + InlineJsonLd(JsonValue), + /// An ad-hoc Turtle shapes document (non-persisting overlay). + InlineTurtle(String), +} + +/// Options for [`validate_view`] / [`crate::Fluree::validate_ledger`]. +#[derive(Debug, Clone)] +pub struct ValidateOptions { + /// IRI of the data graph to validate. `None` = the default graph. + pub graph: Option, + /// Where the shapes come from. + pub shapes: ShapesSource, + /// When the shapes source is not [`ShapesSource::Attached`], also union + /// in the ledger's attached shapes instead of replacing them. + pub include_attached: bool, +} + +impl Default for ValidateOptions { + fn default() -> Self { + Self { + graph: None, + shapes: ShapesSource::Attached, + include_attached: false, + } + } +} + +/// One validation result with all identifiers resolved to IRIs. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ReportResult { + /// The node that failed validation (`sh:focusNode`): a JSON string for + /// IRIs / blank-node labels, or a JSON-LD value object (or native + /// scalar) for literal `sh:targetNode` targets. + pub focus_node: JsonValue, + /// The property path, when it is a single predicate (`sh:resultPath`). + /// Complex paths are omitted rather than misrepresented. + #[serde(skip_serializing_if = "Option::is_none")] + pub result_path: Option, + /// The node shape that produced this result. + pub source_shape: String, + /// The property shape that produced this result, when applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_constraint: Option, + /// The constraint component IRI (`sh:sourceConstraintComponent`). + pub constraint_component: String, + /// Severity IRI: `sh:Violation`, `sh:Warning`, or `sh:Info`. + pub severity: String, + /// Human-readable message (`sh:resultMessage`). + pub message: String, + /// The offending value, when applicable (`sh:value`). + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +/// A resolved validation report, ready for serialization. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ValidateReport { + /// `true` when no result carries `sh:Violation` severity. + pub conforms: bool, + /// Individual results, sorted by (focus node, component, message). + pub results: Vec, + /// Number of compiled shapes that were checked. `0` means the shapes + /// source produced no shapes — the report is vacuously conforming. + pub shape_count: usize, +} + +impl ValidateReport { + /// Count results with `sh:Violation` severity. + pub fn violation_count(&self) -> usize { + self.count_severity(sh_vocab::VIOLATION) + } + + /// Count results with `sh:Warning` severity. + pub fn warning_count(&self) -> usize { + self.count_severity(sh_vocab::WARNING) + } + + /// Count results with `sh:Info` severity. + pub fn info_count(&self) -> usize { + self.count_severity(sh_vocab::INFO) + } + + fn count_severity(&self, severity: &str) -> usize { + self.results + .iter() + .filter(|r| r.severity == severity) + .count() + } + + /// Serialize as a W3C `sh:ValidationReport` JSON-LD document. + pub fn to_jsonld(&self) -> JsonValue { + let results: Vec = self + .results + .iter() + .map(|r| { + let mut obj = serde_json::Map::new(); + obj.insert("@type".into(), json!("sh:ValidationResult")); + let focus = match r.focus_node.as_str() { + Some(iri) => json!({"@id": iri}), + None => r.focus_node.clone(), + }; + obj.insert("sh:focusNode".into(), focus); + if let Some(path) = &r.result_path { + obj.insert("sh:resultPath".into(), json!({"@id": path})); + } + obj.insert( + "sh:resultSeverity".into(), + json!({"@id": compact_sh(&r.severity)}), + ); + // W3C sh:sourceShape is the shape that declares the failed + // constraint — the property shape when there is one. + let source_shape = r.source_constraint.as_ref().unwrap_or(&r.source_shape); + obj.insert("sh:sourceShape".into(), json!({"@id": source_shape})); + obj.insert( + "sh:sourceConstraintComponent".into(), + json!({"@id": compact_sh(&r.constraint_component)}), + ); + obj.insert("sh:resultMessage".into(), json!(r.message)); + if let Some(value) = &r.value { + obj.insert("sh:value".into(), value.clone()); + } + JsonValue::Object(obj) + }) + .collect(); + + json!({ + "@context": {"sh": "http://www.w3.org/ns/shacl#"}, + "@type": "sh:ValidationReport", + "sh:conforms": self.conforms, + "sh:result": results, + }) + } + + /// Serialize as a W3C `sh:ValidationReport` Turtle document. + pub fn to_turtle(&self) -> String { + let mut out = String::from("@prefix sh: .\n\n"); + out.push_str("[] a sh:ValidationReport ;\n"); + out.push_str(&format!(" sh:conforms {}", self.conforms)); + for r in &self.results { + out.push_str(" ;\n sh:result [\n a sh:ValidationResult ;\n"); + let focus_term = match r.focus_node.as_str() { + Some(iri) => turtle_term(iri), + None => turtle_value_term(&r.focus_node), + }; + out.push_str(&format!(" sh:focusNode {focus_term} ;\n")); + if let Some(path) = &r.result_path { + out.push_str(&format!(" sh:resultPath {} ;\n", turtle_term(path))); + } + out.push_str(&format!( + " sh:resultSeverity {} ;\n", + turtle_sh_term(&r.severity) + )); + let source_shape = r.source_constraint.as_ref().unwrap_or(&r.source_shape); + out.push_str(&format!( + " sh:sourceShape {} ;\n", + turtle_term(source_shape) + )); + out.push_str(&format!( + " sh:sourceConstraintComponent {} ;\n", + turtle_sh_term(&r.constraint_component) + )); + if let Some(value) = &r.value { + out.push_str(&format!( + " sh:value {} ;\n", + turtle_value_term(value) + )); + } + out.push_str(&format!( + " sh:resultMessage {}\n ]", + turtle_string(&r.message) + )); + } + out.push_str(" .\n"); + out + } +} + +/// Render an IRI or blank-node label as a Turtle term. +/// +/// Skolemized blank-node labels may carry characters that are invalid in a +/// Turtle BLANK_NODE_LABEL (e.g. `/` and `:` from embedded ledger ids) — +/// sanitize them so the emitted document always parses. +fn turtle_term(iri_or_bnode: &str) -> String { + match iri_or_bnode.strip_prefix("_:") { + Some(label) => { + let clean: String = label + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + format!("_:{clean}") + } + None => turtle_iri(iri_or_bnode), + } +} + +/// Render an IRI as a Turtle IRIREF, `\uXXXX`-escaping the characters the Turtle +/// grammar forbids inside `<…>` (controls, space, and ``<>"{}|^`\``). The parser +/// unescapes these back to the original codepoint, so the IRI round-trips — +/// unlike percent-encoding, which would change the IRI's identity. +fn turtle_iri(iri: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(iri.len() + 2); + out.push('<'); + for c in iri.chars() { + match c { + '\u{00}'..='\u{20}' | '<' | '>' | '"' | '{' | '}' | '|' | '^' | '`' | '\\' => { + let _ = write!(out, "\\u{:04X}", c as u32); + } + _ => out.push(c), + } + } + out.push('>'); + out +} + +/// Render an IRI as `sh:Name` when it lives in the SHACL namespace. +fn turtle_sh_term(iri: &str) -> String { + match iri.strip_prefix("http://www.w3.org/ns/shacl#") { + Some(local) => format!("sh:{local}"), + None => turtle_term(iri), + } +} + +/// Render a report value (as produced by `value_json`) as a Turtle term. +fn turtle_value_term(value: &JsonValue) -> String { + match value { + JsonValue::Object(obj) => { + if let Some(iri) = obj.get("@id").and_then(|v| v.as_str()) { + return turtle_term(iri); + } + if let Some(lex) = obj.get("@value").and_then(|v| v.as_str()) { + if let Some(lang) = obj.get("@language").and_then(|v| v.as_str()) { + return format!("{}@{lang}", turtle_string(lex)); + } + if let Some(dt) = obj.get("@type").and_then(|v| v.as_str()) { + return format!("{}^^{}", turtle_string(lex), turtle_iri(dt)); + } + return turtle_string(lex); + } + turtle_string(&value.to_string()) + } + JsonValue::Bool(b) => b.to_string(), + JsonValue::Number(n) => n.to_string(), + JsonValue::String(s) => turtle_string(s), + other => turtle_string(&other.to_string()), + } +} + +/// Quote and escape a Turtle string literal. +fn turtle_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +/// Compact an IRI in the SHACL namespace to its `sh:` form for readability. +fn compact_sh(iri: &str) -> String { + match iri.strip_prefix("http://www.w3.org/ns/shacl#") { + Some(local) => format!("sh:{local}"), + None => iri.to_string(), + } +} + +impl crate::Fluree { + /// Validate the current state of a ledger against SHACL shapes and + /// return a resolved validation report. See [`ValidateOptions`]. + pub async fn validate_ledger( + &self, + ledger_id: &str, + options: &ValidateOptions, + ) -> Result { + let handle = self.ledger_cached(ledger_id).await?; + let view = handle.snapshot().await; + validate_view(&view, ledger_id, options).await + } +} + +/// Validate a ledger view against SHACL shapes. +/// +/// Runs over the query-visible composition (snapshot + novelty overlay) of +/// `view`, scoped to the data graph selected by `options.graph`. +pub async fn validate_view( + view: &LedgerView, + ledger_id: &str, + options: &ValidateOptions, +) -> Result { + let snapshot = view.snapshot.as_ref(); + let novelty = view.novelty.as_ref(); + let to_t = view.t; + + let data_g_id = match options.graph.as_deref() { + None => 0u16, + Some(iri) => resolve_graph_iri(snapshot, iri)?, + }; + + // Holders declared before `shape_dbs` so the borrows they hand out + // outlive the GraphDbRefs (drop order: shape_dbs first). + let mut inline_registry: Option = None; + #[allow(unused_assignments)] + let mut inline_snapshot: Option = None; + #[allow(unused_assignments)] + let mut inline_overlay = None; + let mut inline_membership: Option> = None; + + let mut shape_dbs: Vec> = Vec::new(); + let mut membership: Vec = Vec::new(); + + let use_attached = matches!(options.shapes, ShapesSource::Attached) || options.include_attached; + if use_attached { + // Mirror the transaction path: a broken config graph read degrades to + // defaults (default-graph shapes) rather than failing validation. + let config = match crate::config_resolver::resolve_ledger_config(snapshot, novelty, to_t) + .await + { + Ok(config) => config, + Err(e) => { + tracing::debug!(error = %e, "Config graph read failed during validate — using defaults"); + None + } + }; + let shapes_g_ids = crate::tx::resolve_shapes_source_g_ids(config.as_ref(), snapshot)?; + for g_id in &shapes_g_ids { + shape_dbs.push(GraphDbRef::new(snapshot, *g_id, novelty, to_t)); + } + membership.extend(shapes_g_ids); + } + + match &options.shapes { + ShapesSource::Attached => {} + ShapesSource::Graph(iri) => { + let g_id = resolve_graph_iri(snapshot, iri)?; + if !membership.contains(&g_id) { + shape_dbs.push(GraphDbRef::new(snapshot, g_id, novelty, to_t)); + membership.push(g_id); + } + } + ShapesSource::InlineJsonLd(_) | ShapesSource::InlineTurtle(_) => { + let mut registry = NamespaceRegistry::from_db(snapshot); + let bundle = match &options.shapes { + ShapesSource::InlineJsonLd(doc) => { + crate::inline_shapes::parse_inline_shapes_to_bundle( + doc, + &mut registry, + to_t, + ledger_id, + )? + } + ShapesSource::InlineTurtle(turtle) => { + crate::inline_shapes::parse_inline_shapes_turtle_to_bundle( + turtle, + &mut registry, + to_t, + ledger_id, + )? + } + _ => unreachable!("outer match arm covers only inline sources"), + } + .ok_or_else(empty_shapes_doc)?; + inline_registry = Some(registry); + // Compile from the bundle alone: an empty genesis snapshot with a + // no-op base overlay keeps the ledger's own graph-0 shapes + // (indexed or in novelty) out of the compile scan. When + // `include_attached` is set, the attached shape dbs pushed above + // contribute the union — never a double scan of graph 0. + static NO_OVERLAY: NoOverlay = NoOverlay; + inline_snapshot = Some(LedgerSnapshot::genesis(ledger_id)); + inline_overlay = Some(fluree_db_query::schema_bundle::SchemaBundleOverlay::new( + &NO_OVERLAY, + bundle, + )); + let bundle_db = GraphDbRef::new( + inline_snapshot.as_ref().expect("just set above"), + 0u16, + inline_overlay.as_ref().expect("just set above"), + to_t, + ); + shape_dbs.push(bundle_db); + // The bundle may carry value-set facts alongside the shapes + // (e.g. `ex:CA rdf:type ex:State` for a `sh:class ex:State` + // constraint) — matching the documented f:shapesSource + // semantics where vocabulary lives with the shapes. It shares + // the data ledger's term space, so membership probes use the + // data-side Sids directly. + inline_membership = Some(fluree_db_shacl::CrossLedgerMembership { + model_db: bundle_db, + data_ns_map: snapshot.namespaces(), + same_term_space: true, + }); + } + } + + let shapes = ShapeCompiler::compile_from_dbs(&shape_dbs) + .await + .map_err(TransactError::from)?; + // Hierarchy comes from the real snapshot even when shapes compile from a + // detached inline bundle — subclass expansion must reflect the data. + let hierarchy = snapshot.schema_hierarchy(); + let cache = ShaclCache::new( + ShaclCacheKey::new(ledger_id, to_t as u64), + shapes, + hierarchy.as_ref(), + ); + let engine = match hierarchy { + Some(h) => ShaclEngine::new_with_hierarchy(cache, h), + None => ShaclEngine::new(cache), + } + .with_membership_graphs(membership); + + let shape_count = engine.shape_count(); + if engine.is_empty() { + return Ok(ValidateReport { + conforms: true, + results: Vec::new(), + shape_count, + }); + } + + let data_db = GraphDbRef::new(snapshot, data_g_id, novelty, to_t); + let raw = engine + .validate_all_with_membership(data_db, inline_membership) + .await + .map_err(TransactError::from)?; + + let resolve = |sid: &Sid| resolve_sid(snapshot, inline_registry.as_ref(), sid); + let mut results: Vec = raw + .results + .iter() + .map(|r| ReportResult { + focus_node: match &r.focus_node { + fluree_db_shacl::FocusNode::Node(sid) => JsonValue::String(resolve(sid)), + fluree_db_shacl::FocusNode::Literal(lit) => { + match value_json( + &lit.value, + Some(&lit.datatype), + lit.lang.as_deref(), + &resolve, + ) { + // A bare string would be ambiguous with an IRI focus — + // wrap plain string literals as a value object. + JsonValue::String(s) => json!({"@value": s}), + other => other, + } + } + }, + result_path: r.result_path.as_ref().map(&resolve), + source_shape: resolve(&r.source_shape), + source_constraint: r.source_constraint.as_ref().map(&resolve), + constraint_component: r.constraint_component.to_string(), + severity: severity_iri(r.severity).to_string(), + message: r.message.clone(), + value: r.value.as_ref().map(|v| { + value_json( + v, + r.value_datatype.as_ref(), + r.value_lang.as_deref(), + &resolve, + ) + }), + }) + .collect(); + results.sort_by(|a, b| { + ( + a.focus_node.to_string(), + &a.constraint_component, + &a.message, + ) + .cmp(&( + b.focus_node.to_string(), + &b.constraint_component, + &b.message, + )) + }); + + Ok(ValidateReport { + conforms: raw.conforms, + results, + shape_count, + }) +} + +fn empty_shapes_doc() -> ApiError { + ApiError::Transact(TransactError::Parse( + "shapes document contains no triples".into(), + )) +} + +fn resolve_graph_iri(snapshot: &LedgerSnapshot, iri: &str) -> Result { + if iri == config_iris::DEFAULT_GRAPH { + return Ok(0); + } + snapshot + .graph_registry + .graph_id_for_iri(iri) + .ok_or_else(|| { + ApiError::NotFound(format!( + "graph '{iri}' not found in this ledger's graph registry" + )) + }) +} + +/// Resolve a SID to an IRI: the snapshot's namespace table first, then the +/// inline-shapes registry (ad-hoc shape vocabulary gets ephemeral codes the +/// snapshot has never seen), then the raw name as a last resort. +fn resolve_sid( + snapshot: &LedgerSnapshot, + inline_registry: Option<&NamespaceRegistry>, + sid: &Sid, +) -> String { + snapshot + .decode_sid(sid) + .or_else(|| { + inline_registry.and_then(|registry| { + registry + .get_prefix(sid.namespace_code) + .map(|prefix| format!("{prefix}{}", sid.name)) + }) + }) + .unwrap_or_else(|| sid.name.to_string()) +} + +fn severity_iri(severity: Severity) -> &'static str { + match severity { + Severity::Violation => sh_vocab::VIOLATION, + Severity::Warning => sh_vocab::WARNING, + Severity::Info => sh_vocab::INFO, + } +} + +const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; + +/// Render an `sh:value` term as JSON-LD, preserving RDF term fidelity: +/// language-tagged literals become `{"@value", "@language"}`, non-native +/// datatypes become `{"@value", "@type"}` with the lexical form, and only +/// the JSON-native XSD types (string / boolean / integer / double) render +/// as bare JSON scalars. +fn value_json( + value: &FlakeValue, + datatype: Option<&Sid>, + lang: Option<&str>, + resolve: &impl Fn(&Sid) -> String, +) -> JsonValue { + if let FlakeValue::Ref(sid) = value { + return json!({"@id": resolve(sid)}); + } + if let Some(lang) = lang { + return json!({"@value": lexical_form(value), "@language": lang}); + } + if let Some(dt) = datatype { + let dt_iri = resolve(dt); + // A String carrying the `@id` datatype is a stringified IRI + // (STR() semantics in the string facets) — report the IRI node. + // Match the JSON-LD `@id` datatype by namespace, not the bare local + // name, so a real datatype whose local name is `id` (e.g. + // ``) is not misreported as an IRI node. + let is_id_datatype = dt_iri == "@id" + || (dt.namespace_code == fluree_vocab::namespaces::JSON_LD + && &*dt.name == fluree_vocab::jsonld_names::ID); + if is_id_datatype { + if let FlakeValue::String(s) = value { + return json!({"@id": s}); + } + } + return match (value, dt_iri.strip_prefix(XSD)) { + (FlakeValue::String(s), Some("string")) => json!(s), + (FlakeValue::Boolean(b), Some("boolean")) => json!(b), + (FlakeValue::Long(n), Some("integer")) => json!(n), + (FlakeValue::Double(d), Some("double")) => json!(d), + _ => json!({"@value": lexical_form(value), "@type": dt_iri}), + }; + } + // No datatype threaded: JSON natives stay native; self-describing + // temporal / numeric variants carry their XSD type. + match value { + FlakeValue::Boolean(b) => json!(b), + FlakeValue::Long(n) => json!(n), + FlakeValue::Double(d) => json!(d), + FlakeValue::String(s) => json!(s), + FlakeValue::Json(s) => serde_json::from_str(s).unwrap_or_else(|_| json!(s)), + FlakeValue::Null => JsonValue::Null, + other => match inferred_xsd_type(other) { + Some(local) => json!({ + "@value": lexical_form(other), + "@type": format!("{XSD}{local}"), + }), + None => json!(other.to_string()), + }, + } +} + +/// Lexical form of a literal for `@value`. `Display` is the canonical form +/// for the temporal / numeric variants, but wraps strings in quotes. +fn lexical_form(value: &FlakeValue) -> String { + match value { + FlakeValue::String(s) | FlakeValue::Json(s) => s.clone(), + other => other.to_string(), + } +} + +/// XSD local name for FlakeValue variants that self-describe their datatype. +fn inferred_xsd_type(value: &FlakeValue) -> Option<&'static str> { + Some(match value { + FlakeValue::Decimal(_) => "decimal", + FlakeValue::BigInt(_) => "integer", + FlakeValue::DateTime(_) => "dateTime", + FlakeValue::Date(_) => "date", + FlakeValue::Time(_) => "time", + FlakeValue::GYear(_) => "gYear", + FlakeValue::GYearMonth(_) => "gYearMonth", + FlakeValue::GMonth(_) => "gMonth", + FlakeValue::GDay(_) => "gDay", + FlakeValue::GMonthDay(_) => "gMonthDay", + FlakeValue::YearMonthDuration(_) => "yearMonthDuration", + FlakeValue::DayTimeDuration(_) => "dayTimeDuration", + FlakeValue::Duration(_) => "duration", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_report() -> ValidateReport { + ValidateReport { + conforms: false, + results: vec![ReportResult { + focus_node: "http://example.org/ns/nameless".into(), + result_path: Some("http://schema.org/name".into()), + source_shape: "http://example.org/ns/UserShape".into(), + source_constraint: Some("http://example.org/ns/name-ps".into()), + constraint_component: "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + .into(), + severity: "http://www.w3.org/ns/shacl#Violation".into(), + message: "Expected at least 1 value(s) but found 0".into(), + value: None, + }], + shape_count: 1, + } + } + + #[test] + fn turtle_report_round_trips_through_parser() { + let turtle = sample_report().to_turtle(); + let mut sink = fluree_graph_ir::GraphCollectorSink::new(); + fluree_graph_turtle::parse(&turtle, &mut sink).expect("report Turtle must parse"); + let graph = sink.finish(); + // report node (type + conforms + result) and the result node's fields + assert!(graph.len() >= 8, "expected full report triples: {turtle}"); + assert!(turtle.contains("sh:MinCountConstraintComponent")); + assert!(turtle.contains("")); + } + + #[test] + fn turtle_term_sanitizes_blank_node_labels() { + assert_eq!( + turtle_term("_:fdb-inline-shapes-validate/scratch:main-2-b1"), + "_:fdb-inline-shapes-validate-scratch-main-2-b1" + ); + assert_eq!(turtle_term("http://ex.org/a"), ""); + } + + #[test] + fn turtle_string_escapes_specials() { + assert_eq!(turtle_string("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\""); + } + + #[test] + fn turtle_iri_escapes_illegal_chars() { + // Well-formed IRIs pass through unchanged. + assert_eq!(turtle_iri("http://ex.org/a"), ""); + // Grammar-illegal chars become \uXXXX and the result still parses. + assert_eq!( + turtle_iri("http://ex.org/a b"), + "" + ); + let turtle = format!( + " {} .\n", + turtle_iri("http://ex.org/a b|c") + ); + let mut sink = fluree_graph_ir::GraphCollectorSink::new(); + fluree_graph_turtle::parse(&turtle, &mut sink).expect("escaped IRI must parse"); + } +} diff --git a/fluree-db-api/tests/grp_graphsource.rs b/fluree-db-api/tests/grp_graphsource.rs index 1eb44db7f3..d2807e3851 100644 --- a/fluree-db-api/tests/grp_graphsource.rs +++ b/fluree-db-api/tests/grp_graphsource.rs @@ -29,3 +29,5 @@ mod it_ontology_inline; mod it_shapes_cross_ledger; #[path = "it_shapes_inline.rs"] mod it_shapes_inline; +#[path = "it_validate_report.rs"] +mod it_validate_report; diff --git a/fluree-db-api/tests/it_validate_report.rs b/fluree-db-api/tests/it_validate_report.rs new file mode 100644 index 0000000000..106b337818 --- /dev/null +++ b/fluree-db-api/tests/it_validate_report.rs @@ -0,0 +1,124 @@ +//! Explicit `validate` endpoint: focus-node computation for class targets. +//! +//! Covers subclass expansion (a `sh:targetClass` shape must also select +//! instances of subclasses) and the per-class focus-node memo shared by +//! multiple shapes targeting the same class — the path in +//! `ShaclEngine::validate_all_with_membership` / `get_focus_nodes`. + +#![cfg(all(feature = "native", feature = "shacl"))] + +use fluree_db_api::validate::ValidateOptions; +use fluree_db_api::FlureeBuilder; +use serde_json::json; + +use crate::support::genesis_ledger; + +#[tokio::test] +async fn validate_report_shares_focus_nodes_across_shapes_targeting_same_class() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "test/validate-report/memo:main"; + let ledger = genesis_ledger(&fluree, ledger_id); + + // Step 1: data only — a subclass hierarchy plus two instances, one of the + // subclass. No shapes exist yet, so no transaction-time enforcement fires. + let r1 = fluree + .insert( + ledger, + &json!({ + "@context": { + "ex": "http://example.org/ns/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#" + }, + "@graph": [ + { "@id": "ex:Dog", "rdfs:subClassOf": {"@id": "ex:Animal"} }, + { "@id": "ex:rex", "@type": "ex:Dog" }, + { "@id": "ex:generic", "@type": "ex:Animal" } + ] + }), + ) + .await + .expect("seed data (no shapes) must succeed"); + let ledger = r1.ledger; + + // Step 2: two node shapes, BOTH targeting ex:Animal — this is what + // exercises the per-class focus-node memo. Staging shape definitions only + // validates the shape subjects (not the pre-existing instances), so it + // commits cleanly. + let r2 = fluree + .insert( + ledger, + &json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#" + }, + "@graph": [ + { + "@id": "ex:AnimalNameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Animal"}, + "sh:property": { "sh:path": {"@id": "ex:name"}, "sh:minCount": 1 } + }, + { + "@id": "ex:AnimalSpeciesShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Animal"}, + "sh:property": { "sh:path": {"@id": "ex:species"}, "sh:minCount": 1 } + } + ] + }), + ) + .await + .expect("seed shapes must succeed (shape subjects are not Animal instances)"); + let _ledger = r2.ledger; + + // Explicit validation over the default graph against the ledger's own + // (attached) shapes. + let report = fluree + .validate_ledger(ledger_id, &ValidateOptions::default()) + .await + .expect("validate must succeed"); + + assert!( + !report.conforms, + "instances violate both shapes: {report:?}" + ); + assert_eq!(report.shape_count, 2, "both shapes compiled"); + + // Subclass expansion: ex:rex (an ex:Dog) must appear as a focus node even + // though the shapes target ex:Animal. ex:generic is a direct instance. + let focus: std::collections::HashSet = report + .results + .iter() + .filter_map(|r| r.focus_node.as_str().map(String::from)) + .collect(); + assert!( + focus.contains("http://example.org/ns/rex"), + "subclass instance ex:rex must be a focus node: {focus:?}" + ); + assert!( + focus.contains("http://example.org/ns/generic"), + "direct instance ex:generic must be a focus node: {focus:?}" + ); + + // Both shapes fired on the shared class — the memo returned the same focus + // set to each. 2 instances × 2 shapes = 4 minCount violations. + let paths: std::collections::HashSet = report + .results + .iter() + .filter_map(|r| r.result_path.clone()) + .collect(); + assert!( + paths.contains("http://example.org/ns/name"), + "name shape must fire: {paths:?}" + ); + assert!( + paths.contains("http://example.org/ns/species"), + "species shape must fire: {paths:?}" + ); + assert_eq!( + report.violation_count(), + 4, + "each of 2 instances violates each of 2 shapes: {report:?}" + ); +} diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index baa29fd9af..ed09716404 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -743,6 +743,52 @@ pub enum Commands { #[command(subcommand)] action: ContextAction, }, + /// Validate data against SHACL shapes and print a validation report + /// + /// Ledger mode validates the current state of a local ledger against its + /// attached shapes (or ad-hoc shapes via --shacl / --shacl-graph). + /// File mode validates an RDF file in an ephemeral in-memory ledger — + /// nothing persists. Exits 1 when results at or above --fail-on exist. + /// + /// Examples: + /// fluree validate mydb + /// fluree validate mydb --shacl proposed-shapes.ttl + /// fluree validate data.ttl --shacl shapes.ttl + /// fluree validate data.jsonld --format jsonld + #[cfg(feature = "shacl")] + Validate { + /// Ledger name (with optional :branch) or an RDF data file + /// (.ttl / .jsonld / .json). Defaults to the active ledger. + #[arg(num_args = 0..=1)] + target: Option, + + /// IRI of a named data graph to validate (defaults to the default graph) + #[arg(long)] + graph: Option, + + /// SHACL shapes file (Turtle or JSON-LD). Replaces the ledger's + /// attached shapes unless --include-attached is also set. + #[arg(long, value_name = "FILE")] + shacl: Option, + + /// IRI of a named graph in the target ledger holding the shapes + #[arg(long, value_name = "IRI", conflicts_with = "shacl")] + shacl_graph: Option, + + /// Union ad-hoc shapes with the ledger's attached shapes instead of + /// replacing them + #[arg(long)] + include_attached: bool, + + /// Output format: table (human), jsonld, or turtle (W3C report) + #[arg(long, default_value = "table")] + format: String, + + /// Exit non-zero when results at or above this severity exist: + /// violation, warning, or info + #[arg(long, default_value = "violation", value_name = "SEVERITY")] + fail_on: String, + }, /// Export ledger data as RDF (Turtle, N-Triples, N-Quads, TriG, JSON-LD) or as a `.flpack` archive Export { diff --git a/fluree-db-cli/src/commands/mod.rs b/fluree-db-cli/src/commands/mod.rs index 64b6a94ca4..b88c36bcb4 100644 --- a/fluree-db-cli/src/commands/mod.rs +++ b/fluree-db-cli/src/commands/mod.rs @@ -35,3 +35,5 @@ pub mod update; pub mod upsert; pub mod upstream; pub mod use_cmd; +#[cfg(feature = "shacl")] +pub mod validate; diff --git a/fluree-db-cli/src/commands/validate.rs b/fluree-db-cli/src/commands/validate.rs new file mode 100644 index 0000000000..bf11c3ffc5 --- /dev/null +++ b/fluree-db-cli/src/commands/validate.rs @@ -0,0 +1,259 @@ +//! `fluree validate` — SHACL validation reports. +//! +//! Ledger mode validates the current state of a local ledger; file mode +//! loads an RDF file into an ephemeral in-memory ledger (staging-time SHACL +//! enforcement disabled so embedded shapes can't reject the load) and runs +//! the same validation core. Both call `fluree_db_api::validate`. + +use crate::context::{self, LedgerMode}; +use crate::detect; +use crate::error::{CliError, CliResult, EXIT_ERROR}; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use fluree_db_api::Fluree; +use std::io::Write; +use std::path::Path; + +/// Severity threshold for the process exit code. +#[derive(Clone, Copy, PartialEq)] +enum FailOn { + Violation, + Warning, + Info, +} + +enum ReportFormat { + Table, + JsonLd, + Turtle, +} + +#[allow(clippy::too_many_arguments)] +pub async fn run( + target: Option<&str>, + graph: Option<&str>, + shacl: Option<&Path>, + shacl_graph: Option<&str>, + include_attached: bool, + format: &str, + fail_on: &str, + config_path: Option<&Path>, +) -> CliResult<()> { + let format = parse_format(format)?; + let fail_on = parse_fail_on(fail_on)?; + let shapes = resolve_shapes_source(shacl, shacl_graph)?; + + // File mode: an existing RDF file validates in an ephemeral memory ledger. + if let Some(t) = target { + let path = Path::new(t); + if path.is_file() { + let report = validate_file(path, graph, shapes, include_attached).await?; + return finish(&report, format, fail_on); + } + if looks_like_data_file(t) { + return Err(CliError::Usage(format!( + "no such file: '{t}' — pass an existing RDF file or a ledger name" + ))); + } + } + + // Ledger mode: local ledgers only (the HTTP validate endpoint comes later). + let dirs = crate::config::require_fluree_dir(config_path)?; + let mode = context::resolve_ledger_mode(target, &dirs).await?; + match mode { + LedgerMode::Local { fluree, alias } => { + let options = ValidateOptions { + graph: graph.map(String::from), + shapes, + include_attached, + }; + let report = fluree.validate_ledger(&alias, &options).await?; + finish(&report, format, fail_on) + } + LedgerMode::Tracked { .. } => Err(CliError::Usage( + "fluree validate runs against local ledgers; validating a remote \ + ledger over HTTP is not yet supported" + .into(), + )), + } +} + +/// Load an RDF file into an ephemeral in-memory ledger and validate it. +async fn validate_file( + path: &Path, + graph: Option<&str>, + shapes: ShapesSource, + include_attached: bool, +) -> CliResult { + let content = std::fs::read_to_string(path) + .map_err(|e| CliError::Input(format!("cannot read data file '{}': {e}", path.display())))?; + let data_format = detect::detect_data_format(Some(path), &content, None)?; + + let fluree = context::build_memory_fluree(); + let alias = "validate/scratch:main"; + fluree.create_ledger(alias).await?; + + // Disable staging-time SHACL so a file that embeds its own shapes loads + // even when the data violates them — surfacing violations is this + // command's job, not the loader's. + disable_staging_shacl(&fluree, alias).await?; + + let ledger_graph = fluree.graph(alias); + match data_format { + detect::DataFormat::Turtle => { + ledger_graph + .transact() + .insert_turtle(&content) + .commit() + .await?; + } + detect::DataFormat::JsonLd => { + let json: serde_json::Value = serde_json::from_str(&content)?; + ledger_graph.transact().insert(&json).commit().await?; + } + } + + let options = ValidateOptions { + graph: graph.map(String::from), + shapes, + include_attached, + }; + Ok(fluree.validate_ledger(alias, &options).await?) +} + +async fn disable_staging_shacl(fluree: &Fluree, alias: &str) -> CliResult<()> { + let config_iri = fluree_db_core::graph_registry::config_graph_iri(alias); + let trig = format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled false . + }} + " + ); + fluree + .graph(alias) + .transact() + .upsert_turtle(&trig) + .commit() + .await?; + Ok(()) +} + +fn resolve_shapes_source( + shacl: Option<&Path>, + shacl_graph: Option<&str>, +) -> CliResult { + if let Some(path) = shacl { + let content = std::fs::read_to_string(path).map_err(|e| { + CliError::Input(format!("cannot read shapes file '{}': {e}", path.display())) + })?; + return Ok( + match detect::detect_data_format(Some(path), &content, None)? { + detect::DataFormat::Turtle => ShapesSource::InlineTurtle(content), + detect::DataFormat::JsonLd => { + ShapesSource::InlineJsonLd(serde_json::from_str(&content)?) + } + }, + ); + } + if let Some(iri) = shacl_graph { + return Ok(ShapesSource::Graph(iri.to_string())); + } + Ok(ShapesSource::Attached) +} + +fn parse_format(format: &str) -> CliResult { + match format { + "table" => Ok(ReportFormat::Table), + "jsonld" | "json-ld" | "json" => Ok(ReportFormat::JsonLd), + "turtle" | "ttl" => Ok(ReportFormat::Turtle), + other => Err(CliError::Usage(format!( + "unknown --format '{other}' (expected table, jsonld, or turtle)" + ))), + } +} + +fn parse_fail_on(fail_on: &str) -> CliResult { + match fail_on { + "violation" => Ok(FailOn::Violation), + "warning" => Ok(FailOn::Warning), + "info" => Ok(FailOn::Info), + other => Err(CliError::Usage(format!( + "unknown --fail-on '{other}' (expected violation, warning, or info)" + ))), + } +} + +/// Print the report and exit non-zero when the fail-on threshold is met. +fn finish(report: &ValidateReport, format: ReportFormat, fail_on: FailOn) -> CliResult<()> { + match format { + ReportFormat::Table => print_table(report), + ReportFormat::JsonLd => println!("{}", serde_json::to_string_pretty(&report.to_jsonld())?), + ReportFormat::Turtle => print!("{}", report.to_turtle()), + } + + if report.shape_count == 0 { + eprintln!("warning: no SHACL shapes found — nothing was validated"); + } + + let failing = match fail_on { + FailOn::Violation => report.violation_count(), + FailOn::Warning => report.violation_count() + report.warning_count(), + FailOn::Info => report.results.len(), + }; + if failing > 0 { + std::io::stdout().flush().ok(); + return Err(CliError::ExitCode(EXIT_ERROR)); + } + Ok(()) +} + +fn print_table(report: &ValidateReport) { + for result in &report.results { + let severity = short_iri(&result.severity); + let component = short_iri(&result.constraint_component); + match result.focus_node.as_str() { + Some(iri) => println!("{severity}: {iri}"), + None => println!("{severity}: {}", result.focus_node), + } + if let Some(path) = &result.result_path { + println!(" path: {path}"); + } + println!(" component: {component}"); + println!(" message: {}", result.message); + if let Some(value) = &result.value { + println!(" value: {value}"); + } + } + if !report.results.is_empty() { + println!(); + } + println!( + "Conforms: {} — {} violation(s), {} warning(s), {} info ({} shape(s) checked)", + report.conforms, + report.violation_count(), + report.warning_count(), + report.info_count(), + report.shape_count + ); +} + +/// Shorten a SHACL-namespace IRI to its local name for table display. +fn short_iri(iri: &str) -> &str { + iri.rsplit_once('#').map_or(iri, |(_, local)| local) +} + +/// A path-shaped or extension-bearing argument that names no existing file is +/// almost always a typo'd file path — reject clearly instead of resolving it +/// as a ledger name. +fn looks_like_data_file(s: &str) -> bool { + // Ledger aliases may contain '/', so key off data-file extensions only. + let lower = s.to_ascii_lowercase(); + [".json", ".jsonld", ".ttl", ".nt", ".nq", ".trig"] + .iter() + .any(|ext| lower.ends_with(ext)) +} diff --git a/fluree-db-cli/src/error.rs b/fluree-db-cli/src/error.rs index 19e1d0568c..8e19c744e5 100644 --- a/fluree-db-cli/src/error.rs +++ b/fluree-db-cli/src/error.rs @@ -31,6 +31,11 @@ pub enum CliError { Remote(String), /// Server lifecycle error (start/stop/status). Server(String), + /// Terminate with a specific exit code and no error message — the + /// command already wrote its output (e.g. `validate` printed a + /// non-conforming report). Library consumers of `run()` receive this + /// as a typed outcome instead of a `process::exit`. + ExitCode(i32), } impl fmt::Display for CliError { @@ -57,6 +62,7 @@ impl fmt::Display for CliError { CliError::Credential(e) => write!(f, "{} {e}", "error:".red().bold()), CliError::Remote(msg) => write!(f, "{} {msg}", "error:".red().bold()), CliError::Server(msg) => write!(f, "{} {msg}", "error:".red().bold()), + CliError::ExitCode(code) => write!(f, "exit code {code}"), } } } @@ -135,6 +141,10 @@ impl From for CliError { /// Print error and exit with the appropriate code. pub fn exit_with_error(err: CliError) -> ! { + // A typed exit carries no message — the command already wrote its output. + if let CliError::ExitCode(code) = &err { + process::exit(*code); + } eprintln!("{err}"); let code = match &err { CliError::Usage(_) => EXIT_USAGE, diff --git a/fluree-db-cli/src/lib.rs b/fluree-db-cli/src/lib.rs index d4e8f17163..81569b03ae 100644 --- a/fluree-db-cli/src/lib.rs +++ b/fluree-db-cli/src/lib.rs @@ -398,6 +398,29 @@ pub async fn run(cli: Cli) -> error::CliResult<()> { } } + #[cfg(feature = "shacl")] + Commands::Validate { + target, + graph, + shacl, + shacl_graph, + include_attached, + format, + fail_on, + } => { + commands::validate::run( + target.as_deref(), + graph.as_deref(), + shacl.as_deref(), + shacl_graph.as_deref(), + include_attached, + &format, + &fail_on, + config_path, + ) + .await + } + Commands::Export { ledger, format, diff --git a/fluree-db-cli/tests/integration.rs b/fluree-db-cli/tests/integration.rs index 57ab9ca8b9..5301c69766 100644 --- a/fluree-db-cli/tests/integration.rs +++ b/fluree-db-cli/tests/integration.rs @@ -2633,3 +2633,89 @@ fn update_via_stdin() { .success() .stdout(predicate::str::contains("Committed t=1")); } + +// ============================================================================ +// Validate command (file mode — no .fluree required) +// ============================================================================ + +const VALIDATE_SHAPES_TTL: &str = r" +@prefix sh: . +@prefix ex: . +@prefix schema: . +ex:UserShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:name ; sh:minCount 1 ] . +"; + +#[test] +fn validate_file_with_shapes_reports_violation() { + let work = TempDir::new().unwrap(); + std::fs::write( + work.path().join("data.ttl"), + r#" +@prefix ex: . +@prefix schema: . +ex:bob a ex:User ; schema:email "bob@example.org" . +"#, + ) + .unwrap(); + std::fs::write(work.path().join("shapes.ttl"), VALIDATE_SHAPES_TTL).unwrap(); + + fluree_cmd(&work) + .args(["validate", "data.ttl", "--shacl", "shapes.ttl"]) + .assert() + .code(1) + .stdout(predicate::str::contains("MinCountConstraintComponent")) + .stdout(predicate::str::contains("Conforms: false")); +} + +#[test] +fn validate_conforming_file_exits_zero() { + let work = TempDir::new().unwrap(); + std::fs::write( + work.path().join("data.ttl"), + r#" +@prefix ex: . +@prefix schema: . +ex:alice a ex:User ; schema:name "Alice" . +"#, + ) + .unwrap(); + std::fs::write(work.path().join("shapes.ttl"), VALIDATE_SHAPES_TTL).unwrap(); + + fluree_cmd(&work) + .args(["validate", "data.ttl", "--shacl", "shapes.ttl"]) + .assert() + .success() + .stdout(predicate::str::contains("Conforms: true")); +} + +#[test] +fn validate_file_with_embedded_shapes() { + // Shapes and violating data in one file: the loader must not reject the + // load (staging-time SHACL is disabled in the ephemeral ledger) — the + // violation surfaces in the report instead. + let work = TempDir::new().unwrap(); + let mut doc = String::from(VALIDATE_SHAPES_TTL); + doc.push_str("\nex:carol a ex:User ; schema:email \"carol@example.org\" .\n"); + std::fs::write(work.path().join("embedded.ttl"), doc).unwrap(); + + fluree_cmd(&work) + .args(["validate", "embedded.ttl", "--format", "turtle"]) + .assert() + .code(1) + .stdout(predicate::str::contains("sh:ValidationReport")) + .stdout(predicate::str::contains("sh:conforms false")) + .stdout(predicate::str::contains("carol")); +} + +#[test] +fn validate_rejects_unknown_fail_on() { + let work = TempDir::new().unwrap(); + std::fs::write(work.path().join("data.ttl"), "@prefix ex: .").unwrap(); + fluree_cmd(&work) + .args(["validate", "data.ttl", "--fail-on", "nope"]) + .assert() + .code(2) + .stderr(predicate::str::contains("unknown --fail-on")); +} diff --git a/fluree-db-server/src/routes/mod.rs b/fluree-db-server/src/routes/mod.rs index 6baaaf596d..766930b4d1 100644 --- a/fluree-db-server/src/routes/mod.rs +++ b/fluree-db-server/src/routes/mod.rs @@ -23,6 +23,8 @@ mod stream_query; mod stubs; mod submissions; mod transact; +#[cfg(feature = "shacl")] +mod validate; use crate::state::AppState; use axum::{ @@ -284,6 +286,13 @@ pub fn build_router(state: Arc) -> Router { .route("/subscribe", get(stubs::subscribe)) .route("/remote/:path", get(stubs::remote).post(stubs::remote)); + // SHACL validation report (read endpoint; see routes/validate.rs) + #[cfg(feature = "shacl")] + let v1 = v1.route( + "/validate/*ledger", + get(validate::validate_ledger_tail).post(validate::validate_ledger_tail), + ); + let mut router = Router::new() // Health check .route("/health", get(admin::health)) diff --git a/fluree-db-server/src/routes/validate.rs b/fluree-db-server/src/routes/validate.rs new file mode 100644 index 0000000000..ed76ca7ebd --- /dev/null +++ b/fluree-db-server/src/routes/validate.rs @@ -0,0 +1,228 @@ +//! SHACL validation report endpoint: `GET|POST /v1/fluree/validate/*ledger`. +//! +//! The HTTP surface of `fluree_db_api::validate` — validates the current +//! state of a ledger (or one of its named graphs) against SHACL shapes and +//! returns a validation report, instead of rejecting a transaction the way +//! staging-time enforcement does. +//! +//! `GET` validates against the ledger's attached shapes with default +//! options. `POST` accepts a JSON body selecting the data graph and the +//! shapes source (see [`ValidateBody`]). Ad-hoc shapes REPLACE the attached +//! shapes unless `includeAttached` is set, matching the CLI semantics. +//! +//! Response negotiation via `Accept`: +//! - `text/turtle` → W3C `sh:ValidationReport` as Turtle +//! - `application/ld+json` → W3C `sh:ValidationReport` as JSON-LD +//! - anything else (default) → JSON summary envelope with per-result fields +//! +//! Auth matches the read endpoints (bearer `can_read` on the ledger). Note +//! that validation reads the full graph — results are not policy-filtered, +//! so access to this endpoint implies read access to the data it reports on. + +use crate::config::ServerRole; +use crate::error::{Result, ServerError}; +use crate::extract::{FlureeHeaders, MaybeDataBearer}; +use crate::state::AppState; +use crate::telemetry::{ + create_request_span, extract_request_id, extract_trace_id, set_span_error_code, +}; +use axum::extract::{Path, Request, State}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use serde::Deserialize; +use serde_json::{json, Value as JsonValue}; +use std::sync::Arc; +use tracing::Instrument; + +/// Maximum request-body size (inline shapes documents). +const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// POST body for the validate endpoint. All fields optional; an empty or +/// absent body validates the default graph against the attached shapes. +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ValidateBody { + /// IRI of the named data graph to validate (default: the default graph). + pub graph: Option, + /// Ad-hoc shapes: a JSON-LD object/array, or a string containing Turtle. + pub shapes: Option, + /// IRI of a named graph in this ledger holding the shapes. + pub shapes_graph: Option, + /// Union ad-hoc shapes with the attached shapes instead of replacing. + #[serde(default)] + pub include_attached: bool, +} + +/// Validate a ledger's current state (ledger in path tail). +/// +/// `GET|POST /v1/fluree/validate/` +pub async fn validate_ledger_tail( + State(state): State>, + Path(ledger): Path, + headers: FlureeHeaders, + bearer: MaybeDataBearer, + request: Request, +) -> Response { + // In peer mode, forward to transactor (body still intact). + if state.config.server_role == ServerRole::Peer { + let client = match state.forwarding_client.as_ref() { + Some(c) => c, + None => { + return ServerError::internal("Forwarding client not configured").into_response() + } + }; + return match client.forward(request).await { + Ok(resp) => resp, + Err(e) => e.into_response(), + }; + } + + let body = match read_body(request).await { + Ok(body) => body, + Err(e) => return e.into_response(), + }; + validate_local(state, ledger, headers, bearer, body) + .await + .into_response() +} + +async fn read_body(request: Request) -> Result { + let bytes = axum::body::to_bytes(request.into_body(), MAX_BODY_BYTES) + .await + .map_err(|e| ServerError::bad_request(format!("failed to read request body: {e}")))?; + if bytes.is_empty() { + return Ok(ValidateBody::default()); + } + serde_json::from_slice(&bytes) + .map_err(|e| ServerError::bad_request(format!("invalid validate body: {e}"))) +} + +async fn validate_local( + state: Arc, + alias: String, + headers: FlureeHeaders, + bearer: MaybeDataBearer, + body: ValidateBody, +) -> Result { + let request_id = extract_request_id(&headers.raw, &state.telemetry_config); + let trace_id = extract_trace_id(&headers.raw); + + let span = create_request_span( + "validate", + request_id.as_deref(), + trace_id.as_deref(), + Some(&alias), + None, + None, + ); + async move { + let span = tracing::Span::current(); + tracing::info!(status = "start", "shacl validate requested"); + + // Enforce data auth (same pattern as the query/show endpoints). + let data_auth = state.config.data_auth(); + if data_auth.mode == crate::config::DataAuthMode::Required && bearer.0.is_none() { + set_span_error_code(&span, "error:Unauthorized"); + return Err(ServerError::unauthorized("Bearer token required")); + } + if let Some(p) = bearer.0.as_ref() { + if !p.can_read(&alias) { + set_span_error_code(&span, "error:Forbidden"); + return Err(ServerError::not_found("Ledger not found")); + } + } + + // Validation reads the local index; proxy storage mode has none. + if state.config.is_proxy_storage_mode() { + set_span_error_code(&span, "error:NotImplemented"); + return Err(ServerError::NotImplemented( + "SHACL validation is not available in proxy storage mode".to_string(), + )); + } + + let options = ValidateOptions { + graph: body.graph.clone(), + shapes: shapes_source(&body)?, + include_attached: body.include_attached, + }; + + let report = state + .fluree + .validate_ledger(&alias, &options) + .await + .map_err(|e| { + if e.is_not_found() { + ServerError::not_found(format!("Ledger not found: {alias}")) + } else { + ServerError::Api(e) + } + })?; + + tracing::info!( + status = "success", + conforms = report.conforms, + results = report.results.len(), + shapes = report.shape_count, + "shacl validate complete" + ); + Ok(negotiate_response(&report, &headers)) + } + .instrument(span) + .await +} + +fn shapes_source(body: &ValidateBody) -> Result { + if body.shapes.is_some() && body.shapes_graph.is_some() { + return Err(ServerError::bad_request( + "'shapes' and 'shapesGraph' are mutually exclusive", + )); + } + match &body.shapes { + Some(JsonValue::String(turtle)) => Ok(ShapesSource::InlineTurtle(turtle.clone())), + Some(doc @ (JsonValue::Object(_) | JsonValue::Array(_))) => { + Ok(ShapesSource::InlineJsonLd(doc.clone())) + } + Some(_) => Err(ServerError::bad_request( + "'shapes' must be a JSON-LD object/array or a string of Turtle", + )), + None => Ok(match &body.shapes_graph { + Some(iri) => ShapesSource::Graph(iri.clone()), + None => ShapesSource::Attached, + }), + } +} + +fn negotiate_response(report: &ValidateReport, headers: &FlureeHeaders) -> Response { + let accept = headers + .raw + .get(http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("text/turtle") { + return ( + [(http::header::CONTENT_TYPE, "text/turtle")], + report.to_turtle(), + ) + .into_response(); + } + if accept.contains("application/ld+json") { + return ( + [(http::header::CONTENT_TYPE, "application/ld+json")], + Json(report.to_jsonld()).into_response().into_body(), + ) + .into_response(); + } + + // Default: JSON summary envelope with IRI-resolved per-result fields. + Json(json!({ + "conforms": report.conforms, + "violations": report.violation_count(), + "warnings": report.warning_count(), + "infos": report.info_count(), + "shapesChecked": report.shape_count, + "results": report.results, + })) + .into_response() +} diff --git a/fluree-db-server/tests/validate_http_integration.rs b/fluree-db-server/tests/validate_http_integration.rs new file mode 100644 index 0000000000..23eb21c75a --- /dev/null +++ b/fluree-db-server/tests/validate_http_integration.rs @@ -0,0 +1,293 @@ +//! HTTP-layer coverage for `GET|POST /v1/fluree/validate/*ledger` — the +//! SHACL validation-report endpoint. Exercises the shapes-source selection +//! (attached / inline Turtle / inline JSON-LD / replace-vs-union), Accept +//! negotiation (JSON envelope, JSON-LD report, Turtle report), and error +//! paths (conflicting sources, unknown graph, unknown ledger). + +#![cfg(feature = "shacl")] + +use axum::body::Body; +use fluree_db_server::routes::build_router; +use fluree_db_server::{AppState, ServerConfig, TelemetryConfig}; +use http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value as JsonValue}; +use std::sync::Arc; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn server_state() -> (TempDir, Arc) { + let tmp = tempfile::tempdir().expect("tempdir"); + let cfg = ServerConfig { + cors_enabled: false, + indexing_enabled: false, + storage_path: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let telemetry = TelemetryConfig::with_server_config(&cfg); + let state = Arc::new(AppState::new(cfg, telemetry).await.expect("AppState")); + (tmp, state) +} + +async fn post_json(state: &Arc, uri: &str, body: JsonValue) -> (StatusCode, JsonValue) { + let resp = build_router(Arc::clone(state)) + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(JsonValue::Null); + (status, json) +} + +/// Seed a ledger whose committed state violates its attached shape: the +/// data (a User without schema:name) commits before the shape exists, so +/// staging-time enforcement never fires. +async fn seed_nonconforming(state: &Arc, ledger: &str) { + let (status, _) = post_json(state, "/v1/fluree/create", json!({ "ledger": ledger })).await; + assert_eq!(status, StatusCode::CREATED, "create {ledger}"); + + let (status, _) = post_json( + state, + &format!("/v1/fluree/insert/{ledger}"), + json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "@id": "ex:bob", + "@type": "ex:User", + "schema:email": "bob@example.org" + }), + ) + .await; + assert!(status.is_success(), "insert data into {ledger}: {status}"); + + let (status, _) = post_json( + state, + &format!("/v1/fluree/insert/{ledger}"), + json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#", + "schema": "http://schema.org/" + }, + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await; + assert!(status.is_success(), "insert shape into {ledger}: {status}"); +} + +const EMAIL_SHAPES_TTL: &str = r" +@prefix sh: . +@prefix ex: . +@prefix schema: . +ex:EmailShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:email ; sh:minCount 1 ] . +"; + +#[tokio::test] +async fn validate_attached_shapes_json_envelope() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-attached").await; + + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-attached", + json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false)); + assert_eq!(body["violations"], json!(1)); + assert_eq!(body["shapesChecked"], json!(1)); + let result = &body["results"][0]; + assert_eq!(result["focus_node"], json!("http://example.org/ns/bob")); + assert_eq!( + result["constraint_component"], + json!("http://www.w3.org/ns/shacl#MinCountConstraintComponent") + ); +} + +#[tokio::test] +async fn validate_get_without_body() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-get").await; + + let resp = build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/fluree/validate/it/validate-get") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body: JsonValue = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["conforms"], json!(false)); +} + +#[tokio::test] +async fn validate_inline_turtle_replaces_attached() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-inline").await; + + // Replace (default): bob conforms to the email-only inline shapes. + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-inline", + json!({ "shapes": EMAIL_SHAPES_TTL }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(true), "{body}"); + + // Union: the attached name shape fires again. + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-inline", + json!({ "shapes": EMAIL_SHAPES_TTL, "includeAttached": true }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false), "{body}"); + assert_eq!(body["violations"], json!(1)); +} + +#[tokio::test] +async fn validate_inline_jsonld_shapes() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-jsonld-shapes").await; + + let shapes = json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#", + "schema": "http://schema.org/" + }, + "@id": "ex:PhoneShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:phone-ps", + "sh:path": {"@id": "schema:telephone"}, + "sh:minCount": 1 + }] + }); + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-jsonld-shapes", + json!({ "shapes": shapes }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false)); + assert_eq!( + body["results"][0]["result_path"], + json!("http://schema.org/telephone") + ); +} + +#[tokio::test] +async fn validate_accept_negotiation() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-accept").await; + + for (accept, content_type, needle) in [ + ( + "text/turtle", + "text/turtle", + "a sh:ValidationReport".to_string(), + ), + ( + "application/ld+json", + "application/ld+json", + "\"sh:conforms\":false".to_string(), + ), + ] { + let resp = build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/validate/it/validate-accept") + .header("content-type", "application/json") + .header("accept", accept) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let got_ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + assert!( + got_ct.starts_with(content_type), + "Accept {accept}: content-type {got_ct}" + ); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let text = String::from_utf8_lossy(&bytes).replace(char::is_whitespace, ""); + let needle = needle.replace(char::is_whitespace, ""); + assert!( + text.contains(&needle), + "Accept {accept}: body missing {needle}: {text}" + ); + } +} + +#[tokio::test] +async fn validate_conflicting_shape_sources_is_bad_request() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-conflict").await; + + let (status, _) = post_json( + &state, + "/v1/fluree/validate/it/validate-conflict", + json!({ + "shapes": EMAIL_SHAPES_TTL, + "shapesGraph": "http://example.org/graphs/shapes" + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn validate_unknown_graph_is_not_found() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-nograph").await; + + let (status, _) = post_json( + &state, + "/v1/fluree/validate/it/validate-nograph", + json!({ "graph": "http://example.org/graphs/missing" }), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn validate_unknown_ledger_is_not_found() { + let (_tmp, state) = server_state().await; + let (status, _) = post_json(&state, "/v1/fluree/validate/it/no-such-ledger", json!({})).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/fluree-db-shacl/src/cache.rs b/fluree-db-shacl/src/cache.rs index 0adbcde36f..b6f4f7f71a 100644 --- a/fluree-db-shacl/src/cache.rs +++ b/fluree-db-shacl/src/cache.rs @@ -95,6 +95,9 @@ impl ShaclCache { by_target_node.entry(node.clone()).or_default().push(idx); } } + // Literal targets have no Sid to index; validate_all + // walks them straight off the shape's target list. + crate::compile::TargetType::LiteralNode(_) => {} crate::compile::TargetType::SubjectsOf(pred) => { by_target_subjects_of .entry(pred.clone()) diff --git a/fluree-db-shacl/src/compile.rs b/fluree-db-shacl/src/compile.rs index ad1fc0b224..8212626713 100644 --- a/fluree-db-shacl/src/compile.rs +++ b/fluree-db-shacl/src/compile.rs @@ -23,6 +23,9 @@ pub enum TargetType { Class(Sid), /// sh:targetNode - specific node(s) Node(Vec), + /// sh:targetNode with literal targets — the focus "node" is a literal + /// value, validated directly against the shape's value constraints. + LiteralNode(Vec), /// sh:targetSubjectsOf - subjects of triples with this predicate SubjectsOf(Sid), /// sh:targetObjectsOf - objects of triples with this predicate @@ -31,6 +34,15 @@ pub enum TargetType { ImplicitClass(Sid), } +/// A literal `sh:targetNode` target: the value plus the datatype / language +/// needed to validate and report it faithfully. +#[derive(Debug, Clone, PartialEq)] +pub struct LiteralTarget { + pub value: FlakeValue, + pub datatype: Sid, + pub lang: Option, +} + /// Severity level for constraint violations #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Severity { @@ -281,6 +293,27 @@ impl ShapeCompiler { } } + let rdf_type = Sid::new(RDF, rdf_names::TYPE); + + // Register subjects explicitly typed sh:NodeShape. A shape whose + // only markers are `rdf:type sh:NodeShape` plus value constraints + // (e.g. an implicit-class-target shape carrying just sh:in) would + // otherwise never be created — no target/property predicate ever + // calls get_or_create_shape for it. + let node_shape_type = Sid::new(SHACL, "NodeShape"); + let flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object( + rdf_type.clone(), + FlakeValue::Ref(node_shape_type), + ), + ) + .await?; + for flake in &flakes { + compiler.get_or_create_shape(&flake.s); + } // Expand rdf:first/rdf:rest lists referenced by sh:in / sh:and / // sh:or / sh:xone / sh:ignoredProperties. Run after each graph so // that lists whose head lives in this graph can resolve — a list @@ -562,6 +595,25 @@ impl ShapeCompiler { if !found { shape.targets.push(TargetType::Node(vec![node.clone()])); } + } else { + // Literal target node: the focus is the literal itself. + let lit = LiteralTarget { + value: flake.o.clone(), + datatype: flake.dt.clone(), + lang: flake.m.as_ref().and_then(|m| m.lang.clone()), + }; + let shape = self.get_or_create_shape(&flake.s); + let mut found = false; + for target in &mut shape.targets { + if let TargetType::LiteralNode(lits) = target { + lits.push(lit.clone()); + found = true; + break; + } + } + if !found { + shape.targets.push(TargetType::LiteralNode(vec![lit])); + } } } name if name == predicates::TARGET_SUBJECTS_OF => { @@ -867,7 +919,18 @@ impl ShapeCompiler { for (id, data) in &shapes { // Resolve property shapes let mut prop_shapes = Vec::new(); - for ps_id in &data.property_shape_ids { + // A property shape can carry its own targets (`ex:S a + // sh:PropertyShape ; sh:path ... ; sh:targetNode ...`) with no + // wrapping node shape — the shape then validates its own focus + // nodes: attach its own path-bearing entry alongside any + // sh:property references. + let mut ps_ids: Vec<&Sid> = data.property_shape_ids.iter().collect(); + if ps_map.get(id).is_some_and(|own| own.path.is_some()) + && !data.property_shape_ids.contains(id) + { + ps_ids.push(id); + } + for ps_id in ps_ids { if let Some(ps_data) = ps_map.get(ps_id) { if ps_data.deactivated { continue; diff --git a/fluree-db-shacl/src/constraints/cardinality.rs b/fluree-db-shacl/src/constraints/cardinality.rs index 23822fb5f6..7b4d7afb8d 100644 --- a/fluree-db-shacl/src/constraints/cardinality.rs +++ b/fluree-db-shacl/src/constraints/cardinality.rs @@ -9,6 +9,7 @@ pub fn validate_min_count(values: &[FlakeValue], min: usize) -> Option Option &'static str { + use fluree_vocab::shacl as sh; + match self { + Constraint::MinCount(_) => sh::MIN_COUNT_CONSTRAINT_COMPONENT, + Constraint::MaxCount(_) => sh::MAX_COUNT_CONSTRAINT_COMPONENT, + Constraint::Datatype(_) => sh::DATATYPE_CONSTRAINT_COMPONENT, + Constraint::NodeKind(_) => sh::NODE_KIND_CONSTRAINT_COMPONENT, + Constraint::Class(_) => sh::CLASS_CONSTRAINT_COMPONENT, + Constraint::MinInclusive(_) => sh::MIN_INCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MaxInclusive(_) => sh::MAX_INCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MinExclusive(_) => sh::MIN_EXCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MaxExclusive(_) => sh::MAX_EXCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::Pattern(..) => sh::PATTERN_CONSTRAINT_COMPONENT, + Constraint::MinLength(_) => sh::MIN_LENGTH_CONSTRAINT_COMPONENT, + Constraint::MaxLength(_) => sh::MAX_LENGTH_CONSTRAINT_COMPONENT, + Constraint::HasValue(_) => sh::HAS_VALUE_CONSTRAINT_COMPONENT, + Constraint::In(_) => sh::IN_CONSTRAINT_COMPONENT, + Constraint::Equals(_) => sh::EQUALS_CONSTRAINT_COMPONENT, + Constraint::Disjoint(_) => sh::DISJOINT_CONSTRAINT_COMPONENT, + Constraint::LessThan(_) => sh::LESS_THAN_CONSTRAINT_COMPONENT, + Constraint::LessThanOrEquals(_) => sh::LESS_THAN_OR_EQUALS_CONSTRAINT_COMPONENT, + Constraint::UniqueLang(_) => sh::UNIQUE_LANG_CONSTRAINT_COMPONENT, + Constraint::LanguageIn(_) => sh::LANGUAGE_IN_CONSTRAINT_COMPONENT, + Constraint::QualifiedValueShape { min_count, .. } => { + if min_count.is_some() { + sh::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT + } else { + sh::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT + } + } + } + } } impl NodeConstraint { @@ -201,6 +240,57 @@ pub struct ConstraintViolation { pub constraint: Constraint, /// The value that violated the constraint (if applicable) pub value: Option, + /// Index of `value` within the value set being checked, when the + /// violation concerns exactly one value. Lets result construction + /// recover the value's datatype / language tag from the parallel + /// `datatypes` / `langs` arrays (term fidelity for `sh:value`). + pub value_index: Option, /// Human-readable message about the violation pub message: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn component_iris_match_w3c_names() { + assert_eq!( + Constraint::MinCount(1).component(), + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); + assert_eq!( + Constraint::Pattern("a".into(), None).component(), + "http://www.w3.org/ns/shacl#PatternConstraintComponent" + ); + assert_eq!( + Constraint::LessThanOrEquals(Sid::new(0, "p")).component(), + "http://www.w3.org/ns/shacl#LessThanOrEqualsConstraintComponent" + ); + } + + #[test] + fn qualified_component_tracks_declared_bound() { + let qualified = |min_count, max_count| Constraint::QualifiedValueShape { + shape: Arc::new(NestedShape { + id: Sid::new(0, "q"), + property_constraints: Vec::new(), + node_constraints: Vec::new(), + value_constraints: Vec::new(), + message: None, + }), + min_count, + max_count, + disjoint: false, + sibling_shapes: Vec::new(), + }; + assert_eq!( + qualified(Some(1), None).component(), + "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent" + ); + assert_eq!( + qualified(None, Some(2)).component(), + "http://www.w3.org/ns/shacl#QualifiedMaxCountConstraintComponent" + ); + } +} diff --git a/fluree-db-shacl/src/constraints/pair.rs b/fluree-db-shacl/src/constraints/pair.rs index 3ed2f8a3f5..316d969a0f 100644 --- a/fluree-db-shacl/src/constraints/pair.rs +++ b/fluree-db-shacl/src/constraints/pair.rs @@ -42,6 +42,7 @@ pub fn validate_equals( name: other_path.into(), }), value: None, + value_index: None, message: format!( "Value set does not equal value set for {}: {}", other_path, @@ -74,6 +75,7 @@ pub fn validate_disjoint( name: other_path.into(), }), value: intersection.first().map(|v| (**v).clone()), + value_index: None, message: format!( "Values must be disjoint from {other_path}, but found common values: {intersection:?}" ), @@ -101,6 +103,7 @@ pub fn validate_less_than( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Value {value:?} is not less than {other:?} from {other_path}" ), @@ -114,6 +117,7 @@ pub fn validate_less_than( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Cannot compare value {value:?} with {other:?} from {other_path} (incompatible types)" ), @@ -142,6 +146,7 @@ pub fn validate_less_than_or_equals( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Value {value:?} is not less than or equal to {other:?} from {other_path}" ), @@ -155,6 +160,7 @@ pub fn validate_less_than_or_equals( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Cannot compare value {value:?} with {other:?} from {other_path} (incompatible types)" ), diff --git a/fluree-db-shacl/src/constraints/pattern.rs b/fluree-db-shacl/src/constraints/pattern.rs index a8da14dd1b..ec0a7844af 100644 --- a/fluree-db-shacl/src/constraints/pattern.rs +++ b/fluree-db-shacl/src/constraints/pattern.rs @@ -49,6 +49,7 @@ pub fn validate_pattern( return Ok(Some(ConstraintViolation { constraint: Constraint::Pattern(pattern.to_string(), flags.map(String::from)), value: Some(value.clone()), + value_index: None, message: "Pattern constraint cannot be applied to a non-literal value".to_string(), })); }; @@ -83,6 +84,7 @@ pub fn validate_pattern( Ok(Some(ConstraintViolation { constraint: Constraint::Pattern(pattern.to_string(), flags.map(String::from)), value: Some(value.clone()), + value_index: None, message: format!("Value '{string_value}' does not match pattern '{pattern}'"), })) } @@ -97,6 +99,7 @@ pub fn validate_min_length(value: &FlakeValue, min: usize) -> Option Option Option Option Option Option Some(ConstraintViolation { constraint: Constraint::MinInclusive(min.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} is less than minimum {min:?}"), }), } @@ -68,6 +71,7 @@ pub fn validate_max_inclusive(value: &FlakeValue, max: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MaxInclusive(max.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} exceeds maximum {max:?}"), }), } @@ -80,6 +84,7 @@ pub fn validate_min_exclusive(value: &FlakeValue, min: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MinExclusive(min.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} must be greater than {min:?}"), }), } @@ -92,6 +97,7 @@ pub fn validate_max_exclusive(value: &FlakeValue, max: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MaxExclusive(max.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} must be less than {max:?}"), }), } diff --git a/fluree-db-shacl/src/lib.rs b/fluree-db-shacl/src/lib.rs index 09489b0960..f0519bcfb2 100644 --- a/fluree-db-shacl/src/lib.rs +++ b/fluree-db-shacl/src/lib.rs @@ -77,8 +77,6 @@ //! //! # Not Yet Supported //! -//! - `sh:targetNode` with a literal value — only IRI/blank-node targets are -//! compiled (focus nodes are subject ids throughout the engine). //! - `sh:sparql` (SPARQL-based constraints). //! //! # Example @@ -107,11 +105,13 @@ pub mod path; pub mod validate; pub use cache::{ShaclCache, ShaclCacheKey}; -pub use compile::{CompiledShape, PropertyShape, Severity, ShapeId, TargetType}; +pub use compile::{CompiledShape, LiteralTarget, PropertyShape, Severity, ShapeId, TargetType}; pub use constraints::Constraint; pub use error::{Result, ShaclError}; pub use path::PropertyPath; -pub use validate::{CrossLedgerMembership, ShaclEngine, ValidationReport, ValidationResult}; +pub use validate::{ + CrossLedgerMembership, FocusNode, ShaclEngine, ValidationReport, ValidationResult, +}; /// SHACL namespace code (re-exported from fluree-vocab) pub use fluree_vocab::namespaces::SHACL; diff --git a/fluree-db-shacl/src/path.rs b/fluree-db-shacl/src/path.rs index 73a9ad23ba..5b1704a1e4 100644 --- a/fluree-db-shacl/src/path.rs +++ b/fluree-db-shacl/src/path.rs @@ -203,6 +203,20 @@ fn resolve_path_node<'a>(db: GraphDbRef<'a>, node: &'a Sid) -> PathFuture<'a, Pr return Ok(PropertyPath::Predicate(node.clone())); } + // Bare RDF list → sequence path. Checked before the operator keys: + // an (ill-formed) node carrying both a list structure and an + // operator reads as the sequence, matching the W3C suite's + // path-strange-001/002 expectations. + let rdf_first = Sid::new(RDF, rdf_names::FIRST); + if has_object(db, node, &rdf_first).await? { + let members = resolve_rdf_list(db, node).await?; + match members.len() { + 0 => return Err(unsupported(node, "sh:path sequence list is empty")), + 1 => return Ok(members.into_iter().next().unwrap()), + _ => return Ok(PropertyPath::Sequence(members)), + } + } + // sh:inversePath — inverse of any path, rewritten into the AST // (inverse of a sequence = reversed sequence of inverses, etc.). if let Some(inner) = operand_path(db, node, &shacl(predicates::INVERSE_PATH)).await? { @@ -232,17 +246,6 @@ fn resolve_path_node<'a>(db: GraphDbRef<'a>, node: &'a Sid) -> PathFuture<'a, Pr } } - // Bare RDF list → sequence path. - let rdf_first = Sid::new(RDF, rdf_names::FIRST); - if has_object(db, node, &rdf_first).await? { - let members = resolve_rdf_list(db, node).await?; - match members.len() { - 0 => return Err(unsupported(node, "sh:path sequence list is empty")), - 1 => return Ok(members.into_iter().next().unwrap()), - _ => return Ok(PropertyPath::Sequence(members)), - } - } - // No path-expression structure → a plain predicate IRI. Ok(PropertyPath::Predicate(node.clone())) }) diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index c2b10e3e78..e6b27d5f0f 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -21,6 +21,7 @@ use fluree_db_core::{ }; use fluree_vocab::namespaces::{BLANK_NODE, RDF}; use fluree_vocab::rdf_names; +use fluree_vocab::shacl as sh_vocab; use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; @@ -59,6 +60,12 @@ type ActiveShapeChecks = Mutex>; pub struct CrossLedgerMembership<'a> { /// `GraphDbRef` into M's value-set graph at the resolved `t`. pub model_db: GraphDbRef<'a>, + /// When true, `model_db` shares the data ledger's term space (e.g. an + /// inline-shapes bundle encoded against the data ledger's namespace + /// registry): membership probes use the data-side Sids directly instead + /// of the decode-to-IRI / re-encode-against-M translation — which would + /// always miss against a bundle backed by an empty genesis snapshot. + pub same_term_space: bool, /// D's namespace codes → IRI prefixes (base + this transaction's staged /// allocations). Used to decode a D-term Sid to its full IRI before /// re-encoding it against M (whose split mode may differ), because the @@ -300,7 +307,9 @@ impl ShaclEngine { results.extend(shape_results); } - let conforms = results.iter().all(|r| r.severity != Severity::Violation); + // Spec: sh:conforms is true iff there are NO validation results + // (warnings and infos included), not merely no violations. + let conforms = results.is_empty(); Ok(ValidationReport { conforms, results }) } @@ -319,6 +328,17 @@ impl ShaclEngine { /// Validate all focus nodes targeted by shapes pub async fn validate_all(&self, db: GraphDbRef<'_>) -> Result { + self.validate_all_with_membership(db, None).await + } + + /// [`Self::validate_all`] with an optional external `sh:class` + /// value-membership source (a model ledger or a same-term-space + /// inline-shapes bundle), consulted when the local lookup misses. + pub async fn validate_all_with_membership( + &self, + db: GraphDbRef<'_>, + cross_ledger: Option>, + ) -> Result { let mut all_results = Vec::new(); // Collect all shapes for logical constraint resolution @@ -327,17 +347,21 @@ impl ShaclEngine { let class_ctx = ClassMembershipCtx { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, - // Full-db validation (`validate_all`) has no cross-ledger model - // context; `sh:class` uses the local lookup only. - cross_ledger: None, + cross_ledger, }; + // Class-target focus nodes are constant across the shape loop (same + // `db`, same hierarchy), so memoize them per class: several shapes + // sharing a target class then pay the subclass BFS + instance scans + // once instead of once each. + let mut class_focus_memo: HashMap> = HashMap::new(); for shape in self.cache.all_shapes() { if shape.deactivated { continue; } // Get focus nodes for this shape (with hierarchy expansion) - let focus_nodes = get_focus_nodes(db, shape, self.hierarchy.as_ref()).await?; + let focus_nodes = + get_focus_nodes(db, shape, self.hierarchy.as_ref(), &mut class_focus_memo).await?; for focus_node in focus_nodes { let active = ActiveShapeChecks::default(); @@ -352,11 +376,56 @@ impl ShaclEngine { .await?; all_results.extend(results); } + + // Literal focus nodes: explicit literal sh:targetNode targets, plus + // literal objects reached via sh:targetObjectsOf (a target + // predicate's objects may be literals). De-duplicated by value — + // the focus set is a set of nodes. + let mut literal_targets: Vec = Vec::new(); + for target in &shape.targets { + match target { + crate::compile::TargetType::LiteralNode(lits) => { + for lit in lits { + if !literal_targets.contains(lit) { + literal_targets.push(lit.clone()); + } + } + } + crate::compile::TargetType::ObjectsOf(predicate) => { + let flakes = db + .range( + IndexType::Psot, + RangeTest::Eq, + RangeMatch::predicate(predicate.clone()), + ) + .await?; + for flake in &flakes { + if matches!(flake.o, FlakeValue::Ref(_)) { + continue; + } + let lit = crate::compile::LiteralTarget { + value: flake.o.clone(), + datatype: flake.dt.clone(), + lang: flake.m.as_ref().and_then(|m| m.lang.clone()), + }; + if !literal_targets.contains(&lit) { + literal_targets.push(lit); + } + } + } + _ => {} + } + } + for lit in &literal_targets { + let active = ActiveShapeChecks::default(); + let results = + validate_literal_focus(db, lit, shape, &all_shapes, Some(class_ctx), &active) + .await?; + all_results.extend(results); + } } - let conforms = all_results - .iter() - .all(|r| r.severity != Severity::Violation); + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, @@ -466,9 +535,7 @@ impl ShaclEngine { all_results.extend(report.results); } - let conforms = all_results - .iter() - .all(|r| r.severity != Severity::Violation); + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, @@ -487,7 +554,9 @@ impl ShaclEngine { ) -> Result<()> { let report = self.validate_staged(db, modified_subjects).await?; - if report.conforms { + // Enforcement rejects on violations only — spec-level `conforms` + // is also false for warnings/infos, which must not block a commit. + if report.violation_count() == 0 { Ok(()) } else { // Build detailed error messages (limit to first 10 to avoid huge errors) @@ -500,10 +569,10 @@ impl ShaclEngine { if let Some(ref path) = r.result_path { format!( "Node {}: property {}: {}", - r.focus_node.name, path.name, r.message + r.focus_node, path.name, r.message ) } else { - format!("Node {}: {}", r.focus_node.name, r.message) + format!("Node {}: {}", r.focus_node, r.message) } }) .collect(); @@ -526,20 +595,52 @@ async fn get_focus_nodes( db: GraphDbRef<'_>, shape: &CompiledShape, hierarchy: Option<&SchemaHierarchy>, + class_focus_memo: &mut HashMap>, ) -> Result> { let mut focus_nodes = Vec::new(); for target in &shape.targets { match target { TargetType::Class(class) | TargetType::ImplicitClass(class) => { - // Build list of classes to query: target class + all subclasses + if let Some(cached) = class_focus_memo.get(class) { + focus_nodes.extend(cached.iter().cloned()); + continue; + } + + // Build list of classes to query: target class + all subclasses. + // The indexed hierarchy misses novelty-added subclass relations, + // so a live rdfs:subClassOf descendant walk unions them in + // (mirrors the live walk sh:class membership already does). let mut classes_to_query = vec![class.clone()]; if let Some(h) = hierarchy { classes_to_query.extend(h.subclasses_of(class).iter().cloned()); } + let sub_class_of = Sid::new(fluree_vocab::namespaces::RDFS, "subClassOf"); + let mut queue: std::collections::VecDeque = + classes_to_query.iter().cloned().collect(); + let mut visited: HashSet = classes_to_query.iter().cloned().collect(); + while let Some(cls) = queue.pop_front() { + let sub_flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object( + sub_class_of.clone(), + FlakeValue::Ref(cls), + ), + ) + .await?; + for flake in &sub_flakes { + if visited.insert(flake.s.clone()) { + classes_to_query.push(flake.s.clone()); + queue.push_back(flake.s.clone()); + } + } + } // Find all instances of each class let rdf_type = Sid::new(RDF, rdf_names::TYPE); + let mut class_focus = Vec::new(); for cls in classes_to_query { let flakes = db .range( @@ -550,13 +651,18 @@ async fn get_focus_nodes( .await?; for flake in flakes { - focus_nodes.push(flake.s.clone()); + class_focus.push(flake.s.clone()); } } + focus_nodes.extend(class_focus.iter().cloned()); + class_focus_memo.insert(class.clone(), class_focus); } TargetType::Node(nodes) => { focus_nodes.extend(nodes.iter().cloned()); } + // Literal targets are not graph nodes — validated directly via + // `validate_literal_focus` in `validate_all_with_membership`. + TargetType::LiteralNode(_) => {} TargetType::SubjectsOf(predicate) => { // Find all subjects that have this predicate let flakes = db @@ -657,6 +763,180 @@ fn validate_shape<'a>( }) } +/// Validate a literal `sh:targetNode` target against a shape. +/// +/// A literal has no graph presence: value constraints evaluate against the +/// literal directly (the value node IS the focus), structural constraints +/// test the literal's conformance to the nested shapes, and property shapes +/// see an empty value set (so only `sh:minCount` / `sh:qualifiedMinCount` +/// can fire). +async fn validate_literal_focus<'a>( + db: GraphDbRef<'a>, + lit: &crate::compile::LiteralTarget, + shape: &'a CompiledShape, + all_shapes: &'a [&'a CompiledShape], + class_ctx: Option>, + active: &'a ActiveShapeChecks, +) -> Result> { + let mut results = Vec::new(); + let focus = FocusNode::Literal(lit.clone()); + let values = [lit.value.clone()]; + let datatypes = [lit.datatype.clone()]; + let langs = [lit.lang.clone()]; + + let mut push = |component: &'static str, message: String| { + results.push(ValidationResult { + focus_node: focus.clone(), + result_path: None, + source_shape: shape.id.clone(), + source_constraint: None, + constraint_component: component, + severity: shape.severity, + message: shape.message.clone().unwrap_or(message), + value: Some(lit.value.clone()), + value_datatype: Some(lit.datatype.clone()), + value_lang: lit.lang.clone(), + graph_id: None, + }); + }; + + // Direct value constraints: the value node is the literal itself. + for constraint in &shape.node_constraints { + let violations = match constraint { + Constraint::Class(expected_class) => { + validate_class_constraint(db, &values, expected_class, class_ctx).await? + } + _ => validate_constraint(constraint, &values, &datatypes, &langs)?, + }; + for violation in violations { + push(violation.constraint.component(), violation.message); + } + } + + // Structural constraints: test the literal against the nested shapes. + for constraint in &shape.structural_constraints { + let conforms_to = |nested: &'a std::sync::Arc| { + check_value_against_nested_shape( + db, + &lit.value, + Some(&lit.datatype), + lit.lang.as_deref(), + nested, + shape, + all_shapes, + class_ctx, + active, + ) + }; + match constraint { + // A literal has no properties to close over. + NodeConstraint::Closed { .. } => {} + NodeConstraint::Node(nested) => { + if !conforms_to(nested).await? { + push( + sh_vocab::NODE_CONSTRAINT_COMPONENT, + format!( + "Node does not conform to shape {} (sh:node)", + nested.id.name + ), + ); + } + } + NodeConstraint::Not(nested) => { + if conforms_to(nested).await? { + push( + sh_vocab::NOT_CONSTRAINT_COMPONENT, + format!( + "Node conforms to shape {} which is not allowed (sh:not)", + nested.id.name + ), + ); + } + } + NodeConstraint::And(nested_shapes) => { + let mut all_conform = true; + for nested in nested_shapes { + if !conforms_to(nested).await? { + all_conform = false; + } + } + if !all_conform { + push( + sh_vocab::AND_CONSTRAINT_COMPONENT, + "Node does not conform to all shapes in sh:and".to_string(), + ); + } + } + NodeConstraint::Or(nested_shapes) => { + let mut any_conforms = nested_shapes.is_empty(); + for nested in nested_shapes { + if conforms_to(nested).await? { + any_conforms = true; + break; + } + } + if !any_conforms { + push( + sh_vocab::OR_CONSTRAINT_COMPONENT, + "Node does not conform to any shape in sh:or".to_string(), + ); + } + } + NodeConstraint::Xone(nested_shapes) => { + let mut conforming = 0usize; + for nested in nested_shapes { + if conforms_to(nested).await? { + conforming += 1; + } + } + if conforming != 1 { + push( + sh_vocab::XONE_CONSTRAINT_COMPONENT, + format!( + "Node conforms to {conforming} shapes in sh:xone (must be exactly 1)" + ), + ); + } + } + } + } + + // Property shapes: any path over a literal yields no values, so only the + // minimum-count constraints can fire. + for prop_shape in &shape.property_shapes { + for constraint in &prop_shape.constraints { + let fired = match constraint { + Constraint::MinCount(min) => *min > 0, + Constraint::QualifiedValueShape { + min_count: Some(min), + .. + } => *min > 0, + _ => continue, + }; + if fired { + results.push(ValidationResult { + focus_node: focus.clone(), + result_path: prop_shape.path.as_predicate().cloned(), + source_shape: shape.id.clone(), + source_constraint: Some(prop_shape.id.clone()), + constraint_component: constraint.component(), + severity: prop_shape.severity, + message: prop_shape + .message + .clone() + .unwrap_or_else(|| "Expected at least 1 value(s) but found 0".to_string()), + value: None, + value_datatype: None, + value_lang: None, + graph_id: None, + }); + } + } + } + + Ok(results) +} + /// Validate value constraints declared directly on a node shape (no `sh:path`) /// against the focus node itself. Per spec, a node shape's value-node set is /// exactly the focus node, so per-value constraints (`sh:in`, `sh:hasValue`, @@ -672,13 +952,18 @@ async fn validate_node_value_constraints<'a>( Ok(violations .into_iter() .map(|violation| ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: shape.id.clone(), source_constraint: None, + constraint_component: violation.constraint.component(), severity: shape.severity, message: shape.message.clone().unwrap_or(violation.message), - value: violation.value, + value: violation + .value + .or_else(|| Some(FlakeValue::Ref(focus_node.clone()))), + value_datatype: None, + value_lang: None, graph_id: None, }) .collect()) @@ -726,13 +1011,17 @@ async fn focus_value_violations<'a>( ); } Constraint::Pattern(..) | Constraint::MinLength(_) | Constraint::MaxLength(_) => { + // String facets evaluate STR(focus), but the reported value + // node is the focus itself — not its stringified IRI. let effective = stringify_iri_values(db, &values); - violations.extend(validate_constraint( - constraint, - &effective, - &datatypes, - &[None], - )?); + violations.extend( + validate_constraint(constraint, &effective, &datatypes, &[None])? + .into_iter() + .map(|mut v| { + v.value = Some(FlakeValue::Ref(focus_node.clone())); + v + }), + ); } _ => { violations.extend(validate_constraint( @@ -791,26 +1080,27 @@ fn validate_structural_constraint<'a>( .filter_map(|ps| ps.path.as_predicate()) .collect(); - // Per SHACL spec section 4.8.1, rdf:type is implicitly ignored - let rdf_type_sid = Sid::new(RDF, rdf_names::TYPE); - let mut effective_ignored = ignored_properties.clone(); - effective_ignored.insert(rdf_type_sid); - + // Per spec, rdf:type is NOT implicitly ignored — shapes + // must declare `sh:ignoredProperties (rdf:type)` (W3C + // core/node/closed-001 pins this). // Check each property on the node for flake in node_flakes { let prop = &flake.p; - if !declared_properties.contains(prop) && !effective_ignored.contains(prop) + if !declared_properties.contains(prop) && !ignored_properties.contains(prop) { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: Some(prop.clone()), source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::CLOSED_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!("Property {} not allowed by closed shape", prop.name) }), value: Some(flake.o.clone()), + value_datatype: Some(flake.dt.clone()), + value_lang: flake.m.as_ref().and_then(|m| m.lang.clone()), graph_id: None, }); } @@ -835,10 +1125,11 @@ fn validate_structural_constraint<'a>( .any(|r| r.severity == Severity::Violation); if has_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -846,7 +1137,9 @@ fn validate_structural_constraint<'a>( nested_shape.id.name ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -872,10 +1165,11 @@ fn validate_structural_constraint<'a>( .any(|r| r.severity == Severity::Violation); if !has_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::NOT_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -883,14 +1177,20 @@ fn validate_structural_constraint<'a>( nested_shape.id.name ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, graph_id: None, }); } } NodeConstraint::And(nested_shapes) => { - // sh:and - ALL nested shapes must match (no violations) + // sh:and - ALL nested shapes must match (no violations). + // Per spec, a failed conjunction produces ONE result per value + // node (= the focus node) with sh:value = focus; the nested + // violations' messages are aggregated for diagnostics. + let mut failure_messages = Vec::new(); for nested in nested_shapes { let nested_results = validate_nested_shape( db, @@ -902,24 +1202,29 @@ fn validate_structural_constraint<'a>( active, ) .await?; - // Include violations from the nested shape for r in nested_results { if r.severity == Severity::Violation { - results.push(ValidationResult { - focus_node: focus_node.clone(), - result_path: r.result_path, - source_shape: parent_shape.id.clone(), - source_constraint: None, - severity: parent_shape.severity, - message: parent_shape.message.clone().unwrap_or_else(|| { - format!("sh:and constraint - {}", r.message) - }), - value: r.value, - graph_id: None, - }); + failure_messages.push(r.message); } } } + if !failure_messages.is_empty() { + results.push(ValidationResult { + focus_node: FocusNode::Node(focus_node.clone()), + result_path: None, + source_shape: parent_shape.id.clone(), + source_constraint: None, + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, + severity: parent_shape.severity, + message: parent_shape.message.clone().unwrap_or_else(|| { + format!("sh:and constraint - {}", failure_messages.join("; ")) + }), + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, + graph_id: None, + }); + } } NodeConstraint::Or(nested_shapes) => { @@ -955,10 +1260,11 @@ fn validate_structural_constraint<'a>( if !any_conforms && !nested_shapes.is_empty() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::OR_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -966,7 +1272,9 @@ fn validate_structural_constraint<'a>( all_messages.join("; ") ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -999,23 +1307,27 @@ fn validate_structural_constraint<'a>( if conforming_count == 0 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { "Node does not conform to any shape in sh:xone".to_string() }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, graph_id: None, }); } else if conforming_count > 1 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -1024,7 +1336,9 @@ fn validate_structural_constraint<'a>( conforming_shapes.join(", ") ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1063,13 +1377,16 @@ fn validate_nested_shape<'a>( // Shape not found and no inline constraints — treat as unresolved. // Return a violation to prevent sh:or from being trivially true. return Ok(vec![ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: format!("Referenced shape {} could not be resolved", nested.id.name), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }]); } @@ -1086,13 +1403,16 @@ fn validate_nested_shape<'a>( .await?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1103,13 +1423,17 @@ fn validate_nested_shape<'a>( // A path that never compiled surfaces as a violation on this member. if let Some(reason) = path.unresolvable_reason() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: + fluree_vocab::fluree::UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: format!("Unsupported sh:path expression: {reason}"), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); continue; @@ -1168,10 +1492,11 @@ fn validate_nested_shape<'a>( if source_values != target_values { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::EQUALS_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: nested.message.clone().unwrap_or_else(|| { format!( @@ -1180,6 +1505,8 @@ fn validate_nested_shape<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1190,13 +1517,22 @@ fn validate_nested_shape<'a>( .await?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::CLASS_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1250,10 +1586,11 @@ fn validate_nested_shape<'a>( let above = max_count.map(|max| conforming > max).unwrap_or(false); if below || above { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: if below { sh_vocab::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT } else { sh_vocab::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT }, severity: Severity::Violation, message: nested.message.clone().unwrap_or_else(|| { format!( @@ -1265,6 +1602,8 @@ fn validate_nested_shape<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1279,13 +1618,22 @@ fn validate_nested_shape<'a>( validate_constraint(constraint, &effective, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1295,13 +1643,22 @@ fn validate_nested_shape<'a>( validate_constraint(constraint, &values, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1346,13 +1703,16 @@ async fn validate_property_shape<'a>( // actually targets) rather than as a ledger-wide compile failure. if let Some(reason) = prop_shape.path.unresolvable_reason() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: fluree_vocab::fluree::UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: format!("Unsupported sh:path expression: {reason}"), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); return Ok(results); @@ -1412,13 +1772,22 @@ async fn validate_property_shape<'a>( ); for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1428,13 +1797,22 @@ async fn validate_property_shape<'a>( validate_class_constraint(db, &values, expected_class, class_ctx).await?; for violation in class_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::CLASS_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1487,32 +1865,41 @@ async fn validate_property_shape<'a>( } } - let mut qualified_messages: Vec = Vec::new(); + let mut qualified_messages: Vec<(String, &'static str)> = Vec::new(); if let Some(min) = min_count { if conforming < *min { - qualified_messages.push(format!( - "Expected at least {} value(s) conforming to shape {} but found {}", - min, shape.id.name, conforming + qualified_messages.push(( + format!( + "Expected at least {} value(s) conforming to shape {} but found {}", + min, shape.id.name, conforming + ), + sh_vocab::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT, )); } } if let Some(max) = max_count { if conforming > *max { - qualified_messages.push(format!( - "Expected at most {} value(s) conforming to shape {} but found {}", - max, shape.id.name, conforming + qualified_messages.push(( + format!( + "Expected at most {} value(s) conforming to shape {} but found {}", + max, shape.id.name, conforming + ), + sh_vocab::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT, )); } } - for message in qualified_messages { + for (message, component) in qualified_messages { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: component, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(message), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1525,13 +1912,22 @@ async fn validate_property_shape<'a>( let violations = validate_constraint(constraint, &effective, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: violation.constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1542,13 +1938,22 @@ async fn validate_property_shape<'a>( for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: violation.constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1630,10 +2035,11 @@ async fn validate_property_value_structural_constraint<'a>( if !any_conforms && !nested_shapes.is_empty() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::OR_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1643,6 +2049,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1650,9 +2058,12 @@ async fn validate_property_value_structural_constraint<'a>( } NodeConstraint::And(nested_shapes) => { - // For each value, ALL nested shapes must accept it + // For each value, ALL nested shapes must accept it. Per spec a + // failed conjunction produces ONE result per value node, however + // many members rejected it. for (i, value) in values.iter().enumerate() { let dt = datatypes.get(i); + let mut failed_members = Vec::new(); for nested in nested_shapes { let conforms = check_value_against_nested_shape( db, @@ -1667,23 +2078,30 @@ async fn validate_property_value_structural_constraint<'a>( ) .await?; if !conforms { - results.push(ValidationResult { - focus_node: focus_node.clone(), - result_path: prop_shape.path.as_predicate().cloned(), - source_shape: parent_shape.id.clone(), - source_constraint: Some(prop_shape.id.clone()), - severity: prop_shape.severity, - message: prop_shape.message.clone().unwrap_or_else(|| { - format!( - "Value {:?} does not conform to shape {} (sh:and)", - value, nested.id.name - ) - }), - value: Some(value.clone()), - graph_id: None, - }); + failed_members.push(nested.id.name.to_string()); } } + if !failed_members.is_empty() { + results.push(ValidationResult { + focus_node: FocusNode::Node(focus_node.clone()), + result_path: prop_shape.path.as_predicate().cloned(), + source_shape: parent_shape.id.clone(), + source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, + severity: prop_shape.severity, + message: prop_shape.message.clone().unwrap_or_else(|| { + format!( + "Value {:?} does not conform to shape(s) {} (sh:and)", + value, + failed_members.join(", ") + ) + }), + value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), + graph_id: None, + }); + } } } @@ -1713,23 +2131,27 @@ async fn validate_property_value_structural_constraint<'a>( if conforming_count == 0 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!("Value {value:?} does not conform to any shape in sh:xone") }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } else if conforming_count > 1 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1737,6 +2159,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1761,10 +2185,11 @@ async fn validate_property_value_structural_constraint<'a>( .await?; if !conforms { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1773,6 +2198,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1797,10 +2224,11 @@ async fn validate_property_value_structural_constraint<'a>( .await?; if conforms { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::NOT_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1809,6 +2237,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1961,15 +2391,17 @@ fn validate_constraint( Constraint::Datatype(expected_dt) => { for (i, value) in values.iter().enumerate() { if let Some(actual_dt) = datatypes.get(i) { - if let Some(v) = validate_datatype(value, actual_dt, expected_dt) { + if let Some(mut v) = validate_datatype(value, actual_dt, expected_dt) { + v.value_index = Some(i); violations.push(v); } } } } Constraint::NodeKind(kind) => { - for value in values { - if let Some(v) = validate_node_kind(value, *kind) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_node_kind(value, *kind) { + v.value_index = Some(i); violations.push(v); } } @@ -1980,57 +2412,65 @@ fn validate_constraint( // pure-values path without a snapshot). } Constraint::MinInclusive(min) => { - for value in values { - if let Some(v) = validate_min_inclusive(value, min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_inclusive(value, min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxInclusive(max) => { - for value in values { - if let Some(v) = validate_max_inclusive(value, max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_inclusive(value, max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MinExclusive(min) => { - for value in values { - if let Some(v) = validate_min_exclusive(value, min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_exclusive(value, min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxExclusive(max) => { - for value in values { - if let Some(v) = validate_max_exclusive(value, max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_exclusive(value, max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::Pattern(pattern, flags) => { - for value in values { - if let Some(v) = validate_pattern(value, pattern, flags.as_deref())? { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_pattern(value, pattern, flags.as_deref())? { + v.value_index = Some(i); violations.push(v); } } } Constraint::MinLength(min) => { - for value in values { - if let Some(v) = validate_min_length(value, *min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_length(value, *min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxLength(max) => { - for value in values { - if let Some(v) = validate_max_length(value, *max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_length(value, *max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::In(allowed) => { - for value in values { - if let Some(v) = validate_in(value, allowed) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_in(value, allowed) { + v.value_index = Some(i); violations.push(v); } } @@ -2053,7 +2493,8 @@ fn validate_constraint( Constraint::LanguageIn(allowed) => { for (i, value) in values.iter().enumerate() { let lang = langs.get(i).and_then(|l| l.as_deref()); - if let Some(v) = validate_language_in(value, lang, allowed) { + if let Some(mut v) = validate_language_in(value, lang, allowed) { + v.value_index = Some(i); violations.push(v); } } @@ -2115,6 +2556,18 @@ fn validate_pair_constraint( // Caller is responsible for only passing pair-constraint variants. _ => {} } + // Backfill value indices by position so result construction can recover + // the datatype / language of the offending source value. Equality lookup + // is exact for the per-value helpers (the violation value IS values[i]). + for v in &mut out { + if v.value_index.is_none() { + v.value_index = v + .value + .as_ref() + .and_then(|val| values.iter().position(|x| x == val)); + } + } + out } @@ -2164,13 +2617,14 @@ async fn validate_class_constraint( } } - for value in values { + for (value_index, value) in values.iter().enumerate() { let value_ref = match value { FlakeValue::Ref(r) => r, other => { out.push(ConstraintViolation { constraint: Constraint::Class(expected_class.clone()), value: Some(other.clone()), + value_index: Some(value_index), message: format!( "Value {:?} is a literal and cannot be an instance of class {}", other, expected_class.name @@ -2213,6 +2667,7 @@ async fn validate_class_constraint( out.push(ConstraintViolation { constraint: Constraint::Class(expected_class.clone()), value: Some(value.clone()), + value_index: Some(value_index), message: format!( "Value {} is not an instance of class {}", value_ref.name, expected_class.name @@ -2320,19 +2775,27 @@ async fn value_conforms_cross_ledger( expected_class: &Sid, ) -> Result { let m_db = cl.model_db; - // D term -> IRI (via D's staged ns map) -> M term. A missing decode/encode - // means the value/class is simply not known to M -> not a member there. - let (Some(value_iri), Some(class_iri)) = ( - decode_sid_with_ns_map(cl.data_ns_map, value_ref), - decode_sid_with_ns_map(cl.data_ns_map, expected_class), - ) else { - return Ok(false); - }; - let (Some(m_value), Some(m_class)) = ( - m_db.snapshot.encode_iri_strict(&value_iri), - m_db.snapshot.encode_iri_strict(&class_iri), - ) else { - return Ok(false); + let (m_value, m_class) = if cl.same_term_space { + // The membership source shares the data ledger's term space (inline + // shapes bundle) — probe with the data-side Sids directly. + (value_ref.clone(), expected_class.clone()) + } else { + // D term -> IRI (via D's staged ns map) -> M term. A missing + // decode/encode means the value/class is simply not known to M -> + // not a member there. + let (Some(value_iri), Some(class_iri)) = ( + decode_sid_with_ns_map(cl.data_ns_map, value_ref), + decode_sid_with_ns_map(cl.data_ns_map, expected_class), + ) else { + return Ok(false); + }; + let (Some(m_value), Some(m_class)) = ( + m_db.snapshot.encode_iri_strict(&value_iri), + m_db.snapshot.encode_iri_strict(&class_iri), + ) else { + return Ok(false); + }; + (m_value, m_class) }; let rdf_type = Sid::new(RDF, rdf_names::TYPE); @@ -2484,23 +2947,61 @@ impl ValidationReport { } } +/// The node (or literal, for literal `sh:targetNode` targets) a validation +/// result is about. +#[derive(Debug, Clone, PartialEq)] +pub enum FocusNode { + /// An IRI or blank node in the graph. + Node(Sid), + /// A literal target from `sh:targetNode` — validated directly, since a + /// literal has no graph presence to probe. + Literal(crate::compile::LiteralTarget), +} + +impl FocusNode { + /// The Sid when the focus is a graph node. + pub fn as_sid(&self) -> Option<&Sid> { + match self { + FocusNode::Node(sid) => Some(sid), + FocusNode::Literal(_) => None, + } + } +} + +impl std::fmt::Display for FocusNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FocusNode::Node(sid) => write!(f, "{}{}", sid.namespace_code, sid.name), + FocusNode::Literal(lit) => write!(f, "{}", lit.value), + } + } +} + /// Individual validation result #[derive(Debug, Clone)] pub struct ValidationResult { /// The focus node that was validated - pub focus_node: Sid, + pub focus_node: FocusNode, /// The property path (if property constraint) pub result_path: Option, /// The shape that produced this result pub source_shape: Sid, /// The constraint component that produced this result pub source_constraint: Option, + /// IRI of the SHACL constraint component that produced this result + /// (`sh:sourceConstraintComponent` in W3C validation reports) + pub constraint_component: &'static str, /// Severity level pub severity: Severity, /// Human-readable message pub message: String, /// The value that caused the violation (if applicable) pub value: Option, + /// Datatype of `value`, when the violation concerns a single value whose + /// datatype is known (resolves to the `sh:value` literal's `@type`) + pub value_datatype: Option, + /// Language tag of `value`, when it is a language-tagged literal + pub value_lang: Option, /// The graph where the focus node was being validated. Populated by the /// staged-validation path (`validate_staged_nodes`) so that callers can /// apply per-graph SHACL policy (e.g. warn vs reject, enable/disable). diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index dd57d83407..f7be8639bf 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2167,7 +2167,9 @@ pub async fn stage_with_shacl( let report = validate_staged_nodes(&view, &engine, Some(&graph_sids), tracker, None, None).await?; - if !report.conforms { + // Reject on violations only — spec-level `conforms` is also false for + // warnings/infos, which must not block a commit. + if report.violation_count() > 0 { return Err(TransactError::ShaclViolation(format_shacl_report(&report))); } @@ -2423,10 +2425,11 @@ async fn validate_staged_nodes( } } - // Check conformance - let conforms = all_results - .iter() - .all(|r| r.severity != fluree_db_shacl::Severity::Violation); + // Spec semantics: `sh:conforms` is true iff there are NO results, matching + // `ShaclEngine::validate_staged` so `ValidationReport.conforms` means the + // same thing across crates. Enforcement gates here key off + // `violation_count()` (warnings/info don't reject), not `conforms`. + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, @@ -2454,12 +2457,7 @@ fn format_shacl_report(report: &ValidationReport) -> String { .enumerate() { writeln!(&mut output, " {}. {}", i + 1, result.message).ok(); - writeln!( - &mut output, - " Focus node: {}{}", - result.focus_node.namespace_code, result.focus_node.name - ) - .ok(); + writeln!(&mut output, " Focus node: {}", result.focus_node).ok(); if let Some(path) = &result.result_path { writeln!( &mut output, diff --git a/fluree-graph-json-ld/src/adapter.rs b/fluree-graph-json-ld/src/adapter.rs index 6670c418ea..f6919b058a 100644 --- a/fluree-graph-json-ld/src/adapter.rs +++ b/fluree-graph-json-ld/src/adapter.rs @@ -209,6 +209,14 @@ fn process_value(value: &Value, sink: &mut S) -> Result { // Check for @id (reference to another node) if let Some(id_val) = obj.get("@id") { + // An embedded node object carrying more than a bare `@id` + // asserts its own triples (JSON-LD embedded-node semantics); + // recurse so they aren't silently dropped. A `{"@id": ...}` + // singleton is a pure reference. + if obj.len() > 1 { + let subject_id = process_node(value, sink, None)?; + return Ok(ProcessedValue::Single(subject_id)); + } let id_str = id_val.as_str().ok_or_else(|| { AdapterError::InvalidStructure("@id must be a string".to_string()) })?; @@ -1122,4 +1130,33 @@ mod tests { } } } + + #[test] + fn embedded_node_with_id_asserts_its_own_triples() { + // {"@id": ..., } nested as a value is an embedded node, not a + // bare reference — its own triples must be emitted, not dropped. + let expanded = json!([{ + "@id": "http://example.org/shape", + "http://example.org/ns#property": [{ + "@id": "http://example.org/ps1", + "http://example.org/ns#minCount": [{"@value": 1}] + }] + }]); + + let mut sink = GraphCollectorSink::new(); + to_graph_events(&expanded, &mut sink).unwrap(); + + let graph = sink.graph(); + // shape →property→ ps1, plus ps1 →minCount→ 1 + assert_eq!(graph.len(), 2); + + let mut sink = GraphCollectorSink::new(); + // A bare {"@id": ...} singleton stays a pure reference. + let expanded = json!([{ + "@id": "http://example.org/shape", + "http://example.org/ns#property": [{"@id": "http://example.org/ps1"}] + }]); + to_graph_events(&expanded, &mut sink).unwrap(); + assert_eq!(sink.graph().len(), 1); + } } diff --git a/fluree-graph-turtle/src/parser.rs b/fluree-graph-turtle/src/parser.rs index ea4e38a466..7664f87f85 100644 --- a/fluree-graph-turtle/src/parser.rs +++ b/fluree-graph-turtle/src/parser.rs @@ -457,8 +457,14 @@ impl<'a, 'input, S: GraphSink> Parser<'a, 'input, S> { /// Parse a triple statement. fn parse_triples(&mut self) -> Result<()> { + let bnode_list_subject = matches!(self.current().kind, TokenKind::LBracket); let subject = self.parse_subject()?; - self.parse_predicate_object_list(subject)?; + // Turtle grammar: `blankNodePropertyList predicateObjectList? '.'` — + // the predicate-object list is optional when the subject is a + // `[...]` property list (its triples were emitted inside the list). + if !(bnode_list_subject && matches!(self.current().kind, TokenKind::Dot)) { + self.parse_predicate_object_list(subject)?; + } self.expect(&TokenKind::Dot)?; Ok(()) } @@ -1533,4 +1539,19 @@ mod tests { ); } } + + #[test] + fn bare_blank_node_property_list_statement() { + // Turtle grammar allows `[ ... ] .` with no outer predicate list. + let turtle = r" + @prefix ex: . + [ + ex:p ex:A ; + ex:p ex:B ; + ]. + "; + let mut sink = GraphCollectorSink::new(); + parse(turtle, &mut sink).expect("bare [ ... ] . statement must parse"); + assert_eq!(sink.finish().len(), 2); + } } diff --git a/fluree-vocab/src/lib.rs b/fluree-vocab/src/lib.rs index c355f6faf3..d6bc3a80c6 100644 --- a/fluree-vocab/src/lib.rs +++ b/fluree-vocab/src/lib.rs @@ -1117,6 +1117,118 @@ pub mod shacl { /// sh:resultMessage IRI pub const RESULT_MESSAGE: &str = "http://www.w3.org/ns/shacl#resultMessage"; + + // ======================================================================== + // Constraint Component IRIs (sh:sourceConstraintComponent values) + // ======================================================================== + + /// sh:MinCountConstraintComponent IRI + pub const MIN_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinCountConstraintComponent"; + + /// sh:MaxCountConstraintComponent IRI + pub const MAX_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxCountConstraintComponent"; + + /// sh:DatatypeConstraintComponent IRI + pub const DATATYPE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#DatatypeConstraintComponent"; + + /// sh:NodeKindConstraintComponent IRI + pub const NODE_KIND_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#NodeKindConstraintComponent"; + + /// sh:ClassConstraintComponent IRI + pub const CLASS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#ClassConstraintComponent"; + + /// sh:MinInclusiveConstraintComponent IRI + pub const MIN_INCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinInclusiveConstraintComponent"; + + /// sh:MaxInclusiveConstraintComponent IRI + pub const MAX_INCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxInclusiveConstraintComponent"; + + /// sh:MinExclusiveConstraintComponent IRI + pub const MIN_EXCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinExclusiveConstraintComponent"; + + /// sh:MaxExclusiveConstraintComponent IRI + pub const MAX_EXCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxExclusiveConstraintComponent"; + + /// sh:PatternConstraintComponent IRI + pub const PATTERN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#PatternConstraintComponent"; + + /// sh:MinLengthConstraintComponent IRI + pub const MIN_LENGTH_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinLengthConstraintComponent"; + + /// sh:MaxLengthConstraintComponent IRI + pub const MAX_LENGTH_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxLengthConstraintComponent"; + + /// sh:HasValueConstraintComponent IRI + pub const HAS_VALUE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#HasValueConstraintComponent"; + + /// sh:InConstraintComponent IRI + pub const IN_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#InConstraintComponent"; + + /// sh:EqualsConstraintComponent IRI + pub const EQUALS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#EqualsConstraintComponent"; + + /// sh:DisjointConstraintComponent IRI + pub const DISJOINT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#DisjointConstraintComponent"; + + /// sh:LessThanConstraintComponent IRI + pub const LESS_THAN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LessThanConstraintComponent"; + + /// sh:LessThanOrEqualsConstraintComponent IRI + pub const LESS_THAN_OR_EQUALS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LessThanOrEqualsConstraintComponent"; + + /// sh:UniqueLangConstraintComponent IRI + pub const UNIQUE_LANG_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#UniqueLangConstraintComponent"; + + /// sh:LanguageInConstraintComponent IRI + pub const LANGUAGE_IN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LanguageInConstraintComponent"; + + /// sh:QualifiedMinCountConstraintComponent IRI + pub const QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"; + + /// sh:QualifiedMaxCountConstraintComponent IRI + pub const QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#QualifiedMaxCountConstraintComponent"; + + /// sh:NodeConstraintComponent IRI + pub const NODE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#NodeConstraintComponent"; + + /// sh:NotConstraintComponent IRI + pub const NOT_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#NotConstraintComponent"; + + /// sh:AndConstraintComponent IRI + pub const AND_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#AndConstraintComponent"; + + /// sh:OrConstraintComponent IRI + pub const OR_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#OrConstraintComponent"; + + /// sh:XoneConstraintComponent IRI + pub const XONE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#XoneConstraintComponent"; + + /// sh:ClosedConstraintComponent IRI + pub const CLOSED_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#ClosedConstraintComponent"; } /// SHACL vocabulary local names (for SID construction) @@ -1420,6 +1532,11 @@ pub mod fluree { /// Fluree DB system namespace IRI (canonical base for all Fluree system vocabulary) pub const DB: &str = "https://ns.flur.ee/db#"; + /// Constraint component reported when a `sh:path` could not be compiled + /// (Fluree extension; W3C SHACL has no component for this condition) + pub const UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT: &str = + "https://ns.flur.ee/db#UnresolvablePathConstraintComponent"; + /// Fluree URN prefix for ledger-scoped identifiers. /// /// Used as a namespace prefix so `encode_iri` can decompose ledger-scoped IRIs. diff --git a/testsuite-shacl/Cargo.lock b/testsuite-shacl/Cargo.lock new file mode 100644 index 0000000000..3ce140f1da --- /dev/null +++ b/testsuite-shacl/Cargo.lock @@ -0,0 +1,3025 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6773ddc0eafc0e509fb60e48dff7f450f8e674a0686ae8605e8d9901bd5eefa" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools", + "num-traits", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float_extras" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b22b70f8649ea2315955f1a36d964b0e4da482dfaa5f0d04df0d1fb7c338ab7a" +dependencies = [ + "libc", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluree-db-api" +version = "4.1.1" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "csv", + "dirs", + "flate2", + "fluree-db-binary-index", + "fluree-db-connection", + "fluree-db-core", + "fluree-db-crypto", + "fluree-db-cypher", + "fluree-db-indexer", + "fluree-db-ledger", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-query", + "fluree-db-r2rml", + "fluree-db-shacl", + "fluree-db-sparql", + "fluree-db-spatial", + "fluree-db-transact", + "fluree-graph-format", + "fluree-graph-ir", + "fluree-graph-json-ld", + "fluree-graph-turtle", + "fluree-search-protocol", + "fluree-vocab", + "futures", + "hex", + "indexmap", + "itoa", + "moka", + "percent-encoding", + "rustc-hash", + "ryu", + "serde", + "serde_json", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-binary-index" +version = "4.1.1" +dependencies = [ + "bigdecimal 0.4.10", + "chrono", + "ciborium", + "fluree-db-core", + "fluree-db-spatial", + "fluree-vocab", + "fs2", + "futures", + "hex", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "once_cell", + "parking_lot", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-connection" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-graph-json-ld", + "serde", + "serde_json", + "sysinfo", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-core" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "ciborium", + "cid", + "dashmap", + "fluree-db-credential", + "fluree-vocab", + "fs2", + "futures", + "hashbrown 0.16.1", + "hex", + "moka", + "multihash", + "num-bigint", + "num-traits", + "once_cell", + "parking_lot", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-credential" +version = "4.1.1" +dependencies = [ + "base64", + "bs58", + "ed25519-dalek", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-crypto" +version = "4.1.1" +dependencies = [ + "aes-gcm", + "async-trait", + "base64", + "fluree-db-core", + "fluree-db-nameservice", + "hex", + "rand_core 0.6.4", + "sha2", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "fluree-db-cypher" +version = "4.1.1" +dependencies = [ + "fluree-db-core", + "fluree-db-query", + "fluree-graph-json-ld", + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-db-indexer" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-spatial", + "fluree-vocab", + "futures", + "hex", + "libc", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "parking_lot", + "rayon", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-ledger" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-vocab", + "futures", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-nameservice" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-vocab", + "parking_lot", + "rand 0.8.6", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "fluree-db-novelty" +version = "4.1.1" +dependencies = [ + "async-trait", + "chrono", + "fluree-db-core", + "fluree-db-credential", + "fluree-vocab", + "futures", + "imbl", + "rayon", + "rustc-hash", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-policy" +version = "4.1.1" +dependencies = [ + "fluree-db-core", + "fluree-vocab", + "futures", + "thiserror 1.0.69", + "tracing", + "uuid", +] + +[[package]] +name = "fluree-db-query" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-r2rml", + "fluree-db-reasoner", + "fluree-db-spatial", + "fluree-db-tabular", + "fluree-graph-json-ld", + "fluree-search-protocol", + "fluree-vocab", + "futures", + "geo", + "geo-types", + "hashbrown 0.16.1", + "lru", + "md-5", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "once_cell", + "percent-encoding", + "postcard", + "rand 0.8.6", + "rayon", + "regex", + "rustc-hash", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "wkt", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-r2rml" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-tabular", + "fluree-graph-ir", + "fluree-graph-turtle", + "fluree-vocab", + "once_cell", + "regex", + "serde", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-reasoner" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "fluree-db-core", + "fluree-vocab", + "hashbrown 0.16.1", + "lru", + "num-bigint", + "parking_lot", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-shacl" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-db-query", + "fluree-vocab", + "parking_lot", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-sparql" +version = "4.1.1" +dependencies = [ + "bigdecimal 0.4.10", + "fluree-db-core", + "fluree-db-query", + "fluree-graph-json-ld", + "fluree-vocab", + "num-bigint", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-db-spatial" +version = "4.1.1" +dependencies = [ + "async-trait", + "crc32fast", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-vocab", + "futures", + "geo", + "geo-types", + "hex", + "memmap2", + "moka", + "rustc-hash", + "s2", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "wkt", + "zstd", +] + +[[package]] +name = "fluree-db-tabular" +version = "4.1.1" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-transact" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-cypher", + "fluree-db-indexer", + "fluree-db-ledger", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-query", + "fluree-db-shacl", + "fluree-db-sparql", + "fluree-graph-ir", + "fluree-graph-json-ld", + "fluree-graph-turtle", + "fluree-vocab", + "futures", + "hex", + "num-bigint", + "once_cell", + "parking_lot", + "postcard", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "ulid", + "zstd", +] + +[[package]] +name = "fluree-graph-format" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-graph-ir" +version = "4.1.1" +dependencies = [ + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-graph-json-ld" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-graph-turtle" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "fluree-vocab", + "rustc-hash", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-search-protocol" +version = "4.1.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-vocab" +version = "4.1.1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f0e6e028c581e82e6822a68869514e94c25e7f8ea669a2d8595bdf7461ccc5" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx 0.5.1", + "num-traits", + "rayon", + "rstar", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "i_float" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775f9961a8d2f879725da8aff789bb20a3ddf297473e0c90af75e69313919490" +dependencies = [ + "serde", +] + +[[package]] +name = "i_key_sort" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347c253b4748a1a28baf94c9ce133b6b166f08573157e05afe718812bc599fcd" + +[[package]] +name = "i_overlay" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01882ce5ed786bf6e8f5167f171a4026cd129ce17d9ff5cbf1e6749b98628ece" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27dbe9e5238d6b9c694c08415bf00fb370b089949bd818ab01f41f8927b8774c" +dependencies = [ + "i_float", + "serde", +] + +[[package]] +name = "i_tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "155181bc97d770181cf9477da51218a19ee92a8e5be642e796661aee2b601139" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "imbl" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc3be8d8cd36f33a46b1849f31f837c44d9fa87223baee3b4bd96b8f11df81eb" +dependencies = [ + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.6.4", + "rand_xoshiro", + "version_check", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" +dependencies = [ + "base-x", + "base256emoji", + "base45", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "smallvec", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "s2" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7fbc04bb52c40b5f48c9bb2d2961375301916e0c25d9f373750654d588cd5c" +dependencies = [ + "bigdecimal 0.3.1", + "cgmath", + "float_extras", + "lazy_static", + "libm", + "serde", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testsuite-shacl" +version = "4.1.1" +dependencies = [ + "anyhow", + "fluree-db-api", + "fluree-db-core", + "fluree-graph-ir", + "fluree-graph-turtle", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wkt" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f7f1ff4ea4c18936d6cd26a6fd24f0003af37e951a8e0e8b9e9a2d0bd0a46d" +dependencies = [ + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/testsuite-shacl/Cargo.toml b/testsuite-shacl/Cargo.toml new file mode 100644 index 0000000000..41952efcf7 --- /dev/null +++ b/testsuite-shacl/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "testsuite-shacl" +version = "4.1.1" +edition = "2021" +publish = false + +[dependencies] +# Fluree crates (for testing) +fluree-db-api = { path = "../fluree-db-api", features = ["native", "shacl"] } +fluree-db-core = { path = "../fluree-db-core" } +fluree-graph-turtle = { path = "../fluree-graph-turtle" } +fluree-graph-ir = { path = "../fluree-graph-ir" } + +# Test infrastructure +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } + +[profile.dev] +opt-level = 1 diff --git a/testsuite-shacl/Makefile b/testsuite-shacl/Makefile new file mode 100644 index 0000000000..57bfda18fe --- /dev/null +++ b/testsuite-shacl/Makefile @@ -0,0 +1,67 @@ +# testsuite-shacl/Makefile +# +# Developer targets for the W3C SHACL core compliance test suite. +# Run `make help` to see available targets. +# +# NOTE: this crate is excluded from the workspace — run make (or cargo) +# from inside testsuite-shacl/. + +CARGO := cargo +REPORT_JSON := report.json + +.PHONY: help test count summary test-cat test-one report-json failures show clean update-submodule + +help: ## Show this help + @echo "W3C SHACL Core Compliance Test Suite" + @echo "" + @echo "Usage: make " + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' + @echo "" + @echo "Examples:" + @echo " make count # overall pass/fail numbers" + @echo " make summary # per-category table" + @echo " make test-cat CAT=node # run one category" + @echo " make test-one TEST=minLength-001 # run matching tests, full output" + @echo " make failures # list failing tests with reasons" + @echo " make show TEST=node/minLength-001 # print a test file" + +test: ## Run the full core suite (report-only; never fails the build) + @$(CARGO) test --test w3c_shacl -- --nocapture + +count: ## Overall pass/fail numbers + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | grep -E "=== Test Summary" || true + +summary: ## Per-category pass/fail table + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | \ + sed -n '/=== W3C SHACL Core/,/=== Test Summary/p' + +test-cat: ## Run one category: make test-cat CAT=node + @test -n "$(CAT)" || (echo "usage: make test-cat CAT=node" && exit 2) + @SHACL_CATEGORY=$(CAT) $(CARGO) test --test w3c_shacl -- --nocapture + +test-one: ## Run tests whose name matches: make test-one TEST=minLength-001 + @test -n "$(TEST)" || (echo "usage: make test-one TEST=minLength-001" && exit 2) + @SHACL_TEST=$(TEST) $(CARGO) test --test w3c_shacl -- --nocapture + +report-json: ## Write machine-readable report to report.json + @W3C_REPORT_JSON=$(REPORT_JSON) $(CARGO) test --test w3c_shacl -- --nocapture > /dev/null 2>&1; true + @echo "JSON report written to $(REPORT_JSON)" + @python3 -c "import json; r=json.load(open('$(REPORT_JSON)')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true + +failures: ## List failing tests with reasons (from a fresh run) + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | grep -A1 -E "^(FAIL|ERROR)" || echo "no failures" + +show: ## Print a W3C test file: make show TEST=node/minLength-001 + @test -n "$(TEST)" || (echo "usage: make show TEST=node/minLength-001" && exit 2) + @cat data-shapes/data-shapes-test-suite/tests/core/$(TEST).ttl + +clean: ## Remove build artifacts and reports + @$(CARGO) clean + @rm -f $(REPORT_JSON) + +update-submodule: ## Update the W3C data-shapes submodule to latest + cd data-shapes && git pull origin gh-pages + @echo "" + @echo "Submodule updated. Run 'make count' to check for changes." diff --git a/testsuite-shacl/data-shapes b/testsuite-shacl/data-shapes new file mode 160000 index 0000000000..08adb37767 --- /dev/null +++ b/testsuite-shacl/data-shapes @@ -0,0 +1 @@ +Subproject commit 08adb3776709a014bc3062ede793c36275b22446 diff --git a/testsuite-shacl/src/compare.rs b/testsuite-shacl/src/compare.rs new file mode 100644 index 0000000000..df2307f231 --- /dev/null +++ b/testsuite-shacl/src/compare.rs @@ -0,0 +1,148 @@ +//! Compare an actual [`ValidateReport`] against the expected results. + +use std::fmt; + +use fluree_db_api::validate::{ReportResult, ValidateReport}; +use serde_json::json; + +use crate::manifest::{ExpectedResult, TermPat}; + +/// Why a report did not match. +pub struct Mismatch(String); + +impl fmt::Display for Mismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Compare the actual report to the expected conformance + result multiset. +/// Returns `None` on a match. +pub fn compare_report( + expected_conforms: bool, + expected: &[ExpectedResult], + actual: &ValidateReport, +) -> Option { + if actual.conforms != expected_conforms { + return Some(Mismatch(format!( + "conforms: expected {expected_conforms}, got {} ({} results: {})", + actual.conforms, + actual.results.len(), + summarize(&actual.results), + ))); + } + if actual.results.len() != expected.len() { + return Some(Mismatch(format!( + "result count: expected {}, got {} ({})", + expected.len(), + actual.results.len(), + summarize(&actual.results), + ))); + } + + // Multiset match with backtracking (result sets are small). + let mut used = vec![false; actual.results.len()]; + if match_all(expected, &actual.results, &mut used) { + None + } else { + Some(Mismatch(format!( + "results do not match expected set (expected: {:?} | actual: {})", + expected, + summarize(&actual.results), + ))) + } +} + +fn match_all(expected: &[ExpectedResult], actual: &[ReportResult], used: &mut [bool]) -> bool { + let Some(exp) = expected.first() else { + return true; + }; + for (i, act) in actual.iter().enumerate() { + if !used[i] && matches(exp, act) { + used[i] = true; + if match_all(&expected[1..], actual, used) { + return true; + } + used[i] = false; + } + } + false +} + +fn matches(exp: &ExpectedResult, act: &ReportResult) -> bool { + // Focus node: a JSON string for IRIs / skolemized blank-node labels, + // or a JSON-LD value form for literal sh:targetNode targets. + let focus_ok = match &exp.focus { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => match act.focus_node.as_str() { + Some(s) if s.starts_with("_:") => false, + Some(s) => *j == json!({"@id": s}), + // Literal focus: a bare {"@value": s} wraps a plain string to + // keep it distinct from an IRI — unwrap it for comparison. + None => { + let unwrapped = act + .focus_node + .as_object() + .filter(|o| o.len() == 1) + .and_then(|o| o.get("@value")) + .unwrap_or(&act.focus_node); + *j == *unwrapped + } + }, + }; + if !focus_ok { + return false; + } + + // Result path: compared only when the expected path is a plain IRI; + // blank-node (complex) paths match leniently, absent accepts anything. + let path_ok = match &exp.path { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => match &act.result_path { + Some(p) => *j == json!({"@id": p}), + None => false, + }, + }; + if !path_ok { + return false; + } + + if let Some(sev) = &exp.severity { + if *sev != act.severity { + return false; + } + } + if let Some(component) = &exp.component { + if *component != act.constraint_component { + return false; + } + } + + match &exp.value { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => act.value.as_ref() == Some(j), + } +} + +fn summarize(results: &[ReportResult]) -> String { + results + .iter() + .map(|r| { + format!( + "[{} {} @{}{}]", + short(&r.constraint_component), + r.focus_node, + r.result_path.as_deref().unwrap_or("-"), + r.value + .as_ref() + .map(|v| format!(" = {v}")) + .unwrap_or_default(), + ) + }) + .collect::>() + .join(" ") +} + +fn short(iri: &str) -> &str { + iri.rsplit_once('#').map_or(iri, |(_, l)| l) +} diff --git a/testsuite-shacl/src/lib.rs b/testsuite-shacl/src/lib.rs new file mode 100644 index 0000000000..a1ec1640f7 --- /dev/null +++ b/testsuite-shacl/src/lib.rs @@ -0,0 +1,48 @@ +//! W3C SHACL test-suite harness. +//! +//! Walks the official `data-shapes` test manifests (vendored as a git +//! submodule under `data-shapes/`), runs each `sht:Validate` case through +//! the `fluree_db_api::validate` core (the same code path behind +//! `fluree validate`), and compares the produced report against the +//! expected `sh:ValidationReport` embedded in the manifest. +//! +//! ## Comparison semantics +//! +//! The W3C suite compares validation reports as RDF graphs. This harness +//! compares the *result multiset* on the fields implementations are judged +//! on — `sh:focusNode`, `sh:resultPath`, `sh:resultSeverity`, +//! `sh:sourceConstraintComponent`, `sh:value` — with deliberate leniency +//! where exact comparison needs machinery we don't have yet: +//! +//! - **Blank nodes** (focus nodes, values, complex `sh:resultPath` +//! structures, `sh:sourceShape`): matched as wildcards rather than by +//! graph isomorphism. `sh:sourceShape` is not compared at all. +//! - **Missing expected fields**: not compared (an expected result without +//! `sh:value` accepts any actual value). +//! +//! `sh:resultMessage` is never compared (per the suite's own rules). + +pub mod compare; +pub mod manifest; +pub mod runner; + +pub use compare::{compare_report, Mismatch}; +pub use manifest::{collect_tests, Expectation, ExpectedResult, TermPat, TestCase}; +pub use runner::{run_case, Outcome}; + +/// Namespaces used by the manifests. +pub mod ns { + pub const MF: &str = "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#"; + pub const SHT: &str = "http://www.w3.org/ns/shacl-test#"; + pub const SH: &str = "http://www.w3.org/ns/shacl#"; + pub const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + pub const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; +} + +/// Turn a filesystem path into the `file://` IRI used as the Turtle +/// `@base`, so relative IRIs (including `<>` self-references) resolve +/// identically in the manifest graph, the loaded data, and the report. +pub fn file_iri(path: &std::path::Path) -> String { + let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + format!("file://{}", abs.display()) +} diff --git a/testsuite-shacl/src/manifest.rs b/testsuite-shacl/src/manifest.rs new file mode 100644 index 0000000000..1d770a2cbf --- /dev/null +++ b/testsuite-shacl/src/manifest.rs @@ -0,0 +1,301 @@ +//! Manifest walking: turn the W3C `data-shapes` manifest tree into test cases. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use fluree_graph_ir::{Graph, GraphCollectorSink, Term}; +use fluree_graph_turtle::parse; +use serde_json::{json, Value as JsonValue}; + +use crate::{file_iri, ns}; + +/// One `sht:Validate` test case. +#[derive(Debug)] +pub struct TestCase { + /// Entry IRI (resolved against the manifest file). + pub id: String, + /// Short display name, e.g. `node/minLength-001`. + pub name: String, + /// Category directory, e.g. `node`. + pub category: String, + /// `rdfs:label` when present. + pub label: Option, + /// `mf:status` is `sht:approved`. + pub approved: bool, + /// Data graph file. + pub data_path: PathBuf, + /// Shapes graph file. + pub shapes_path: PathBuf, + /// Expected outcome. + pub expect: Expectation, +} + +/// Expected outcome of a test case. +#[derive(Debug)] +pub enum Expectation { + /// A validation report with the given conformance and results. + Report { + conforms: bool, + results: Vec, + }, + /// Validation itself is expected to fail (`mf:result sht:Failure`). + Failure, +} + +/// One expected `sh:ValidationResult`, reduced to comparable patterns. +#[derive(Debug)] +pub struct ExpectedResult { + pub focus: TermPat, + pub path: TermPat, + pub severity: Option, + pub component: Option, + pub value: TermPat, +} + +/// A comparable pattern for one expected term. +#[derive(Debug, Clone, PartialEq)] +pub enum TermPat { + /// The field is absent in the expected result — accept anything. + Absent, + /// A blank node (or other structure we match leniently) — accept anything. + Any, + /// Concrete term in the same JSON form the report core emits. + Json(JsonValue), +} + +/// Recursively collect all `sht:Validate` cases reachable from a manifest. +pub fn collect_tests(manifest_path: &Path) -> Result> { + let mut out = Vec::new(); + walk(manifest_path, &mut out)?; + Ok(out) +} + +fn walk(manifest_path: &Path, out: &mut Vec) -> Result<()> { + let base = file_iri(manifest_path); + let raw = std::fs::read_to_string(manifest_path) + .with_context(|| format!("reading manifest {}", manifest_path.display()))?; + let content = format!("@base <{base}> .\n{raw}"); + + let mut sink = GraphCollectorSink::new(); + parse(&content, &mut sink) + .with_context(|| format!("parsing manifest {}", manifest_path.display()))?; + let graph = sink.finish(); + + let manifest_subject = find_manifest_subject(&graph, &base) + .ok_or_else(|| anyhow!("no mf:Manifest subject in {}", manifest_path.display()))?; + + // Sub-manifests + let mf_include = format!("{}include", ns::MF); + for include in list_items(&graph, &manifest_subject, &mf_include) { + let path = iri_to_path(&include) + .ok_or_else(|| anyhow!("mf:include is not a file IRI: {include}"))?; + walk(&path, out)?; + } + + // Entries in this manifest + let mf_entries = format!("{}entries", ns::MF); + for entry in list_items(&graph, &manifest_subject, &mf_entries) { + if let Some(case) = parse_entry(&graph, &entry, manifest_path) + .with_context(|| format!("entry {entry} in {}", manifest_path.display()))? + { + out.push(case); + } + } + Ok(()) +} + +fn parse_entry(graph: &Graph, entry_iri: &str, manifest_path: &Path) -> Result> { + let entry = Term::iri(entry_iri); + + // Only sht:Validate entries are runnable validation tests. + let sht_validate = format!("{}Validate", ns::SHT); + let is_validate = objects_for(graph, &entry, ns::RDF_TYPE) + .iter() + .any(|t| t.as_iri() == Some(sht_validate.as_str())); + if !is_validate { + return Ok(None); + } + + let label = object_for(graph, &entry, "http://www.w3.org/2000/01/rdf-schema#label") + .and_then(|t| t.as_literal().map(|(v, _, _)| v.lexical())); + + let approved = object_for(graph, &entry, &format!("{}status", ns::MF)) + .and_then(|t| t.as_iri().map(String::from)) + .is_some_and(|iri| iri == format!("{}approved", ns::SHT)); + + // mf:action → sht:dataGraph / sht:shapesGraph + let action = object_for(graph, &entry, &format!("{}action", ns::MF)) + .ok_or_else(|| anyhow!("entry has no mf:action"))? + .clone(); + let data_iri = object_for(graph, &action, &format!("{}dataGraph", ns::SHT)) + .and_then(|t| t.as_iri().map(String::from)) + .ok_or_else(|| anyhow!("mf:action has no sht:dataGraph"))?; + let shapes_iri = object_for(graph, &action, &format!("{}shapesGraph", ns::SHT)) + .and_then(|t| t.as_iri().map(String::from)) + .ok_or_else(|| anyhow!("mf:action has no sht:shapesGraph"))?; + let data_path = iri_to_path(&data_iri).ok_or_else(|| anyhow!("dataGraph is not a file IRI"))?; + let shapes_path = + iri_to_path(&shapes_iri).ok_or_else(|| anyhow!("shapesGraph is not a file IRI"))?; + + // mf:result → sht:Failure or an inline sh:ValidationReport + let result = object_for(graph, &entry, &format!("{}result", ns::MF)) + .ok_or_else(|| anyhow!("entry has no mf:result"))? + .clone(); + let expect = if result.as_iri() == Some(format!("{}Failure", ns::SHT).as_str()) { + Expectation::Failure + } else { + let conforms = object_for(graph, &result, &format!("{}conforms", ns::SH)) + .and_then(|t| t.as_literal().map(|(v, _, _)| v.lexical() == "true")) + .ok_or_else(|| anyhow!("expected report has no sh:conforms"))?; + let results = objects_for(graph, &result, &format!("{}result", ns::SH)) + .into_iter() + .map(|r| parse_expected_result(graph, r)) + .collect(); + Expectation::Report { conforms, results } + }; + + // node/minLength-001.ttl → category "node", name "node/minLength-001" + let category = manifest_path + .parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let stem = manifest_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + Ok(Some(TestCase { + id: entry_iri.to_string(), + name: format!("{category}/{stem}"), + category, + label, + approved, + data_path, + shapes_path, + expect, + })) +} + +fn parse_expected_result(graph: &Graph, result: &Term) -> ExpectedResult { + let get = |local: &str| object_for(graph, result, &format!("{}{local}", ns::SH)); + // A list-valued sh:resultPath (sequence/alternative path structure) is a + // complex path — matched leniently, like blank-node path structures. + let path_pred = format!("{}resultPath", ns::SH); + let path_is_list = graph.iter().any(|t| { + t.s == *result && t.p.as_iri() == Some(path_pred.as_str()) && t.list_index.is_some() + }); + ExpectedResult { + focus: term_pat(get("focusNode")), + path: if path_is_list { + TermPat::Any + } else { + term_pat(get("resultPath")) + }, + severity: get("resultSeverity").and_then(|t| t.as_iri().map(String::from)), + component: get("sourceConstraintComponent").and_then(|t| t.as_iri().map(String::from)), + value: term_pat(get("value")), + } +} + +/// Reduce an expected term to a comparable pattern in the same JSON form +/// the report core emits for `sh:value` / focus nodes. +fn term_pat(term: Option<&Term>) -> TermPat { + match term { + None => TermPat::Absent, + Some(Term::BlankNode(_)) => TermPat::Any, + Some(Term::Iri(iri)) => TermPat::Json(json!({"@id": iri.to_string()})), + Some(Term::Literal { + value, + datatype, + language, + }) => TermPat::Json(literal_json(value, datatype.as_iri(), language.as_deref())), + } +} + +/// Mirror of the report core's `value_json` emission rules: JSON-native XSD +/// types render as bare scalars, language-tagged literals as +/// `{"@value", "@language"}`, everything else as `{"@value", "@type"}`. +fn literal_json( + value: &fluree_graph_ir::LiteralValue, + dt_iri: &str, + lang: Option<&str>, +) -> JsonValue { + use fluree_graph_ir::LiteralValue as LV; + if let Some(lang) = lang { + return json!({"@value": value.lexical(), "@language": lang}); + } + // The Turtle parser may keep explicitly-typed literals as lexical + // strings (`"true"^^xsd:boolean` → String("true")) — normalize the + // JSON-native XSD types the same way the report core does. + match (value, dt_iri.strip_prefix(ns::XSD)) { + (LV::String(s), Some("string")) => json!(s.to_string()), + (LV::Boolean(b), Some("boolean")) => json!(b), + (LV::Integer(i), Some("integer")) => json!(i), + (LV::Double(d), Some("double")) => json!(d), + (LV::String(s), Some("boolean")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } + (LV::String(s), Some("integer")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } + (LV::String(s), Some("double")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } + _ => json!({"@value": value.lexical(), "@type": dt_iri}), + } +} + +// --------------------------------------------------------------------------- +// Graph helpers +// --------------------------------------------------------------------------- + +fn find_manifest_subject(graph: &Graph, base: &str) -> Option { + let mf_manifest = format!("{}Manifest", ns::MF); + for t in graph.iter() { + if t.p.as_iri() == Some(ns::RDF_TYPE) && t.o.as_iri() == Some(mf_manifest.as_str()) { + return Some(t.s.clone()); + } + } + let mf_entries = format!("{}entries", ns::MF); + for t in graph.iter() { + if t.p.as_iri() == Some(mf_entries.as_str()) { + return Some(t.s.clone()); + } + } + Some(Term::iri(base)) +} + +fn object_for<'g>(graph: &'g Graph, subject: &Term, predicate: &str) -> Option<&'g Term> { + graph + .iter() + .find(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .map(|t| &t.o) +} + +fn objects_for<'g>(graph: &'g Graph, subject: &Term, predicate: &str) -> Vec<&'g Term> { + graph + .iter() + .filter(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .map(|t| &t.o) + .collect() +} + +/// Ordered items of an object-position collection. Fluree's Turtle parser +/// emits list members as triples carrying `list_index` (not rdf:first/rest). +fn list_items(graph: &Graph, subject: &Term, predicate: &str) -> Vec { + let mut items: Vec<(i32, String)> = graph + .iter() + .filter(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .filter_map(|t| { + let iri = t.o.as_iri()?; + Some((t.list_index.unwrap_or(0), iri.to_string())) + }) + .collect(); + items.sort_by_key(|(i, _)| *i); + items.into_iter().map(|(_, v)| v).collect() +} + +fn iri_to_path(iri: &str) -> Option { + iri.strip_prefix("file://").map(PathBuf::from) +} diff --git a/testsuite-shacl/src/runner.rs b/testsuite-shacl/src/runner.rs new file mode 100644 index 0000000000..016203cb69 --- /dev/null +++ b/testsuite-shacl/src/runner.rs @@ -0,0 +1,114 @@ +//! Run one test case through the `fluree_db_api::validate` core. + +use anyhow::{Context, Result}; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use fluree_db_api::{Fluree, FlureeBuilder}; + +use crate::compare::compare_report; +use crate::file_iri; +use crate::manifest::{Expectation, TestCase}; + +/// Outcome of running one case. +#[derive(Debug)] +pub enum Outcome { + Pass, + /// The report was produced but did not match the expected one. + Fail(String), + /// The pipeline errored where a report was expected (load failure, + /// unsupported construct, validation error). + Error(String), +} + +impl Outcome { + pub fn passed(&self) -> bool { + matches!(self, Outcome::Pass) + } +} + +/// Load the data graph into an ephemeral memory ledger, validate against the +/// shapes graph (inline Turtle), compare against the expectation. +pub async fn run_case(case: &TestCase) -> Outcome { + match execute(case).await { + Ok(outcome) => outcome, + Err(e) => match case.expect { + // A processing failure is the expected outcome of sht:Failure tests. + Expectation::Failure => Outcome::Pass, + _ => Outcome::Error(format!("{e:#}")), + }, + } +} + +async fn execute(case: &TestCase) -> Result { + let data = read_with_base(&case.data_path)?; + let shapes = read_with_base(&case.shapes_path)?; + + let fluree = FlureeBuilder::memory().build_memory(); + let alias = "w3c/shacl:main"; + fluree.create_ledger(alias).await?; + disable_staging_shacl(&fluree, alias).await?; + + fluree + .graph(alias) + .transact() + .insert_turtle(&data) + .commit() + .await + .context("loading data graph")?; + + let options = ValidateOptions { + graph: None, + shapes: ShapesSource::InlineTurtle(shapes), + include_attached: false, + }; + let report: ValidateReport = fluree + .validate_ledger(alias, &options) + .await + .context("validating")?; + + match &case.expect { + Expectation::Failure => Ok(Outcome::Fail( + "expected a validation failure, got a report".to_string(), + )), + Expectation::Report { conforms, results } => { + match compare_report(*conforms, results, &report) { + None => Ok(Outcome::Pass), + Some(mismatch) => Ok(Outcome::Fail(mismatch.to_string())), + } + } + } +} + +/// Read a Turtle file, prepending `@base ` so relative IRIs +/// (including `<>` self-references) resolve the same way they do in the +/// manifest graph the expected report was parsed from. +fn read_with_base(path: &std::path::Path) -> Result { + let raw = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Ok(format!("@base <{}> .\n{raw}", file_iri(path))) +} + +/// Test files embed shapes and (often deliberately violating) data in one +/// document — staging-time SHACL enforcement must not reject the load. +/// Same mechanism as `fluree validate`'s file mode. +async fn disable_staging_shacl(fluree: &Fluree, alias: &str) -> Result<()> { + let config_iri = fluree_db_core::graph_registry::config_graph_iri(alias); + let trig = format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled false . + }} + " + ); + fluree + .graph(alias) + .transact() + .upsert_turtle(&trig) + .commit() + .await?; + Ok(()) +} diff --git a/testsuite-shacl/tests/w3c_shacl.rs b/testsuite-shacl/tests/w3c_shacl.rs new file mode 100644 index 0000000000..d9f3360f5e --- /dev/null +++ b/testsuite-shacl/tests/w3c_shacl.rs @@ -0,0 +1,111 @@ +//! W3C SHACL core test-suite runner. +//! +//! Env vars: +//! - `SHACL_CATEGORY=node` — run one category only +//! - `SHACL_TEST=minLength-001` — run tests whose name contains the string +//! - `W3C_REPORT_JSON=report.json` — write a machine-readable report +//! - `SHACL_STRICT=1` — fail the test on any non-pass (default: report only) + +use std::collections::BTreeMap; +use std::path::Path; + +use testsuite_shacl::{collect_tests, run_case, Outcome}; + +#[test] +fn shacl_core_w3c_testsuite() { + let manifest = Path::new("data-shapes/data-shapes-test-suite/tests/core/manifest.ttl"); + assert!( + manifest.exists(), + "data-shapes submodule missing — run: git submodule update --init testsuite-shacl/data-shapes" + ); + + let category_filter = std::env::var("SHACL_CATEGORY").ok(); + let test_filter = std::env::var("SHACL_TEST").ok(); + + let cases = collect_tests(manifest).expect("manifest walk"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let mut per_category: BTreeMap = BTreeMap::new(); // (passed, total) + let mut failures: Vec<(String, String)> = Vec::new(); + let mut json_tests = Vec::new(); + let mut skipped = 0usize; + + for case in &cases { + if let Some(cat) = &category_filter { + if case.category != *cat { + skipped += 1; + continue; + } + } + if let Some(pat) = &test_filter { + if !case.name.contains(pat.as_str()) { + skipped += 1; + continue; + } + } + + let outcome = runtime.block_on(run_case(case)); + let entry = per_category.entry(case.category.clone()).or_default(); + entry.1 += 1; + + let (status, detail) = match &outcome { + Outcome::Pass => { + entry.0 += 1; + ("PASS", String::new()) + } + Outcome::Fail(reason) => ("FAIL", reason.clone()), + Outcome::Error(reason) => ("ERROR", reason.clone()), + }; + println!("{status:5} {}", case.name); + if !outcome.passed() { + println!(" {detail}"); + failures.push((case.name.clone(), detail.clone())); + } + json_tests.push(serde_json::json!({ + "name": case.name, + "category": case.category, + "approved": case.approved, + "status": status, + "detail": detail, + })); + } + + let total: usize = per_category.values().map(|(_, t)| t).sum(); + let passed: usize = per_category.values().map(|(p, _)| p).sum(); + + println!(); + println!("=== W3C SHACL Core — Per-Category Breakdown ==="); + for (cat, (p, t)) in &per_category { + println!(" {cat:20} {p:3}/{t:<3}"); + } + println!(); + println!( + "=== Test Summary === Total: {total} Passed: {passed} Failed: {} Skipped: {skipped} Rate: {:.1}%", + total - passed, + if total > 0 { + passed as f64 * 100.0 / total as f64 + } else { + 0.0 + } + ); + + if let Ok(path) = std::env::var("W3C_REPORT_JSON") { + let report = serde_json::json!({ + "total": total, + "passed": passed, + "failed": total - passed, + "pass_rate": format!("{:.1}%", if total > 0 { passed as f64 * 100.0 / total as f64 } else { 0.0 }), + "tests": json_tests, + }); + std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()) + .expect("write report"); + println!("JSON report written to {path}"); + } + + if std::env::var("SHACL_STRICT").is_ok() && !failures.is_empty() { + panic!("{} W3C SHACL test(s) failed", failures.len()); + } +}