Repository: edgedb/edgedb

Issue:
GraphQL does not support `EXISTS`
There seems to be no way to test for empty set in GraphQL filters.


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
@@ -1335,14 +1335,32 @@ def visit_ObjectFieldNode(self, node):
 
         if op:
             value = self.visit(node.value)
-            if self._context.right_cast is not None:
+            left = self._context.base_expr
+
+            # 'exists' filter gets converted to:
+            # EXISTS (<expr>) = <value>
+            # where the <value> is either true or false. This is so
+            # that there's a one-to-one correspondence between the
+            # potential input variables and the EdgeQL variables.
+            #
+            # If different EdgeQL code were generated instead, then
+            # the assumption that it's safe to re-run the same EdgeQL
+            # query with different input variables would not hold.
+            if op == 'EXISTS':
+                left = qlast.UnaryOp(op='EXISTS', operand=left)
+                # The binary operator that we need here is "="
+                op = '='
+
+            elif self._context.right_cast is not None:
+                # We don't need to cast the RHS for the EXISTS, only
+                # for other operations.
                 value = qlast.TypeCast(
                     expr=value,
                     type=self._context.right_cast,
                 )
 
             return qlast.BinOp(
-                left=self._context.base_expr, op=op, right=value)
+                left=left, op=op, right=value)
 
         # we're at the beginning of a scalar op
         _, target = self._get_parent_and_current_type()
@@ -1387,8 +1405,14 @@ def visit_ObjectFieldNode(self, node):
             self._context.base_expr = None
 
         # we need to cast a target string into <uuid> or enum
-        if typename == 'std::uuid' and not isinstance(
-                value.right, qlast.TypeCast):
+        if (typename == 'std::uuid'
+                and not (
+                    # EXISTS side does not need a <uuid> cast
+                    isinstance(value.left, qlast.UnaryOp) and
+                    value.left.op == 'EXISTS'
+                )
+                and not isinstance(value.right, qlast.TypeCast)):
+
             value.right = qlast.TypeCast(
                 expr=value.right,
                 type=qlast.TypeName(maintype=ftype.edb_base_name_ast),
diff --git a/edb/graphql/types.py b/edb/graphql/types.py
--- a/edb/graphql/types.py
+++ b/edb/graphql/types.py
@@ -248,6 +248,7 @@ def parse_decimal_literal(ast):
 
 
 GQL_TO_OPS_MAP = {
+    'exists': 'EXISTS',
     'eq': '=',
     'neq': '!=',
     'gt': '>',
@@ -581,12 +582,16 @@ def get_fields(self, typename):
     def get_filter_fields(self, typename, nested=False):
         selftype = self._gql_inobjtypes[typename]
         fields = OrderedDict()
+
         if not nested:
             fields['and'] = GraphQLInputObjectField(
                 GraphQLList(GraphQLNonNull(selftype)))
             fields['or'] = GraphQLInputObjectField(
                 GraphQLList(GraphQLNonNull(selftype)))
             fields['not'] = GraphQLInputObjectField(selftype)
+        else:
+            # Always include the 'exists' operation
+            fields['exists'] = GraphQLInputObjectField(GraphQLBoolean)
 
         edb_type = self.edb_schema.get(typename)
         pointers = edb_type.get_pointers(self.edb_schema)
@@ -892,24 +897,31 @@ def define_generic_filter_types(self):
         comp = eq + ['gte', 'gt', 'lte', 'lt']
         string = comp + ['like', 'ilike']
 
-        self._make_generic_input_type(GraphQLBoolean, eq)
-        self._make_generic_input_type(GraphQLID, eq)
-        self._make_generic_input_type(GraphQLInt, comp)
-        self._make_generic_input_type(GraphQLInt64, comp)
-        self._make_generic_input_type(GraphQLBigint, comp)
-        self._make_generic_input_type(GraphQLFloat, comp)
-        self._make_generic_input_type(GraphQLDecimal, comp)
-        self._make_generic_input_type(GraphQLString, string)
+        self._make_generic_filter_type(GraphQLBoolean, eq)
+        self._make_generic_filter_type(GraphQLID, eq)
+        self._make_generic_filter_type(GraphQLInt, comp)
+        self._make_generic_filter_type(GraphQLInt64, comp)
+        self._make_generic_filter_type(GraphQLBigint, comp)
+        self._make_generic_filter_type(GraphQLFloat, comp)
+        self._make_generic_filter_type(GraphQLDecimal, comp)
+        self._make_generic_filter_type(GraphQLString, string)
 
         for name, etype in self._gql_enums.items():
             if name not in {'directionEnum', 'nullsOrderingEnum'}:
-                self._make_generic_input_type(etype, comp)
+                self._make_generic_filter_type(etype, comp)
 
-    def _make_generic_input_type(self, base, ops):
+    def _make_generic_filter_type(self, base, ops):
         name = f'Filter{base.name}'
+        fields = OrderedDict()
+
+        # Always include the 'exists' operation
+        fields['exists'] = GraphQLInputObjectField(GraphQLBoolean)
+        for op in ops:
+            fields[op] = GraphQLInputObjectField(base)
+
         self._gql_inobjtypes[name] = GraphQLInputObjectType(
             name=name,
-            fields={op: GraphQLInputObjectField(base) for op in ops},
+            fields=fields,
         )
 
     def define_generic_insert_types(self):


Test patch:
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
@@ -3227,6 +3227,210 @@ def test_graphql_functional_order_04(self):
             ]
         })
 
+    def test_graphql_functional_exists_01(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {profile: {exists: true}},
+                    order: {name: {dir: ASC}}
+                ) {
+                    name
+                    profile {
+                        name
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Alice",
+                    "profile": {
+                        "name": "Alice profile",
+                    },
+                },
+                {
+                    "name": "Bob",
+                    "profile": {
+                        "name": "Bob profile",
+                    },
+                },
+            ]
+        })
+
+    def test_graphql_functional_exists_02(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {profile: {exists: false}},
+                    order: {name: {dir: ASC}}
+                ) {
+                    name
+                    profile {
+                        name
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Jane",
+                    "profile": None,
+                },
+                {
+                    "name": "John",
+                    "profile": None,
+                },
+            ]
+        })
+
+    def test_graphql_functional_exists_03(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {groups: {settings: {exists: false}}},
+                    order: {name: {dir: ASC}}
+                ) {
+                    name
+                    groups {
+                        name
+                        settings {
+                            name
+                        }
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Alice",
+                    "groups": [],
+                },
+                {
+                    "name": "Bob",
+                    "groups": [],
+                },
+                {
+                    "name": "John",
+                    "groups": [
+                        {
+                            "name": "basic",
+                            "settings": [],
+                        }
+                    ],
+                },
+            ]
+        })
+
+    def test_graphql_functional_exists_04(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {groups: {settings: {exists: true}}}
+                ) {
+                    name
+                    groups {
+                        name
+                        settings(order: {name: {dir: ASC}}) {
+                            name
+                        }
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Jane",
+                    "groups": [
+                        {
+                            "name": "upgraded",
+                            "settings": [
+                                {
+                                    "name": "perks",
+                                },
+                                {
+                                    "name": "template",
+                                },
+                            ]
+                        }
+                    ]
+                }
+            ]
+        })
+
+    def test_graphql_functional_exists_05(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {groups: {settings: {id: {exists: false}}}},
+                    order: {name: {dir: ASC}}
+                ) {
+                    name
+                    groups {
+                        name
+                        settings {
+                            name
+                        }
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Alice",
+                    "groups": [],
+                },
+                {
+                    "name": "Bob",
+                    "groups": [],
+                },
+                {
+                    "name": "John",
+                    "groups": [
+                        {
+                            "name": "basic",
+                            "settings": [],
+                        }
+                    ],
+                },
+            ]
+        })
+
+    def test_graphql_functional_exists_06(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                User(
+                    filter: {groups: {settings: {id: {exists: true}}}}
+                ) {
+                    name
+                    groups {
+                        name
+                        settings(order: {name: {dir: ASC}}) {
+                            name
+                        }
+                    }
+                }
+            }
+        """, {
+            "User": [
+                {
+                    "name": "Jane",
+                    "groups": [
+                        {
+                            "name": "upgraded",
+                            "settings": [
+                                {
+                                    "name": "perks",
+                                },
+                                {
+                                    "name": "template",
+                                },
+                            ]
+                        }
+                    ]
+                }
+            ]
+        })
+
 
 class TestGraphQLInit(tb.GraphQLTestCase):
     """Test GraphQL initialization on an empty database."""