Skip to content

Commit f9904ca

Browse files
authored
fix(cypher): frontend should always emit exclusive kind matchers BED-7495 (#37)
* fix(cypher): frontend should always emit exclusive kind matchers * fix(pgutil): move nextKindID onto InMemoryKindMapper for determinism * fix(pgutil): make InMemoryKindMapper.Put idempotent * chore(query): add test for query.Kind(In) generating inclusive matcher
1 parent a3be153 commit f9904ca

6 files changed

Lines changed: 102 additions & 28 deletions

File tree

cypher/frontend/expression.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ type NonArithmeticOperatorExpressionVisitor struct {
378378
func (s *NonArithmeticOperatorExpressionVisitor) EnterOC_NodeLabels(ctx *parser.OC_NodeLabelsContext) {
379379
s.Expression = &cypher.KindMatcher{
380380
Reference: s.Expression,
381+
// Cypher-generated `KindMatcher`s should _always_ be exclusive to jive with the spec
382+
IsExclusive: true,
381383
}
382384
}
383385

cypher/models/cypher/model.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ func (s AssignmentOperator) String() string {
2525
return string(s)
2626
}
2727

28-
type SyntaxNode any
29-
type Expression SyntaxNode
28+
type (
29+
SyntaxNode any
30+
Expression SyntaxNode
31+
)
3032

3133
type ExpressionList interface {
3234
Add(expression Expression)
@@ -744,12 +746,15 @@ func NewRangeQuantifier(value string) *RangeQuantifier {
744746
type KindMatcher struct {
745747
Reference Expression
746748
Kinds graph.Kinds
749+
// IsExclusive changes the kind matching operator from overlap (PG &&) to a stricter "left contains right" (PG @>)
750+
IsExclusive bool
747751
}
748752

749-
func NewKindMatcher(reference Expression, kinds graph.Kinds) *KindMatcher {
753+
func NewKindMatcher(reference Expression, kinds graph.Kinds, isExclusive bool) *KindMatcher {
750754
return &KindMatcher{
751-
Reference: reference,
752-
Kinds: kinds,
755+
Reference: reference,
756+
Kinds: kinds,
757+
IsExclusive: isExclusive,
753758
}
754759
}
755760

@@ -759,8 +764,9 @@ func (s *KindMatcher) copy() *KindMatcher {
759764
}
760765

761766
return &KindMatcher{
762-
Reference: Copy(s.Reference),
763-
Kinds: Copy(s.Kinds),
767+
Reference: Copy(s.Reference),
768+
Kinds: Copy(s.Kinds),
769+
IsExclusive: s.IsExclusive,
764770
}
765771
}
766772

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package test
2+
3+
import (
4+
"context"
5+
"slices"
6+
"testing"
7+
8+
"github.com/specterops/dawgs/cypher/models/cypher"
9+
"github.com/specterops/dawgs/cypher/models/pgsql"
10+
"github.com/specterops/dawgs/cypher/models/pgsql/translate"
11+
"github.com/specterops/dawgs/cypher/models/walk"
12+
"github.com/specterops/dawgs/query"
13+
)
14+
15+
func TestQuery_KindGeneratesInclusiveKindMatcher(t *testing.T) {
16+
mapper := newKindMapper()
17+
18+
queries := []*cypher.Where{
19+
query.Where(query.KindIn(query.Node(), NodeKind1)),
20+
query.Where(query.Kind(query.Node(), NodeKind2)),
21+
}
22+
23+
for _, nodeQuery := range queries {
24+
builder := query.NewBuilderWithCriteria(nodeQuery)
25+
builtQuery, err := builder.Build(false)
26+
if err != nil {
27+
t.Errorf("could not build query: %v", err)
28+
}
29+
30+
translatedQuery, err := translate.Translate(context.Background(), builtQuery, mapper, nil)
31+
if err != nil {
32+
t.Errorf("could not translate query: %#v: %v", builtQuery, err)
33+
}
34+
35+
walk.PgSQL(translatedQuery.Statement, walk.NewSimpleVisitor(func(node pgsql.SyntaxNode, visitorHandler walk.VisitorHandler) {
36+
switch typedNode := node.(type) {
37+
case *pgsql.BinaryExpression:
38+
switch leftTyped := typedNode.LOperand.(type) {
39+
case pgsql.CompoundIdentifier:
40+
if slices.Equal(leftTyped, pgsql.AsCompoundIdentifier("n0", "kind_ids")) && typedNode.Operator != pgsql.OperatorPGArrayOverlap {
41+
t.Errorf("query did not generate an array overlap operator (&&): %#v", nodeQuery)
42+
}
43+
}
44+
}
45+
}))
46+
}
47+
}

cypher/models/pgsql/translate/kind.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,30 @@ import (
99
"github.com/specterops/dawgs/cypher/models/pgsql/pgd"
1010
)
1111

12-
func newPGKindIDMatcher(scope *Scope, treeTranslator *ExpressionTreeTranslator, binding *BoundIdentifier, kindIDs []int16) error {
12+
func newPGKindIDMatcher(scope *Scope, treeTranslator *ExpressionTreeTranslator, binding *BoundIdentifier, kindIDs []int16, isExclusive bool) error {
1313
kindIDsLiteral := pgsql.NewLiteral(kindIDs, pgsql.Int2Array)
1414

1515
switch binding.DataType {
1616
case pgsql.NodeComposite, pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode:
1717
treeTranslator.PushOperand(pgd.Column(binding.Identifier, pgsql.ColumnKindIDs))
1818
treeTranslator.PushOperand(kindIDsLiteral)
1919

20-
return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayLHSContainsRHS)
20+
// In an exclusive kind match, if there are no kind IDs to be matched on,
21+
// the behavior of the contains (`@>`) operator will select all nodes, which drastically differs from
22+
// the overlap operator's behavior (matches nothing), so preserve the previous behavior.
23+
//
24+
// There shouldn't be a case in the Cypher frontend where a kind ID matcher is
25+
// created without any kind IDs to match on, but `query.Kind`/`query.KindIn` can create those
26+
// edge cases. We want any existing `query.Kind`/`query.KindIn` usages to match the previous behavior
27+
// expectations by using overlap/`&&`, which protects from the empty RHS problem.
28+
if isExclusive && len(kindIDs) > 0 {
29+
return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayLHSContainsRHS)
30+
} else {
31+
return treeTranslator.CompleteBinaryExpression(scope, pgsql.OperatorPGArrayOverlap)
32+
}
2133

2234
case pgsql.EdgeComposite, pgsql.ExpansionEdge:
35+
// Edge kind checking is a strict equality, so the IsExclusive condition does not apply here.
2336
treeTranslator.PushOperand(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnKindID})
2437
treeTranslator.PushOperand(pgsql.NewAnyExpressionHinted(kindIDsLiteral))
2538

@@ -39,6 +52,6 @@ func (s *Translator) translateKindMatcher(kindMatcher *cypher.KindMatcher) error
3952
} else if kindIDs, err := s.kindMapper.MapKinds(kindMatcher.Kinds); err != nil {
4053
return fmt.Errorf("failed to translate kinds: %w", err)
4154
} else {
42-
return newPGKindIDMatcher(s.scope, s.treeTranslator, binding, kindIDs)
55+
return newPGKindIDMatcher(s.scope, s.treeTranslator, binding, kindIDs, kindMatcher.IsExclusive)
4356
}
4457
}

drivers/pg/pgutil/kindmapper.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ import (
77
"github.com/specterops/dawgs/graph"
88
)
99

10-
var nextKindID = int16(1)
11-
1210
type InMemoryKindMapper struct {
13-
KindToID map[graph.Kind]int16
14-
IDToKind map[int16]graph.Kind
11+
nextKindID int16
12+
KindToID map[graph.Kind]int16
13+
IDToKind map[int16]graph.Kind
1514
}
1615

1716
func NewInMemoryKindMapper() *InMemoryKindMapper {
1817
return &InMemoryKindMapper{
19-
KindToID: map[graph.Kind]int16{},
20-
IDToKind: map[int16]graph.Kind{},
18+
nextKindID: int16(1),
19+
KindToID: map[graph.Kind]int16{},
20+
IDToKind: map[int16]graph.Kind{},
2121
}
2222
}
2323

@@ -67,6 +67,7 @@ func (s *InMemoryKindMapper) mapKinds(kinds graph.Kinds) ([]int16, graph.Kinds)
6767

6868
return ids, missing
6969
}
70+
7071
func (s *InMemoryKindMapper) MapKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) {
7172
if ids, missing := s.mapKinds(kinds); len(missing) > 0 {
7273
return nil, fmt.Errorf("missing kinds: %v", missing)
@@ -86,8 +87,12 @@ func (s *InMemoryKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds)
8687
}
8788

8889
func (s *InMemoryKindMapper) Put(kind graph.Kind) int16 {
89-
kindID := nextKindID
90-
nextKindID += 1
90+
if kindID, ok := s.KindToID[kind]; ok {
91+
return kindID
92+
}
93+
94+
kindID := s.nextKindID
95+
s.nextKindID += 1
9196

9297
s.KindToID[kind] = kindID
9398
s.IDToKind[kindID] = kind

query/model.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import (
1010
)
1111

1212
func convertCriteria[T any](criteria ...graph.Criteria) []T {
13-
var (
14-
converted = make([]T, len(criteria))
15-
)
13+
converted := make([]T, len(criteria))
1614

1715
for idx, nextCriteria := range criteria {
1816
converted[idx] = nextCriteria.(T)
@@ -67,8 +65,9 @@ func DeleteKind(reference graph.Criteria, kind graph.Kind) *cypherModel.Updating
6765
return cypherModel.NewUpdatingClause(&cypherModel.Remove{
6866
Items: []*cypherModel.RemoveItem{{
6967
KindMatcher: &cypherModel.KindMatcher{
70-
Reference: reference,
71-
Kinds: graph.Kinds{kind},
68+
Reference: reference,
69+
Kinds: graph.Kinds{kind},
70+
IsExclusive: false,
7271
},
7372
}},
7473
})
@@ -78,8 +77,9 @@ func DeleteKinds(reference graph.Criteria, kinds graph.Kinds) *cypherModel.Updat
7877
return cypherModel.NewUpdatingClause(&cypherModel.Remove{
7978
Items: []*cypherModel.RemoveItem{{
8079
KindMatcher: &cypherModel.KindMatcher{
81-
Reference: reference,
82-
Kinds: kinds,
80+
Reference: reference,
81+
Kinds: kinds,
82+
IsExclusive: false,
8383
},
8484
}},
8585
})
@@ -131,13 +131,14 @@ func DeleteProperties(reference graph.Criteria, propertyNames ...string) *cypher
131131

132132
func Kind(reference graph.Criteria, kinds ...graph.Kind) *cypherModel.KindMatcher {
133133
return &cypherModel.KindMatcher{
134-
Reference: reference,
135-
Kinds: kinds,
134+
Reference: reference,
135+
Kinds: kinds,
136+
IsExclusive: false,
136137
}
137138
}
138139

139140
func KindIn(reference graph.Criteria, kinds ...graph.Kind) *cypherModel.KindMatcher {
140-
return cypherModel.NewKindMatcher(reference, kinds)
141+
return cypherModel.NewKindMatcher(reference, kinds, false)
141142
}
142143

143144
func NodeProperty(name string) *cypherModel.PropertyLookup {

0 commit comments

Comments
 (0)