diff --git a/fluree-db-api/src/query/builder.rs b/fluree-db-api/src/query/builder.rs index ff9c8bf9f6..b00ae6fde1 100644 --- a/fluree-db-api/src/query/builder.rs +++ b/fluree-db-api/src/query/builder.rs @@ -1024,7 +1024,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) { - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; Ok(result .format_async(view.as_graph_db_ref(), &format_config) .await?) @@ -1064,7 +1067,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) .ok_or_else(|| ApiError::query("No graph specified for formatting"))?; - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; Ok(result .format_async(view.as_graph_db_ref(), &format_config) .await?) @@ -1120,7 +1126,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) { - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; Ok(result .format_async(view.as_graph_db_ref(), &format_config) .await?) @@ -1165,7 +1174,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) { - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; crate::format::format_results_string_async( &result, &result.context, @@ -1211,7 +1223,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) .ok_or_else(|| ApiError::query("No graph specified for formatting"))?; - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; crate::format::format_results_string_async( &result, &result.context, @@ -1273,7 +1288,10 @@ impl<'a> FromQueryBuilder<'a> { .first() .or_else(|| spec.named_graphs.first()) { - let view = self.fluree.db(alias.identifier.as_str()).await?; + let view = self + .fluree + .db_or_graph_source(alias.identifier.as_str()) + .await?; crate::format::format_results_string_async( &result, &result.context, diff --git a/fluree-db-api/src/view/dataset_builder.rs b/fluree-db-api/src/view/dataset_builder.rs index 4e0eaaec29..f0dfbac278 100644 --- a/fluree-db-api/src/view/dataset_builder.rs +++ b/fluree-db-api/src/view/dataset_builder.rs @@ -278,23 +278,9 @@ impl Fluree { /// Resolve an identifier as a graph source, creating a minimal genesis context. async fn resolve_as_graph_source(&self, identifier: &str) -> Result { - let gs_id = fluree_db_core::normalize_ledger_id(identifier) - .unwrap_or_else(|_| identifier.to_string()); - let record = self - .nameservice() - .lookup_graph_source(&gs_id) - .await - .map_err(|e| ApiError::internal(e.to_string()))?; - if record.is_none() { - return Err(ApiError::NotFound(identifier.to_string())); - } - - let snapshot = fluree_db_core::LedgerSnapshot::genesis(&gs_id); - let state = - fluree_db_ledger::LedgerState::new(snapshot, fluree_db_novelty::Novelty::new(0)); - let mut db = GraphDb::from_ledger_state(&state); - db.graph_source_id = Some(gs_id.into()); - Ok(db) + self.resolve_graph_source(identifier) + .await? + .ok_or_else(|| ApiError::NotFound(identifier.to_string())) } } diff --git a/fluree-db-api/src/view/fluree_ext.rs b/fluree-db-api/src/view/fluree_ext.rs index 584238ec5e..f769e409d4 100644 --- a/fluree-db-api/src/view/fluree_ext.rs +++ b/fluree-db-api/src/view/fluree_ext.rs @@ -583,38 +583,54 @@ impl Fluree { // ============================================================================ impl Fluree { - /// Load a graph view, falling back to graph source resolution. + /// Like [`db()`](Self::db) but falls back to graph-source resolution when no + /// ledger exists under the alias. /// - /// Tries to load a ledger first. If not found, checks if the alias - /// matches a graph source (Iceberg/R2RML) and creates a minimal genesis - /// snapshot tagged with the graph source ID. The tag causes query - /// execution to auto-wrap patterns in `GRAPH { ... }`. - pub async fn load_graph_db_or_graph_source(&self, ledger_id: &str) -> Result { - match self.load_graph_db(ledger_id).await { - Ok(db) => Ok(db), - Err(ref e) if e.is_not_found() => { - let gs_id = fluree_db_core::normalize_ledger_id(ledger_id) - .unwrap_or_else(|_| ledger_id.to_string()); - - let _record = self - .nameservice() - .lookup_graph_source(&gs_id) - .await - .map_err(|e| ApiError::internal(e.to_string()))? - .ok_or_else(|| ApiError::NotFound(ledger_id.to_string()))?; - - let snapshot = fluree_db_core::LedgerSnapshot::genesis(&gs_id); - let state = fluree_db_ledger::LedgerState::new( - snapshot, - fluree_db_novelty::Novelty::new(0), - ); - let mut db = GraphDb::from_ledger_state(&state); - db.graph_source_id = Some(gs_id.into()); - Ok(db) - } + /// Used where an alias may name a registered graph source (Iceberg/R2RML, + /// BM25, vector, …) rather than a native ledger — e.g. resolving a view for + /// result formatting after a `FROM ` query. Native ledgers keep the + /// full `db()` resolution (graph-ref selection + config attachment). + pub async fn db_or_graph_source(&self, ledger_id: &str) -> Result { + match self.db(ledger_id).await { + Ok(view) => Ok(view), + Err(ref e) if e.is_not_found() => self + .resolve_graph_source(ledger_id) + .await? + .ok_or_else(|| ApiError::NotFound(ledger_id.to_string())), Err(e) => Err(e), } } + + /// Resolve `ledger_id` as a registered graph source, returning a minimal + /// genesis view tagged with the graph source id, or `Ok(None)` when no graph + /// source is registered under the alias. + /// + /// The `graph_source_id` tag causes query execution to auto-wrap patterns in + /// `GRAPH { ... }` so the configured provider resolves them. This is + /// the single source of truth for the genesis graph-source view; see + /// [`db_or_graph_source`](Self::db_or_graph_source) and the dataset path's + /// `resolve_as_graph_source`. + pub async fn resolve_graph_source(&self, ledger_id: &str) -> Result> { + let gs_id = fluree_db_core::normalize_ledger_id(ledger_id) + .unwrap_or_else(|_| ledger_id.to_string()); + + if self + .nameservice() + .lookup_graph_source(&gs_id) + .await + .map_err(|e| ApiError::internal(e.to_string()))? + .is_none() + { + return Ok(None); + } + + let snapshot = fluree_db_core::LedgerSnapshot::genesis(&gs_id); + let state = + fluree_db_ledger::LedgerState::new(snapshot, fluree_db_novelty::Novelty::new(0)); + let mut db = GraphDb::from_ledger_state(&state); + db.graph_source_id = Some(gs_id.into()); + Ok(Some(db)) + } } // ============================================================================ diff --git a/fluree-db-nameservice/src/file.rs b/fluree-db-nameservice/src/file.rs index eb3d946c7c..7b405a2a28 100644 --- a/fluree-db-nameservice/src/file.rs +++ b/fluree-db-nameservice/src/file.rs @@ -276,15 +276,29 @@ impl FileNameService { /// Load and merge main record with index file async fn load_record(&self, ledger_name: &str, branch: &str) -> Result> { + use fluree_db_core::StorageRead; let main_address = Self::ns_address(ledger_name, branch); let index_address = Self::index_address(ledger_name, branch); - // Read main record - let main_file: Option = self.read_json_from_address(&main_address).await?; + // Read the main record bytes once. + let main_bytes = match self.storage.read_bytes(&main_address).await { + Ok(bytes) => bytes, + Err(fluree_db_core::Error::NotFound(_)) => return Ok(None), + Err(e) => return Err(NameServiceError::from(e)), + }; - let Some(main) = main_file else { + // A graph-source record shares the `ns@v2/{name}/{branch}.json` address + // space with ledger records but uses a different schema (no `f:ledger`). + // Report it as "not a ledger" (Ok(None)) so single-alias resolution + // yields a clean not-found and callers fall back to graph-source + // resolution — instead of failing to deserialize NsFileV2 with a + // "missing field `f:ledger`" error. This is the single guard shared by + // all ledger read paths (`lookup`, `list_branches`, `all_records`). + if Self::is_graph_source_from_bytes(&main_bytes) { return Ok(None); - }; + } + + let main: NsFileV2 = serde_json::from_slice(&main_bytes)?; // Read index file (if exists) let index_file: Option = self.read_json_from_address(&index_address).await?; @@ -352,6 +366,15 @@ impl FileNameService { Ok(Self::is_graph_source_from_json(&parsed)) } + /// Check if raw JSON bytes represent a graph source record. Unparseable + /// bytes are treated as "not a graph source" so the caller surfaces the + /// underlying deserialization error for the concrete record type. + fn is_graph_source_from_bytes(bytes: &[u8]) -> bool { + serde_json::from_slice::(bytes) + .map(|v| Self::is_graph_source_from_json(&v)) + .unwrap_or(false) + } + /// Check if parsed JSON represents a graph source record (exact match). /// Matches `"f:"` compact prefixes and full IRIs. fn is_graph_source_from_json(parsed: &serde_json::Value) -> bool { @@ -436,6 +459,8 @@ impl FileNameService { impl crate::NameServiceLookup for FileNameService { async fn lookup(&self, ledger_id: &str) -> Result> { let (ledger_name, branch) = split_ledger_id(ledger_id)?; + // A graph-source record is not a ledger (#1369). `load_record` reports it + // as Ok(None) so the caller can fall back to the graph-source path. self.load_record(&ledger_name, &branch).await } @@ -449,10 +474,7 @@ impl crate::NameServiceLookup for FileNameService { .trim_end_matches(".json") .to_string(); - if self.is_graph_source_record(ledger_name, &branch).await? { - continue; - } - + // Graph-source records are skipped by `load_record` (returns Ok(None)). if let Ok(Some(record)) = self.load_record(ledger_name, &branch).await { if !record.retracted { records.push(record); @@ -480,10 +502,7 @@ impl crate::NameServiceLookup for FileNameService { continue; } - if self.is_graph_source_record(&parent, &file_stem).await? { - continue; - } - + // Graph-source records are skipped by `load_record` (returns Ok(None)). if let Ok(Some(record)) = self.load_record(&parent, &file_stem).await { records.push(record); } @@ -1773,6 +1792,22 @@ mod tests { // ========== Graph Source Tests ========== + #[tokio::test] + async fn lookup_skips_graph_source_records() { + // Regression for #1369: the ledger `lookup` must not deserialize a + // graph-source record as a ledger `NsFileV2` (which lacks `f:ledger`). + // Before the fix this returned a "missing field f:ledger" error. + let (_temp, ns) = setup().await; + ns.publish_graph_source("actor", "main", GraphSourceType::Bm25, r"{}", &[]) + .await + .unwrap(); + let record = ns + .lookup("actor:main") + .await + .expect("lookup of a graph-source alias must not error"); + assert!(record.is_none(), "a graph source is not a ledger record"); + } + #[tokio::test] async fn test_graph_source_publish_and_lookup() { let (_temp, ns) = setup().await; @@ -1933,6 +1968,32 @@ mod tests { assert!(result.is_none()); } + /// The graph-source skip is now a single guard fused into `load_record`, so + /// the listing paths (`list_branches` / `all_records`) must keep excluding + /// graph-source records without their own pre-check. A coexisting real ledger + /// still resolves (#1369). + #[tokio::test] + async fn test_listings_exclude_graph_source_records() { + let (_temp, ns) = setup().await; + + ns.publish_commit("realdb:main", 1, &test_cid("commit-1")) + .await + .unwrap(); + ns.publish_graph_source("gs", "main", GraphSourceType::Iceberg, "{}", &[]) + .await + .unwrap(); + + // The real ledger resolves; the graph-source alias is a clean not-found. + assert!(ns.lookup("realdb:main").await.unwrap().is_some()); + assert!(matches!(ns.lookup("gs:main").await, Ok(None))); + + // list_branches / all_records exclude the graph-source record. + assert!(ns.list_branches("gs").await.unwrap().is_empty()); + let all = ns.all_records().await.unwrap(); + assert_eq!(all.len(), 1, "all_records should list only the ledger"); + assert_eq!(all[0].ledger_id, "realdb:main"); + } + #[tokio::test] async fn test_graph_source_config_update_preserves_index() { let (_temp, ns) = setup().await; diff --git a/fluree-db-nameservice/src/storage_ns.rs b/fluree-db-nameservice/src/storage_ns.rs index c2b46ed490..a53d1778a1 100644 --- a/fluree-db-nameservice/src/storage_ns.rs +++ b/fluree-db-nameservice/src/storage_ns.rs @@ -331,12 +331,28 @@ where let main_key = self.ns_key(ledger_name, branch); let index_key = self.index_key(ledger_name, branch); - // Read main record - let main_file: Option = self.read_json(&main_key).await?; + // Read the main record bytes once. + let main_bytes = match self.storage.read_bytes(&main_key).await { + Ok(bytes) => bytes, + Err(CoreError::NotFound(_)) => return Ok(None), + Err(e) => { + return Err(NameServiceError::storage(format!( + "Failed to read {main_key}: {e}" + ))) + } + }; - let Some(main) = main_file else { + // A graph-source record shares the `ns@v2/{name}/{branch}.json` key space + // with ledger records but uses a different schema (no `f:ledger`). Report + // it as "not a ledger" (Ok(None)) so single-alias resolution yields a + // clean not-found and callers fall back to graph-source resolution — + // instead of failing to deserialize NsFileV2 with a "missing field + // `f:ledger`" error. Single guard shared by all ledger read paths. + if Self::is_graph_source_from_bytes(&main_bytes) { return Ok(None); - }; + } + + let main: NsFileV2 = serde_json::from_slice(&main_bytes)?; // Read index file (if exists) let index_file: Option = self.read_json(&index_key).await?; @@ -478,6 +494,8 @@ where { async fn lookup(&self, ledger_id: &str) -> Result> { let (ledger_name, branch) = split_ledger_id(ledger_id)?; + // A graph-source record is not a ledger (#1369). `load_record` reports it + // as Ok(None) so the caller can fall back to the graph-source path. self.load_record(&ledger_name, &branch).await } @@ -2289,4 +2307,46 @@ mod tests { ); assert_eq!(after_retract.payload.state, "retracted"); } + + /// Regression (#1369): a graph-source record shares the + /// `ns@v2/{name}/{branch}.json` key space with ledger records but uses a + /// different schema (no `f:ledger`). `lookup` must report it as a clean + /// not-found (`Ok(None)`) instead of failing to deserialize `NsFileV2` + /// ("missing field `f:ledger`"), so single-alias query/`use` resolution can + /// fall back to graph-source resolution. Mirrors the file-backend twin. + #[tokio::test] + async fn test_storage_ns_lookup_skips_graph_source_record() { + let ns = make_storage_ns(); + + publish_commit(&ns, "realdb:main", 1, &dummy_cid("commit-1")).await; + ns.publish_graph_source( + "gs", + "main", + GraphSourceType::Iceberg, + r#"{"catalog":"https://example.invalid","table":"ns.t"}"#, + &["realdb:main".to_string()], + ) + .await + .unwrap(); + + // The bug: lookup of a graph-source alias used to fail to deserialize the + // ledger `NsFileV2`. It must now be a clean not-found. + let result = ns.lookup("gs:main").await; + assert!( + matches!(result, Ok(None)), + "lookup of a graph-source alias should be Ok(None), got {result:?}" + ); + + // The regular ledger still resolves, and the type-aware resolver still + // classifies each correctly. + assert!(ns.lookup("realdb:main").await.unwrap().is_some()); + assert!(matches!( + ns.lookup_any("gs:main").await.unwrap(), + NsLookupResult::GraphSource(_) + )); + assert!(matches!( + ns.lookup_any("realdb:main").await.unwrap(), + NsLookupResult::Ledger(_) + )); + } } diff --git a/fluree-db-server/src/routes/query.rs b/fluree-db-server/src/routes/query.rs index a0cb211e98..767f8edaa8 100644 --- a/fluree-db-server/src/routes/query.rs +++ b/fluree-db-server/src/routes/query.rs @@ -1977,8 +1977,35 @@ async fn execute_query( return execute_query_proxy(state, ledger_id, query_json, &span).await; } - // Shared storage mode: use load_ledger_for_query with freshness checking - let ledger = load_ledger_for_query(state, ledger_id, &span).await?; + // Shared storage mode: use load_ledger_for_query with freshness checking. + // The alias may name a graph source (Iceberg/R2RML, BM25, …) rather than a + // ledger; those resolve through the connection/dataset path, which queries + // them via their source engine instead of loading a ledger (#1369). + let ledger = match load_ledger_for_query(state, ledger_id, &span).await { + Ok(l) => l, + Err(e) => { + // Non-not-found errors (real load failures) propagate unchanged. + if !matches!(&e, ServerError::Api(api) if api.is_not_found()) { + return Err(e); + } + // The alias may name a graph source (Iceberg/R2RML, BM25, …) rather + // than a ledger. A registered source runs through the dataset path + // (queried via its source engine); a delimited format on a real + // source gets the explicit format error (matching the SPARQL + // handler). A genuinely-missing ledger returns a clean NotFound + // (404 + NotFound span) instead of being misreported by that path. + if state.fluree.resolve_graph_source(ledger_id).await?.is_some() { + if let Some(fmt) = delimited { + return Err(ServerError::not_acceptable(format!( + "{} format not supported for graph source queries", + fmt.name().to_uppercase() + ))); + } + return execute_dataset_query(state, ledger_id, query_json, &span).await; + } + return Err(ServerError::Api(ApiError::NotFound(ledger_id.to_string()))); + } + }; let graph = GraphDb::from_ledger_state(&ledger); let fluree = &state.fluree; @@ -2490,8 +2517,20 @@ async fn execute_sparql_ledger( } // Ensure head is fresh in shared storage mode before time-travel view loading. + // The URL alias may name a graph source rather than a ledger; a graph + // source is a genesis view with no ledger head to refresh, so a clean + // not-found there is expected — let the dataset build resolve it (it + // already supports graph sources via `load_view_from_source`). A + // genuinely-missing ledger still propagates its not-found. if !state.config.is_proxy_storage_mode() { - let _ = load_ledger_for_query(state, ledger_id, &span).await?; + if let Err(e) = load_ledger_for_query(state, ledger_id, &span).await { + let is_not_found = matches!(&e, ServerError::Api(api) if api.is_not_found()); + if !(is_not_found + && state.fluree.resolve_graph_source(ledger_id).await?.is_some()) + { + return Err(e); + } + } } let spec = ledger_scoped_sparql_dataset_spec(ledger_id, dc)?; @@ -2676,12 +2715,59 @@ async fn execute_sparql_ledger( .into_response()); } - // Shared storage mode: use load_ledger_for_query with freshness checking - let ledger = load_ledger_for_query(state, ledger_id, &span) - .await - .inspect_err(|_| { - set_span_error_code(&span, "error:LedgerLoad"); - })?; + // Shared storage mode: use load_ledger_for_query with freshness checking. + // The alias may name a graph source (Iceberg/R2RML) rather than a ledger; + // on a clean not-found, resolve it through the graph-source-aware single + // target path (`graph().query()` auto-enables R2RML), which queries it via + // its source engine (#1369). Mirrors the JSON-LD `execute_query` -> + // `execute_dataset_query` fallback. Graph sources support JSON output only. + let ledger = match load_ledger_for_query(state, ledger_id, &span).await { + Ok(ledger) => ledger, + Err(e) => { + // Non-not-found errors (real load failures) keep the LedgerLoad tag. + if !matches!(&e, ServerError::Api(api) if api.is_not_found()) { + set_span_error_code(&span, "error:LedgerLoad"); + return Err(e); + } + // As in execute_query: resolve the alias as a graph source; only a + // registered source reports the JSON-only format errors before + // running via the R2RML-aware single-target path. A genuinely- + // missing ledger returns a clean NotFound. + if state.fluree.resolve_graph_source(ledger_id).await?.is_some() { + if wants_sparql_xml || wants_rdf_xml { + return Err(ServerError::not_acceptable( + "Only JSON output is supported for graph source queries".to_string(), + )); + } + if let Some(fmt) = delimited { + return Err(ServerError::not_acceptable(format!( + "{} format not supported for graph source queries", + fmt.name().to_uppercase() + ))); + } + let result = state + .fluree + .graph(ledger_id) + .query() + .sparql(sparql) + .format(json_fmt_config.clone()) + .execution_options(query_execution_options(state)) + .execute_formatted() + .await + .map_err(|e| { + set_span_error_code(&span, "error:QueryFailed"); + ServerError::Api(e) + })?; + tracing::info!(status = "success", graph_source = true); + return Ok(( + [(axum::http::header::CONTENT_TYPE, json_content_type)], + Json(result), + ) + .into_response()); + } + return Err(ServerError::Api(ApiError::NotFound(ledger_id.to_string()))); + } + }; let graph = attach_default_context_to_graph( state, ledger_id, diff --git a/fluree-db-server/tests/graph_source_format_gating.rs b/fluree-db-server/tests/graph_source_format_gating.rs new file mode 100644 index 0000000000..6e3e85cc30 --- /dev/null +++ b/fluree-db-server/tests/graph_source_format_gating.rs @@ -0,0 +1,243 @@ +//! HTTP-layer coverage for the single-target graph-source query fallback in +//! `execute_query` (JSON-LD) and `execute_sparql_ledger` (SPARQL). +//! +//! Two properties, previously inconsistent between the two handlers: +//! - A registered graph source requested in a delimited (CSV/TSV) format is +//! rejected with an explicit `406 "… format not supported for graph source +//! queries"`, not a misleading `404`. +//! - A genuinely-missing ledger keeps its `404 NotFound` (it is not a graph +//! source), rather than being reported as a format/dataset error. +//! +//! The graph source is registered with a bogus catalog: registration completes +//! before any catalog call, and these requests are rejected at format +//! negotiation before the source is ever scanned, so no live catalog is needed. +#![cfg(feature = "iceberg")] + +use axum::body::Body; +use fluree_db_api::R2rmlCreateConfig; +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; +use std::sync::Arc; +use tempfile::TempDir; +use tower::ServiceExt; + +const MAPPING_TTL: &str = r#" +@prefix rr: . +@prefix ex: . + + a rr:TriplesMap ; + rr:logicalTable [ rr:tableName "openflights.airlines" ] ; + rr:subjectMap [ + rr:template "http://example.org/airline/{id}" ; + rr:class ex:Airline + ] ; + rr:predicateObjectMap [ + rr:predicate ex:name ; + rr:objectMap [ rr:column "name" ] + ] . +"#; + +async fn state_with_graph_source() -> (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::new")); + + state + .fluree + .create_r2rml_graph_source( + R2rmlCreateConfig::new( + "gs", + "https://example.invalid", + "openflights.airlines", + MAPPING_TTL, + ) + .with_mapping_media_type("text/turtle"), + ) + .await + .expect("graph source registration should succeed"); + + (tmp, state) +} + +async fn body_text(resp: http::Response) -> (StatusCode, String) { + let status = resp.status(); + let bytes = resp.into_body().collect().await.expect("body").to_bytes(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// JSON-LD: a graph source requested as CSV must be a 406 with the explicit +/// graph-source format message — previously this path returned a 404 because +/// the fallback was gated on `delimited.is_none()`. +#[tokio::test] +async fn jsonld_graph_source_delimited_is_406_not_404() { + let (_tmp, state) = state_with_graph_source().await; + + let body = json!({ + "@context": {"ex": "http://example.org/"}, + "select": ["?s"], + "where": [["?s", "a", "ex:Airline"]] + }); + let resp = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/gs:main") + .header("content-type", "application/json") + .header("accept", "text/csv") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let (status, text) = body_text(resp).await; + assert_eq!( + status, + StatusCode::NOT_ACCEPTABLE, + "graph-source + CSV should be 406, got {status}: {text}" + ); + assert!( + text.contains("format not supported for graph source queries"), + "expected the explicit graph-source format message, got: {text}" + ); +} + +/// SPARQL sibling: same explicit 406 for a graph source requested as CSV. +#[tokio::test] +async fn sparql_graph_source_delimited_is_406_not_404() { + let (_tmp, state) = state_with_graph_source().await; + + let resp = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/gs:main") + .header("content-type", "application/sparql-query") + .header("accept", "text/csv") + .body(Body::from("SELECT ?s WHERE { ?s ?p ?o } LIMIT 1")) + .unwrap(), + ) + .await + .unwrap(); + + let (status, text) = body_text(resp).await; + assert_eq!( + status, + StatusCode::NOT_ACCEPTABLE, + "graph-source + CSV should be 406, got {status}: {text}" + ); + assert!( + text.contains("format not supported for graph source queries"), + "expected the explicit graph-source format message, got: {text}" + ); +} + +/// SPARQL `FROM ` on `POST /query/` where the alias is a graph +/// source: the dataset-clause branch's freshness preload must not block on the +/// alias not being a ledger. It should reach the dataset build and execute (the +/// bogus catalog then fails at scan time — proving resolution got that far, +/// rather than a `Ledger not found` from the preload). +#[tokio::test] +async fn sparql_from_same_graph_source_alias_resolves_past_preload() { + let (_tmp, state) = state_with_graph_source().await; + + let resp = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/gs:main") + .header("content-type", "application/sparql-query") + .header("accept", "application/json") + .body(Body::from( + "SELECT ?s FROM WHERE { ?s ?p ?o } LIMIT 1", + )) + .unwrap(), + ) + .await + .unwrap(); + + let (status, text) = body_text(resp).await; + assert_ne!( + status, + StatusCode::NOT_FOUND, + "graph-source alias with a self-referential FROM should resolve, got {status}: {text}" + ); + assert!( + !text.contains("Ledger not found"), + "must get past the ledger-only freshness preload, got: {text}" + ); + assert!( + !text.contains("Ledger mismatch"), + "a self-referential FROM must not trip the cross-target guard, got: {text}" + ); +} + +/// The cross-target guard still fires for a graph-source alias: `FROM` pointing +/// at a *different* target must be a `400 "Ledger mismatch"`. Reaching this +/// (rather than the preload's not-found) proves the preload was correctly +/// bypassed for the graph source. +#[tokio::test] +async fn sparql_from_other_target_on_graph_source_is_mismatch() { + let (_tmp, state) = state_with_graph_source().await; + + let resp = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/gs:main") + .header("content-type", "application/sparql-query") + .header("accept", "application/json") + .body(Body::from("SELECT ?s FROM WHERE { ?s ?p ?o }")) + .unwrap(), + ) + .await + .unwrap(); + + let (status, text) = body_text(resp).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "cross-target FROM should be a 400 mismatch, got {status}: {text}" + ); + assert!( + text.contains("Ledger mismatch"), + "expected the cross-target mismatch error, got: {text}" + ); +} + +/// A genuinely-missing ledger requested as CSV must keep its 404 — it is not a +/// graph source, so it must not borrow the graph-source format message. +#[tokio::test] +async fn jsonld_missing_ledger_delimited_is_404() { + let (_tmp, state) = state_with_graph_source().await; + + let body = json!({ "select": ["?s"], "where": [["?s", "?p", "?o"]] }); + let resp = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/doesnotexist:main") + .header("content-type", "application/json") + .header("accept", "text/csv") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let (status, text) = body_text(resp).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "a missing ledger should be 404, got {status}: {text}" + ); +} diff --git a/fluree-db-server/tests/integration.rs b/fluree-db-server/tests/integration.rs index 0c51c87510..8aa2ddd3af 100644 --- a/fluree-db-server/tests/integration.rs +++ b/fluree-db-server/tests/integration.rs @@ -28,6 +28,83 @@ async fn test_state() -> (TempDir, Arc) { (tmp, state) } +// Regression for #1369: querying a registered Iceberg/R2RML graph source by +// alias (SPARQL `POST /query/`, the `execute_sparql_ledger` path) must +// route to the graph-source engine, not load it as a ledger (which deserialized +// the graph-source nameservice record as `NsFileV2` and failed on the missing +// `f:ledger` field with a 500) nor 404 as a missing ledger. The bogus catalog +// (`s3://nonexistent`) means execution can't return rows, but the response must +// prove the alias resolved AND reached the source engine: the error is the +// engine's own "Iceberg graph source config" failure, never `f:ledger` and +// never a not-found. (A readable in-tree source — which needs Iceberg test +// infra — would additionally assert returned rows.) +#[cfg(feature = "iceberg")] +#[tokio::test] +async fn graph_source_alias_query_does_not_deserialize_as_ledger() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cfg = ServerConfig { + cors_enabled: false, + indexing_enabled: false, + storage_path: Some(tmp.path().to_path_buf()), + query_timeout_ms: 3000, + ..Default::default() + }; + let telemetry = TelemetryConfig::with_server_config(&cfg); + let state = Arc::new(AppState::new(cfg, telemetry).await.expect("AppState::new")); + + let mapping = concat!( + "@base .\n", + "@prefix rr: .\n", + "@prefix ex: .\n", + "<#M> a rr:TriplesMap ; rr:logicalTable [ rr:tableName \"actor\" ] ; ", + "rr:subjectMap [ rr:template \"https://ex/{id}\" ] ; ", + "rr:predicateObjectMap [ rr:predicate ex:name ; rr:objectMap [ rr:column \"as_name\" ] ] ." + ); + let create = + fluree_db_api::R2rmlCreateConfig::new_direct("actor", "s3://nonexistent/actor", mapping) + .with_mapping_media_type("text/turtle"); + state + .fluree + .create_r2rml_graph_source(create) + .await + .expect("register graph source"); + + let resp = build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/query/actor:main") + .header("content-type", "application/sparql-query") + .body(Body::from("SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 1")) + .unwrap(), + ) + .await + .unwrap(); + let (status, body) = json_body(resp).await; + let err = body + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("") + .to_lowercase(); + // Must NOT be the ledger-deserialization failure... + assert!( + !err.contains("f:ledger") && !err.contains("missing field"), + "alias query must route to the graph source, not deserialize the record as a ledger; status={status} body={body}" + ); + // ...and must NOT be a not-found: the alias resolved and routed to the + // R2RML/Iceberg engine, which then fails on the bogus catalog config. This + // is the assertion the prior "not f:ledger" check missed (a 404 passed it). + assert_ne!( + status, + StatusCode::NOT_FOUND, + "graph-source alias should resolve and route to the engine, not 404; body={body}" + ); + assert!( + err.contains("graph source") || err.contains("iceberg"), + "alias query should reach the graph-source engine (expected an Iceberg/graph-source config error); status={status} body={body}" + ); +} + async fn json_body(resp: http::Response) -> (StatusCode, JsonValue) { let status = resp.status(); let bytes = resp