mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(graph): visualize and edit NebulaGraph query results
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
|
||||
+202
@@ -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);
|
||||
});
|
||||
});
|
||||
+16
-2
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "変換プレビュー",
|
||||
|
||||
@@ -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: "변환 미리보기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: "将当前内容转换后查看或复制,不改变原值和编辑草稿。",
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
declare module "cytoscape-fcose" {
|
||||
import type cytoscape from "cytoscape";
|
||||
const fcose: cytoscape.Ext;
|
||||
export default fcose;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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 ───────────────────────────── #
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+34
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user