From c8fd3b15ef67e575fb064b8b79a64c9c790355fe Mon Sep 17 00:00:00 2001 From: Keenan Nemetz Date: Wed, 6 Nov 2024 12:33:30 -0800 Subject: [PATCH] feat: Filter alias target (#3201) ## Relevant issue(s) Resolves #3194 ## Description This PR adds alias targeting in filters. **Aggregate targets are not included in this PR as they require more changes.** ## Tasks - [x] I made sure the code is well commented, particularly hard-to-understand areas. - [x] I made sure the repository-held documentation is changed accordingly. - [x] I made sure the pull request title adheres to the conventional commit style (the subset used in the project can be found in [tools/configs/chglog/config.yml](tools/configs/chglog/config.yml)). - [x] I made sure to discuss its limitations such as threats to validity, vulnerability to mistake and misuse, robustness to invalidation of assumptions, resource requirements, ... ## How has this been tested? Added and updated integration tests. Specify the platform(s) on which this was tested: - MacOS --- client/request/filter.go | 7 +- internal/connor/connor.go | 9 +- internal/connor/eq.go | 9 +- internal/connor/key.go | 11 +- internal/connor/not_test.go | 8 +- internal/core/doc.go | 12 + internal/planner/filter/copy_field.go | 14 +- internal/planner/filter/copy_field_test.go | 10 + internal/planner/mapper/errors.go | 5 + internal/planner/mapper/mapper.go | 35 +- internal/planner/mapper/targetable.go | 36 +- internal/request/graphql/parser/filter.go | 12 +- internal/request/graphql/schema/generate.go | 10 +- .../query/one_to_many/with_count_test.go | 63 ++++ .../query/one_to_many/with_filter_test.go | 86 +++++ .../simple/with_filter/with_alias_test.go | 320 ++++++++++++++++++ .../schema/aggregates/inline_array_test.go | 6 + tests/integration/schema/default_fields.go | 1 + tests/integration/schema/filter_test.go | 21 ++ 19 files changed, 610 insertions(+), 65 deletions(-) create mode 100644 tests/integration/query/simple/with_filter/with_alias_test.go diff --git a/client/request/filter.go b/client/request/filter.go index aabfafb9b9..feacb02f2b 100644 --- a/client/request/filter.go +++ b/client/request/filter.go @@ -13,9 +13,10 @@ package request import "github.com/sourcenetwork/immutable" const ( - FilterOpOr = "_or" - FilterOpAnd = "_and" - FilterOpNot = "_not" + FilterOpOr = "_or" + FilterOpAnd = "_and" + FilterOpNot = "_not" + FilterOpAlias = "_alias" ) // Filter contains the parsed condition map to be diff --git a/internal/connor/connor.go b/internal/connor/connor.go index da4f7f5b4d..cdb49d2973 100644 --- a/internal/connor/connor.go +++ b/internal/connor/connor.go @@ -9,9 +9,10 @@ package connor const ( - AndOp = "_and" - OrOp = "_or" - NotOp = "_not" + AliasOp = "_alias" + AndOp = "_and" + OrOp = "_or" + NotOp = "_not" AnyOp = "_any" AllOp = "_all" @@ -62,7 +63,7 @@ func matchWith(op string, conditions, data any) (bool, error) { return anyOp(conditions, data) case AllOp: return all(conditions, data) - case EqualOp: + case EqualOp, AliasOp: return eq(conditions, data) case GreaterOrEqualOp: return ge(conditions, data) diff --git a/internal/connor/eq.go b/internal/connor/eq.go index 65c17356f0..1caa43e81b 100644 --- a/internal/connor/eq.go +++ b/internal/connor/eq.go @@ -34,10 +34,15 @@ func eq(condition, data any) (bool, error) { switch cn := condition.(type) { case map[FilterKey]any: for prop, cond := range cn { - m, err := matchWith(prop.GetOperatorOrDefault(EqualOp), cond, prop.GetProp(data)) + d, op, err := prop.PropertyAndOperator(data, EqualOp) if err != nil { return false, err - } else if !m { + } + m, err := matchWith(op, cond, d) + if err != nil { + return false, err + } + if !m { return false, nil } } diff --git a/internal/connor/key.go b/internal/connor/key.go index b02769685c..98e8d747ea 100644 --- a/internal/connor/key.go +++ b/internal/connor/key.go @@ -3,12 +3,11 @@ package connor // FilterKey represents a type that may be used as a map key // in a filter. type FilterKey interface { - // GetProp returns the data that should be used with this key - // from the given data. - GetProp(data any) any - // GetOperatorOrDefault returns either the operator that corresponds - // to this key, or the given default. - GetOperatorOrDefault(defaultOp string) string + // PropertyAndOperator returns the data and operator that should be used + // to filter the value matching this key. + // + // If the key does not have an operator the given defaultOp will be returned. + PropertyAndOperator(data any, defaultOp string) (any, string, error) // Equal returns true if other is equal, otherwise returns false. Equal(other FilterKey) bool } diff --git a/internal/connor/not_test.go b/internal/connor/not_test.go index 1a1dd785dd..959ef04177 100644 --- a/internal/connor/not_test.go +++ b/internal/connor/not_test.go @@ -34,12 +34,8 @@ type operator struct { Operation string } -func (k *operator) GetProp(data any) any { - return data -} - -func (k *operator) GetOperatorOrDefault(defaultOp string) string { - return k.Operation +func (k *operator) PropertyAndOperator(data any, defaultOp string) (any, string, error) { + return data, k.Operation, nil } func (k *operator) Equal(other FilterKey) bool { diff --git a/internal/core/doc.go b/internal/core/doc.go index 379ac79bf9..d8716346a0 100644 --- a/internal/core/doc.go +++ b/internal/core/doc.go @@ -298,3 +298,15 @@ func (mapping *DocumentMapping) TryToFindNameFromIndex(targetIndex int) (string, return "", false } + +// TryToFindIndexFromRenderKey returns the corresponding index of the given render key. +// +// Additionally, will also return true if the render key was found, and false otherwise. +func (mapping *DocumentMapping) TryToFindIndexFromRenderKey(key string) (int, bool) { + for _, renderKey := range mapping.RenderKeys { + if renderKey.Key == key { + return renderKey.Index, true + } + } + return -1, false +} diff --git a/internal/planner/filter/copy_field.go b/internal/planner/filter/copy_field.go index 838cdf4cf0..7254dc6cc3 100644 --- a/internal/planner/filter/copy_field.go +++ b/internal/planner/filter/copy_field.go @@ -62,10 +62,10 @@ func traverseFilterByProperty( } } } else if opKey, isOpKey := targetKey.(*mapper.Operator); isOpKey { - clauseArr, isArr := clause.([]any) - if isArr { + switch t := clause.(type) { + case []any: resultArr := make([]any, 0) - for _, elementClause := range clauseArr { + for _, elementClause := range t { elementMap, ok := elementClause.(map[connor.FilterKey]any) if !ok { continue @@ -80,6 +80,14 @@ func traverseFilterByProperty( } else if shouldDelete { delete(result, opKey) } + + case map[connor.FilterKey]any: + resultMap := traverseFilterByProperty(keys, t, shouldDelete) + if len(resultMap) > 0 { + result[opKey] = resultMap + } else if shouldDelete { + delete(result, opKey) + } } } } diff --git a/internal/planner/filter/copy_field_test.go b/internal/planner/filter/copy_field_test.go index d539e437e3..d86aa59531 100644 --- a/internal/planner/filter/copy_field_test.go +++ b/internal/planner/filter/copy_field_test.go @@ -46,6 +46,16 @@ func TestCopyField(t *testing.T) { m("age", m("_gt", 55)), ), }, + { + name: "within _not", + inputFilter: m("_not", + m("age", m("_gt", 55)), + ), + inputField: []mapper.Field{{Index: authorAgeInd}}, + expectedFilter: m("_not", + m("age", m("_gt", 55)), + ), + }, { name: "within _or and _and", inputFilter: r("_and", diff --git a/internal/planner/mapper/errors.go b/internal/planner/mapper/errors.go index 43f7f56a7a..6aa03758bd 100644 --- a/internal/planner/mapper/errors.go +++ b/internal/planner/mapper/errors.go @@ -15,6 +15,7 @@ import "github.com/sourcenetwork/defradb/errors" const ( errInvalidFieldToGroupBy string = "invalid field value to groupBy" errTypeNotFound string = "type not found" + errFieldOrAliasNotFound string = "field or alias not found" ) var ( @@ -33,3 +34,7 @@ func NewErrInvalidFieldToGroupBy(field string) error { func NewErrTypeNotFound(name string) error { return errors.New(errTypeNotFound, errors.NewKV("Type", name)) } + +func NewErrFieldOrAliasNotFound(name string) error { + return errors.New(errFieldOrAliasNotFound, errors.NewKV("Name", name)) +} diff --git a/internal/planner/mapper/mapper.go b/internal/planner/mapper/mapper.go index 4717b7cba0..15014fb9f4 100644 --- a/internal/planner/mapper/mapper.go +++ b/internal/planner/mapper/mapper.go @@ -992,6 +992,12 @@ func resolveInnerFilterDependencies( newFields := []Requestable{} for key, value := range source { + // alias fields are guarenteed to be resolved + // because they refer to existing fields + if key == request.FilterOpAlias { + continue + } + if key == request.FilterOpAnd || key == request.FilterOpOr { if value == nil { continue @@ -1335,8 +1341,27 @@ func toFilterKeyValue( sourceClause any, mapping *core.DocumentMapping, ) (connor.FilterKey, any) { + var propIndex = -1 + if mapping != nil { + // if we have a mapping available check if the + // source key is a field or alias (render key) + if indexes, ok := mapping.IndexesByName[sourceKey]; ok { + // If there are multiple properties of the same name we can just take the first as + // we have no other reasonable way of identifying which property they mean if multiple + // consumer specified requestables are available. Aggregate dependencies should not + // impact this as they are added after selects. + propIndex = indexes[0] + } else if index, ok := mapping.TryToFindIndexFromRenderKey(sourceKey); ok { + propIndex = index + } + } + var returnKey connor.FilterKey - if strings.HasPrefix(sourceKey, "_") && sourceKey != request.DocIDFieldName { + if propIndex >= 0 { + returnKey = &PropertyIndex{ + Index: propIndex, + } + } else if strings.HasPrefix(sourceKey, "_") { returnKey = &Operator{ Operation: sourceKey, } @@ -1345,14 +1370,6 @@ func toFilterKeyValue( if connor.IsOpSimple(sourceKey) { return returnKey, sourceClause } - } else if mapping != nil && len(mapping.IndexesByName[sourceKey]) > 0 { - // If there are multiple properties of the same name we can just take the first as - // we have no other reasonable way of identifying which property they mean if multiple - // consumer specified requestables are available. Aggregate dependencies should not - // impact this as they are added after selects. - returnKey = &PropertyIndex{ - Index: mapping.FirstIndexOfName(sourceKey), - } } else { returnKey = &ObjectProperty{ Name: sourceKey, diff --git a/internal/planner/mapper/targetable.go b/internal/planner/mapper/targetable.go index 2611d297dc..55bc256327 100644 --- a/internal/planner/mapper/targetable.go +++ b/internal/planner/mapper/targetable.go @@ -30,16 +30,11 @@ type PropertyIndex struct { Index int } -func (k *PropertyIndex) GetProp(data any) any { +func (k *PropertyIndex) PropertyAndOperator(data any, defaultOp string) (any, string, error) { if data == nil { - return nil + return nil, defaultOp, nil } - - return data.(core.Doc).Fields[k.Index] -} - -func (k *PropertyIndex) GetOperatorOrDefault(defaultOp string) string { - return defaultOp + return data.(core.Doc).Fields[k.Index], defaultOp, nil } func (k *PropertyIndex) Equal(other connor.FilterKey) bool { @@ -57,12 +52,8 @@ type Operator struct { Operation string } -func (k *Operator) GetProp(data any) any { - return data -} - -func (k *Operator) GetOperatorOrDefault(defaultOp string) string { - return k.Operation +func (k *Operator) PropertyAndOperator(data any, defaultOp string) (any, string, error) { + return data, k.Operation, nil } func (k *Operator) Equal(other connor.FilterKey) bool { @@ -81,16 +72,15 @@ type ObjectProperty struct { Name string } -func (k *ObjectProperty) GetProp(data any) any { +func (k *ObjectProperty) PropertyAndOperator(data any, defaultOp string) (any, string, error) { if data == nil { - return nil + return nil, defaultOp, nil } - object := data.(map[string]any) - return object[k.Name] -} - -func (k *ObjectProperty) GetOperatorOrDefault(defaultOp string) string { - return defaultOp + docMap, ok := data.(map[string]any) + if !ok { + return nil, defaultOp, NewErrFieldOrAliasNotFound(k.Name) + } + return docMap[k.Name], defaultOp, nil } func (k *ObjectProperty) Equal(other connor.FilterKey) bool { @@ -165,7 +155,7 @@ func filterObjectToMap(mapping *core.DocumentMapping, obj map[connor.FilterKey]a logicMapEntries[i] = filterObjectToMap(mapping, itemMap) } outmap[keyType.Operation] = logicMapEntries - case request.FilterOpNot: + case request.FilterOpNot, request.FilterOpAlias: itemMap, ok := v.(map[connor.FilterKey]any) if ok { outmap[keyType.Operation] = filterObjectToMap(mapping, itemMap) diff --git a/internal/request/graphql/parser/filter.go b/internal/request/graphql/parser/filter.go index 40d4a798f4..1995eeb58b 100644 --- a/internal/request/graphql/parser/filter.go +++ b/internal/request/graphql/parser/filter.go @@ -93,20 +93,20 @@ func parseFilterFieldsForDescriptionMap( fields := make([]client.FieldDefinition, 0) for k, v := range conditions { switch k { - case "_or", "_and": + case request.FilterOpOr, request.FilterOpAnd: conds := v.([]any) - parsedFileds, err := parseFilterFieldsForDescriptionSlice(conds, col) + parsedFields, err := parseFilterFieldsForDescriptionSlice(conds, col) if err != nil { return nil, err } - fields = append(fields, parsedFileds...) - case "_not": + fields = append(fields, parsedFields...) + case request.FilterOpNot, request.FilterOpAlias: conds := v.(map[string]any) - parsedFileds, err := parseFilterFieldsForDescriptionMap(conds, col) + parsedFields, err := parseFilterFieldsForDescriptionMap(conds, col) if err != nil { return nil, err } - fields = append(fields, parsedFileds...) + fields = append(fields, parsedFields...) default: f, found := col.GetFieldByName(k) if !found || f.Kind.IsObject() { diff --git a/internal/request/graphql/schema/generate.go b/internal/request/graphql/schema/generate.go index 254fae6e7d..608c83e381 100644 --- a/internal/request/graphql/schema/generate.go +++ b/internal/request/graphql/schema/generate.go @@ -1188,18 +1188,22 @@ func (g *Generator) genTypeFilterArgInput(obj *gql.Object) *gql.InputObject { func() (gql.InputObjectConfigFieldMap, error) { fields := gql.InputObjectConfigFieldMap{} - fields["_and"] = &gql.InputObjectFieldConfig{ + fields[request.FilterOpAnd] = &gql.InputObjectFieldConfig{ Description: schemaTypes.AndOperatorDescription, Type: gql.NewList(gql.NewNonNull(selfRefType)), } - fields["_or"] = &gql.InputObjectFieldConfig{ + fields[request.FilterOpOr] = &gql.InputObjectFieldConfig{ Description: schemaTypes.OrOperatorDescription, Type: gql.NewList(gql.NewNonNull(selfRefType)), } - fields["_not"] = &gql.InputObjectFieldConfig{ + fields[request.FilterOpNot] = &gql.InputObjectFieldConfig{ Description: schemaTypes.NotOperatorDescription, Type: selfRefType, } + fields[request.FilterOpAlias] = &gql.InputObjectFieldConfig{ + Description: "The alias operator allows filters to target aliased fields.", + Type: schemaTypes.JSONScalarType(), + } // generate basic filter operator blocks for f, field := range obj.Fields() { diff --git a/tests/integration/query/one_to_many/with_count_test.go b/tests/integration/query/one_to_many/with_count_test.go index 2b4e8a5fbe..77d4e754f3 100644 --- a/tests/integration/query/one_to_many/with_count_test.go +++ b/tests/integration/query/one_to_many/with_count_test.go @@ -118,3 +118,66 @@ func TestQueryOneToManyWithCount(t *testing.T) { executeTestCase(t, test) } } + +// This test documents the behavior of aggregate alias targeting which is not yet implemented. +// https://github.com/sourcenetwork/defradb/issues/3195 +func TestQueryOneToMany_WithCountAliasFilter_ShouldFilterAll(t *testing.T) { + test := testUtils.TestCase{ + Description: "One-to-many relation query from many side with count", + Actions: []any{ + testUtils.CreateDoc{ + CollectionID: 1, + Doc: `{ + "name": "John Grisham", + "age": 65, + "verified": true + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + Doc: `{ + "name": "Cornelia Funke", + "age": 62, + "verified": false + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + DocMap: map[string]any{ + "name": "Painted House", + "rating": 4.9, + "author_id": testUtils.NewDocIndex(1, 0), + }, + }, + testUtils.CreateDoc{ + CollectionID: 0, + DocMap: map[string]any{ + "name": "A Time for Mercy", + "rating": 4.5, + "author_id": testUtils.NewDocIndex(1, 0), + }, + }, + testUtils.CreateDoc{ + CollectionID: 0, + DocMap: map[string]any{ + "name": "Theif Lord", + "rating": 4.8, + "author_id": testUtils.NewDocIndex(1, 1), + }, + }, + testUtils.Request{ + Request: `query { + Author(filter: {_alias: {publishedCount: {_gt: 0}}}) { + name + publishedCount: _count(published: {}) + } + }`, + Results: map[string]any{ + "Author": []map[string]any{}, + }, + }, + }, + } + + executeTestCase(t, test) +} diff --git a/tests/integration/query/one_to_many/with_filter_test.go b/tests/integration/query/one_to_many/with_filter_test.go index 317d89b2fd..e507b43a78 100644 --- a/tests/integration/query/one_to_many/with_filter_test.go +++ b/tests/integration/query/one_to_many/with_filter_test.go @@ -562,3 +562,89 @@ func TestQueryOneToMany_WithCompoundOperatorInFilterAndRelationAndCaseInsensitiv } testUtils.ExecuteTestCase(t, test) } + +func TestQueryOneToMany_WithAliasFilterOnRelated_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "One-to-many relation query from the many side, alias filter", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: bookAuthorGQLSchema, + }, + testUtils.CreateDoc{ + CollectionID: 0, + // bae-be6d8024-4953-5a92-84b4-f042d25230c6 + Doc: `{ + "name": "Painted House", + "rating": 4.9, + "author_id": "bae-e1ea288f-09fa-55fa-b0b5-0ac8941ea35b" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "A Time for Mercy", + "rating": 4.5, + "author_id": "bae-e1ea288f-09fa-55fa-b0b5-0ac8941ea35b" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 0, + Doc: `{ + "name": "Theif Lord", + "rating": 4.8, + "author_id": "bae-72e8c691-9f20-55e7-9228-8af1cf54cace" + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + // bae-e1ea288f-09fa-55fa-b0b5-0ac8941ea35b + Doc: `{ + "name": "John Grisham", + "age": 65, + "verified": true + }`, + }, + testUtils.CreateDoc{ + CollectionID: 1, + // bae-72e8c691-9f20-55e7-9228-8af1cf54cace + Doc: `{ + "name": "Cornelia Funke", + "age": 62, + "verified": false + }`, + }, + testUtils.Request{ + Request: `query { + Author(filter: {_alias: {books: {rating: {_gt: 4.8}}}}) { + name + age + books: published { + name + rating + } + } + }`, + Results: map[string]any{ + "Author": []map[string]any{ + { + "name": "John Grisham", + "age": int64(65), + "books": []map[string]any{ + { + "name": "Painted House", + "rating": 4.9, + }, + { + "name": "A Time for Mercy", + "rating": 4.5, + }, + }, + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/query/simple/with_filter/with_alias_test.go b/tests/integration/query/simple/with_filter/with_alias_test.go new file mode 100644 index 0000000000..a3e2c920be --- /dev/null +++ b/tests/integration/query/simple/with_filter/with_alias_test.go @@ -0,0 +1,320 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package simple + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestQuerySimple_WithAliasEqualsFilterBlock_ShouldFilter(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with alias filter(age)", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: {UserAge: {_eq: 21}}}) { + Name + UserAge: Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "Name": "John", + "UserAge": int64(21), + }, + }, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithEmptyAlias_ShouldNotFilter(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with empty alias filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: {}}) { + Name + Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "Name": "John", + "Age": int64(21), + }, + { + "Name": "Bob", + "Age": int64(32), + }, + }, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithNullAlias_ShouldFilterAll(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with null alias filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: null}) { + Name + Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{}, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithNonObjectAlias_ShouldFilterAll(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with non object alias filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: 1}) { + Name + Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{}, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithNonExistantAlias_ShouldReturnError(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with non existant alias filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: {UserAge: {_eq: 21}}}) { + Name + Age + } + }`, + ExpectedError: `field or alias not found. Name: UserAge`, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithNonAliasedField_ShouldMatchFilter(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with non aliased filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {_alias: {Age: {_eq: 32}}}) { + Name + Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "Name": "Bob", + "Age": int64(32), + }, + }, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithCompoundAlias_ShouldMatchFilter(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with compound alias filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: { + _and: [ + {_alias: {userAge: {_gt: 30}}}, + {_alias: {userAge: {_lt: 40}}} + ] + }) { + Name + userAge: Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "Name": "Bob", + "userAge": int64(32), + }, + }, + }, + }, + }, + } + + executeTestCase(t, test) +} + +func TestQuerySimple_WithAliasWithCompound_ShouldMatchFilter(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple query with alias with compound filter", + Actions: []any{ + testUtils.CreateDoc{ + Doc: `{ + "Name": "John", + "Age": 21 + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "Name": "Bob", + "Age": 32 + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: { + _alias: { + _and: [ + {userAge: {_gt: 30}}, + {userAge: {_lt: 40}} + ] + } + }) { + Name + userAge: Age + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "Name": "Bob", + "userAge": int64(32), + }, + }, + }, + }, + }, + } + + executeTestCase(t, test) +} diff --git a/tests/integration/schema/aggregates/inline_array_test.go b/tests/integration/schema/aggregates/inline_array_test.go index 067f17c6ed..03ad59aa75 100644 --- a/tests/integration/schema/aggregates/inline_array_test.go +++ b/tests/integration/schema/aggregates/inline_array_test.go @@ -405,6 +405,12 @@ func aggregateGroupArg(fieldType string) map[string]any { "name": fieldType + "ListOperatorBlock", }, }, + map[string]any{ + "name": "_alias", + "type": map[string]any{ + "name": "JSON", + }, + }, map[string]any{ "name": "_and", "type": map[string]any{ diff --git a/tests/integration/schema/default_fields.go b/tests/integration/schema/default_fields.go index 18c09975a2..1f71f6bc2f 100644 --- a/tests/integration/schema/default_fields.go +++ b/tests/integration/schema/default_fields.go @@ -228,6 +228,7 @@ func buildFilterArg(objectName string, fields []argDef) Field { filterArgName := objectName + "FilterArg" inputFields := []any{ + makeInputObject("_alias", "JSON", nil), makeInputObject("_and", nil, map[string]any{ "kind": "NON_NULL", "name": nil, diff --git a/tests/integration/schema/filter_test.go b/tests/integration/schema/filter_test.go index a48ac2e296..5b6fcb2c74 100644 --- a/tests/integration/schema/filter_test.go +++ b/tests/integration/schema/filter_test.go @@ -66,6 +66,13 @@ func TestFilterForSimpleSchema(t *testing.T) { "type": map[string]any{ "name": "UsersFilterArg", "inputFields": []any{ + map[string]any{ + "name": "_alias", + "type": map[string]any{ + "name": "JSON", + "ofType": nil, + }, + }, map[string]any{ "name": "_and", "type": map[string]any{ @@ -198,6 +205,13 @@ func TestFilterForOneToOneSchema(t *testing.T) { "type": map[string]any{ "name": "BookFilterArg", "inputFields": []any{ + map[string]any{ + "name": "_alias", + "type": map[string]any{ + "name": "JSON", + "ofType": nil, + }, + }, map[string]any{ "name": "_and", "type": map[string]any{ @@ -356,6 +370,13 @@ func TestSchemaFilterInputs_WithJSONField_Succeeds(t *testing.T) { "type": map[string]any{ "name": "UsersFilterArg", "inputFields": []any{ + map[string]any{ + "name": "_alias", + "type": map[string]any{ + "name": "JSON", + "ofType": nil, + }, + }, map[string]any{ "name": "_and", "type": map[string]any{