Skip to content

Commit 3302f2f

Browse files
authored
feat(composer): cross-graph queries + Composer UX & view descriptions (#1031)
* feat(composer): cross-graph discovery/follow, view descriptions, table UX Knowledge-graph Composer (graph explore) evolutions: - Cross-graph column discovery: resolve a relation's triple and the related resource's type in their own GRAPH clauses spanning the workspace's graphs, decoupled from the grain-type anchor — so relations into/from other named graphs (e.g. Chunk <- extracted_from_chunk - ExtractedItem, asserted in a different graph than Chunk) are discoverable. Service passes the owned graph set as the type-resolution scope. - Cross-graph follow: re-anchor onto the followed class and widen the query scope to the workspace's graphs, so a grain that lives in another graph returns rows + columns instead of an empty table. - View descriptions: persist a free-text "what does this view answer?" on saved views (migration 0034 + model/service/endpoint/client) — previously the field was accepted on the wire but silently dropped. Edit it in the Save dialog and via an inline bar when on a loaded view. - Results table UX: column-filter popover no longer closes when you scroll inside it; right-click a cell to "Filter by this value" (same canonical value as ticking the facet checkbox); export the full view (all pages) to CSV. - Inspect drawer: group relations into Outgoing/Incoming sections. - Docs: spec for the upcoming union-graph (FROM) query-engine rewrite that makes cross-graph *columns* (not just discovery/follow) return data. Tests: backend graph/query suite (104) + column discovery (7) pass. Frontend type-check pending (run `npm run typecheck` in apps/web). * feat(composer): scope cross-graph follow to the target's graph Replace the "follow widens to every workspace graph" behaviour with precise scoping. Column discovery now tags each target class with a named graph it lives in (SAMPLE(?tg)/SAMPLE(?sg) over the type-resolution graphs), surfaced through TargetClassData.graph -> TargetClassModel.graph -> TS TargetClass.graph. On Follow, the builder adds exactly that one graph to the selection, so a cross-graph follow resolves without over-widening; a same-graph follow leaves scope unchanged. Also refreshes the union-graph engine spec: graph scope is picker-driven (FROM over spec.graph_uris), no "span all graphs" default needed. Verified on live Fuseki: extracted_from_chunk -> ExtractedItem reports graph=extractions; lexical/embedding occurrences report papers. Tests: column discovery (7) + graph/query suite (104) pass. * feat(composer): union-graph (FROM) compiler for cross-graph queries Replace the single `VALUES ?g { … } GRAPH ?g { … }` wrapper with a SPARQL dataset clause — one `FROM <g>` per spec graph. The query's default graph becomes the RDF-merge (union) of the spec's graphs, so plain patterns join ACROSS named graphs while scope stays exactly the workspace graphs selected. Sub-SELECTs (measures, to-many collapse) inherit the outer FROM dataset and drop their own GRAPH wrapper. - add _from_clause(spec); drop the graphs param + GRAPH wrapper from _agg_subquery / _measure_fragment / _collapse_fragment - convert compile_list (page+count), compile_aggregate (page+count), compile_facet to FROM-union; remove dead _graph_values - update golden compiler_test expectations; add a structural unit test (FROM present, no GRAPH/VALUES ?g, collapse sub-SELECT self-contained) - add a 2-graph integration test (cross-graph collapse join) Same-graph queries are unchanged (union of one graph == that graph). Verified read-only on live Fuseki: cross-graph chunk->extracted_text returns data, cross-graph count == same-graph count (5826), and the GROUP_CONCAT sub-SELECT resolves under FROM (sub-SELECT dataset inheritance holds on TDB2). Unit suite: 105 pass. * feat(composer): scope cross-graph columns + tests for graph-scope union Symmetric with the Follow scoping: when adding a column through an expanded cross-graph relation, add the relation's target-class graph (TargetClass.graph) to the query scope so the column resolves under the FROM-union instead of rendering blank. - explore-state: addColumn action takes optional graphUris and unions it into scope; simplify the union guard; document that scope only grows (the picker is the manual control to narrow — removeColumn never auto-shrinks since other columns/filters may rely on the graph). - BuilderPanel: onAddExpanded passes rel.target_classes[0].graph. - tests: cover follow/addColumn graph-scope union, same-graph no-op, dedup, and specFromState/stateFromSpec round-trip preservation. - spec: note the remaining auto-scoping gaps (aggregate dimensions/measures and branch filters crossing graphs) as known limitations; the picker covers them. Adversarial review of the change set found the compiler rewrite solid; the flagged "unsafe [0] indexing" is a non-issue (noUncheckedIndexedAccess is off) and ancestor-column scope is already covered by the grow-only union. * revert(composer): drop FROM-union compiler, keep GRAPH ?g Reverts the compiler rewrite (d86346e) after benchmarking it on the real ~240k-triple Fuseki: the FROM-union dataset is 3.2x slower on cross-graph pages (212ms -> 686ms). Root cause is the FROM mechanism on TDB2, not the multi-graph union — even a single-graph subquery is ~2x slower as `FROM <g>` (931ms) than `GRAPH <g>` (429ms), because TDB2 serves named graphs from its quad index but must dynamically materialize the FROM-defined default graph per pattern. The FROM rewrite bought nothing for the real use case: with single_valued_predicates empty, every path column compiles to a collapse subquery whose far-graph patterns are same-graph, so `GRAPH ?g` already resolves them once the graph is in scope. Cross-graph support is delivered by the discovery + graph-scoping fixes (kept), which are engine-independent. `GRAPH ?g` needs neither FROM nor Fuseki's tdb2:unionDefaultGraph. - restore compiler.py / compiler_test.py / compiler_integration_test.py to the pre-rewrite GRAPH ?g version (graph/query suite: 104 pass) - rewrite the engine doc as a decision record (why GRAPH ?g; FROM benchmark + root cause; per-pattern GRAPH ?gN as the future path for true multi-hop joins)
1 parent e58917a commit 3302f2f

21 files changed

Lines changed: 680 additions & 78 deletions

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/api/endpoints/view.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class UpdateGraphView(BaseModel):
8181

8282
workspace_id: str = Field(..., min_length=1, max_length=100)
8383
name: str | None = Field(default=None, min_length=1, max_length=200)
84+
description: str | None = Field(default=None, max_length=5000)
8485
path: str | None = Field(default=None, max_length=1024)
8586
state: dict[str, Any] | None = None
8687
visibility: str | None = Field(default=None, pattern="^(workspace)$")
@@ -287,6 +288,7 @@ async def update_view(
287288
visibility=payload.visibility,
288289
view_type=payload.view_type,
289290
kind=payload.kind,
291+
description=payload.description,
290292
)
291293
except ViewNotFoundError as exc:
292294
raise HTTPException(status_code=404, detail=str(exc)) from exc

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ class GraphViewModel(Base):
496496
String, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
497497
)
498498
name = Column(String(200), nullable=False)
499+
description = Column(Text, nullable=True) # free-text "what does this view answer?"
499500
view_type = Column(String(100), nullable=False, default="network")
500501
kind = Column(String(50), nullable=False, default="network")
501502
visibility = Column(String(20), nullable=False, default="workspace")

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/graph/query/adapters/primary/graph_query__primary_adapter__schemas.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ class TargetClassModel(BaseModel):
344344
uri: str
345345
label: str
346346
instance_count: int
347+
graph: str = "" # a named graph the target class's instances live in
347348

348349

349350
class DiscoveredColumnModel(BaseModel):
@@ -376,7 +377,7 @@ def from_domain(cls, class_uris: list[str], cols: tuple) -> GraphColumnsResponse
376377
source=c.source, instance_count=c.instance_count, is_functional=c.is_functional,
377378
facetable=c.facetable,
378379
target_classes=[
379-
TargetClassModel(uri=t.uri, label=t.label, instance_count=t.instance_count)
380+
TargetClassModel(uri=t.uri, label=t.label, instance_count=t.instance_count, graph=t.graph)
380381
for t in c.target_classes
381382
],
382383
)

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/graph/query/column_discovery.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,19 @@ def discover_columns(
7373
graph_uris: list[str],
7474
class_uris: list[str],
7575
schema_graph: str = SCHEMA_GRAPH,
76+
type_graph_uris: list[str] | None = None,
7677
) -> tuple[DiscoveredColumn, ...]:
7778
graphs = " ".join(sparql_iri(g) for g in graph_uris)
7879
classes = " ".join(sparql_iri(c) for c in class_uris)
80+
# A relation may be asserted in — and point INTO — a DIFFERENT named graph than the grain
81+
# (cross-graph relations). E.g. ``ExtractedItem --extracted_from_chunk--> Chunk`` lives in
82+
# the *extractions* graph while ``Chunk`` lives in *papers*; from Chunk this is an incoming
83+
# relation whose triple isn't in Chunk's graph at all. So the relation triple ``?s ?p ?o``
84+
# (GRAPH ?rg) and the related resource's type ``?o a ?tc`` / ``?s a ?sc`` (GRAPH ?tg/?sg)
85+
# are resolved in their own graph clauses spanning ``type_graph_uris`` (default: the grain's
86+
# graphs), decoupled from the grain-type anchor ``GRAPH ?g {{ ?s a ?cls }}``. The grain
87+
# anchor stays scoped to ``graph_uris`` so the grain itself is still the selected one.
88+
type_graphs = " ".join(sparql_iri(g) for g in (type_graph_uris or graph_uris))
7989

8090
# ── (A) data side — datatype properties (literal objects) ─────────────────
8191
dt_rows = store.select(
@@ -88,41 +98,57 @@ def discover_columns(
8898
"""
8999
)
90100
# ── (B) data side — relations (IRI objects) ───────────────────────────────
101+
# The relation triple is resolved in GRAPH ?rg (type_graphs), decoupled from the grain
102+
# anchor, so a relation asserted in another named graph still surfaces.
91103
rel_rows = store.select(
92104
f"""
93105
SELECT ?p (COUNT(DISTINCT ?s) AS ?subjects) (COUNT(?o) AS ?objs)
94-
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} GRAPH ?g {{
95-
?s a ?cls . ?s ?p ?o . FILTER(isIRI(?o)) FILTER(?p != {sparql_iri(_RDF_TYPE)})
96-
}} }} GROUP BY ?p
106+
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} VALUES ?rg {{ {type_graphs} }}
107+
GRAPH ?g {{ ?s a ?cls }}
108+
GRAPH ?rg {{ ?s ?p ?o . FILTER(isIRI(?o)) FILTER(?p != {sparql_iri(_RDF_TYPE)}) }}
109+
}} GROUP BY ?p
97110
"""
98111
)
99112
# ── (B-tc) relation target classes ────────────────────────────────────────
113+
# ``?o a ?tc`` resolved in its own GRAPH ?tg (type_graphs) so a relation into another named
114+
# graph still surfaces its target class.
100115
tc_rows = store.select(
101116
f"""
102-
SELECT ?p ?tc (COUNT(DISTINCT ?s) AS ?subjects)
103-
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} GRAPH ?g {{
104-
?s a ?cls . ?s ?p ?o . FILTER(isIRI(?o)) FILTER(?p != {sparql_iri(_RDF_TYPE)})
105-
?o a ?tc . FILTER(isIRI(?tc)) FILTER(?tc != {sparql_iri(_OWL_NAMED_INDIVIDUAL)})
106-
}} }} GROUP BY ?p ?tc
117+
SELECT ?p ?tc (COUNT(DISTINCT ?s) AS ?subjects) (SAMPLE(?tg) AS ?tgraph)
118+
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} VALUES ?rg {{ {type_graphs} }}
119+
GRAPH ?g {{ ?s a ?cls }}
120+
GRAPH ?rg {{ ?s ?p ?o . FILTER(isIRI(?o)) FILTER(?p != {sparql_iri(_RDF_TYPE)}) }}
121+
VALUES ?tg {{ {type_graphs} }}
122+
GRAPH ?tg {{ ?o a ?tc . }}
123+
FILTER(isIRI(?tc)) FILTER(?tc != {sparql_iri(_OWL_NAMED_INDIVIDUAL)})
124+
}} GROUP BY ?p ?tc
107125
"""
108126
)
109127
# ── (B-in) data side — INCOMING relations (grain is the object: ?s ?p ?grain) ──
128+
# The relation triple lives in GRAPH ?rg (type_graphs), NOT the grain's graph — so an
129+
# inbound relation asserted in another named graph (the common cross-graph case) is found.
110130
in_rel_rows = store.select(
111131
f"""
112132
SELECT ?p (COUNT(DISTINCT ?o) AS ?objects)
113-
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} GRAPH ?g {{
114-
?o a ?cls . ?s ?p ?o . FILTER(isIRI(?s)) FILTER(?p != {sparql_iri(_RDF_TYPE)})
115-
}} }} GROUP BY ?p
133+
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} VALUES ?rg {{ {type_graphs} }}
134+
GRAPH ?g {{ ?o a ?cls }}
135+
GRAPH ?rg {{ ?s ?p ?o . FILTER(isIRI(?s)) FILTER(?p != {sparql_iri(_RDF_TYPE)}) }}
136+
}} GROUP BY ?p
116137
"""
117138
)
118139
# ── (B-in-tc) incoming relation source classes (the entity on the other end) ──
140+
# Relation in GRAPH ?rg, source type ``?s a ?sc`` in GRAPH ?sg — both over type_graphs — so
141+
# an inbound relation FROM a resource in another named graph still surfaces its source class.
119142
in_tc_rows = store.select(
120143
f"""
121-
SELECT ?p ?sc (COUNT(DISTINCT ?o) AS ?objects)
122-
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} GRAPH ?g {{
123-
?o a ?cls . ?s ?p ?o . FILTER(isIRI(?s)) FILTER(?p != {sparql_iri(_RDF_TYPE)})
124-
?s a ?sc . FILTER(isIRI(?sc)) FILTER(?sc != {sparql_iri(_OWL_NAMED_INDIVIDUAL)})
125-
}} }} GROUP BY ?p ?sc
144+
SELECT ?p ?sc (COUNT(DISTINCT ?o) AS ?objects) (SAMPLE(?sg) AS ?sgraph)
145+
WHERE {{ VALUES ?g {{ {graphs} }} VALUES ?cls {{ {classes} }} VALUES ?rg {{ {type_graphs} }}
146+
GRAPH ?g {{ ?o a ?cls }}
147+
GRAPH ?rg {{ ?s ?p ?o . FILTER(isIRI(?s)) FILTER(?p != {sparql_iri(_RDF_TYPE)}) }}
148+
VALUES ?sg {{ {type_graphs} }}
149+
GRAPH ?sg {{ ?s a ?sc . }}
150+
FILTER(isIRI(?sc)) FILTER(?sc != {sparql_iri(_OWL_NAMED_INDIVIDUAL)})
151+
}} GROUP BY ?p ?sc
126152
"""
127153
)
128154
# ── (C) ontology side — declared props for the class (incl. inherited) ────
@@ -141,26 +167,34 @@ def discover_columns(
141167
"""
142168
)
143169

144-
# target classes per predicate (out direction)
170+
# target classes per predicate (out direction); ?tgraph = a graph the target type lives in.
145171
targets: dict[str, list[TargetClassData]] = {}
146172
for r in tc_rows:
147173
p = r["p"].value
148174
tc = r.get("tc")
149175
if tc is None:
150176
continue
177+
tg = r.get("tgraph")
151178
targets.setdefault(p, []).append(
152-
TargetClassData(uri=tc.value, label=_fragment(tc.value), instance_count=_int(r, "subjects"))
179+
TargetClassData(
180+
uri=tc.value, label=_fragment(tc.value), instance_count=_int(r, "subjects"),
181+
graph=tg.value if tg is not None else "",
182+
)
153183
)
154184

155-
# source classes per INCOMING predicate (the class reached by going "in")
185+
# source classes per INCOMING predicate (the class reached by going "in"); ?sgraph = its graph.
156186
in_targets: dict[str, list[TargetClassData]] = {}
157187
for r in in_tc_rows:
158188
p = r["p"].value
159189
sc = r.get("sc")
160190
if sc is None:
161191
continue
192+
sg = r.get("sgraph")
162193
in_targets.setdefault(p, []).append(
163-
TargetClassData(uri=sc.value, label=_fragment(sc.value), instance_count=_int(r, "objects"))
194+
TargetClassData(
195+
uri=sc.value, label=_fragment(sc.value), instance_count=_int(r, "objects"),
196+
graph=sg.value if sg is not None else "",
197+
)
164198
)
165199

166200
# ontology declarations keyed by predicate

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/graph/query/column_discovery_test.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,82 @@ def test_non_functional_property_detected() -> None:
111111
store = _RoutedStore(dt=[_row(p=DOC + "tag", subjects=4, objs=12, distinct_objs=6, dt=XSD + "string")])
112112
col = discover_columns(store, graph_uris=["g"], class_uris=[DOC + "Item"])[0]
113113
assert col.is_functional is False
114+
115+
116+
class _CapturingStore(_RoutedStore):
117+
"""Records the SPARQL of the target-class queries so we can assert their graph scoping."""
118+
119+
def __init__(self, **kw) -> None:
120+
super().__init__(**kw)
121+
self.seen: dict[str, str] = {}
122+
123+
def select(self, sparql: str):
124+
if "?sc" in sparql:
125+
self.seen["in_tc"] = sparql
126+
elif "?tc" in sparql:
127+
self.seen["tc"] = sparql
128+
return super().select(sparql)
129+
130+
131+
def test_outgoing_target_class_resolved_cross_graph() -> None:
132+
# A relation whose target lives in a SECOND graph: the target-class lookup must range over
133+
# type_graph_uris in its OWN graph clause, while the grain anchor stays scoped to its graph.
134+
store = _CapturingStore(
135+
rel=[_row(p=DOC + "extracted_by", subjects=2, objs=2)],
136+
tc=[_row(p=DOC + "extracted_by", tc=DOC + "Extraction", subjects=2, tgraph="http://g/B")],
137+
)
138+
cols = {
139+
c.id: c
140+
for c in discover_columns(
141+
store,
142+
graph_uris=["http://g/A"],
143+
class_uris=[DOC + "Item"],
144+
type_graph_uris=["http://g/A", "http://g/B"],
145+
)
146+
}
147+
tc_sparql = store.seen["tc"]
148+
# Relation (?rg) and target type (?tg) use dedicated graph variables spanning both graphs.
149+
assert "?tg" in tc_sparql and "?rg" in tc_sparql
150+
assert "http://g/B" in tc_sparql
151+
# The grain anchor stays scoped to the requested graph only (not widened to g/B).
152+
assert "VALUES ?g { <http://g/A> }" in tc_sparql
153+
# End-to-end: the cross-graph target class is surfaced on the relation column, tagged with
154+
# the graph it lives in (so the UI can scope a follow to exactly that graph).
155+
tcs = cols["extracted_by"].target_classes
156+
assert [t.uri for t in tcs] == [DOC + "Extraction"]
157+
assert tcs[0].graph == "http://g/B"
158+
159+
160+
def test_incoming_source_class_resolved_cross_graph() -> None:
161+
store = _CapturingStore(
162+
in_rel=[_row(p=DOC + "has_item", objects=3)],
163+
in_tc=[_row(p=DOC + "has_item", sc=DOC + "Chunk", objects=3, sgraph="http://g/B")],
164+
)
165+
cols = {
166+
c.id: c
167+
for c in discover_columns(
168+
store,
169+
graph_uris=["http://g/A"],
170+
class_uris=[DOC + "Item"],
171+
type_graph_uris=["http://g/A", "http://g/B"],
172+
)
173+
}
174+
in_tc_sparql = store.seen["in_tc"]
175+
# Relation (?rg) and source type (?sg) span both graphs; the grain anchor stays scoped to g/A.
176+
assert "?sg" in in_tc_sparql and "?rg" in in_tc_sparql
177+
assert "http://g/B" in in_tc_sparql
178+
assert "VALUES ?g { <http://g/A> }" in in_tc_sparql
179+
in_tcs = cols["has_item__in"].target_classes
180+
assert [t.uri for t in in_tcs] == [DOC + "Chunk"]
181+
assert in_tcs[0].graph == "http://g/B"
182+
183+
184+
def test_type_graphs_default_to_grain_graphs() -> None:
185+
# With no type_graph_uris, the type lookup is scoped to the grain graphs (no cross-graph
186+
# widening) — preserving the original single-graph behavior.
187+
store = _CapturingStore(
188+
rel=[_row(p=DOC + "rel", subjects=1, objs=1)],
189+
tc=[_row(p=DOC + "rel", tc=DOC + "Other", subjects=1)],
190+
)
191+
discover_columns(store, graph_uris=["http://g/A"], class_uris=[DOC + "Item"])
192+
assert "VALUES ?tg { <http://g/A> }" in store.seen["tc"]

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/graph/query/query__schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ class TargetClassData:
302302
uri: str
303303
label: str
304304
instance_count: int
305+
# The named graph an instance of this target class was found in. Lets the UI add exactly
306+
# that graph to the query scope when following a (possibly cross-graph) relation into it.
307+
graph: str = ""
305308

306309

307310
@dataclass(frozen=True)

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/graph/query/service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,16 @@ async def discover_columns(
187187
self._check_ownership(tuple(graph_uris), owned)
188188
if not class_uris:
189189
raise GraphQuerySpecError("at least one class_uri is required")
190+
# Resolve related-resource types across ALL of the workspace's graphs (plus the
191+
# requested ones), so a relation into another named graph still surfaces its target
192+
# class — without widening which graphs the grain's own columns are sampled from.
193+
type_graph_uris = sorted(set(owned) | set(graph_uris))
190194
return await asyncio.to_thread(
191-
_discover_columns, self._store, graph_uris=graph_uris, class_uris=class_uris
195+
_discover_columns,
196+
self._store,
197+
graph_uris=graph_uris,
198+
class_uris=class_uris,
199+
type_graph_uris=type_graph_uris,
192200
)
193201

194202
async def search_entities(

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/services/view/service.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def _model_to_dict(model: GraphViewModel, workspace_id: str) -> dict[str, Any]:
9696
"uri": f"/api/view/{model.id}",
9797
"label": model.name,
9898
"name": model.name,
99-
"description": None,
99+
"description": getattr(model, "description", None),
100100
"view_type": model.view_type,
101101
"kind": model.kind,
102102
"visibility": model.visibility,
@@ -502,8 +502,13 @@ async def update_view(
502502
visibility: str | None = None,
503503
view_type: str | None = None,
504504
kind: str | None = None,
505+
description: str | None = None,
505506
) -> dict[str, Any]:
506-
"""Partial update — rename / move / replace state / change visibility. Preserves id+created_at."""
507+
"""Partial update — rename / move / replace state / change visibility / description.
508+
509+
``description`` is partial too: ``None`` leaves it untouched; pass an empty string to
510+
clear it (stored as NULL).
511+
"""
507512
if self._db is None:
508513
raise ViewServiceUnavailableError("Database session is not available")
509514
result = await self._db.execute(
@@ -520,6 +525,8 @@ async def update_view(
520525
if not name.strip():
521526
raise ValueError("View name cannot be empty")
522527
model.name = name.strip()
528+
if description is not None:
529+
model.description = description.strip() or None
523530
if path is not None:
524531
model.path = _normalize_path(path)
525532
if visibility is not None:
@@ -633,7 +640,7 @@ async def create_view(
633640
filters: list[dict[str, str | None]] | None = None,
634641
path: str | None = None,
635642
) -> dict[str, Any]:
636-
_ = description, graph_names, filters
643+
_ = graph_names, filters
637644
if self._db is None:
638645
raise ViewServiceUnavailableError("Database session is not available")
639646
if not name.strip():
@@ -648,10 +655,12 @@ async def create_view(
648655
raise ValueError("A query view requires a compiled spec in state")
649656

650657
view_id = f"view-{uuid4().hex[:12]}"
658+
clean_description = description.strip() if description and description.strip() else None
651659
model = GraphViewModel(
652660
id=view_id,
653661
workspace_id=workspace_id,
654662
name=name.strip(),
663+
description=clean_description,
655664
view_type=(view_type or "Unknown").strip() or "Unknown",
656665
kind=kind or "network",
657666
visibility=visibility or "workspace",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Migration: Add a free-text description to graph views (explains what a view answers)
2+
3+
ALTER TABLE graph_views
4+
ADD COLUMN IF NOT EXISTS description TEXT;
5+
6+
COMMENT ON COLUMN graph_views.description IS
7+
'Free-text description of what the saved view is trying to answer. NULL = none.';

libs/naas-abi/naas_abi/apps/nexus/apps/web/src/components/graph/explore/BuilderPanel.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,16 @@ export function BuilderPanel({
255255
disabled={!grainClass}
256256
onAdd={(dc) => dispatch({ type: 'addColumn', column: discoveredToColumn(dc) })}
257257
loadFields={loadFields}
258-
onAddExpanded={(rel, field) => dispatch({ type: 'addColumn', column: expandedColumn(rel, field) })}
258+
onAddExpanded={(rel, field) => {
259+
// The field lives on the relation's target class — add that class's graph to the
260+
// scope so a cross-graph column resolves (matches the Follow scoping).
261+
const targetGraph = rel.target_classes[0]?.graph
262+
dispatch({
263+
type: 'addColumn',
264+
column: expandedColumn(rel, field),
265+
graphUris: targetGraph ? [targetGraph] : [],
266+
})
267+
}}
259268
ancestors={ancestors}
260269
onAddAncestor={(anc, field) =>
261270
dispatch({ type: 'addColumn', column: columnThroughPath(field, anc.inversePath, anc.label) })
@@ -272,6 +281,9 @@ export function BuilderPanel({
272281
via: { predicate: dc.predicate_uri, direction: dc.direction, label: dc.label },
273282
targetClassUri: target.uri,
274283
targetClassLabel: target.label,
284+
// Add exactly the graph the followed class lives in (cross-graph follow), so the
285+
// new grain resolves without over-widening the scope to every graph.
286+
graphUris: target.graph ? [target.graph] : [],
275287
})
276288
}
277289
/>

0 commit comments

Comments
 (0)