Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ integration/testdata/local/

# Local benchmark comparison output
.bench/

# Augment
.augment/
71 changes: 51 additions & 20 deletions cypher/models/pgsql/translate/expansion.go
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,47 @@ func rewriteCurrentFrameProjectionReferences(expression pgsql.Expression, frameI
}
}

// isSelfLoopEndpoints reports whether a traversal step's left and right nodes are the same Cypher
// variable. For a self-loop such as (n)-[*..]->(n) the translator reuses a single BoundIdentifier for
// both endpoints.
func isSelfLoopEndpoints(traversalStep *TraversalStep) bool {
return traversalStep.LeftNode.Identifier == traversalStep.RightNode.Identifier
}

// expansionProjectionNodeJoins builds the projection node-lookup joins for an expansion frame. When the
// endpoints are the same variable a single join on root_id is emitted to avoid a duplicate table alias;
// otherwise the usual root_id/next_id pair is returned.
func expansionProjectionNodeJoins(traversalStep *TraversalStep, frameID pgsql.Identifier) []pgsql.Join {
rootJoin := expansionNodeLookupJoin(
traversalStep.LeftNode.Identifier,
pgsql.CompoundIdentifier{frameID, expansionRootID},
)

if isSelfLoopEndpoints(traversalStep) {
return []pgsql.Join{rootJoin}
}

nextJoin := expansionNodeLookupJoin(
traversalStep.RightNode.Identifier,
pgsql.CompoundIdentifier{frameID, expansionNextID},
)

return []pgsql.Join{rootJoin, nextJoin}
}

// selfLoopIdentityConstraint returns a root_id = next_id predicate for self-loop endpoints, restricting
// the projection to walks that returned to their origin. It returns nil for non-self-loops.
func selfLoopIdentityConstraint(traversalStep *TraversalStep, frameID pgsql.Identifier) pgsql.Expression {
if !isSelfLoopEndpoints(traversalStep) {
return nil
}

return pgd.Equals(
pgsql.CompoundIdentifier{frameID, expansionRootID},
pgsql.CompoundIdentifier{frameID, expansionNextID},
)
}

func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalStepContext, expansion *ExpansionBuilder) (pgsql.Query, error) {
var (
traversalStep = traversalStepContext.CurrentStep
Expand Down Expand Up @@ -2565,21 +2606,16 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte
Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier},
Binding: models.EmptyOptional[pgsql.Identifier](),
},
Joins: []pgsql.Join{
expansionNodeLookupJoin(
traversalStep.LeftNode.Identifier,
pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionRootID},
),
expansionNodeLookupJoin(
traversalStep.RightNode.Identifier,
pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID},
),
},
Joins: expansionProjectionNodeJoins(traversalStep, expansionModel.Frame.Binding.Identifier),
})

if projectionConstraints, err := s.buildExpansionProjectionConstraints(traversalStepContext); err != nil {
return pgsql.Query{}, err
} else {
projectionConstraints = pgsql.OptionalAnd(
projectionConstraints,
selfLoopIdentityConstraint(traversalStep, expansionModel.Frame.Binding.Identifier),
)
if previousProjectionFrameID != "" && traversalStep.LeftNodeBound {
projectionConstraints = pgsql.OptionalAnd(
projectionConstraints,
Expand Down Expand Up @@ -2707,21 +2743,16 @@ func (s *Translator) buildExpansionPatternStep(traversalStepContext TraversalSte
Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier},
Binding: models.EmptyOptional[pgsql.Identifier](),
},
Joins: []pgsql.Join{
expansionNodeLookupJoin(
traversalStep.LeftNode.Identifier,
pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionRootID},
),
expansionNodeLookupJoin(
traversalStep.RightNode.Identifier,
pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID},
),
},
Joins: expansionProjectionNodeJoins(traversalStep, expansionModel.Frame.Binding.Identifier),
})

if projectionConstraints, err := s.buildExpansionProjectionConstraints(traversalStepContext); err != nil {
return pgsql.Query{}, err
} else {
projectionConstraints = pgsql.OptionalAnd(
projectionConstraints,
selfLoopIdentityConstraint(traversalStep, expansionModel.Frame.Binding.Identifier),
)
projectionConstraints = rewriteCurrentFrameProjectionReferences(
projectionConstraints,
traversalStep.Frame.Binding.Identifier,
Expand Down
32 changes: 31 additions & 1 deletion drivers/neo4j/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,42 @@ import (
func newPath(internalPath neo4j_core.Path) graph.Path {
path := graph.Path{}

// Neo4j always returns a distinct node pool, so repeats in the walk
// (e.g. a cycle's closing node) are missing and the walk must be rebuilt by
// following the relationships, starting from Nodes[0]. A relationship's
// StartId/EndId are its stored direction, which the walk may traverse either
// way, so the next node is whichever endpoint is not the current walk position.
nodesByID := make(map[graph.ID]*graph.Node, len(internalPath.Nodes))
for _, node := range internalPath.Nodes {
path.Nodes = append(path.Nodes, newNode(node))
nodesByID[graph.ID(node.Id)] = newNode(node)
}

if len(internalPath.Relationships) == 0 {
for _, node := range internalPath.Nodes {
path.Nodes = append(path.Nodes, newNode(node))
}

return path
}

current := graph.ID(internalPath.Nodes[0].Id)
path.Nodes = append(path.Nodes, nodesByID[current])

// relationships are stored in traversal order so walk them to reconstruct path
for _, relationship := range internalPath.Relationships {
var (
start = graph.ID(relationship.StartId)
end = graph.ID(relationship.EndId)
next = end
)

if start != end && end == current {
next = start
}

path.Edges = append(path.Edges, newRelationship(relationship))
path.Nodes = append(path.Nodes, nodesByID[next])
current = next
}

return path
Expand Down
126 changes: 126 additions & 0 deletions drivers/neo4j/node_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package neo4j

import (
"testing"

neo4j_core "github.com/neo4j/neo4j-go-driver/v5/neo4j"
"github.com/specterops/dawgs/graph"
"github.com/stretchr/testify/require"
)

// coreNode builds a minimal neo4j driver node with the given ID.
func coreNode(id int64) neo4j_core.Node {
return neo4j_core.Node{Id: id}
}

// coreRel builds a minimal neo4j driver relationship. StartId and EndId report
// the relationship's stored direction, which may be the reverse of the direction
// the path traverses it.
func coreRel(id, start, end int64, kind string) neo4j_core.Relationship {
return neo4j_core.Relationship{Id: id, StartId: start, EndId: end, Type: kind}
}

func TestNewPath(t *testing.T) {
cases := []struct {
name string
path neo4j_core.Path
wantNodes []graph.ID
wantKinds []string
}{
{
name: "single node, no relationships",
path: neo4j_core.Path{Nodes: []neo4j_core.Node{coreNode(1)}},
wantNodes: []graph.ID{1},
wantKinds: nil,
},
{
name: "simple one hop a->b",
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1), coreNode(2)},
Relationships: []neo4j_core.Relationship{coreRel(10, 1, 2, "R")},
},
wantNodes: []graph.ID{1, 2},
wantKinds: []string{"R"},
},
{
name: "self loop a->a repeats the node",
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1)},
Relationships: []neo4j_core.Relationship{coreRel(10, 1, 1, "R")},
},
wantNodes: []graph.ID{1, 1},
wantKinds: []string{"R"},
},
{
name: "two hop cycle u->v->u repeats the closing node",
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1), coreNode(2)},
Relationships: []neo4j_core.Relationship{
coreRel(10, 1, 2, "R"),
coreRel(11, 2, 1, "R"),
},
},
wantNodes: []graph.ID{1, 2, 1},
wantKinds: []string{"R", "R"},
},
{
name: "three hop cycle d->e->f->d repeats the closing node",
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1), coreNode(2), coreNode(3)},
Relationships: []neo4j_core.Relationship{
coreRel(10, 1, 2, "R"),
coreRel(11, 2, 3, "R"),
coreRel(12, 3, 1, "R"),
},
},
wantNodes: []graph.ID{1, 2, 3, 1},
wantKinds: []string{"R", "R", "R"},
},
{
name: "inbound edge stored against traversal keeps pattern order",
// Models match p=(a)<-[:R]-(b): the path is bound a..b (Nodes[0]=a)
// but the edge is stored b->a, so StartId=2(b), EndId=1(a). The walk
// must still yield [a, b], following the pool order not stored order.
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1), coreNode(2)},
Relationships: []neo4j_core.Relationship{coreRel(10, 2, 1, "R")},
},
wantNodes: []graph.ID{1, 2},
wantKinds: []string{"R"},
},
{
name: "mixed direction three hop keeps traversal order",
// a -> b (stored a->b), then b <- c stored c->b traversed b..c.
path: neo4j_core.Path{
Nodes: []neo4j_core.Node{coreNode(1), coreNode(2), coreNode(3)},
Relationships: []neo4j_core.Relationship{
coreRel(10, 1, 2, "R"),
coreRel(11, 3, 2, "S"),
},
},
wantNodes: []graph.ID{1, 2, 3},
wantKinds: []string{"R", "S"},
},
}

for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
path := newPath(testCase.path)

require.Len(t, path.Nodes, len(testCase.wantNodes))
require.Len(t, path.Edges, len(testCase.wantKinds))
require.Equal(t, len(path.Edges)+1, len(path.Nodes))

for idx, wantID := range testCase.wantNodes {
require.NotNil(t, path.Nodes[idx], "node at index %d is nil", idx)
require.Equal(t, wantID, path.Nodes[idx].ID)
}

for idx, wantKind := range testCase.wantKinds {
require.NotNil(t, path.Edges[idx], "edge at index %d is nil", idx)
require.NotNil(t, path.Edges[idx].Kind, "edge kind at index %d is nil", idx)
require.Equal(t, wantKind, path.Edges[idx].Kind.String())
}
})
}
}
14 changes: 7 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
github.com/pashagolub/pgxmock/v5 v5.1.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/stretchr/testify v1.11.1
golang.org/x/tools v0.47.0
golang.org/x/tools v0.48.0
)

// Dawgrun requirements
Expand All @@ -31,7 +31,7 @@ require (
github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb
github.com/mitchellh/go-wordwrap v1.0.1
github.com/specterops/go-repl v1.0.1
golang.org/x/term v0.44.0
golang.org/x/term v0.45.0
)

require (
Expand Down Expand Up @@ -229,13 +229,13 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
Expand Down
Loading
Loading