diff --git a/agents/drivers/nebula-go/graph_display.go b/agents/drivers/nebula-go/graph_display.go new file mode 100644 index 0000000000..eb004119ed --- /dev/null +++ b/agents/drivers/nebula-go/graph_display.go @@ -0,0 +1,104 @@ +package main + +import ( + "sort" + + nebula "github.com/vesoft-inc/nebula-go/v3" +) + +func appendDisplayText(parts *[]any, text string) { + if len(*parts) > 0 { + if previous, ok := (*parts)[len(*parts)-1].(string); ok && len(previous)+len(text) <= 4096 { + (*parts)[len(*parts)-1] = previous + text + return + } + } + *parts = append(*parts, text) +} + +// Retain scalar text and ordered references instead of reparsing displayed nGQL. +func appendGraphDisplay(value *nebula.ValueWrapper, parts *[]any) { + switch { + case value.IsVertex(): + if node, err := value.AsNode(); err == nil { + *parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(node.GetID()).ID}) + return + } + case value.IsEdge(): + if edge, err := value.AsRelationship(); err == nil { + *parts = append(*parts, map[string]string{"edgeId": graphEdgeFromNebula(edge).ID}) + return + } + case value.IsPath(): + if path, err := value.AsPath(); err == nil { + nodes, edges := path.GetNodes(), path.GetRelationships() + if len(nodes) == len(edges)+1 { + appendDisplayText(parts, "<") + *parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(nodes[0].GetID()).ID}) + for index, edge := range edges { + identity := graphEdgeFromNebula(edge) + forward := graphPlaceholder(nodes[index].GetID()).ID == identity.Source + if forward { + appendDisplayText(parts, "-") + } else { + appendDisplayText(parts, "<-") + } + *parts = append(*parts, map[string]any{"edgeId": identity.ID, "path": true}) + if forward { + appendDisplayText(parts, "->") + } else { + appendDisplayText(parts, "-") + } + *parts = append(*parts, map[string]string{"nodeId": graphPlaceholder(nodes[index+1].GetID()).ID}) + } + appendDisplayText(parts, ">") + return + } + } + case value.IsList(): + if values, err := value.AsList(); err == nil { + appendGraphDisplayList(values, parts, "[", "]") + return + } + case value.IsSet(): + if values, err := value.AsDedupList(); err == nil { + appendGraphDisplayList(values, parts, "{", "}") + return + } + case value.IsMap(): + if values, err := value.AsMap(); err == nil { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + appendDisplayText(parts, "{") + for index, key := range keys { + if index > 0 { + appendDisplayText(parts, ", ") + } + appendDisplayText(parts, key+": ") + item := values[key] + appendGraphDisplay(&item, parts) + } + appendDisplayText(parts, "}") + return + } + } + if value.IsFloat() { + appendDisplayText(parts, normalizeValue(value).(string)) + } else { + appendDisplayText(parts, value.String()) + } +} + +func appendGraphDisplayList(values []nebula.ValueWrapper, parts *[]any, open, close string) { + appendDisplayText(parts, open) + for index := range values { + if index > 0 { + appendDisplayText(parts, ", ") + } + appendGraphDisplay(&values[index], parts) + } + appendDisplayText(parts, close) +} diff --git a/agents/drivers/nebula-go/graph_display_test.go b/agents/drivers/nebula-go/graph_display_test.go new file mode 100644 index 0000000000..7d2e0bc8c6 --- /dev/null +++ b/agents/drivers/nebula-go/graph_display_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + wire "github.com/vesoft-inc/nebula-go/v3/nebula" +) + +func TestGraphDisplayPartsPreserveOrderedEntitiesAndScalarTokens(t *testing.T) { + large := int64(9007199254740993) + small := 0.000001 + a := &wire.Vertex{Vid: &wire.Value{SVal: []byte("a")}, Tags: []*wire.Tag{{Name: []byte("Person"), Props: map[string]*wire.Value{"count": {IVal: &large}}}}} + b := &wire.Vertex{Vid: &wire.Value{SVal: []byte("b")}} + edge := &wire.Edge{Src: a.Vid, Dst: b.Vid, Name: []byte("LINK"), Type: 1, Ranking: 7, Props: map[string]*wire.Value{"weight": {FVal: &small}}} + path := &wire.Path{Src: a, Steps: []*wire.Step{{Dst: b, Type: -1, Name: edge.Name, Ranking: edge.Ranking, Props: edge.Props}}} + values := []*wire.Value{ + {PVal: path}, + {LVal: &wire.NList{Values: []*wire.Value{{VVal: a}, {SVal: []byte("literal(a)")}, {IVal: &large}, {FVal: &small}, {EVal: edge}}}}, + {UVal: &wire.NSet{Values: []*wire.Value{{VVal: a}, {EVal: edge}}}}, + {MVal: &wire.NMap{Kvs: map[string]*wire.Value{"nested": {LVal: &wire.NList{Values: []*wire.Value{{VVal: a}, {VVal: a}}}}, "scalar": {IVal: &large}}}}, + } + rows, _, err := readRows(resultSet(t, []string{"path", "list", "set", "map"}, [][]*wire.Value{values}), 1) + if err != nil { + t.Fatal(err) + } + for column, value := range rows[0] { + cell := value.(*graphCell) + if len(cell.DisplayParts) == 0 { + t.Fatalf("column %d has no editable display references", column) + } + nodes := make(map[string]bool) + edges := make(map[string]bool) + for _, node := range cell.Nodes { + nodes[node.ID] = true + } + for _, edge := range cell.Edges { + edges[edge.ID] = true + } + for _, part := range cell.DisplayParts { + encoded, err := json.Marshal(part) + if err != nil { + t.Fatal(err) + } + if _, ok := part.(string); ok { + continue + } + var reference struct { + NodeID string `json:"nodeId"` + EdgeID string `json:"edgeId"` + } + if err := json.Unmarshal(encoded, &reference); err != nil || (!nodes[reference.NodeID] && !edges[reference.EdgeID]) { + t.Fatalf("unresolvable display reference: %s", encoded) + } + } + } + pathCell := rows[0][0].(*graphCell) + if !reflect.DeepEqual(pathCell.DisplayParts[2], "<-") || !reflect.DeepEqual(pathCell.DisplayParts[4], "-") { + t.Fatalf("reverse path order lost: %#v", pathCell.DisplayParts) + } + listJSON, _ := json.Marshal(rows[0][1].(*graphCell).DisplayParts) + if !strings.Contains(string(listJSON), "literal(a)") || !strings.Contains(string(listJSON), "9007199254740993") || !strings.Contains(string(listJSON), "1e-06") || strings.Contains(string(listJSON), "1e-06.0") { + t.Fatalf("scalar text or exact numbers changed: %s", listJSON) + } + mapCell := rows[0][3].(*graphCell) + var references []string + for _, part := range mapCell.DisplayParts { + if ref, ok := part.(map[string]string); ok { + references = append(references, ref["nodeId"]) + } + } + if len(references) != 2 || references[0] != references[1] { + t.Fatalf("repeated aliases lost: %#v", mapCell.DisplayParts) + } +} diff --git a/agents/drivers/nebula-go/main_test.go b/agents/drivers/nebula-go/main_test.go index c4806444cb..edaf3ff300 100644 --- a/agents/drivers/nebula-go/main_test.go +++ b/agents/drivers/nebula-go/main_test.go @@ -2,7 +2,9 @@ package main import ( "encoding/json" + "math" "reflect" + "strconv" "strings" "testing" @@ -111,6 +113,65 @@ func TestReadRowsPreservesScalarAndGraphValues(t *testing.T) { } } +func TestReadRowsFloatRoundTripDoesNotUseSDKDisplay(t *testing.T) { + for _, number := range []float64{0.000001, -0.000001, 1e20, 1.25e-7, math.SmallestNonzeroFloat64, math.MaxFloat64} { + t.Run(strconv.FormatFloat(number, 'g', -1, 64), func(t *testing.T) { + result := resultSet(t, []string{"dbx_value"}, [][]*wire.Value{{{FVal: &number}}}) + rows, types, err := readRows(result, 1) + if err != nil { + t.Fatal(err) + } + text := rows[0][0].(string) + got, err := strconv.ParseFloat(text, 64) + if err != nil || got != number || types[0] != "float" { + t.Fatalf("float round-trip lost: value=%q parsed=%v error=%v types=%v", text, got, err, types) + } + }) + } +} + +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..16fcb57e0f 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,45 @@ 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"` + DisplayParts []any `json:"displayParts,omitempty"` +} + func (s *agentSession) dispatch(method string, params map[string]json.RawMessage) (any, error) { switch method { case "connection_info": @@ -239,9 +280,162 @@ func normalizeValue(value *nebula.ValueWrapper) any { return text } } + if value.IsFloat() { + if number, err := value.AsFloat(); err == nil { + return strconv.FormatFloat(number, 'g', -1, 64) + } + } + 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 + } + appendGraphDisplay(value, &cell.DisplayParts) + 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..623bfbbd2f --- /dev/null +++ b/apps/desktop/src/components/graph/GraphResultView.vue @@ -0,0 +1,447 @@ + + + + + + + + + + {{ 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/graph/__tests__/GraphResultView.spec.ts b/apps/desktop/src/components/graph/__tests__/GraphResultView.spec.ts new file mode 100644 index 0000000000..16c1460a41 --- /dev/null +++ b/apps/desktop/src/components/graph/__tests__/GraphResultView.spec.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { createI18n } from "vue-i18n"; +import type { Core, LayoutOptions } from "cytoscape"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GraphNode, GraphProperty, GraphResult } from "@/lib/graph/graphResult"; + +const renderer = vi.hoisted(() => ({ instances: [] as Core[], layouts: [] as (LayoutOptions & { fixedNodeConstraint?: { nodeId: string }[] })[] })); +vi.mock("cytoscape", async (importOriginal) => { + const { default: cytoscape } = await importOriginal(); + const factory = Object.assign( + (options: Parameters[0]) => { + const core = cytoscape({ ...options, container: undefined, headless: true, styleEnabled: true }); + vi.spyOn(core, "layout").mockImplementation((layout) => { + const constraints = layout as (typeof renderer.layouts)[number]; + renderer.layouts.push(constraints); + for (const fixed of constraints.fixedNodeConstraint ?? []) { + if (core.getElementById(fixed.nodeId).empty()) throw new Error(`Missing layout anchor: ${fixed.nodeId}`); + } + return { run: () => undefined } as unknown as ReturnType; + }); + renderer.instances.push(core); + return core; + }, + { use: cytoscape.use }, + ); + return { default: factory }; +}); + +import GraphResultView from "../GraphResultView.vue"; + +function node(id: string, value = id): GraphNode { + return { id, vid: { type: "string", value: id }, labels: ["Person"], properties: [{ owner: "Person", name: "name", type: "string", value }] }; +} + +function graph(...nodes: GraphNode[]): GraphResult { + return { nodes, edges: [], cells: nodes.map((item, row) => ({ row, column: 0, kind: "vertex", nodeIds: [item.id], edgeIds: [] })) }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const mounted: { app: App; host: HTMLDivElement }[] = []; +async function flush() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +async function mountGraph(initial: GraphResult, actions: Partial["$props"]> = {}) { + const state = reactive({ graph: initial }); + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp(defineComponent({ setup: () => () => h(GraphResultView, { graph: state.graph, rows: state.graph.nodes.map((item) => [item.id]), columns: ["v"], ...actions }) })); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mounted.push({ app, host }); + await flush(); + return { state, host, app }; +} + +function core() { + return renderer.instances.at(-1)!; +} + +async function select(id: string) { + core().getElementById(id).emit("tap"); + await flush(); +} + +async function click(host: HTMLElement, label: string) { + const button = host.querySelector(`button[aria-label="graph.${label}"], button[title="graph.${label}"]`) ?? [...host.querySelectorAll("button")].find((item) => item.textContent === `graph.${label}`); + expect(button).toBeDefined(); + button!.click(); + await flush(); +} + +afterEach(() => { + for (const { app, host } of mounted.splice(0)) { + app.unmount(); + host.remove(); + } + renderer.instances.length = 0; + renderer.layouts.length = 0; +}); + +describe("graph result request isolation", () => { + it.each(["string", "bool", "int", "float"])("does not offer edits for a NULL %s property", async (type) => { + const vertex = node("null-node"); + vertex.properties = [{ owner: "Person", name: "nullable", type, value: null }]; + const saveProperty = vi.fn(); + const { host } = await mountGraph(graph(vertex), { saveProperty }); + await select("null-node"); + const button = [...host.querySelectorAll("aside button")].find((item) => item.textContent?.trim() === "NULL"); + expect(button).toBeDefined(); + expect(button!.disabled).toBe(true); + button!.click(); + await flush(); + expect(host.querySelector('input[aria-label="nullable"]')).toBeNull(); + expect(saveProperty).not.toHaveBeenCalled(); + }); + it("discards an expansion after replacing the result, even with overlapping identities", async () => { + const pending = deferred(); + const { state, host } = await mountGraph(graph(node("shared")), { expandNode: () => pending.promise }); + await select("shared"); + await click(host, "expand"); + state.graph = graph(node("shared", "other database")); + await flush(); + pending.resolve(graph(node("stale-neighbor"))); + await flush(); + expect( + core() + .nodes() + .map((item) => item.id()), + ).toEqual(["shared"]); + expect(host.textContent).not.toContain("stale-neighbor"); + }); + + it("does not release a new request's busy state when an old request finishes", async () => { + const oldRequest = deferred(); + const newRequest = deferred(); + const expandNode = vi.fn().mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise); + const { state, host } = await mountGraph(graph(node("a")), { expandNode }); + await select("a"); + await click(host, "expand"); + state.graph = graph(node("b")); + await flush(); + await select("b"); + await click(host, "expand"); + expect(expandNode).toHaveBeenCalledTimes(2); + oldRequest.resolve(graph(node("old"))); + await flush(); + await click(host, "expand"); + expect(expandNode).toHaveBeenCalledTimes(2); + newRequest.resolve(graph(node("new"))); + await flush(); + expect( + core() + .nodes() + .map((item) => item.id()), + ).toEqual(["b", "new"]); + }); + + it("ignores stale errors and saves after result replacement or unmount", async () => { + const pending = deferred(); + const first = graph(node("shared", "first")); + const { state, host } = await mountGraph(first, { saveProperty: () => pending.promise }); + await select("shared"); + host.querySelector("aside button.hover\\:underline")!.click(); + await flush(); + await click(host, "save"); + state.graph = graph(node("shared", "second")); + await flush(); + pending.reject(new Error("stale save error")); + await flush(); + expect(host.textContent).not.toContain("stale save error"); + expect(state.graph.nodes[0].properties[0].value).toBe("second"); + const unmountedSave = deferred(); + const second = await mountGraph(graph(node("c")), { saveProperty: () => unmountedSave.promise }); + await select("c"); + second.host.querySelector("aside button.hover\\:underline")!.click(); + await flush(); + await click(second.host, "save"); + second.app.unmount(); + mounted.splice( + mounted.findIndex((item) => item.app === second.app), + 1, + ); + second.host.remove(); + unmountedSave.resolve({ owner: "Person", name: "name", type: "string", value: "changed" }); + await flush(); + expect(second.state.graph.nodes[0].properties[0].value).toBe("c"); + }); + + it("updates the saved entity rather than a newly selected node", async () => { + const pending = deferred(); + const { state, host } = await mountGraph(graph(node("a"), node("b")), { saveProperty: () => pending.promise }); + await select("a"); + host.querySelector("aside button.hover\\:underline")!.click(); + await flush(); + await click(host, "save"); + await select("b"); + pending.resolve({ owner: "Person", name: "name", type: "string", value: "updated-a" }); + await flush(); + expect(state.graph.nodes.map((item) => item.properties[0].value)).toEqual(["updated-a", "b"]); + expect(core().getElementById("a").data("label")).toBe("updated-a"); + expect(core().getElementById("b").data("label")).toBe("b"); + }); + + it("does not replace original record cell mappings with expansion rows", async () => { + const { host } = await mountGraph(graph(node("a")), { expandNode: async () => graph(node("neighbor")) }); + await select("a"); + await click(host, "expand"); + await click(host, "records"); + host.querySelector("tbody button")!.click(); + await flush(); + expect(core().getElementById("a").selected()).toBe(true); + expect(core().getElementById("neighbor").selected()).toBe(false); + }); + + it("keeps the draft and reports a save conflict instead of closing the editor", async () => { + const { host } = await mountGraph(graph(node("a")), { + saveProperty: async () => { + throw new Error("Graph property changed concurrently"); + }, + }); + await select("a"); + host.querySelector("aside button.hover\\:underline")!.click(); + await flush(); + await click(host, "save"); + expect(host.querySelector('input[aria-label="name"]')!.value).toBe("a"); + expect(host.textContent).toContain("Graph property changed concurrently"); + }); + + it("lets an in-flight expansion complete across a page append", async () => { + const pending = deferred(); + const a = node("a"); + const { state, host } = await mountGraph(graph(a), { expandNode: () => pending.promise }); + await select("a"); + await click(host, "expand"); + state.graph = graph(a, node("page-two")); + await flush(); + pending.resolve(graph(node("neighbor"))); + await flush(); + expect( + core() + .nodes() + .map((item) => item.id()), + ).toEqual(["a", "page-two", "neighbor"]); + }); +}); + +describe("graph result layout and append", () => { + it("drops removed layout anchors when hiding a node exposes the 1,001st node", async () => { + const { host } = await mountGraph(graph(...Array.from({ length: 1001 }, (_, index) => node(`n${index}`)))); + await select("n0"); + await click(host, "hide"); + expect(core().nodes()).toHaveLength(1000); + expect(core().getElementById("n0").empty()).toBe(true); + expect(core().getElementById("n1000").nonempty()).toBe(true); + const anchors = renderer.layouts.at(-1)!.fixedNodeConstraint!; + expect(anchors).toHaveLength(999); + expect(anchors.some((item) => item.nodeId === "n0")).toBe(false); + }); + + it("preserves expansion, selection, pins, hidden nodes, and positions on page append", async () => { + const a = node("a"); + const b = node("b"); + const { state, host } = await mountGraph(graph(a, b), { expandNode: async () => graph(node("neighbor")) }); + await select("a"); + await click(host, "expand"); + await select("b"); + await click(host, "hide"); + await select("a"); + core().getElementById("a").position({ x: 150, y: 200 }); + await click(host, "pin"); + state.graph = graph(a, b, node("page-two")); + await flush(); + expect( + core() + .nodes() + .map((item) => item.id()), + ).toEqual(["a", "page-two", "neighbor"]); + expect(core().getElementById("a").selected()).toBe(true); + expect(core().getElementById("a").locked()).toBe(true); + expect(core().getElementById("a").position()).toEqual({ x: 150, y: 200 }); + expect(host.querySelector("aside")!.textContent).toContain("Person.name"); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index aa4ad9d4ff..85dfc189ad 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, graphPropertyMatchesValue } 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) { @@ -390,6 +395,7 @@ const activeResultErrorPosition = computed(() => { }); const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database); const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema); +const graphViewKey = computed(() => JSON.stringify([props.activeTab.id, props.activeTab.resultViewGeneration, props.activeTab.activeResultRunId, props.activeTab.activeResultIndex, activeResultConnectionId.value, activeResultDatabase.value])); const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value)); // 表数据工具箱的「导入数据」与侧边栏、对象浏览器共用同一条能力判断:未适配导入的引擎(如 HANA)不出现入口。 const canOpenTableImport = computed(() => supportsTableImport(activeEffectiveDatabaseType.value)); @@ -739,6 +745,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 +760,50 @@ 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 generation = tab.resultViewGeneration; + const sourceKey = graphViewKey.value; + const isCurrent = () => props.activeTab === tab && graphViewKey.value === sourceKey && (generation !== undefined ? !!tab.result?.graph_data : tab.result === result); + 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 (!isCurrent() || !currentConnection || connectionIsEffectivelyReadOnly(currentConnection)) throw new Error(t("graph.resultChanged")); + const response = await api.executeQuery(connectionId, database, statement, undefined, undefined, { maxRows: 1 }); + if (!isCurrent()) return undefined; + 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); + // A failed WHEN condition may still yield the stored value. + if (!graphPropertyMatchesValue(updated, value)) throw new Error(t("graph.conflict")); + applyGraphPropertyToResult(tab.result!, entity, property, updated); + return updated; +} + +async function expandNebulaGraphNode(node: GraphNode): Promise { + const tab = props.activeTab; + const result = tab.result; + const connectionId = activeResultConnectionId.value; + const database = activeResultDatabase.value; + const generation = tab.resultViewGeneration; + const sourceKey = graphViewKey.value; + if (activeEffectiveDatabaseType.value !== "nebula" || !connectionId || !database) return undefined; + const response = await api.executeQuery(connectionId, database, buildNebulaGraphExpand(node), undefined, undefined, { maxRows: 200 }); + if (props.activeTab !== tab || graphViewKey.value !== sourceKey || (generation === undefined && tab.result !== result)) return undefined; + if (response.execution_error) throw new Error(response.error?.detail ?? t("graph.unavailable")); + return extractGraphCells(response).graph_data; +} let standaloneResultToolbarResizeObserver: ResizeObserver | undefined; function updateStandaloneResultToolbarDimensions() { @@ -1004,6 +1055,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 +2419,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 +2465,16 @@ defineExpose({ + @@ -2621,6 +2687,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 @@