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/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 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 {