diff --git a/agents/drivers/nebula-go/main_test.go b/agents/drivers/nebula-go/main_test.go index c4806444cb..c32e9963f5 100644 --- a/agents/drivers/nebula-go/main_test.go +++ b/agents/drivers/nebula-go/main_test.go @@ -111,6 +111,48 @@ func TestReadRowsPreservesScalarAndGraphValues(t *testing.T) { } } +func TestReadRowsPreservesGraphIdentityAndProperties(t *testing.T) { + large := int64(9007199254740993) + rank := wire.EdgeRanking(7) + src := &wire.Value{SVal: []byte("person:1")} + dst := &wire.Value{SVal: []byte("company:1")} + person := &wire.Vertex{Vid: src, Tags: []*wire.Tag{{Name: []byte("Person"), Props: map[string]*wire.Value{ + "name": {SVal: []byte("Ada")}, "visits": {IVal: &large}, + }}}} + company := &wire.Vertex{Vid: dst, Tags: []*wire.Tag{{Name: []byte("Company")}}} + edge := &wire.Edge{Src: src, Dst: dst, Type: 1, Name: []byte("WORK_IN"), Ranking: rank, Props: map[string]*wire.Value{"since": {IVal: &large}}} + path := &wire.Path{Src: person, Steps: []*wire.Step{{Dst: company, Type: 1, Name: []byte("WORK_IN"), Ranking: rank, Props: edge.Props}}} + reversed := &wire.Edge{Src: src, Dst: dst, Type: -1, Name: []byte("WORK_IN"), Ranking: rank} + result := resultSet(t, []string{"vertex", "edge", "path", "items", "reverse_edge"}, [][]*wire.Value{{ + {VVal: person}, {EVal: edge}, {PVal: path}, {LVal: &wire.NList{Values: []*wire.Value{{VVal: person}, {EVal: edge}}}}, {EVal: reversed}, + }}) + rows, _, err := readRows(result, 10) + if err != nil { + t.Fatal(err) + } + vertex := rows[0][0].(*graphCell) + if vertex.Kind != "vertex" || vertex.Nodes[0].VID.Value != "person:1" || vertex.Nodes[0].Properties[1].Value != "9007199254740993" { + t.Fatalf("vertex lost identity or large integer: %#v", vertex) + } + relationship := rows[0][1].(*graphCell) + if relationship.Kind != "edge" || relationship.Edges[0].SourceVID.Value != "person:1" || relationship.Edges[0].TargetVID.Value != "company:1" || relationship.Edges[0].Rank != "7" { + t.Fatalf("edge lost direction or rank: %#v", relationship) + } + if pathCell := rows[0][2].(*graphCell); pathCell.Kind != "path" || len(pathCell.Nodes) != 2 || len(pathCell.Edges) != 1 { + t.Fatalf("path was not preserved: %#v", pathCell) + } + if items := rows[0][3].(*graphCell); len(items.Nodes) < 1 || len(items.Edges) != 1 { + t.Fatalf("nested graph values were not preserved: %#v", items) + } + if reverse := rows[0][4].(*graphCell); reverse.Edges[0].SourceVID.Value != "company:1" || reverse.Edges[0].TargetVID.Value != "person:1" { + t.Fatalf("reverse edge direction was lost: %#v", reverse) + } + data, err := json.Marshal(rows) + if err != nil || !strings.Contains(string(data), `"__dbx_graph_cell":"nebula-v1"`) { + t.Fatalf("graph envelope cannot cross the agent protocol: %s, %v", data, err) + } +} + func TestFetchQueryPageAdvancesWithoutReexecuting(t *testing.T) { session := &agentSession{cursors: map[string]*queryCursor{ "page-1": {result: queryResult{Columns: []string{"n"}, ColumnTypes: []string{"int"}, Rows: [][]any{{"1"}, {"2"}, {"3"}}}, offset: 1}, diff --git a/agents/drivers/nebula-go/query.go b/agents/drivers/nebula-go/query.go index 066d27b768..84f461c2c2 100644 --- a/agents/drivers/nebula-go/query.go +++ b/agents/drivers/nebula-go/query.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "sort" + "strconv" "strings" "time" @@ -43,6 +45,44 @@ type queryCursor struct { offset int } +type graphVID struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type graphProperty struct { + Owner string `json:"owner,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Value any `json:"value"` +} + +type graphNode struct { + ID string `json:"id"` + VID graphVID `json:"vid"` + Labels []string `json:"labels"` + Properties []graphProperty `json:"properties"` +} + +type graphEdge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + SourceVID graphVID `json:"sourceVid"` + TargetVID graphVID `json:"targetVid"` + Type string `json:"type"` + Rank string `json:"rank"` + Properties []graphProperty `json:"properties"` +} + +type graphCell struct { + Marker string `json:"__dbx_graph_cell"` + Kind string `json:"kind"` + Display string `json:"display"` + Nodes []graphNode `json:"nodes"` + Edges []graphEdge `json:"edges"` +} + func (s *agentSession) dispatch(method string, params map[string]json.RawMessage) (any, error) { switch method { case "connection_info": @@ -239,9 +279,156 @@ func normalizeValue(value *nebula.ValueWrapper) any { return text } } + if graph := graphCellForValue(value); graph != nil { + return graph + } return value.String() } +func graphCellForValue(value *nebula.ValueWrapper) *graphCell { + if !value.IsVertex() && !value.IsEdge() && !value.IsPath() && !value.IsList() && !value.IsSet() && !value.IsMap() { + return nil + } + cell := &graphCell{Marker: "nebula-v1", Kind: value.GetType(), Display: value.String(), Nodes: []graphNode{}, Edges: []graphEdge{}} + appendGraphValue(value, cell) + if len(cell.Nodes) == 0 && len(cell.Edges) == 0 { + return nil + } + return cell +} + +func appendGraphValue(value *nebula.ValueWrapper, cell *graphCell) { + if value == nil { + return + } + switch { + case value.IsVertex(): + node, err := value.AsNode() + if err == nil { + cell.Nodes = append(cell.Nodes, graphNodeFromNebula(node)) + } + case value.IsEdge(): + edge, err := value.AsRelationship() + if err == nil { + cell.Edges = append(cell.Edges, graphEdgeFromNebula(edge)) + cell.Nodes = append(cell.Nodes, graphPlaceholder(edge.GetSrcVertexID()), graphPlaceholder(edge.GetDstVertexID())) + } + case value.IsPath(): + path, err := value.AsPath() + if err == nil { + for _, node := range path.GetNodes() { + cell.Nodes = append(cell.Nodes, graphNodeFromNebula(node)) + } + for _, edge := range path.GetRelationships() { + cell.Edges = append(cell.Edges, graphEdgeFromNebula(edge)) + } + } + case value.IsList(): + items, err := value.AsList() + if err == nil { + for i := range items { + appendGraphValue(&items[i], cell) + } + } + case value.IsSet(): + items, err := value.AsDedupList() + if err == nil { + for i := range items { + appendGraphValue(&items[i], cell) + } + } + case value.IsMap(): + items, err := value.AsMap() + if err == nil { + for _, item := range items { + appendGraphValue(&item, cell) + } + } + } +} + +func graphIdentity(parts ...string) string { + encoded, _ := json.Marshal(parts) + return string(encoded) +} + +func graphVIDFromNebula(value nebula.ValueWrapper) graphVID { + if value.IsString() { + text, _ := value.AsString() + return graphVID{Type: "string", Value: text} + } + if value.IsInt() { + number, _ := value.AsInt() + return graphVID{Type: "int", Value: strconv.FormatInt(number, 10)} + } + return graphVID{Type: value.GetType(), Value: value.String()} +} + +func graphPlaceholder(value nebula.ValueWrapper) graphNode { + vid := graphVIDFromNebula(value) + return graphNode{ID: graphIdentity("vertex", vid.Type, vid.Value), VID: vid, Labels: []string{}, Properties: []graphProperty{}} +} + +func graphPropertyFromNebula(owner, name string, value *nebula.ValueWrapper) graphProperty { + property := graphProperty{Owner: owner, Name: name, Type: "null"} + if value == nil || value.IsNull() { + return property + } + property.Type = value.GetType() + switch { + case value.IsString(): + property.Value, _ = value.AsString() + case value.IsBool(): + property.Value, _ = value.AsBool() + case value.IsInt(): + number, _ := value.AsInt() + property.Value = strconv.FormatInt(number, 10) + case value.IsFloat(): + number, _ := value.AsFloat() + property.Value = strconv.FormatFloat(number, 'g', -1, 64) + default: + property.Value = value.String() + } + return property +} + +func graphNodeFromNebula(node *nebula.Node) graphNode { + result := graphPlaceholder(node.GetID()) + result.Labels = append([]string{}, node.GetTags()...) + for _, tag := range result.Labels { + props, err := node.Properties(tag) + if err != nil { + continue + } + for name, value := range props { + result.Properties = append(result.Properties, graphPropertyFromNebula(tag, name, value)) + } + } + sort.Slice(result.Properties, func(i, j int) bool { + if result.Properties[i].Owner == result.Properties[j].Owner { + return result.Properties[i].Name < result.Properties[j].Name + } + return result.Properties[i].Owner < result.Properties[j].Owner + }) + return result +} + +func graphEdgeFromNebula(edge *nebula.Relationship) graphEdge { + src := graphPlaceholder(edge.GetSrcVertexID()) + dst := graphPlaceholder(edge.GetDstVertexID()) + rank := strconv.FormatInt(edge.GetRanking(), 10) + result := graphEdge{ + ID: graphIdentity("edge", edge.GetEdgeName(), src.ID, dst.ID, rank), + Source: src.ID, Target: dst.ID, SourceVID: src.VID, TargetVID: dst.VID, + Type: edge.GetEdgeName(), Rank: rank, Properties: []graphProperty{}, + } + for name, value := range edge.Properties() { + result.Properties = append(result.Properties, graphPropertyFromNebula("", name, value)) + } + sort.Slice(result.Properties, func(i, j int) bool { return result.Properties[i].Name < result.Properties[j].Name }) + return result +} + type nebulaQueryError struct { code string message string diff --git a/apps/desktop/src/components/graph/GraphResultView.vue b/apps/desktop/src/components/graph/GraphResultView.vue new file mode 100644 index 0000000000..a47e2b84db --- /dev/null +++ b/apps/desktop/src/components/graph/GraphResultView.vue @@ -0,0 +1,411 @@ + + + + + + + + + + {{ visibleNodes.length }}/{{ graph.nodes.length }} {{ t("graph.nodes") }} · {{ visibleEdges.length }}/{{ graph.edges.length }} + {{ t("graph.edges") }} + + + + + + + + + + + + + + + # + {{ column }} + + + + + {{ rowIndex + 1 }} + + {{ value }} + {{ value }} + + + + + + + + + + diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 61a95a8d8f..45ef2ef422 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -6,6 +6,8 @@ import { canReloadUnavailableDataTab, restoredDataTabReloadFilters } from "@/lib import { defaultViewForResult } from "@/lib/query/queryResultDefaultView"; import { extractNeo4jNodeCells, projectNeo4jNodeResult } from "@/lib/neo4j/neo4jNodeResult"; import { useNeo4jNodeTableResult } from "@/composables/useNeo4jNodeTableResult"; +import { extractGraphCells, type GraphEdge, type GraphNode, type GraphProperty, type GraphResult } from "@/lib/graph/graphResult"; +import { applyGraphPropertyToResult, buildNebulaGraphExpand, buildNebulaGraphPropertyUpdate, graphPropertyFromUpdateResult } from "@/lib/graph/nebulaGraph"; import { queryResultMessages } from "@/lib/query/queryResultMessages"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput"; @@ -145,8 +147,10 @@ const SolrAdmin = defineAsyncComponent(() => import("@/components/solr/SolrAdmin const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue")); const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue")); const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue")); +const GraphResultView = defineAsyncComponent(() => import("@/components/graph/GraphResultView.vue")); import { useQueryStore } from "@/stores/queryStore"; import { useConnectionStore } from "@/stores/connectionStore"; +import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps } from "@/components/layout/querySurfaces"; import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridRowNumberMode, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; @@ -270,6 +274,7 @@ const emit = defineEmits(); const { t } = useI18n(); const queryStore = useQueryStore(); const connectionStore = useConnectionStore(); +const productionSafetyStore = useProductionSafetyStore(); /** Clear a consumed editor reveal request so a later normal tab re-visit doesn't re-jump. */ function clearEditorRevealRequest(tab: { editorRevealRequest?: unknown }): void { if (tab.editorRevealRequest !== undefined) { @@ -739,6 +744,7 @@ const redisConsoleResults = computed(() => (props.activeTab.results?.length ? pr const canShowRedisConsoleOutput = computed(() => activeEffectiveDatabaseType.value === "redis" && (props.activeTab.isExecuting || redisConsoleResults.value.some((result) => result.execution_error === true || typeof result.redis_console_output === "string"))); const redisResultViewMode = computed(() => (activeEffectiveDatabaseType.value === "redis" ? (props.activeTab.uiState?.redisResultViewMode ?? "grid") : "grid")); const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting); +const canShowGraphOutput = computed(() => activeEffectiveDatabaseType.value === "nebula" && !!props.activeTab.result?.graph_data?.nodes.length); const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true); // A batch can attach server messages to more than one statement result (for // example a `DO $$ RAISE NOTICE $$` block followed by a SELECT). The messages @@ -753,6 +759,36 @@ const resultMessageCount = computed(() => resultMessages.value.length); const canShowMessagesOutput = computed(() => resultMessageCount.value > 0); const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || (redisResultViewMode.value === "console" && canShowRedisConsoleOutput.value) || !props.activeTab.result || !hasTabularResult.value); const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value)); + +async function saveNebulaGraphProperty(entity: GraphNode | GraphEdge, property: GraphProperty, value: string | boolean): Promise { + const connection = activeResultConnection.value; + if (activeEffectiveDatabaseType.value !== "nebula" || connectionIsEffectivelyReadOnly(connection)) throw new Error(t("graph.unavailable")); + const tab = props.activeTab; + const result = tab.result; + const connectionId = activeResultConnectionId.value; + const database = activeResultDatabase.value; + if (!result?.graph_data || !connectionId || !database) throw new Error(t("graph.resultChanged")); + const statement = buildNebulaGraphPropertyUpdate(entity, property, value); + const production = productionContextForDatabase(connection, database); + if (production.active) { + const confirmed = await productionSafetyStore.requestConfirmation({ sql: statement, connectionName: connection?.name, database, productionDatabases: production.databases, source: t("graph.title") }); + if (!confirmed) return undefined; + } + const currentConnection = connectionStore.getConfig(connectionId); + if (tab.result !== result || !currentConnection || connectionIsEffectivelyReadOnly(currentConnection)) throw new Error(t("graph.resultChanged")); + const response = await api.executeQuery(connectionId, database, statement, undefined, undefined, { maxRows: 1 }); + if (response.execution_error) throw new Error(response.error?.detail ?? t("graph.updateFailed")); + if (response.rows.length === 0) throw new Error(t("graph.conflict")); + const updated = graphPropertyFromUpdateResult(response, property); + if (tab.result === result) applyGraphPropertyToResult(result, entity, property, updated); + return updated; +} + +async function expandNebulaGraphNode(node: GraphNode): Promise { + if (activeEffectiveDatabaseType.value !== "nebula" || !activeResultConnectionId.value || !activeResultDatabase.value) return undefined; + const response = await api.executeQuery(activeResultConnectionId.value, activeResultDatabase.value, buildNebulaGraphExpand(node), undefined, undefined, { maxRows: 200 }); + return extractGraphCells(response).graph_data; +} let standaloneResultToolbarResizeObserver: ResizeObserver | undefined; function updateStandaloneResultToolbarDimensions() { @@ -1004,6 +1040,10 @@ watch( // view when its own tab finishes executing. if (props.editorOnly) return; if (props.activeTab.isExecuting) return; + if (props.activeOutputView === "graph" && !canShowGraphOutput.value) { + emit("update:activeOutputView", props.activeTab.id, "result"); + return; + } if (hasExecutionSummary.value && (!hasTabularResult.value || props.activeTab.result?.server_message === true) && props.activeOutputView === "result") { const result = props.activeTab.result; emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary"); @@ -2364,6 +2404,7 @@ defineExpose({ :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData && !activeElasticsearchJsonResponse" + :can-show-graph="canShowGraphOutput" :can-show-messages="canShowMessagesOutput" :can-show-redis-console="canShowRedisConsoleOutput" :result-mode="redisResultViewMode" @@ -2409,6 +2450,15 @@ defineExpose({ + @@ -2619,6 +2669,7 @@ defineExpose({ :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData && !activeElasticsearchJsonResponse" + :can-show-graph="canShowGraphOutput" :can-show-messages="canShowMessagesOutput" :can-show-redis-console="canShowRedisConsoleOutput" :result-mode="redisResultViewMode" diff --git a/apps/desktop/src/components/layout/QueryResultToolbarActions.vue b/apps/desktop/src/components/layout/QueryResultToolbarActions.vue index bb578bf0ce..fbb8e0019c 100644 --- a/apps/desktop/src/components/layout/QueryResultToolbarActions.vue +++ b/apps/desktop/src/components/layout/QueryResultToolbarActions.vue @@ -8,7 +8,7 @@ import * as api from "@/lib/backend/api"; import { createFrontendPluginRegistry, type PluginContributionEntry } from "@/lib/plugins/frontendPlugin"; import type { InstalledPlugin, PluginResultViewContribution } from "@/types/database"; -type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile"; +type OutputView = "result" | "graph" | "summary" | "explain" | "chart" | "messages" | "profile"; const props = withDefaults( defineProps<{ diff --git a/apps/desktop/src/components/layout/QueryResultViewSwitcher.vue b/apps/desktop/src/components/layout/QueryResultViewSwitcher.vue index 91d3a3c0dd..c3b40eb0de 100644 --- a/apps/desktop/src/components/layout/QueryResultViewSwitcher.vue +++ b/apps/desktop/src/components/layout/QueryResultViewSwitcher.vue @@ -1,12 +1,12 @@