Gå til hovedinnhold

Graph Store Library

The graph store library (core/graph/) provides a backend-agnostic graph database abstraction for concept management. The framework includes a SQLite backend (adjacency tables) for local and CLI use. The interface is designed for extension, so server deployments can add their own backend behind the same interface.

Architecture

GraphStore Interface

type GraphStore interface {
// Node CRUD
CreateNode(ctx context.Context, node *Node) error
GetNode(ctx context.Context, id string) (*Node, error)
UpdateNode(ctx context.Context, node *Node) error
DeleteNode(ctx context.Context, id string) error

// Node queries
FindNodes(ctx context.Context, label string, properties map[string]string) ([]*Node, error)
FindNodesScoped(ctx context.Context, label string, properties map[string]string, scope Scope) ([]*Node, error)

// Edge CRUD + queries
CreateEdge(ctx context.Context, edge *Edge) error
GetEdge(ctx context.Context, id string) (*Edge, error)
UpdateEdge(ctx context.Context, edge *Edge) error
DeleteEdge(ctx context.Context, id string) error
FindEdges(ctx context.Context, label string, properties map[string]string) ([]*Edge, error)

// Traversal
Neighbors(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Node, error)
NeighborsScoped(ctx context.Context, nodeID string, direction Direction, scope Scope, labels ...string) ([]*Node, error)
EdgesOf(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Edge, error)
ShortestPath(ctx context.Context, fromID, toID string, maxDepth int) (*Path, error)

// Bulk operations
BulkCreateNodes(ctx context.Context, nodes []*Node) error
BulkCreateEdges(ctx context.Context, edges []*Edge) error

// Cypher escape hatch (AGE backend only; SQLite returns ErrCypherNotSupported)
CypherQuery(ctx context.Context, query string, params map[string]any) ([]*Node, error)
CypherExec(ctx context.Context, query string, params map[string]any) error

// Lifecycle
Close() error
}

Key Types

Node

type Node struct {
ID string `json:"id"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

Edge

type Edge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
Validity *Validity `json:"validity,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

Direction

type Direction int
const (
Outgoing Direction = iota // source -> target
Incoming // source <- target
Both // either direction
)

Path

type Path struct {
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
}

Temporal Validity

Edges can carry temporal bounds and tag-based scoping:

type Validity struct {
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
}

Scope Matching

A Scope represents an evaluation point:

type Scope struct {
At time.Time `json:"at"`
Tags map[string]string `json:"tags,omitempty"`
}

Matching rules:

  • Nil validity always matches (unbounded edge)
  • Time: half-open interval [ValidFrom, ValidTo)
  • Tags: all scope tags must be present in validity tags with matching values
  • Extra validity tags not in scope are ignored (open-world assumption)
import "github.com/neokapi/neokapi/core/graph"

// Query with current time, no tag constraints
scope := graph.Now()

// Query at a specific point in time
scope := graph.ScopeAt(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC))

// Query with tag constraints
scope := graph.ScopeWithTags(map[string]string{"market": "us", "product": "enterprise"})

// Check if validity is currently active
edge.Validity.IsActive()

// Check if validity has expired
edge.Validity.IsExpired()

Edge Labels

Labels are aligned with W3C SKOS vocabulary for terminology interoperability:

// Hierarchical (SKOS)
graph.LabelBroader // "BROADER" — parent concept
graph.LabelNarrower // "NARROWER" — child concept

// Associative (SKOS)
graph.LabelRelated // "RELATED" — associative link

// Compositional
graph.LabelPartOf // "PART_OF" — component of
graph.LabelHasPart // "HAS_PART" — contains component

// Terminological
graph.LabelHasTerm // "HAS_TERM" — concept → term
graph.LabelUseInstead // "USE_INSTEAD" — deprecated → preferred
graph.LabelReplacedBy // "REPLACED_BY" — superseded → replacement

// Equivalence (SKOS)
graph.LabelExactMatch // "EXACT_MATCH" — cross-scheme equivalence
graph.LabelCloseMatch // "CLOSE_MATCH" — approximate equivalence

// Brand voice
graph.LabelForbidden // "FORBIDDEN" — brand → forbidden term
graph.LabelPreferred // "PREFERRED" — brand → preferred term
graph.LabelCompetitor // "COMPETITOR" — brand → competitor term

InverseLabel() returns the inverse of directional labels (e.g., BROADER -> NARROWER).

SQLite Backend

import (
"github.com/neokapi/neokapi/core/storage"
graphstore "github.com/neokapi/neokapi/cli/storage/graph"
)

db, _ := storage.Open("graph.db")
store, _ := graphstore.NewSQLiteGraphStore(db)
defer store.Close()

Uses adjacency tables (graph_nodes, graph_edges) with JSON properties. Shortest path uses recursive CTE with BFS. Scoped queries filter edges in Go after retrieval.

The SQLite backend has no native Cypher support, so CypherQuery and CypherExec return the sentinel graph.ErrCypherNotSupported. A server-side deployment can supply a backend with native Cypher support behind the same interface.

The GraphStore interface is designed for extension — server deployments can add their own backend behind the same interface.

Usage Examples

Building a Concept Hierarchy

store, _ := graphstore.NewSQLiteGraphStore(db)

// Create concept nodes
store.CreateNode(ctx, &graph.Node{ID: "animal", Label: "Concept", Properties: map[string]string{"name": "Animal"}})
store.CreateNode(ctx, &graph.Node{ID: "mammal", Label: "Concept", Properties: map[string]string{"name": "Mammal"}})
store.CreateNode(ctx, &graph.Node{ID: "dog", Label: "Concept", Properties: map[string]string{"name": "Dog"}})

// Create hierarchy edges
store.CreateEdge(ctx, &graph.Edge{ID: "e1", Source: "mammal", Target: "animal", Label: graph.LabelBroader})
store.CreateEdge(ctx, &graph.Edge{ID: "e2", Source: "dog", Target: "mammal", Label: graph.LabelBroader})

// Navigate: what is broader than "dog"?
parents, _ := store.Neighbors(ctx, "dog", graph.Outgoing, graph.LabelBroader)
// parents = [mammal]

// Navigate: what is narrower than "animal"?
children, _ := store.Neighbors(ctx, "animal", graph.Incoming, graph.LabelBroader)
// children = [mammal]

// Find path from dog to animal
path, _ := store.ShortestPath(ctx, "dog", "animal", 10)
// path.Nodes = [dog, mammal, animal]
// path.Edges = [e2, e1]

Temporal Edges

start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

store.CreateEdge(ctx, &graph.Edge{
ID: "e3", Source: "old-term", Target: "new-term", Label: graph.LabelReplacedBy,
Validity: &graph.Validity{
ValidFrom: &start,
ValidTo: &end,
Tags: map[string]string{"market": "us"},
},
})

// Query with scope — only returns edges active at the given time with matching tags
scope := graph.Scope{At: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), Tags: map[string]string{"market": "us"}}
neighbors, _ := store.NeighborsScoped(ctx, "old-term", graph.Outgoing, scope, graph.LabelReplacedBy)

Use FindNodes, Neighbors, and ShortestPath for portable queries that work across all backends.