Help with a schema for complex relationships

Hi @chilltemp and welcome to the forums!

I agree that Union types would be very helpful for your use case. It’s on our roadmap to provide them, but we are not there yet, so obviously that won’t help you right now.

A lot will depend on how complex you data is and how much work you want to do to create and maintain a flexible schema. I have a couple of ideas to start, then a much more detailed suggestion. The detailed suggestion goes a bit off the rails, but I hope that it demonstrates some more advanced techniques available to you.

First let’s identify some goals/capabilities of our schema:

  1. Query for all library items of a given type (Series, Book, Movie, etc.)
  2. Query for all relationships to/from an item
  3. Query for all items in a library
  4. Relationships (edges in the graph) have properties.

Different Link types

If the number of types is obvious, small and not likely to change, this might not be a bad idea

  1. You can get all items of a type, because each item has its own Collection
  2. You can query for all relationships by enumerating each link type
  3. You can query for all items in a library by enumerating each type
  4. Different link types can have different properties

#4 might be unique to this option, so that is something worth consideration!

Sparse fields

If the data in the different kinds of items are not too different you can put all of the fields in one LibraryItem type. Only the fields that are required for a given “type” of item are provided. You can provide a string “type” or “tag” field that identifies the item as a certain type of item.

Downsides to this are fairly clear: you lose a great deal of control over type validation in GraphQL. Your app has to do a lot more heavy lifting to validate and manage your various types.

But we satisfy our objectives:

  1. You can get all items of a type by indexing on the “type” or “tag” field.
  2. You can query for all relationships through the unified Link type.
  3. You can query for all items in a library through a single relation field.
  4. You have one Link type with properties

Example using a “component” pattern

This is similar to the “sparse fields” idea, in that it puts all entities in the same Collection. But we’ll wrap each distinct bit of data into an @embedded type. This will give us more control over our types and GraphQL queries.

I’ve been thinking about how to model complex relationships in GraphQL for a long time, specifically how to do so without support for Union type. Lacking Union types, and (importantly) for the data I have modeled, I’ve observed that it was easier to describe entities has having a multitude of components, rather than just a single type.

Clearly, not all enties have the same properties: a paperback book does not have a listening length like an audio book. But for a library perhaps all types of a media can share some properties, like if they are “new releases” or are being “promoted” somehow.

Handling nonsensical types could be helped by… Unions! What I would LOVE to do is still have different types represented as a Union (just like you shared). But since we don’t have them, there is some amount of effort that will have to go into avoiding them through the application.

example in graphQL playground

Here is an example of creating some media and querying for it with this pattern:

Create a series with some linked books. These will be “promoted”.

Create another book that is a new release

We can search for all items in the library:

We can search for all items of a given type:

Or by multiple types:

GraphQL Schema

type Library {  
  key: String! @unique
  items: [LibraryItem!] @relation
}

type LibraryItem {
  library: Library! @relation
  key: String!

  inboundLinks:  [ItemLink!] @relation(name: "link_target")
  outboundLinks: [ItemLink!] @relation(name: "link_source")

  components: Components
}

type ItemLink {
  # unique == source & target & label
  source: LibraryItem! @relation(name: "link_source")
  target: LibraryItem! @relation(name: "link_target")
  label: String! # ex. "is_in_series", "is_authored_by", "is_reserved_by"

  # other link attributes
}

type Components @embedded {
  # components with data
  book: Book,
  series: Series,
  movie: Movie,
  game: Game,
  borrowable: Borrowable

  # singleton components
  promoted: Boolean
  newRelease: Boolean
}

type Book @embedded {
  title: String!
  pages: Int
}

type Series @embedded {
  title: String!
}

type Movie @embedded {
  title: String!
}

type Game @embedded {
  title: String!
}

type Borrowable @embedded {
  total: Int!
  available: Int!
}

type Query {
  itemsWithComponent(component: String!): [LibraryItem]! @resolver(paginated: true)
  itemsWithAllComponents(components: [String!]!): [LibraryItem]! @resolver(paginated: true)
  itemsWithAnyComponents(components: [String!]!): [LibraryItem]! @resolver(paginated: true)
}

Fauna Indexes and Functions

An index that can find all items with a given component:

CreateIndex({
  name: "LibraryItem_has_component",
  source: {
    collection: Collection("LibraryItem"),
    fields: {
      components: Query(Lambda("doc",
        Let(
          {
            components: Select(["data", "components"], Var("doc"), {}),
            component_keys: Map(ToArray(Var("components")), c => Select(0, c))
          },
          Var("component_keys")
        )
      ))
    }
  },
  terms: [
    { binding: "components" }  
  ]
})

When there are multiple @resolver fields that are paginated, I prefer to reuse a helper UDF:

CreateFunction({
  name: "paginate_helper",
  body: Query(Lambda(["match", "size", "after", "before"],
    If(
      Equals(Var("before"), null),
      If(
        Equals(Var("after"), null),
          Paginate(Var("match"), { size: Var("size") }),
          Paginate(Var("match"), { size: Var("size"), after: Var("after") })
      ),
      Paginate(Var("match"), { size: Var("size"), before: Var("before") }),
    )
  ))
})

Customer resolvers

CreateFunction({
  name: "itemsWithComponent",
  body: Query(
    Lambda(
      ["component", "size", "after", "before"],
      Let(
        {
          match: Match(Index("LibraryItem_has_component"), Var("component")),
          page: Call("paginate_helper", [
            Var("match"),
            Var("size"),
            Var("after"),
            Var("before")
          ])
        },
        Map(Var("page"), ref => Get(ref))
      )
    )
  )
})

Use Intersection to require many components

CreateFunction({
  name: "itemsWithAllComponents",
  body: Query(
    Lambda(
      ["components", "size", "after", "before"],
      Let(
        {
          match: Intersection(
            Map(
              Var("components"),
              Lambda(
                "component",
                Match(Index("LibraryItem_has_component"), Var("component"))
              )
            )
          ),
          page: Call("paginate_helper", [
            Var("match"),
            Var("size"),
            Var("after"),
            Var("before")
          ])
        },
        Map(Var("page"), ref => Get(ref))
      )
    )
  )
})

Use Union to select for any of multiple components

CreateFunction({
  name: "itemsWithAnyComponents",
  body: Query(
    Lambda(
      ["components", "size", "after", "before"],
      Let(
        {
          match: Union(
            Map(
              Var("components"),
              Lambda(
                "component",
                Match(Index("LibraryItem_has_component"), Var("component"))
              )
            )
          ),
          page: Call("paginate_helper", [
            Var("match"),
            Var("size"),
            Var("after"),
            Var("before")
          ])
        },
        Map(Var("page"), ref => Get(ref))
      )
    )
  )
})
2 Likes