feat(graph): visualize and edit NebulaGraph query results

This commit is contained in:
Hand Sonic
2026-10-09 00:49:33 +08:00
committed by GitHub
parent 732e081ee4
commit b06c499902
36 changed files with 2211 additions and 11 deletions
+104
View File
@@ -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)
}
@@ -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)
}
}
+61
View File
@@ -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},
+194
View File
@@ -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
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import cytoscape, { type Core, type ElementDefinition } from "cytoscape";
import fcose from "cytoscape-fcose";
import { Download, EyeOff, Focus, Maximize2, Network, Pin, RotateCcw, Search, Save, X, ZoomIn, ZoomOut } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useI18n } from "vue-i18n";
import { mergeGraphResults, type GraphEdge, type GraphNode, type GraphProperty, type GraphResult } from "@/lib/graph/graphResult";
import type { QueryResult } from "@/types/database";
cytoscape.use(fcose);
const props = defineProps<{
graph: GraphResult;
rows: QueryResult["rows"];
columns: string[];
readOnly?: boolean;
saveProperty?: (entity: GraphNode | GraphEdge, property: GraphProperty, value: string | boolean) => Promise<GraphProperty | undefined>;
expandNode?: (node: GraphNode) => Promise<GraphResult | undefined>;
}>();
const { t } = useI18n();
const MAX_VISIBLE_NODES = 1000;
const MAX_VISIBLE_EDGES = 2000;
const canvas = ref<HTMLDivElement>();
const query = ref("");
const showRecords = ref(false);
const hidden = ref<Set<string>>(new Set());
const pinned = ref<Set<string>>(new Set());
const expanded = ref<GraphResult>();
const selectedId = ref("");
const selectedKind = ref<"node" | "edge">("node");
const editing = ref("");
const draft = ref<string | boolean>("");
const busy = ref(false);
const error = ref("");
let cy: Core | undefined;
let observer: ResizeObserver | undefined;
let themeObserver: MutationObserver | undefined;
let lastNodeTap = { id: "", at: 0 };
let disposed = false;
let graphRevision = 0;
const graph = computed(() => mergeGraphResults(props.graph, expanded.value) ?? props.graph);
const visibleNodes = computed(() => graph.value.nodes.filter((node) => !hidden.value.has(node.id)).slice(0, MAX_VISIBLE_NODES));
const visibleNodeIds = computed(() => new Set(visibleNodes.value.map((node) => node.id)));
const visibleEdges = computed(() => graph.value.edges.filter((edge) => visibleNodeIds.value.has(edge.source) && visibleNodeIds.value.has(edge.target) && !hidden.value.has(edge.id)).slice(0, MAX_VISIBLE_EDGES));
const selected = computed(() => (selectedKind.value === "node" ? graph.value.nodes.find((node) => node.id === selectedId.value) : graph.value.edges.find((edge) => edge.id === selectedId.value)));
const selectedNode = computed(() => (selectedKind.value === "node" ? (selected.value as GraphNode | undefined) : undefined));
const relatedCells = computed(() => graph.value.cells.filter((cell) => cell.nodeIds.includes(selectedId.value) || cell.edgeIds.includes(selectedId.value)).slice(0, 50));
const records = computed(() => props.rows.slice(0, 300));
const cellIndex = computed(() => new Map(graph.value.cells.map((cell) => [`${cell.row}:${cell.column}`, cell])));
function nodeLabel(node: GraphNode): string {
const title = node.properties.find((property) => /^(name|title|label)$/i.test(property.name) && typeof property.value === "string");
return String(title?.value ?? node.vid?.value ?? node.id);
}
const palette = ["#2890a7", "#d07855", "#6c9d66", "#a478ba", "#cc9a44", "#5381bb", "#b56989", "#779987"];
function nodeColor(node: GraphNode): string {
const label = node.labels[0] ?? "vertex";
let hash = 0;
for (const char of label) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return palette[Math.abs(hash) % palette.length];
}
function renderGraph(keepPositions = false) {
if (!canvas.value) return;
const dark = document.documentElement.classList.contains("dark");
const textColor = dark ? "#e6edf0" : "#202a31";
const canvasColor = dark ? "#192327" : "#ffffff";
const positions = new Map<string, { x: number; y: number }>();
if (keepPositions && cy)
cy.nodes().forEach((node) => {
if (visibleNodeIds.value.has(node.id())) positions.set(node.id(), node.position());
});
cy?.destroy();
const elements: ElementDefinition[] = [
...visibleNodes.value.map((node) => ({ data: { id: node.id, label: nodeLabel(node), color: nodeColor(node) }, ...(positions.has(node.id) ? { position: positions.get(node.id) } : {}) })),
...visibleEdges.value.map((edge) => ({ data: { id: edge.id, source: edge.source, target: edge.target, label: edge.type } })),
];
cy = cytoscape({
container: canvas.value,
elements,
layout: { name: "preset" },
minZoom: 0.08,
maxZoom: 1.5,
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
"border-color": canvasColor,
"border-width": 2,
width: 42,
height: 42,
label: "data(label)",
color: textColor,
"font-size": 11,
"font-weight": 600,
"text-valign": "bottom",
"text-margin-y": 10,
"text-wrap": "ellipsis",
"text-max-width": "120px",
"text-background-color": canvasColor,
"text-background-opacity": 0.86,
"text-background-padding": "2px",
},
},
{
selector: "edge",
style: {
width: 2,
"line-color": "#9baeb3",
"target-arrow-color": "#9baeb3",
"target-arrow-shape": "triangle",
"arrow-scale": 0.8,
"curve-style": "bezier",
"control-point-step-size": 50,
label: "data(label)",
color: dark ? "#c2cdd0" : "#52616b",
"font-size": 10,
"text-rotation": "autorotate",
"text-background-color": canvasColor,
"text-background-opacity": 0.88,
"text-background-padding": "2px",
},
},
{ selector: ":selected", style: { "border-color": "#173e48", "border-width": 4, "line-color": "#173e48", "target-arrow-color": "#173e48" } },
{ selector: ".search-hit", style: { "border-color": "#e69a3e", "border-width": 5 } },
],
});
cy.on("tap", "node, edge", (event) => {
selectedId.value = event.target.id();
selectedKind.value = event.target.isNode() ? "node" : "edge";
editing.value = "";
error.value = "";
if (event.target.isNode()) {
const now = performance.now();
if (lastNodeTap.id === selectedId.value && now - lastNodeTap.at < 320) void expand();
lastNodeTap = { id: selectedId.value, at: now };
}
});
cy.on("tap", (event) => {
if (event.target === cy) selectedId.value = "";
});
if (selectedId.value) cy.getElementById(selectedId.value).select();
for (const id of pinned.value) cy.getElementById(id).lock();
if (!positions.size) relayout();
else if (visibleNodes.value.some((node) => !positions.has(node.id))) {
cy.layout({
name: "fcose",
quality: "default",
animate: true,
animationDuration: 350,
randomize: false,
nodeDimensionsIncludeLabels: true,
fixedNodeConstraint: [...positions].map(([nodeId, position]) => ({ nodeId, position })),
} as cytoscape.LayoutOptions).run();
} else cy.fit(undefined, 40);
highlightSearch();
}
function relayout() {
if (!cy || !cy.nodes().length) return;
const fixedNodeConstraint = cy
.nodes()
.filter((node) => pinned.value.has(node.id()))
.map((node) => ({ nodeId: node.id(), position: (node as cytoscape.NodeSingular).position() }));
cy.layout({ name: "fcose", quality: "default", animate: true, animationDuration: 350, randomize: false, nodeDimensionsIncludeLabels: true, fixedNodeConstraint } as cytoscape.LayoutOptions).run();
}
function highlightSearch() {
if (!cy) return;
cy.elements().removeClass("search-hit");
const text = query.value.trim().toLocaleLowerCase();
if (!text) return;
cy.nodes().forEach((node) => {
if (String(node.data("label")).toLocaleLowerCase().includes(text) || node.id().toLocaleLowerCase().includes(text)) node.addClass("search-hit");
});
}
function focusElement(id: string, kind: "node" | "edge") {
selectedId.value = id;
selectedKind.value = kind;
cy?.elements().unselect();
const element = cy?.getElementById(id);
if (element?.length) {
element.select();
cy?.animate({ center: { eles: element }, zoom: Math.max(cy.zoom(), 0.8) }, { duration: 250 });
}
}
function cellFor(row: number, column: number) {
return cellIndex.value.get(`${row}:${column}`);
}
function selectCell(row: number, column: number) {
const cell = cellFor(row, column);
if (!cell) return;
const edge = cell.kind === "edge" && !!cell.edgeIds[0];
const id = edge ? cell.edgeIds[0] : (cell.nodeIds[0] ?? cell.edgeIds[0]);
if (id) focusElement(id, edge ? "edge" : "node");
}
function hideSelected() {
if (!selectedId.value) return;
hidden.value = new Set([...hidden.value, selectedId.value]);
selectedId.value = "";
renderGraph(true);
}
function restoreHidden() {
hidden.value = new Set();
renderGraph(true);
}
function togglePin() {
const element = cy?.getElementById(selectedId.value);
if (!element?.length || !element.isNode()) return;
const next = new Set(pinned.value);
if (next.has(selectedId.value)) {
next.delete(selectedId.value);
element.unlock();
} else {
next.add(selectedId.value);
element.lock();
}
pinned.value = next;
}
async function expand() {
if (!selectedNode.value || !props.expandNode || busy.value) return;
const revision = graphRevision;
const isCurrent = () => !disposed && graphRevision === revision;
busy.value = true;
error.value = "";
try {
const result = await props.expandNode(selectedNode.value);
if (!isCurrent()) return;
// Neighbor-query rows do not belong to the original result table.
expanded.value = mergeGraphResults(expanded.value, result ? { ...result, cells: [] } : undefined);
await nextTick();
if (isCurrent()) renderGraph(true);
} catch (cause) {
if (isCurrent()) error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (isCurrent()) busy.value = false;
}
}
function canEdit(property: GraphProperty): boolean {
return !props.readOnly && !!props.saveProperty && property.value !== null && ["string", "bool", "int", "float"].includes(property.type);
}
function startEdit(property: GraphProperty) {
editing.value = `${property.owner}\u0000${property.name}`;
draft.value = property.value ?? "";
error.value = "";
}
async function save(property: GraphProperty) {
if (!selected.value || !props.saveProperty || busy.value) return;
const entity = selected.value;
const revision = graphRevision;
const editKey = editing.value;
const isCurrent = () => !disposed && graphRevision === revision;
busy.value = true;
error.value = "";
try {
const updated = await props.saveProperty(entity, property, draft.value);
if (!updated || !isCurrent()) return;
const element = ("labels" in entity ? graph.value.nodes : graph.value.edges).find((item) => item.id === entity.id);
if (!element) return;
const target = element.properties.find((candidate) => candidate.owner === property.owner && candidate.name === property.name);
if (target) target.value = updated.value;
if ("labels" in element) cy?.getElementById(element.id).data("label", nodeLabel(element));
if (selectedId.value === entity.id && editing.value === editKey) editing.value = "";
} catch (cause) {
if (isCurrent() && selectedId.value === entity.id) error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (isCurrent()) busy.value = false;
}
}
const draftText = computed({
get: () => String(draft.value),
set: (value: string) => {
draft.value = value;
},
});
function exportImage() {
if (!cy) return;
const link = document.createElement("a");
link.download = "dbx-graph.png";
link.href = cy.png({ full: true, scale: 2, bg: document.documentElement.classList.contains("dark") ? "#111a1e" : "#ffffff" });
link.click();
}
watch(query, highlightSearch);
watch(
() => props.graph,
(next, previous) => {
// Appending a result page reuses the same cells and only adds new ones;
// keep the view state and re-render with preserved positions instead of
// wiping selection/pins/hidden nodes and re-running a full layout.
const appended =
!!previous &&
previous.cells.length < next.cells.length &&
(() => {
const nextCellKeys = new Set(next.cells.map((cell) => `${cell.row}:${cell.column}`));
if (previous.cells.some((cell) => !nextCellKeys.has(`${cell.row}:${cell.column}`))) return false;
const nextNodeIds = new Set(next.nodes.map((node) => node.id));
return previous.nodes.every((node) => nextNodeIds.has(node.id));
})();
if (!appended) {
graphRevision++;
busy.value = false;
expanded.value = undefined;
selectedId.value = "";
editing.value = "";
error.value = "";
lastNodeTap = { id: "", at: 0 };
hidden.value = new Set();
pinned.value = new Set();
}
const revision = graphRevision;
void nextTick(() => {
if (!disposed && graphRevision === revision) renderGraph(appended);
});
},
{ flush: "sync" },
);
watch(selectedId, () => {
editing.value = "";
error.value = "";
});
onMounted(() => {
renderGraph();
if (canvas.value && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
requestAnimationFrame(() => {
cy?.resize();
cy?.fit(undefined, 32);
});
});
observer.observe(canvas.value);
}
themeObserver = new MutationObserver(() => renderGraph(true));
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
});
onBeforeUnmount(() => {
disposed = true;
observer?.disconnect();
themeObserver?.disconnect();
cy?.destroy();
});
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col bg-background" data-graph-result-view>
<div class="flex h-9 shrink-0 items-center gap-1 border-b px-2">
<div class="relative min-w-0 flex-1 sm:max-w-52">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="query" :aria-label="t('graph.search')" :placeholder="t('graph.search')" class="h-7 pl-7 text-xs" />
</div>
<span class="hidden shrink-0 text-xs tabular-nums text-muted-foreground sm:inline"
>{{ visibleNodes.length }}<template v-if="graph.nodes.length > visibleNodes.length">/{{ graph.nodes.length }}</template> {{ t("graph.nodes") }} · {{ visibleEdges.length }}<template v-if="graph.edges.length > visibleEdges.length">/{{ graph.edges.length }}</template>
{{ t("graph.edges") }}</span
>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.zoomIn')" :aria-label="t('graph.zoomIn')" @click="cy?.zoom(cy.zoom() * 1.2)"><ZoomIn class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.zoomOut')" :aria-label="t('graph.zoomOut')" @click="cy?.zoom(cy.zoom() / 1.2)"><ZoomOut class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="cy?.fit(undefined, 40)"><Maximize2 class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.layout')" :aria-label="t('graph.layout')" @click="relayout"><RotateCcw class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.records')" :aria-label="t('graph.records')" :aria-pressed="showRecords" @click="showRecords = !showRecords"><Focus class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.exportPng')" :aria-label="t('graph.exportPng')" @click="exportImage"><Download class="h-4 w-4" /></Button>
</div>
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
<div class="flex min-h-[280px] min-w-0 flex-1 flex-col">
<div ref="canvas" class="min-h-0 flex-1" data-graph-canvas />
<div v-if="showRecords" class="max-h-40 shrink-0 overflow-auto border-t text-xs">
<table class="min-w-full border-collapse text-left">
<thead class="sticky top-0 bg-muted/80 text-muted-foreground">
<tr>
<th class="w-10 px-2 py-1 font-medium">#</th>
<th v-for="(column, index) in columns" :key="index" class="min-w-32 px-2 py-1 font-medium">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in records" :key="rowIndex" class="border-t hover:bg-muted/30">
<td class="px-2 py-1 text-muted-foreground">{{ rowIndex + 1 }}</td>
<td v-for="(value, columnIndex) in row" :key="columnIndex" class="max-w-60 px-2 py-1">
<button v-if="cellFor(rowIndex, columnIndex)" type="button" class="block max-w-60 truncate text-left hover:underline" :title="String(value)" @click="selectCell(rowIndex, columnIndex)">{{ value }}</button>
<span v-else class="block max-w-60 truncate" :title="String(value)">{{ value }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<aside class="max-h-64 min-h-0 w-full shrink-0 overflow-auto border-t md:max-h-none md:w-72 md:border-l md:border-t-0">
<template v-if="selected">
<div class="flex items-start justify-between gap-2 border-b px-3 py-2">
<div class="min-w-0">
<div class="truncate text-sm font-semibold">{{ selectedKind === "node" ? nodeLabel(selected as GraphNode) : (selected as GraphEdge).type }}</div>
<div class="truncate text-[11px] text-muted-foreground">{{ selectedKind === "node" ? (selected as GraphNode).labels.join(", ") || (selected as GraphNode).vid?.value || (selected as GraphNode).id : `${(selected as GraphEdge).source} → ${(selected as GraphEdge).target}` }}</div>
</div>
<Button size="icon" variant="ghost" class="h-6 w-6 shrink-0" :title="t('graph.clearSelection')" :aria-label="t('graph.clearSelection')" @click="selectedId = ''"><X class="h-3.5 w-3.5" /></Button>
</div>
<div class="flex items-center gap-1 border-b px-2 py-1">
<Button v-if="selectedKind === 'node' && expandNode" size="sm" variant="ghost" class="h-7 gap-1 px-2 text-xs" :disabled="busy || visibleNodes.length >= MAX_VISIBLE_NODES" @click="expand"><Network class="h-3.5 w-3.5" />{{ t("graph.expand") }}</Button>
<Button v-if="selectedKind === 'node'" size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.pin')" :aria-label="t('graph.pin')" @click="togglePin"><Pin class="h-3.5 w-3.5" /></Button>
<Button size="icon" variant="ghost" class="h-7 w-7" :title="t('graph.hide')" :aria-label="t('graph.hide')" @click="hideSelected"><EyeOff class="h-3.5 w-3.5" /></Button>
</div>
<div class="px-3 py-2 text-[11px] font-medium text-muted-foreground">{{ t("graph.properties") }}</div>
<div v-for="property in selected.properties" :key="`${property.owner}:${property.name}`" class="border-b px-3 py-2 text-xs">
<div class="flex min-w-0 items-center justify-between gap-2">
<span class="min-w-0 truncate font-medium" :title="property.owner ? `${property.owner}.${property.name}` : property.name">{{ property.owner ? `${property.owner}.${property.name}` : property.name }}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">{{ property.type }}</span>
</div>
<template v-if="editing === `${property.owner}\u0000${property.name}`">
<label v-if="property.type === 'bool'" class="mt-1 flex items-center gap-2"><input v-model="draft" type="checkbox" :disabled="busy" class="h-4 w-4" />{{ draft ? "true" : "false" }}</label>
<Input v-else v-model="draftText" :disabled="busy" class="mt-1 h-7 text-xs" :aria-label="property.name" @keyup.enter="save(property)" />
<div class="mt-1 flex justify-end gap-1">
<Button size="icon" variant="ghost" class="h-6 w-6" :title="t('graph.cancel')" :aria-label="t('graph.cancel')" @click="editing = ''"><X class="h-3.5 w-3.5" /></Button>
<Button size="icon" variant="ghost" class="h-6 w-6" :title="t('graph.save')" :aria-label="t('graph.save')" :disabled="busy" @click="save(property)"><Save class="h-3.5 w-3.5" /></Button>
</div>
</template>
<button v-else type="button" class="mt-1 block w-full break-all text-left text-foreground" :class="canEdit(property) ? 'hover:underline' : 'cursor-default'" :disabled="busy || !canEdit(property)" @click="startEdit(property)">
{{ property.value === null ? "NULL" : String(property.value) }}
</button>
</div>
<div v-if="relatedCells.length" class="px-3 py-2 text-[11px] text-muted-foreground">{{ t("graph.records") }}: {{ relatedCells.map((cell) => cell.row + 1).join(", ") }}</div>
</template>
<div v-else class="flex h-24 items-center justify-center text-xs text-muted-foreground">
{{ visibleNodes.length }}<template v-if="graph.nodes.length > visibleNodes.length">/{{ graph.nodes.length }}</template> {{ t("graph.nodes") }} · {{ visibleEdges.length }}<template v-if="graph.edges.length > visibleEdges.length">/{{ graph.edges.length }}</template> {{ t("graph.edges") }}
</div>
<div v-if="error" role="alert" class="break-words px-3 py-2 text-xs text-destructive">{{ error }}</div>
<div v-if="hidden.size" class="border-t px-2 py-1">
<Button size="sm" variant="ghost" class="h-7 text-xs" @click="restoreHidden">{{ t("graph.restoreHidden") }}</Button>
</div>
</aside>
</div>
</div>
</template>
@@ -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<typeof import("cytoscape")>();
const factory = Object.assign(
(options: Parameters<typeof cytoscape>[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<Core["layout"]>;
});
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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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<InstanceType<typeof GraphResultView>["$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<HTMLButtonElement>(`button[aria-label="graph.${label}"], button[title="graph.${label}"]`) ?? [...host.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("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<GraphResult>();
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<GraphResult>();
const newRequest = deferred<GraphResult>();
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<GraphProperty>();
const first = graph(node("shared", "first"));
const { state, host } = await mountGraph(first, { saveProperty: () => pending.promise });
await select("shared");
host.querySelector<HTMLButtonElement>("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<GraphProperty>();
const second = await mountGraph(graph(node("c")), { saveProperty: () => unmountedSave.promise });
await select("c");
second.host.querySelector<HTMLButtonElement>("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<GraphProperty>();
const { state, host } = await mountGraph(graph(node("a"), node("b")), { saveProperty: () => pending.promise });
await select("a");
host.querySelector<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("aside button.hover\\:underline")!.click();
await flush();
await click(host, "save");
expect(host.querySelector<HTMLInputElement>('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<GraphResult>();
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");
});
});
@@ -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<ContentAreaSurfaceEmits>();
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<RedisResultViewMode>(() => (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<GraphProperty | undefined> {
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<GraphResult | undefined> {
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({
<ElasticsearchProfilePanel v-else-if="activeOutputView === 'profile' && canShowProfile" class="flex-1 min-h-0" :body="activeElasticsearchProfileBody ?? ''" />
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
<GraphResultView
v-else-if="activeOutputView === 'graph' && activeTab.result?.graph_data"
:key="graphViewKey"
:graph="activeTab.result.graph_data"
:rows="activeTab.result.rows"
:columns="activeTab.result.columns"
:read-only="connectionIsEffectivelyReadOnly(activeResultConnection)"
:save-property="saveNebulaGraphProperty"
:expand-node="expandNebulaGraphNode"
/>
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 min-w-0 overflow-auto bg-background">
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -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"
@@ -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<{
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from "vue";
import { BarChart3, ListChecks, MessageSquareText, SquareTerminal } from "@lucide/vue";
import { BarChart3, ListChecks, MessageSquareText, Network, SquareTerminal } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { RedisResultViewMode } from "@/types/database";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
type OutputView = "result" | "graph" | "summary" | "explain" | "chart" | "messages" | "profile";
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
const props = withDefaults(
@@ -15,6 +15,7 @@ const props = withDefaults(
canShowResult: boolean;
canShowSummary: boolean;
canShowChart: boolean;
canShowGraph?: boolean;
canShowMessages: boolean;
canShowRedisConsole?: boolean;
resultMode?: RedisResultViewMode;
@@ -59,6 +60,13 @@ function selectResultMode(mode: RedisResultViewMode) {
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
</Button>
<LightTooltip v-if="canShowGraph" :text="t('graph.title')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button size="sm" :variant="activeView === 'graph' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :class="compact ? 'w-6 px-0' : 'gap-1'" :title="t('graph.title')" :aria-label="t('graph.title')" :aria-pressed="activeView === 'graph'" @click="selectView('graph')">
<Network class="h-3.5 w-3.5" />
<span v-if="!compact">{{ t("graph.title") }}</span>
</Button>
</LightTooltip>
<LightTooltip v-if="canShowRedisConsole" :text="t('redis.commandLine')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
@@ -0,0 +1,202 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, onMounted, reactive, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database";
import type { GraphEdge, GraphNode, GraphProperty, GraphResult } from "@/lib/graph/graphResult";
interface GraphActions {
graph: GraphResult;
saveProperty: (entity: GraphNode | GraphEdge, property: GraphProperty, value: string | boolean) => Promise<GraphProperty | undefined>;
expandNode: (node: GraphNode) => Promise<GraphResult | undefined>;
}
const graphViews = vi.hoisted(() => [] as GraphActions[]);
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/grid/DataGridColumnLayoutPopover.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/graph/GraphResultView.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["graph", "rows", "columns", "readOnly", "saveProperty", "expandNode"],
setup(props) {
onMounted(() => graphViews.push(props as unknown as GraphActions));
return () => h("div", { "data-test": "graph-view" });
},
}),
}));
import ContentArea from "../ContentArea.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
function node(): GraphNode {
return { id: "shared", vid: { type: "string", value: "shared" }, labels: ["Person"], properties: [{ owner: "Person", name: "age", type: "int", value: "20" }] };
}
function result(count = 1): QueryResult {
return {
columns: ["v"],
rows: Array.from({ length: count }, () => ["original"]),
affected_rows: 0,
execution_time_ms: 0,
graph_data: { nodes: [node()], edges: [], cells: Array.from({ length: count }, (_, row) => ({ row, column: 0, kind: "vertex", nodeIds: ["shared"], edgeIds: [] })) },
};
}
function response(value: string): QueryResult {
return { columns: ["dbx_value"], rows: [[value]], affected_rows: 0, execution_time_ms: 0 };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
const mounted: { app: App; host: HTMLDivElement }[] = [];
async function flush() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
}
async function mountContentArea(production = false) {
const pinia = createPinia();
setActivePinia(pinia);
const connectionStore = useConnectionStore();
connectionStore.connections = ["a", "b"].map((id): ConnectionConfig => ({ id, name: `Synthetic ${id}`, db_type: "nebula", driver_profile: "nebula-v3", host: "localhost", port: 9669, username: "", password: "", is_production: production }));
const queryStore = useQueryStore();
queryStore.tabs = ["a", "b"].map((id): QueryTab => ({ id, title: id, connectionId: id, database: `space_${id}`, mode: "query", sql: "MATCH (v) RETURN v", isExecuting: false, isCancelling: false, isExplaining: false, resultViewGeneration: `generation-${id}`, result: result(id === "a" ? 1 : 2) }));
const state = reactive({ activeTab: queryStore.tabs[0] });
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup: () => () =>
h(ContentArea, {
activeTab: state.activeTab,
activeConnection: connectionStore.getConfig(state.activeTab.connectionId),
activeOutputView: "graph",
executableSql: state.activeTab.sql,
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
resultOnly: true,
}),
}),
);
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mounted.push({ app, host });
await flush();
expect(graphViews).toHaveLength(1);
return { state, queryStore, connectionStore, safetyStore: useProductionSafetyStore() };
}
function save(actions: GraphActions, value = "22") {
const entity = actions.graph.nodes[0];
return actions.saveProperty(entity, entity.properties[0], value);
}
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
graphViews.length = 0;
});
afterEach(() => {
for (const { app, host } of mounted.splice(0)) {
app.unmount();
host.remove();
}
});
describe("ContentArea graph actions", () => {
it("rejects a conditional update that returns a different stored value", async () => {
const execute = vi.spyOn(api, "executeQuery").mockResolvedValue(response("21"));
const { state } = await mountContentArea();
await expect(save(graphViews[0])).rejects.toThrow("graph.conflict");
expect(execute).toHaveBeenCalledWith("a", "space_a", expect.stringContaining("WHEN `age` == 20"), undefined, undefined, { maxRows: 1 });
expect(state.activeTab.result!.graph_data!.nodes[0].properties[0].value).toBe("20");
expect(state.activeTab.result!.rows[0][0]).toBe("original");
});
it("applies a confirmed returned value to the graph and direct table cells", async () => {
vi.spyOn(api, "executeQuery").mockResolvedValue(response("22"));
const { state } = await mountContentArea();
await expect(save(graphViews[0])).resolves.toEqual(expect.objectContaining({ value: "22" }));
expect(state.activeTab.result!.graph_data!.nodes[0].properties[0].value).toBe("22");
expect(state.activeTab.result!.rows[0][0]).toContain("age: 22");
});
it("discards an expansion when switching to a tab with a superset of the same IDs", async () => {
const pending = deferred<QueryResult>();
const execute = vi.spyOn(api, "executeQuery").mockReturnValue(pending.promise);
const { state, queryStore } = await mountContentArea();
const expansion = graphViews[0].expandNode(graphViews[0].graph.nodes[0]);
state.activeTab = queryStore.tabs[1];
await flush();
expect(graphViews).toHaveLength(2);
pending.resolve(result());
await expect(expansion).resolves.toBeUndefined();
expect(execute.mock.calls[0].slice(0, 2)).toEqual(["a", "space_a"]);
expect(state.activeTab.result!.graph_data!.nodes).toHaveLength(1);
});
it("does not dispatch a save if the tab changed during production confirmation", async () => {
const execute = vi.spyOn(api, "executeQuery").mockResolvedValue(response("22"));
const { state, queryStore, safetyStore } = await mountContentArea(true);
const confirmation = deferred<boolean>();
vi.spyOn(safetyStore, "requestConfirmation").mockReturnValue(confirmation.promise);
const saved = save(graphViews[0]);
state.activeTab = queryStore.tabs[1];
await flush();
confirmation.resolve(true);
await expect(saved).rejects.toThrow("graph.resultChanged");
expect(execute).not.toHaveBeenCalled();
});
it("does not apply a completed save to another tab or after returning to the old tab", async () => {
const pending = deferred<QueryResult>();
vi.spyOn(api, "executeQuery").mockReturnValue(pending.promise);
const { state, queryStore } = await mountContentArea();
const saved = save(graphViews[0]);
state.activeTab = queryStore.tabs[1];
await flush();
pending.resolve(response("22"));
await expect(saved).resolves.toBeUndefined();
expect(state.activeTab.result!.graph_data!.nodes[0].properties[0].value).toBe("20");
state.activeTab = queryStore.tabs[0];
await flush();
expect(state.activeTab.result!.graph_data!.nodes[0].properties[0].value).toBe("20");
});
it("reuses the view on append but remounts when the logical query generation changes", async () => {
const { state } = await mountContentArea();
state.activeTab.result = result(2);
await flush();
expect(graphViews).toHaveLength(1);
state.activeTab.resultViewGeneration = "new-query-with-the-same-node-ids";
await flush();
expect(graphViews).toHaveLength(2);
});
it("applies an in-flight save to the appended result of the same query", async () => {
const pending = deferred<QueryResult>();
vi.spyOn(api, "executeQuery").mockReturnValue(pending.promise);
const { state } = await mountContentArea();
const saved = save(graphViews[0]);
state.activeTab.result = result(2);
await flush();
pending.resolve(response("22"));
await expect(saved).resolves.toEqual(expect.objectContaining({ value: "22" }));
expect(state.activeTab.result!.rows.every((row) => String(row[0]).includes("age: 22"))).toBe(true);
});
});
@@ -14,7 +14,7 @@ afterEach(() => {
}
});
async function mountSwitcher(canShowRedisConsole: boolean) {
async function mountSwitcher(canShowRedisConsole: boolean, canShowGraph = false) {
const state = reactive<{ view: TabOutputView; mode?: RedisResultViewMode }>({ view: "result" });
const host = document.createElement("div");
document.body.appendChild(host);
@@ -28,6 +28,7 @@ async function mountSwitcher(canShowRedisConsole: boolean) {
canShowChart: false,
canShowMessages: false,
canShowRedisConsole,
canShowGraph,
resultMode: state.mode,
onSelectView: (view: TabOutputView) => {
state.view = view;
@@ -42,7 +43,7 @@ async function mountSwitcher(canShowRedisConsole: boolean) {
createI18n({
legacy: false,
locale: "en",
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, redis: { commandLine: "Command line" } } },
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, graph: { title: "Graph" }, redis: { commandLine: "Command line" } } },
}),
);
app.mount(host);
@@ -78,4 +79,17 @@ describe("QueryResultViewSwitcher Redis mode", () => {
const { host } = await mountSwitcher(false);
expect(host.querySelector('button[aria-label="Command line"]')).toBeNull();
});
it("shows the graph view only for graph results and switches back to the table", async () => {
const { host, state } = await mountSwitcher(false, true);
const graph = host.querySelector<HTMLButtonElement>('button[aria-label="Graph"]');
expect(graph).not.toBeNull();
graph?.click();
await nextTick();
expect(state.view).toBe("graph");
expect(graph?.getAttribute("aria-pressed")).toBe("true");
host.querySelector<HTMLButtonElement>("button")?.click();
await nextTick();
expect(state.view).toBe("result");
});
});
@@ -164,7 +164,7 @@ export function useSqlExecution(deps: {
activeConnection: ComputedRef<ConnectionConfig | undefined>;
executableSql: ComputedRef<string>;
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise<string>;
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">;
activeOutputView: Ref<"result" | "graph" | "summary" | "explain" | "chart" | "messages" | "profile">;
blockDangerousRedisCommands?: Ref<boolean>;
onMissingDatabase?: (tabId?: string) => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
+24
View File
@@ -83,6 +83,30 @@ export default {
templateHelp:
"Variables: table.name, table.schema, table.comment, class.name; inside a columns section: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Use double braces for variables, #columns and /columns for loops, #column.nullable and /column.nullable for conditional sections.",
},
graph: {
title: "Graph",
search: "Search graph",
nodes: "nodes",
edges: "edges",
records: "Records",
properties: "Properties",
expand: "Expand",
hide: "Hide",
pin: "Pin or unpin",
restoreHidden: "Show hidden elements",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
fit: "Fit graph",
layout: "Run layout",
exportPng: "Export PNG",
clearSelection: "Clear selection",
save: "Save",
cancel: "Cancel",
conflict: "The property changed in the database. Refresh the result and retry.",
unavailable: "Graph editing is unavailable for this connection.",
resultChanged: "The result or connection changed. Refresh and retry.",
updateFailed: "The property could not be updated.",
},
cellTransform: {
title: "Convert preview",
description: "Convert the current value for viewing or copying. The source value and editor draft stay unchanged.",
+24
View File
@@ -85,6 +85,30 @@ export default withEnglishFallback({
templateHelp:
"Variables: table.name, table.schema, table.comment, class.name; dentro de una sección de columnas: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Usa llaves dobles para las variables, #columns y /columns para los bucles, y #column.nullable y /column.nullable para las condiciones.",
},
graph: {
title: "Grafo",
search: "Buscar en el grafo",
nodes: "nodos",
edges: "aristas",
records: "Registros",
properties: "Propiedades",
expand: "Expandir",
hide: "Ocultar",
pin: "Fijar o liberar",
restoreHidden: "Mostrar elementos ocultos",
zoomIn: "Acercar",
zoomOut: "Alejar",
fit: "Ajustar grafo",
layout: "Reorganizar",
exportPng: "Exportar PNG",
clearSelection: "Deseleccionar",
save: "Guardar",
cancel: "Cancelar",
conflict: "La propiedad cambió en la base de datos. Actualiza el resultado y vuelve a intentarlo.",
unavailable: "Esta conexión no permite editar el grafo.",
resultChanged: "El resultado o la conexión cambió. Actualiza y vuelve a intentarlo.",
updateFailed: "No se pudo actualizar la propiedad.",
},
mongoDump,
cellTransform: {
title: "Vista previa de conversión",
+24
View File
@@ -83,6 +83,30 @@ export default withEnglishFallback({
templateHelp:
"Variabili: table.name, table.schema, table.comment, class.name; all'interno di una sezione columns: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Usa doppie parentesi graffe per le variabili, #columns e /columns per i cicli, #column.nullable e /column.nullable per le condizioni.",
},
graph: {
title: "Grafo",
search: "Cerca nel grafo",
nodes: "nodi",
edges: "archi",
records: "Record",
properties: "Proprietà",
expand: "Espandi",
hide: "Nascondi",
pin: "Fissa o sblocca",
restoreHidden: "Mostra gli elementi nascosti",
zoomIn: "Ingrandisci",
zoomOut: "Riduci",
fit: "Adatta il grafo",
layout: "Riorganizza",
exportPng: "Esporta PNG",
clearSelection: "Deseleziona",
save: "Salva",
cancel: "Annulla",
conflict: "La proprietà è cambiata nel database. Aggiorna il risultato e riprova.",
unavailable: "La modifica del grafo non è disponibile per questa connessione.",
resultChanged: "Il risultato o la connessione è cambiato. Aggiorna e riprova.",
updateFailed: "Impossibile aggiornare la proprietà.",
},
mongoDump,
cellTransform: {
title: "Anteprima conversione",
+24
View File
@@ -84,6 +84,30 @@ export default withEnglishFallback({
templateHelp:
"変数: table.name、table.schema、table.comment、class.name。columns セクション内では column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey を使用できます。変数は二重中括弧で囲み、#columns と /columns はループ、#column.nullable と /column.nullable は条件ブロックを表します。",
},
graph: {
title: "グラフ",
search: "グラフを検索",
nodes: "ノード",
edges: "エッジ",
records: "レコード",
properties: "プロパティ",
expand: "展開",
hide: "非表示",
pin: "固定または解除",
restoreHidden: "非表示の要素を表示",
zoomIn: "拡大",
zoomOut: "縮小",
fit: "全体表示",
layout: "再配置",
exportPng: "PNG をエクスポート",
clearSelection: "選択解除",
save: "保存",
cancel: "キャンセル",
conflict: "データベース内のプロパティが変更されました。結果を更新して再試行してください。",
unavailable: "この接続ではグラフを編集できません。",
resultChanged: "結果または接続が変更されました。更新して再試行してください。",
updateFailed: "プロパティを更新できませんでした。",
},
mongoDump,
cellTransform: {
title: "変換プレビュー",
+24
View File
@@ -83,6 +83,30 @@ export default withEnglishFallback({
templateHelp:
"변수: table.name, table.schema, table.comment, class.name. columns 섹션 안에서는 column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey를 사용할 수 있습니다. 변수는 중괄호 두 개로 감싸고, #columns와 /columns는 반복, #column.nullable와 /column.nullable는 조건 블록을 나타냅니다.",
},
graph: {
title: "그래프",
search: "그래프 검색",
nodes: "노드",
edges: "간선",
records: "레코드",
properties: "속성",
expand: "확장",
hide: "숨기기",
pin: "고정 또는 해제",
restoreHidden: "숨긴 요소 표시",
zoomIn: "확대",
zoomOut: "축소",
fit: "그래프 맞춤",
layout: "다시 배치",
exportPng: "PNG 내보내기",
clearSelection: "선택 해제",
save: "저장",
cancel: "취소",
conflict: "데이터베이스의 속성이 변경되었습니다. 결과를 새로고침한 뒤 다시 시도하세요.",
unavailable: "이 연결에서는 그래프를 편집할 수 없습니다.",
resultChanged: "결과 또는 연결이 변경되었습니다. 새로고침한 뒤 다시 시도하세요.",
updateFailed: "속성을 업데이트하지 못했습니다.",
},
mongoDump,
cellTransform: {
title: "변환 미리보기",
+24
View File
@@ -84,6 +84,30 @@ export default withEnglishFallback({
templateHelp:
"Variáveis: table.name, table.schema, table.comment, class.name; dentro de uma seção columns: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Use chaves duplas para variáveis, #columns e /columns para loops, e #column.nullable e /column.nullable para blocos condicionais.",
},
graph: {
title: "Grafo",
search: "Pesquisar no grafo",
nodes: "nós",
edges: "arestas",
records: "Registros",
properties: "Propriedades",
expand: "Expandir",
hide: "Ocultar",
pin: "Fixar ou desafixar",
restoreHidden: "Mostrar elementos ocultos",
zoomIn: "Ampliar",
zoomOut: "Reduzir",
fit: "Ajustar grafo",
layout: "Reorganizar",
exportPng: "Exportar PNG",
clearSelection: "Limpar seleção",
save: "Salvar",
cancel: "Cancelar",
conflict: "A propriedade foi alterada no banco de dados. Atualize o resultado e tente novamente.",
unavailable: "A edição do grafo não está disponível para esta conexão.",
resultChanged: "O resultado ou a conexão mudou. Atualize e tente novamente.",
updateFailed: "Não foi possível atualizar a propriedade.",
},
mongoDump,
cellTransform: {
title: "Prévia da conversão",
+24
View File
@@ -27,6 +27,30 @@ export default withEnglishFallback({
templateHelp:
"Переменные: table.name, table.schema, table.comment, class.name; внутри секции columns доступны column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Используйте двойные фигурные скобки для переменных, #columns и /columns для циклов, а #column.nullable и /column.nullable для условных блоков.",
},
graph: {
title: "Граф",
search: "Поиск по графу",
nodes: "узлы",
edges: "связи",
records: "Записи",
properties: "Свойства",
expand: "Развернуть",
hide: "Скрыть",
pin: "Закрепить или открепить",
restoreHidden: "Показать скрытые элементы",
zoomIn: "Увеличить",
zoomOut: "Уменьшить",
fit: "Уместить граф",
layout: "Перестроить",
exportPng: "Экспорт PNG",
clearSelection: "Снять выделение",
save: "Сохранить",
cancel: "Отмена",
conflict: "Свойство изменилось в базе данных. Обновите результат и повторите попытку.",
unavailable: "Для этого подключения редактирование графа недоступно.",
resultChanged: "Результат или подключение изменились. Обновите и повторите попытку.",
updateFailed: "Не удалось обновить свойство.",
},
docs,
dataDictionary,
cellTransform: {
+24
View File
@@ -29,6 +29,30 @@ export default withEnglishFallback({
templateHelp:
"变量:table.name、table.schema、table.comment、class.name;字段循环内可用 column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey。变量用双花括号包围,#columns 与 /columns 表示循环,#column.nullable 与 /column.nullable 表示条件块。",
},
graph: {
title: "图谱",
search: "搜索图谱",
nodes: "节点",
edges: "关系",
records: "记录",
properties: "属性",
expand: "展开",
hide: "隐藏",
pin: "固定或取消固定",
restoreHidden: "显示隐藏的对象",
zoomIn: "放大",
zoomOut: "缩小",
fit: "适应画布",
layout: "重新布局",
exportPng: "导出 PNG",
clearSelection: "取消选择",
save: "保存",
cancel: "取消",
conflict: "数据库中的属性已变化,请刷新结果后重试。",
unavailable: "当前连接不能编辑图谱。",
resultChanged: "结果或连接已变化,请刷新后重试。",
updateFailed: "属性更新失败。",
},
cellTransform: {
title: "转换预览",
description: "将当前内容转换后查看或复制,不改变原值和编辑草稿。",
+24
View File
@@ -85,6 +85,30 @@ export default withEnglishFallback({
templateHelp:
"變數:table.name、table.schema、table.comment、class.name;欄位迴圈內可用 column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey。變數使用雙大括號,#columns 與 /columns 表示迴圈,#column.nullable 與 /column.nullable 表示條件區塊。",
},
graph: {
title: "圖譜",
search: "搜尋圖譜",
nodes: "節點",
edges: "關係",
records: "記錄",
properties: "屬性",
expand: "展開",
hide: "隱藏",
pin: "固定或取消固定",
restoreHidden: "顯示隱藏的物件",
zoomIn: "放大",
zoomOut: "縮小",
fit: "符合畫布",
layout: "重新佈局",
exportPng: "匯出 PNG",
clearSelection: "取消選取",
save: "儲存",
cancel: "取消",
conflict: "資料庫中的屬性已變更,請重新整理結果後再試。",
unavailable: "目前連線無法編輯圖譜。",
resultChanged: "結果或連線已變更,請重新整理後再試。",
updateFailed: "屬性更新失敗。",
},
mongoDump,
cellTransform: {
title: "轉換預覽",
@@ -151,7 +151,7 @@ function restoredEditorViewport(tab: SavedOpenTab): QueryTab["editorViewport"] {
};
}
const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "summary", "explain", "chart", "messages", "profile"]);
const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "graph", "summary", "explain", "chart", "messages", "profile"]);
function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
const activeOutputView = tab.uiState?.activeOutputView;
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { extractGraphCells, graphResultRows, mergeGraphResults, type GraphEdge, type GraphNode } from "./graphResult";
import type { QueryResult } from "@/types/database";
const a: GraphNode = { id: "a", vid: { type: "string", value: "a" }, labels: ["Person"], properties: [{ owner: "Person", name: "name", type: "string", value: "Ada" }] };
const b: GraphNode = { id: "b", vid: { type: "string", value: "b" }, labels: [], properties: [] };
const edge: GraphEdge = { id: "e", source: "a", target: "b", sourceVid: a.vid, targetVid: b.vid, type: "WORK_IN", rank: "0", properties: [] };
function result(rows: unknown[][]): QueryResult {
return { columns: ["entity"], rows: rows as QueryResult["rows"], affected_rows: 0, execution_time_ms: 0 };
}
describe("graph result transport", () => {
it("extracts a typed graph cell while keeping the table display and is idempotent", () => {
const page = result([[{ __dbx_graph_cell: "nebula-v1", kind: "edge", display: "[:WORK_IN]", nodes: [a, b], edges: [edge] }], ["scalar"]]);
extractGraphCells(page);
expect(page.rows).toEqual([["[:WORK_IN]"], ["scalar"]]);
expect(page.graph_data?.nodes).toHaveLength(2);
expect(page.graph_data?.cells).toEqual([{ row: 0, column: 0, kind: "edge", nodeIds: ["a", "b"], edgeIds: ["e"] }]);
extractGraphCells(page);
expect(page.graph_data?.cells).toHaveLength(1);
});
it("deduplicates nodes and offsets cell references across pages", () => {
const first = extractGraphCells(result([[{ __dbx_graph_cell: "nebula-v1", kind: "vertex", display: "(a)", nodes: [b], edges: [] }]]));
const second = extractGraphCells(result([[{ __dbx_graph_cell: "nebula-v1", kind: "path", display: "<path>", nodes: [a, b], edges: [edge] }]]));
const merged = mergeGraphResults(first.graph_data, second.graph_data, 1);
expect(merged?.nodes).toHaveLength(2);
expect(merged?.nodes.find((node) => node.id === "a")?.properties[0].value).toBe("Ada");
expect(merged?.cells.map((cell) => cell.row)).toEqual([0, 1]);
expect(graphResultRows(second.graph_data, 0)).toEqual({ nodes: [], edges: [], cells: [] });
});
it("combines Tag properties for the same NebulaGraph VID", () => {
const otherTag: GraphNode = { ...a, labels: ["Employee"], properties: [{ owner: "Employee", name: "role", type: "string", value: "Engineer" }] };
const merged = mergeGraphResults({ nodes: [a], edges: [], cells: [] }, { nodes: [otherTag], edges: [], cells: [] });
expect(merged?.nodes[0].labels).toEqual(["Person", "Employee"]);
expect(merged?.nodes[0].properties.map((property) => `${property.owner}.${property.name}`)).toEqual(["Person.name", "Employee.role"]);
});
});
+153
View File
@@ -0,0 +1,153 @@
import type { QueryResult } from "@/types/database";
export interface GraphVid {
type: string;
value: string;
}
export interface GraphProperty {
owner: string;
name: string;
type: string;
value: string | boolean | null;
}
export interface GraphNode {
id: string;
vid?: GraphVid;
labels: string[];
properties: GraphProperty[];
}
export interface GraphEdge {
id: string;
source: string;
target: string;
sourceVid?: GraphVid;
targetVid?: GraphVid;
type: string;
rank?: string;
properties: GraphProperty[];
}
export interface GraphCellRef {
row: number;
column: number;
kind: string;
nodeIds: string[];
edgeIds: string[];
displayParts?: GraphDisplayPart[];
}
export type GraphDisplayPart = string | { nodeId: string } | { edgeId: string; path?: boolean };
export interface GraphResult {
nodes: GraphNode[];
edges: GraphEdge[];
cells: GraphCellRef[];
}
interface GraphCellEnvelope {
__dbx_graph_cell: "nebula-v1";
kind: string;
display: string;
nodes: GraphNode[];
edges: GraphEdge[];
displayParts?: GraphDisplayPart[];
}
function graphCellEnvelope(value: unknown): value is GraphCellEnvelope {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const cell = value as Partial<GraphCellEnvelope>;
return cell.__dbx_graph_cell === "nebula-v1" && typeof cell.display === "string" && typeof cell.kind === "string" && Array.isArray(cell.nodes) && Array.isArray(cell.edges);
}
function mergeProperties(left: GraphProperty[], right: GraphProperty[]): GraphProperty[] {
const values = new Map(left.map((property) => [`${property.owner}\u0000${property.name}`, property]));
for (const property of right) values.set(`${property.owner}\u0000${property.name}`, property);
return [...values.values()];
}
function mergeNode(left: GraphNode | undefined, right: GraphNode): GraphNode {
if (!left) return right;
return {
...left,
...right,
vid: right.vid ?? left.vid,
labels: [...new Set([...left.labels, ...right.labels])],
properties: mergeProperties(left.properties, right.properties),
};
}
function mergeEdge(left: GraphEdge | undefined, right: GraphEdge): GraphEdge {
if (!left) return right;
return { ...left, ...right, sourceVid: right.sourceVid ?? left.sourceVid, targetVid: right.targetVid ?? left.targetVid, rank: right.rank ?? left.rank, properties: mergeProperties(left.properties, right.properties) };
}
export function mergeGraphResults(left: GraphResult | undefined, right: GraphResult | undefined, rowOffset = 0): GraphResult | undefined {
if (!left && !right) return undefined;
const nodes = new Map<string, GraphNode>();
const edges = new Map<string, GraphEdge>();
const cells = [...(left?.cells ?? [])];
for (const node of [...(left?.nodes ?? []), ...(right?.nodes ?? [])]) {
nodes.set(node.id, mergeNode(nodes.get(node.id), node));
}
for (const edge of [...(left?.edges ?? []), ...(right?.edges ?? [])]) edges.set(edge.id, mergeEdge(edges.get(edge.id), edge));
for (const cell of right?.cells ?? []) cells.push({ ...cell, row: cell.row + rowOffset });
return { nodes: [...nodes.values()], edges: [...edges.values()], cells };
}
export function graphResultRows(graph: GraphResult | undefined, count: number): GraphResult | undefined {
if (!graph) return undefined;
const cells = graph.cells.filter((cell) => cell.row < count);
const nodeIds = new Set(cells.flatMap((cell) => cell.nodeIds));
const edgeIds = new Set(cells.flatMap((cell) => cell.edgeIds));
const edges = graph.edges.filter((edge) => edgeIds.has(edge.id));
for (const edge of edges) {
nodeIds.add(edge.source);
nodeIds.add(edge.target);
}
return { nodes: graph.nodes.filter((node) => nodeIds.has(node.id)), edges, cells };
}
export function extractGraphCells(result: QueryResult): QueryResult {
let rows: QueryResult["rows"] | undefined;
const nodes = new Map<string, GraphNode>();
const edges = new Map<string, GraphEdge>();
const cells: GraphCellRef[] = [];
result.rows.forEach((row, rowIndex) => {
row.forEach((value, columnIndex) => {
if (!graphCellEnvelope(value)) return;
rows ??= result.rows.map((original) => [...original]);
rows[rowIndex][columnIndex] = value.display;
for (const node of value.nodes) {
nodes.set(node.id, mergeNode(nodes.get(node.id), node));
}
for (const edge of value.edges) edges.set(edge.id, mergeEdge(edges.get(edge.id), edge));
cells.push({ row: rowIndex, column: columnIndex, kind: value.kind, nodeIds: [...new Set(value.nodes.map((node) => node.id))], edgeIds: [...new Set(value.edges.map((edge) => edge.id))], ...(value.displayParts ? { displayParts: value.displayParts } : {}) });
});
});
if (rows) result.rows = rows;
if (cells.length) result.graph_data = mergeGraphResults(result.graph_data, { nodes: [...nodes.values()], edges: [...edges.values()], cells });
return result;
}
export function updateGraphResultProperty(result: QueryResult, entity: GraphNode | GraphEdge, property: GraphProperty, updated: GraphProperty, formatNode: (node: GraphNode) => string, formatEdge: (edge: GraphEdge) => string, formatCell?: (cell: GraphCellRef) => string | undefined): void {
const graph = result.graph_data;
if (!graph) return;
const nodes = graph.nodes.filter((node) => node.id === entity.id);
const edges = graph.edges.filter((edge) => edge.id === entity.id);
for (const item of [...nodes, ...edges]) {
const target = item.properties.find((candidate) => candidate.owner === property.owner && candidate.name === property.name);
if (target) target.value = updated.value;
}
const affected = graph.cells.filter((cell) => cell.nodeIds.includes(entity.id) || cell.edgeIds.includes(entity.id));
if (!affected.length) return;
const rows = result.rows.map((row) => [...row]);
for (const cell of affected) {
const node = cell.kind === "vertex" ? nodes.find((candidate) => cell.nodeIds[0] === candidate.id) : undefined;
const edge = cell.kind === "edge" ? edges.find((candidate) => cell.edgeIds[0] === candidate.id) : undefined;
if (rows[cell.row]) rows[cell.row][cell.column] = formatCell?.(cell) ?? (node ? formatNode(node) : edge ? formatEdge(edge) : rows[cell.row][cell.column]);
}
result.rows = rows;
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { applyGraphPropertyToResult, buildNebulaGraphExpand, buildNebulaGraphPropertyUpdate, graphPropertyFromUpdateResult, graphPropertyMatchesValue } from "./nebulaGraph";
import { extractGraphCells, type GraphEdge, type GraphNode, type GraphProperty } from "./graphResult";
import type { QueryResult } from "@/types/database";
const node: GraphNode = { id: "n", vid: { type: "string", value: 'a"b' }, labels: ["Person"], properties: [{ owner: "Person", name: "age", type: "int", value: "9007199254740993" }] };
const edge: GraphEdge = { id: "e", source: "n", target: "m", sourceVid: node.vid, targetVid: { type: "string", value: "company" }, type: "WORK_IN", rank: "-2", properties: [{ owner: "", name: "active", type: "bool", value: true }] };
describe("Nebula graph actions", () => {
it("refreshes every ordered graph reference inside paths, lists, sets, and maps", () => {
const vertex = structuredClone(node);
const relationship = structuredClone(edge);
const cells = [
{ kind: "path", displayParts: ["<", { nodeId: "n" }, "-", { edgeId: "e", path: true }, "->", { nodeId: "n" }, ">"] },
{ kind: "list", displayParts: ["[", { nodeId: "n" }, ', "literal(n):9007199254740999", ', { edgeId: "e" }, "]"] },
{ kind: "set", displayParts: ["{", { edgeId: "e" }, ", ", { nodeId: "n" }, "}"] },
{ kind: "map", displayParts: ["{nested: [", { nodeId: "n" }, ", ", { nodeId: "n" }, "], large: 9007199254740999}"] },
];
const result = extractGraphCells({
columns: ["path", "list", "set", "map", "scalar"],
rows: [[...cells.map((cell) => ({ __dbx_graph_cell: "nebula-v1", display: "before", nodes: [vertex], edges: [relationship], ...cell })), "unchanged scalar"]] as unknown as QueryResult["rows"],
affected_rows: 0,
execution_time_ms: 0,
});
applyGraphPropertyToResult(result, vertex, vertex.properties[0], { ...vertex.properties[0], value: "9007199254740994" });
expect(result.rows[0].slice(0, 4).every((value) => String(value).includes("age: 9007199254740994"))).toBe(true);
expect(result.rows[0][1]).toContain('"literal(n):9007199254740999"');
expect(result.rows[0][3]).toContain("large: 9007199254740999");
applyGraphPropertyToResult(result, relationship, relationship.properties[0], { ...relationship.properties[0], value: false });
expect(result.rows[0].slice(0, 3).every((value) => String(value).includes("active: false"))).toBe(true);
expect(result.rows[0][0]).toContain("[:WORK_IN@-2 {active: false}]");
expect(result.rows[0][4]).toBe("unchanged scalar");
});
it("quotes identities and uses conditional updates for nodes and edges", () => {
expect(buildNebulaGraphPropertyUpdate(node, node.properties[0], "9007199254740994")).toBe('UPDATE VERTEX ON `Person` "a\\"b" SET `age` = 9007199254740994 WHEN `age` == 9007199254740993 YIELD `age` AS dbx_value');
expect(buildNebulaGraphPropertyUpdate(edge, edge.properties[0], false)).toBe('UPDATE EDGE ON `WORK_IN` "a\\"b" -> "company"@-2 SET `active` = false WHEN `active` == true YIELD `active` AS dbx_value');
expect(buildNebulaGraphExpand(node)).toContain('GET SUBGRAPH WITH PROP 1 STEPS FROM "a\\"b"');
});
it("rejects malformed numeric edits and missing Tag identity", () => {
expect(() => buildNebulaGraphPropertyUpdate(node, node.properties[0], "1; DELETE VERTEX")).toThrow();
expect(() => buildNebulaGraphPropertyUpdate(node, { ...node.properties[0], owner: "Other" }, "1")).toThrow();
});
it("requires returned values and updates linked direct result cells", () => {
const property = node.properties[0];
const result: QueryResult = { columns: ["v"], rows: [["old"]], affected_rows: 0, execution_time_ms: 0, graph_data: { nodes: [node], edges: [], cells: [{ row: 0, column: 0, kind: "vertex", nodeIds: ["n"], edgeIds: [] }] } };
const response: QueryResult = { columns: ["dbx_value"], rows: [["9007199254740994"]], affected_rows: 0, execution_time_ms: 0 };
const updated: GraphProperty = graphPropertyFromUpdateResult(response, property);
applyGraphPropertyToResult(result, node, property, updated);
expect(node.properties[0].value).toBe("9007199254740994");
expect(result.rows[0][0]).toContain("9007199254740994");
expect(() => graphPropertyFromUpdateResult({ ...response, rows: [] }, property)).toThrow();
});
it.each([
["int", "21", "22", false],
["int", "9007199254740993", "9007199254740992", false],
["int", "9007199254740993", "9007199254740993", true],
["int", "0", "-0", true],
["float", "100", "1e2", true],
["float", "1e-06", "0.000001", true],
["float", "-1e-06", "-0.000001", true],
["float", "1e+20", "100000000000000000000", true],
["float", "21.5", "22.5", false],
// A stored single-precision FLOAT echoes back through a float64 format;
// the promoted representation must still match the requested value.
["float", "3.1500000948905659", "3.15", true],
["float", "0.10000000149011612", "0.1", true],
["float", "3.1500000948905659", "3.16", false],
["float", "", "0", false],
["float", "NaN", "NaN", false],
["bool", true, false, false],
["bool", false, false, true],
["string", "old", "new", false],
["string", "new", "new", true],
] as const)("checks the stored %s value %s against requested %s", (type, stored, requested, expected) => {
expect(graphPropertyMatchesValue({ owner: "Person", name: "value", type, value: stored }, requested)).toBe(expected);
});
it("does not accept malformed booleans or inexact integers as saved values", () => {
const response: QueryResult = { columns: ["dbx_value"], rows: [["unexpected"]], affected_rows: 0, execution_time_ms: 0 };
expect(() => graphPropertyFromUpdateResult(response, edge.properties[0])).toThrow("boolean");
expect(() => graphPropertyFromUpdateResult({ ...response, rows: [[9007199254740992]] }, node.properties[0])).toThrow("integer");
});
});
+116
View File
@@ -0,0 +1,116 @@
import { updateGraphResultProperty, type GraphEdge, type GraphNode, type GraphProperty, type GraphVid } from "./graphResult";
import type { QueryResult } from "@/types/database";
function quoteIdentifier(value: string): string {
if (
!value ||
[...value].some((char) => {
const code = char.codePointAt(0) ?? 0;
return code < 32 || (code >= 127 && code <= 159);
})
)
throw new Error("Invalid NebulaGraph identifier");
return `\`${value.replaceAll("\\", "\\\\").replaceAll("`", "\\`")}\``;
}
function quoteVid(vid: GraphVid | undefined): string {
if (!vid) throw new Error("Missing NebulaGraph vertex ID");
if (vid.type === "string") return JSON.stringify(vid.value);
if (vid.type === "int" && /^-?(?:0|[1-9]\d*)$/u.test(vid.value)) return vid.value;
throw new Error("Unsupported NebulaGraph vertex ID");
}
function quoteValue(type: string, value: string | boolean): string {
if (type === "string" && typeof value === "string") return JSON.stringify(value);
if (type === "bool" && typeof value === "boolean") return value ? "true" : "false";
if (type === "int" && typeof value === "string" && /^-?(?:0|[1-9]\d*)$/u.test(value)) return value;
if (type === "float" && typeof value === "string" && /^(?:-?)(?:\d+\.\d*|\d*\.\d+|\d+)(?:[eE][+-]?\d+)?$/u.test(value) && Number.isFinite(Number(value))) return value;
throw new Error("Invalid value for this NebulaGraph property type");
}
export function buildNebulaGraphPropertyUpdate(entity: GraphNode | GraphEdge, property: GraphProperty, value: string | boolean): string {
const oldValue = property.value;
if (oldValue === null) throw new Error("A null property needs its schema type before it can be edited");
const name = quoteIdentifier(property.name);
const next = quoteValue(property.type, value);
const previous = quoteValue(property.type, oldValue);
if ("labels" in entity) {
if (!property.owner || !entity.labels.includes(property.owner)) throw new Error("Missing NebulaGraph Tag identity");
return `UPDATE VERTEX ON ${quoteIdentifier(property.owner)} ${quoteVid(entity.vid)} SET ${name} = ${next} WHEN ${name} == ${previous} YIELD ${name} AS dbx_value`;
}
if (property.owner || !entity.rank || !/^-?\d+$/u.test(entity.rank)) throw new Error("Invalid NebulaGraph Edge identity");
return `UPDATE EDGE ON ${quoteIdentifier(entity.type)} ${quoteVid(entity.sourceVid)} -> ${quoteVid(entity.targetVid)}@${entity.rank} SET ${name} = ${next} WHEN ${name} == ${previous} YIELD ${name} AS dbx_value`;
}
export function buildNebulaGraphExpand(node: GraphNode): string {
return `GET SUBGRAPH WITH PROP 1 STEPS FROM ${quoteVid(node.vid)} YIELD VERTICES AS nodes, EDGES AS relationships`;
}
export function graphPropertyFromUpdateResult(result: QueryResult, property: GraphProperty): GraphProperty {
if (result.execution_error) throw new Error(result.error?.detail ?? "NebulaGraph update failed");
if (result.rows.length !== 1 || !result.rows[0]?.length || result.rows[0][0] === null) throw new Error("The NebulaGraph property was not updated");
const value = result.rows[0][0];
if (property.type === "bool" && ![true, false, "true", "false"].includes(value as string | boolean)) throw new Error("Invalid returned graph boolean");
if (property.type === "int" && typeof value === "number" && !Number.isSafeInteger(value)) throw new Error("Inexact returned graph integer");
return { ...property, value: property.type === "bool" ? value === true || value === "true" : String(value) };
}
export function graphPropertyMatchesValue(property: GraphProperty, value: string | boolean): boolean {
if (property.type === "int" && typeof property.value === "string" && typeof value === "string") {
return /^-?\d+$/u.test(property.value) && /^-?\d+$/u.test(value) && BigInt(property.value) === BigInt(value);
}
if (property.type === "float" && typeof property.value === "string" && typeof value === "string") {
// Nebula FLOAT is single-precision and the agent echoes the YIELDed value
// through a float64 format, so a stored 3.15 round-trips as
// "3.1500000948905659"; compare at binary32 precision to keep genuine
// concurrent updates detectable without false conflicts.
return property.value.trim() !== "" && value.trim() !== "" && Number.isFinite(Number(property.value)) && Number.isFinite(Number(value)) && Math.fround(Number(property.value)) === Math.fround(Number(value));
}
return property.value === value;
}
function propertyDisplay(property: GraphProperty): string {
if (property.value === null) return "NULL";
return property.type === "string" ? JSON.stringify(property.value) : String(property.value);
}
function nodeDisplay(node: GraphNode): string {
const tags = node.labels.map((label) => {
const properties = node.properties
.filter((property) => property.owner === label)
.map((property) => `${property.name}: ${propertyDisplay(property)}`)
.join(", ");
return `${label}{${properties}}`;
});
return `(${quoteVid(node.vid)}${tags.length ? ` :${tags.join(" :")}` : ""})`;
}
function edgeDisplay(edge: GraphEdge, path = false): string {
const properties = edge.properties.map((property) => `${property.name}: ${propertyDisplay(property)}`).join(", ");
if (path) return `[:${edge.type}@${edge.rank} {${properties}}]`;
return `[:${edge.type} ${quoteVid(edge.sourceVid)}->${quoteVid(edge.targetVid)} @${edge.rank} {${properties}}]`;
}
export function applyGraphPropertyToResult(result: QueryResult, entity: GraphNode | GraphEdge, property: GraphProperty, updated: GraphProperty): void {
const graph = result.graph_data;
if (!graph) return;
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
const edges = new Map(graph.edges.map((edge) => [edge.id, edge]));
updateGraphResultProperty(result, entity, property, updated, nodeDisplay, edgeDisplay, (cell) => {
if (!cell.displayParts) return undefined;
const parts: string[] = [];
for (const part of cell.displayParts) {
if (typeof part === "string") parts.push(part);
else if ("nodeId" in part) {
const node = nodes.get(part.nodeId);
if (!node?.vid) return undefined;
parts.push(nodeDisplay(node));
} else {
const edge = edges.get(part.edgeId);
if (!edge?.sourceVid || !edge.targetVid) return undefined;
parts.push(edgeDisplay(edge, part.path));
}
}
return parts.join("");
});
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { appendQueryResultSegment } from "@/stores/queryStore";
import { extractGraphCells, type GraphNode } from "@/lib/graph/graphResult";
import type { QueryResult } from "@/types/database";
const node: GraphNode = { id: "node-a", vid: { type: "string", value: "a" }, labels: ["Person"], properties: [] };
function page(text: string): QueryResult {
return {
columns: ["vertex"],
rows: [[{ __dbx_graph_cell: "nebula-v1", kind: "vertex", display: text, nodes: [node], edges: [] }]] as unknown as QueryResult["rows"],
affected_rows: 0,
execution_time_ms: 1,
};
}
describe("query result graph paging", () => {
it("keeps table cells scalar and offsets graph references when a page is appended", () => {
const first = extractGraphCells(page("(a)"));
const combined = appendQueryResultSegment(first, page("(a :Person)"), 2);
expect(combined.rows).toEqual([["(a)"], ["(a :Person)"]]);
expect(combined.graph_data?.nodes).toHaveLength(1);
expect(combined.graph_data?.cells.map((cell) => cell.row)).toEqual([0, 1]);
expect(appendQueryResultSegment(first, page("ignored"), 1).graph_data?.cells).toHaveLength(1);
});
});
+5
View File
@@ -74,6 +74,7 @@ import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { extractGraphCells, graphResultRows, mergeGraphResults } from "@/lib/graph/graphResult";
import { formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
import { usesAgentCursorForQuery, usesAgentCursorForTableData } from "@/lib/database/databaseDriverManifest";
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
@@ -312,6 +313,7 @@ function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): S
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
extractNeo4jNodeCells(result);
extractGraphCells(result);
markRaw(result.rows);
if (result.neo4j_node_cells) markRaw(result.neo4j_node_cells);
if (result.large_value_cells) markRaw(result.large_value_cells);
@@ -334,6 +336,7 @@ function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | u
}
export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
markQueryResultRowsRaw(segment);
if (segment.execution_error) {
throw segment.error ? new BackendErrorException(segment.error) : new BackendErrorException(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
}
@@ -367,6 +370,7 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
neo4j_node_cells: appendNeo4jNodeCells(previous, segment, appendedRowCount),
graph_data: mergeGraphResults(previous.graph_data, graphResultRows(segment.graph_data, appendedRowCount), previous.rows.length),
spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined,
spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values),
large_value_cells: appendLargeValueCells(previous.large_value_cells, segment.large_value_cells, previous.rows.length, appendedRowCount),
@@ -402,6 +406,7 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns
function releaseResultObjectPayload(result: QueryResult): void {
result.columns = [];
result.rows = [];
result.graph_data = undefined;
result.column_types = undefined;
result.column_sortables = undefined;
result.spatial_columns = undefined;
+5
View File
@@ -0,0 +1,5 @@
declare module "cytoscape-fcose" {
import type cytoscape from "cytoscape";
const fcose: cytoscape.Ext;
export default fcose;
}
+3 -1
View File
@@ -4,6 +4,7 @@ import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution";
import type { DatabaseType } from "@/types/generated/databaseTypes";
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
import type { GraphResult } from "@/lib/graph/graphResult";
export type { DatabaseType } from "@/types/generated/databaseTypes";
@@ -1306,6 +1307,7 @@ export interface QueryResult {
columns: string[];
/** Typed Neo4j node properties; source columns remain unchanged for paging. */
neo4j_node_cells?: import("@/lib/neo4j/neo4jNodeResult").Neo4jNodeCell[];
graph_data?: GraphResult;
/** One SRID per geometry/geography column (first non-null observed). */
spatial_columns?: SpatialColumn[];
/**
@@ -1921,7 +1923,7 @@ export interface QueryPageJumpProgress {
targetPage: number;
}
export type TabOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
export type TabOutputView = "result" | "graph" | "summary" | "explain" | "chart" | "messages" | "profile";
export type RedisResultViewMode = "grid" | "console";
@@ -1103,6 +1103,9 @@ async fn export_query_result_core_inner(
result.rows.truncate(this_page);
}
let row_count = result.rows.len();
if request.database_type == DatabaseType::Nebula {
crate::text_export::normalize_nebula_export_rows(&mut result.rows);
}
let formatted_rows = crate::temporal_format::format_temporal_export_rows_with_string_types_cow(
&result.rows,
&page_column_types,
+63 -1
View File
@@ -12,6 +12,20 @@ pub struct QueryResultTextExportData {
pub rows: Vec<Vec<Value>>,
}
pub fn normalize_nebula_export_rows(rows: &mut [Vec<Value>]) {
for value in rows.iter_mut().flatten() {
let graph_cell = value.get("__dbx_graph_cell").and_then(Value::as_str) == Some("nebula-v1")
&& value.get("kind").is_some_and(Value::is_string)
&& value.get("nodes").is_some_and(Value::is_array)
&& value.get("edges").is_some_and(Value::is_array);
if graph_cell {
if let Some(display) = value.get("display").and_then(Value::as_str) {
*value = Value::String(display.to_owned());
}
}
}
}
pub fn format_json(data: &QueryResultTextExportData) -> Result<String, String> {
let rows = data
.rows
@@ -428,7 +442,55 @@ fn chrono_local_now() -> String {
mod tests {
use serde_json::{json, Value};
use super::{format_html, format_json, format_markdown, QueryResultTextExportData};
use super::{format_html, format_json, format_markdown, normalize_nebula_export_rows, QueryResultTextExportData};
#[test]
fn nebula_exports_use_display_cells_without_leaking_graph_metadata() {
let mut rows = vec![
vec![
json!({
"__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": "(:\"player\" {\"name\":\"Tim\"})",
"nodes": [{"id": "vid:1", "labels": ["player"], "properties": [{"name": "name", "value": "Tim"}]}], "edges": []
}),
json!(false),
Value::Null,
],
vec![
json!({"__dbx_graph_cell": "nebula-v1", "kind": "edge", "display": "[:follow]", "nodes": [], "edges": [{"type": "follow"}]}),
json!(7),
json!("plain"),
],
];
normalize_nebula_export_rows(&mut rows);
assert_eq!(rows[0], vec![json!("(:\"player\" {\"name\":\"Tim\"})"), json!(false), Value::Null]);
assert_eq!(rows[1], vec![json!("[:follow]"), json!(7), json!("plain")]);
let data = QueryResultTextExportData {
title: None,
columns: vec!["entity".into(), "value".into(), "note".into()],
rows,
};
let exported: Value = serde_json::from_str(&format_json(&data).unwrap()).unwrap();
assert_eq!(exported[0]["entity"], json!("(:\"player\" {\"name\":\"Tim\"})"));
assert!(!format_markdown(&data).contains("__dbx_graph_cell"));
}
#[test]
fn nebula_export_normalization_preserves_ordinary_and_unknown_objects() {
let mut rows = vec![vec![
json!({"display": "ordinary", "properties": []}),
json!({"__dbx_graph_cell": "neo4j-v1", "kind": "vertex", "display": "neo4j", "nodes": [], "edges": []}),
json!({"__dbx_graph_cell": "nebula-v2", "kind": "vertex", "display": "future", "nodes": [], "edges": []}),
json!({"__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": 42, "nodes": [], "edges": []}),
json!({"__dbx_graph_cell": "nebula-v1", "kind": "vertex", "display": "malformed", "nodes": null, "edges": []}),
]];
let original = rows.clone();
normalize_nebula_export_rows(&mut rows);
assert_eq!(rows[0][0], json!({"display": "ordinary", "properties": []}));
assert_eq!(rows[0][1], original[0][1]);
assert_eq!(rows[0][2], original[0][2]);
assert_eq!(rows[0][3], original[0][3]);
assert_eq!(rows[0][4], original[0][4]);
}
#[test]
fn formats_json_rows_as_objects() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+2 -2
View File
@@ -184,7 +184,7 @@
# ------------------------------------------------------------------ #
packages.dbx-desktop = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "dbx-desktop";
version = "0.6.36";
version = "0.6.28";
src = pkgs.lib.cleanSource ./.;
@@ -197,7 +197,7 @@
fetcherVersion = 4;
# Update with the hash reported by a failed fixed-output build:
# nix build .#dbx-pnpm-deps 2>&1 | grep 'got:'
hash = "sha256-WH7jvgDWvh8coatfWsdZNzsjElVDPyB48CV0HkTcqj4=";
hash = "sha256-Bo0dVkNR2jupcSthmcrjJw2aAsJ+fMhe3g82T4H8u2M=";
};
# ── Step 2: vendor Cargo dependencies ───────────────────────────── #
+2
View File
@@ -95,6 +95,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.34.3",
"cytoscape-fcose": "^2.2.0",
"dayjs": "^1.11.21",
"diff": "^9.0.0",
"dom-to-image-more": "^3.7.2",
+34
View File
@@ -144,6 +144,12 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
cytoscape:
specifier: ^3.34.3
version: 3.34.3
cytoscape-fcose:
specifier: ^2.2.0
version: 2.2.0(cytoscape@3.34.3)
dayjs:
specifier: ^1.11.21
version: 1.11.21
@@ -2079,6 +2085,9 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -2104,6 +2113,15 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
cytoscape-fcose@2.2.0:
resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
peerDependencies:
cytoscape: ^3.2.0
cytoscape@3.34.3:
resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==}
engines: {node: '>=0.10'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
@@ -2827,6 +2845,9 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
layout-base@2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
@@ -5799,6 +5820,10 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cose-base@2.2.0:
dependencies:
layout-base: 2.0.1
crelt@1.0.6: {}
cross-spawn@7.0.6:
@@ -5827,6 +5852,13 @@ snapshots:
csstype@3.2.3: {}
cytoscape-fcose@2.2.0(cytoscape@3.34.3):
dependencies:
cose-base: 2.2.0
cytoscape: 3.34.3
cytoscape@3.34.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
@@ -6529,6 +6561,8 @@ snapshots:
kleur@3.0.3: {}
layout-base@2.0.1: {}
leaflet@1.9.4: {}
levn@0.4.1: