Repository: edgedb/edgedb

Issue:
GraphQL doesn't work on schemas that have union links
I tried to fire up the graphql client on our `issues` database, and it fails with

```
    /home/sully/src/e/edgedb/edb/server/compiler_pool/worker.py, line 244, in compile_graphql
        > return graphql.compile_graphql(
    /home/sully/src/e/edgedb/edb/graphql/compiler.py, line 85, in compile_graphql
        > op = graphql.translate_ast(
    /home/sully/src/e/edgedb/edb/graphql/translator.py, line 1807, in translate_ast
        > graphql.validate(gqlcore.graphql_schema, document_ast),
    /home/sully/src/e/edgedb-dev/lib/python3.10/site-packages/graphql/validation/validate.py, line 44, in validate
        > assert_valid_schema(schema)
    /home/sully/src/e/edgedb-dev/lib/python3.10/site-packages/graphql/type/validate.py, line 91, in assert_valid_schema
        > raise TypeError("\n\n".join(error.message for error in errors))

TypeError: Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but '(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'Order(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'Filter(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'NestedFilter(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'NestedInsert(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'NestedUpdate(default:File | default:Publication | default:URL)' does not.

Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but '(default:File | default:Publication | default:URL)' does not.
```

It looks like no graphql queries against the database can work, even ones not involving those fields 

Looks like we'll have to do some sort of name mangling?


Discussion:
Same issue: type union operator in schema broke GraphQL support. Aslo I have problem with globals: `default|currentUser@default|currentUser@global' does not.\n\nNames must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ but 'default|superUser@default|superUser@global' does not.`

Resolving patch:
diff --git a/edb/graphql/types.py b/edb/graphql/types.py
--- a/edb/graphql/types.py
+++ b/edb/graphql/types.py
@@ -341,6 +341,7 @@ def __init__(self, edb_schema: s_schema.Schema) -> None:
         } - HIDDEN_MODULES))
 
         self._gql_interfaces = {}
+        self._gql_uniontypes: Set[s_name.QualName] = set()
         self._gql_objtypes_from_alias = {}
         self._gql_objtypes = {}
         self._gql_inobjtypes = {}
@@ -396,16 +397,26 @@ def graphql_schema(self) -> GraphQLSchema:
 
     def get_gql_name(self, name: s_name.QualName) -> str:
         module, shortname = name.module, name.name
+
+        # Adjust the shortname.
+        if shortname.startswith('__'):
+            # Use '_edb' prefix to mark derived and otherwise
+            # internal types. We opt out of '__edb' because we
+            # still rely on the first occurrence of '__' in
+            # GraphQL names to separate the module from the rest
+            # of the name in some code.
+            shortname = f'_edb{shortname}'
+        elif shortname.startswith('('):
+            # Looks like a union type, so we'll need to process individual
+            # parts of the name.
+            names = []
+            for part in shortname[1:-1].split(' | '):
+                names.append(
+                    self.get_gql_name(s_name.QualName(*part.split(':', 1))))
+            shortname = '_OR_'.join(names)
+
         if module in {'default', 'std'}:
-            if shortname.startswith('__'):
-                # Use '_edb' prefix to mark derived and otherwise
-                # internal types. We opt out of '__edb' because we
-                # still rely on the first occurrence of '__' in
-                # GraphQL names to separate the module from the rest
-                # of the name in some code.
-                return '_edb' + shortname
-            else:
-                return shortname
+            return shortname
         else:
             assert module != '', f'get_gl_name {name=}'
             return f'{module}__{shortname}'
@@ -609,6 +620,9 @@ def get_fields(
                 # exposed as a top-level query option.
                 if name in TOP_LEVEL_TYPES or gqliface.name.startswith('_edb'):
                     continue
+                # Check that the underlying type is not a union type.
+                if name in self._gql_uniontypes:
+                    continue
                 fields[gqliface.name] = GraphQLField(
                     GraphQLList(GraphQLNonNull(gqliface)),
                     args=self._get_query_args(name),
@@ -1232,6 +1246,7 @@ def get_order_fields(
     def _define_types(self) -> None:
         interface_types = []
         obj_types = []
+        from_union = {}
 
         self.define_enums()
         self.define_generic_filter_types()
@@ -1278,6 +1293,29 @@ def _type_resolver(
                     description=self._get_description(t),
                 )
 
+            if t.is_union_type(self.edb_schema):
+                # NOTE: EdgeDB union types and GraphQL union types are
+                # different in some important ways. In EdgeDB a union object
+                # type will have all the common links and properties that are
+                # shared among the members of the union. In GraphQL a union
+                # type has *no fields* at all and must be accessed via typed
+                # fragments. Effectively, EdgeDB union types behave exactly
+                # like GraphQL interfaces, though, which is why they will be
+                # reflected more naturally as interfaces.
+                #
+                # We still need to internally keep track of which interfaces
+                # are actually union types so that we don't create any
+                # top-level Query or Mutation entires for union types, but
+                # stick to only use them in the nested structures they
+                # actually appear in.
+
+                self._gql_uniontypes.add(t_name)
+                for member in t.get_union_of(self.edb_schema) \
+                               .names(self.edb_schema):
+                    # Union types must be interfaces for each of
+                    # the individual components so we need to record that.
+                    from_union[member] = t_name
+
             # input object types corresponding to this interface
             gqlfiltertype = GraphQLInputObjectType(
                 name=self.get_input_name('Filter', gql_name),
@@ -1292,9 +1330,11 @@ def _type_resolver(
             )
             self._gql_ordertypes[str(t_name)] = gqlordertype
 
-            # update object types corresponding to this object (all
-            # non-views can appear as update types)
-            if not t.is_view(self.edb_schema):
+            # update object types corresponding to this object (all types
+            # except views and union types can appear as update types)
+            if not (t.is_view(self.edb_schema)
+                    or t.is_union_type(self.edb_schema)):
+
                 # only objects that have at least one non-readonly
                 # link/property are eligible
                 pointers = t.get_pointers(self.edb_schema)
@@ -1321,9 +1361,15 @@ def _type_resolver(
                     self._gql_objtypes_from_alias[t_name]
                 continue
 
+            if t.is_union_type(self.edb_schema):
+                continue
+
             if t_name in self._gql_interfaces:
                 interfaces.append(self._gql_interfaces[t_name])
 
+            if t_name in from_union:
+                interfaces.append(self._gql_interfaces[from_union[t_name]])
+
             ancestors = t.get_ancestors(self.edb_schema)
             for st in ancestors.objects(self.edb_schema):
                 if (st.is_object_type() and


Test patch:
diff --git a/tests/schemas/graphql.esdl b/tests/schemas/graphql.esdl
--- a/tests/schemas/graphql.esdl
+++ b/tests/schemas/graphql.esdl
@@ -173,3 +173,10 @@ function id_func(s: str) -> str using (s);
 alias FuncTest := {
     fstr := id_func('test'),
 };
+
+type Combo {
+    required property name -> str;
+    # Test type union, Setting and Profile share some inherited fields as well
+    # as non-inherited ones.
+    link data -> Setting | Profile;
+};
\ No newline at end of file
diff --git a/tests/schemas/graphql_schema.esdl b/tests/schemas/graphql_schema.esdl
--- a/tests/schemas/graphql_schema.esdl
+++ b/tests/schemas/graphql_schema.esdl
@@ -63,6 +63,12 @@ alias SettingAliasAugmented := Setting {
 
 type Person extending User;
 
+type Combo {
+    # Test type union, Setting and Profile share some inherited fields as well
+    # as non-inherited ones.
+    link data -> Setting | Profile;
+}
+
 scalar type positive_int_t extending int64 {
     constraint min_value(0);
 }
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
@@ -186,3 +186,19 @@ INSERT LinkedList {
         LIMIT 1
     )
 };
+
+INSERT Combo {
+    name := 'combo 0',
+};
+
+INSERT Combo {
+    name := 'combo 1',
+    data := assert_single((
+        SELECT Setting FILTER .name = 'template' AND .value = 'blue'
+    )),
+};
+
+INSERT Combo {
+    name := 'combo 2',
+    data := assert_single((SELECT Profile FILTER .name = 'Alice profile')),
+};
\ No newline at end of file
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
@@ -3183,6 +3183,130 @@ def test_graphql_mutation_update_link_05(self):
             "User": [orig_data]
         })
 
+    def test_graphql_mutation_update_link_06(self):
+        # updating a single link targeting a type union
+        orig_data = {
+            'name': 'combo 0',
+            'data': None,
+        }
+        data1 = {
+            'name': 'combo 0',
+            'data': {
+                "__typename": "Setting_Type",
+                "name": "perks",
+            },
+        }
+        data2 = {
+            'name': 'combo 0',
+            'data': {
+                "__typename": "Profile_Type",
+                "name": "Bob profile",
+            },
+        }
+
+        validation_query = r"""
+            query {
+                Combo(
+                    filter: {name: {eq: "combo 0"}}
+                ) {
+                    name
+                    data {
+                        __typename
+                        name
+                    }
+                }
+            }
+        """
+
+        self.assert_graphql_query_result(validation_query, {
+            "Combo": [orig_data]
+        })
+
+        self.assert_graphql_query_result(r"""
+            mutation update_Combo {
+                update_Combo(
+                    data: {
+                        data: {
+                            set: {
+                                filter: {name: {eq: "perks"}}
+                            }
+                        }
+                    },
+                    filter: {
+                        name: {eq: "combo 0"}
+                    }
+                ) {
+                    name
+                    data {
+                        __typename
+                        name
+                    }
+                }
+            }
+        """, {
+            "update_Combo": [data1]
+        })
+
+        self.assert_graphql_query_result(validation_query, {
+            "Combo": [data1]
+        })
+
+        self.assert_graphql_query_result(r"""
+            mutation update_Combo {
+                update_Combo(
+                    data: {
+                        data: {
+                            set: {
+                                filter: {name: {eq: "Bob profile"}}
+                            }
+                        }
+                    },
+                    filter: {
+                        name: {eq: "combo 0"}
+                    }
+                ) {
+                    name
+                    data {
+                        __typename
+                        name
+                    }
+                }
+            }
+        """, {
+            "update_Combo": [data2]
+        })
+
+        self.assert_graphql_query_result(validation_query, {
+            "Combo": [data2]
+        })
+
+        self.assert_graphql_query_result(r"""
+            mutation update_Combo {
+                update_Combo(
+                    data: {
+                        data: {
+                            clear: true
+                        }
+                    },
+                    filter: {
+                        name: {eq: "combo 0"}
+                    }
+                ) {
+                    name
+                    data {
+                        __typename
+                        name
+                    }
+                }
+            }
+        """, {
+            "update_Combo": [orig_data]
+        })
+
+        self.assert_graphql_query_result(validation_query, {
+            "Combo": [orig_data]
+        })
+
     def test_graphql_mutation_update_bad_01(self):
         with self.assertRaisesRegex(
                 edgedb.QueryError,
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
@@ -4092,6 +4092,49 @@ def test_graphql_functional_in_02(self):
             ]
         })
 
+    def test_graphql_functional_type_union_01(self):
+        self.assert_graphql_query_result(r"""
+            query {
+                Combo(
+                    order: {name: {dir: ASC}}
+                ) {
+                    name
+                    data {
+                        __typename
+                        name
+                        value
+                        ... on Profile {
+                            tags
+                        }
+                    }
+                }
+            }
+        """, {
+            "Combo": [
+                {
+                    "name": "combo 0",
+                    "data": None,
+                },
+                {
+                    "name": "combo 1",
+                    "data": {
+                        "__typename": "Setting_Type",
+                        "name": "template",
+                        "value": "blue",
+                    },
+                },
+                {
+                    "name": "combo 2",
+                    "data": {
+                        "__typename": "Profile_Type",
+                        "name": "Alice profile",
+                        "value": "special",
+                        "tags": ['1st', '2nd'],
+                    },
+                },
+            ]
+        })
+
     def test_graphql_globals_01(self):
         Q = r'''query { GlobalTest { gstr, garray, gid, gdef, gdef2 } }'''
 
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
@@ -1274,6 +1274,11 @@ def test_graphql_schema_type_07(self):
                                 "name": "Profile",
                                 "kind": "INTERFACE"
                             },
+                            {
+                                "__typename": "__Type",
+                                "name": "Profile_OR_Setting",
+                                "kind": "INTERFACE"
+                            },
                         ],
                         "possibleTypes": None,
                         "enumValues": None,
@@ -1354,6 +1359,11 @@ def test_graphql_schema_type_07(self):
                                 "name": "Object",
                                 "kind": "INTERFACE"
                             },
+                            {
+                                "__typename": "__Type",
+                                "name": "Profile_OR_Setting",
+                                "kind": "INTERFACE"
+                            },
                             {
                                 "__typename": "__Type",
                                 "name": "Setting",
@@ -2495,6 +2505,404 @@ def test_graphql_schema_type_19(self):
             }
         })
 
+    def test_graphql_schema_type_20(self):
+        # make sure the union type got reflected
+        self.assert_graphql_query_result(r"""
+        query {
+            __type(name: "Profile_OR_Setting") {
+                __typename
+                name
+                kind
+                fields {
+                    __typename
+                    name
+                    description
+                    args {
+                        name
+                        description
+                        type {
+                            __typename
+                            name
+                            kind
+                            ofType {
+                                __typename
+                                name
+                                kind
+                            }
+                        }
+                        defaultValue
+                    }
+                    type {
+                        __typename
+                        name
+                        kind
+                        fields {name}
+                        ofType {
+                            name
+                            kind
+                            fields {name}
+                        }
+                    }
+                    isDeprecated
+                    deprecationReason
+                }
+                possibleTypes {
+                    name
+                }
+            }
+        }
+        """, {
+            "__type": {
+                "__typename": "__Type",
+                "name": "Profile_OR_Setting",
+                "kind": "INTERFACE",
+                "fields": [
+                    {
+                        "__typename": "__Field",
+                        "name": "id",
+                        "description": None,
+                        "args": [],
+                        "type": {
+                            "__typename": "__Type",
+                            "name": None,
+                            "kind": "NON_NULL",
+                            "fields": None,
+                            "ofType": {
+                                "name": "ID",
+                                "kind": "SCALAR",
+                                "fields": None
+                            }
+                        },
+                        "isDeprecated": False,
+                        "deprecationReason": None
+                    },
+                    {
+                        "__typename": "__Field",
+                        "name": "name",
+                        "description": None,
+                        "args": [],
+                        "type": {
+                            "__typename": "__Type",
+                            "name": None,
+                            "kind": "NON_NULL",
+                            "fields": None,
+                            "ofType": {
+                                "name": "String",
+                                "kind": "SCALAR",
+                                "fields": None
+                            }
+                        },
+                        "isDeprecated": False,
+                        "deprecationReason": None
+                    },
+                    {
+                        "__typename": "__Field",
+                        "name": "value",
+                        "description": None,
+                        "args": [],
+                        "type": {
+                            "__typename": "__Type",
+                            "name": None,
+                            "kind": "NON_NULL",
+                            "fields": None,
+                            "ofType": {
+                                "name": "String",
+                                "kind": "SCALAR",
+                                "fields": None
+                            }
+                        },
+                        "isDeprecated": False,
+                        "deprecationReason": None
+                    },
+                ],
+                "possibleTypes": [
+                    {"name": "Profile_Type"},
+                    {"name": "Setting_Type"},
+                ],
+            }
+        }, sort={
+            'fields': {
+                '.': lambda x: x['name'],
+                'args': lambda x: x['name'],
+            }
+        })
+
+    def test_graphql_schema_type_21(self):
+        # make sure that the union type supports filtering
+        self.assert_graphql_query_result(r"""
+            fragment _t on __Type {
+                name
+                kind
+            }
+
+            query {
+                __type(name: "FilterProfile_OR_Setting") {
+                    __typename
+                    ..._t
+                    inputFields {
+                        name
+                        type {
+                            ..._t
+                            ofType {
+                                ..._t
+                                ofType {
+                                    ..._t
+                                    ofType {
+                                        ..._t
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        """, {
+            "__type": {
+                "__typename": "__Type",
+                "name": "FilterProfile_OR_Setting",
+                "kind": "INPUT_OBJECT",
+                "inputFields": [
+                    {
+                        "name": "and",
+                        "type": {
+                            "name": None,
+                            "kind": "LIST",
+                            "ofType": {
+                                "name": None,
+                                "kind": "NON_NULL",
+                                "ofType": {
+                                    "name": "FilterProfile_OR_Setting",
+                                    "kind": "INPUT_OBJECT",
+                                    "ofType": None
+                                }
+                            }
+                        }
+                    },
+                    {
+                        "name": "id",
+                        "type": {
+                            "name": "FilterID",
+                            "kind": "INPUT_OBJECT",
+                            "ofType": None
+                        }
+                    },
+                    {
+                        "name": "name",
+                        "type": {
+                            "name": "FilterString",
+                            "kind": "INPUT_OBJECT",
+                            "ofType": None
+                        }
+                    },
+                    {
+                        "name": "not",
+                        "type": {
+                            "name": "FilterProfile_OR_Setting",
+                            "kind": "INPUT_OBJECT",
+                            "ofType": None
+                        }
+                    },
+                    {
+                        "name": "or",
+                        "type": {
+                            "name": None,
+                            "kind": "LIST",
+                            "ofType": {
+                                "name": None,
+                                "kind": "NON_NULL",
+                                "ofType": {
+                                    "name": "FilterProfile_OR_Setting",
+                                    "kind": "INPUT_OBJECT",
+                                    "ofType": None
+                                }
+                            }
+                        }
+                    },
+                    {
+                        "name": "value",
+                        "type": {
+                            "name": "FilterString",
+                            "kind": "INPUT_OBJECT",
+                            "ofType": None
+                        }
+                    },
+                ]
+            }
+        }, sort={
+            'inputFields': lambda x: x['name'],
+        })
+
+    def test_graphql_schema_type_22(self):
+        # make sure the union type is used as link target
+        self.assert_graphql_query_result(r"""
+        query {
+            __type(name: "Combo") {
+                __typename
+                name
+                kind
+                fields {
+                    __typename
+                    name
+                    description
+                    args {
+                        name
+                        description
+                        type {
+                            __typename
+                            name
+                            kind
+                            ofType {
+                                __typename
+                                name
+                                kind
+                            }
+                        }
+                        defaultValue
+                    }
+                    type {
+                        __typename
+                        name
+                        kind
+                        fields {name}
+                        ofType {
+                            name
+                            kind
+                            fields {name}
+                        }
+                    }
+                    isDeprecated
+                    deprecationReason
+                }
+            }
+        }
+        """, {
+            "__type": {
+
+                "__typename": "__Type",
+                "kind": "INTERFACE",
+                "name": "Combo",
+                "fields": [
+                    {
+                        "__typename": "__Field",
+                        "name": "data",
+                        "description": None,
+                        "args": [
+                            {
+                                "name": "after",
+                                "type": {
+                                    "kind": "SCALAR",
+                                    "name": "String",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                            {
+                                "name": "before",
+                                "type": {
+                                    "kind": "SCALAR",
+                                    "name": "String",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                            {
+                                "name": "filter",
+                                "type": {
+                                    "kind": "INPUT_OBJECT",
+                                    "name": "FilterProfile_OR_Setting",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                            {
+                                "name": "first",
+                                "type": {
+                                    "kind": "SCALAR",
+                                    "name": "Int",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                            {
+                                "name": "last",
+                                "type": {
+                                    "kind": "SCALAR",
+                                    "name": "Int",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                            {
+                                "name": "order",
+                                "type": {
+                                    "kind": "INPUT_OBJECT",
+                                    "name": "OrderProfile_OR_Setting",
+                                    "ofType": None,
+                                    "__typename": "__Type"
+                                },
+                                "description": None,
+                                "defaultValue": None
+                            },
+                        ],
+                        "type": {
+                            "kind": "INTERFACE",
+                            "name": "Profile_OR_Setting",
+                            "fields": [
+                                {
+                                    "name": "id"
+                                },
+                                {
+                                    "name": "name"
+                                },
+                                {
+                                    "name": "value"
+                                }
+                            ],
+                            "ofType": None,
+                            "__typename": "__Type"
+                        },
+                        "isDeprecated": False,
+                        "deprecationReason": None
+                    },
+                    {
+                        "__typename": "__Field",
+                        "name": "id",
+                        "description": None,
+                        "args": [],
+                        "type": {
+                            "__typename": "__Type",
+                            "name": None,
+                            "kind": "NON_NULL",
+                            "fields": None,
+                            "ofType": {
+                                "name": "ID",
+                                "kind": "SCALAR",
+                                "fields": None
+                            }
+                        },
+                        "isDeprecated": False,
+                        "deprecationReason": None
+                    },
+                ],
+
+            }
+        }, sort={
+            'fields': {
+                '.': lambda x: x['name'],
+                'args': lambda x: x['name'],
+            }
+        })
+
     def test_graphql_reflection_01(self):
         # Make sure that FreeObject is not reflected.
         result = self.graphql_query(r"""
@@ -2562,3 +2970,40 @@ def test_graphql_reflection_02(self):
                 bad,
                 [t['name'] for t in result['up']['inputFields']]
             )
+
+    def test_graphql_reflection_03(self):
+        # Make sure that union type `Profile | Setting` is not reflected at
+        # the root of Query or Mutation.
+        result = self.graphql_query(r"""
+            query {
+                __schema {
+                    queryType {
+                        fields {
+                            name
+                        }
+                    }
+                    mutationType {
+                        fields {
+                            name
+                        }
+                    }
+                }
+            }
+        """)
+
+        self.assertNotIn(
+            'Profile_OR_Setting',
+            [t['name'] for t in result['__schema']['queryType']['fields']]
+        )
+        self.assertNotIn(
+            'delete_Profile_OR_Setting',
+            [t['name'] for t in result['__schema']['mutationType']['fields']]
+        )
+        self.assertNotIn(
+            'update_Profile_OR_Setting',
+            [t['name'] for t in result['__schema']['mutationType']['fields']]
+        )
+        self.assertNotIn(
+            'insert_Profile_OR_Setting',
+            [t['name'] for t in result['__schema']['mutationType']['fields']]
+        )