Help with a schema for complex relationships

I’m trying to define a graphql schema to handle the complex relationships between books.

  • Relationship rules (ItemLink’s):
    • A Book or Schema can be linked to any other Book/Schema in the same Library
    • I need to know which Book/Schema is the source, and which is the target
    • Source and target pairs must be unique
  • Unrelated to the ItemLink’s:
    • Both Books and Series must be a child of a Library
    • A Book may be a child of a Series
type Library {  
  key: String! @unique
  series: [Series!]
  books: [Book!]
}

type Series {  
  # unique == library & key
  library: Library! 
  key: String!
  books: [Book!]
  inboundLinks:  [ItemLink!] # this entity should be ItemLink.target
  outboundLinks: [ItemLink!] # this entity should be ItemLink.source
}

type Book {
  # unique == library & key
  library: Library! 
  key: String!
  inboundLinks:  [ItemLink!] # this entity should be ItemLink.target
  outboundLinks: [ItemLink!] # this entity should be ItemLink.source
}

union Linkable = Series | Book;
type ItemLink {
  # unique == source & target
  source: Linkable! 
  target: Linkable! 
  # other link attributes
}

I know that Fauna doesn’t support unions, but I’m using them here to explain my goals. So far the only workaround I’ve thought of is to create a ItemLink type for every possible combination of entity types, and corresponding fields on the Book and Series entities (book to book, book to series, series to book, series to series). There has got to be a better way. Any ideas?

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

Thanks for such a detailed reply! I’m restructuring my schema so that the LibraryItem type has feature based embedded types. A configuration file will specify that ItemSubType has which feature types.

Are there any concerns with having a 3 field unique composite index? (performance, costs, …)
Reason: ‘Aeon 14’ (Library) has reused a “normal book” title as an “omnibus” (Collection) title.

enum ItemSubType {
  BOOK
  SERIES
  COLLECTION
}

type LibraryItem {
  # unique == library, type, & key
  library: Library! @relation
  subType: ItemType!
  key: String!
}

For anyone who’s curious about my project. I’m currently doing a complete re-write of readingorder.info to remove a critical unwanted dependency, apply lessons learned, etc.

Any Index comes with storage costs, but an Index with 3 terms* will still perform very well – A lookup for a unique index with multiple terms, and no values, is essentially constant-time.


*Uniqueness is determined by the combination of an Index’s terms and values, but you should avoid unique Indexes with 0 terms, or few terms that result in many possible values. Performance comes from using terms to look up results in constant time, whereas additional values as part of the unique constraint means that Fauna has to traverse n results and compare all of them against a new or updated Document.

EDIT: I am in love with your readingorder project now, by the way! :heart_eyes:

I’m assuming that this fourm will lock the thread once I “accept your answer”. So, I’m going to hold off on that for a few days, so that I can post my schema as reference for others.

Thanks! It will probably take couple of months for the re-write. I’ll make a post in Random when it’s ready (assuming that’s acceptable). Feel free to message me with ideas/comments/etc.

If you don’t mind, I marked the post as the solution, but then removed the timer to close it out. Posting more details of your solution is welcome, and on topic! Though please consider separate questions if you think the topic strays away from this one. We’d love to hear more about how this goes :slight_smile:

Random category is a good place to share more in general about what you are building with Fauna. Discord has a showcase channel for sharing projects as well. (Invite link to Discord if you don’t have it)

1 Like

Here’s my resulting schema (trimmed). I still have to create the custom resolvers for the queries I need. Fun fun :slight_smile:

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

enum ItemSubType {
  BOOK
  SERIES
  OMNIBUS
}

type LibraryItem {
  # unique == library, type, & key
  library: Library! @relation
  subType: ItemSubType!
  key: String!
  title: String!
  inboundLinks:  [ItemLink!] @relation(name: "item_link_target")
  outboundLinks: [ItemLink!] @relation(name: "item_link_source")
  characters: [CharacterLink!] @relation
  publishedDate: String
  position: Position
  formatting: Formatting
}

enum LinkType {
  NEXT
  OFFSHOOT
}

type ItemLink {
  # unique == source & target
  source: LibraryItem! @relation(name: "item_link_source")
  target: LibraryItem! @relation(name: "item_link_target")
  type: LinkType!
}

type Formatting @embedded {
  backgroundColor: String!
  foregroundColor: String
  borderColor: String
}

type Position @embedded {
  startDate: Date
  endDate: Date
}

enum CharacterRelationshipType {
  DEFAULT
  MAIN
}

type CharacterLink {
  # unique == source & character
  source: LibraryItem! @relation
  character: Character! @relation
  relationship: CharacterRelationshipType
}

type Character {
  # unique == library & key
  library: Library! 
  key: String!
  name: String!
  books: [CharacterLink!] @relation
  formatting: Formatting!
}

@ptpaterson Thanks for the help. Feel free to close this thread.

1 Like

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.