radial sugiyama positioning integration
This commit is contained in:
@@ -1,23 +1,34 @@
|
||||
ARG GO_VERSION=1.24
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
FROM rust:bookworm AS rust-builder
|
||||
|
||||
WORKDIR /src
|
||||
WORKDIR /src/radial_sugiyama
|
||||
|
||||
COPY go.mod /src/go.mod
|
||||
COPY radial_sugiyama /src/radial_sugiyama
|
||||
|
||||
RUN cargo build --release --bin radial_sugiyama_go_bridge
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS go-builder
|
||||
|
||||
WORKDIR /src/backend_go
|
||||
|
||||
COPY backend_go/go.mod /src/backend_go/go.mod
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . /src
|
||||
COPY backend_go /src/backend_go
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/backend ./
|
||||
|
||||
FROM alpine:3.20
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/backend /app/backend
|
||||
COPY --from=go-builder /out/backend /app/backend
|
||||
COPY --from=rust-builder /src/radial_sugiyama/target/release/radial_sugiyama_go_bridge /app/radial_sugiyama_go_bridge
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ type Config struct {
|
||||
SparqlReadyDelay time.Duration
|
||||
SparqlReadyTimeout time.Duration
|
||||
|
||||
HierarchyLayoutEngine string
|
||||
HierarchyLayoutBridgeBin string
|
||||
HierarchyLayoutBridgeWorkdir string
|
||||
HierarchyLayoutTimeout time.Duration
|
||||
HierarchyLayoutRootIRI string
|
||||
|
||||
ListenAddr string
|
||||
}
|
||||
|
||||
@@ -64,6 +70,11 @@ func LoadConfig() (Config, error) {
|
||||
SparqlLoadOnStart: envBool("SPARQL_LOAD_ON_START", false),
|
||||
SparqlClearOnStart: envBool("SPARQL_CLEAR_ON_START", false),
|
||||
|
||||
HierarchyLayoutEngine: envString("HIERARCHY_LAYOUT_ENGINE", "go"),
|
||||
HierarchyLayoutBridgeBin: envString("HIERARCHY_LAYOUT_BRIDGE_BIN", "/app/radial_sugiyama_go_bridge"),
|
||||
HierarchyLayoutBridgeWorkdir: envString("HIERARCHY_LAYOUT_BRIDGE_WORKDIR", "/workspace/radial_sugiyama"),
|
||||
HierarchyLayoutRootIRI: envString("HIERARCHY_LAYOUT_ROOT_IRI", "http://purl.obolibrary.org/obo/BFO_0000001"),
|
||||
|
||||
SparqlReadyRetries: envInt("SPARQL_READY_RETRIES", 30),
|
||||
ListenAddr: envString("LISTEN_ADDR", ":8000"),
|
||||
}
|
||||
@@ -81,6 +92,10 @@ func LoadConfig() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.HierarchyLayoutTimeout, err = envSeconds("HIERARCHY_LAYOUT_TIMEOUT_S", 60)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.SparqlLoadOnStart && strings.TrimSpace(cfg.SparqlDataFile) == "" {
|
||||
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
||||
@@ -110,6 +125,24 @@ func LoadConfig() (Config, error) {
|
||||
if cfg.EdgeBatchSize > cfg.MaxEdgeLimit {
|
||||
return Config{}, fmt.Errorf("EDGE_BATCH_SIZE must be <= MAX_EDGE_LIMIT")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.HierarchyLayoutEngine)) {
|
||||
case "go", "rust":
|
||||
cfg.HierarchyLayoutEngine = strings.ToLower(strings.TrimSpace(cfg.HierarchyLayoutEngine))
|
||||
default:
|
||||
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_ENGINE must be 'go' or 'rust'")
|
||||
}
|
||||
if strings.TrimSpace(cfg.HierarchyLayoutBridgeBin) == "" {
|
||||
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_BRIDGE_BIN must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.HierarchyLayoutBridgeWorkdir) == "" {
|
||||
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_BRIDGE_WORKDIR must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.HierarchyLayoutRootIRI) == "" {
|
||||
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_ROOT_IRI must not be empty")
|
||||
}
|
||||
if cfg.HierarchyLayoutTimeout <= 0 {
|
||||
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_TIMEOUT_S must be > 0")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -168,40 +168,56 @@ func fetchGraphSnapshot(
|
||||
|
||||
nodes := acc.nodes
|
||||
edges := acc.edges
|
||||
routeSegments := []RouteSegment(nil)
|
||||
layoutEngine := "go"
|
||||
var layoutRootIRI *string
|
||||
|
||||
// Layout: invert edges for hierarchy (target -> source).
|
||||
hierEdges := make([][2]int, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
hierEdges = append(hierEdges, [2]int{int(e.Target), int(e.Source)})
|
||||
}
|
||||
|
||||
layers, cycleErr := levelSynchronousKahnLayers(len(nodes), hierEdges)
|
||||
if cycleErr != nil {
|
||||
sample := make([]string, 0, 20)
|
||||
for _, nid := range cycleErr.RemainingNodeIDs {
|
||||
if len(sample) >= 20 {
|
||||
break
|
||||
}
|
||||
if nid >= 0 && nid < len(nodes) {
|
||||
sample = append(sample, nodes[nid].IRI)
|
||||
}
|
||||
if shouldUseRustHierarchyLayout(cfg, graphQueryID) {
|
||||
layoutResult, err := layoutHierarchyWithRust(ctx, cfg, nodes, edges, preds)
|
||||
if err != nil {
|
||||
return GraphResponse{}, err
|
||||
}
|
||||
nodes = layoutResult.Nodes
|
||||
edges = layoutResult.Edges
|
||||
routeSegments = layoutResult.RouteSegments
|
||||
layoutEngine = rustHierarchyLayoutEngineID
|
||||
rootIRI := cfg.HierarchyLayoutRootIRI
|
||||
layoutRootIRI = &rootIRI
|
||||
} else {
|
||||
// Layout: invert edges for hierarchy (target -> source).
|
||||
hierEdges := make([][2]int, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
hierEdges = append(hierEdges, [2]int{int(e.Target), int(e.Source)})
|
||||
}
|
||||
cycleErr.RemainingIRISample = sample
|
||||
return GraphResponse{}, cycleErr
|
||||
}
|
||||
|
||||
idToIRI := make([]string, len(nodes))
|
||||
for i := range nodes {
|
||||
idToIRI[i] = nodes[i].IRI
|
||||
}
|
||||
for _, layer := range layers {
|
||||
sortLayerByIRI(layer, idToIRI)
|
||||
}
|
||||
layers, cycleErr := levelSynchronousKahnLayers(len(nodes), hierEdges)
|
||||
if cycleErr != nil {
|
||||
sample := make([]string, 0, 20)
|
||||
for _, nid := range cycleErr.RemainingNodeIDs {
|
||||
if len(sample) >= 20 {
|
||||
break
|
||||
}
|
||||
if nid >= 0 && nid < len(nodes) {
|
||||
sample = append(sample, nodes[nid].IRI)
|
||||
}
|
||||
}
|
||||
cycleErr.RemainingIRISample = sample
|
||||
return GraphResponse{}, cycleErr
|
||||
}
|
||||
|
||||
xs, ys := radialPositionsFromLayers(len(nodes), layers, 5000.0)
|
||||
for i := range nodes {
|
||||
nodes[i].X = xs[i]
|
||||
nodes[i].Y = ys[i]
|
||||
idToIRI := make([]string, len(nodes))
|
||||
for i := range nodes {
|
||||
idToIRI[i] = nodes[i].IRI
|
||||
}
|
||||
for _, layer := range layers {
|
||||
sortLayerByIRI(layer, idToIRI)
|
||||
}
|
||||
|
||||
xs, ys := radialPositionsFromLayers(len(nodes), layers, 5000.0)
|
||||
for i := range nodes {
|
||||
nodes[i].X = xs[i]
|
||||
nodes[i].Y = ys[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Attach labels for URI nodes.
|
||||
@@ -240,9 +256,11 @@ func fetchGraphSnapshot(
|
||||
EdgeLimit: edgeLimit,
|
||||
Nodes: len(nodes),
|
||||
Edges: len(edges),
|
||||
LayoutEngine: layoutEngine,
|
||||
LayoutRootIRI: layoutRootIRI,
|
||||
}
|
||||
|
||||
return GraphResponse{Nodes: nodes, Edges: edges, Meta: meta}, nil
|
||||
return GraphResponse{Nodes: nodes, Edges: edges, RouteSegments: routeSegments, Meta: meta}, nil
|
||||
}
|
||||
|
||||
type bestLabel struct {
|
||||
|
||||
268
backend_go/hierarchy_layout_bridge.go
Normal file
268
backend_go/hierarchy_layout_bridge.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hierarchyGraphQueryID = "hierarchy"
|
||||
rustHierarchyLayoutEngineID = "rust_radial_sugiyama"
|
||||
)
|
||||
|
||||
type hierarchyLayoutResult struct {
|
||||
Nodes []Node
|
||||
Edges []Edge
|
||||
RouteSegments []RouteSegment
|
||||
}
|
||||
|
||||
type hierarchyLayoutPrepared struct {
|
||||
Request hierarchyLayoutRequest
|
||||
NormalizedEdges []Edge
|
||||
}
|
||||
|
||||
type hierarchyLayoutRequest struct {
|
||||
RootIRI string `json:"root_iri"`
|
||||
Nodes []hierarchyLayoutRequestNode `json:"nodes"`
|
||||
Edges []hierarchyLayoutRequestEdge `json:"edges"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutRequestNode struct {
|
||||
NodeID uint32 `json:"node_id"`
|
||||
IRI string `json:"iri"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutRequestEdge struct {
|
||||
EdgeIndex int `json:"edge_index"`
|
||||
ParentID uint32 `json:"parent_id"`
|
||||
ChildID uint32 `json:"child_id"`
|
||||
PredicateIRI *string `json:"predicate_iri,omitempty"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutResponse struct {
|
||||
Nodes []hierarchyLayoutResponseNode `json:"nodes"`
|
||||
RouteSegments []hierarchyLayoutResponseRouteSegment `json:"route_segments"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutResponseNode struct {
|
||||
NodeID uint32 `json:"node_id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutResponseRouteSegment struct {
|
||||
EdgeIndex int `json:"edge_index"`
|
||||
Kind string `json:"kind"`
|
||||
Points []hierarchyLayoutResponseRoutePoint `json:"points"`
|
||||
}
|
||||
|
||||
type hierarchyLayoutResponseRoutePoint struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type hierarchyEdgeKey struct {
|
||||
ParentID uint32
|
||||
ChildID uint32
|
||||
}
|
||||
|
||||
func shouldUseRustHierarchyLayout(cfg Config, graphQueryID string) bool {
|
||||
return cfg.HierarchyLayoutEngine == "rust" && graphQueryID == hierarchyGraphQueryID
|
||||
}
|
||||
|
||||
func prepareHierarchyLayoutRequest(
|
||||
rootIRI string,
|
||||
nodes []Node,
|
||||
edges []Edge,
|
||||
preds *PredicateDict,
|
||||
) hierarchyLayoutPrepared {
|
||||
requestNodes := make([]hierarchyLayoutRequestNode, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
requestNodes = append(requestNodes, hierarchyLayoutRequestNode{
|
||||
NodeID: node.ID,
|
||||
IRI: node.IRI,
|
||||
})
|
||||
}
|
||||
|
||||
predicateIRIs := []string(nil)
|
||||
if preds != nil {
|
||||
predicateIRIs = preds.IRIs()
|
||||
}
|
||||
|
||||
seenEdges := make(map[hierarchyEdgeKey]struct{}, len(edges))
|
||||
normalizedEdges := make([]Edge, 0, len(edges))
|
||||
requestEdges := make([]hierarchyLayoutRequestEdge, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
parentID := edge.Target
|
||||
childID := edge.Source
|
||||
if parentID == childID {
|
||||
continue
|
||||
}
|
||||
|
||||
key := hierarchyEdgeKey{ParentID: parentID, ChildID: childID}
|
||||
if _, ok := seenEdges[key]; ok {
|
||||
continue
|
||||
}
|
||||
seenEdges[key] = struct{}{}
|
||||
|
||||
normalizedEdges = append(normalizedEdges, edge)
|
||||
|
||||
var predicateIRI *string
|
||||
if int(edge.PredicateID) >= 0 && int(edge.PredicateID) < len(predicateIRIs) {
|
||||
value := predicateIRIs[edge.PredicateID]
|
||||
if strings.TrimSpace(value) != "" {
|
||||
predicateIRI = &value
|
||||
}
|
||||
}
|
||||
|
||||
requestEdges = append(requestEdges, hierarchyLayoutRequestEdge{
|
||||
EdgeIndex: len(normalizedEdges) - 1,
|
||||
ParentID: parentID,
|
||||
ChildID: childID,
|
||||
PredicateIRI: predicateIRI,
|
||||
})
|
||||
}
|
||||
|
||||
return hierarchyLayoutPrepared{
|
||||
Request: hierarchyLayoutRequest{
|
||||
RootIRI: rootIRI,
|
||||
Nodes: requestNodes,
|
||||
Edges: requestEdges,
|
||||
},
|
||||
NormalizedEdges: normalizedEdges,
|
||||
}
|
||||
}
|
||||
|
||||
func applyHierarchyLayoutResponse(
|
||||
nodes []Node,
|
||||
normalizedEdges []Edge,
|
||||
response hierarchyLayoutResponse,
|
||||
) (hierarchyLayoutResult, error) {
|
||||
positionByID := make(map[uint32]hierarchyLayoutResponseNode, len(response.Nodes))
|
||||
for _, node := range response.Nodes {
|
||||
if _, ok := positionByID[node.NodeID]; ok {
|
||||
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned duplicate node_id %d", node.NodeID)
|
||||
}
|
||||
positionByID[node.NodeID] = node
|
||||
}
|
||||
|
||||
filteredNodes := make([]Node, 0, len(response.Nodes))
|
||||
keptNodeIDs := make(map[uint32]struct{}, len(response.Nodes))
|
||||
for _, node := range nodes {
|
||||
position, ok := positionByID[node.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
node.X = position.X
|
||||
node.Y = position.Y
|
||||
filteredNodes = append(filteredNodes, node)
|
||||
keptNodeIDs[node.ID] = struct{}{}
|
||||
}
|
||||
if len(filteredNodes) != len(response.Nodes) {
|
||||
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned unknown node ids")
|
||||
}
|
||||
|
||||
filteredEdges := make([]Edge, 0, len(normalizedEdges))
|
||||
normalizedToFilteredEdge := make(map[int]int, len(normalizedEdges))
|
||||
for normalizedIndex, edge := range normalizedEdges {
|
||||
if _, ok := keptNodeIDs[edge.Source]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := keptNodeIDs[edge.Target]; !ok {
|
||||
continue
|
||||
}
|
||||
normalizedToFilteredEdge[normalizedIndex] = len(filteredEdges)
|
||||
filteredEdges = append(filteredEdges, edge)
|
||||
}
|
||||
|
||||
routeSegments := make([]RouteSegment, 0, len(response.RouteSegments))
|
||||
for _, segment := range response.RouteSegments {
|
||||
filteredEdgeIndex, ok := normalizedToFilteredEdge[segment.EdgeIndex]
|
||||
if !ok {
|
||||
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned route for unknown edge_index %d", segment.EdgeIndex)
|
||||
}
|
||||
points := make([]RoutePoint, 0, len(segment.Points))
|
||||
for _, point := range segment.Points {
|
||||
points = append(points, RoutePoint{
|
||||
X: point.X,
|
||||
Y: point.Y,
|
||||
})
|
||||
}
|
||||
routeSegments = append(routeSegments, RouteSegment{
|
||||
EdgeIndex: filteredEdgeIndex,
|
||||
Kind: segment.Kind,
|
||||
Points: points,
|
||||
})
|
||||
}
|
||||
|
||||
return hierarchyLayoutResult{
|
||||
Nodes: filteredNodes,
|
||||
Edges: filteredEdges,
|
||||
RouteSegments: routeSegments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runHierarchyLayoutBridge(
|
||||
ctx context.Context,
|
||||
cfg Config,
|
||||
request hierarchyLayoutRequest,
|
||||
) (hierarchyLayoutResponse, error) {
|
||||
input, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return hierarchyLayoutResponse{}, fmt.Errorf("marshal hierarchy layout request failed: %w", err)
|
||||
}
|
||||
|
||||
bridgeCtx, cancel := context.WithTimeout(ctx, cfg.HierarchyLayoutTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(bridgeCtx, cfg.HierarchyLayoutBridgeBin)
|
||||
cmd.Dir = cfg.HierarchyLayoutBridgeWorkdir
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if bridgeCtx.Err() != nil {
|
||||
return hierarchyLayoutResponse{}, fmt.Errorf("hierarchy layout bridge timed out after %s", cfg.HierarchyLayoutTimeout)
|
||||
}
|
||||
detail := strings.TrimSpace(stderr.String())
|
||||
if detail == "" {
|
||||
detail = err.Error()
|
||||
}
|
||||
return hierarchyLayoutResponse{}, fmt.Errorf("hierarchy layout bridge failed: %s", detail)
|
||||
}
|
||||
|
||||
var response hierarchyLayoutResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
|
||||
detail := strings.TrimSpace(stderr.String())
|
||||
if detail != "" {
|
||||
return hierarchyLayoutResponse{}, fmt.Errorf("parse hierarchy layout bridge response failed: %v (stderr: %s)", err, detail)
|
||||
}
|
||||
return hierarchyLayoutResponse{}, fmt.Errorf("parse hierarchy layout bridge response failed: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func layoutHierarchyWithRust(
|
||||
ctx context.Context,
|
||||
cfg Config,
|
||||
nodes []Node,
|
||||
edges []Edge,
|
||||
preds *PredicateDict,
|
||||
) (hierarchyLayoutResult, error) {
|
||||
prepared := prepareHierarchyLayoutRequest(cfg.HierarchyLayoutRootIRI, nodes, edges, preds)
|
||||
response, err := runHierarchyLayoutBridge(ctx, cfg, prepared.Request)
|
||||
if err != nil {
|
||||
return hierarchyLayoutResult{}, err
|
||||
}
|
||||
return applyHierarchyLayoutResponse(nodes, prepared.NormalizedEdges, response)
|
||||
}
|
||||
158
backend_go/hierarchy_layout_bridge_test.go
Normal file
158
backend_go/hierarchy_layout_bridge_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPrepareHierarchyLayoutRequestNormalizesEdges(t *testing.T) {
|
||||
nodes := []Node{
|
||||
{ID: 0, TermType: "uri", IRI: "http://example.com/root"},
|
||||
{ID: 1, TermType: "uri", IRI: "http://example.com/child"},
|
||||
{ID: 2, TermType: "uri", IRI: "http://example.com/leaf"},
|
||||
}
|
||||
preds := NewPredicateDict([]string{"http://www.w3.org/2000/01/rdf-schema#subClassOf"})
|
||||
edges := []Edge{
|
||||
{Source: 1, Target: 0, PredicateID: 0},
|
||||
{Source: 1, Target: 0, PredicateID: 0},
|
||||
{Source: 2, Target: 2, PredicateID: 0},
|
||||
{Source: 2, Target: 1, PredicateID: 0},
|
||||
}
|
||||
|
||||
prepared := prepareHierarchyLayoutRequest("http://example.com/root", nodes, edges, preds)
|
||||
|
||||
if got, want := len(prepared.Request.Nodes), 3; got != want {
|
||||
t.Fatalf("len(request.nodes)=%d want %d", got, want)
|
||||
}
|
||||
if got, want := len(prepared.Request.Edges), 2; got != want {
|
||||
t.Fatalf("len(request.edges)=%d want %d", got, want)
|
||||
}
|
||||
if prepared.Request.Edges[0].ParentID != 0 || prepared.Request.Edges[0].ChildID != 1 {
|
||||
t.Fatalf("first normalized edge = %+v, want parent=0 child=1", prepared.Request.Edges[0])
|
||||
}
|
||||
if prepared.Request.Edges[1].ParentID != 1 || prepared.Request.Edges[1].ChildID != 2 {
|
||||
t.Fatalf("second normalized edge = %+v, want parent=1 child=2", prepared.Request.Edges[1])
|
||||
}
|
||||
if prepared.Request.Edges[0].PredicateIRI == nil || *prepared.Request.Edges[0].PredicateIRI == "" {
|
||||
t.Fatalf("expected predicate iri to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHierarchyLayoutResponsePreservesIDsAndRemapsRoutes(t *testing.T) {
|
||||
nodes := []Node{
|
||||
{ID: 0, TermType: "uri", IRI: "http://example.com/root"},
|
||||
{ID: 1, TermType: "uri", IRI: "http://example.com/child"},
|
||||
{ID: 2, TermType: "uri", IRI: "http://example.com/leaf"},
|
||||
}
|
||||
normalizedEdges := []Edge{
|
||||
{Source: 1, Target: 0, PredicateID: 0},
|
||||
{Source: 2, Target: 0, PredicateID: 0},
|
||||
}
|
||||
response := hierarchyLayoutResponse{
|
||||
Nodes: []hierarchyLayoutResponseNode{
|
||||
{NodeID: 0, X: 10, Y: 20},
|
||||
{NodeID: 2, X: 30, Y: 40},
|
||||
},
|
||||
RouteSegments: []hierarchyLayoutResponseRouteSegment{
|
||||
{
|
||||
EdgeIndex: 1,
|
||||
Kind: "spiral",
|
||||
Points: []hierarchyLayoutResponseRoutePoint{
|
||||
{X: 10, Y: 20},
|
||||
{X: 30, Y: 40},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := applyHierarchyLayoutResponse(nodes, normalizedEdges, response)
|
||||
if err != nil {
|
||||
t.Fatalf("applyHierarchyLayoutResponse returned error: %v", err)
|
||||
}
|
||||
|
||||
if got, want := len(result.Nodes), 2; got != want {
|
||||
t.Fatalf("len(nodes)=%d want %d", got, want)
|
||||
}
|
||||
if result.Nodes[0].ID != 0 || result.Nodes[1].ID != 2 {
|
||||
t.Fatalf("filtered node ids = [%d %d], want [0 2]", result.Nodes[0].ID, result.Nodes[1].ID)
|
||||
}
|
||||
if result.Nodes[0].X != 10 || result.Nodes[0].Y != 20 || result.Nodes[1].X != 30 || result.Nodes[1].Y != 40 {
|
||||
t.Fatalf("positions were not applied to filtered nodes: %+v", result.Nodes)
|
||||
}
|
||||
if got, want := len(result.Edges), 1; got != want {
|
||||
t.Fatalf("len(edges)=%d want %d", got, want)
|
||||
}
|
||||
if result.Edges[0] != normalizedEdges[1] {
|
||||
t.Fatalf("filtered edge = %+v, want %+v", result.Edges[0], normalizedEdges[1])
|
||||
}
|
||||
if got, want := len(result.RouteSegments), 1; got != want {
|
||||
t.Fatalf("len(route_segments)=%d want %d", got, want)
|
||||
}
|
||||
if result.RouteSegments[0].EdgeIndex != 0 {
|
||||
t.Fatalf("route edge index = %d want 0", result.RouteSegments[0].EdgeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHierarchyLayoutBridgeUsesConfiguredWorkingDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "pwd.txt")
|
||||
scriptPath := filepath.Join(tmpDir, "bridge.sh")
|
||||
script := "#!/bin/sh\npwd > \"" + outputPath + "\"\ncat >/dev/null\nprintf '{\"nodes\":[{\"node_id\":1,\"x\":10,\"y\":20,\"level\":0}],\"route_segments\":[]}'\n"
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
HierarchyLayoutBridgeBin: scriptPath,
|
||||
HierarchyLayoutBridgeWorkdir: tmpDir,
|
||||
HierarchyLayoutTimeout: 2 * time.Second,
|
||||
}
|
||||
response, err := runHierarchyLayoutBridge(context.Background(), cfg, hierarchyLayoutRequest{
|
||||
RootIRI: "root",
|
||||
Nodes: []hierarchyLayoutRequestNode{
|
||||
{NodeID: 1, IRI: "root"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runHierarchyLayoutBridge returned error: %v", err)
|
||||
}
|
||||
if got, want := len(response.Nodes), 1; got != want {
|
||||
t.Fatalf("len(response.nodes)=%d want %d", got, want)
|
||||
}
|
||||
|
||||
pwdBytes, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read pwd output: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(string(pwdBytes)), tmpDir; got != want {
|
||||
t.Fatalf("bridge working directory=%q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHierarchyLayoutBridgeReturnsSvgWriteFailure(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "bridge_fail.sh")
|
||||
script := "#!/bin/sh\ncat >/dev/null\necho 'failed to write SVG output: permission denied' >&2\nexit 1\n"
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
HierarchyLayoutBridgeBin: scriptPath,
|
||||
HierarchyLayoutBridgeWorkdir: tmpDir,
|
||||
HierarchyLayoutTimeout: 2 * time.Second,
|
||||
}
|
||||
_, err := runHierarchyLayoutBridge(context.Background(), cfg, hierarchyLayoutRequest{
|
||||
RootIRI: "root",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hierarchy layout bridge error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write SVG output") {
|
||||
t.Fatalf("error=%q does not mention SVG write failure", err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package main
|
||||
|
||||
import selectionqueries "visualizador_instanciados/backend_go/selection_queries"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
@@ -18,28 +20,42 @@ type Node struct {
|
||||
}
|
||||
|
||||
type Edge struct {
|
||||
Source uint32 `json:"source"`
|
||||
Target uint32 `json:"target"`
|
||||
Source uint32 `json:"source"`
|
||||
Target uint32 `json:"target"`
|
||||
PredicateID uint32 `json:"predicate_id"`
|
||||
}
|
||||
|
||||
type RoutePoint struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type RouteSegment struct {
|
||||
EdgeIndex int `json:"edge_index"`
|
||||
Kind string `json:"kind"`
|
||||
Points []RoutePoint `json:"points"`
|
||||
}
|
||||
|
||||
type GraphMeta struct {
|
||||
Backend string `json:"backend"`
|
||||
TTLPath *string `json:"ttl_path"`
|
||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
||||
IncludeBNodes bool `json:"include_bnodes"`
|
||||
GraphQueryID string `json:"graph_query_id"`
|
||||
Backend string `json:"backend"`
|
||||
TTLPath *string `json:"ttl_path"`
|
||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
||||
IncludeBNodes bool `json:"include_bnodes"`
|
||||
GraphQueryID string `json:"graph_query_id"`
|
||||
Predicates []string `json:"predicates,omitempty"` // index = predicate_id
|
||||
NodeLimit int `json:"node_limit"`
|
||||
EdgeLimit int `json:"edge_limit"`
|
||||
Nodes int `json:"nodes"`
|
||||
Edges int `json:"edges"`
|
||||
NodeLimit int `json:"node_limit"`
|
||||
EdgeLimit int `json:"edge_limit"`
|
||||
Nodes int `json:"nodes"`
|
||||
Edges int `json:"edges"`
|
||||
LayoutEngine string `json:"layout_engine,omitempty"`
|
||||
LayoutRootIRI *string `json:"layout_root_iri,omitempty"`
|
||||
}
|
||||
|
||||
type GraphResponse struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
Meta *GraphMeta `json:"meta"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
RouteSegments []RouteSegment `json:"route_segments,omitempty"`
|
||||
Meta *GraphMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type StatsResponse struct {
|
||||
@@ -80,3 +96,9 @@ type SelectionQueryResponse struct {
|
||||
SelectedIDs []uint32 `json:"selected_ids"`
|
||||
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||
}
|
||||
|
||||
type SelectionTriplesResponse struct {
|
||||
QueryID string `json:"query_id"`
|
||||
SelectedIDs []uint32 `json:"selected_ids"`
|
||||
Triples []selectionqueries.Triple `json:"triples"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package selection_queries
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -66,30 +67,83 @@ func selectedNodesFromIDs(idx Index, selectedIDs []uint32, includeBNodes bool) (
|
||||
return out, set
|
||||
}
|
||||
|
||||
func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[uint32]struct{}, includeBNodes bool) ([]uint32, error) {
|
||||
func idFromSparqlTerm(term sparqlTerm, idx Index, includeBNodes bool) (uint32, bool) {
|
||||
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
nid, ok := idx.KeyToID[key]
|
||||
return nid, ok
|
||||
}
|
||||
|
||||
func tripleTermFromSparqlTerm(term sparqlTerm) TripleTerm {
|
||||
return TripleTerm{
|
||||
Type: term.Type,
|
||||
Value: term.Value,
|
||||
Lang: term.Lang,
|
||||
}
|
||||
}
|
||||
|
||||
func logQueryExecutionFailure(queryName string, selectedIDs []uint32, includeBNodes bool, sparql string, err error) {
|
||||
log.Printf(
|
||||
"%s: SPARQL execution failed selected_ids=%v include_bnodes=%t err=%v\nSPARQL:\n%s",
|
||||
queryName,
|
||||
selectedIDs,
|
||||
includeBNodes,
|
||||
err,
|
||||
strings.TrimSpace(sparql),
|
||||
)
|
||||
}
|
||||
|
||||
func resultFromTripleBindings(raw []byte, idx Index, selectedSet map[uint32]struct{}, includeBNodes bool) (Result, error) {
|
||||
var res sparqlResponse
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
||||
}
|
||||
|
||||
neighborSet := make(map[uint32]struct{})
|
||||
triples := make([]Triple, 0, len(res.Results.Bindings))
|
||||
for _, b := range res.Results.Bindings {
|
||||
term, ok := b[varName]
|
||||
if !ok {
|
||||
sTerm, okS := b["s"]
|
||||
pTerm, okP := b["p"]
|
||||
oTerm, okO := b["o"]
|
||||
if !okS || !okP || !okO {
|
||||
continue
|
||||
}
|
||||
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
triple := Triple{
|
||||
S: tripleTermFromSparqlTerm(sTerm),
|
||||
P: tripleTermFromSparqlTerm(pTerm),
|
||||
O: tripleTermFromSparqlTerm(oTerm),
|
||||
}
|
||||
nid, ok := idx.KeyToID[key]
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
subjID, subjOK := idFromSparqlTerm(sTerm, idx, includeBNodes)
|
||||
if subjOK {
|
||||
id := subjID
|
||||
triple.SubjectID = &id
|
||||
}
|
||||
if _, sel := selectedSet[nid]; sel {
|
||||
continue
|
||||
objID, objOK := idFromSparqlTerm(oTerm, idx, includeBNodes)
|
||||
if objOK {
|
||||
id := objID
|
||||
triple.ObjectID = &id
|
||||
}
|
||||
neighborSet[nid] = struct{}{}
|
||||
if pTerm.Type == "uri" {
|
||||
if predID, ok := idx.PredicateIDByIRI[pTerm.Value]; ok {
|
||||
id := predID
|
||||
triple.PredicateID = &id
|
||||
}
|
||||
}
|
||||
|
||||
_, subjSelected := selectedSet[subjID]
|
||||
_, objSelected := selectedSet[objID]
|
||||
if subjOK && subjSelected && objOK && !objSelected {
|
||||
neighborSet[objID] = struct{}{}
|
||||
}
|
||||
if objOK && objSelected && subjOK && !subjSelected {
|
||||
neighborSet[subjID] = struct{}{}
|
||||
}
|
||||
|
||||
triples = append(triples, triple)
|
||||
}
|
||||
|
||||
ids := make([]uint32, 0, len(neighborSet))
|
||||
@@ -97,5 +151,5 @@ func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[uint
|
||||
ids = append(ids, nid)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids, nil
|
||||
return Result{NeighborIDs: ids, Triples: triples}, nil
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
@@ -31,46 +31,55 @@ PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
{
|
||||
?sel rdf:type ?o .
|
||||
VALUES ?sel { %s }
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdf:type }
|
||||
?s ?p ?o .
|
||||
?o rdf:type owl:Class .
|
||||
BIND(?o AS ?nbr)
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?s rdf:type ?sel .
|
||||
VALUES ?sel { %s }
|
||||
VALUES ?p { rdf:type }
|
||||
?s ?p ?sel .
|
||||
?sel rdf:type owl:Class .
|
||||
BIND(?s AS ?nbr)
|
||||
BIND(?sel AS ?o)
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?sel rdfs:subClassOf ?o .
|
||||
BIND(?o AS ?nbr)
|
||||
VALUES ?sel { %s }
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?o .
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?s rdfs:subClassOf ?sel .
|
||||
BIND(?s AS ?nbr)
|
||||
VALUES ?sel { %s }
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?sel .
|
||||
BIND(?sel AS ?o)
|
||||
}
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
`, values, values, values, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, nil
|
||||
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||
}
|
||||
|
||||
raw, err := q.Query(ctx, neighborsQuery(selectedNodes, includeBNodes))
|
||||
query := neighborsQuery(selectedNodes, includeBNodes)
|
||||
raw, err := q.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
logQueryExecutionFailure("neighbors", selectedIDs, includeBNodes, query, err)
|
||||
return Result{}, err
|
||||
}
|
||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
||||
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||
}
|
||||
|
||||
@@ -17,38 +17,42 @@ func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
return fmt.Sprintf(`
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
?nbr rdfs:subClassOf ?sel .
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?sel .
|
||||
BIND(?sel AS ?o)
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, nil
|
||||
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||
}
|
||||
|
||||
raw, err := q.Query(ctx, subclassesQuery(selectedNodes, includeBNodes))
|
||||
query := subclassesQuery(selectedNodes, includeBNodes)
|
||||
raw, err := q.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
logQueryExecutionFailure("subclasses", selectedIDs, includeBNodes, query, err)
|
||||
return Result{}, err
|
||||
}
|
||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
||||
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||
}
|
||||
|
||||
@@ -17,38 +17,42 @@ func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
return fmt.Sprintf(`
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
?sel rdfs:subClassOf ?nbr .
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?o .
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, nil
|
||||
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||
}
|
||||
|
||||
raw, err := q.Query(ctx, superclassesQuery(selectedNodes, includeBNodes))
|
||||
query := superclassesQuery(selectedNodes, includeBNodes)
|
||||
raw, err := q.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
logQueryExecutionFailure("superclasses", selectedIDs, includeBNodes, query, err)
|
||||
return Result{}, err
|
||||
}
|
||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
||||
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ type NodeRef struct {
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
IDToNode map[uint32]NodeRef
|
||||
KeyToID map[string]uint32
|
||||
IDToNode map[uint32]NodeRef
|
||||
KeyToID map[string]uint32
|
||||
PredicateIDByIRI map[string]uint32
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
@@ -22,7 +23,27 @@ type Meta struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type TripleTerm struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
}
|
||||
|
||||
type Triple struct {
|
||||
S TripleTerm `json:"s"`
|
||||
P TripleTerm `json:"p"`
|
||||
O TripleTerm `json:"o"`
|
||||
SubjectID *uint32 `json:"subject_id,omitempty"`
|
||||
ObjectID *uint32 `json:"object_id,omitempty"`
|
||||
PredicateID *uint32 `json:"predicate_id,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||
Triples []Triple `json:"triples"`
|
||||
}
|
||||
|
||||
type Definition struct {
|
||||
Meta Meta
|
||||
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error)
|
||||
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error)
|
||||
}
|
||||
|
||||
@@ -14,19 +14,32 @@ func runSelectionQuery(
|
||||
queryID string,
|
||||
selectedIDs []uint32,
|
||||
includeBNodes bool,
|
||||
) ([]uint32, error) {
|
||||
) (selectionqueries.Result, error) {
|
||||
def, ok := selectionqueries.Get(queryID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown query_id: %s", queryID)
|
||||
return selectionqueries.Result{}, fmt.Errorf("unknown query_id: %s", queryID)
|
||||
}
|
||||
|
||||
idToNode := make(map[uint32]selectionqueries.NodeRef, len(snapshot.Nodes))
|
||||
keyToID := make(map[string]uint32, len(snapshot.Nodes))
|
||||
predicateIDByIRI := make(map[string]uint32)
|
||||
for _, n := range snapshot.Nodes {
|
||||
nr := selectionqueries.NodeRef{ID: n.ID, TermType: n.TermType, IRI: n.IRI}
|
||||
idToNode[n.ID] = nr
|
||||
keyToID[n.TermType+"\x00"+n.IRI] = n.ID
|
||||
}
|
||||
if snapshot.Meta != nil {
|
||||
for predID, iri := range snapshot.Meta.Predicates {
|
||||
if iri == "" {
|
||||
continue
|
||||
}
|
||||
predicateIDByIRI[iri] = uint32(predID)
|
||||
}
|
||||
}
|
||||
|
||||
return def.Run(ctx, sparql, selectionqueries.Index{IDToNode: idToNode, KeyToID: keyToID}, selectedIDs, includeBNodes)
|
||||
return def.Run(ctx, sparql, selectionqueries.Index{
|
||||
IDToNode: idToNode,
|
||||
KeyToID: keyToID,
|
||||
PredicateIDByIRI: predicateIDByIRI,
|
||||
}, selectedIDs, includeBNodes)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func (s *APIServer) handler() http.Handler {
|
||||
mux.HandleFunc("/api/graph_queries", s.handleGraphQueries)
|
||||
mux.HandleFunc("/api/selection_queries", s.handleSelectionQueries)
|
||||
mux.HandleFunc("/api/selection_query", s.handleSelectionQuery)
|
||||
mux.HandleFunc("/api/selection_triples", s.handleSelectionTriples)
|
||||
mux.HandleFunc("/api/neighbors", s.handleNeighbors)
|
||||
|
||||
return s.corsMiddleware(mux)
|
||||
@@ -134,14 +135,14 @@ func (s *APIServer) handleGraph(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
||||
if graphQueryID == "" {
|
||||
graphQueryID = graphqueries.DefaultID
|
||||
}
|
||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||
return
|
||||
}
|
||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
||||
if graphQueryID == "" {
|
||||
graphQueryID = graphqueries.DefaultID
|
||||
}
|
||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||
return
|
||||
}
|
||||
|
||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
||||
if err != nil {
|
||||
@@ -225,8 +226,18 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
ids, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||
result, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
"handleSelectionQuery: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||
req.QueryID,
|
||||
graphQueryID,
|
||||
req.SelectedIDs,
|
||||
nodeLimit,
|
||||
edgeLimit,
|
||||
s.cfg.IncludeBNodes,
|
||||
err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -234,23 +245,31 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
||||
QueryID: req.QueryID,
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
NeighborIDs: ids,
|
||||
NeighborIDs: result.NeighborIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *APIServer) handleSelectionTriples(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req NeighborsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
var req SelectionQueryRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || strings.TrimSpace(req.QueryID) == "" {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||
return
|
||||
}
|
||||
if _, ok := selectionqueries.Get(req.QueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown query_id")
|
||||
return
|
||||
}
|
||||
if len(req.SelectedIDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: []uint32{}})
|
||||
writeJSON(w, http.StatusOK, SelectionTriplesResponse{
|
||||
QueryID: req.QueryID,
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
Triples: []selectionqueries.Triple{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,13 +301,96 @@ func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
nbrs, err := runSelectionQuery(r.Context(), s.sparql, snap, "neighbors", req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||
result, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
"handleSelectionTriples: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||
req.QueryID,
|
||||
graphQueryID,
|
||||
req.SelectedIDs,
|
||||
nodeLimit,
|
||||
edgeLimit,
|
||||
s.cfg.IncludeBNodes,
|
||||
err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: nbrs})
|
||||
writeJSON(w, http.StatusOK, SelectionTriplesResponse{
|
||||
QueryID: req.QueryID,
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
Triples: result.Triples,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req NeighborsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.SelectedIDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, NeighborsResponse{
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
NeighborIDs: []uint32{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
graphQueryID := graphqueries.DefaultID
|
||||
if req.GraphQueryID != nil && strings.TrimSpace(*req.GraphQueryID) != "" {
|
||||
graphQueryID = strings.TrimSpace(*req.GraphQueryID)
|
||||
}
|
||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||
return
|
||||
}
|
||||
|
||||
nodeLimit := s.cfg.DefaultNodeLimit
|
||||
edgeLimit := s.cfg.DefaultEdgeLimit
|
||||
if req.NodeLimit != nil {
|
||||
nodeLimit = *req.NodeLimit
|
||||
}
|
||||
if req.EdgeLimit != nil {
|
||||
edgeLimit = *req.EdgeLimit
|
||||
}
|
||||
if nodeLimit < 1 || nodeLimit > s.cfg.MaxNodeLimit || edgeLimit < 1 || edgeLimit > s.cfg.MaxEdgeLimit {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid node_limit/edge_limit")
|
||||
return
|
||||
}
|
||||
|
||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := runSelectionQuery(r.Context(), s.sparql, snap, "neighbors", req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
"handleNeighbors: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||
"neighbors",
|
||||
graphQueryID,
|
||||
req.SelectedIDs,
|
||||
nodeLimit,
|
||||
edgeLimit,
|
||||
s.cfg.IncludeBNodes,
|
||||
err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, NeighborsResponse{
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
NeighborIDs: result.NeighborIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func intQuery(r *http.Request, name string, def int) (int, error) {
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
)
|
||||
|
||||
type snapshotKey struct {
|
||||
NodeLimit int
|
||||
EdgeLimit int
|
||||
IncludeBNodes bool
|
||||
GraphQueryID string
|
||||
NodeLimit int
|
||||
EdgeLimit int
|
||||
IncludeBNodes bool
|
||||
GraphQueryID string
|
||||
LayoutEngine string
|
||||
LayoutRootIRI string
|
||||
}
|
||||
|
||||
type snapshotInflight struct {
|
||||
@@ -37,7 +39,14 @@ func NewGraphSnapshotService(sparql *AnzoGraphClient, cfg Config) *GraphSnapshot
|
||||
}
|
||||
|
||||
func (s *GraphSnapshotService) Get(ctx context.Context, nodeLimit int, edgeLimit int, graphQueryID string) (GraphResponse, error) {
|
||||
key := snapshotKey{NodeLimit: nodeLimit, EdgeLimit: edgeLimit, IncludeBNodes: s.cfg.IncludeBNodes, GraphQueryID: graphQueryID}
|
||||
key := snapshotKey{
|
||||
NodeLimit: nodeLimit,
|
||||
EdgeLimit: edgeLimit,
|
||||
IncludeBNodes: s.cfg.IncludeBNodes,
|
||||
GraphQueryID: graphQueryID,
|
||||
LayoutEngine: s.cfg.HierarchyLayoutEngine,
|
||||
LayoutRootIRI: s.cfg.HierarchyLayoutRootIRI,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if snap, ok := s.cache[key]; ok {
|
||||
|
||||
Reference in New Issue
Block a user