Skip to content

Commit 7ad8a99

Browse files
committed
fixing query support and neo4j compatibility and create perormance when k-means is enabled
1 parent 1faa362 commit 7ad8a99

File tree

7 files changed

+1403
-11
lines changed

7 files changed

+1403
-11
lines changed

pkg/cypher/call.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1234,7 +1234,13 @@ func (e *StorageExecutor) callDbIndexFulltextQueryNodes(cypher string) (*Execute
12341234
}
12351235
}
12361236

1237-
// Default searchable properties if no index config
1237+
// Neo4j compatibility: error if index doesn't exist
1238+
// Only apply strict validation for explicitly named indexes (not "default")
1239+
if len(targetProperties) == 0 && indexName != "" && indexName != "default" {
1240+
return nil, fmt.Errorf("there is no such fulltext schema index: %s", indexName)
1241+
}
1242+
1243+
// Default searchable properties if no index config or using "default"
12381244
if len(targetProperties) == 0 {
12391245
targetProperties = []string{"content", "text", "title", "name", "description", "body", "summary"}
12401246
}

pkg/cypher/clauses_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,12 @@ func TestCallDbIndexFulltextQueryNodes(t *testing.T) {
698698
e := NewStorageExecutor(store)
699699
ctx := context.Background()
700700

701+
// Create the fulltext index first (Neo4j compatibility)
702+
_, err := e.Execute(ctx, "CALL db.index.fulltext.createNodeIndex('node_search', ['Document'], ['content', 'title'])", nil)
703+
if err != nil {
704+
t.Fatalf("Failed to create fulltext index: %v", err)
705+
}
706+
701707
// Create nodes with searchable content
702708
store.CreateNode(&storage.Node{
703709
ID: "doc-1",
@@ -734,6 +740,12 @@ func TestCallDbIndexFulltextQueryNoMatch(t *testing.T) {
734740
e := NewStorageExecutor(store)
735741
ctx := context.Background()
736742

743+
// Create the fulltext index first (Neo4j compatibility)
744+
_, err := e.Execute(ctx, "CALL db.index.fulltext.createNodeIndex('node_search', ['Document'], ['content'])", nil)
745+
if err != nil {
746+
t.Fatalf("Failed to create fulltext index: %v", err)
747+
}
748+
737749
store.CreateNode(&storage.Node{
738750
ID: "doc-1",
739751
Labels: []string{"Document"},

pkg/cypher/create.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,290 @@ func (e *StorageExecutor) executeCompoundCreateWithDelete(ctx context.Context, c
15251525
return result, nil
15261526
}
15271527

1528+
// executeCreateSet handles CREATE ... SET queries (Neo4j compatibility).
1529+
// Neo4j allows SET immediately after CREATE to set additional properties
1530+
// on newly created nodes/relationships.
1531+
// Example: CREATE (n:Node {id: 'test'}) SET n.content = 'value' RETURN n
1532+
func (e *StorageExecutor) executeCreateSet(ctx context.Context, cypher string) (*ExecuteResult, error) {
1533+
// Substitute parameters AFTER routing to avoid keyword detection issues
1534+
if params := getParamsFromContext(ctx); params != nil {
1535+
cypher = e.substituteParams(cypher, params)
1536+
}
1537+
1538+
result := &ExecuteResult{
1539+
Columns: []string{},
1540+
Rows: [][]interface{}{},
1541+
Stats: &QueryStats{},
1542+
}
1543+
1544+
// Normalize whitespace for index finding (newlines/tabs become spaces)
1545+
normalized := strings.ReplaceAll(strings.ReplaceAll(cypher, "\n", " "), "\t", " ")
1546+
1547+
// Find clause boundaries
1548+
setIdx := findKeywordIndex(normalized, "SET")
1549+
returnIdx := findKeywordIndex(normalized, "RETURN")
1550+
1551+
if setIdx < 0 {
1552+
return nil, fmt.Errorf("SET clause not found in CREATE...SET query")
1553+
}
1554+
1555+
// Extract CREATE part (everything before SET)
1556+
createPart := strings.TrimSpace(normalized[:setIdx])
1557+
1558+
// Extract SET part (between SET and RETURN, or end)
1559+
var setPart string
1560+
if returnIdx > 0 {
1561+
setPart = strings.TrimSpace(normalized[setIdx+4 : returnIdx])
1562+
} else {
1563+
setPart = strings.TrimSpace(normalized[setIdx+4:])
1564+
}
1565+
1566+
// Pre-validate SET assignments BEFORE executing CREATE
1567+
// This ensures we fail fast and don't create nodes that would be orphaned
1568+
if !strings.Contains(setPart, "+=") {
1569+
assignments := e.splitSetAssignmentsRespectingBrackets(setPart)
1570+
if err := e.validateSetAssignments(assignments); err != nil {
1571+
return nil, err
1572+
}
1573+
}
1574+
1575+
// Execute CREATE first and get references to created entities
1576+
createResult, createdNodes, createdEdges, err := e.executeCreateWithRefs(ctx, createPart)
1577+
if err != nil {
1578+
return nil, fmt.Errorf("CREATE failed in CREATE...SET: %w", err)
1579+
}
1580+
result.Stats.NodesCreated = createResult.Stats.NodesCreated
1581+
result.Stats.RelationshipsCreated = createResult.Stats.RelationshipsCreated
1582+
1583+
// Check for property merge operator: n += $properties
1584+
if strings.Contains(setPart, "+=") {
1585+
// Handle property merge on created entities
1586+
err := e.applySetMergeToCreated(setPart, createdNodes, createdEdges, result)
1587+
if err != nil {
1588+
return nil, err
1589+
}
1590+
} else {
1591+
// Handle regular SET assignments
1592+
// Split SET clause into individual assignments
1593+
assignments := e.splitSetAssignmentsRespectingBrackets(setPart)
1594+
1595+
for _, assignment := range assignments {
1596+
assignment = strings.TrimSpace(assignment)
1597+
if assignment == "" {
1598+
continue
1599+
}
1600+
1601+
// Parse assignment: var.property = value
1602+
eqIdx := strings.Index(assignment, "=")
1603+
if eqIdx == -1 {
1604+
// Could be a label assignment like "n:Label"
1605+
colonIdx := strings.Index(assignment, ":")
1606+
if colonIdx > 0 {
1607+
varName := strings.TrimSpace(assignment[:colonIdx])
1608+
newLabel := strings.TrimSpace(assignment[colonIdx+1:])
1609+
if node, exists := createdNodes[varName]; exists {
1610+
// Add label to existing node
1611+
if !containsString(node.Labels, newLabel) {
1612+
node.Labels = append(node.Labels, newLabel)
1613+
if err := e.storage.UpdateNode(node); err != nil {
1614+
return nil, fmt.Errorf("failed to add label: %w", err)
1615+
}
1616+
result.Stats.LabelsAdded++
1617+
}
1618+
}
1619+
}
1620+
continue
1621+
}
1622+
1623+
leftSide := strings.TrimSpace(assignment[:eqIdx])
1624+
rightSide := strings.TrimSpace(assignment[eqIdx+1:])
1625+
1626+
// Parse variable.property
1627+
dotIdx := strings.Index(leftSide, ".")
1628+
if dotIdx == -1 {
1629+
return nil, fmt.Errorf("invalid SET assignment (expected var.property): %s", assignment)
1630+
}
1631+
1632+
varName := strings.TrimSpace(leftSide[:dotIdx])
1633+
propName := strings.TrimSpace(leftSide[dotIdx+1:])
1634+
1635+
// Note: Function validation is already done in validateSetAssignments()
1636+
// which runs before CREATE to ensure rollback safety
1637+
1638+
// Parse the value
1639+
value := e.parseValue(rightSide)
1640+
1641+
// Apply to created node or edge
1642+
if node, exists := createdNodes[varName]; exists {
1643+
node.Properties[propName] = value
1644+
if err := e.storage.UpdateNode(node); err != nil {
1645+
return nil, fmt.Errorf("failed to update node property: %w", err)
1646+
}
1647+
result.Stats.PropertiesSet++
1648+
} else if edge, exists := createdEdges[varName]; exists {
1649+
edge.Properties[propName] = value
1650+
if err := e.storage.UpdateEdge(edge); err != nil {
1651+
return nil, fmt.Errorf("failed to update edge property: %w", err)
1652+
}
1653+
result.Stats.PropertiesSet++
1654+
} else {
1655+
return nil, fmt.Errorf("unknown variable in SET clause: %s", varName)
1656+
}
1657+
}
1658+
}
1659+
1660+
// Handle RETURN clause
1661+
if returnIdx > 0 {
1662+
returnPart := strings.TrimSpace(normalized[returnIdx+6:])
1663+
1664+
// Parse return items
1665+
returnItems := splitReturnExpressions(returnPart)
1666+
for _, item := range returnItems {
1667+
item = strings.TrimSpace(item)
1668+
alias := item
1669+
1670+
// Check for alias
1671+
upperItem := strings.ToUpper(item)
1672+
if asIdx := strings.Index(upperItem, " AS "); asIdx > 0 {
1673+
alias = strings.TrimSpace(item[asIdx+4:])
1674+
item = strings.TrimSpace(item[:asIdx])
1675+
}
1676+
1677+
result.Columns = append(result.Columns, alias)
1678+
1679+
// Resolve the value
1680+
if node, exists := createdNodes[item]; exists {
1681+
if len(result.Rows) == 0 {
1682+
result.Rows = append(result.Rows, []interface{}{})
1683+
}
1684+
result.Rows[0] = append(result.Rows[0], node)
1685+
} else if edge, exists := createdEdges[item]; exists {
1686+
if len(result.Rows) == 0 {
1687+
result.Rows = append(result.Rows, []interface{}{})
1688+
}
1689+
result.Rows[0] = append(result.Rows[0], edge)
1690+
} else {
1691+
// Could be an expression or property access
1692+
if len(result.Rows) == 0 {
1693+
result.Rows = append(result.Rows, []interface{}{})
1694+
}
1695+
result.Rows[0] = append(result.Rows[0], nil)
1696+
}
1697+
}
1698+
} else {
1699+
// No RETURN clause - return created entities by default
1700+
for _, node := range createdNodes {
1701+
if len(result.Columns) == 0 {
1702+
result.Columns = append(result.Columns, "node")
1703+
}
1704+
if len(result.Rows) == 0 {
1705+
result.Rows = append(result.Rows, []interface{}{})
1706+
}
1707+
result.Rows[0] = append(result.Rows[0], node)
1708+
}
1709+
}
1710+
1711+
return result, nil
1712+
}
1713+
1714+
// applySetMergeToCreated applies SET += property merge to created entities.
1715+
func (e *StorageExecutor) applySetMergeToCreated(setPart string, createdNodes map[string]*storage.Node, createdEdges map[string]*storage.Edge, result *ExecuteResult) error {
1716+
// Parse: n += {prop: value, ...}
1717+
parts := strings.SplitN(setPart, "+=", 2)
1718+
if len(parts) != 2 {
1719+
return fmt.Errorf("invalid SET += syntax")
1720+
}
1721+
1722+
varName := strings.TrimSpace(parts[0])
1723+
propsStr := strings.TrimSpace(parts[1])
1724+
1725+
// Parse the properties map
1726+
props := e.parseMapLiteral(propsStr)
1727+
if props == nil {
1728+
return fmt.Errorf("failed to parse properties in SET +=")
1729+
}
1730+
1731+
// Apply to node or edge
1732+
if node, exists := createdNodes[varName]; exists {
1733+
for k, v := range props {
1734+
node.Properties[k] = v
1735+
result.Stats.PropertiesSet++
1736+
}
1737+
if err := e.storage.UpdateNode(node); err != nil {
1738+
return fmt.Errorf("failed to update node: %w", err)
1739+
}
1740+
} else if edge, exists := createdEdges[varName]; exists {
1741+
for k, v := range props {
1742+
edge.Properties[k] = v
1743+
result.Stats.PropertiesSet++
1744+
}
1745+
if err := e.storage.UpdateEdge(edge); err != nil {
1746+
return fmt.Errorf("failed to update edge: %w", err)
1747+
}
1748+
} else {
1749+
return fmt.Errorf("unknown variable in SET +=: %s", varName)
1750+
}
1751+
1752+
return nil
1753+
}
1754+
1755+
// containsString checks if a slice contains a string.
1756+
func containsString(slice []string, s string) bool {
1757+
for _, item := range slice {
1758+
if item == s {
1759+
return true
1760+
}
1761+
}
1762+
return false
1763+
}
1764+
1765+
// validateSetAssignments pre-validates SET clause assignments before executing CREATE
1766+
// This ensures we fail fast on invalid function calls, preventing orphaned nodes
1767+
func (e *StorageExecutor) validateSetAssignments(assignments []string) error {
1768+
// Known Cypher functions
1769+
knownFunctions := map[string]bool{
1770+
"COALESCE": true, "TOSTRING": true, "TOINT": true, "TOFLOAT": true,
1771+
"TOBOOLEAN": true, "TOLOWER": true, "TOUPPER": true, "TRIM": true,
1772+
"SIZE": true, "LENGTH": true, "ABS": true, "CEIL": true, "FLOOR": true,
1773+
"ROUND": true, "RAND": true, "SQRT": true, "SIGN": true, "LOG": true,
1774+
"LOG10": true, "EXP": true, "SIN": true, "COS": true, "TAN": true,
1775+
"DATE": true, "DATETIME": true, "TIME": true, "TIMESTAMP": true,
1776+
"DURATION": true, "LOCALDATETIME": true, "LOCALTIME": true,
1777+
"HEAD": true, "LAST": true, "TAIL": true, "KEYS": true, "LABELS": true,
1778+
"TYPE": true, "ID": true, "ELEMENTID": true, "PROPERTIES": true,
1779+
"POINT": true, "DISTANCE": true, "REPLACE": true, "SUBSTRING": true,
1780+
"LEFT": true, "RIGHT": true, "SPLIT": true, "REVERSE": true,
1781+
"LTRIM": true, "RTRIM": true, "COLLECT": true, "RANGE": true,
1782+
}
1783+
1784+
for _, assignment := range assignments {
1785+
assignment = strings.TrimSpace(assignment)
1786+
if assignment == "" {
1787+
continue
1788+
}
1789+
1790+
// Parse assignment: var.property = value or var:Label
1791+
eqIdx := strings.Index(assignment, "=")
1792+
if eqIdx == -1 {
1793+
// Could be a label assignment like "n:Label" - these are valid
1794+
continue
1795+
}
1796+
1797+
rightSide := strings.TrimSpace(assignment[eqIdx+1:])
1798+
1799+
// Check if right side looks like a function call
1800+
if strings.Contains(rightSide, "(") && strings.HasSuffix(strings.TrimSpace(rightSide), ")") {
1801+
// Extract function name (before first parenthesis)
1802+
parenIdx := strings.Index(rightSide, "(")
1803+
funcName := strings.ToUpper(strings.TrimSpace(rightSide[:parenIdx]))
1804+
if !knownFunctions[funcName] {
1805+
return fmt.Errorf("unknown function: %s", funcName)
1806+
}
1807+
}
1808+
}
1809+
return nil
1810+
}
1811+
15281812
// executeMerge handles MERGE queries with ON CREATE SET / ON MATCH SET support.
15291813
// This implements Neo4j-compatible MERGE semantics:
15301814
// 1. Try to find an existing node matching the pattern

pkg/cypher/executor.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,12 @@ func (e *StorageExecutor) executeWithoutTransaction(ctx context.Context, cypher
969969
hasOnCreateSet := containsKeywordOutsideStrings(cypher, "ON CREATE SET")
970970
hasOnMatchSet := containsKeywordOutsideStrings(cypher, "ON MATCH SET")
971971

972+
// NEO4J COMPAT: Handle CREATE ... SET pattern (e.g., CREATE (n) SET n.x = 1)
973+
// Neo4j allows SET immediately after CREATE without requiring MATCH
974+
if startsWithCreate && hasSet && !hasOnCreateSet && !hasOnMatchSet {
975+
return e.executeCreateSet(ctx, cypher)
976+
}
977+
972978
// Only route to executeSet if it's a MATCH ... SET or standalone SET
973979
if hasSet && !hasOnCreateSet && !hasOnMatchSet {
974980
return e.executeSet(ctx, cypher)
@@ -981,7 +987,15 @@ func (e *StorageExecutor) executeWithoutTransaction(ctx context.Context, cypher
981987
}
982988

983989
// Compound queries: MATCH ... OPTIONAL MATCH ...
990+
// But NOT when there's a WITH clause before OPTIONAL MATCH (that's handled by executeMatchWithOptionalMatch)
984991
if startsWithMatch && optionalMatchIdx > 0 {
992+
// Check if there's a WITH clause BEFORE OPTIONAL MATCH
993+
// If so, route to the specialized handler that processes WITH first
994+
withBeforeOptional := findKeywordIndex(cypher[:optionalMatchIdx], "WITH")
995+
if withBeforeOptional > 0 {
996+
// WITH comes before OPTIONAL MATCH - route to executeMatchWithOptionalMatch
997+
return e.executeMatchWithOptionalMatch(ctx, cypher)
998+
}
985999
return e.executeCompoundMatchOptionalMatch(ctx, cypher)
9861000
}
9871001

0 commit comments

Comments
 (0)