diff --git a/.gitignore b/.gitignore index 9834c914..5ba8be64 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ integration/testdata/local/ # Local benchmark comparison output .bench/ + +# Augment +.augment/ diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index c7d27587..efe84d6f 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -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 @@ -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, @@ -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, diff --git a/drivers/neo4j/node.go b/drivers/neo4j/node.go index 0484d286..a9e12b5b 100644 --- a/drivers/neo4j/node.go +++ b/drivers/neo4j/node.go @@ -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 diff --git a/drivers/neo4j/node_internal_test.go b/drivers/neo4j/node_internal_test.go new file mode 100644 index 00000000..21cff6ad --- /dev/null +++ b/drivers/neo4j/node_internal_test.go @@ -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()) + } + }) + } +} diff --git a/go.mod b/go.mod index 1f380c05..b51bb4c6 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ( @@ -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 diff --git a/go.sum b/go.sum index bfc4bb5c..c910d000 100644 --- a/go.sum +++ b/go.sum @@ -638,8 +638,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= @@ -658,8 +658,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -676,8 +676,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -689,8 +689,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -718,8 +718,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -728,8 +728,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -740,8 +740,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -761,8 +761,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/integration/testdata/cases/self_cycles.json b/integration/testdata/cases/self_cycles.json new file mode 100644 index 00000000..edd8dec8 --- /dev/null +++ b/integration/testdata/cases/self_cycles.json @@ -0,0 +1,145 @@ +{ + "dataset": "self_cycles", + "cases": [ + { + "name": "untyped variable-length self-loop returns exactly the 24 nodes that lie on a cycle and excludes the 2 acyclic decoy nodes", + "cypher": "match (n)-[*..]->(n) return n", + "assert": { + "keys": ["n"], + "row_count": 24, + "node_id_set": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "r", "s", "t", "u", "v", "w", "x", "y", "z"] + } + }, + { + "name": "untyped variable-length self-loop filtered to the acyclic decoy nodes (p, q, reachable from a but never returning) yields no rows", + "cypher": "match (n)-[*..]->(n) where n.cycle = 'acyclic' return n", + "assert": "empty" + }, + { + "name": "untyped self-loop for node a returns exactly its 1-hop self-edge path a->a and not the a->p decoy branch", + "cypher": "match p = (n)-[*..]->(n) where n.name = 'a' return p", + "assert": { + "keys": ["p"], + "row_count": 1, + "path_node_ids": [["a", "a"]], + "path_lengths": [1], + "path_edge_kinds": [["EdgeKind1"]] + } + }, + { + "name": "untyped self-loop for node b returns exactly one 2-hop cycle path b->c->b over two EdgeKind1 edges", + "cypher": "match p = (n)-[*..]->(n) where n.name = 'b' return p", + "assert": { + "keys": ["p"], + "row_count": 1, + "path_node_ids": [["b", "c", "b"]], + "path_lengths": [2], + "path_edge_kinds": [["EdgeKind1", "EdgeKind1"]] + } + }, + { + "name": "untyped self-loop for node d returns exactly one 3-hop cycle path d->e->f->d over three EdgeKind1 edges", + "cypher": "match p = (n)-[*..]->(n) where n.name = 'd' return p", + "assert": { + "keys": ["p"], + "row_count": 1, + "path_node_ids": [["d", "e", "f", "d"]], + "path_lengths": [3], + "path_edge_kinds": [["EdgeKind1", "EdgeKind1", "EdgeKind1"]] + } + }, + { + "name": "untyped self-loop filtered to the self4 group returns exactly its 4 member nodes g, h, i, j", + "cypher": "match (n)-[*..]->(n) where n.cycle = 'self4' return n", + "assert": { + "keys": ["n"], + "row_count": 4, + "node_id_set": ["g", "h", "i", "j"] + } + }, + { + "name": "untyped self-loop filtered to the mixed-kind self3mix group returns all 3 members r, s, t (the cycle closes only by crossing edge kinds)", + "cypher": "match (n)-[*..]->(n) where n.cycle = 'self3mix' return n", + "assert": { + "keys": ["n"], + "row_count": 3, + "node_id_set": ["r", "s", "t"] + } + }, + { + "name": "untyped self-loop for node r returns exactly one 3-hop path r->s->t->r whose edges span EdgeKind1, EdgeKind2, EdgeKind1", + "cypher": "match p = (n)-[*..]->(n) where n.name = 'r' return p", + "assert": { + "keys": ["p"], + "row_count": 1, + "path_node_ids": [["r", "s", "t", "r"]], + "path_lengths": [3], + "path_edge_kinds": [["EdgeKind1", "EdgeKind2", "EdgeKind1"]] + } + }, + { + "name": "EdgeKind1-typed self-loop returns exactly the 19 nodes on all-EdgeKind1 cycles, excluding the mixed-kind and EdgeKind2-only cycles", + "cypher": "match (n)-[:EdgeKind1*..]->(n) return n", + "assert": { + "keys": ["n"], + "row_count": 19, + "node_id_set": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "w", "x", "y", "z"] + } + }, + { + "name": "EdgeKind1-typed self-loop filtered to the mixed-kind self3mix group yields no rows (the cycle cannot close using EdgeKind1 alone)", + "cypher": "match (n)-[:EdgeKind1*..]->(n) where n.cycle = 'self3mix' return n", + "assert": "empty" + }, + { + "name": "EdgeKind1-typed self-loop filtered to the EdgeKind2-only self2k2 group yields no rows", + "cypher": "match (n)-[:EdgeKind1*..]->(n) where n.cycle = 'self2k2' return n", + "assert": "empty" + }, + { + "name": "EdgeKind2-typed self-loop returns exactly the 2 nodes u, v of the only EdgeKind2-only cycle", + "cypher": "match (n)-[:EdgeKind2*..]->(n) return n", + "assert": { + "keys": ["n"], + "row_count": 2, + "node_id_set": ["u", "v"] + } + }, + { + "name": "EdgeKind2-typed self-loop for node u returns exactly one 2-hop path u->v->u over two EdgeKind2 edges", + "cypher": "match p = (n)-[:EdgeKind2*..]->(n) where n.name = 'u' return p", + "assert": { + "keys": ["p"], + "row_count": 1, + "path_node_ids": [["u", "v", "u"]], + "path_lengths": [2], + "path_edge_kinds": [["EdgeKind2", "EdgeKind2"]] + } + }, + { + "name": "fixed-length (a)-[:EdgeKind1]->(b)-[:EdgeKind1]->(a) with limit 100 returns all 6 two-hop EdgeKind1 round-trip paths", + "cypher": "match p = (a)-[:EdgeKind1]->(b)-[:EdgeKind1]->(a) return p limit 100", + "assert": { + "keys": ["p"], + "row_count": 6, + "path_lengths": [2, 2, 2, 2, 2, 2], + "path_edge_kinds": [ + ["EdgeKind1", "EdgeKind1"], + ["EdgeKind1", "EdgeKind1"], + ["EdgeKind1", "EdgeKind1"], + ["EdgeKind1", "EdgeKind1"], + ["EdgeKind1", "EdgeKind1"], + ["EdgeKind1", "EdgeKind1"] + ] + } + }, + { + "name": "fixed-length untyped (a)-[]->(b)-[]->(a) with limit 100 returns all 8 two-hop round-trip endpoint pairs", + "cypher": "match (a)-[]->(b)-[]->(a) return a, b limit 100", + "assert": { + "keys": ["a", "b"], + "row_count": 8 + } + } + ] +} diff --git a/integration/testdata/self_cycles.json b/integration/testdata/self_cycles.json new file mode 100644 index 00000000..4c2baebd --- /dev/null +++ b/integration/testdata/self_cycles.json @@ -0,0 +1,78 @@ +{ + "graph": { + "nodes": [ + { "id": "a", "kinds": ["NodeKind1"], "properties": { "name": "a", "cycle": "self1", "hops": 1 } }, + + { "id": "b", "kinds": ["NodeKind1"], "properties": { "name": "b", "cycle": "self2", "hops": 2 } }, + { "id": "c", "kinds": ["NodeKind1"], "properties": { "name": "c", "cycle": "self2", "hops": 2 } }, + + { "id": "d", "kinds": ["NodeKind1"], "properties": { "name": "d", "cycle": "self3", "hops": 3 } }, + { "id": "e", "kinds": ["NodeKind1"], "properties": { "name": "e", "cycle": "self3", "hops": 3 } }, + { "id": "f", "kinds": ["NodeKind1"], "properties": { "name": "f", "cycle": "self3", "hops": 3 } }, + + { "id": "g", "kinds": ["NodeKind1"], "properties": { "name": "g", "cycle": "self4", "hops": 4 } }, + { "id": "h", "kinds": ["NodeKind1"], "properties": { "name": "h", "cycle": "self4", "hops": 4 } }, + { "id": "i", "kinds": ["NodeKind1"], "properties": { "name": "i", "cycle": "self4", "hops": 4 } }, + { "id": "j", "kinds": ["NodeKind1"], "properties": { "name": "j", "cycle": "self4", "hops": 4 } }, + + { "id": "k", "kinds": ["NodeKind1"], "properties": { "name": "k", "cycle": "self5", "hops": 5 } }, + { "id": "l", "kinds": ["NodeKind1"], "properties": { "name": "l", "cycle": "self5", "hops": 5 } }, + { "id": "m", "kinds": ["NodeKind1"], "properties": { "name": "m", "cycle": "self5", "hops": 5 } }, + { "id": "n", "kinds": ["NodeKind1"], "properties": { "name": "n", "cycle": "self5", "hops": 5 } }, + { "id": "o", "kinds": ["NodeKind1"], "properties": { "name": "o", "cycle": "self5", "hops": 5 } }, + + { "id": "p", "kinds": ["NodeKind1"], "properties": { "name": "p", "cycle": "acyclic" } }, + { "id": "q", "kinds": ["NodeKind1"], "properties": { "name": "q", "cycle": "acyclic" } }, + + { "id": "r", "kinds": ["NodeKind1"], "properties": { "name": "r", "cycle": "self3mix", "hops": 3 } }, + { "id": "s", "kinds": ["NodeKind1"], "properties": { "name": "s", "cycle": "self3mix", "hops": 3 } }, + { "id": "t", "kinds": ["NodeKind1"], "properties": { "name": "t", "cycle": "self3mix", "hops": 3 } }, + + { "id": "u", "kinds": ["NodeKind1"], "properties": { "name": "u", "cycle": "self2k2", "hops": 2 } }, + { "id": "v", "kinds": ["NodeKind1"], "properties": { "name": "v", "cycle": "self2k2", "hops": 2 } }, + + { "id": "w", "kinds": ["NodeKind1"], "properties": { "name": "w", "cycle": "self2b", "hops": 2 } }, + { "id": "x", "kinds": ["NodeKind1"], "properties": { "name": "x", "cycle": "self2b", "hops": 2 } }, + + { "id": "y", "kinds": ["NodeKind1"], "properties": { "name": "y", "cycle": "self2c", "hops": 2 } }, + { "id": "z", "kinds": ["NodeKind1"], "properties": { "name": "z", "cycle": "self2c", "hops": 2 } } + ], + "edges": [ + { "start_id": "a", "end_id": "a", "kind": "EdgeKind1", "properties": { "hops": 1 } }, + + { "start_id": "b", "end_id": "c", "kind": "EdgeKind1", "properties": { "hops": 2 } }, + { "start_id": "c", "end_id": "b", "kind": "EdgeKind1", "properties": { "hops": 2 } }, + + { "start_id": "d", "end_id": "e", "kind": "EdgeKind1", "properties": { "hops": 3 } }, + { "start_id": "e", "end_id": "f", "kind": "EdgeKind1", "properties": { "hops": 3 } }, + { "start_id": "f", "end_id": "d", "kind": "EdgeKind1", "properties": { "hops": 3 } }, + + { "start_id": "g", "end_id": "h", "kind": "EdgeKind1", "properties": { "hops": 4 } }, + { "start_id": "h", "end_id": "i", "kind": "EdgeKind1", "properties": { "hops": 4 } }, + { "start_id": "i", "end_id": "j", "kind": "EdgeKind1", "properties": { "hops": 4 } }, + { "start_id": "j", "end_id": "g", "kind": "EdgeKind1", "properties": { "hops": 4 } }, + + { "start_id": "k", "end_id": "l", "kind": "EdgeKind1", "properties": { "hops": 5 } }, + { "start_id": "l", "end_id": "m", "kind": "EdgeKind1", "properties": { "hops": 5 } }, + { "start_id": "m", "end_id": "n", "kind": "EdgeKind1", "properties": { "hops": 5 } }, + { "start_id": "n", "end_id": "o", "kind": "EdgeKind1", "properties": { "hops": 5 } }, + { "start_id": "o", "end_id": "k", "kind": "EdgeKind1", "properties": { "hops": 5 } }, + + { "start_id": "a", "end_id": "p", "kind": "EdgeKind1", "properties": { "hops": 0 } }, + { "start_id": "p", "end_id": "q", "kind": "EdgeKind1", "properties": { "hops": 0 } }, + + { "start_id": "r", "end_id": "s", "kind": "EdgeKind1", "properties": { "hops": 3 } }, + { "start_id": "s", "end_id": "t", "kind": "EdgeKind2", "properties": { "hops": 3 } }, + { "start_id": "t", "end_id": "r", "kind": "EdgeKind1", "properties": { "hops": 3 } }, + + { "start_id": "u", "end_id": "v", "kind": "EdgeKind2", "properties": { "hops": 2 } }, + { "start_id": "v", "end_id": "u", "kind": "EdgeKind2", "properties": { "hops": 2 } }, + + { "start_id": "w", "end_id": "x", "kind": "EdgeKind1", "properties": { "hops": 2 } }, + { "start_id": "x", "end_id": "w", "kind": "EdgeKind1", "properties": { "hops": 2 } }, + + { "start_id": "y", "end_id": "z", "kind": "EdgeKind1", "properties": { "hops": 2 } }, + { "start_id": "z", "end_id": "y", "kind": "EdgeKind1", "properties": { "hops": 2 } } + ] + } +} diff --git a/integration/testdata/self_cycles.md b/integration/testdata/self_cycles.md new file mode 100644 index 00000000..dd23ac4f --- /dev/null +++ b/integration/testdata/self_cycles.md @@ -0,0 +1,140 @@ +# self_cycles dataset + +Visualization of the `self_cycles.json` OpenGraph fixture used by +`integration/testdata/cases/self_cycles.json`. + +- **26 nodes, 26 edges**, node kind `NodeKind1`, edge kinds `EdgeKind1` and + `EdgeKind2`. +- Cycle nodes each lie on a cycle, so they can reach themselves — a correct + untyped `(n)-[*..]->(n)` returns all of them (`root_id = next_id`). Only node + `a` has a literal self-edge (`a→a`); every other cycle node closes the loop + over 2–5 hops. +- The decoy nodes (`p`, `q`) form a dead-end branch reachable from `a` (dashed + decoy edge `a→p→q`). They never return to themselves, so a correct self-loop + query must exclude them. +- The decoy edge `a→p` is the key adversarial case: node `a` has both a real + 1-hop self-loop **and** an outgoing branch to `p`, so the query must not + confuse "reachable from `a`" with "returns to `a`". +- **Multiple relationship kinds:** `self3mix` is a cycle whose edges span both + kinds, and `self2k2` is an `EdgeKind2`-only cycle. Together they exercise + untyped self-loops (must cross kinds) and typed `(n)-[:Kind*..]->(n)` + self-loops (must filter by kind). + +| Cycle group | Nodes | Structure | Hops | Edge kinds | +|---|---|---|---|---| +| `self1` | `a` | `a→a` | 1 | K1 | +| `self2` | `b,c` | `b→c→b` | 2 | K1 | +| `self2b` | `w,x` | `w→x→w` | 2 | K1 | +| `self2c` | `y,z` | `y→z→y` | 2 | K1 | +| `self3` | `d,e,f` | `d→e→f→d` | 3 | K1 | +| `self4` | `g,h,i,j` | `g→h→i→j→g` | 4 | K1 | +| `self5` | `k,l,m,n,o` | `k→l→m→n→o→k` | 5 | K1 | +| `self3mix` | `r,s,t` | `r→s→t→r` | 3 | K1, K2, K1 | +| `self2k2` | `u,v` | `u→v→u` | 2 | K2 | +| `acyclic` | `p,q` | `a→p→q` (dead-end) | — | K1 | + +Typed-self-loop expectations: +- `(n)-[:EdgeKind1*..]->(n)` returns `self1`–`self5` (all K1 cycles) but + **excludes** `self3mix` (can't close with only K1) and `self2k2` (K2-only). +- `(n)-[:EdgeKind2*..]->(n)` returns **only** `self2k2`. + +```mermaid +graph LR + subgraph self1["self1 — 1 hop"] + a((a)) + a -->|EdgeKind1| a + end + + subgraph self2["self2 — 2 hops"] + b((b)) -->|EdgeKind1| c((c)) + c -->|EdgeKind1| b + end + + subgraph self2b["self2b — 2 hops"] + w((w)) -->|EdgeKind1| xx((x)) + xx -->|EdgeKind1| w + end + + subgraph self2c["self2c — 2 hops"] + yy((y)) -->|EdgeKind1| zz((z)) + zz -->|EdgeKind1| yy + end + + subgraph self3["self3 — 3 hops"] + d((d)) -->|EdgeKind1| e((e)) + e -->|EdgeKind1| f((f)) + f -->|EdgeKind1| d + end + + subgraph self4["self4 — 4 hops"] + g((g)) -->|EdgeKind1| h((h)) + h -->|EdgeKind1| i((i)) + i -->|EdgeKind1| j((j)) + j -->|EdgeKind1| g + end + + subgraph self5["self5 — 5 hops"] + k((k)) -->|EdgeKind1| l((l)) + l -->|EdgeKind1| m((m)) + m -->|EdgeKind1| n((n)) + n -->|EdgeKind1| o((o)) + o -->|EdgeKind1| k + end + + subgraph self3mix["self3mix — 3 hops, mixed kinds"] + r((r)) -->|EdgeKind1| s((s)) + s -->|EdgeKind2| t((t)) + t -->|EdgeKind1| r + end + + subgraph self2k2["self2k2 — 2 hops, EdgeKind2 only"] + u((u)) -->|EdgeKind2| vv((v)) + vv -->|EdgeKind2| u + end + + subgraph acyclic["acyclic decoy — never returns"] + p((p)) -->|EdgeKind1| q((q)) + end + + a -.->|EdgeKind1 decoy| p + + classDef accent2 fill:#c6f6d5,stroke:#22863a,stroke-width:2px,color:#0b3d17 + classDef accent1 fill:#fed7d7,stroke:#c53030,stroke-width:2px,color:#5b1717 + class a,b,c,w,xx,yy,zz,d,e,f,g,h,i,j,k,l,m,n,o,r,s,t,u,vv accent2 + class p,q accent1 +``` + +## Test cases + +The cases below are defined in `integration/testdata/cases/self_cycles.json` and +run against this dataset. + +### Untyped variable-length self-loops — `(n)-[*..]->(n)` + +| Cypher | Expected result | +|---|---| +| `match (n)-[*..]->(n) return n` | The 24 nodes on a cycle; excludes the 2 acyclic decoy nodes (`p`, `q`). | +| `match (n)-[*..]->(n) where n.cycle = 'acyclic' return n` | Empty — decoy nodes `p`, `q` are reachable from `a` but never return. | +| `match p = (n)-[*..]->(n) where n.name = 'a' return p` | One 1-hop path `a→a`; the `a→p` decoy branch is not followed. | +| `match p = (n)-[*..]->(n) where n.name = 'b' return p` | One 2-hop path `b→c→b` over two EdgeKind1 edges. | +| `match p = (n)-[*..]->(n) where n.name = 'd' return p` | One 3-hop path `d→e→f→d` over three EdgeKind1 edges. | +| `match (n)-[*..]->(n) where n.cycle = 'self4' return n` | The 4 members of `self4`: `g`, `h`, `i`, `j`. | +| `match (n)-[*..]->(n) where n.cycle = 'self3mix' return n` | All 3 members `r`, `s`, `t`; the cycle closes only by crossing edge kinds. | +| `match p = (n)-[*..]->(n) where n.name = 'r' return p` | One 3-hop path `r→s→t→r` spanning EdgeKind1, EdgeKind2, EdgeKind1. | + +### Typed variable-length self-loops — `(n)-[:Kind*..]->(n)` + +| Cypher | Expected result | +|---|---| +| `match (n)-[:EdgeKind1*..]->(n) return n` | The 19 nodes on all-EdgeKind1 cycles; excludes mixed-kind and EdgeKind2-only cycles. | +| `match (n)-[:EdgeKind1*..]->(n) where n.cycle = 'self3mix' return n` | Empty — `self3mix` cannot close using EdgeKind1 alone. | +| `match (n)-[:EdgeKind1*..]->(n) where n.cycle = 'self2k2' return n` | Empty — `self2k2` is EdgeKind2-only. | +| `match (n)-[:EdgeKind2*..]->(n) return n` | The 2 nodes `u`, `v` of the only EdgeKind2-only cycle. | +| `match p = (n)-[:EdgeKind2*..]->(n) where n.name = 'u' return p` | One 2-hop path `u→v→u` over two EdgeKind2 edges. | + +### Fixed-length round-trips + +| Cypher | Expected result | +|---|---| +| `match p = (a)-[:EdgeKind1]->(b)-[:EdgeKind1]->(a) return p limit 100` | All 6 two-hop EdgeKind1 round-trip paths. | +| `match (a)-[]->(b)-[]->(a) return a, b limit 100` | All 8 two-hop round-trip endpoint pairs. | diff --git a/tools/dawgrun/README.md b/tools/dawgrun/README.md index c92676ab..de6a3ab0 100644 --- a/tools/dawgrun/README.md +++ b/tools/dawgrun/README.md @@ -73,6 +73,7 @@ The REPL supports command-name completion with `Tab`; ambiguous matches render a Available commands: ``` + connect-local Connects to the default local Postgres using the default dev credentials copy-opengraph Copies all graph data from one connection to another exit Quit explain-psql Explains a translated query over an active PG connection @@ -133,6 +134,24 @@ and dumps the result. ## Examples +### Connect to the default local backend + +For the repository's docker-compose Postgres, `connect-local` opens a +connection using the default development credentials and initializes +the `default` graph if it does not already exist: + + dawgrun > connect-local + Opened pg connection 'local' + +The connection name defaults to `local`; pass an optional name to +override it: + + dawgrun > connect-local dev + Opened pg connection 'dev' + +This is equivalent to running `open -init-graph local +"postgres://dawgs:weneedbetterpasswords@localhost:65432/dawgs?sslmode=disable"`. + ### Open a backend connection dawgrun > open local "postgres://dawgs:dawgs@localhost:5432/dawgs?sslmode=disable" diff --git a/tools/dawgrun/pkg/commands/db.go b/tools/dawgrun/pkg/commands/db.go index 36bd7e1d..c39081ee 100644 --- a/tools/dawgrun/pkg/commands/db.go +++ b/tools/dawgrun/pkg/commands/db.go @@ -115,6 +115,35 @@ func openCmd() CommandDesc { } } +const ( + // defaultConnectionName is the connection name used by connect-local when none is supplied. + defaultConnectionName = "local" + // defaultPGConnectionString is the docker-compose Postgres connection string using the repo's default dev credentials. + defaultPGConnectionString = "postgres://dawgs:weneedbetterpasswords@localhost:65432/dawgs?sslmode=disable" +) + +func connectLocalCmd() CommandDesc { + return CommandDesc{ + args: []string{"[name]"}, + help: "Connects to the default local Postgres using the default dev credentials", + desc: "Opens the docker-compose Postgres at " + defaultPGConnectionString + " and initializes the 'default' graph if needed. The connection name defaults to '" + defaultConnectionName + "'.", + + Fn: func(ctx *CommandContext, fields []string) error { + name := defaultConnectionName + if len(fields) >= 1 { + name = fields[0] + } + + _, err := openConnection(ctx, name, defaultPGConnectionString, openConnectionOptions{ + driverName: "", + defaultGraphName: "default", + initGraphOnFail: true, + }) + return err + }, + } +} + func openConnection(ctx *CommandContext, name string, connStr string, options openConnectionOptions) (string, error) { querier, driverName, err := ctx.scope.openDatabase(ctx, connStr, options) if err != nil { @@ -154,7 +183,6 @@ func openDAWGSDatabase(ctx context.Context, connStr string, options openConnecti if options.defaultGraphName == "" { options.defaultGraphName = "default" } - config := dawgs.Config{ ConnectionString: connStr, } diff --git a/tools/dawgrun/pkg/commands/registry.go b/tools/dawgrun/pkg/commands/registry.go index fe07b05a..324c7fd1 100644 --- a/tools/dawgrun/pkg/commands/registry.go +++ b/tools/dawgrun/pkg/commands/registry.go @@ -7,6 +7,7 @@ import ( ) var cmdRegistry map[string]CommandDesc = map[string]CommandDesc{ + "connect-local": connectLocalCmd(), "copy-opengraph": copyOpenGraphCmd(), "exit": quitCmd(), "explain-psql": explainAsPsqlCmd(),