From c413fd5477ad279cb59e9edfaf7d9fe497d08535 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 9 Jul 2026 20:08:15 -0700 Subject: [PATCH] chore: fallout work from bringing beagle back up to date with dawgs mainline --- container/triplestore.go | 4 +-- container/triplestore_test.go | 46 +++++++++++++++++++++++++++++++++++ query/neo4j/rewrite.go | 14 +++++++++++ query/v2/backend_test.go | 29 ++++++++++++++++++++++ query/v2/query.go | 8 ++++++ query/v2/query_test.go | 12 +++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 container/triplestore_test.go diff --git a/container/triplestore.go b/container/triplestore.go index 6f6c65b6..acb909a3 100644 --- a/container/triplestore.go +++ b/container/triplestore.go @@ -249,7 +249,7 @@ func (s *triplestoreProjection) NumEdges() uint64 { count := uint64(0) s.origin.EachEdge(func(next Edge) bool { - if !s.deletedEdges.Contains(next.ID) { + if !s.deletedEdges.Contains(next.ID) && !s.deletedNodes.Contains(next.Start) && !s.deletedNodes.Contains(next.End) { count += 1 } @@ -281,7 +281,7 @@ func (s *triplestoreProjection) EachEdge(delegate func(next Edge) bool) { func (s *triplestoreProjection) EachAdjacentEdge(node uint64, direction graph.Direction, delegate func(next Edge) bool) { s.origin.EachAdjacentEdge(node, direction, func(next Edge) bool { - if !s.deletedEdges.Contains(next.ID) && !s.deletedNodes.Contains(next.Start) && !s.deletedNodes.Contains(next.Start) { + if !s.deletedEdges.Contains(next.ID) && !s.deletedNodes.Contains(next.Start) && !s.deletedNodes.Contains(next.End) { return delegate(next) } diff --git a/container/triplestore_test.go b/container/triplestore_test.go new file mode 100644 index 00000000..1ba05166 --- /dev/null +++ b/container/triplestore_test.go @@ -0,0 +1,46 @@ +package container_test + +import ( + "testing" + + "github.com/specterops/dawgs/cardinality" + "github.com/specterops/dawgs/container" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func collectAdjacentEdges(ts container.Triplestore, node uint64, direction graph.Direction) []container.Edge { + var edges []container.Edge + + ts.EachAdjacentEdge(node, direction, func(next container.Edge) bool { + edges = append(edges, next) + return true + }) + + return edges +} + +func collectEdges(ts container.Triplestore) []container.Edge { + var edges []container.Edge + + ts.EachEdge(func(next container.Edge) bool { + edges = append(edges, next) + return true + }) + + return edges +} + +func TestTriplestoreProjectionFiltersDeletedEndNodesFromAdjacentEdges(t *testing.T) { + ts := container.NewTriplestore() + ts.AddTriple(10, 1, 2) + ts.AddTriple(11, 2, 3) + + projection := ts.Projection(cardinality.NewBitmap64With(3), cardinality.NewBitmap64()) + + require.Equal(t, uint64(1), projection.NumEdges()) + require.Equal(t, []container.Edge{{ID: 10, Start: 1, End: 2}}, collectEdges(projection)) + require.Empty(t, collectAdjacentEdges(projection, 3, graph.DirectionInbound)) + require.Empty(t, collectAdjacentEdges(projection, 2, graph.DirectionOutbound)) + require.Equal(t, []container.Edge{{ID: 10, Start: 1, End: 2}}, collectAdjacentEdges(projection, 1, graph.DirectionOutbound)) +} diff --git a/query/neo4j/rewrite.go b/query/neo4j/rewrite.go index 2e6f0ce3..b99b06a9 100644 --- a/query/neo4j/rewrite.go +++ b/query/neo4j/rewrite.go @@ -41,6 +41,16 @@ func (s *ExpressionListRewriter) peekExpressionList() (cypher.ExpressionList, bo return nil, false } +func (s *ExpressionListRewriter) hasNegationAncestor() bool { + for idx := len(s.descentStack) - 1; idx >= 0; idx-- { + if _, isNegation := s.descentStack[idx].(*cypher.Negation); isNegation { + return true + } + } + + return false +} + func (s *ExpressionListRewriter) popExpression() { s.descentStack = s.descentStack[:len(s.descentStack)-1] } @@ -121,6 +131,10 @@ func (s *ExpressionListRewriter) Exit(node cypher.SyntaxNode) { if variable, typeOK := typedNode.Reference.(*cypher.Variable); !typeOK { s.SetErrorf("expected a variable as the reference for a kind matcher but received: %T", node) } else if variable.Symbol == query.EdgeSymbol { + if s.hasNegationAncestor() { + return + } + // We need to remove this expression from the most recent expression list and tack it onto the // relationship of the last match if lastMatch, hasLastMatch := s.peekLastMatch(); !hasLastMatch { diff --git a/query/v2/backend_test.go b/query/v2/backend_test.go index 475e4e45..f5524ff9 100644 --- a/query/v2/backend_test.go +++ b/query/v2/backend_test.go @@ -53,6 +53,35 @@ func TestBackendParityNeo4jPrepare(t *testing.T) { expectedCypher: "match (s)-[r:MemberOf]->(e) where id(s) = $p0 return id(s), id(r), id(e)", expectedParams: map[string]any{"p0": 1}, }, + "relationship read with negated relationship kind": { + builder: v2.New().Where( + v2.Not(v2.Relationship().Kind().IsOneOf(graph.Kinds{graph.StringKind("HasSession")})), + v2.Relationship().Kind().IsOneOf(graph.Kinds{graph.StringKind("MemberOf")}), + v2.End().ID().Equals(42), + ).Return( + v2.Start().ID(), + v2.Relationship().ID(), + v2.KindsOf(v2.Relationship()), + v2.End().ID(), + ), + expectedCypher: "match (s)-[r:MemberOf]->(e) where not r:HasSession and id(e) = $p0 return id(s), id(r), type(r), id(e)", + expectedParams: map[string]any{"p0": 42}, + }, + "relationship read with compound negated relationship kinds": { + builder: v2.New().Where( + v2.Not(v2.Or( + v2.Relationship().Kind().Is(graph.StringKind("HasSession")), + v2.Relationship().Kind().Is(graph.StringKind("MemberOf")), + )), + v2.End().ID().Equals(42), + ).Return( + v2.Start().ID(), + v2.Relationship().ID(), + v2.End().ID(), + ), + expectedCypher: "match (s)-[r]->(e) where not (r:HasSession or r:MemberOf) and id(e) = $p0 return id(s), id(r), id(e)", + expectedParams: map[string]any{"p0": 42}, + }, "shortest path": { builder: v2.New().WithShortestPaths().Where( v2.Relationship().Kind().Is(graph.StringKind("MemberOf")), diff --git a/query/v2/query.go b/query/v2/query.go index 66ecb179..a841c752 100644 --- a/query/v2/query.go +++ b/query/v2/query.go @@ -678,6 +678,10 @@ func (s kindContinuation) Is(kind graph.Kind) cypher.Expression { } func (s kindContinuation) IsOneOf(kinds graph.Kinds) cypher.Expression { + if len(kinds) == 0 { + return invalidExpression(fmt.Errorf("kind predicate requires at least one kind")) + } + return &cypher.KindMatcher{ Reference: s.identifier, Kinds: kinds, @@ -702,6 +706,10 @@ func (s kindsContinuation) Has(kind graph.Kind) cypher.Expression { } func (s kindsContinuation) HasOneOf(kinds graph.Kinds) cypher.Expression { + if len(kinds) == 0 { + return invalidExpression(fmt.Errorf("kind predicate requires at least one kind")) + } + return &cypher.KindMatcher{ Reference: s.identifier, Kinds: kinds, diff --git a/query/v2/query_test.go b/query/v2/query_test.go index 61ea0d32..188530fd 100644 --- a/query/v2/query_test.go +++ b/query/v2/query_test.go @@ -215,6 +215,18 @@ func TestInvalidCreateQualifiedExpressionReturnsError(t *testing.T) { require.ErrorContains(t, err, "invalid qualified expression for create: *cypher.PropertyLookup") } +func TestEmptyKindPredicatesReturnErrors(t *testing.T) { + _, err := v2.New().Where( + v2.Relationship().Kind().IsOneOf(nil), + ).Return(v2.Relationship()).Build() + require.ErrorContains(t, err, "kind predicate requires at least one kind") + + _, err = v2.New().Where( + v2.Node().Kinds().HasOneOf(graph.Kinds{}), + ).Return(v2.Node()).Build() + require.ErrorContains(t, err, "kind predicate requires at least one kind") +} + func TestUpdatingClausesPreserveFluentOrder(t *testing.T) { preparedQuery, err := v2.New().Create( v2.NodePattern(graph.Kinds{graph.StringKind("User")}, nil),