From 7dadc28a13a9a9f15354710ed276c5425f223a8c Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Tue, 24 Feb 2026 10:48:02 -0600 Subject: [PATCH 1/4] fix(cypher): frontend should always emit exclusive kind matchers --- cypher/frontend/expression.go | 2 ++ cypher/models/cypher/model.go | 20 +++++++++++++------- cypher/models/pgsql/translate/kind.go | 19 ++++++++++++++++--- query/model.go | 21 +++++++++++---------- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/cypher/frontend/expression.go b/cypher/frontend/expression.go index e9b4e82..994105b 100644 --- a/cypher/frontend/expression.go +++ b/cypher/frontend/expression.go @@ -378,6 +378,8 @@ type NonArithmeticOperatorExpressionVisitor struct { func (s *NonArithmeticOperatorExpressionVisitor) EnterOC_NodeLabels(ctx *parser.OC_NodeLabelsContext) { s.Expression = &cypher.KindMatcher{ Reference: s.Expression, + // Cypher-generated `KindMatcher`s should _always_ be exclusive to jive with the spec + IsExclusive: true, } } diff --git a/cypher/models/cypher/model.go b/cypher/models/cypher/model.go index 32cccb4..aff1f0f 100644 --- a/cypher/models/cypher/model.go +++ b/cypher/models/cypher/model.go @@ -25,8 +25,10 @@ func (s AssignmentOperator) String() string { return string(s) } -type SyntaxNode any -type Expression SyntaxNode +type ( + SyntaxNode any + Expression SyntaxNode +) type ExpressionList interface { Add(expression Expression) @@ -744,12 +746,15 @@ func NewRangeQuantifier(value string) *RangeQuantifier { type KindMatcher struct { Reference Expression Kinds graph.Kinds + // IsExclusive changes the kind matching operator from overlap (PG &&) to a stricter "left contains right" (PG @>) + IsExclusive bool } -func NewKindMatcher(reference Expression, kinds graph.Kinds) *KindMatcher { +func NewKindMatcher(reference Expression, kinds graph.Kinds, isExclusive bool) *KindMatcher { return &KindMatcher{ - Reference: reference, - Kinds: kinds, + Reference: reference, + Kinds: kinds, + IsExclusive: isExclusive, } } @@ -759,8 +764,9 @@ func (s *KindMatcher) copy() *KindMatcher { } return &KindMatcher{ - Reference: Copy(s.Reference), - Kinds: Copy(s.Kinds), + Reference: Copy(s.Reference), + Kinds: Copy(s.Kinds), + IsExclusive: s.IsExclusive, } } diff --git a/cypher/models/pgsql/translate/kind.go b/cypher/models/pgsql/translate/kind.go index 71ae610..1862865 100644 --- a/cypher/models/pgsql/translate/kind.go +++ b/cypher/models/pgsql/translate/kind.go @@ -9,7 +9,7 @@ import ( "github.com/specterops/dawgs/cypher/models/pgsql/pgd" ) -func newPGKindIDMatcher(scope *Scope, treeTranslator *ExpressionTreeTranslator, binding *BoundIdentifier, kindIDs []int16) error { +func newPGKindIDMatcher(scope *Scope, treeTranslator *ExpressionTreeTranslator, binding *BoundIdentifier, kindIDs []int16, isExclusive bool) error { kindIDsLiteral := pgsql.NewLiteral(kindIDs, pgsql.Int2Array) switch binding.DataType { @@ -17,9 +17,22 @@ func newPGKindIDMatcher(scope *Scope, treeTranslator *ExpressionTreeTranslator, treeTranslator.PushOperand(pgd.Column(binding.Identifier, pgsql.ColumnKindIDs)) treeTranslator.PushOperand(kindIDsLiteral) - return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayLHSContainsRHS) + // In an exclusive kind match, if there are no kind IDs to be matched on, + // the behavior of the contains (`@>`) operator will select all nodes, which drastically differs from + // the overlap operator's behavior (matches nothing), so preserve the previous behavior. + // + // There shouldn't be a case in the Cypher frontend where a kind ID matcher is + // created without any kind IDs to match on, but `query.Kind`/`query.KindIn` can create those + // edge cases. We want any existing `query.Kind`/`query.KindIn` usages to match the previous behavior + // expectations by using overlap/`&&`, which protects from the empty RHS problem. + if isExclusive && len(kindIDs) > 0 { + return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayLHSContainsRHS) + } else { + return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayOverlap) + } case pgsql.EdgeComposite, pgsql.ExpansionEdge: + // Edge kind checking is a strict equality, so the IsExclusive condition does not apply here. treeTranslator.PushOperand(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnKindID}) treeTranslator.PushOperand(pgsql.NewAnyExpressionHinted(kindIDsLiteral)) @@ -39,6 +52,6 @@ func (s *Translator) translateKindMatcher(kindMatcher *cypher.KindMatcher) error } else if kindIDs, err := s.kindMapper.MapKinds(kindMatcher.Kinds); err != nil { return fmt.Errorf("failed to translate kinds: %w", err) } else { - return newPGKindIDMatcher(s.scope, s.treeTranslator, binding, kindIDs) + return newPGKindIDMatcher(s.scope, s.treeTranslator, binding, kindIDs, kindMatcher.IsExclusive) } } diff --git a/query/model.go b/query/model.go index a3b2233..c5b8588 100644 --- a/query/model.go +++ b/query/model.go @@ -10,9 +10,7 @@ import ( ) func convertCriteria[T any](criteria ...graph.Criteria) []T { - var ( - converted = make([]T, len(criteria)) - ) + converted := make([]T, len(criteria)) for idx, nextCriteria := range criteria { converted[idx] = nextCriteria.(T) @@ -67,8 +65,9 @@ func DeleteKind(reference graph.Criteria, kind graph.Kind) *cypherModel.Updating return cypherModel.NewUpdatingClause(&cypherModel.Remove{ Items: []*cypherModel.RemoveItem{{ KindMatcher: &cypherModel.KindMatcher{ - Reference: reference, - Kinds: graph.Kinds{kind}, + Reference: reference, + Kinds: graph.Kinds{kind}, + IsExclusive: false, }, }}, }) @@ -78,8 +77,9 @@ func DeleteKinds(reference graph.Criteria, kinds graph.Kinds) *cypherModel.Updat return cypherModel.NewUpdatingClause(&cypherModel.Remove{ Items: []*cypherModel.RemoveItem{{ KindMatcher: &cypherModel.KindMatcher{ - Reference: reference, - Kinds: kinds, + Reference: reference, + Kinds: kinds, + IsExclusive: false, }, }}, }) @@ -131,13 +131,14 @@ func DeleteProperties(reference graph.Criteria, propertyNames ...string) *cypher func Kind(reference graph.Criteria, kinds ...graph.Kind) *cypherModel.KindMatcher { return &cypherModel.KindMatcher{ - Reference: reference, - Kinds: kinds, + Reference: reference, + Kinds: kinds, + IsExclusive: false, } } func KindIn(reference graph.Criteria, kinds ...graph.Kind) *cypherModel.KindMatcher { - return cypherModel.NewKindMatcher(reference, kinds) + return cypherModel.NewKindMatcher(reference, kinds, false) } func NodeProperty(name string) *cypherModel.PropertyLookup { From 559061e784217703fddd07f8fba1977f12c92be7 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 26 Feb 2026 14:23:20 -0600 Subject: [PATCH 2/4] fix(pgutil): move nextKindID onto InMemoryKindMapper for determinism --- drivers/pg/pgutil/kindmapper.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/drivers/pg/pgutil/kindmapper.go b/drivers/pg/pgutil/kindmapper.go index ca325b1..8b04184 100644 --- a/drivers/pg/pgutil/kindmapper.go +++ b/drivers/pg/pgutil/kindmapper.go @@ -7,17 +7,17 @@ import ( "github.com/specterops/dawgs/graph" ) -var nextKindID = int16(1) - type InMemoryKindMapper struct { - KindToID map[graph.Kind]int16 - IDToKind map[int16]graph.Kind + nextKindID int16 + KindToID map[graph.Kind]int16 + IDToKind map[int16]graph.Kind } func NewInMemoryKindMapper() *InMemoryKindMapper { return &InMemoryKindMapper{ - KindToID: map[graph.Kind]int16{}, - IDToKind: map[int16]graph.Kind{}, + nextKindID: int16(1), + KindToID: map[graph.Kind]int16{}, + IDToKind: map[int16]graph.Kind{}, } } @@ -67,6 +67,7 @@ func (s *InMemoryKindMapper) mapKinds(kinds graph.Kinds) ([]int16, graph.Kinds) return ids, missing } + func (s *InMemoryKindMapper) MapKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { if ids, missing := s.mapKinds(kinds); len(missing) > 0 { return nil, fmt.Errorf("missing kinds: %v", missing) @@ -86,8 +87,8 @@ func (s *InMemoryKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds) } func (s *InMemoryKindMapper) Put(kind graph.Kind) int16 { - kindID := nextKindID - nextKindID += 1 + kindID := s.nextKindID + s.nextKindID += 1 s.KindToID[kind] = kindID s.IDToKind[kindID] = kind From 969157fd912a8488c942c12b1775ad30a99a2836 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 26 Feb 2026 16:24:14 -0600 Subject: [PATCH 3/4] fix(pgutil): make InMemoryKindMapper.Put idempotent --- drivers/pg/pgutil/kindmapper.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/drivers/pg/pgutil/kindmapper.go b/drivers/pg/pgutil/kindmapper.go index 8b04184..f0b8b54 100644 --- a/drivers/pg/pgutil/kindmapper.go +++ b/drivers/pg/pgutil/kindmapper.go @@ -87,6 +87,10 @@ func (s *InMemoryKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds) } func (s *InMemoryKindMapper) Put(kind graph.Kind) int16 { + if kindID, ok := s.KindToID[kind]; ok { + return kindID + } + kindID := s.nextKindID s.nextKindID += 1 From fc44869f6518197b55b33e8f4f7ae31bf3ed7e04 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 26 Feb 2026 17:18:30 -0600 Subject: [PATCH 4/4] chore(query): add test for query.Kind(In) generating inclusive matcher --- cypher/models/pgsql/test/query_test.go | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 cypher/models/pgsql/test/query_test.go diff --git a/cypher/models/pgsql/test/query_test.go b/cypher/models/pgsql/test/query_test.go new file mode 100644 index 0000000..3e3a617 --- /dev/null +++ b/cypher/models/pgsql/test/query_test.go @@ -0,0 +1,47 @@ +package test + +import ( + "context" + "slices" + "testing" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/query" +) + +func TestQuery_KindGeneratesInclusiveKindMatcher(t *testing.T) { + mapper := newKindMapper() + + queries := []*cypher.Where{ + query.Where(query.KindIn(query.Node(), NodeKind1)), + query.Where(query.Kind(query.Node(), NodeKind2)), + } + + for _, nodeQuery := range queries { + builder := query.NewBuilderWithCriteria(nodeQuery) + builtQuery, err := builder.Build(false) + if err != nil { + t.Errorf("could not build query: %v", err) + } + + translatedQuery, err := translate.Translate(context.Background(), builtQuery, mapper, nil) + if err != nil { + t.Errorf("could not translate query: %#v: %v", builtQuery, err) + } + + walk.PgSQL(translatedQuery.Statement, walk.NewSimpleVisitor(func(node pgsql.SyntaxNode, visitorHandler walk.VisitorHandler) { + switch typedNode := node.(type) { + case *pgsql.BinaryExpression: + switch leftTyped := typedNode.LOperand.(type) { + case pgsql.CompoundIdentifier: + if slices.Equal(leftTyped, pgsql.AsCompoundIdentifier("n0", "kind_ids")) && typedNode.Operator != pgsql.OperatorPGArrayOverlap { + t.Errorf("query did not generate an array overlap operator (&&): %#v", nodeQuery) + } + } + } + })) + } +}