feat: Session 10.3 MCP Server + Session 11 Graph Engine Deep Integration

MCP Server (10.3):
- STDIO JSON-RPC 2.0 transport with 3 AI tools: query, vector_search, schema_inspect
- Multi-tenant session vars (tenant_id, user_id) with RLS support
- Standalone binary: build/baramcp
- Tested with all 3 tools + parameterized queries + vector search + schema inspect

Graph Engine Deep Integration (Session 11):
- CREATE GRAPH / DROP GRAPH DDL support
- Graph engine wired to SQL executor via GRAPH_TABLE() function
- 7 algorithms: BFS, DFS, PageRank, ShortestPath, Dijkstra, Louvain, Community
- INSERT into _nodes/_edges tables auto-syncs with native Graph adjacency lists
- Optional MATCH pattern, ALGORITHM, START, END, MAXDEPTH in GRAPH_TABLE syntax
- All 340+ existing tests pass
This commit is contained in:
2026-05-17 15:18:31 +03:00
parent 55bc3e862a
commit 8a395225c0
9 changed files with 1166 additions and 59 deletions
+9
View File
@@ -41,6 +41,8 @@ type
nkGrant
nkRevoke
nkSetVar
nkCreateGraph
nkDropGraph
# Clauses
nkFrom
@@ -328,6 +330,12 @@ type
of nkSetVar:
svName*: string
svValue*: string
of nkCreateGraph:
cgName*: string
cgIfNotExists*: bool
of nkDropGraph:
dgName*: string
dgIfExists*: bool
of nkApplyMigration:
amName*: string
of nkMigrationStatus:
@@ -440,6 +448,7 @@ type
gtEnd*: Node
gtMaxDepth*: int
gtReturnCols*: seq[string]
gtAlgo*: string
of nkBfsQuery:
bfsStart*: Node
bfsTarget*: Node
+228 -14
View File
@@ -25,6 +25,8 @@ import ../core/mvcc
import ../core/tracing
import ../fts/engine as fts
import ../vector/engine as vengine
import ../graph/engine as gengine
import ../graph/community as gcomm
type
IndexEntry* = ref object
@@ -68,6 +70,7 @@ type
cteTables*: Table[string, seq[Row]] # CTE name -> rows
ftsIndexes*: Table[string, fts.InvertedIndex] # table.col -> FTS index
vectorIndexes*: Table[string, vengine.HNSWIndex] # table.col -> HNSW index
graphs*: Table[string, gengine.Graph] # graph name -> Graph object
txnManager*: TxnManager
pendingTxn*: Transaction
onChange*: proc(ev: ChangeEvent) {.closure.}
@@ -1539,6 +1542,48 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
meta[col] = val
vengine.insert(vecIdx, docId, vec, meta)
# Update Graph objects for graph node/edge tables
for graphName, graph in ctx.graphs:
if table == graphName & "_nodes":
var nodeIdStr = ""
for i, f in fields:
if f == "id" and i < rowVals.len:
nodeIdStr = rowVals[i]
break
if nodeIdStr.len > 0:
let nid = gengine.NodeId(parseUInt(nodeIdStr))
var label = ""
var props = initTable[string, string]()
for i, f in fields:
if i < rowVals.len:
if f == "node_label":
label = rowVals[i]
elif f != "id" and f != "properties":
props[f] = rowVals[i]
try:
gengine.addNodeWithId(graph, nid, label, props)
except:
discard
elif table == graphName & "_edges":
var srcStr = ""
var dstStr = ""
var label = ""
var weight = 1.0
for i, f in fields:
if i < rowVals.len:
if f == "source_id": srcStr = rowVals[i]
elif f == "dest_id": dstStr = rowVals[i]
elif f == "edge_label": label = rowVals[i]
elif f == "weight":
try: weight = parseFloat(rowVals[i]) except: discard
if srcStr.len > 0 and dstStr.len > 0:
let srcId = gengine.NodeId(parseUInt(srcStr))
let dstId = gengine.NodeId(parseUInt(dstStr))
try:
gengine.addEdgeWithId(graph, srcId, dstId, label, weight)
except:
discard
inc count
return count
@@ -2138,7 +2183,17 @@ proc lowerSelect*(node: Node): IRPlan =
elif node.selFrom.kind == nkGraphTraversal:
let graphPlan = IRPlan(kind: irpkGraphTraversal)
graphPlan.graphName = node.selFrom.gtGraphName
graphPlan.graphAlgo = "bfs"
graphPlan.graphAlgo = node.selFrom.gtAlgo.toLowerAscii()
if node.selFrom.gtStart != nil:
if node.selFrom.gtStart.kind == nkIdent:
graphPlan.graphStartNode = node.selFrom.gtStart.identName
elif node.selFrom.gtStart.kind == nkIntLit:
graphPlan.graphStartNode = $node.selFrom.gtStart.intVal
if node.selFrom.gtEnd != nil:
if node.selFrom.gtEnd.kind == nkIdent:
graphPlan.graphEndNode = node.selFrom.gtEnd.identName
elif node.selFrom.gtEnd.kind == nkIntLit:
graphPlan.graphEndNode = $node.selFrom.gtEnd.intVal
graphPlan.graphEdgeLabel = node.selFrom.gtEdge
graphPlan.graphMaxDepth = node.selFrom.gtMaxDepth
graphPlan.graphReturnCols = node.selFrom.gtReturnCols
@@ -3247,20 +3302,136 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
return result
of irpkGraphTraversal:
# Execute graph traversal using the graph engine
# For now, return graph metadata as rows
# Execute real graph traversal using the graph engine
result = @[]
# Check if we have a cross-modal engine with graph
# The graph is stored by name; for simplicity, we'll use a table-based approach
# Graph nodes are stored as rows with their properties
let graphTable = plan.graphName & "_nodes"
# Try to scan the nodes table
let nodeRows = execScan(ctx, graphTable)
if nodeRows.len > 0:
for row in nodeRows:
var resultRow = row
result.add(resultRow)
return result
let graphName = plan.graphName
if graphName notin ctx.graphs:
return @[]
let g = ctx.graphs[graphName]
if g == nil or g.nodes.len == 0:
return @[]
let algo = plan.graphAlgo.toLowerAscii()
let returnCols = plan.graphReturnCols
let firstNodeId = if g.nodes.len > 0: g.nodes.keys.toSeq[0] else: gengine.NodeId(0)
case algo
of "bfs":
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
let traverseResult = gengine.bfs(g, startId, maxDepth)
for nodeId in traverseResult:
var row = initTable[string, string]()
let nid = uint64(nodeId)
row["_node_id"] = $nid
if nodeId in g.nodes:
let gn = g.nodes[nodeId]
row["_node_label"] = gn.label
for col in returnCols:
if col == "label":
row[col] = gn.label
elif col == "id":
row[col] = $nid
elif col in gn.properties:
row[col] = gn.properties[col]
result.add(row)
of "dfs":
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
let traverseResult = gengine.dfs(g, startId, maxDepth)
for nodeId in traverseResult:
var row = initTable[string, string]()
let nid = uint64(nodeId)
row["_node_id"] = $nid
if nodeId in g.nodes:
let gn = g.nodes[nodeId]
row["_node_label"] = gn.label
for col in returnCols:
if col == "label":
row[col] = gn.label
elif col == "id":
row[col] = $nid
elif col in gn.properties:
row[col] = gn.properties[col]
result.add(row)
of "pagerank", "page_rank":
let prResult = gengine.pageRank(g, 20, 0.85)
var sortedNodes = prResult.keys.toSeq
sortedNodes.sort(proc(a, b: gengine.NodeId): int =
let va = prResult.getOrDefault(a, 0.0)
let vb = prResult.getOrDefault(b, 0.0)
if va > vb: return -1 elif va < vb: return 1 else: return 0)
for nodeId in sortedNodes:
var row = initTable[string, string]()
let nid = uint64(nodeId)
row["_node_id"] = $nid
row["rank"] = $prResult.getOrDefault(nodeId, 0.0)
if nodeId in g.nodes:
row["_node_label"] = g.nodes[nodeId].label
for col in returnCols:
if col == "rank": row["rank"] = $prResult.getOrDefault(nodeId, 0.0)
elif col == "id": row[col] = $nid
elif col == "label": row[col] = g.nodes[nodeId].label
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
result.add(row)
of "shortest_path", "shortestpath":
if plan.graphStartNode.len > 0 and plan.graphEndNode.len > 0:
let startId = gengine.NodeId(parseUInt(plan.graphStartNode))
let endId = gengine.NodeId(parseUInt(plan.graphEndNode))
let path = gengine.shortestPath(g, startId, endId)
for nodeId in path:
var row = initTable[string, string]()
let nid = uint64(nodeId)
row["_node_id"] = $nid
if nodeId in g.nodes:
row["_node_label"] = g.nodes[nodeId].label
for col in returnCols:
if col == "id": row[col] = $nid
elif col == "label": row[col] = g.nodes[nodeId].label
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
result.add(row)
else:
return @[]
of "dijkstra":
if plan.graphStartNode.len > 0:
let startId = gengine.NodeId(parseUInt(plan.graphStartNode))
let dists = gengine.dijkstra(g, startId)
for nodeId, dist in dists:
var row = initTable[string, string]()
row["_node_id"] = $(uint64(nodeId))
row["distance"] = $dist
if nodeId in g.nodes:
row["_node_label"] = g.nodes[nodeId].label
result.add(row)
else:
return @[]
of "community", "community_detect", "louvain":
let louvainResult = gcomm.louvain(g)
for nodeId, communityId in louvainResult.communities:
var row = initTable[string, string]()
row["_node_id"] = $(uint64(nodeId))
row["community"] = $communityId
if nodeId in g.nodes:
row["_node_label"] = g.nodes[nodeId].label
result.add(row)
else:
for nodeId in g.nodes.keys:
var row = initTable[string, string]()
let nid = uint64(nodeId)
row["_node_id"] = $nid
row["_node_label"] = g.nodes[nodeId].label
for col in returnCols:
if col == "id": row[col] = $nid
elif col == "label": row[col] = g.nodes[nodeId].label
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
result.add(row)
else:
return @[]
@@ -3421,6 +3592,7 @@ proc isDDL(stmt: Node): bool =
nkCreateTrigger, nkDropTrigger,
nkCreateUser, nkDropUser,
nkCreatePolicy, nkDropPolicy,
nkCreateGraph, nkDropGraph,
nkGrant, nkRevoke,
nkEnableRLS, nkDisableRLS:
result = true
@@ -4211,6 +4383,48 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
for idxName in toDelete: ctx.btrees.del(idxName)
return okResult()
of nkCreateGraph:
let name = stmt.cgName
if name in ctx.graphs:
if not stmt.cgIfNotExists:
return errResult("Graph '" & name & "' already exists")
return okResult(msg="Graph '" & name & "' already exists")
var g = gengine.newGraph()
ctx.graphs[name] = g
var createNodesSql = "CREATE TABLE " & name & "_nodes (id INTEGER PRIMARY KEY, node_label TEXT, properties TEXT)"
var createEdgesSql = "CREATE TABLE " & name & "_edges (source_id INTEGER, dest_id INTEGER, edge_label TEXT, weight REAL)"
let nodesTokens = qlex.tokenize(createNodesSql)
let nodesAst = qpar.parse(nodesTokens)
let nodesRes = executeQueryImpl(ctx, nodesAst)
if not nodesRes.success:
ctx.graphs.del(name)
return errResult("Failed to create graph nodes table: " & nodesRes.message)
let edgesTokens = qlex.tokenize(createEdgesSql)
let edgesAst = qpar.parse(edgesTokens)
let edgesRes = executeQueryImpl(ctx, edgesAst)
if not edgesRes.success:
ctx.tables.del(name & "_nodes")
ctx.graphs.del(name)
return errResult("Failed to create graph edges table: " & edgesRes.message)
return okResult(msg="CREATE GRAPH " & name)
of nkDropGraph:
let name = stmt.dgName
if name notin ctx.graphs:
if stmt.dgIfExists:
return okResult()
return errResult("Graph '" & name & "' does not exist")
ctx.graphs.del(name)
var dropNodesSql = "DROP TABLE " & name & "_nodes"
var dropEdgesSql = "DROP TABLE " & name & "_edges"
let nodesTokens = qlex.tokenize(dropNodesSql)
let nodesAst = qpar.parse(nodesTokens)
discard executeQueryImpl(ctx, nodesAst)
let edgesTokens = qlex.tokenize(dropEdgesSql)
let edgesAst = qpar.parse(edgesTokens)
discard executeQueryImpl(ctx, edgesAst)
return okResult(msg="DROP GRAPH " & name)
of nkBeginTxn:
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
discard ctx.txnManager.commit(ctx.pendingTxn)
+5 -1
View File
@@ -135,6 +135,8 @@ type
tkEdge
tkLabels
tkGraphTable
tkCreateGraph
tkDropGraph
tkMatch
tkColumns
tkSrc
@@ -362,6 +364,9 @@ const keywords*: Table[string, TokenKind] = {
"label": tkLabels,
"labels": tkLabels,
"graph_table": tkGraphTable,
"create_graph": tkCreateGraph,
"drop_graph": tkDropGraph,
"graph": tkGraph,
"match": tkMatch,
"columns": tkColumns,
"src": tkSrc,
@@ -370,7 +375,6 @@ const keywords*: Table[string, TokenKind] = {
"matched": tkMatched,
"array": tkArray,
"vector": tkVector,
"graph": tkGraph,
"document": tkDocument,
"similar": tkSimilar,
"nearest": tkNearest,
+113 -34
View File
@@ -42,6 +42,8 @@ proc parseOverClause(p: var Parser): Node
proc parseFrameSpec(p: var Parser): Node
proc parseFrameBoundary(p: var Parser): string
proc parseSetVar(p: var Parser): Node
proc parseCreateGraph(p: var Parser): Node
proc parseDropGraph(p: var Parser): Node
proc parsePrimary(p: var Parser): Node =
let tok = p.peek()
@@ -464,60 +466,108 @@ proc parseSelect(p: var Parser): Node =
discard p.advance()
discard p.expect(tkLParen)
let graphName = p.expect(tkIdent).value
discard p.expect(tkMatch)
# Parse pattern: (node)-[edge]->(node)
var hasMatch = p.match(tkMatch)
var patternNodes: seq[string]
var patternEdges: seq[string]
# First node
discard p.expect(tkLParen)
if p.peek().kind == tkIdent:
patternNodes.add(p.advance().value)
discard p.expect(tkRParen)
# Edge and next node(s)
while p.peek().kind == tkMinus or p.peek().kind == tkArrowR:
if p.match(tkArrowR):
discard
elif p.match(tkMinus):
if p.peek().kind == tkLBracket:
if hasMatch:
# First node
discard p.expect(tkLParen)
if p.peek().kind == tkIdent:
patternNodes.add(p.advance().value)
discard p.expect(tkRParen)
# Edge and next node(s)
while p.peek().kind == tkMinus or p.peek().kind == tkArrowR:
if p.match(tkArrowR):
discard
elif p.match(tkMinus):
if p.peek().kind == tkLBracket:
discard p.advance()
if p.peek().kind == tkIdent:
patternEdges.add(p.advance().value)
discard p.expect(tkRBracket)
discard p.expect(tkArrowR)
else:
discard
if p.peek().kind == tkLParen:
discard p.advance()
if p.peek().kind == tkIdent:
patternEdges.add(p.advance().value)
discard p.expect(tkRBracket)
discard p.expect(tkArrowR)
else:
discard
if p.peek().kind == tkLParen:
discard p.advance()
if p.peek().kind == tkIdent:
patternNodes.add(p.advance().value)
discard p.expect(tkRParen)
patternNodes.add(p.advance().value)
discard p.expect(tkRParen)
# COLUMNS (col1, col2, ...)
var algo = "bfs"
var maxDepth = -1
var startId = ""
var endId = ""
let startVar = if patternNodes.len > 0: patternNodes[0] else: ""
let endVar = if patternNodes.len > 1: patternNodes[1] else: ""
if p.peek().kind == tkIdent and p.peek().value.toLower() == "algorithm":
discard p.advance()
algo = p.advance().value.toLower()
if p.peek().kind == tkIdent and p.peek().value.toLower() == "start":
discard p.advance()
if p.peek().kind == tkIntLit:
startId = p.advance().value
elif p.peek().kind == tkIdent:
startId = p.advance().value
if p.peek().kind == tkIdent and p.peek().value.toLower() == "end" or p.peek().kind == tkEnd:
discard p.advance()
if p.peek().kind == tkIntLit:
endId = p.advance().value
elif p.peek().kind == tkIdent:
endId = p.advance().value
if p.peek().kind == tkIdent and p.peek().value.toLower() == "maxdepth":
discard p.advance()
if p.peek().kind == tkIntLit:
maxDepth = parseInt(p.advance().value)
var returnCols: seq[string]
if p.match(tkColumns):
discard p.expect(tkLParen)
if p.peek().kind == tkIdent:
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkEnd, tkMatch, tkColumns, tkSrc, tkDst, tkBfs, tkDfs, tkMerge}:
var colName = p.advance().value
# Handle dotted names: e.name
while p.peek().kind == tkDot:
discard p.advance() # skip dot
colName &= "." & p.expect(tkIdent).value
discard p.advance()
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkBfs, tkDfs, tkMatch, tkColumns, tkEnd, tkSrc, tkDst, tkMerge}:
colName &= "." & p.advance().value
else:
colName &= "." & p.expect(tkIdent).value
returnCols.add(colName)
while p.match(tkComma):
if p.peek().kind == tkIdent:
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkEnd, tkMatch, tkColumns, tkSrc, tkDst, tkBfs, tkDfs, tkMerge}:
colName = p.advance().value
while p.peek().kind == tkDot:
discard p.advance()
colName &= "." & p.expect(tkIdent).value
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkBfs, tkDfs, tkMatch, tkColumns, tkEnd, tkSrc, tkDst, tkMerge}:
colName &= "." & p.advance().value
else:
colName &= "." & p.expect(tkIdent).value
returnCols.add(colName)
if p.match(tkAs):
discard p.advance() # skip alias
discard p.advance()
discard p.expect(tkRParen)
discard p.expect(tkRParen)
# Create a graph traversal node
var startNode: Node = nil
if startId.len > 0:
startNode = Node(kind: nkIntLit, intVal: parseInt(startId))
elif patternNodes.len > 0:
startNode = Node(kind: nkIdent, identName: patternNodes[0])
var endNode: Node = nil
if endId.len > 0:
endNode = Node(kind: nkIntLit, intVal: parseInt(endId))
elif patternNodes.len > 1:
endNode = Node(kind: nkIdent, identName: patternNodes[1])
result.selFrom = Node(kind: nkGraphTraversal, gtGraphName: graphName,
gtStart: nil, gtEdge: if patternEdges.len > 0: patternEdges[0] else: "",
gtDirection: "out", gtEnd: nil, gtMaxDepth: -1,
gtReturnCols: returnCols, line: tok.line, col: tok.col)
gtStart: startNode, gtEdge: if patternEdges.len > 0: patternEdges[0] else: "",
gtDirection: "out", gtEnd: endNode, gtMaxDepth: maxDepth,
gtReturnCols: returnCols, gtAlgo: algo, line: tok.line, col: tok.col)
else:
let tableTok = p.expect(tkIdent)
var alias = ""
@@ -1542,6 +1592,31 @@ proc parseSetVar(p: var Parser): Node =
result = Node(kind: nkSetVar, svName: varName, svValue: valStr,
line: tok.line, col: tok.col)
proc parseCreateGraph(p: var Parser): Node =
let tok = p.expect(tkCreate)
discard p.expect(tkGraph)
var ifNotExists = false
if p.peek().kind == tkIdent and p.peek().value.toLower() == "if":
discard p.advance()
discard p.expect(tkNot)
discard p.expect(tkExists)
ifNotExists = true
let name = p.expect(tkIdent).value
result = Node(kind: nkCreateGraph, cgName: name, cgIfNotExists: ifNotExists,
line: tok.line, col: tok.col)
proc parseDropGraph(p: var Parser): Node =
let tok = p.expect(tkDrop)
discard p.expect(tkGraph)
var ifExists = false
if p.peek().kind == tkIdent and p.peek().value.toLower() == "if":
discard p.advance()
discard p.expect(tkExists)
ifExists = true
let name = p.expect(tkIdent).value
result = Node(kind: nkDropGraph, dgName: name, dgIfExists: ifExists,
line: tok.line, col: tok.col)
proc parseStatement*(p: var Parser): Node =
case p.peek().kind
of tkWith, tkSelect: p.parseSelect()
@@ -1568,6 +1643,8 @@ proc parseStatement*(p: var Parser): Node =
p.parseCreateUser()
elif next.kind == tkPolicy:
p.parseCreatePolicy()
elif next.kind == tkGraph:
p.parseCreateGraph()
else:
p.parseCreateType()
else:
@@ -1587,6 +1664,8 @@ proc parseStatement*(p: var Parser): Node =
p.parseDropUser()
elif next.kind == tkPolicy:
p.parseDropPolicy()
elif next.kind == tkGraph:
p.parseDropGraph()
else:
let tok = p.advance()
Node(kind: nkNullLit, line: tok.line, col: tok.col)