190 changes: 154 additions & 36 deletions graphql/resolve/query_rewriter.go

Large diffs are not rendered by default.

140 changes: 139 additions & 1 deletion graphql/resolve/query_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3517,4 +3517,142 @@
dgraph.uid : uid
ProjectDotProduct.vector_distance : val(distance)
}
}
}
- name: "query nested type with interface"
gqlquery: |
query {
queryNested_X(filter: {s: {eq: ""}, y: {s: {eq: ""}}}) {
s
}
}
dgquery: |-
query {
queryNested_X(func: type(Nested_X)) @filter((eq(Nested_X.s, "") AND (uid(queryNested_X_y)))) {
Nested_X.s : Nested_X.s
dgraph.uid : uid
}
var(func: type(Nested_Y)) @filter(eq(Nested_Y.s, "")) {
queryNested_X_y as Nested_Z.x
}
}
- name: "query nested type from type with interface"
gqlquery: |
query {
queryNested_Y(filter: {s: {eq: ""}, x: {s: {eq: ""}}}) {
s
}
}
dgquery: |-
query {
queryNested_Y(func: type(Nested_Y)) @filter((eq(Nested_Y.s, "") AND (uid(queryNested_Y_x)))) {
Nested_Y.s : Nested_Y.s
dgraph.uid : uid
}
var(func: type(Nested_X)) @filter(eq(Nested_X.s, "")) {
queryNested_Y_x as Nested_X.y
}
}
- name: "query nested type from interface"
gqlquery: |
query {
queryNested_Z(filter: { x: {s: {eq: ""}}}) {
x { s }
}
}
dgquery: |-
query {
queryNested_Z(func: type(Nested_Z)) @filter((uid(queryNested_Z_x))) {
dgraph.type
Nested_Z.x : Nested_Z.x {
Nested_X.s : Nested_X.s
dgraph.uid : uid
}
dgraph.uid : uid
}
var(func: type(Nested_X)) @filter(eq(Nested_X.s, "")) {
queryNested_Z_x as Nested_X.y
}
}
- name: "query deeply nested object"
gqlquery: |
query {
queryNested_Z(filter: { x: { y: {s: {eq: ""}}}}) {
x { s }
}
}
dgquery: |-
query {
queryNested_Z(func: type(Nested_Z)) @filter((uid(queryNested_Z_x))) {
dgraph.type
Nested_Z.x : Nested_Z.x {
Nested_X.s : Nested_X.s
dgraph.uid : uid
}
dgraph.uid : uid
}
var(func: type(Nested_Y)) @filter(eq(Nested_Y.s, "")) {
queryNested_Z_x_y as Nested_Z.x
}
var(func: type(Nested_X)) @filter((uid(queryNested_Z_x_y))) {
queryNested_Z_x as Nested_X.y
}
}
- name: "query nested with AND condition"
gqlquery: |
query {
queryNested_X(filter: {s: {eq: ""}, and: { y: {s: {eq: ""}}}}) {
s
}
}
dgquery: |-
query {
queryNested_X(func: type(Nested_X)) @filter(((uid(queryNested_X_and_y)) AND eq(Nested_X.s, ""))) {
Nested_X.s : Nested_X.s
dgraph.uid : uid
}
var(func: type(Nested_Y)) @filter(eq(Nested_Y.s, "")) {
queryNested_X_and_y as Nested_Z.x
}
}
- name: "query nested with OR condition"
gqlquery: |
query {
queryNested_X(filter: {s: {eq: ""}, or: { y: {s: {eq: ""}}}}) {
s
}
}
dgquery: |-
query {
queryNested_X(func: type(Nested_X)) @filter((eq(Nested_X.s, "") OR ((uid(queryNested_X_or_y))))) {
Nested_X.s : Nested_X.s
dgraph.uid : uid
}
var(func: type(Nested_Y)) @filter(eq(Nested_Y.s, "")) {
queryNested_X_or_y as Nested_Z.x
}
}
- name: "query nested with aggregate function"
gqlquery: |
query {
aggregateNested_X(filter: {s: {eq: ""}, or: { y: {s: {eq: ""}}}}) {
sMax
}
}
dgquery: |-
query {
aggregateNested_X() {
Nested_XAggregateResult.sMax : max(val(sVar))
}
var(func: type(Nested_X)) @filter((eq(Nested_X.s, "") OR ((uid(aggregateNested_X_or_y))))) {
sVar as Nested_X.s
}
var(func: type(Nested_Y)) @filter(eq(Nested_Y.s, "")) {
aggregateNested_X_or_y as Nested_Z.x
}
}
16 changes: 16 additions & 0 deletions graphql/resolve/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,19 @@ type ProjectDotProduct {
title: String
description_v: [Float!] @embedding @search(by: ["hnsw(metric: dotproduct, exponent: 4)"])
}

"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required here? This makes it such that generated schema would contain this. From what I understand, you only need this in the tests?

Copy link
Author

@iyinoluwaayoola iyinoluwaayoola Jan 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, only required for the test cases. Not sure if you require this to be removed, in which case the tests will fail.

This types are used to validate nested filting.
"""
type Nested_X {
s: String @search(by: [hash])
y: Nested_Y @hasInverse(field: x) @search
}

type Nested_Y implements Nested_Z{
s: String @search(by: [hash])
}

interface Nested_Z {
x: Nested_X @search
}
7 changes: 7 additions & 0 deletions graphql/schema/gqlschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,13 @@ func addPaginationArguments(fld *ast.FieldDefinition) {

// getFilterTypes converts search arguments of a field to graphql filter types.
func getFilterTypes(schema *ast.Schema, fld *ast.FieldDefinition, filterName string) []string {

// Return the object filter if the field is an object that is searchable.
fldType := schema.Types[fld.Type.Name()]
if isCustomType(schema, fld.Type) && hasFilterable(fldType) && hasSearchDirective(fld) {
return []string{fld.Type.Name() + "Filter"}
}

searchArgs := getSearchArgs(fld)
filterNames := make([]string, len(searchArgs))

Expand Down
52 changes: 49 additions & 3 deletions graphql/schema/gqlschema_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ invalid_schemas:
]

-
name: "Search will error on type that can't have the @search"
name: "Search will error on type that require @hasInverse directive"
input: |
type X {
y: Y @search
Expand All @@ -413,8 +413,22 @@ invalid_schemas:
y: String
}
errlist: [
{"message": "Type X; Field y: has the @search directive but fields of type Y
can't have the @search directive.",
{"message": "Type X; Field y: has the @search directive for type Y but also requires
the @hasInverse directive.",
"locations":[{"line":2, "column":9}]}
]
-
name: "Search will error on interface that require @hasInverse directive"
input: |
type X {
y: Y @search
}
interface Y {
y: String
}
errlist: [
{"message": "Type X; Field y: has the @search directive for type Y but also requires
the @hasInverse directive.",
"locations":[{"line":2, "column":9}]}
]

Expand Down Expand Up @@ -3270,6 +3284,38 @@ valid_schemas:
A
}
-
name: "Correct search on object type"
input: |
type X {
y: Y @hasInverse(field: x) @search
y2: Y @search
y3: Y @hasInverse(field: x3) @search
}
type Y {
x: X
x2: X @hasInverse(field: y2)
x3: X @hasInverse(field: y3) @search
}
-
name: "Correct search on interface type"
input: |
type X {
y: Y @hasInverse(field: x) @search
y2: Y @search
y3: Y @hasInverse(field: x3) @search
y4: Y
y5: Y @hasInverse(field: x5)
}
interface Y {
x: X
x2: X @hasInverse(field: y2)
x3: X @hasInverse(field: y3) @search
x4: X @hasInverse(field: y4) @search
x5: X @search
}
-
name: "dgraph directive with correct reverse field works"
input: |
Expand Down
15 changes: 15 additions & 0 deletions graphql/schema/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,21 @@ func searchValidation(
return nil
}

// If the field is an object, it is require to have an inverse edge for filtering.
// It's not enough to just check for the @hasInverse directive as it
// may be defined in the inverse type.
if isCustomType(sch, field.Type) {
if !hasInverse(sch, typ, field) {
errs = append(errs, gqlerror.ErrorPosf(
dir.Position,
"Type %s; Field %s: has the @search directive for type %s "+
"but also requires the @hasInverse directive.",
typ.Name, field.Name, field.Type.Name()))
return errs
}
return nil
}

errs = append(errs, gqlerror.ErrorPosf(
dir.Position,
"Type %s; Field %s: has the @search directive but fields of type %s "+
Expand Down
126 changes: 110 additions & 16 deletions graphql/schema/wrappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ type FieldDefinition interface {
IsID() bool
IsExternal() bool
HasIDDirective() bool
HasSearchDirective() bool
HasEmbeddingDirective() bool
EmbeddingSearchMetric() string
HasInterfaceArg() bool
Expand Down Expand Up @@ -2405,6 +2406,18 @@ func hasEmbeddingDirective(fd *ast.FieldDefinition) bool {
return id != nil
}

func (fd *fieldDefinition) HasSearchDirective() bool {
if fd.fieldDef == nil {
return false
}
return hasSearchDirective(fd.fieldDef)
}

func hasSearchDirective(fd *ast.FieldDefinition) bool {
id := fd.Directives.ForName(searchDirective)
return id != nil
}

func (fd *fieldDefinition) HasInterfaceArg() bool {
if fd.fieldDef == nil {
return false
Expand All @@ -2429,6 +2442,47 @@ func hasInterfaceArg(fd *ast.FieldDefinition) bool {
return false
}

// hasInverse checks if an inverse predicate is configured for an object.
func hasInverse(sch *ast.Schema, typ *ast.Definition, fd *ast.FieldDefinition) bool {
// check that the @hasInverse directive is provided
id := fd.Directives.ForName(inverseDirective)
if id != nil {
return true
}

// also check the reference type.
refType := sch.Types[fd.Type.Name()]
for _, refField := range refType.Fields {

refFieldType := sch.Types[refField.Type.Name()]
if refField.Type.Name() != typ.Name && !typ.OneOf(refFieldType.Interfaces...) {
continue
}

refFieldDir := refField.Directives.ForName(inverseDirective)
if refFieldDir == nil {
continue
}

invField := refFieldDir.Arguments.ForName("field")
if invField == nil {
continue
}

invFieldName := invField.Value.Raw
if invFieldName == fd.Name {
return true
}
}
return false
}

func isCustomType(sch *ast.Schema, t *ast.Type) bool {
_, ok := inbuiltTypeToDgraph[t.Name()]
return !ok && (sch.Types[t.Name()].Kind == ast.Object ||
sch.Types[t.Name()].Kind == ast.Interface)
}

func isID(fd *ast.FieldDefinition) bool {
return fd.Type.Name() == "ID"
}
Expand All @@ -2451,29 +2505,69 @@ func (fd *fieldDefinition) ParentType() Type {

func (fd *fieldDefinition) Inverse() FieldDefinition {

invDirective := fd.fieldDef.Directives.ForName(inverseDirective)
if invDirective == nil {
if fd.fieldDef == nil {
return nil
}

invFieldArg := invDirective.Arguments.ForName(inverseArg)
if invFieldArg == nil {
return nil // really not possible
}
invDirective := fd.fieldDef.Directives.ForName(inverseDirective)
if invDirective != nil {

typeWrapper := fd.Type()
// typ must exist if the schema passed GQL validation
typ := fd.inSchema.schema.Types[typeWrapper.Name()]
invFieldArg := invDirective.Arguments.ForName(inverseArg)
if invFieldArg == nil {
return nil // really not possible
}

// fld must exist if the schema passed our validation
fld := typ.Fields.ForName(invFieldArg.Value.Raw)
typeWrapper := fd.Type()
// typ must exist if the schema passed GQL validation
typ := fd.inSchema.schema.Types[typeWrapper.Name()]

return &fieldDefinition{
fieldDef: fld,
inSchema: fd.inSchema,
dgraphPredicate: fd.dgraphPredicate,
parentType: typeWrapper,
// fld must exist if the schema passed our validation
fld := typ.Fields.ForName(invFieldArg.Value.Raw)

return &fieldDefinition{
fieldDef: fld,
inSchema: fd.inSchema,
dgraphPredicate: fd.dgraphPredicate,
parentType: typeWrapper,
}
} else {
// also check the inverse type especially when querying from an interface
// and not the implemented type. In this case the interface won't have the
// inverse field.
typeWrapper := fd.Type()
// typ must exist if the schema passed GQL validation
typ := fd.inSchema.schema.Types[typeWrapper.Name()]

for _, refField := range typ.Fields {

refFieldType := fd.inSchema.schema.Types[refField.Type.Name()]
if refField.Type.Name() != typ.Name &&
!fd.inSchema.schema.Types[fd.ParentType().Name()].OneOf(refFieldType.Interfaces...) {
continue
}

refFieldDir := refField.Directives.ForName(inverseDirective)
if refFieldDir == nil {
continue
}

invField := refFieldDir.Arguments.ForName("field")
if invField == nil {
continue
}

invFieldName := invField.Value.Raw
if invFieldName == fd.Name() {
return &fieldDefinition{
fieldDef: refField,
inSchema: fd.inSchema,
dgraphPredicate: fd.dgraphPredicate,
parentType: typeWrapper,
}
}
}
}
return nil
}

func (fd *fieldDefinition) WithMemberType(memberType string) FieldDefinition {
Expand Down