(continued from Modeling Unions as Sparse fields in GraphQL (3 of 4))
The following "component" pattern describes entities as being a single type built from various components, rather than as individual types. It is similar to the “sparse fields” introduced in the previous article: it uses a single Collection for all entities, but rather than put additional fields at the top level, you wrap the entities' data into various @embedded
types. This will give us more control over our types and GraphQL queries.
Not all entities have the same properties: A movie does not have a number of pages like a book, and a book does not have a length of time like a movie. However, entities of different types can share some properties, such as whether they “new releases” or are being “promoted” somehow. This pattern will allow us to reuse the same component to describe multiple types, similar to how Union types would.
Schema
type User { username: String! @unique favorites: [MediaItem]! @relation } type MediaItem { title: String! favoritedBy: [User]! @relation components: Components } type Components @embedded { # components with data movie: Movie, show: Show, book: Book, # singleton components promoted: Boolean newRelease: Boolean } type Movie @embedded { length: Float! # ... other Movie fields } type Show @embedded { seasons: Int! # ... other Show fields } type Book @embedded { pages: Int! # ... other Book fields } type Query { itemsWithComponent(component: String!): [MediaItem]! @resolver(paginated: true) itemsWithAllComponents(components: [String!]!): [MediaItem]! @resolver(paginated: true) itemsWithAnyComponents(components: [String!]!): [MediaItem]! @resolver(paginated: true) }
Create initial data
Create some media items:
Note that some of the items have "promoted" or "newRelease" components. Those can be searched for later.
Create a user with some favorite items:
When the user was created, it demonstrated that the MediaItems can be fetched from the User, but we can also fetch the users who have favorited the item
Additional Fauna Indexes and Functions
Create a new Index to support the custom resolvers:
CreateIndex({ name: "MediaItem_has_component", source: { collection: Collection("MediaItem"), 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, the following helper function can be reused with each of the resolvers.
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") }), ) )) })
Now the customer resolvers can be updated:
Update(Function("itemsWithComponent"), { body: Query( Lambda( ["component", "size", "after", "before"], Let( { match: Match(Index("MediaItem_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
Update(Function("itemsWithAllComponents"), { body: Query( Lambda( ["components", "size", "after", "before"], Let( { match: Intersection( Map( Var("components"), Lambda( "component", Match(Index("MediaItem_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
Update(Function("itemsWithAnyComponents"), { body: Query( Lambda( ["components", "size", "after", "before"], Let( { match: Union( Map( Var("components"), Lambda( "component", Match(Index("MediaItem_has_component"), Var("component")) ) ) ), page: Call("paginate_helper", [ Var("match"), Var("size"), Var("after"), Var("before") ]) }, Map(Var("page"), ref => Get(ref)) ) ) ) })
Conclusion
The downsides to this are fairly clear: you lose control over type validation in GraphQL; your app has to do a lot more heavy lifting to validate and manage your various types.
However, you still satisfy the objectives:
- ✅ query for all entities of a given type
- You can get all items of a type by indexing on the existence of the component field.
- ✅ query for all entities with certain properties
- Every entity is searchable by every property.
- Indexes can be created with terms on component fields, such as
data.components.book.author
to extend the above schema.
- ✅ query for all relationships of an entity, regardless of which type it points to
- You can search for all user favorites by selecting the single
favorites
field. - You can search for all users that have favorited the media item through the
favoritedBy
field.
- You can search for all user favorites by selecting the single
In the series:
This article is part of a series. Be sure to read the rest for different methods of modeling unions in GraphQL with Fauna.
- Composable Types without Unions in GraphQL (1 of 4)
- Modeling Unions in GraphQL (2 of 4)
- Modeling Unions as Sparse fields in GraphQL (3 of 4)
- A "Component" Pattern for GraphQL (4 of 4)