graphql icon indicating copy to clipboard operation
graphql copied to clipboard

Feature Request: Allow multiple relationship types

Open dmoree opened this issue 2 years ago • 3 comments

Is your feature request related to a problem? Please describe. It can be helpful to have a field on a GraphQL type that corresponds to multiple relationship types in the underlying graph. As a familiar example, take for instance the typeDefs:

type Director {
    id: ID!
    name: String!

    directed: [Movie!]! @relationship(type: "DIRECTED", direction: OUT)
}

type Actor {
    id: ID!
    name: String!

    movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT)
}

type Movie {
    id: ID!
    title: String!

    director: Director! @relationship(type: "DIRECTED", direction: IN)
    actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN)
}

This is a one-to-one representation of the nodes in the database where (:Director) nodes are related to a (:Movie) node by a single relationship [:DIRECTED] and (:Actor) nodes are related to (:Movie) nodes by a single relationship [:ACTED_IN].

A typical question of Movie would be: who are the associated people? In order to pull these nodes from the database a cypher query of the form

MATCH (person)-[:DIRECTED|ACTED_IN]->(this:Movie)
RETURN person

will return what is being sought after i.e. (:Director) and (:Actor) nodes. Fortunately, GraphQL has an abstract type Union that can be used to describe the associated types. This would lead to extending the typeDefs with:

union Person = Director | Actor

extend type Movie {
  people: [Person!]!
}

The obvious type for the @relationship directive would be "DIRECTED|ACTED_IN" as it is the only way not to bring the abstraction of Person into the database (one wouldn't want to add superfluous relationships like [:SEARCH] or [:PERSON] in order to facilitate a query that is as simple as the one above). Therefore, a proposal would be to allow multiple types to the @relationship directive so that a natural extension would be:

union Person = Director | Actor

extend type Movie {
  people: [Person!]! @relationship(type: "DIRECTED|ACTED_IN", direction: IN)
}

If [:DIRECTED|ACTED_IN] is used strictly for traversal, then where, update, disconnect, and delete through the people field should be preserved as these only affect the underlying nodes. connect and create would have to be disabled as the relationship would be ambiguous and could not be resolved, although this may have a solution as well if one were to move beyond using this only to traverse the graph.

Describe the solution you'd like Currently, @neo4j/graphql will handle traversing the graph quite easily with multiple types as long as the parameter name is transformed to convert | to _, but will also generate types in the schema related to create and connect that would be disallowed using the above reasoning.

Describe alternatives you've considered Besides introducing additional relationships in the database, I don't believe there is an alternative solution.

dmoree avatar Sep 16 '21 23:09 dmoree

Create and Connect

The above description outlines the case for special consideration for create and connect. To accommodate these operations a relationship type must be specified as a special property on an edge in order to disambiguate the relationship. Currently, edges only exist on connections when a properties argument is specified on @relationship. Once specified it takes the shape of the interface that defines those properties. A further proposal would be to extend this edge to include not only the properties of the relationship but also its type; a special field (edge._type) would be reserved for this purpose. It is important to note that this is not a breaking change as edge would only be required for @relationships that have multiple types associated with them, much the same way that the edge field is required if there are required fields on the properties interface.

Example

Query
mutation {
  createMovies(
    input: {
      title: "The Matrix"
      people: {
        Director: {
          create: [
            { node: { name: "Lana Wachowski" }, edge: { _type: DIRECTED } }
            { node: { name: "Lily Wachowski" }, edge: { _type: DIRECTED } }
          ]
        }
        Actor: {
          create: [
            { node: { name: "Keanu Reeves" }, edge: { _type: ACTED_IN } }
            { node: { name: "Carrie-Ann Moss" }, edge: { _type: ACTED_IN } }
          ]
        }
      }
    }
  ) {
    movies {
      title
      peopleConnection{
        totalCount
        edges {
          _type
          node {
            __typename
            ...on Director {
              name
            }
            ...on Actor {
              name
            }
          }
        }
      }
    }
  }
}
Response
{
  "data": {
    "createMovies": {
      "movies": [
        {
          "title": "The Matrix",
          "peopleConnection": {
            "totalCount": 4,
            "edges": [
              {
                "_type": "DIRECTED",
                "node": {
                  "__typename": "Director",
                  "name": "Lana Wachowski"
                }
              },
              {
                "_type": "DIRECTED",
                "node": {
                  "__typename": "Director",
                  "name": "Lily Wachowski"
                }
              },
              {
                "_type": "ACTED_IN",
                "node": {
                  "__typename": "Actor",
                  "name": "Keanu Reeves"
                }
              },
              {
                "_type": "ACTED_IN",
                "node": {
                  "__typename": "Actor",
                  "name": "Carrie-Ann Moss"
                }
              }
            ]
          }
        }
      ]
    }
  }
}
Graph

Screen Shot 2021-09-19 at 6 44 00 PM

There a some things to note:

  1. An edge field is required in the MoviePeopleDirectorCreateFieldInput since @relationship type is DIRECTED|ACTED_IN. An error will be thrown if it is not provided. Screen Shot 2021-09-19 at 8 34 45 PM
  2. The edge field has a required field _type that is of type MoviePeopleRelationshipType. This type is an enum holding the various types on the relationship; i.e.
enum MoviePeopleRelationshipType {
  DIRECTED
  ACTED_IN
}
  1. Similarly, in the edges field of the response a new field _type is exposed that holds a value of MoviePeopleRelationshipType. This represents the type of edge that is being returned and is treated as a property of the edge.

A Further Use Case

Multiple relationship types do not need to correspond to multiple nodes. They can and do relate a particular node to another. This can be demonstrated by extending the above schema to include Series.

type Series {
  id: ID! @id
  title: String!
  
  actors: [Actor!]! @relationship(type: "ACTED_IN|ACTING_IN", direction: IN)
}

Since series can be ongoing there will be some actors that are current and those that are past. This information is most effectively stored as the relationship type between Actor and Series. To get the current actors:

query {
  series {
    id
    title
    actorsConnection(where: { edge: { _type: ACTING_IN } }) {
      totalCount
      edges {
        node {
          name
        }
      }
    }
  }
}

Currently, edge and edge_NOT are added to ConnectionWhere types if there are any relationship properties specified. Here these fields are also added in the case of multiple relationship types where edges can be filtered by type.

Lastly, it is important to reiterate that these are intended to be non-breaking changes.

dmoree avatar Sep 20 '21 04:09 dmoree

Hi @dmoree, sorry for taking that long. After some discussion, we have a couple of questions regarding the design of this proposal that we would need to address before reviewing the PR.

First, and more importantly. In the Create and Connect example from above, what would stop users from creating invalid relationships?: e.g.

mutation {
  createMovies(
    input: {
      title: "The Matrix"
      people: {
        Director: {
          create: [
            { node: { name: "Lana Wachowski" }, edge: { _type: DIRECTED } }
            { node: { name: "Lily Wachowski" }, edge: { _type: ACTED_IN } }
          ]
        }
        Actor: {
          create: [
            { node: { name: "Keanu Reeves" }, edge: { _type: DIRECTED } }
            { node: { name: "Carrie-Ann Moss" }, edge: { _type: ACTED_IN } }
          ]
        }
      }
    }
  ) {
    movies {
      title
      peopleConnection{
        totalCount
        edges {
          _type
          node {
            __typename
            ...on Director {
              name
            }
            ...on Actor {
              name
            }
          }
        }
      }
    }
  }
}

Union relationships for creation are not supported by Neo4j, and honestly, don't really make much sense as, in this edge case, you'll need to know who is an actor and who a director is anyway.

Union relationships working as a read-only could be a potential solution to this problem, as the use-case of querying for a union relationship makes sense.


Another, less important concern is the syntax. Using the string to define unions ACTOR|DIRECTOR poses 2 problems:

  1. This change of behaviour is not explicit from a graphQL point of view (different strings define different behaviour on the same property)
  2. This is a bit of Cypher exposure to the GraphQL API, which is not ideal. Labels are all escaped, and ACTOR|DIRECTOR is actually a valid label, any change in behaviour would be better defined in explicit GraphQL statements (e.g. different property, array or directive) rather than on the string, which ideally should be the escaped label:
    CREATE (n:`Actor|Director`)
    

angrykoala avatar Mar 07 '22 10:03 angrykoala

We have initial designs for a new directive which support more complex relationship paths in a read-only manner, but don't intend to add this functionality to the @relationship directive.

darrellwarde avatar Feb 21 '24 10:02 darrellwarde