Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ adhere to [Semantic Versioning](https://semver.org) starting `v22.0.0`.
- **Fixed**
- fix(core): fix panic in verifyUniqueWithinMutation when mutation is conditionally pruned (#9450)
- fix(query): return full float value in query results (#9492)
- **Vector**
- fix(vector/hnsw): correct early termination in bottom-layer search to ensure at least k
candidates are considered before breaking
- feat(vector/hnsw): add optional per-query controls to similar_to via a 4th argument: `ef`
(search breadth override) and `distance_threshold` (metric-domain cutoff); defaults unchanged

## [v24.X.X] - YYYY-MM-DD

Expand Down Expand Up @@ -100,7 +105,7 @@ adhere to [Semantic Versioning](https://semver.org) starting `v22.0.0`.
- **Perf**
- perf(query): Read just the latest value for scalar types
https://github.com/hypermodeinc/dgraph/pull/8966
- perf(vector): Add heap to neighbour edges https://github.com/hypermodeinc/dgraph/pull/9122
- perf(vector): Add heap to neighbor edges https://github.com/hypermodeinc/dgraph/pull/9122

## [v24.0.1] - 2024-07-30

Expand Down Expand Up @@ -4798,8 +4803,8 @@ Users can set `port_offset` flag, to modify these fixed ports.
- `Query` Grpc endpoint returns response in JSON under `Json` field instead of protocol buffer.
`client.Unmarshal` method also goes away from the Go client. Users can use `json.Unmarshal` for
unmarshalling the response.
- Response for predicate of type `geo` can be unmarshalled into a struct. Example
[here](https://godoc.org/github.com/hypermodeinc/dggraph/client#example-package--SetObject).
- Response for predicate of type `geo` can be unmarshalled into a struct. See the
[SetObject example](https://godoc.org/github.com/hypermodeinc/dggraph/client#example-package--SetObject).
- `Node` and `Edge` structs go away along with the `SetValue...` methods. We recommend using
[`SetJson`](https://godoc.org/github.com/hypermodeinc/dggraph/client#example-package--SetObject)
and `DeleteJson` fields to do mutations.
Expand Down
57 changes: 56 additions & 1 deletion dql/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -1874,7 +1874,10 @@ L:
case IsInequalityFn(function.Name):
err = parseFuncArgs(it, function)

case function.Name == "uid_in" || function.Name == "similar_to":
case function.Name == "uid_in":
err = parseFuncArgs(it, function)

case function.Name == "similar_to":
err = parseFuncArgs(it, function)

default:
Expand All @@ -1892,6 +1895,25 @@ L:
}
expectArg = false
continue
case itemLeftCurl:
// Guard: Only similar_to may use object-literal syntax in its 4th argument.
// By checking Name=="similar_to", Attr is set (predicate) and Args has
// exactly two elements (k and vector), we ensure the '{' is in position 4.
// All other functions receive the historical error for stray braces.
if function.Name != "similar_to" || function.Attr == "" || len(function.Args) != 2 {
return nil, itemInFunc.Errorf("Unrecognized character inside a func: U+007B '{'")
}
// Parse the object literal: {ef: 64, distance_threshold: 0.45}
// The helper consumes tokens until the matching '}' is found.
if err := parseSimilarToObjectArg(it, function, itemInFunc); err != nil {
return nil, err
}
expectArg = false
continue
case itemRightCurl:
// Right curly braces are never valid in function arguments outside of
// the object literal parsed above. Always error on stray '}'.
return nil, itemInFunc.Errorf("Unrecognized character inside a func: U+007D '}'")
default:
if itemInFunc.Typ != itemName {
return nil, itemInFunc.Errorf("Expected arg after func [%s], but got item %v",
Expand Down Expand Up @@ -2408,6 +2430,10 @@ loop:
// The parentheses are balanced out. Let's break.
break loop
}
case item.Typ == itemLeftCurl:
return nil, item.Errorf("Unrecognized character inside a func: U+007B '{'")
case item.Typ == itemRightCurl:
return nil, item.Errorf("Unrecognized character inside a func: U+007D '}'")
default:
return nil, item.Errorf("Unexpected item while parsing @filter: %v", item)
}
Expand Down Expand Up @@ -3471,3 +3497,32 @@ func trySkipItemTyp(it *lex.ItemIterator, typ lex.ItemType) bool {
it.Next()
return true
}

func parseSimilarToObjectArg(it *lex.ItemIterator, function *Function, start lex.Item) error {
depth := 1
var builder strings.Builder
builder.WriteString(start.Val)

for depth > 0 {
if !it.Next() {
return start.Errorf("Unexpected end of object literal while parsing similar_to options")
}

item := it.Item()
builder.WriteString(item.Val)

switch item.Typ {
case itemLeftCurl:
depth++
case itemRightCurl:
depth--
case itemRightRound:
if depth > 0 {
return item.Errorf("Expected '}' before ')' in similar_to options")
}
}
}

function.Args = append(function.Args, Arg{Value: builder.String()})
return nil
}
90 changes: 84 additions & 6 deletions dql/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2518,6 +2518,12 @@ func TestParseFilter_brac(t *testing.T) {
}

// Test if unbalanced brac will lead to errors.
// Note: This query has two errors: missing ')' after '()' AND a stray '{'.
// After changes to support similar_to's JSON args the lexer now emits brace tokens
// instead of erroring immediately. This causes the query to fail on the structural
// error (unclosed brackets) rather than the character-specific error. This is an
// acceptable trade-off because queries with multiple syntax errors may report a different
// (but equally fatal) error first.
func TestParseFilter_unbalancedbrac(t *testing.T) {
query := `
query {
Expand All @@ -2532,8 +2538,76 @@ func TestParseFilter_unbalancedbrac(t *testing.T) {
`
_, err := Parse(Request{Str: query})
require.Error(t, err)
require.Contains(t, err.Error(),
"Unrecognized character inside a func: U+007B '{'")
require.Contains(t, err.Error(), "Unclosed Brackets")
}

func TestParseSimilarToObjectLiteral(t *testing.T) {
query := `{
q(func: similar_to(voptions, 4, "[0,0]", {distance_threshold: 1.5, ef: 12})) {
uid
}
}`
res, err := Parse(Request{Str: query})
require.NoError(t, err)
require.Len(t, res.Query, 1)
require.NotNil(t, res.Query[0])
require.NotNil(t, res.Query[0].Func)
require.Equal(t, "similar_to", res.Query[0].Func.Name)
require.Len(t, res.Query[0].Func.Args, 3)
require.Equal(t, "voptions", res.Query[0].Func.Attr)
require.Equal(t, "4", res.Query[0].Func.Args[0].Value)
require.Equal(t, "[0,0]", res.Query[0].Func.Args[1].Value)
require.Equal(t, "{distance_threshold:1.5,ef:12}", res.Query[0].Func.Args[2].Value)
}

func TestParseSimilarToStringOptions(t *testing.T) {
// Test string-based options format (backwards compatibility)
query := `{
q(func: similar_to(voptions, 4, "[0,0]", "ef=64,distance_threshold=0.45")) {
uid
}
}`
res, err := Parse(Request{Str: query})
require.NoError(t, err)
require.Equal(t, "similar_to", res.Query[0].Func.Name)
require.Equal(t, "ef=64,distance_threshold=0.45", res.Query[0].Func.Args[2].Value)
}

func TestParseSimilarToThreeArgs(t *testing.T) {
// Test three-arg form (no options)
query := `{
q(func: similar_to(voptions, 4, "[0,0]")) {
uid
}
}`
res, err := Parse(Request{Str: query})
require.NoError(t, err)
require.Equal(t, "similar_to", res.Query[0].Func.Name)
require.Len(t, res.Query[0].Func.Args, 2)
}

func TestParseSimilarToBraceInWrongPosition(t *testing.T) {
// Brace as third argument should be rejected
query := `{
q(func: similar_to(voptions, 4, {ef: 12})) {
uid
}
}`
_, err := Parse(Request{Str: query})
require.Error(t, err)
require.Contains(t, err.Error(), "Unrecognized character inside a func: U+007B '{'")
}

func TestParseNonSimilarToWithBrace(t *testing.T) {
// Braces in non-similar_to functions should be rejected
query := `{
q(func: eq(name, {value: "test"})) {
uid
}
}`
_, err := Parse(Request{Str: query})
require.Error(t, err)
require.Contains(t, err.Error(), "Unrecognized character inside a func: U+007B '{'")
}

func TestParseFilter_Geo1(t *testing.T) {
Expand Down Expand Up @@ -2768,6 +2842,10 @@ func TestParseCountAsFunc(t *testing.T) {

}

// Note: This query has two errors: missing ')' after 'friends' AND a stray '}'.
// After changes to support similar_to's JSON args the lexer emits brace tokens instead
// of erroring immediately -- causing this to fail on unclosed brackets rather than the
// specific character error. See TestParseFilter_unbalancedbrac for full explanation.
func TestParseCountError1(t *testing.T) {
query := `{
me(func: uid(1)) {
Expand All @@ -2779,10 +2857,11 @@ func TestParseCountError1(t *testing.T) {
`
_, err := Parse(Request{Str: query})
require.Error(t, err)
require.Contains(t, err.Error(),
"Unrecognized character inside a func: U+007D '}'")
require.Contains(t, err.Error(), "Unclosed Brackets")
}

// Note: Similar to TestParseCountError1, this has missing ')' and stray '}',
// now reports structural error instead of character-specific error.
func TestParseCountError2(t *testing.T) {
query := `{
me(func: uid(1)) {
Expand All @@ -2794,8 +2873,7 @@ func TestParseCountError2(t *testing.T) {
`
_, err := Parse(Request{Str: query})
require.Error(t, err)
require.Contains(t, err.Error(),
"Unrecognized character inside a func: U+007D '}'")
require.Contains(t, err.Error(), "Unclosed Brackets")
}

func TestParseCheckPwd(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions dql/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,19 @@ func lexFuncOrArg(l *lex.Lexer) lex.StateFn {
l.Emit(itemLeftSquare)
case r == rightSquare:
l.Emit(itemRightSquare)
case r == leftCurl:
empty = false
l.Emit(itemLeftCurl)
// Design decision: Emit brace tokens without affecting ArgDepth tracking.
// This allows similar_to's JSON-style options ({ef: 64, distance_threshold: 0.45})
// to be parsed. The parser validates whether braces are legal in context.
// Trade-off: Queries with multiple syntax errors (e.g., missing ')' AND stray '}')
// will report structural errors (Unclosed Brackets) rather than character-specific
// errors. This is acceptable as the query is still rejected with a clear error.
case r == rightCurl:
l.Emit(itemRightCurl)
// Don't decrement ArgDepth for braces; let parser validate context.
// See leftCurl case above for full rationale.
case r == '#':
return lexComment
case r == '.':
Expand Down
68 changes: 68 additions & 0 deletions query/vector/vector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,74 @@ func TestVectorIndexRebuildWhenChange(t *testing.T) {
require.Greater(t, dur, time.Second*4)
}

func TestSimilarToOptionsIntegration(t *testing.T) {
const pred = "voptions"
dropPredicate(pred)
t.Cleanup(func() { dropPredicate(pred) })

setSchema(fmt.Sprintf(vectorSchemaWithIndex, pred, "4", "euclidean"))

rdf := `<0x1> <voptions> "[0,0]" .
<0x2> <voptions> "[1,0]" .
<0x3> <voptions> "[2,0]" .
<0x4> <voptions> "[5,0]" .`
require.NoError(t, addTriplesToCluster(rdf))

t.Run("ef_override_string_syntax", func(t *testing.T) {
query := `{
results(func: similar_to(voptions, 3, "[0,0]", "ef=2")) {
uid
}
}`
resp := processQueryNoErr(t, query)

var result struct {
Data struct {
Results []struct {
UID string `json:"uid"`
} `json:"results"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(resp), &result))
require.Len(t, result.Data.Results, 3)

expected := map[string]struct{}{"0x1": {}, "0x2": {}, "0x3": {}}
for _, r := range result.Data.Results {
_, ok := expected[r.UID]
require.Truef(t, ok, "unexpected uid %s", r.UID)
delete(expected, r.UID)
}
require.Empty(t, expected)
})

t.Run("distance_threshold_json_syntax", func(t *testing.T) {
query := `{
results(func: similar_to(voptions, 4, "[0,0]", {distance_threshold: 1.5})) {
uid
}
}`
resp := processQueryNoErr(t, query)

var result struct {
Data struct {
Results []struct {
UID string `json:"uid"`
} `json:"results"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(resp), &result))
require.Len(t, result.Data.Results, 2)

expected := map[string]struct{}{"0x1": {}, "0x2": {}}
for _, r := range result.Data.Results {
_, ok := expected[r.UID]
require.Truef(t, ok, "unexpected uid %s", r.UID)
delete(expected, r.UID)
}
require.Empty(t, expected)
})
}

func TestVectorInQueryArgument(t *testing.T) {
dropPredicate("vtest")
setSchema(fmt.Sprintf(vectorSchemaWithIndex, "vtest", "4", "euclidean"))
Expand Down
Loading
Loading