Skip to content
2 changes: 1 addition & 1 deletion cypher/frontend/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,5 +424,5 @@ func (s *NonArithmeticOperatorExpressionVisitor) EnterOC_PropertyKeyName(ctx *pa
}

func (s *NonArithmeticOperatorExpressionVisitor) ExitOC_PropertyKeyName(ctx *parser.OC_PropertyKeyNameContext) {
s.PropertyKeyName = s.ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name
s.PropertyKeyName = extractPropertyKeyName(s.ctx, ctx)
}
2 changes: 1 addition & 1 deletion cypher/frontend/literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func (s *MapLiteralVisitor) EnterOC_PropertyKeyName(ctx *parser.OC_PropertyKeyNa
}

func (s *MapLiteralVisitor) ExitOC_PropertyKeyName(ctx *parser.OC_PropertyKeyNameContext) {
s.nextPropertyKey = s.ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name
s.nextPropertyKey = cypher.UnescapePropertyKeyName(s.ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name)
}

func (s *MapLiteralVisitor) EnterOC_Expression(ctx *parser.OC_ExpressionContext) {
Expand Down
20 changes: 20 additions & 0 deletions cypher/frontend/property_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package frontend

import (
"github.com/specterops/dawgs/cypher/models/cypher"
"github.com/specterops/dawgs/cypher/parser"
)

func extractPropertyKeyName(ctx *Context, cypherCtx *parser.OC_PropertyKeyNameContext) string {
name := cypher.UnescapePropertyKeyName(ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name)
if err := cypher.ValidatePropertyKeyName(name); err != nil {
ctx.AddErrors(SyntaxError{
Line: cypherCtx.GetStart().GetLine(),
Column: cypherCtx.GetStart().GetColumn(),
OffendingSymbol: cypherCtx.GetText(),
Message: err.Error(),
})
}

return name
}
103 changes: 103 additions & 0 deletions cypher/frontend/property_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package frontend_test

import (
"testing"

"github.com/specterops/dawgs/cypher/frontend"
"github.com/specterops/dawgs/cypher/models/cypher"
"github.com/specterops/dawgs/cypher/models/walk"
"github.com/stretchr/testify/require"
)

func TestParsePropertyLookupStoresRawPropertyKeyNames(t *testing.T) {
regularQuery, err := frontend.ParseCypher(frontend.NewContext(), "RETURN n.match, n.`a-aaa`, n.`has``tick`, n.` `")
require.NoError(t, err)

var symbols []string
err = walk.CypherStructural(regularQuery, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) {
if propertyLookup, typeOK := node.(*cypher.PropertyLookup); typeOK {
symbols = append(symbols, propertyLookup.Symbol)
}
}))
require.NoError(t, err)

require.Equal(t, []string{"match", "a-aaa", "has`tick", " "}, symbols)
}

func TestParsePropertyLookupStoresQuotePropertyKeyNames(t *testing.T) {
regularQuery, err := frontend.ParseCypher(frontend.NewContext(), "RETURN n.`'`, n.`\"`")
require.NoError(t, err)

var symbols []string
err = walk.CypherStructural(regularQuery, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) {
if propertyLookup, typeOK := node.(*cypher.PropertyLookup); typeOK {
symbols = append(symbols, propertyLookup.Symbol)
}
}))
require.NoError(t, err)

require.Equal(t, []string{"'", "\""}, symbols)
}

func TestParsePropertyLookupStoresUnicodePropertyKeyNames(t *testing.T) {
regularQuery, err := frontend.ParseCypher(frontend.NewContext(), "RETURN n.\u2118, n.a\u00b7, n.a\u0301, n.a\u093e, n.a$, n.`a\u20dd`")
require.NoError(t, err)

var symbols []string
err = walk.CypherStructural(regularQuery, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) {
if propertyLookup, typeOK := node.(*cypher.PropertyLookup); typeOK {
symbols = append(symbols, propertyLookup.Symbol)
}
}))
require.NoError(t, err)

require.Equal(t, []string{"\u2118", "a\u00b7", "a\u0301", "a\u093e", "a$", "a\u20dd"}, symbols)
}

func TestParseMapLiteralStoresRawPropertyKeyNames(t *testing.T) {
regularQuery, err := frontend.ParseCypher(frontend.NewContext(), "RETURN {match: 1, `a-aaa`: 2, `has``tick`: 3, ``: 4, ` `: 5}")
require.NoError(t, err)

var keys []string
err = walk.CypherStructural(regularQuery, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) {
if mapItem, typeOK := node.(*cypher.MapItem); typeOK {
keys = append(keys, mapItem.Key)
}
}))
require.NoError(t, err)

require.ElementsMatch(t, []string{"match", "a-aaa", "has`tick", "", " "}, keys)
}

func TestParseMapLiteralStoresQuotePropertyKeyNames(t *testing.T) {
regularQuery, err := frontend.ParseCypher(frontend.NewContext(), "RETURN {`'`: 1, `\"`: 2}")
require.NoError(t, err)

var keys []string
err = walk.CypherStructural(regularQuery, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) {
if mapItem, typeOK := node.(*cypher.MapItem); typeOK {
keys = append(keys, mapItem.Key)
}
}))
require.NoError(t, err)

require.ElementsMatch(t, []string{"'", "\""}, keys)
}

func TestParseRejectsEmptyPropertyKeyNames(t *testing.T) {
testCases := []struct {
name string
query string
}{
{name: "property lookup", query: "RETURN n.``"},
{name: "set property", query: "MATCH (n) SET n.`` = 'value'"},
{name: "remove property", query: "MATCH (n) REMOVE n.``"},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, err := frontend.ParseCypher(frontend.NewContext(), testCase.query)
require.ErrorContains(t, err, cypher.ErrEmptyPropertyKeyName.Error())
})
}
}
2 changes: 1 addition & 1 deletion cypher/frontend/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,5 +707,5 @@ func (s *PropertyExpressionVisitor) EnterOC_PropertyKeyName(ctx *parser.OC_Prope
}

func (s *PropertyExpressionVisitor) ExitOC_PropertyKeyName(ctx *parser.OC_PropertyKeyNameContext) {
s.PropertyLookup.SetSymbol(s.ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name)
s.PropertyLookup.SetSymbol(extractPropertyKeyName(s.ctx, ctx))
}
8 changes: 6 additions & 2 deletions cypher/models/cypher/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ func (s Emitter) formatMapLiteral(output io.Writer, mapLiteral cypher.MapLiteral
first = false
}

if _, err := io.WriteString(output, key); err != nil {
if _, err := io.WriteString(output, cypher.EscapePropertyKeyName(key)); err != nil {
return err
}

Expand Down Expand Up @@ -633,7 +633,11 @@ func (s Emitter) WriteExpression(output io.Writer, expression cypher.Expression)
return err
}

if _, err := io.WriteString(output, typedExpression.Symbol); err != nil {
if err := cypher.ValidatePropertyKeyName(typedExpression.Symbol); err != nil {
return err
}

if _, err := io.WriteString(output, cypher.EscapePropertyKeyName(typedExpression.Symbol)); err != nil {
return err
}

Expand Down
85 changes: 85 additions & 0 deletions cypher/models/cypher/format/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,91 @@ func TestCypherEmitter_FormatsMapLiteralInKeyOrder(t *testing.T) {
require.Equal(t, "{a: 1, b: 2}", buffer.String())
}

func TestCypherEmitter_FormatsMapLiteralPropertyKeys(t *testing.T) {
var (
buffer = &bytes.Buffer{}
emitter = format.NewCypherEmitter(false)
)

err := emitter.WriteExpression(buffer, cypher.MapLiteral{
"match": cypher.NewLiteral(1, false),
"a-aaa": cypher.NewLiteral(2, false),
"has`tick": cypher.NewLiteral(3, false),
"": cypher.NewLiteral(4, false),
" ": cypher.NewLiteral(5, false),
"'": cypher.NewLiteral(6, false),
})

require.NoError(t, err)
require.Equal(t, "{``: 4, ` `: 5, `'`: 6, `a-aaa`: 2, `has``tick`: 3, match: 1}", buffer.String())
}

func TestCypherEmitter_FormatsPropertyLookupKeys(t *testing.T) {
testCases := []struct {
name string
symbol string
expected string
}{
{
name: "simple key",
symbol: "name",
expected: "n.name",
},
{
name: "reserved word key",
symbol: "match",
expected: "n.match",
},
{
name: "key with hyphen",
symbol: "a-aaa",
expected: "n.`a-aaa`",
},
{
name: "key with backtick",
symbol: "has`tick",
expected: "n.`has``tick`",
},
{
name: "key with single quote",
symbol: "'",
expected: "n.`'`",
},
{
name: "whitespace-only key",
symbol: " ",
expected: "n.` `",
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
buffer := &bytes.Buffer{}
emitter := format.NewCypherEmitter(false)

err := emitter.WriteExpression(buffer, &cypher.PropertyLookup{
Atom: cypher.NewVariableWithSymbol("n"),
Symbol: testCase.symbol,
})

require.NoError(t, err)
require.Equal(t, testCase.expected, buffer.String())
})
}
}

func TestCypherEmitter_RejectsEmptyPropertyLookupKey(t *testing.T) {
buffer := &bytes.Buffer{}
emitter := format.NewCypherEmitter(false)

err := emitter.WriteExpression(buffer, &cypher.PropertyLookup{
Atom: cypher.NewVariableWithSymbol("n"),
Symbol: "",
})

require.ErrorIs(t, err, cypher.ErrEmptyPropertyKeyName)
}

func TestCypherEmitter_MapLiteralPropagatesExpressionError(t *testing.T) {
var (
buffer = &bytes.Buffer{}
Expand Down
5 changes: 4 additions & 1 deletion cypher/models/cypher/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,10 @@ func (s *ProjectionItem) copy() *ProjectionItem {
}

type PropertyLookup struct {
Atom Expression
Atom Expression

// Symbol is the raw property key, not an already-rendered Cypher token.
// Callers should not pre-wrap names in backticks; formatting handles that.
Symbol string
}

Expand Down
80 changes: 80 additions & 0 deletions cypher/models/cypher/property_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cypher

import (
"errors"
"strings"
"unicode"
)

var ErrEmptyPropertyKeyName = errors.New("property key name must not be empty")

func isCypherIDStart(char rune) bool {
return unicode.IsLetter(char) || unicode.In(char, unicode.Nl, unicode.Other_ID_Start)
}

func isCypherIDContinue(char rune) bool {
return isCypherIDStart(char) || unicode.In(char, unicode.Mn, unicode.Mc, unicode.Nd, unicode.Pc, unicode.Other_ID_Continue)
}

func isCypherSymbolStart(char rune) bool {
return isCypherIDStart(char) || unicode.In(char, unicode.Pc)
}

func isCypherSymbolPart(char rune) bool {
return isCypherIDContinue(char) || unicode.In(char, unicode.Sc)
}

// CanEmitBarePropertyKeyName returns true when a raw property key can be emitted without backticks.
//
// This is specific to Cypher property-key position, such as n.name and {name: value}. Property keys use
// oC_PropertyKeyName -> oC_SchemaName, where reserved words are valid bare names, unlike variable or parameter
// symbols. Empty keys and keys containing characters outside the unescaped symbolic-name grammar return false; non-empty
// keys outside the bare grammar are still representable by EscapePropertyKeyName using backticks.
func CanEmitBarePropertyKeyName(name string) bool {
if name == "" {
return false
}

for idx, char := range name {
if idx == 0 {
if !isCypherSymbolStart(char) {
return false
}
} else if !isCypherSymbolPart(char) {
return false
}
}

return true
}

func ValidatePropertyKeyName(name string) error {
if name == "" {
return ErrEmptyPropertyKeyName
}

return nil
}

// EscapePropertyKeyName formats a raw property key as a Cypher property-key token.
func EscapePropertyKeyName(name string) string {
if CanEmitBarePropertyKeyName(name) {
return name
}

return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}

// IsEscapedPropertyKeyName returns true when name is wrapped in Cypher backtick delimiters.
func IsEscapedPropertyKeyName(name string) bool {
return len(name) >= 2 && name[0] == '`' && name[len(name)-1] == '`'
}

// UnescapePropertyKeyName decodes a Cypher property-key token into the raw property key it names.
func UnescapePropertyKeyName(name string) string {
if !IsEscapedPropertyKeyName(name) {
return name
}

return strings.ReplaceAll(name[1:len(name)-1], "``", "`")
}
Loading
Loading