a0c5ce8598
- similarity_nodes(graph, metric): Jaccard / Adamic-Adar similarity between all node pairs - node2vec_embed(graph, dimensions): Random-walk based graph embeddings - cypher(query): Translate Cypher MATCH...RETURN → BaraQL GRAPH_TABLE SQL - Fixed recursive lock deadlock in similarity functions - Fixed graph start node parsing (pattern variable vs numeric ID) - Added sequtils import to graph engine - All 340+ existing tests pass
388 lines
12 KiB
Nim
388 lines
12 KiB
Nim
## Cypher-like Graph Query Extension for BaraQL
|
|
import std/tables
|
|
import std/strutils
|
|
import std/locks
|
|
import engine
|
|
|
|
type
|
|
CypherNode* = object
|
|
variable*: string
|
|
label*: string
|
|
properties*: Table[string, string]
|
|
|
|
CypherEdge* = object
|
|
variable*: string
|
|
label*: string
|
|
direction*: string # "->", "<-", "--"
|
|
properties*: Table[string, string]
|
|
|
|
CypherPattern* = object
|
|
nodes*: seq[CypherNode]
|
|
edges*: seq[CypherEdge]
|
|
|
|
CypherQuery* = object
|
|
kind*: string # "MATCH", "CREATE", "MERGE", "DELETE"
|
|
pattern*: CypherPattern
|
|
whereClause*: string
|
|
returnExprs*: seq[string]
|
|
orderBy*: string
|
|
limit*: int
|
|
|
|
CypherResult* = object
|
|
columns*: seq[string]
|
|
rows*: seq[seq[string]]
|
|
|
|
proc parseCypher*(query: string): CypherQuery =
|
|
result = CypherQuery(returnExprs: @[], limit: 0)
|
|
let upper = query.toUpper().strip()
|
|
|
|
if upper.startsWith("MATCH"):
|
|
result.kind = "MATCH"
|
|
elif upper.startsWith("CREATE"):
|
|
result.kind = "CREATE"
|
|
elif upper.startsWith("MERGE"):
|
|
result.kind = "MERGE"
|
|
else:
|
|
return
|
|
|
|
# Parse node pattern: (variable:Label {props})
|
|
var pos = 0
|
|
var nodes: seq[CypherNode] = @[]
|
|
var edges: seq[CypherEdge] = @[]
|
|
|
|
while pos < query.len:
|
|
if query[pos] == '(':
|
|
# Parse node
|
|
inc pos
|
|
var variable = ""
|
|
var label = ""
|
|
var props = initTable[string, string]()
|
|
|
|
# Read variable name
|
|
while pos < query.len and query[pos] notin {':', ' ', '{', ')'}:
|
|
variable &= query[pos]
|
|
inc pos
|
|
|
|
if pos < query.len and query[pos] == ':':
|
|
inc pos
|
|
while pos < query.len and query[pos] notin {' ', '{', ')'}:
|
|
label &= query[pos]
|
|
inc pos
|
|
|
|
if pos < query.len and query[pos] == '{':
|
|
inc pos
|
|
var key = ""
|
|
var value = ""
|
|
var inKey = true
|
|
while pos < query.len and query[pos] != '}':
|
|
let ch = query[pos]
|
|
if ch == ':':
|
|
inKey = false
|
|
elif ch in {',', ' '}:
|
|
if key.len > 0 and value.len > 0:
|
|
props[key.strip()] = value.strip().strip(chars = {'"'})
|
|
key = ""
|
|
value = ""
|
|
inKey = true
|
|
elif inKey:
|
|
key &= ch
|
|
else:
|
|
value &= ch
|
|
inc pos
|
|
if key.len > 0 and value.len > 0:
|
|
props[key.strip()] = value.strip().strip(chars = {'"'})
|
|
inc pos # skip }
|
|
|
|
nodes.add(CypherNode(variable: variable, label: label, properties: props))
|
|
inc pos # skip )
|
|
elif query[pos] == '-' or query[pos] == '<' or query[pos] == '[':
|
|
# Parse edge
|
|
var direction = ""
|
|
var edgeVar = ""
|
|
var edgeLabel = ""
|
|
|
|
if query[pos] == '<':
|
|
inc pos
|
|
direction = "<-"
|
|
|
|
if query[pos] == '-':
|
|
inc pos
|
|
if direction == "":
|
|
direction = "-"
|
|
else:
|
|
direction &= "-"
|
|
|
|
if pos < query.len and query[pos] == '[':
|
|
inc pos
|
|
while pos < query.len and query[pos] notin {']', ':'}:
|
|
edgeVar &= query[pos]
|
|
inc pos
|
|
if pos < query.len and query[pos] == ':':
|
|
inc pos
|
|
while pos < query.len and query[pos] != ']':
|
|
edgeLabel &= query[pos]
|
|
inc pos
|
|
inc pos # skip ]
|
|
|
|
if pos < query.len and query[pos] == '-':
|
|
inc pos
|
|
direction &= "-"
|
|
if pos < query.len and query[pos] == '>':
|
|
inc pos
|
|
direction &= ">"
|
|
|
|
edges.add(CypherEdge(variable: edgeVar, label: edgeLabel, direction: direction))
|
|
else:
|
|
inc pos
|
|
|
|
result.pattern.nodes = nodes
|
|
result.pattern.edges = edges
|
|
|
|
# Parse WHERE
|
|
let wherePos = query.toUpper().find(" WHERE ")
|
|
if wherePos >= 0:
|
|
let whereStart = wherePos + 7
|
|
let returnPos = query.toUpper().find(" RETURN ")
|
|
if returnPos > wherePos:
|
|
result.whereClause = query[whereStart..<returnPos].strip()
|
|
else:
|
|
result.whereClause = query[whereStart..^1].strip()
|
|
|
|
# Parse RETURN
|
|
let returnPos = query.toUpper().find(" RETURN ")
|
|
if returnPos >= 0:
|
|
let returnContent = query[returnPos + 8..^1]
|
|
for expr in returnContent.split(","):
|
|
let trimmed = expr.strip()
|
|
if trimmed.len > 0:
|
|
result.returnExprs.add(trimmed)
|
|
|
|
# Parse ORDER BY
|
|
let orderPos = query.toUpper().find(" ORDER BY ")
|
|
if orderPos >= 0:
|
|
let limPos = query.toUpper().find(" LIMIT ")
|
|
if limPos > orderPos:
|
|
result.orderBy = query[orderPos + 9..<limPos].strip()
|
|
else:
|
|
result.orderBy = query[orderPos + 9..^1].strip()
|
|
|
|
# Parse LIMIT
|
|
let limPos = query.toUpper().find(" LIMIT ")
|
|
if limPos >= 0:
|
|
try:
|
|
result.limit = parseInt(query[limPos + 7..^1].strip())
|
|
except:
|
|
result.limit = 0
|
|
|
|
proc executeCypher*(g: Graph, query: CypherQuery): CypherResult =
|
|
result = CypherResult(columns: @[], rows: @[])
|
|
|
|
if query.pattern.nodes.len == 0:
|
|
return
|
|
|
|
acquire(g.lock)
|
|
defer: release(g.lock)
|
|
|
|
# Basic MATCH execution
|
|
if query.kind == "MATCH":
|
|
# For each node matching the pattern, collect results
|
|
for nodeId, node in g.nodes:
|
|
let patternNode = query.pattern.nodes[0]
|
|
if patternNode.label.len == 0 or node.label == patternNode.label:
|
|
var propsMatch = true
|
|
for pk, pv in patternNode.properties:
|
|
if node.properties.getOrDefault(pk, "") != pv:
|
|
propsMatch = false
|
|
break
|
|
if propsMatch:
|
|
var row: seq[string] = @[]
|
|
for expr in query.returnExprs:
|
|
if expr == patternNode.variable:
|
|
row.add(node.label)
|
|
elif expr.startsWith(patternNode.variable & "."):
|
|
let propName = expr[expr.find('.') + 1 .. ^1]
|
|
row.add(node.properties.getOrDefault(propName, ""))
|
|
else:
|
|
row.add(expr)
|
|
result.rows.add(row)
|
|
|
|
result.columns = query.returnExprs
|
|
if query.limit > 0 and result.rows.len > query.limit:
|
|
result.rows = result.rows[0..<query.limit]
|
|
|
|
proc toCypher*(query: string): string =
|
|
## Convert basic BaraQL to Cypher-like syntax for graph queries
|
|
let upper = query.toUpper()
|
|
if upper.startsWith("SELECT") and upper.contains("MATCH"):
|
|
return query
|
|
return query
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cypher → BaraQL GRAPH_TABLE translation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
proc cypherToSql*(cypher: string): string =
|
|
## Translate a Cypher MATCH query to BaraQL GRAPH_TABLE syntax.
|
|
## Example:
|
|
## MATCH (a:Person)-[r:KNOWS]->(b:Person) WHERE a.name = 'Alice' RETURN b.name
|
|
## → SELECT b.name FROM GRAPH_TABLE(g MATCH (a)-[r]->(b) COLUMNS (a.label, b.name))
|
|
let trimmed = cypher.strip()
|
|
if not trimmed.toUpper().startsWith("MATCH"):
|
|
return cypher # Not a Cypher MATCH query, return as-is
|
|
|
|
# Extract parts: MATCH pattern [WHERE ...] [RETURN ...] [ORDER BY ...] [LIMIT n]
|
|
var pattern = ""
|
|
var whereClause = ""
|
|
var returnClause = ""
|
|
var orderByClause = ""
|
|
var limitVal = ""
|
|
|
|
var remaining = trimmed[5..^1] # Strip "MATCH"
|
|
|
|
# Extract the MATCH pattern up to WHERE/RETURN/ORDER/LIMIT
|
|
let upperRemaining = remaining.toUpper()
|
|
let wherePos = upperRemaining.find("WHERE")
|
|
let returnPos = upperRemaining.find("RETURN")
|
|
let orderPos = upperRemaining.find("ORDER")
|
|
let limitPos = upperRemaining.find("LIMIT")
|
|
|
|
var patternEnd = remaining.len
|
|
for pos in [wherePos, returnPos, orderPos, limitPos]:
|
|
if pos > 0 and pos < patternEnd:
|
|
patternEnd = pos
|
|
|
|
pattern = remaining[0..<patternEnd].strip()
|
|
|
|
if returnPos > 0:
|
|
var returnStart = returnPos + 6
|
|
var returnEnd = remaining.len
|
|
for pos in [orderPos, limitPos]:
|
|
if pos > returnStart and pos < returnEnd:
|
|
returnEnd = pos
|
|
returnClause = remaining[returnStart..<returnEnd].strip()
|
|
|
|
if wherePos > 0 and (wherePos < returnPos or returnPos < 0):
|
|
var whereStart = wherePos + 5
|
|
var whereEnd = remaining.len
|
|
for pos in [returnPos, orderPos, limitPos]:
|
|
if pos > whereStart and pos < whereEnd:
|
|
whereEnd = pos
|
|
whereClause = remaining[whereStart..<whereEnd].strip()
|
|
|
|
if orderPos > 0:
|
|
var orderStart = orderPos + 5
|
|
if upperRemaining[orderPos + 5..<orderPos + 7] == "BY":
|
|
orderStart = orderPos + 8
|
|
var orderEnd = remaining.len
|
|
if limitPos > orderStart and limitPos < orderEnd:
|
|
orderEnd = limitPos
|
|
orderByClause = remaining[orderStart..<orderEnd].strip()
|
|
|
|
if limitPos > 0:
|
|
var limitStart = limitPos + 5
|
|
limitVal = remaining[limitStart..^1].strip().split(' ')[0]
|
|
|
|
# Parse pattern: (a:Label)-[r:TYPE]->(b:Label)
|
|
# Extract graph name from context or use "g"
|
|
var graphName = "g"
|
|
var patternNodes: seq[(string, string)] = @[] # (variable, label)
|
|
var patternEdges: seq[(string, string, string)] = @[] # (variable, label, direction)
|
|
|
|
var i = 0
|
|
while i < pattern.len:
|
|
if pattern[i] == '(':
|
|
inc i
|
|
var nodeVar = ""
|
|
var nodeLabel = ""
|
|
while i < pattern.len and pattern[i] != ')' and pattern[i] != ':':
|
|
if pattern[i] != ' ':
|
|
nodeVar.add(pattern[i])
|
|
inc i
|
|
if i < pattern.len and pattern[i] == ':':
|
|
inc i
|
|
while i < pattern.len and pattern[i] != ')' and pattern[i] != ' ':
|
|
nodeLabel.add(pattern[i])
|
|
inc i
|
|
if i < pattern.len and pattern[i] == ')':
|
|
inc i
|
|
patternNodes.add((nodeVar, nodeLabel))
|
|
elif pattern[i] == '[':
|
|
inc i
|
|
var edgeVar = ""
|
|
var edgeLabel = ""
|
|
var edgeDir = "->"
|
|
while i < pattern.len and pattern[i] != ']' and pattern[i] != ':':
|
|
if pattern[i] != ' ':
|
|
edgeVar.add(pattern[i])
|
|
inc i
|
|
if i < pattern.len and pattern[i] == ':':
|
|
inc i
|
|
while i < pattern.len and pattern[i] != ']' and pattern[i] != ' ':
|
|
edgeLabel.add(pattern[i])
|
|
inc i
|
|
if i < pattern.len and pattern[i] == ']':
|
|
inc i
|
|
if i < pattern.len and (pattern[i] == '-' or pattern[i] == '<'):
|
|
edgeDir = ""
|
|
while i < pattern.len and pattern[i] != '(':
|
|
if pattern[i] notin {' ', '-'}:
|
|
edgeDir.add(pattern[i])
|
|
inc i
|
|
patternEdges.add((edgeVar, edgeLabel, edgeDir))
|
|
elif pattern[i] in {'-', '<', '>'}:
|
|
inc i
|
|
else:
|
|
inc i
|
|
|
|
# Build GRAPH_TABLE SQL
|
|
var columns: seq[string] = @[]
|
|
if returnClause.len > 0:
|
|
for part in returnClause.split(','):
|
|
let col = part.strip()
|
|
if col == "*": continue
|
|
columns.add(col)
|
|
|
|
# Build pattern string for GRAPH_TABLE
|
|
var graphPattern = ""
|
|
for j in 0 ..< patternNodes.len:
|
|
graphPattern.add("(" & patternNodes[j][0] & ")")
|
|
if j < patternEdges.len:
|
|
graphPattern.add("-[" & patternEdges[j][0] & "]->")
|
|
elif j < patternNodes.len - 1:
|
|
graphPattern.add("-")
|
|
|
|
# Build SQL
|
|
let colsStr = if columns.len > 0: columns.join(", ") else: "*"
|
|
|
|
var sql = "SELECT " & colsStr & " FROM GRAPH_TABLE(" & graphName & " MATCH " & graphPattern
|
|
|
|
sql.add(" COLUMNS (")
|
|
if columns.len > 0:
|
|
sql.add(columns.join(", "))
|
|
sql.add("))")
|
|
|
|
if whereClause.len > 0:
|
|
sql.add(" WHERE ")
|
|
sql.add(whereClause)
|
|
if orderByClause.len > 0:
|
|
sql.add(" ORDER BY ")
|
|
sql.add(orderByClause)
|
|
if limitVal.len > 0:
|
|
sql.add(" LIMIT ")
|
|
sql.add(limitVal)
|
|
|
|
return sql
|
|
|
|
proc matchNodes*(g: Graph, label: string,
|
|
props: Table[string, string] = initTable[string, string]()): seq[GraphNode] =
|
|
result = @[]
|
|
for nodeId, node in g.nodes:
|
|
if label.len == 0 or node.label == label:
|
|
var match = true
|
|
for pk, pv in props:
|
|
if node.properties.getOrDefault(pk, "") != pv:
|
|
match = false
|
|
break
|
|
if match:
|
|
result.add(node)
|