radial sugiyama positioning integration

This commit is contained in:
Oxy8
2026-03-23 11:13:27 -03:00
parent 6b9115e43b
commit 696844f341
51 changed files with 10089 additions and 364 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View 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)
}

View 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)
}
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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 {