Repository: edgedb/edgedb

Issue:
GraphQL: Sorting on non-trivial path doesn't work
```
CREATE TYPE Bar {
    CREATE PROPERTY name -> str;
};

CREATE TYPE Foo {
    CREATE LINK bar -> Bar;
};
```

```
  query {
    foo: Foo(order: { bar: { name: { dir: DESC } } } ) {
      id
    }
  }
```

Actual result:

```
[GraphQL error]: Message: QueryError: Field 'bar' is not defined by type OrderFoo., Location: [object Object], Path: undefined
```

Expected result: sorting by paths of arbitrary length should work (if cardinality is proper, of course).


Discussion:

Resolving patch:
diff --git a/edb/graphql/translator.py b/edb/graphql/translator.py
--- a/edb/graphql/translator.py
+++ b/edb/graphql/translator.py
@@ -144,6 +144,13 @@ class TranspiledOperation(NamedTuple):
     variables_desc: dict
 
 
+class Ordering(NamedTuple):
+
+    names: List[str]
+    direction: qlast.SortOrder
+    nulls: qlast.NonesOrder
+
+
 class BookkeepDict(dict):
 
     def __init__(self, values):
@@ -1412,37 +1419,53 @@ def visit_order(self, node):
         # Ordering is handled by specifying a list of special Ordering objects.
         # Validation is already handled by this point.
         orderby = []
-        for enum in node.fields:
-            name, direction, nulls = self._visit_order_item(enum)
+        for ordering in self._visit_order_item(node):
             orderby.append(qlast.SortExpr(
                 path=qlast.Path(
-                    steps=[qlast.Ptr(ptr=qlast.ObjectRef(name=name))],
+                    steps=[
+                        qlast.Ptr(ptr=qlast.ObjectRef(name=name))
+                        for name in ordering.names
+                    ],
                     partial=True,
                 ),
-                direction=direction,
-                nones_order=nulls,
+                direction=ordering.direction,
+                nones_order=ordering.nulls,
             ))
 
         return orderby
 
     def _visit_order_item(self, node):
-        if not isinstance(node, gql_ast.ObjectFieldNode):
-            raise g_errors.GraphQLTranslationError(
-                f'an object is expected for "order"')
-
-        if not isinstance(node.value, gql_ast.ObjectValueNode):
+        if not isinstance(node, gql_ast.ObjectValueNode):
             raise g_errors.GraphQLTranslationError(
                 f'an object is expected for "order"')
 
-        name = node.name.value
+        orderings = []
         direction = nulls = None
 
-        for part in node.value.fields:
-            if part.name.value == 'dir':
+        for part in node.fields:
+            # Check if there's a longer nested path here. If there is,
+            # validate that there's only one option chosen at this
+            # level.
+            if isinstance(part.value, gql_ast.ObjectValueNode):
+                for subordering in self._visit_order_item(part.value):
+                    orderings.append(
+                        Ordering(
+                            names=[part.name.value] + subordering.names,
+                            direction=subordering.direction,
+                            nulls=subordering.nulls
+                        )
+                    )
+
+            elif part.name.value == 'dir':
                 direction = part.value.value
-            if part.name.value == 'nulls':
+            elif part.name.value == 'nulls':
                 nulls = part.value.value
 
+        if orderings:
+            # We have compiled some ordering paths, so we don't have
+            # any direction or nulls on this level.
+            return orderings
+
         # direction is a required field, so we can rely on it having
         # one of two values
         if direction == 'ASC':
@@ -1461,7 +1484,7 @@ def _visit_order_item(self, node):
             else:
                 nulls = qlast.NonesLast
 
-        return name, direction, nulls
+        return [Ordering(names=[], direction=direction, nulls=nulls)]
 
     def visit_VariableNode(self, node):
         varname = node.name.value
diff --git a/edb/graphql/types.py b/edb/graphql/types.py
--- a/edb/graphql/types.py
+++ b/edb/graphql/types.py
@@ -949,21 +949,31 @@ def get_order_fields(self, typename):
 
             ptr = edb_type.getptr(self.edb_schema, name)
 
-            if not ptr.get_target(self.edb_schema).is_scalar():
+            if not ptr.singular(self.edb_schema):
                 continue
 
-            target = self._convert_edb_type(ptr.get_target(self.edb_schema))
+            t = ptr.get_target(self.edb_schema)
+            target = self._convert_edb_type(t)
+
             if target is None:
-                # don't expose this
+                # Don't expose this
                 continue
 
-            # this makes sure that we can only order by properties
-            # that can be reflected into GraphQL
-            intype = self._gql_inobjtypes.get(f'Filter{target.name}')
-            if intype:
-                fields[name] = GraphQLInputObjectField(
-                    self._gql_ordertypes['Ordering']
-                )
+            if t.is_scalar():
+                # This makes sure that we can only order by properties
+                # that can be reflected into GraphQL
+                intype = self._gql_inobjtypes.get(f'Filter{target.name}')
+
+                if intype:
+                    fields[name] = GraphQLInputObjectField(
+                        self._gql_ordertypes['Ordering']
+                    )
+            elif t.is_object_type():
+                # It's a link so we need the link's type order input
+                t_name = t.get_name(self.edb_schema)
+                fields[name] = self._gql_ordertypes[t_name]
+
+            # We ignore pointers that aren't scalars or objects.
 
         return fields
 


Test patch:
diff --git a/tests/schemas/graphql.esdl b/tests/schemas/graphql.esdl
--- a/tests/schemas/graphql.esdl
+++ b/tests/schemas/graphql.esdl
@@ -125,3 +125,8 @@ type Genre extending NamedObject {
 type Game extending NamedObject {
     multi link players -> User
 }
+
+# Recursive structure
+type LinkedList extending NamedObject {
+    link next -> LinkedList
+}
diff --git a/tests/schemas/graphql_setup.edgeql b/tests/schemas/graphql_setup.edgeql
--- a/tests/schemas/graphql_setup.edgeql
+++ b/tests/schemas/graphql_setup.edgeql
@@ -36,6 +36,16 @@ INSERT UserGroup {
     settings := (SELECT Setting)
 };
 
+INSERT UserGroup {
+    name := 'unused',
+    settings := (
+        INSERT Setting {
+            name := 'template',
+            value := 'none'
+        }
+    )
+};
+
 INSERT User {
     name := 'John',
     age := 25,
@@ -69,7 +79,11 @@ INSERT Person {
     name := 'Bob',
     age := 21,
     active := True,
-    score := 4.2
+    score := 4.2,
+    profile := (INSERT Profile {
+        name := 'Bob profile',
+        value := 'special',
+    }),
 };
 
 WITH MODULE other
@@ -136,3 +150,35 @@ INSERT Rab {
 INSERT Rab2 {
     blah := (SELECT Bar2 LIMIT 1)
 };
+
+
+INSERT LinkedList {
+    name := '4th',
+};
+
+INSERT LinkedList {
+    name := '3rd',
+    next := (
+        SELECT DETACHED LinkedList
+        FILTER .name = '4th'
+        LIMIT 1
+    )
+};
+
+INSERT LinkedList {
+    name := '2nd',
+    next := (
+        SELECT DETACHED LinkedList
+        FILTER .name = '3rd'
+        LIMIT 1
+    )
+};
+
+INSERT LinkedList {
+    name := '1st',
+    next := (
+        SELECT DETACHED LinkedList
+        FILTER .name = '2nd'
+        LIMIT 1
+    )
+};
diff --git a/tests/test_http_edgeql.py b/tests/test_http_edgeql.py
--- a/tests/test_http_edgeql.py
+++ b/tests/test_http_edgeql.py
@@ -83,11 +83,12 @@ def test_http_edgeql_query_01(self):
                             name,
                             value
                         }
-                        ORDER BY .name ASC;
+                        ORDER BY .value ASC;
                     """,
                     [
+                        {'name': 'template', 'value': 'blue'},
                         {'name': 'perks', 'value': 'full'},
-                        {'name': 'template', 'value': 'blue'}
+                        {'name': 'template', 'value': 'none'},
                     ],
                     use_http_post=use_http_post
                 )
diff --git a/tests/test_http_graphql_mutation.py b/tests/test_http_graphql_mutation.py
--- a/tests/test_http_graphql_mutation.py
+++ b/tests/test_http_graphql_mutation.py
@@ -2147,6 +2147,8 @@ def test_graphql_mutation_update_link_01(self):
             'name': 'John',
             'groups': [{
                 'name': 'basic'
+            }, {
+                'name': 'unused'
             }, {
                 'name': 'upgraded'
             }],
@@ -2269,6 +2271,8 @@ def test_graphql_mutation_update_link_02(self):
             'name': 'John',
             'groups': [{
                 'name': 'basic'
+            }, {
+                'name': 'unused'
             }, {
                 'name': 'upgraded'
             }],
diff --git a/tests/test_http_graphql_query.py b/tests/test_http_graphql_query.py
--- a/tests/test_http_graphql_query.py
+++ b/tests/test_http_graphql_query.py
@@ -60,7 +60,8 @@ def test_graphql_http_keepalive_01(self):
                     'application/json')
                 self.assertEqual(
                     json.loads(data)['data'],
-                    {'Setting': [{'value': 'blue'}, {'value': 'full'}]})
+                    {'Setting': [{'value': 'blue'}, {'value': 'full'},
+                                 {'value': 'none'}]})
 
                 req2_data = {
                     'query': '''
@@ -140,13 +141,16 @@ def test_graphql_functional_query_01(self):
                 }
             """, {
                 'Setting': [{
+                    'name': 'template',
+                    'value': 'blue',
+                }, {
                     'name': 'perks',
                     'value': 'full',
                 }, {
                     'name': 'template',
-                    'value': 'blue',
+                    'value': 'none',
                 }],
-            }, sort=lambda x: x['name'])
+            }, sort=lambda x: x['value'])
 
     def test_graphql_functional_query_02(self):
         self.assert_graphql_query_result(r"""
@@ -210,6 +214,8 @@ def test_graphql_functional_query_03(self):
                 'name': 'perks',
             }, {
                 'name': 'template',
+            }, {
+                'name': 'template',
             }],
         }, sort=lambda x: x['name'])
 
@@ -309,6 +315,8 @@ def test_graphql_functional_query_08(self):
                     'name': 'perks',
                 }, {
                     'name': 'template',
+                }, {
+                    'name': 'template',
                 }],
             },
             sort=lambda x: x['name'],
@@ -334,6 +342,8 @@ def test_graphql_functional_query_08(self):
                     'value': 'blue',
                 }, {
                     'value': 'full',
+                }, {
+                    'value': 'none',
                 }],
             },
             sort=lambda x: x['value'],
@@ -540,6 +550,8 @@ def test_graphql_functional_query_18(self):
                     {'name': 'basic'},
                     {'name': 'perks'},
                     {'name': 'template'},
+                    {'name': 'template'},
+                    {'name': 'unused'},
                     {'name': 'upgraded'},
                 ]
             }],
@@ -563,6 +575,11 @@ def test_graphql_functional_alias_01(self):
             """,
             {
                 "SettingAlias": [
+                    {
+                        "__typename": "SettingAlias",
+                        "name": "template",
+                        "value": "blue",
+                    },
                     {
                         "__typename": "SettingAlias",
                         "name": "perks",
@@ -571,10 +588,15 @@ def test_graphql_functional_alias_01(self):
                     {
                         "__typename": "SettingAlias",
                         "name": "template",
-                        "value": "blue",
+                        "value": "none",
                     },
                 ],
                 "Setting": [
+                    {
+                        "__typename": "Setting_Type",
+                        "name": "template",
+                        "value": "blue",
+                    },
                     {
                         "__typename": "Setting_Type",
                         "name": "perks",
@@ -583,11 +605,11 @@ def test_graphql_functional_alias_01(self):
                     {
                         "__typename": "Setting_Type",
                         "name": "template",
-                        "value": "blue",
+                        "value": "none",
                     },
                 ],
             },
-            sort=lambda x: x['name']
+            sort=lambda x: x['value']
         )
 
     def test_graphql_functional_alias_02(self):
@@ -607,6 +629,15 @@ def test_graphql_functional_alias_02(self):
             """,
             {
                 "SettingAlias": [
+                    {
+                        "__typename": "SettingAlias",
+                        "name": "template",
+                        "value": "blue",
+                        "of_group": {
+                            "__typename": "UserGroup_Type",
+                            "name": "upgraded",
+                        }
+                    },
                     {
                         "__typename": "SettingAlias",
                         "name": "perks",
@@ -619,15 +650,15 @@ def test_graphql_functional_alias_02(self):
                     {
                         "__typename": "SettingAlias",
                         "name": "template",
-                        "value": "blue",
+                        "value": "none",
                         "of_group": {
                             "__typename": "UserGroup_Type",
-                            "name": "upgraded",
+                            "name": "unused",
                         }
                     },
                 ],
             },
-            sort=lambda x: x['name']
+            sort=lambda x: x['value']
         )
 
     def test_graphql_functional_alias_03(self):
@@ -648,6 +679,17 @@ def test_graphql_functional_alias_03(self):
             """,
             {
                 "SettingAliasAugmented": [
+                    {
+                        "__typename": "SettingAliasAugmented",
+                        "name": "template",
+                        "value": "blue",
+                        "of_group": {
+                            "__typename":
+                                "__SettingAliasAugmented__of_group",
+                            "name": "upgraded",
+                            "name_upper": "UPGRADED",
+                        }
+                    },
                     {
                         "__typename": "SettingAliasAugmented",
                         "name": "perks",
@@ -662,17 +704,17 @@ def test_graphql_functional_alias_03(self):
                     {
                         "__typename": "SettingAliasAugmented",
                         "name": "template",
-                        "value": "blue",
+                        "value": "none",
                         "of_group": {
                             "__typename":
                                 "__SettingAliasAugmented__of_group",
-                            "name": "upgraded",
-                            "name_upper": "UPGRADED",
+                            "name": "unused",
+                            "name_upper": "UNUSED",
                         }
                     },
                 ],
             },
-            sort=lambda x: x['name']
+            sort=lambda x: x['value']
         )
 
     def test_graphql_functional_alias_04(self):
@@ -702,6 +744,17 @@ def test_graphql_functional_alias_04(self):
                                 "id": uuid.UUID,
                             }
                         ]
+                    },
+                    {
+                        "__typename": "ProfileAlias",
+                        "name": "Bob profile",
+                        "value": "special",
+                        "owner": [
+                            {
+                                "__typename": "Person_Type",
+                                "id": uuid.UUID,
+                            }
+                        ]
                     }
                 ]
             },
@@ -1589,14 +1642,21 @@ def test_graphql_functional_fragment_type_06(self):
             }
         """, {
             "NamedObject": [
+                {"age": None, "name": "1st", "score": None},
+                {"age": None, "name": "2nd", "score": None},
+                {"age": None, "name": "3rd", "score": None},
+                {"age": None, "name": "4th", "score": None},
                 {"age": 27, "name": "Alice", "score": 5},
                 {"age": None, "name": "Alice profile", "score": None},
                 {"age": 21, "name": "Bob", "score": 4.2},
+                {"age": None, "name": "Bob profile", "score": None},
                 {"age": 25, "name": "Jane", "score": 1.23},
                 {"age": 25, "name": "John", "score": 3.14},
                 {"age": None, "name": "basic", "score": None},
                 {"age": None, "name": "perks", "score": None},
                 {"age": None, "name": "template", "score": None},
+                {"age": None, "name": "template", "score": None},
+                {"age": None, "name": "unused", "score": None},
                 {"age": None, "name": "upgraded", "score": None},
             ]
         }, sort=lambda x: x['name'])
@@ -1615,14 +1675,21 @@ def test_graphql_functional_fragment_type_07(self):
             }
         """, {
             "NamedObject": [
+                {"id": uuid.UUID, "name": "1st"},
+                {"id": uuid.UUID, "name": "2nd"},
+                {"id": uuid.UUID, "name": "3rd"},
+                {"id": uuid.UUID, "name": "4th"},
                 {"id": uuid.UUID, "name": "Alice"},
                 {"id": uuid.UUID, "name": "Alice profile"},
                 {"id": uuid.UUID, "name": "Bob"},
+                {"id": uuid.UUID, "name": "Bob profile"},
                 {"id": uuid.UUID, "name": "Jane"},
                 {"id": uuid.UUID, "name": "John"},
                 {"id": uuid.UUID, "name": "basic"},
                 {"id": uuid.UUID, "name": "perks"},
                 {"id": uuid.UUID, "name": "template"},
+                {"id": uuid.UUID, "name": "template"},
+                {"id": uuid.UUID, "name": "unused"},
                 {"id": uuid.UUID, "name": "upgraded"},
             ]
         }, sort=lambda x: x['name'])
@@ -1682,14 +1749,21 @@ def test_graphql_functional_fragment_type_10(self):
             }
         """, {
             "NamedObject": [
+                {"id": uuid.UUID, "name": "1st", "age": None},
+                {"id": uuid.UUID, "name": "2nd", "age": None},
+                {"id": uuid.UUID, "name": "3rd", "age": None},
+                {"id": uuid.UUID, "name": "4th", "age": None},
                 {"id": uuid.UUID, "name": "Alice", "age": 27},
                 {"id": uuid.UUID, "name": "Alice profile", "age": None},
                 {"id": uuid.UUID, "name": "Bob", "age": 21},
+                {"id": uuid.UUID, "name": "Bob profile", "age": None},
                 {"id": uuid.UUID, "name": "Jane", "age": 25},
                 {"id": uuid.UUID, "name": "John", "age": 25},
                 {"id": uuid.UUID, "name": "basic", "age": None},
                 {"id": uuid.UUID, "name": "perks", "age": None},
                 {"id": uuid.UUID, "name": "template", "age": None},
+                {"id": uuid.UUID, "name": "template", "age": None},
+                {"id": uuid.UUID, "name": "unused", "age": None},
                 {"id": uuid.UUID, "name": "upgraded", "age": None},
             ]
         }, sort=lambda x: x['name'])
@@ -1731,15 +1805,22 @@ def test_graphql_functional_fragment_type_12(self):
             }
         """, {
             "NamedObject": [
+                {"age": None},
+                {"age": None},
+                {"age": None},
+                {"age": None},
                 {"age": 27},
                 {"age": None},
                 {"age": 21},
+                {"age": None},
                 {"age": 25},
                 {"age": 25},
                 {"age": None},
                 {"age": None},
                 {"age": None},
                 {"age": None},
+                {"age": None},
+                {"age": None},
             ]
         })
 
@@ -3009,6 +3090,143 @@ def test_graphql_functional_inheritance_03(self):
             }],
         })
 
+    def test_graphql_functional_order_01(self):
+        # Test order by nested objects
+        self.assert_graphql_query_result(r"""
+            query {
+                Rab(order: {blah: {q: {dir: DESC}}}) {
+                    blah {
+                        q
+                    }
+                }
+            }
+        """, {
+            "Rab": [
+                {
+                    "blah": {
+                        "q": "bar2"
+                    }
+                },
+                {
+                    "blah": {
+                        "q": "bar"
+                    },
+                }
+            ]
+        })
+
+    def test_graphql_functional_order_02(self):
+        # Test order by nested objects
+        self.assert_graphql_query_result(r"""
+            query {
+                SettingAliasAugmented(
+                    order: {
+                        of_group: {name_upper: {dir: ASC}},
+                        name: {dir: DESC}
+                    }
+                ) {
+                    name
+                    of_group {
+                        name_upper
+                    }
+                }
+            }
+        """, {
+            "SettingAliasAugmented": [
+                {
+                    "name": "template",
+                    "of_group": {
+                        "name_upper": "UNUSED"
+                    },
+                },
+                {
+                    "name": "template",
+                    "of_group": {
+                        "name_upper": "UPGRADED"
+                    },
+                },
+                {
+                    "name": "perks",
+                    "of_group": {
+                        "name_upper": "UPGRADED"
+                    },
+                },
+            ]
+        })
+
+    def test_graphql_functional_order_03(self):
+        # Test order by nested objects
+        self.assert_graphql_query_result(r"""
+            query {
+                LinkedList(order: {
+                    next: {next: {name: {dir: DESC, nulls: SMALLEST}}},
+                    name: {dir: ASC}
+                }) {
+                    name
+                }
+            }
+        """, {
+
+            "LinkedList": [
+                {
+                    "name": "2nd"
+                },
+                {
+                    "name": "1st"
+                },
+                {
+                    "name": "3rd"
+                },
+                {
+                    "name": "4th"
+                }
+            ]
+        })
+
+    def test_graphql_functional_order_04(self):
+        # Test order by nested objects
+        self.assert_graphql_query_result(r"""
+            query {
+                User(order: {
+                    profile: {
+                        value: {dir: ASC},
+                        name: {dir: DESC}
+                    }
+                }) {
+                    name
+                    profile {
+                        name
+                        value
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "John",
+                    "profile": None,
+                },
+                {
+                    "name": "Jane",
+                    "profile": None,
+                },
+                {
+                    "name": "Bob",
+                    "profile": {
+                        "name": "Bob profile",
+                        "value": "special",
+                    },
+                },
+                {
+                    "name": "Alice",
+                    "profile": {
+                        "name": "Alice profile",
+                        "value": "special",
+                    },
+                }
+            ]
+        })
+
 
 class TestGraphQLInit(tb.GraphQLTestCase):
     """Test GraphQL initialization on an empty database."""
diff --git a/tests/test_http_graphql_schema.py b/tests/test_http_graphql_schema.py
--- a/tests/test_http_graphql_schema.py
+++ b/tests/test_http_graphql_schema.py
@@ -1890,6 +1890,14 @@ def test_graphql_schema_type_10(self):
                             "ofType": None
                         }
                     },
+                    {
+                        "name": "profile",
+                        "type": {
+                            "name": "OrderProfile",
+                            "kind": "INPUT_OBJECT",
+                            "ofType": None
+                        }
+                    },
                     {
                         "name": "score",
                         "type": {