Skip to content
30 changes: 24 additions & 6 deletions fluree-db-api/src/query/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand Down Expand Up @@ -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?)
Expand Down Expand Up @@ -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?)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 3 additions & 17 deletions fluree-db-api/src/view/dataset_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphDb> {
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()))
}
}

Expand Down
72 changes: 44 additions & 28 deletions fluree-db-api/src/view/fluree_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gs_id> { ... }`.
pub async fn load_graph_db_or_graph_source(&self, ledger_id: &str) -> Result<GraphDb> {
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 <alias>` 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<GraphDb> {
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 <gs_id> { ... }` 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<Option<GraphDb>> {
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))
}
}

// ============================================================================
Expand Down
85 changes: 73 additions & 12 deletions fluree-db-nameservice/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<NsRecord>> {
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<NsFileV2> = 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<NsIndexFileV2> = self.read_json_from_address(&index_address).await?;
Expand Down Expand Up @@ -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::<serde_json::Value>(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 {
Expand Down Expand Up @@ -436,6 +459,8 @@ impl FileNameService {
impl crate::NameServiceLookup for FileNameService {
async fn lookup(&self, ledger_id: &str) -> Result<Option<NsRecord>> {
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
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading