[PATCH cubicweb] [graphql] Implementation of GraphQL

Laurent Wouters lwouters at cenotelie.fr
Tue Jun 5 19:21:43 CEST 2018


# HG changeset patch
# User Laurent Wouters <lwouters at cenotelie.fr>
# Date 1528218558 -7200
#      Tue Jun 05 19:09:18 2018 +0200
# Node ID 0978e5ebc5c4f2ae95818230844345bebfff67e6
# Parent  aa999699e5047da7512ececd8ead42a2f1b15e4b
[graphql] Implementation of GraphQL

Implement the GraphQL query language through the use of the GraphQL and Graphene
librairies. This change provides a GraphQL endpoint accessible through an API
similar to the RQL one. It is also made accessible to the web API with a new
'graphql' AJAX function.

GraphQL queries rely on a type system that is generated at runtime from the
current YAMS schema. Possible queries then look like as follow.

* Reading entities: Personne { firstname, surname }"
* Creating entities: createPersonne(firstname: "john", surname: "doe")
* Deleting entities: deletePersonne(firstname: "john")
* Modifying entities: updatePersonne(old: {firstname: "john"},
        new: {firstname: "jane"})
* Deleting relations between entities: deleteFromPersonne(
        from: {firstname: "john"},
        relation: {})

GraphQL natively supports introspection to query the GraphQL schema. In addition
it is also possible to simply query the stored schema in the same way as RQL.
For example: { CWEType(final: false) { name } }

diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/gql/__init__.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/gql/__init__.py	Tue Jun 05 19:09:18 2018 +0200
@@ -0,0 +1,83 @@
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact at logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""Implementation of GraphQL support for CubicWeb"""
+
+from schema import create_graphql_schema
+from resolver import Resolver, CONNECTION
+from graphql.error import format_error as format_graphql_error, GraphQLError
+import six
+
+
+class GraphQLQuerier(object):
+    """
+    Main entry point for the execution of GraphQL queries
+    """
+
+    def __init__(self, repository, yams_schema):
+        """
+        Initialize this structure
+        :param repository: The parent repository
+        :param yams_schema: The input YAMS schema
+        """
+        # system info helper
+        self._repo = repository
+        self._resolver = Resolver(repository, yams_schema, lambda: self._query_schema)
+        self._query_schema = create_graphql_schema(yams_schema, self._resolver)
+
+    def execute(self, connection, query, **kwargs):
+        """
+        Execute a GraphQL query
+        :param connection: The current connection
+        :param query: The query to execute
+        :param kwargs: The mapping of values for the named variables in the query
+        :return: The GraphQL execution result
+        """
+        graphql_kwargs = {
+            "context_value": {CONNECTION: connection},
+            "variable_values": kwargs
+        }
+        return self._query_schema.execute(query, **graphql_kwargs)
+
+
+def default_format_error(error):
+    """
+    Default formatter for GraphQL errors
+    :param error: The error to format
+    :return: The formatted error
+    """
+    if isinstance(error, GraphQLError):
+        return format_graphql_error(error)
+    return {'message': six.text_type(error)}
+
+
+def format_execution_result(execution_result, format_error=default_format_error):
+    """
+    Format a GraphQL execution result for JSON serialization
+    :param execution_result: The execution result to format
+    :param format_error: The format to use for GraphQL errors
+    :return: The formatted execution result
+    """
+    if execution_result:
+        response = {}
+        if execution_result.errors:
+            response['errors'] = [
+                format_error(e) for e in execution_result.errors
+            ]
+        if not execution_result.invalid:
+            response['data'] = execution_result.data
+        return response
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/gql/resolver.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/gql/resolver.py	Tue Jun 05 19:09:18 2018 +0200
@@ -0,0 +1,458 @@
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact at logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""Implement the resolver for CubicWeb entities"""
+from cubicweb.gql.schema import FIELD_SUB_SEPARATOR, TYPE_FIELD_META, ARG_UPDATE_OLD, ARG_UPDATE_NEW, ARG_DELETE_FROM
+
+CONNECTION = "__connection"  # The key for the current connection in the context of a GraphQL query
+_CACHE = "__cache"  # The key for the object cache in the context of a GraphQL query
+_MODEL = "__model"  # The attribute name that refers to the CubicWeb entity from the GraphQL object instance
+_SUBJECT_ARG = "_subject"  # argument name for the current subject of a query
+_ARG_PREFIX = "arg"  # Prefix for generated query arguments
+_SUBJECT_VAR = "X"  # Name of the first subject in generated RQL queries
+
+
+class Resolver:
+    """The resolver is in charge of fetching the backing data for the GraphQL query execution engine"""
+
+    def __init__(self, repository, yams_schema, graphql_schema):
+        """
+        Initialize the resolver
+        :param repository: The parent repository
+        :param yams_schema: The current YAMS schema
+        :param graphql_schema: The corresponding GraphQL schema
+        """
+        self._repository = repository
+        self._yams_schema = yams_schema
+        self._graphql_schema = graphql_schema
+
+    def get_yams_schema(self):
+        """
+        Get the associated YAMS schema
+        :return: The associated YAMS schema
+        """
+        if callable(self._yams_schema):
+            return self._yams_schema()
+        return self._yams_schema
+
+    def get_query_schema(self):
+        """
+        Get the associated GraphQL query schema
+        :return: The associated GraphQL query schema
+        """
+        if callable(self._graphql_schema):
+            return self._graphql_schema()
+        return self._graphql_schema
+
+    def _resolve_entity(self, cache, entity):
+        """
+        Resolve the GraphQL instance object for the specified entity
+        :param cache: The cache of GraphQL instances for the current query
+        :param entity: The YAMS object instance
+        :return: The GraphQL instance object
+        """
+        type_name = type(entity).__name__
+        graphql_type = self.get_query_schema().get_type(type_name).graphene_type
+        return Resolver._get_graphql_entity(cache, graphql_type, entity)
+
+    @staticmethod
+    def _resolve_entity_from_eid(cache, entity_type, connection, eid):
+        """
+        Resolve the GraphQL instance object for the specified entity EID
+        :param cache: The cache of GraphQL instances for the current query
+        :param entity_type: The GraphQL object type to instantiate for the entity
+        :param connection: The current connection
+        :param eid: The EID of the object to retrieve
+        :return: The GraphQL instance object
+        """
+        try:
+            entity = connection.entity_cache(eid)
+        except KeyError:
+            entity = connection.vreg['etypes'].etype_class(entity_type.__name__)(connection)
+            entity.eid = eid
+            connection.set_entity_cache(entity)
+        return Resolver._get_graphql_entity(cache, entity_type, entity)
+
+    @staticmethod
+    def _get_graphql_entity(cache, entity_type, entity):
+        """
+        Resolve the GraphQL instance object for the specified entity
+        :param cache: The cache of GraphQL instances for the current query
+        :param entity_type: The GraphQL object type to instantiate for the entity
+        :param entity: The YAMS object instance
+        :return: The GraphQL instance object
+        """
+        result = cache.get(entity, None)
+        if result is None:
+            result = entity_type()
+            setattr(result, _MODEL, entity)
+            cache[entity] = result
+        return result
+
+    @staticmethod
+    def _get_cache_for(info):
+        """
+        Get the GraphQL object instance cache for the specified query info
+        :param info: Some complementary GraphQL information
+        :return: The GraphQL object instance cache
+        """
+        result = info.context.get(_CACHE, None)
+        if result is None:
+            result = {}
+            info.context[_CACHE] = result
+        return result
+
+    @staticmethod
+    def _get_constraints(subject, arguments):
+        """
+        Get the constraints to be applied to the subject of a query
+        :param subject: The subject of the constraints
+        :param arguments: The input GraphQL query arguments
+        :return: A tuple of the RQL constraints and new arguments
+        """
+        constraints = []
+        new_arguments = {}
+        Resolver._build_constraints(subject, arguments, constraints, new_arguments)
+        return constraints, new_arguments
+
+    @staticmethod
+    def _build_constraints(subject, restrictions, constraints, new_arguments, sub_start=0):
+        """
+        Build the set of constraints applicable for a RQL query
+        :param subject: The subject of the constraints
+        :param restrictions: The restrictions to be applied on the subject
+        :param constraints: The buffer of resulting constraints
+        :param new_arguments: The map of resulting arguments
+        :param sub_start: Starting index of the generated sub variables for the specified subject
+        :return: The new index of the generated sub variables for the specified subject
+        """
+        sub = sub_start
+        for field, value in restrictions.items():
+            if FIELD_SUB_SEPARATOR in field:
+                field = field[0:field.index(FIELD_SUB_SEPARATOR)]
+            if hasattr(value, TYPE_FIELD_META):
+                # this is a complex value
+                sub_restrictions = {k: v for k, v in value.__dict__.items() if not k.startswith("_") and v is not None}
+                sub_subject = subject + str(chr(ord('A') + sub))
+                sub += 1
+                constraints.append(subject + " " + field + " " + sub_subject)
+                Resolver._build_constraints(sub_subject, sub_restrictions, constraints, new_arguments)
+            else:
+                # simple value
+                arg_name = _ARG_PREFIX + str(len(new_arguments))
+                constraints.append(subject + " " + field + " %(" + arg_name + ")s")
+                new_arguments[arg_name] = value
+        return sub
+
+    def resolve_entities(self, entity_type, query, info, *args, **kwargs):
+        """
+        Fetch entities of the specified type
+        :param entity_type: The GraphQL Object Type for the entity
+        :param query: The GraphQL being resolved
+        :param info: Some complementary GraphQL information
+        :param args: The passed arguments
+        :param kwargs: The query parameters
+        :return: The resolved entities
+        """
+        connection = info.context[CONNECTION]
+        cache = Resolver._get_cache_for(info)
+        if kwargs is None or len(kwargs) == 0:
+            rql = entity_type.__name__ + " " + _SUBJECT_VAR
+            arguments = {}
+        else:
+            rql = entity_type.__name__ + " " + _SUBJECT_VAR + " WHERE"
+            constraints, arguments = Resolver._get_constraints(_SUBJECT_VAR, kwargs)
+            first = True
+            for constraint in constraints:
+                if not first:
+                    rql += " AND"
+                first = False
+                rql += " " + constraint
+        results = connection.execute(rql, arguments)
+        return [self._get_graphql_entity(cache, entity_type, entity) for entity in results.entities()]
+
+    def resolve_primitive_field(self, instance, relation_name, target_type, info, *args, **kwargs):
+        """
+        Fetch the values for an entity's field when its type is primitive
+        :param instance: The entity's instance
+        :param relation_name: The name of the relation for this field
+        :param target_type: The GraphQL type for this field
+        :param info: Some complementary GraphQL information
+        :param args: The passed arguments
+        :param kwargs: The query parameters
+        :return: The resolved value
+        """
+        model = getattr(instance, _MODEL)
+        return getattr(model, relation_name)
+
+    def resolve_scalar_object_field(self, instance, relation_name, target_type, info, *args, **kwargs):
+        """
+        Fetch the values for an entity's field when its type is a single instance of an entity
+        :param instance: The entity's instance
+        :param relation_name: The name of the relation for this field
+        :param target_type: The GraphQL type for this field
+        :param info: Some complementary GraphQL information
+        :param args: The passed arguments
+        :param kwargs: The query parameters
+        :return: The resolved value
+        """
+        return self._resolve_object_field(instance, relation_name, target_type, info, True, *args, **kwargs)
+
+    def resolve_vector_object_field(self, instance, relation_name, target_type, info, *args, **kwargs):
+        """
+        Fetch the values for an entity's field when its type is a collection of instances of other entities
+        :param instance: The entity's instance
+        :param relation_name: The name of the relation for this field
+        :param target_type: The GraphQL type for this field
+        :param info: Some complementary GraphQL information
+        :param args: The passed arguments
+        :param kwargs: The query parameters
+        :return: The resolved value
+        """
+        return self._resolve_object_field(instance, relation_name, target_type, info, False, *args, **kwargs)
+
+    def _resolve_object_field(self, instance, relation_name, target_type, info, scalar, *args, **kwargs):
+        """
+        Fetch the values for an entity's field when its type is another entity
+        :param instance: The entity's instance
+        :param relation_name: The name of the relation for this field
+        :param target_type: The GraphQL type for this field
+        :param info: Some complementary GraphQL information
+        :param scalar: Whether this is a scalar field
+        :param args: The passed arguments
+        :param kwargs: The query parameters
+        :return: The resolved value
+        """
+        model = getattr(instance, _MODEL)
+        cache = Resolver._get_cache_for(info)
+        if kwargs is None or len(kwargs) == 0:
+            if scalar:
+                # Get the single value
+                value = getattr(model, relation_name)
+                if isinstance(value, tuple):
+                    value = value[0]
+                return self._resolve_entity(cache, value)
+            else:
+                # Get all the values
+                values = getattr(model, relation_name)
+                return [self._resolve_entity(cache, x) for x in values]
+        else:
+            # Generate a RQL query and execute it
+            connection = info.context[CONNECTION]
+            rql = "Any " + _SUBJECT_VAR + " WHERE S " + relation_name + " " + _SUBJECT_VAR + " AND S eid %(" + _SUBJECT_ARG + ")s"
+            constraints, arguments = self._get_constraints(_SUBJECT_VAR, kwargs)
+            for constraint in constraints:
+                rql += " AND " + constraint
+            arguments[_SUBJECT_ARG] = model.eid
+            results = connection.execute(rql, arguments)
+            values = [self._resolve_entity(cache, entity) for entity in results.entities()]
+            if scalar:
+                return values[0] if len(values) > 0 else None
+            return values
+
+    @staticmethod
+    def _get_insertion_rql(type_name, arguments):
+        """
+        Generate the RQL statement for an insertion
+        :param type_name: The name of the type of entity to create
+        :param arguments: The arguments for the creation
+        :return: A tuple of the RQL statement and its arguments
+        """
+        fields = []
+        constraints = []
+        new_arguments = {}
+        sub = 0
+        for field, value in arguments.items():
+            if FIELD_SUB_SEPARATOR in field:
+                field = field[0:field.index(FIELD_SUB_SEPARATOR)]
+            if hasattr(value, TYPE_FIELD_META):
+                # this is a complex value
+                sub_restrictions = {k: v for k, v in value.__dict__.items() if not k.startswith("_") and v is not None}
+                sub_subject = _SUBJECT_VAR + str(chr(ord('A') + sub))
+                sub += 1
+                fields.append(_SUBJECT_VAR + " " + field + " " + sub_subject)
+                Resolver._build_constraints(sub_subject, sub_restrictions, constraints, new_arguments)
+            else:
+                # simple value
+                arg_name = _ARG_PREFIX + str(len(new_arguments))
+                fields.append(_SUBJECT_VAR + " " + field + " %(" + arg_name + ")s")
+                new_arguments[arg_name] = value
+        rql = "INSERT " + type_name + " " + _SUBJECT_VAR + ":"
+        for i in range(len(fields)):
+            prefix = ", " if i > 0 else " "
+            rql += prefix + fields[i]
+        if len(constraints) > 0:
+            rql += " WHERE"
+            for i in range(len(constraints)):
+                prefix = " AND " if i > 0 else " "
+                rql += prefix + constraints[i]
+        return rql, new_arguments
+
+    def on_create_entity(self, root, info, entity_type, **kwargs):
+        """
+        Create a new instance of an entity
+        :param root: The GraphQL query root
+        :param info: Some complementary GraphQL information
+        :param entity_type: The GraphQL type to instantiate
+        :param kwargs: The query parameters
+        :return: The instantiated entity
+        """
+        rql, arguments = Resolver._get_insertion_rql(entity_type.__name__, kwargs or {})
+        connection = info.context[CONNECTION]
+        cache = Resolver._get_cache_for(info)
+        results = connection.execute(rql, arguments)
+        values = [Resolver._get_graphql_entity(cache, entity_type, entity) for entity in results.entities()]
+        return values[0] if len(values) > 0 else None
+
+    @staticmethod
+    def _get_update_rql(type_name, arguments):
+        """
+        Generate the RQL statement for an insertion
+        :param type_name: The name of the type of entity to update
+        :param arguments: The arguments for the creation
+        :return: A tuple of the RQL statement and its arguments
+        """
+        fields = []
+        constraints = []
+        new_arguments = {}
+        sub = 0
+
+        # gather constraints for matching current entities
+        sub_restrictions = {k: v for k, v in arguments[ARG_UPDATE_OLD].__dict__.items() if not k.startswith("_") and v is not None}
+        sub = Resolver._build_constraints(_SUBJECT_VAR, sub_restrictions, constraints, new_arguments, sub)
+
+        # gather new fields to set for the matched entities
+        sub_restrictions = {k: v for k, v in arguments[ARG_UPDATE_NEW].__dict__.items() if not k.startswith("_") and v is not None}
+        for field, value in sub_restrictions.items():
+            if FIELD_SUB_SEPARATOR in field:
+                field = field[0:field.index(FIELD_SUB_SEPARATOR)]
+            if hasattr(value, TYPE_FIELD_META):
+                # this is a complex value
+                value_restrictions = {k: v for k, v in value.__dict__.items() if not k.startswith("_") and v is not None}
+                sub_subject = _SUBJECT_VAR + str(chr(ord('A') + sub))
+                sub += 1
+                fields.append(_SUBJECT_VAR + " " + field + " " + sub_subject)
+                Resolver._build_constraints(sub_subject, value_restrictions, constraints, new_arguments)
+            else:
+                # simple value
+                arg_name = _ARG_PREFIX + str(len(new_arguments))
+                fields.append(_SUBJECT_VAR + " " + field + " %(" + arg_name + ")s")
+                new_arguments[arg_name] = value
+
+        rql = "SET"
+        for i in range(len(fields)):
+            prefix = ", " if i > 0 else " "
+            rql += prefix + fields[i]
+        rql += " WHERE " + _SUBJECT_VAR + " is " + type_name
+        for constraint in constraints:
+            rql += " AND " + constraint
+        return rql, new_arguments
+
+    def on_update_entity(self, root, info, entity_type, **kwargs):
+        """
+        Update instances of an entity
+        :param root: The GraphQL query root
+        :param info: Some complementary GraphQL information
+        :param entity_type: The GraphQL Object Type for the entity
+        :param kwargs: The query parameters
+        :return: The updated entities
+        """
+        rql, arguments = Resolver._get_update_rql(entity_type.__name__, kwargs or {})
+        connection = info.context[CONNECTION]
+        cache = Resolver._get_cache_for(info)
+        results = connection.execute(rql, arguments)
+        # no description is built for SET queries
+        return [self._resolve_entity_from_eid(cache, entity_type, connection, results.rows[i][0]) for i in range(len(results))]
+
+    @staticmethod
+    def _get_delete_rql(type_name, arguments):
+        """
+        Generate the RQL statement for a deletion
+        :param type_name: The name of the type of entity to delete
+        :param arguments: The arguments for the creation
+        :return: A tuple of the RQL statement and its arguments
+        """
+        constraints, new_arguments = Resolver._get_constraints(_SUBJECT_VAR, arguments)
+        rql = "DELETE " + type_name + " " + _SUBJECT_VAR
+        for i in range(len(constraints)):
+            rql += " WHERE " if i == 0 else " AND "
+            rql += constraints[i]
+        return rql, new_arguments
+
+    def on_delete_entity(self, root, info, entity_type, **kwargs):
+        """
+        Delete instances of an entity
+        :param root: The GraphQL query root
+        :param info: Some complementary GraphQL information
+        :param entity_type: The GraphQL Object Type for the entity
+        :param kwargs: The query parameters
+        :return: The EID of the delete entities
+        """
+        rql, arguments = Resolver._get_delete_rql(entity_type.__name__, kwargs or {})
+        connection = info.context[CONNECTION]
+        results = connection.execute(rql, arguments)
+        return [row[0] for row in results.rows]
+
+    @staticmethod
+    def _get_delete_relation_rql(type_name, arguments):
+        """
+        Generate the RQL statement for a relation deletion
+        :param type_name: The name of the type of source entity
+        :param arguments: The arguments for the creation
+        :return: A tuple of the RQL statement and its arguments
+        """
+        constraints = []
+        new_arguments = {}
+        to_delete = []
+        sub = 0
+        # gather constraints for matching source entities
+        sub_restrictions = {k: v for k, v in arguments[ARG_DELETE_FROM].__dict__.items() if not k.startswith("_") and v is not None}
+        sub = Resolver._build_constraints(_SUBJECT_VAR, sub_restrictions, constraints, new_arguments, sub)
+
+        for field, value in arguments.items():
+            if field == ARG_DELETE_FROM:
+                continue
+            if FIELD_SUB_SEPARATOR in field:
+                field = field[0:field.index(FIELD_SUB_SEPARATOR)]
+            value_restrictions = {k: v for k, v in value.__dict__.items() if not k.startswith("_") and v is not None}
+            sub_subject = _SUBJECT_VAR + str(chr(ord('A') + sub))
+            sub += 1
+            to_delete.append(_SUBJECT_VAR + " " + field + " " + sub_subject)
+            Resolver._build_constraints(sub_subject, value_restrictions, constraints, new_arguments)
+        rql = "DELETE"
+        for i in range(len(to_delete)):
+            prefix = ", " if i > 0 else " "
+            rql += prefix + to_delete[i]
+
+        rql += " WHERE " + _SUBJECT_VAR + " is " + type_name
+        for constraint in constraints:
+            rql += " AND " + constraint
+        return rql, new_arguments
+
+    def on_delete_relation(self, root, info, entity_type, **kwargs):
+        """
+        Delete relations between entities
+        :param root: The GraphQL query root
+        :param info: Some complementary GraphQL information
+        :param entity_type: The GraphQL Object Type for the source entity
+        :param kwargs: The query parameters
+        :return: The EID of the delete relations
+        """
+        rql, arguments = Resolver._get_delete_relation_rql(entity_type.__name__, kwargs or {})
+        connection = info.context[CONNECTION]
+        connection.execute(rql, arguments)
+        return []
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/gql/schema.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/gql/schema.py	Tue Jun 05 19:09:18 2018 +0200
@@ -0,0 +1,559 @@
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact at logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""Implement the interface of a GraphQL schema for YAMS"""
+
+import binascii
+import graphene
+import graphene.types.datetime
+import graphene.relay
+from graphql.language import ast
+
+TYPE_FIELD_META = "_meta"  # Name of the meta field for a GraphQL object type class
+_TYPE_FIELD_SCHEMA = "__schema"  # Attribute name for the reference to the YAMS entity schema from a GraphQL object type
+FIELD_SUB_SEPARATOR = "_as_"  # Separator between a field name and its type in generated typed sub-fields (e.g. thing_as_Stuff)
+_FIELD_CREATE = "create"  # Prefix for the mutation fields for object creators
+_FIELD_UPDATE = "update"  # Prefix for the mutation fields for object updaters
+_FIELD_DELETE = "delete"  # Prefix for the mutation fields for object removers
+_FIELD_DELETE_FROM = "deleteFrom"  # Prefix for the mutation fields for relation removers
+ARG_UPDATE_OLD = "old"  # Name of the argument in an updater for the values to be matched
+ARG_UPDATE_NEW = "new"  # Name of the argument in an updated for the new value
+ARG_DELETE_FROM = "from"  # Name of the argument in a delete relation for the targeted entities
+_TYPE_META = "Meta"  # Name for the meta-class of a GraphQL object type
+_TYPE_FIELDS = "Fields"  # Suffix for the graphene types of inheritable data
+_TYPE_INPUT = "InputOf"  # Prefix for the graphene type for input types
+_TYPE_CREATOR = "Create"  # Prefix for the graphene type for creator mutation types
+_TYPE_UNION = "Union"  # Prefix for the graphene anonymous union types
+_TYPE_QUERY = "Query"  # Suffix for the graphene type for the top query type
+_TYPE_MUTATION = "Mutation"  # Suffix for the graphene type for the top mutation type
+
+
+class ScalarBytes(graphene.Scalar):
+    """
+    Graphene scalar type for YAMS Bytes
+    """
+
+    @staticmethod
+    def serialize(value):
+        return binascii.hexlify(value)
+
+    @staticmethod
+    def parse_literal(node):
+        if isinstance(node, ast.StringValue):
+            return binascii.unhexlify(node.value)
+
+    @staticmethod
+    def parse_value(value):
+        return binascii.unhexlify(value)
+
+
+class ScalarBigInt(graphene.Scalar):
+    """
+    Graphene scalar type for YAMS BigInt
+    """
+
+    @staticmethod
+    def serialize(value):
+        return str(value)
+
+    @staticmethod
+    def parse_literal(node):
+        if isinstance(node, ast.StringValue):
+            return int(node.value)
+
+    @staticmethod
+    def parse_value(value):
+        return int(value)
+
+
+class ScalarDecimal(graphene.Scalar):
+    """
+    Graphene scalar type for YAMS Decimal
+    """
+
+    @staticmethod
+    def serialize(value):
+        return str(value)
+
+    @staticmethod
+    def parse_literal(node):
+        raise NotImplementedError
+
+    @staticmethod
+    def parse_value(value):
+        raise NotImplementedError
+
+
+class ScalarInterval(graphene.Scalar):
+    """
+    Graphene scalar type for YAMS Interval
+    """
+
+    @staticmethod
+    def serialize(value):
+        return str(value)
+
+    @staticmethod
+    def parse_literal(node):
+        raise NotImplementedError
+
+    @staticmethod
+    def parse_value(value):
+        raise NotImplementedError
+
+
+_PRIMITIVES = {
+    "String": graphene.String,
+    "Password": graphene.String,
+    "Int": graphene.Int,
+    "Float": graphene.Float,
+    "Boolean": graphene.Boolean,
+    "Time": graphene.types.datetime.Time,
+    "Date": graphene.types.datetime.Date,
+    "Datetime": graphene.types.datetime.DateTime,
+    "TZTime": graphene.types.datetime.Time,
+    "TZDatetime": graphene.types.datetime.DateTime,
+    "Bytes": ScalarBytes,
+    "BigInt": ScalarBigInt,
+    "Decimal": ScalarDecimal,
+    "Interval": ScalarInterval
+}
+
+
+class GraphQLSchemaTypes:
+    """
+    Repository of GraphQL types for a schema
+    """
+
+    def __init__(self, schema, resolver):
+        """
+        Initializes this structure
+        :param schema: The input YAMS schema
+        :param resolver: The resolver to use
+        """
+        self.schema = schema
+        self.resolver = resolver
+        # Map of YAMS entity names to their GraphQL representation
+        self.entities = {}
+        # Map of inheritable data structure of fields
+        self.blobs = {}
+        # The known union types
+        self.unions = {}
+        # Map of YAMS entity names to their GraphQL input representation
+        self.inputs = {}
+        # Map of YAMS entity names to their corresponding list of possible arguments
+        self.arguments = {}
+        # Map of YAMS entity names to their corresponding GraphQL updater mutation types
+        self.updaters = {}
+        # All known GraphQL types
+        self.all_types = []
+        # The GraphQL top type for the query
+        self.query_type = None
+        # The GraphQL top type for the mutation
+        self.mutation_type = None
+        # The final GraphQL schema
+        self.graphql_schema = None
+        self._produce()
+
+    def _get_union(self, members):
+        """
+        Get the union type for the specified types
+        :param members: The types (schema entities) in this union
+        :return: The corresponding union type
+        """
+        key = tuple(sorted([member.type for member in members]))
+        if self.unions.has_key(key):
+            return self.unions[key]
+        base_classes = (graphene.Union,)
+        common = GraphQLSchemaTypes._get_common_ancestor(members)
+        if common is not None and common.type + _TYPE_FIELDS in self.blobs:
+            base_classes += (self.blobs[common.type + _TYPE_FIELDS],)
+        member_types = tuple([self.entities[member.type] for member in members])
+        union_members = {_TYPE_META: type(_TYPE_META, (object,), {"types": member_types})}
+        result = type(_TYPE_UNION + str(len(self.unions)), base_classes, union_members)
+        self.unions[key] = result
+        self.all_types.append(result)
+        return result
+
+    @staticmethod
+    def _get_common_ancestor(members):
+        """
+        Get the common ancestor for all the members
+        :param members: The types (schema entities) in this union
+        :return: The common ancestor, if any
+        """
+        ancestries = []
+        for member in members:
+            ancestries.append(GraphQLSchemaTypes._get_ancestry_of(member))
+        min_length = min([len(ancestry) for ancestry in ancestries])
+        result = None
+        for i in range(min_length):
+            first = ancestries[0][i]
+            for j in range(1, len(ancestries)):
+                if ancestries[j][i] != first:
+                    return result
+            # All the same as first
+            result = first
+        return result
+
+    @staticmethod
+    def _get_ancestry_of(entity):
+        """
+        Get the ancestry of this entity, beginning from the most abstract and terminating by this entity
+        :param entity: The entity
+        :return: The ancestry
+        """
+        ancestors = entity.ancestors()
+        result = list(reversed(ancestors))
+        result.append(entity)
+        return result
+
+    def _get_arguments_union(self, members):
+        """
+        Get the arguments for a union type
+        :param members: The types (schema entities) in this union
+        :return: The arguments
+        """
+        result = {}
+        for member in members:
+            result.update(self.arguments[member.type])
+        return result
+
+    def _create_entity_object_type(self, entity_schema):
+        """
+        Create a GraphQL object type for a YAMS entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: Nothing
+        """
+        fields = {  # Data fields
+            _TYPE_FIELD_SCHEMA: entity_schema
+        }
+        for relation in entity_schema.subject_relations():
+            if relation.type == "eid":
+                # eid is treated as the identifier
+                fields["eid"] = graphene.Field(
+                    graphene.ID,
+                    name="eid",
+                    resolver=lambda instance, info, relation_name=relation.type, resolver=self.resolver, *args, **kwargs:
+                    resolver.resolve_primitive_field(instance, relation_name, graphene.ID, info, *args, **kwargs))
+            elif relation.final:
+                # handle a primitive type
+                field_type = relation.objects(entity_schema)[0].type
+                if _PRIMITIVES.has_key(field_type):
+                    target_type = _PRIMITIVES[field_type]
+                    fields[relation.type] = graphene.Field(
+                        target_type,
+                        name=relation.type,
+                        resolver=lambda instance, info, relation_name=relation.type, target_type=target_type, resolver=self.resolver, *args, **kwargs:
+                        resolver.resolve_primitive_field(instance, relation_name, target_type, info, *args, **kwargs))
+            else:
+                # handle a reference to another entity
+                objects = relation.objects(entity_schema)
+                definition = entity_schema.rdef(relation, role="subject", targettype=objects[0])
+                is_multi = definition.cardinality[1] == '+' or definition.cardinality[1] == '*'
+                if len(objects) > 1:
+                    target_type = lambda objects=objects, data=self: data._get_union(objects)
+                    arguments = self._get_arguments_union(objects)
+                else:
+                    target_type = lambda entities=self.entities, n=objects[0].type: entities[n]
+                    arguments = self.arguments[objects[0].type]
+                if is_multi:
+                    fields[relation.type] = graphene.Field(
+                        graphene.List(graphene.NonNull(target_type)),
+                        name=relation.type,
+                        args=arguments,
+                        resolver=lambda instance, info, relation_name=relation.type, target_type=target_type, resolver=self.resolver, *args, **kwargs:
+                        resolver.resolve_vector_object_field(instance, relation_name, target_type(), info, *args, **kwargs))
+                else:
+                    fields[relation.type] = graphene.Field(
+                        target_type,
+                        name=relation.type,
+                        args=arguments,
+                        resolver=lambda instance, info, relation_name=relation.type, target_type=target_type, resolver=self.resolver, *args, **kwargs:
+                        resolver.resolve_scalar_object_field(instance, relation_name, target_type(), info, *args, **kwargs))
+
+        super_entity = entity_schema.specializes()
+        sub_entities = entity_schema.specialized_by()
+        if len(sub_entities) > 0:
+            # Produce a EntityFields graphene AbstractType and a graph ObjectType that inherits from it
+            fields[TYPE_FIELD_META] = type(_TYPE_META, (object,), {"name": entity_schema.type + _TYPE_FIELDS})
+            if super_entity is not None:
+                # Intermediate entity (is a specialization and can be specialized)
+                super_type_fields = self.blobs[super_entity.type + _TYPE_FIELDS] if super_entity is not None else None
+                class_fields = type(entity_schema.type + _TYPE_FIELDS, (super_type_fields,), fields)
+            else:
+                # Can be specialized, but is not a specialization
+                class_fields = type(entity_schema.type + _TYPE_FIELDS, (object,), fields)
+            class_entity = type(entity_schema.type, (graphene.ObjectType, class_fields), {})
+            self.blobs[class_fields.__name__] = class_fields
+            self.entities[class_entity.__name__] = class_entity
+            self.all_types.append(class_entity)
+        elif super_entity is not None:
+            # This is a specialized entity
+            super_type_fields = self.blobs[super_entity.type + _TYPE_FIELDS] if super_entity is not None else None
+            class_entity = type(entity_schema.type, (graphene.ObjectType, super_type_fields,), fields)
+            self.entities[class_entity.__name__] = class_entity
+            self.all_types.append(class_entity)
+        else:
+            # This entity is not a specialization, nor is it specialized
+            class_entity = type(entity_schema.type, (graphene.ObjectType,), fields)
+            self.entities[class_entity.__name__] = class_entity
+            self.all_types.append(class_entity)
+
+    def _create_entity_input_type(self, entity_schema):
+        """
+        Create the GraphQL input type for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: Nothing
+        """
+        fields = {  # Data fields
+            _TYPE_FIELD_SCHEMA: entity_schema
+        }
+        for relation in entity_schema.subject_relations():
+            if relation.type == "eid":
+                # eid is treated as the identifier
+                fields["eid"] = graphene.Field(
+                    graphene.ID,
+                    name="eid")
+            elif relation.final:
+                # handle a primitive type
+                field_type = relation.objects(entity_schema)[0].type
+                if _PRIMITIVES.has_key(field_type):
+                    target_type = _PRIMITIVES[field_type]
+                    fields[relation.type] = graphene.Field(
+                        target_type,
+                        name=relation.type)
+            else:
+                # handle a reference to another entity
+                objects = relation.objects(entity_schema)
+                if len(objects) > 1:
+                    for target_schema in objects:
+                        target_type = lambda inputs=self.inputs, n=_TYPE_INPUT + target_schema.type: inputs[n]
+                        name = relation.type + FIELD_SUB_SEPARATOR + target_schema.type
+                        fields[name] = graphene.Field(
+                            target_type,
+                            name=name)
+                else:
+                    target_type = lambda inputs=self.inputs, n=_TYPE_INPUT + objects[0].type: inputs[n]
+                    fields[relation.type] = graphene.Field(
+                        target_type,
+                        name=relation.type)
+        class_entity = type(_TYPE_INPUT + entity_schema.type, (graphene.InputObjectType,), fields)
+        self.inputs[class_entity.__name__] = class_entity
+        self.all_types.append(class_entity)
+
+    def _create_entity_arguments(self, entity_schema):
+        """
+        Create the GraphQL argument for an input of the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: Nothing
+        """
+        arguments = {}
+        for relation in entity_schema.subject_relations():
+            if relation.type == "eid":
+                # eid is treated as the identifier
+                arguments["eid"] = graphene.Argument(
+                    graphene.ID,
+                    name="eid",
+                    required=False)
+            elif relation.final:
+                # handle a primitive type
+                field_type = relation.objects(entity_schema)[0].type
+                if _PRIMITIVES.has_key(field_type):
+                    target_type = _PRIMITIVES[field_type]
+                    arguments[relation.type] = graphene.Argument(
+                        target_type,
+                        name=relation.type,
+                        required=False)
+            else:
+                # handle a reference to another entity
+                objects = relation.objects(entity_schema)
+                if len(objects) > 1:
+                    for target_schema in objects:
+                        name = relation.type + FIELD_SUB_SEPARATOR + target_schema.type
+                        target_type = lambda inputs=self.inputs, n=_TYPE_INPUT + target_schema.type: inputs[n]
+                        arguments[name] = graphene.Argument(
+                            target_type,
+                            name=name,
+                            required=False)
+                else:
+                    target_type = lambda inputs=self.inputs, n=_TYPE_INPUT + objects[0].type: inputs[n]
+                    arguments[relation.type] = graphene.Argument(
+                        target_type,
+                        name=relation.type,
+                        required=False)
+        self.arguments[entity_schema.type] = arguments
+
+    def _create_entity_query_field(self, entity_schema):
+        """
+        Create the GraphQL field for the query for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: The corresponding field
+        """
+        entity_class = self.entities[entity_schema.type]
+        return graphene.Field(
+            graphene.List(graphene.NonNull(entity_class)),
+            name=entity_schema.type,
+            args=self.arguments[entity_schema.type],
+            resolver=lambda self, info, entity_type=entity_class, resolver=self.resolver, *args, **kwargs:
+            resolver.resolve_entities(entity_type, self, info, *args, **kwargs))
+
+    def _create_query_type(self):
+        """
+        Create the GraphQL query schema
+        :return: Nothing
+        """
+        members = {}
+        for entity in self.schema.entities():
+            if entity.final:
+                continue
+            members[entity.type] = self._create_entity_query_field(entity)
+        self.query_type = type(self.schema.name + _TYPE_QUERY, (graphene.ObjectType,), members)
+
+    def _create_entity_creator_field(self, entity_schema):
+        """
+        Create the GraphQL field for the creation mutation for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: The corresponding field
+        """
+        entity_class = self.entities[entity_schema.type]
+        return graphene.Field(
+            graphene.NonNull(entity_class),
+            name=_FIELD_CREATE + entity_schema.type,
+            args=self.arguments[entity_schema.type],
+            resolver=lambda self, info, entity_type=entity_class, resolver=self.resolver, *args, **kwargs:
+            resolver.on_create_entity(self, info, entity_type, **kwargs))
+
+    def _create_entity_updater_field(self, entity_schema):
+        """
+        Create the GraphQL field for the update mutation for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: The corresponding field
+        """
+        entity_class = self.entities[entity_schema.type]
+        arguments = {
+            ARG_UPDATE_OLD: graphene.Argument(
+                self.inputs[_TYPE_INPUT + entity_schema.type],
+                name=ARG_UPDATE_OLD,
+                required=False
+            ),
+            ARG_UPDATE_NEW: graphene.Argument(
+                self.inputs[_TYPE_INPUT + entity_schema.type],
+                name=ARG_UPDATE_NEW,
+                required=False
+            )
+        }
+        return graphene.Field(
+            graphene.List(graphene.NonNull(entity_class)),
+            name=_FIELD_UPDATE + entity_schema.type,
+            args=arguments,
+            resolver=lambda self, info, entity_type=entity_class, resolver=self.resolver, *args, **kwargs:
+            resolver.on_update_entity(self, info, entity_type, **kwargs))
+
+    def _create_entity_deleter_field(self, entity_schema):
+        """
+        Create the GraphQL field for the removal mutation for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: The corresponding field
+        """
+        entity_class = self.entities[entity_schema.type]
+        return graphene.Field(
+            graphene.List(graphene.ID),
+            name=_FIELD_DELETE + entity_schema.type,
+            args=self.arguments[entity_schema.type],
+            resolver=lambda self, info, entity_type=entity_class, resolver=self.resolver, *args, **kwargs:
+            resolver.on_delete_entity(self, info, entity_type, **kwargs))
+
+    def _create_entity_relation_deleter_field(self, entity_schema):
+        """
+        Create the GraphQL field for the removal mutation for the specified entity
+        :param entity_schema: The schema of a YAMS entity
+        :return: The corresponding field
+        """
+        entity_class = self.entities[entity_schema.type]
+        arguments = {
+            ARG_DELETE_FROM: graphene.Argument(
+                self.inputs[_TYPE_INPUT + entity_schema.type],
+                name=ARG_DELETE_FROM,
+                required=False
+            ),
+        }
+        for name, arg in self.arguments[entity_schema.type].items():
+            arg_type = arg.type
+            if name == "eid" or arg_type in _PRIMITIVES.values():
+                # primitive field
+                continue
+            arguments[name] = arg
+        return graphene.Field(
+            graphene.List(graphene.ID),
+            name=_FIELD_DELETE_FROM + entity_schema.type,
+            args=arguments,
+            resolver=lambda self, info, entity_type=entity_class, resolver=self.resolver, *args, **kwargs:
+            resolver.on_delete_relation(self, info, entity_type, **kwargs))
+
+    def _create_mutation_type(self):
+        """
+        Create the GraphQL mutation schema
+        :return: Nothing
+        """
+        members = {}
+        for entity in self.schema.entities():
+            if entity.final:
+                continue
+            members[_FIELD_CREATE + entity.type] = self._create_entity_creator_field(entity)
+            members[_FIELD_UPDATE + entity.type] = self._create_entity_updater_field(entity)
+            members[_FIELD_DELETE + entity.type] = self._create_entity_deleter_field(entity)
+            members[_FIELD_DELETE_FROM + entity.type] = self._create_entity_relation_deleter_field(entity)
+        self.mutation_type = type(self.schema.name + _TYPE_MUTATION, (graphene.ObjectType,), members)
+
+    def _produce(self):
+        """
+        Create the Graphene schema for the specified YAMS schema
+        :return: The corresponding GraphQL query and mutation schemas
+        """
+        for entity in self.schema.entities():
+            if not entity.final:
+                self._create_entity_input_type(entity)
+                self._create_entity_arguments(entity)
+
+        yams_types = [entity for entity in self.schema.entities() if not entity.final]
+        remainings = yams_types
+        while len(remainings) > 0:
+            yams_types = remainings
+            remainings = []
+            for entity in yams_types:
+                specialization_of = entity.specializes()
+                if specialization_of is None or specialization_of.type in self.entities:
+                    # We can resolve this type
+                    self._create_entity_object_type(entity)
+                else:
+                    # Cannot resolve yet => wait for the super-type
+                    remainings.append(entity)
+        self._create_query_type()
+        self._create_mutation_type()
+        self.graphql_schema = graphene.Schema(query=self.query_type, mutation=self.mutation_type, auto_camelcase=False, types=self.all_types)
+
+
+def create_graphql_schema(schema, resolver):
+    """
+    Create the Graphene schema for the specified YAMS schema
+    :param schema: The YAMS schema
+    :param resolver: The resolver to be used
+    :return: The corresponding GraphQL schema
+    """
+    factory = GraphQLSchemaTypes(schema, resolver)
+    return factory.graphql_schema
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/server/repository.py
--- a/cubicweb/server/repository.py	Thu May 03 16:47:51 2018 +0200
+++ b/cubicweb/server/repository.py	Tue Jun 05 19:09:18 2018 +0200
@@ -49,6 +49,7 @@
 from cubicweb.server import utils, hook, querier, sources
 from cubicweb.server.session import InternalManager, Connection
 
+from cubicweb.gql import GraphQLQuerier
 
 NO_CACHE_RELATIONS = set([
     ('owned_by', 'object'),
@@ -235,6 +236,8 @@
         self._type_cache = {}
         # the hooks manager
         self.hm = hook.HooksManager(self.vreg)
+        # initialize gql
+        self.graphql = None
 
     def bootstrap(self):
         self.info('starting repository from %s', self.config.apphome)
@@ -382,6 +385,7 @@
         else:
             self.vreg._set_schema(schema)
         self.querier.set_schema(schema)
+        self.graphql = GraphQLQuerier(self, schema)
         self.system_source.set_schema(schema)
         self.schema = schema
 
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/server/session.py
--- a/cubicweb/server/session.py	Thu May 03 16:47:51 2018 +0200
+++ b/cubicweb/server/session.py	Tue Jun 05 19:09:18 2018 +0200
@@ -721,6 +721,16 @@
         return rset
 
     @_open_only
+    def execute_graphql(self, query, **kwargs):
+        """
+        Executes a GraphQL request
+        :param query: The query to execute
+        :param kwargs: The mapping of values for the named variables in the query
+        :return: The GraphQL execution result
+        """
+        return self.repo.graphql.execute(self, query, **kwargs)
+
+    @_open_only
     def rollback(self, free_cnxset=None, reset_pool=None):
         """rollback the current transaction"""
         if free_cnxset is not None:
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/server/test/unittest_graphql.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/server/test/unittest_graphql.py	Tue Jun 05 19:09:18 2018 +0200
@@ -0,0 +1,619 @@
+# copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact at logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""Unit tests for GraphQL security"""
+from cubicweb import Unauthorized, QueryError
+from cubicweb.devtools.testlib import CubicWebTC
+
+
+class GraphQLBaseTC(CubicWebTC):
+    """
+    Base test cases for GraphQL security
+    """
+
+    def setup_database(self):
+        super(GraphQLBaseTC, self).setup_database()
+        with self.admin_access.client_cnx() as cnx:
+            self.create_user(cnx, u"iaminusersgrouponly")
+            self.affaire1 = cnx.execute("INSERT Affaire X: X sujet 'affaire1'")[0][0]
+            self.affaire2 = cnx.execute("INSERT Affaire X: X sujet 'affaire2'")[0][0]
+            cnx.commit()
+
+
+class GraphQLSecurityTC(CubicWebTC):
+    """
+    Suite of security tests for GraphQL (adapted from RQL security test suite)
+    """
+
+    def gql_ok(self, connection, query, **kwargs):
+        """
+        Execute a GraphQL query and assert there was no error
+        :param connection: The current connection
+        :param query: The query
+        :param kwargs: The arguments
+        :return: The query's result
+        """
+        result = connection.execute_graphql(query, **kwargs)
+        self.assertIsNotNone(result.data)
+        self.assertIsNone(result.errors)
+        return result
+
+    def gql_nok(self, connection, query, **kwargs):
+        """
+        Execute a GraphQL query and assert there was an error
+        :param connection: The current connection
+        :param query: The query
+        :param kwargs: The arguments
+        :return: The query's result
+        """
+        result = connection.execute_graphql(query, **kwargs)
+        self.assertIsNotNone(result.errors)
+        self.assertTrue(len(result.errors) >= 1)
+        return result
+
+    def setUp(self):
+        super(GraphQLSecurityTC, self).setUp()
+        # implicitly test manager can add some entities
+        with self.admin_access.repo_cnx() as cnx:
+            self.create_user(cnx, u"iaminusersgrouponly")
+            cnx.execute("INSERT Affaire X: X sujet 'cool'")
+            cnx.execute("INSERT Societe X: X nom 'logilab'")
+            cnx.execute("INSERT Personne X: X nom 'bidule'")
+            cnx.execute('INSERT CWGroup X: X name "staff"')
+            cnx.commit()
+
+    def test_simple_read(self):
+        query = "{ Personne { nom, prenom, sexe } }"
+        with self.admin_access.repo_cnx() as cnx:
+            result = self.gql_ok(cnx, query)
+            self.assertEquals(len(result.data["Personne"]), 1)
+
+    def test_empty_when_cannot_read_simple(self):
+        query = "{ Affaire { sujet } }"
+        sujet = self.repo.schema["Affaire"].rdef("sujet")
+        with self.temporary_permissions((sujet, {'read': ('users', 'managers')})):
+            with self.admin_access.repo_cnx() as cnx:
+                # Reading is OK for the manager
+                result = self.gql_ok(cnx, query)
+                self.assertEquals(len(result.data["Affaire"]), 1)
+            with self.new_access(u'anon').repo_cnx() as cnx:
+                # Reading is not OK for anonymous
+                result = self.gql_ok(cnx, query)
+                self.assertEquals(len(result.data["Affaire"]), 0)
+
+    def test_empty_when_cannot_read_with_args(self):
+        query = "query getAffaire($sujet: String) { Affaire(sujet: $sujet) { sujet } }"
+        sujet = self.repo.schema["Affaire"].rdef("sujet")
+        with self.temporary_permissions((sujet, {'read': ('users', 'managers')})):
+            with self.admin_access.repo_cnx() as cnx:
+                # Reading is OK for the manager
+                result = self.gql_ok(cnx, query, sujet="cool")
+                self.assertEquals(len(result.data["Affaire"]), 1)
+            with self.new_access(u'anon').repo_cnx() as cnx:
+                # Reading is not OK for anonymous
+                result = self.gql_nok(cnx, query, sujet="cool")
+                self.assertIsNone(result.data["Affaire"])
+
+    def test_upassword_not_selectable(self):
+        query = "{ CWUser { login, upassword } }"
+        with self.admin_access.repo_cnx() as cnx:
+            result = self.gql_ok(cnx, query)
+            self.assertEqual(len(result.data["CWUser"]), 3)
+            for user in result.data["CWUser"]:
+                self.assertIsNone(user["upassword"])
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            result = self.gql_ok(cnx, query)
+            self.assertEqual(len(result.data["CWUser"]), 3)
+            for user in result.data["CWUser"]:
+                self.assertIsNone(user["upassword"])
+
+    def test_read_erqlexpr(self):
+        """
+        Test the application of security constraints expressed with RQL expressions
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            aff1 = cnx.execute("INSERT Affaire X: X sujet 'cool'")[0][0]
+            card1 = cnx.execute("INSERT Card X: X title 'cool'")[0][0]
+            cnx.execute('SET X owned_by U WHERE X eid %(x)s, U login "iaminusersgrouponly"', {'x': card1})
+            cnx.commit()
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            aff2 = cnx.execute("INSERT Affaire X: X sujet 'cool'")[0][0]
+            soc1 = cnx.execute("INSERT Societe X: X nom 'chouette'")[0][0]
+            cnx.execute("SET A concerne S WHERE A eid %(a)s, S eid %(s)s", {'a': aff2, 's': soc1})
+            cnx.commit()
+            result = self.gql_nok(cnx, "query getStuff($x: ID) { Affaire(eid: $x) {eid, sujet} }", x=aff1)
+            self.assertIsNone(result.data["Affaire"])
+            result = self.gql_ok(cnx, "query getStuff($x: ID) { Affaire(eid: $x) {eid, sujet} }", x=aff2)
+            self.assertEquals(len(result.data["Affaire"]), 1)
+            self.assertEquals(result.data["Affaire"][0]["eid"], str(aff2))
+            result = self.gql_ok(cnx, "query getStuff($x: ID) { Card(eid: $x) {eid, title} }", x=card1)
+            self.assertEquals(len(result.data["Card"]), 1)
+            self.assertEquals(result.data["Card"][0]["eid"], str(card1))
+
+    def test_insert_security(self):
+        with self.new_access(u'anon').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($nom: String) { createPersonne(nom: $nom) {  nom  } }", nom=u"bidule")
+            self.assertRaises(Unauthorized, cnx.commit)
+            self.assertEqual(len(self.gql_ok(cnx, "{ Personne { nom } }").data["Personne"]), 1)
+
+    def test_insert_security_2(self):
+        with self.new_access(u'anon').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($nom: String) { createAffaire(sujet: $nom) {  sujet  } }", nom=u"bidule")
+            self.assertRaises(Unauthorized, cnx.commit)
+            # anon has no read permission on Affaire entities, so
+            self.assertEqual(len(self.gql_ok(cnx, "{ Affaire { sujet } }").data["Affaire"]), 0)
+
+    def test_insert_rql_permission(self):
+        # test user can only add une affaire related to a societe he owns
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($nom: String) { createAffaire(sujet: $nom) {  sujet  } }", nom=u"bidule")
+            self.assertRaises(Unauthorized, cnx.commit)
+        # test nothing has actually been inserted
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertEqual(len(self.gql_ok(cnx, "{ Affaire { sujet } }").data["Affaire"]), 1)
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($nom: String) { createAffaire(sujet: $nom) {  sujet  } }", nom=u"cool")
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) {  nom  } }", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($affaire: String, $societe: String) { updateAffaire(old: {sujet: $affaire}, new: {concerne_as_Societe: {nom: $societe}}) { sujet } }", affaire=u"cool", societe=u"chouette")
+            cnx.commit()
+
+    def test_update_security_1(self):
+        with self.new_access(u'anon').repo_cnx() as cnx:
+            # local security check
+            self.gql_ok(cnx, "mutation m($nom: String) { updatePersonne(old: {}, new: {nom: $nom}) { nom } }", nom=u"bidulechouette")
+            self.assertRaises(Unauthorized, cnx.commit)
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertEqual(len(self.gql_ok(cnx, "query q($nom: String) { Personne(nom: $nom) { nom } }", nom=u"bidulechouette").data["Personne"]), 0)
+
+    def test_update_security_2(self):
+        with self.temporary_permissions(Personne={'read': ('users', 'managers'),
+                                                  'add': ('guests', 'users', 'managers')}):
+            with self.new_access(u'anon').repo_cnx() as cnx:
+                self.gql_nok(cnx, "mutation m($nom: String) { updatePersonne(old: {}, new: {nom: $nom}) { nom } }", nom=u"bidulechouette")
+        # test nothing has actually been inserted
+        with self.admin_access.repo_cnx() as cnx:
+            self.assertEqual(len(self.gql_ok(cnx, "query q($nom: String) { Personne(nom: $nom) { nom } }", nom=u"bidulechouette").data["Personne"]), 0)
+
+    def test_update_security_3(self):
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($nom: String) { createPersonne(nom: $nom) {  nom  } }", nom=u"biduuule")
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) {  nom  } }", nom=u"looogilab")
+            self.gql_ok(cnx, "mutation m($personne: String, $societe: String) { updatePersonne(old: {nom: $personne}, new: {travaille_as_Societe: {nom: $societe}}) { nom } }", personne=u"biduuule", societe=u"looogilab")
+
+    def test_update_rql_permission(self):
+        with self.admin_access.repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation { updateAffaire(old: {}, new: {concerne_as_Societe: {}}) {sujet} }")
+            cnx.commit()
+        # test user can only update une affaire related to a societe he owns
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation m($sujet:String) { updateAffaire(old: {}, new: {sujet: $sujet}) {sujet} }", sujet=u"pascool")
+            # this won't actually do anything since the selection query won't return anything
+            cnx.commit()
+            # to actually get Unauthorized exception, try to update an entity we can read
+            self.gql_ok(cnx, "mutation m($nom: String) { updateSociete(old: {}, new: {nom: $nom}) {nom} }", nom=u"toto")
+            self.assertRaises(Unauthorized, cnx.commit)
+            self.gql_ok(cnx, "mutation m($sujet: String) { createAffaire(sujet: $sujet) { sujet } }", sujet=u"pascool")
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) { nom } }", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($sujet:String, $nom:String) { updateAffaire(old: {sujet: $sujet}, new: {concerne_as_Societe: {nom: $nom}}) {sujet} }", sujet=u"pascool", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($n1:String, $n2:String) { updateAffaire(old: {sujet: $n1}, new: {sujet: $n2}) {sujet} }", n1=u"pascool", n2=u"habahsicestcool")
+            cnx.commit()
+
+    def test_delete_security(self):
+        # check local security
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_nok(cnx, "mutation m($n:String) { deleteCWGroup(name: $n) }", n=u"staff")
+
+    def test_delete_rql_permission(self):
+        with self.admin_access.repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation { updateAffaire(old: {}, new: {concerne_as_Societe: {}}) {sujet} }")
+            cnx.commit()
+        # test user can only dele une affaire related to a societe he owns
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            # this won't actually do anything since the selection query won't return anything
+            self.gql_ok(cnx, "mutation { deleteAffaire }")
+            cnx.commit()
+            # to actually get Unauthorized exception, try to delete an entity we can read
+            self.gql_nok(cnx, "mutation { deleteSociete }")
+            self.assertRaises(QueryError, cnx.commit)  # can't commit anymore
+            cnx.rollback()
+            self.gql_ok(cnx, "mutation m($sujet: String) { createAffaire(sujet: $sujet) { sujet } }", sujet=u"pascool")
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) { nom } }", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($sujet:String, $nom:String) { updateAffaire(old: {sujet: $sujet}, new: {concerne_as_Societe: {nom: $nom}}) {sujet} }", sujet=u"pascool", nom=u"chouette")
+            cnx.commit()
+            self.gql_ok(cnx, "mutation m($sujet: String) { deleteAffaire(sujet: $sujet) }", sujet=u"pascool")
+            cnx.commit()
+
+    def test_insert_relation_rql_permission(self):
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation { updateAffaire(old: {}, new: {concerne_as_Societe: {}}) {sujet} }")
+            # should raise Unauthorized since user don't own S though this won't
+            # actually do anything since the selection query won't return
+            # anything
+            cnx.commit()
+            # to actually get Unauthorized exception, try to insert a relation
+            # were we can read both entities
+            rset = cnx.execute('Personne P')
+            self.assertEqual(len(rset), 1)
+            ent = rset.get_entity(0, 0)
+            self.assertRaises(Unauthorized, ent.cw_check_perm, 'update')
+
+            result = self.gql_ok(cnx, "{ Personne(travaille_as_Societe: {}) { eid } }")
+            self.assertEquals(len(result.data["Personne"]), 0)
+            self.gql_nok(cnx, "mutation { updatePersonne(old: {}, new: {travaille_as_Societe: {}}) { eid } }")
+            self.assertRaises(QueryError, cnx.commit)  # can't commit anymore
+            cnx.rollback()
+            # test nothing has actually been inserted:
+            result = self.gql_ok(cnx, "{ Personne(travaille_as_Societe: {}) { eid } }")
+            self.assertEquals(len(result.data["Personne"]), 0)
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) {nom} }", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($nom: String) { updateAffaire(old: {}, new: {concerne_as_Societe: {nom: $nom}}) {sujet} }", nom=u"chouette")
+            cnx.commit()
+
+    def test_delete_relation_rql_permission(self):
+        with self.admin_access.repo_cnx() as cnx:
+            self.gql_ok(cnx, "mutation { updateAffaire(old: {}, new: {concerne_as_Societe: {}}) {sujet} }")
+            cnx.commit()
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            # this won't actually do anything since the selection query won't return anything
+            self.gql_ok(cnx, "mutation { deleteAffaire(concerne_as_Societe: {}) }")
+            cnx.commit()
+        with self.admin_access.repo_cnx() as cnx:
+            # to actually get Unauthorized exception, try to delete a relation we can read
+            result = self.gql_ok(cnx, "mutation m($sujet: String) { createAffaire(sujet: $sujet) { eid, sujet } }", sujet=u"pascool")
+            eid = result.data["createAffaire"]["eid"]
+            self.gql_ok(cnx, "mutation m($eid: ID, $login:String) { updateAffaire(old: {eid: $eid}, new: {owned_by: {login: $login}}) {sujet} }", eid=eid, login=u"iaminusersgrouponly")
+            self.gql_ok(cnx, "mutation m($sujet: String) { updateAffaire(old: {sujet: $sujet}, new: {concerne_as_Societe: {}}) {sujet} }", sujet=u"pascool")
+            cnx.commit()
+        with self.new_access(u'iaminusersgrouponly').repo_cnx() as cnx:
+            self.gql_nok(cnx, "mutation { deleteFromAffaire(from: {}, concerne_as_Societe: {}) }")
+            self.assertRaises(QueryError, cnx.commit)  # can't commit anymore
+            cnx.rollback()
+            self.gql_ok(cnx, "mutation m($nom: String) { createSociete(nom: $nom) { nom } }", nom=u"chouette")
+            self.gql_ok(cnx, "mutation m($nom: String) { updateAffaire(old: {}, new: {concerne_as_Societe: {nom: $nom}}) {sujet} }", nom=u"chouette")
+            cnx.commit()
+            self.gql_ok(cnx, "mutation m($nom: String) { deleteFromAffaire(from: {}, concerne_as_Societe: {nom: $nom}) }", nom=u"chouette")
+            cnx.commit()
+
+
+class GraphQLFeatureTC(GraphQLBaseTC):
+    """
+    Suite of feature tests for GraphQL
+    """
+
+    def test_simple_query_no_arg(self):
+        """
+        Test simple querying without arguments
+        """
+        query = "{ Affaire { sujet } }"
+        with self.admin_access.repo_cnx() as cnx:
+            # Reading is OK for the manager
+            result = cnx.execute_graphql(query)
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Affaire"]), 2)
+
+    def test_simple_query_with_eid_arg(self):
+        """
+        Test simple querying with argument
+        """
+        query = "query getAffaire($affaire_eid: ID) { Affaire(eid: $affaire_eid) { sujet } }"
+        with self.admin_access.repo_cnx() as cnx:
+            # Reading is OK for the manager
+            result = cnx.execute_graphql(query, affaire_eid=self.affaire1)
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Affaire"]), 1)
+            self.assertEquals(result.data["Affaire"][0]["sujet"], "affaire1")
+            result = cnx.execute_graphql(query, affaire_eid=self.affaire2)
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Affaire"]), 1)
+            self.assertEquals(result.data["Affaire"][0]["sujet"], "affaire2")
+
+    def test_simple_query_with_eid_arg_double(self):
+        """
+        Test querying several times the same entities with different arguments
+        """
+        query = "query getAffaire($eid1: ID, $eid2: ID) { affaire1: Affaire(eid: $eid1) { sujet }, affaire2: Affaire(eid: $eid2) { sujet } }"
+        with self.admin_access.repo_cnx() as cnx:
+            # Reading is OK for the manager
+            result = cnx.execute_graphql(query, eid1=self.affaire1, eid2=self.affaire2)
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(result.data["affaire1"][0]["sujet"], "affaire1")
+            self.assertEquals(result.data["affaire2"][0]["sujet"], "affaire2")
+
+    def test_nested_query_arguments(self):
+        """
+        Test querying with arguments on fields on more than one nesting levels
+        """
+        query = "query getPersonne($pn: String, $sn: String) { Personne(nom: $pn) { nom, travaille(nom: $sn) { ... on Societe { nom } } } }"
+        with self.admin_access.repo_cnx() as cnx:
+            cnx.execute("INSERT Personne X: X nom 'personne1'")
+            cnx.execute("INSERT Personne X: X nom 'personne2'")
+            cnx.execute("INSERT Societe X: X nom 'societe1'")
+            cnx.execute("INSERT Societe X: X nom 'societe2'")
+            cnx.execute("SET P travaille S WHERE P nom 'personne1', S nom 'societe1'")
+            cnx.execute("SET P travaille S WHERE P nom 'personne1', S nom 'societe2'")
+            result = cnx.execute_graphql(query, pn="personne1", sn="societe1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Personne"]), 1)
+            self.assertEquals(result.data["Personne"][0]["nom"], "personne1")
+            self.assertEquals(len(result.data["Personne"][0]["travaille"]), 1)
+            self.assertEquals(result.data["Personne"][0]["travaille"][0]["nom"], "societe1")
+
+    def test_complex_query_arguments(self):
+        """
+        Test querying with an object argument
+        """
+        query = "query getPersonne($nom: String) { Personne(travaille_as_Societe: {nom: $nom}) { nom, travaille { ... on Societe { nom } } } }"
+        with self.admin_access.repo_cnx() as cnx:
+            cnx.execute("INSERT Personne X: X nom 'personne1'")
+            cnx.execute("INSERT Personne X: X nom 'personne2'")
+            cnx.execute("INSERT Societe X: X nom 'societe1'")
+            cnx.execute("INSERT Societe X: X nom 'societe2'")
+            cnx.execute("SET P travaille S WHERE P nom 'personne1', S nom 'societe1'")
+            cnx.execute("SET P travaille S WHERE P nom 'personne2', S nom 'societe2'")
+            result = cnx.execute_graphql(query, nom="societe1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Personne"]), 1)
+            self.assertEquals(result.data["Personne"][0]["nom"], "personne1")
+            self.assertEquals(len(result.data["Personne"][0]["travaille"]), 1)
+            self.assertEquals(result.data["Personne"][0]["travaille"][0]["nom"], "societe1")
+
+    def test_argument_on_scalar_field(self):
+        """
+        Test querying with an argument in a scalar primitive field
+        """
+        query = "query getPersonne($title: String) { Personne { nom, fiche(title: $title) { title, content } } }"
+        with self.admin_access.repo_cnx() as cnx:
+            cnx.execute("INSERT Personne X: X nom 'personne1'")
+            cnx.execute("INSERT Personne X: X nom 'personne2'")
+            cnx.execute("INSERT Card X: X title 'fiche1', X content 'fiche1'")
+            cnx.execute("INSERT Card X: X title 'fiche2', X content 'fiche2'")
+            cnx.execute("SET P fiche C WHERE P nom 'personne1' AND C title 'fiche1'")
+            cnx.execute("SET P fiche C WHERE P nom 'personne2' AND C title 'fiche2'")
+            result = cnx.execute_graphql(query, title="fiche1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["Personne"]), 2)
+            self.assertEquals(result.data["Personne"][0]["nom"], "personne1")
+            self.assertEquals(result.data["Personne"][0]["fiche"]["title"], "fiche1")
+            self.assertEquals(result.data["Personne"][1]["nom"], "personne2")
+            self.assertEquals(result.data["Personne"][1]["fiche"], None)
+
+    def test_mutation_insert_simple(self):
+        """
+        Test simple insertion of a new entity with primitive fields
+        """
+        query = "mutation myMutation($sujet: String) { createAffaire(sujet: $sujet) {  eid, sujet  } }"
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql(query, sujet=u"cool")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["createAffaire"]), 2)
+            self.assertIsNotNone(result.data["createAffaire"]["eid"])
+            self.assertEquals(result.data["createAffaire"]["sujet"], u"cool")
+
+    def test_mutation_insert_with_where(self):
+        """
+        Test insertion of a new entity with a reference to a existing one looked-up based on its attributes
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) { eid } }", nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation myMutation($sujet: String, $societe: String) { "
+                                         "createAffaire(sujet: $sujet, concerne_as_Societe: {nom: $societe}) { "
+                                         "eid, sujet, concerne { ... on Societe { eid, nom } } } }",
+                                         sujet=u"affaire1", societe=u"societe1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["createAffaire"]), 3)
+            self.assertEquals(result.data["createAffaire"]["sujet"], u"affaire1")
+            self.assertEquals(len(result.data["createAffaire"]["concerne"]), 1)
+            self.assertEquals(result.data["createAffaire"]["concerne"][0]["nom"], u"societe1")
+            self.assertEquals(result.data["createAffaire"]["concerne"][0]["eid"], societe1)
+
+    def test_mutation_update_simple(self):
+        """
+        Test simple update of an entity with primitive fields
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation myMutation($nom_old: String, $nom_new: String) { updateSociete(old: {nom: $nom_old}, new: {nom: $nom_new}) { eid, nom } }",
+                                         nom_old=u"societe1",
+                                         nom_new=u"societe2")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["updateSociete"]), 1)
+            self.assertEquals(len(result.data["updateSociete"][0]), 2)
+            self.assertEquals(result.data["updateSociete"][0]["eid"], societe1)
+            self.assertEquals(result.data["updateSociete"][0]["nom"], u"societe2")
+
+    def test_mutation_update_object_field(self):
+        """
+        Test update of an entity with a new value for an object field
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe2")
+            societe2 = result.data["createSociete"]["eid"]
+
+            result = cnx.execute_graphql("mutation myMutation($sujet: String, $societe: String) { updateAffaire("
+                                         "old: {sujet: $sujet}"
+                                         ", new: {concerne_as_Societe: {nom: $societe}}) {"
+                                         "eid, sujet, concerne { ... on Societe {eid, nom} } } }",
+                                         sujet=u"affaire1",
+                                         societe=u"societe1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["updateAffaire"]), 1)
+            self.assertEquals(len(result.data["updateAffaire"][0]), 3)
+            self.assertEquals(result.data["updateAffaire"][0]["eid"], str(self.affaire1))
+            self.assertEquals(result.data["updateAffaire"][0]["sujet"], u"affaire1")
+            self.assertEquals(len(result.data["updateAffaire"][0]["concerne"]), 1)
+            self.assertEquals(result.data["updateAffaire"][0]["concerne"][0]["eid"], societe1)
+            self.assertEquals(result.data["updateAffaire"][0]["concerne"][0]["nom"], u"societe1")
+
+            result = cnx.execute_graphql("mutation myMutation($sujet: String, $societe: String) { updateAffaire("
+                                         "old: {sujet: $sujet}"
+                                         ", new: {concerne_as_Societe: {nom: $societe}}) {"
+                                         "eid, sujet, concerne { ... on Societe {eid, nom} } } }",
+                                         sujet=u"affaire2",
+                                         societe=u"societe2")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["updateAffaire"]), 1)
+            self.assertEquals(len(result.data["updateAffaire"][0]), 3)
+            self.assertEquals(result.data["updateAffaire"][0]["eid"], str(self.affaire2))
+            self.assertEquals(result.data["updateAffaire"][0]["sujet"], u"affaire2")
+            self.assertEquals(len(result.data["updateAffaire"][0]["concerne"]), 1)
+            self.assertEquals(result.data["updateAffaire"][0]["concerne"][0]["eid"], societe2)
+            self.assertEquals(result.data["updateAffaire"][0]["concerne"][0]["nom"], u"societe2")
+
+    def test_mutation_delete_simple_single(self):
+        """
+        Test delete a single entity on a simple query
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe2")
+            societe2 = result.data["createSociete"]["eid"]
+
+            result = cnx.execute_graphql("mutation myMutation($societe: String) { deleteSociete(nom: $societe) }",
+                                         societe=u"societe1")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["deleteSociete"]), 1)
+            self.assertEquals(result.data["deleteSociete"][0], societe1)
+
+    def test_mutation_delete_simple_multiple(self):
+        """
+        Test delete a multiple entities on a simple query
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe2")
+            societe2 = result.data["createSociete"]["eid"]
+
+            result = cnx.execute_graphql("mutation myMutation { deleteSociete }")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["deleteSociete"]), 2)
+            self.assertEquals(result.data["deleteSociete"][0], societe1)
+            self.assertEquals(result.data["deleteSociete"][1], societe2)
+
+    def test_mutation_delete_relation(self):
+        """
+        Test delete relations
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("mutation myMutation($nom: String) { createSociete(nom: $nom) {  eid  } }",
+                                         nom=u"societe1")
+            societe1 = result.data["createSociete"]["eid"]
+            result = cnx.execute_graphql("mutation m($s: ID, $a: ID) { updateAffaire(old: {eid: $a}, new: {concerne_as_Societe: {eid: $s}}) {eid, concerne {... on Societe {eid}}} }", s=societe1, a=self.affaire1)
+            self.assertEquals(result.data["updateAffaire"][0]["eid"], str(self.affaire1))
+            self.assertEquals(result.data["updateAffaire"][0]["concerne"][0]["eid"], str(societe1))
+            result = cnx.execute_graphql("{ Affaire { eid, concerne { ... on Societe { eid } } } }")
+            self.assertEquals(result.data["Affaire"][0]["eid"], str(self.affaire1))
+            self.assertEquals(result.data["Affaire"][0]["concerne"][0]["eid"], str(societe1))
+            result = cnx.execute_graphql("mutation m($s: ID, $a: ID) { deleteFromAffaire(from: {eid: $a}, concerne_as_Societe: {eid: $s}) }", s=societe1, a=self.affaire1)
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            result = cnx.execute_graphql("{ Affaire { eid, concerne { ... on Societe { eid } } } }")
+            self.assertEquals(result.data["Affaire"][0]["eid"], str(self.affaire1))
+            self.assertEquals(len(result.data["Affaire"][0]["concerne"]), 0)
+            pass
+
+
+class GraphQLReflectionTC(GraphQLBaseTC):
+    """
+    Test suite for GraphQL reflection features
+    """
+
+    def test_graphql_based_reflection_list_entities(self):
+        """
+        Test listing entity types using GraphQL-based reflection
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("{ __schema { queryType { fields { name } } } }")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["__schema"]["queryType"]["fields"]), 45)
+            found_affaire = False
+            for t in result.data["__schema"]["queryType"]["fields"]:
+                if t["name"] == "Affaire":
+                    found_affaire = True
+            self.assertTrue(found_affaire)
+
+    def test_cw_based_reflection_list_entities(self):
+        """
+        Test listing entity types using CubicWeb-based reflection
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("{ CWEType(final: false) { name } }")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["CWEType"]), 45)
+            found_affaire = False
+            for t in result.data["CWEType"]:
+                if t["name"] == "Affaire":
+                    found_affaire = True
+            self.assertTrue(found_affaire)
+
+    def test_graphql_based_reflection_list_fields_of_entity(self):
+        """
+        Test listing the fields of an entity using GraphQL-based reflection
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("query q($name: String!) { __type(name: $name) { name, fields { name, type { name, kind } } } }", name="Affaire")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["__type"]["fields"]), 28)
+            found = False
+            for f in result.data["__type"]["fields"]:
+                if f["name"] == "todo_by":
+                    found = True
+            self.assertTrue(found)
+
+    def test_cw_based_reflection_list_fields_of_entity(self):
+        """
+        Test listing the fields of an entity using CubicWeb-based reflection
+        """
+        with self.admin_access.repo_cnx() as cnx:
+            result = cnx.execute_graphql("query q($name: String!) { CWRelation(from_entity: {name: $name}) { relation_type { name }, to_entity { name } } }", name="Affaire")
+            self.assertIsNone(result.errors)
+            self.assertIsNotNone(result.data)
+            self.assertEquals(len(result.data["CWRelation"]), 16)
+            found = False
+            for f in result.data["CWRelation"]:
+                if f["relation_type"][0]["name"] == "todo_by":
+                    found = True
+            self.assertTrue(found)
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/web/request.py
--- a/cubicweb/web/request.py	Thu May 03 16:47:51 2018 +0200
+++ b/cubicweb/web/request.py	Tue Jun 05 19:09:18 2018 +0200
@@ -958,6 +958,18 @@
         rset.req = self
         return rset
 
+    def execute_graphql(self, query, **kwargs):
+        """
+        Executes a GraphQL request
+        :param query: The query to execute
+        :param kwargs: The mapping of values for the named variables in the query
+        :return: The GraphQL execution result
+        """
+        results = self.cnx.execute_graphql(query, **kwargs)
+        from cubicweb.gql import format_execution_result
+        results = format_execution_result(results)
+        return results
+
     entity_metas = _cnx_func('entity_metas')  # XXX deprecated
     entity_type = _cnx_func('entity_type')
     source_defs = _cnx_func('source_defs')
diff -r aa999699e504 -r 0978e5ebc5c4 cubicweb/web/views/ajaxcontroller.py
--- a/cubicweb/web/views/ajaxcontroller.py	Thu May 03 16:47:51 2018 +0200
+++ b/cubicweb/web/views/ajaxcontroller.py	Tue Jun 05 19:09:18 2018 +0200
@@ -464,3 +464,9 @@
 def add_relation(self, rtype, subjeid, objeid):
     rql = 'SET S %s O WHERE S eid %%(s)s, O eid %%(o)s' % rtype
     self._cw.execute(rql, {'s': subjeid, 'o': objeid})
+
+ at ajaxfunc(output_type='json')
+def graphql(self):
+    req = self._cw
+    query = req.form.get('query')
+    return self._cw.execute_graphql(query)
\ No newline at end of file
diff -r aa999699e504 -r 0978e5ebc5c4 setup.py
--- a/setup.py	Thu May 03 16:47:51 2018 +0200
+++ b/setup.py	Tue Jun 05 19:09:18 2018 +0200
@@ -96,6 +96,11 @@
         'ext': [
             'docutils >= 0.6',
         ],
+        'gql': [
+            'graphql >= 2.0',
+            'graphene >= 2.0',
+            'iso8601'
+        ],
         'ical': [
             'vobject >= 0.6.0',
         ],


More information about the cubicweb-devel mailing list