-
Notifications
You must be signed in to change notification settings - Fork 61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: Replace "descendant unions" with fields to simplify querying #209
Comments
Given that the current GraphQL 3.x implementation has been broken until two days ago, I'm not sure how many people are already relying on We could then introduce this new signature in GraphQL 4.x. We'd need to change existing core queries (in SilverStripe 4.4 or 4.5), and everyone else needs to change their queries as well. That's annoying, but the reality of a young module in a young ecosystem. |
Option A: New
|
I've done a bit of work on Option D: Interface shared between all types on unionThis option is pretty close to what we already have, with one distinction: All types implement the interface of the base type. In our examples, that's Aside: This might make the lives of frontend devs harder when using fragments or unions on interfaces. We're already doing that by using unions though, so interfaces+unions doesn't make it any worse. And it sounds like Apollo gracefully handles that through heuristics, it can just be more efficient with more context on those schema relationships. Type system interface PageInterface {
id
created
title
content
}
type Page implements PageInterface {
id
created
title
content
}
type MyPage implements PageInterface {
id
created
title
content
myField
}
type MyExtendedPage implements PageInterface {
id
created
title
content
myField
myExtendedField
}
union PageWithDescendants = Page | MyPage | MyExtendedPage
type readPage {
[PageWithDescendants]
} Query query readPage {
readPage {
...on PageInterface {
ID
Content
}
...on MyPage {
MyField
}
...on MyExtendedPage {
MyExtendedField
}
}
} I've created a PoC with this on the webonyx implementation, it works: https://gist.github.com/chillu/0afd4e22e3c8b58f1d848288fa07892a |
Further complications with scaffolding:
types:
Page:
fields: ['Content', 'MyExtendedField']
operations:
read: true
nestedQueries:
MyPageRelation: true
MyPage:
description: MyPage description
fields: '*'
MyExtendedPage:
fields: []
We could disallow defining fields on parent classes ( I'm leaning towards A middle ground could be that we go for |
I like option D. The implicit support for option C just seems like complex overhead for some hard to define behaviour. It just feels like a trap. I don't think that's worth it... Anyone who would prefer simpler queries can define their own query resolvers still. Is that a potential option E that we implement multiple options with an adapter interface and then allow schemas (or individual scaffolding) to indicate which type they want? Maybe that's going down the crazy path. |
Approximating "Option C" in silverstripe#209. Probably will be abandoned for an alternative "Option D" approach.
This enables more stable query creation with union types. Instead of referring to specific types (e.g. "... on Page"), you can use the relevant interface for that type (e.g. "...on PageInterface"). Since all types in the union share those interfaces, this results in less duplication of fields in the query. It also makes the query resilient against adding new types to the union (or subclasses to the DataObject), which would previously result in NULL query results.
Alright, PoC for A few implementation notes:
interface PageInterface {
Content: String
Versions: [PageVersionWithDescendants]
}
interface MyPageInterface {
Content: String
MyField: String
Versions: [PageVersionWithDescendants]
}
interface VersionInterface {
Version: Int
Author: String
}
type Page implements PageInterface {
Content: String
Versions: [PageVersionWithDescendants]
}
type MyPage implements PageInterface, MyPageInterface{
Content: String
MyField: String
Versions: [PageVersionWithDescendants]
}
type PageVersion implements PageInterface, VersionInterface {
Content: String
Version: Int
Author: String
}
type MyPageVersion implements PageInterface, MyPageInterface, VersionInterface {
Content: String
MyField: String
Version: Int
Author: String
}
type PageVersionWithDescendants = PageVersion | MyPageVersion
type PageVersion = PageVersion | MyPageVersion
query readPages {
[Page]
} |
Alright, I'm ready to crack open this chestnut again. On the status quoI think the core problem here is that the scaffolded queries are non-deterministic. A declaration of a Option D, "always use interfaces"Good that we're to adding some determinism and convention to these queries. The idea here is that every read query returns a union, no matter what? That's a good idea in principle, and it certainly solves the aforementioned problem, but I worry about the DX. It's not intuitive at all, it adds a burden of comms and documentation, and overall I think it positions Silverstripe weakly on the landscape of headless options. I'm afraid it might be throwing a super clever solution at an uncommon problem with a cost of steepening the learning curve for devs. A few overly broad-based assumptions:
In other words, that I have to do this:
in lieu of the more intuitive
simply because it buys me resilience against a future subclass of Further, I've been playing with this stuff for a while, particularly as it pertains to Gatsby, and I have yet to find a common use case for querying extended fields from the base type. Even just using ORM code as an analogue, this isn't very common: foreach (SiteTree::get() as $page) {
$page->Title;
$page->RedirectURL;
} Typically, when you're querying from the base, you're building menus and indexes that only need the base properties. Option D, all types are interfaces, with all their descendant fieldsThis one confuses me. What's the point of separating the types if they're just all-inclusive collections of their ancestral and descendant fields? What is the difference between
And then your queries just become:
This seems even more weird than the last option. The case for Option BQuerying base types and asking for their descendant fields does happen, and there's a reason our ORM supports that approach, but if and when it does, I think it's an edge case. For that reason, relying on the |
I largely agree with what @unclecheese wrote. In terms of an example: we have one in elemental. In the designs, each element in the list has a little summary/preview section. That might contain a summary of the content in a content block, or a thumbnail of the image in an image block. This doesn't work with GraphQL because the type is variable, so we built in a custom type in that module for a JSON serialised object, and treated it as a workaround that we were OK with because it's an uncommon scenario. I think that we've previously encouraged things like this in SilverStripe development: foreach (SiteTree::get() as $page) {
if ($page->hasMethod('getLocation')) {
return $page->Title . ', written from ' . $page->getLocation();
}
return $page->Title;
} To me this is an anti-pattern - it doesn't really work with SOLID principles. You should instead add The example in the main post uses If you were building a frontend with GraphQL and elemental you'd know the structure of the page you want to render, so you'd be able to write a query for it. For elemental however, it needs to be able to work with any custom element type without requiring a re-compile of the JavaScript - this requirement is unique to us as module developers. Ideally our GraphQL's primary consumers are website/app developers, who would prefer an intuitive and standard implementation without having to learn a unique data structure too much before using it. We'll obviously have a number of module developers using it in the CMS too, but I think these people are less of our focus in this regard. If we do support this, I'd be in favour of the option B too. When it comes to module development, I think we can demand a little more from the API consumers. |
I am working on using Silverstripe as a headless CMS (with NextJS/React) and stumbled on the "descendant union" problem when trying to render a page. For now I went back to a REST approach, but I really would like to use GraphQL. In my case, querying base types with the fields of the descendants is not an edge case but the main reason to use GraphQL: With it, I have a typed API and can be sure what I will get from my backend. If I am using some kind of flattening as @unclecheese did in silverstripe-gatsby, I'm getting the same as in a untyped REST approach. Also, it's ok to just include everything in a static generator scenario, but not in my NextJS situation where the first api call is on the server but api calls thereafter are from the client. The adequate example for me is not the rendering of a menu, but the rendering of a Page with Elements in it, with a query like this:
Then you could iterate the elements, create components via a factory and render the components. Now, if I understand the options correctly, I am in favour of Option D, because it looks the closest to the standard. Maybe it's possible to make it an option in the scaffolding? So that you get the base object per default but you can mark an operation as "with descendants union"? |
My use case has changed in the meantime with the transition from getInitialProps to getServerSideProps in NextJS 9.3. As the API now won't be called directly from the client anymore, but from the SSR, it's more or less ok to include all properties in the API result like in silverstripe-gatsby. So the requirement to query base types with the fields of the descendants is becoming an edge case for me, too. |
@unclecheese Can you please sanity check that we still have a path to implementing Option B or Option D on your GraphQL v4 refactor? I've since been working a bit more with Github's GraphQL API. They don't really have "subclasses", but lots of types with overlapping fields with liberal use of interfaces and unions. Interestingly, they have very few queries, for example there's no interface Node {
id:ID!
}
interface RepositoryNode {
repository: Repository!
}
type Issue implements Node,RepositoryNode {
id: ID!
repository: Repository
}
type PullRequest implements Node,RepositoryNode {
id: ID!
repository: Repository
}
type SearchResultItem = Issue | PullRequest
query nodes(id:ID!) {
[Node]
}
query search(query:String!, type:SearchType) {
[SearchResultItem]
} So if you translate this back into Silverstripe, Option D is effectively the The main argument for Option D I've seen here is rendering of blocks (as @adiwidjaja outlined), where you actually need type-specific fields on subclasses because they drive what's rendered. But maybe that's enough of an edge case to address @unclecheese concerns about DX by implementing this as Option B ( Regarding the CMS use case (stable GraphQL queries without recompiling JS bundles, while supporting type additions through PHP): My hunch is that strong typing will actually get in the way for CMS UIs which need to support custom types with all of their fields (GridField, edit forms). Hence this discussion is somewhat tied into React GridField :/ |
Yeah, all really good points. This needs to be added to the upgrade guide. In GraphQL 4, we're using Option B, and I think it's the best fit. Before:
After:
More information here https://pulls-master-schemageddon--ss-docs.netlify.app/en/4/developer_guides/graphql/working_with_dataobjects/inheritance/
This will likely change. Now that we have a better designed GraphQL API for silverstripe, I think the Gatsby module will just use the generic |
UpdateThere is a draft PR that I've opened that changes the chosen Additionally, working more with the Gatsby integration and trying to achieve some cohesion between the SS schema and Gatsby, you definitely see the case for a higher level of abstraction and fewer idiosyncrasies. So with all that said: Presenting Option EThis recommendation is probably the most abstract approach suggested so far, which is funny because the option we chose is the least abstract, relying on pseudo everything in the interest of staying concrete, so this is a real shift of extremes. But bear with me. It comes down to a few things: There are two kinds of models: Inherited and StandaloneAn inherited model is just what it sounds like -- anything that has ancestors or descendants. A standalone model is one that has no ancestors (parent = DataObject), and no descendants. Inherited models are always abstractionsThis may be the most controversial pillar of this proposal, but I think inherited models should never be expressed as concrete types. Here's why:
Of course, using this logic, you can argue that non-inherited (standalone) models are equally abstract because they inherit from DataObject, but I think that's where we can draw the line. The small handful of properties in the base class don't need to be abstracted away rigidly. I mean, who needs this: query {
readSiteTrees {
... on DataObject { id }
}
} We don't need to explicitly cross-pollenate fields in the inheritance chainRight now, we have this:
While this type of thinking maintains a high level of fidelity to the ORM we're used to using in PHP, it doesn't hold up well in GraphQL. Browsing the schema documentation is confusing. Fields are copied all over the place, and types are huge, particularly as you get further down the chain, making it very hard to find what you're looking for. As a dev, you're much more used to thinking of A more sensible representation is to isolate all the relevant fields to interfaces.
Most queries return a union of abstractionsImagine this data model:
This will produce the following unions:
Notice that some of these unions are identical. That happens when a node has only one direct descendant. It's probably not worth fussing over trying to eliminate the duplication.
The queriesquery {
readSiteTrees {
... on SiteTree { content }
... on Page {
bannerImage { url }
}
... on BlogPage {
date
}
}
readProducts {
... on SpecialProduct {
specialField
}
}
readMembers {
firstName
}
} The DataObject interfaceIn practice, all these interfaces would implement the
About naming conventionsI've deliberately avoided using the Fragments FTWIn addition to many other benefits, this will really open the door to the use of fragments, which will become a lot more portable when they're only bound to an interface.
ImplementationIt's actually just a few minor changes to the PR I've linked above. Most of this work is already done. It's just a matter of reducing clutter. |
What are the key differences between the new option E and option D? E seems to just use interfaces and unions, whereas D uses types and interfaces… is that it and what is being improved with that change? From a frontend-dev perspective, I'm not too fond of the current implementation with I'd really like to see how a query for a page with elemental blocks would look like with Option E. |
Yeah, I think we're building a consensus that the chosen Difference between D and E is two things:
Elemental queries look like this:
|
Yup, I'd much rather prefer the above query/output… purely from a frontend-dev perspective 😅 I'm not very well versed in the whole GraphQL Schema thing, so take this with a grain of salt. Isn't using types (Option D) slightly easier to understand? Let's say you expose a public GraphQL API, but the consumers have no Idea about SilverStripe and the inheritance chain. Do they understand the content structure by just looking at the Schema with Option E? If Option D and E are equally understandable without lots of background-knowledge about the inner workings of SilverStripe, then I'd probably opt for E. It seems to reduce boilerplate and is composable. |
Yeah, that's why I made the point about naming conventions. As long as it's called I think it's important to remember that most queries are strongly guided by the autocomplete of an IDE, too. Most devs aren't writing queries freeform. |
One more quick point -- you might be wondering, if queries just return a union of abstractions, then how will graphql know what type to use? Interfaces don't have resolvers. You need a concrete type to do the resolving. Turns out, in graphql, abstractions and concretions are quite tightly coupled. Concrete types are aware of their interfaces, and interfaces are also aware of their implementations. Interfaces must provide a way of finding out what implementation they're currently on. In fact, Gatsby will generate queries for your interfaces. |
Well it turns out I was completely wrong about option E. You can't do unions of interfaces. That's one level of abstraction too far for the GraphQL spec. The pull request is now ready for review: #374 What we ended up with is pretty darn close to Option D, save a few details here and there. |
Oh no! Here we go again! This issue never seems to die. But here's the thing: There's a good chance we've been WAY overthinking this.According to the GraphQL docs, a query that returns an interface doesn't require inline fragments until you're asking for a field that's specific to an implementation (concrete type). This means we can add subclasses without breaking APIs. Test casetype Query {
readPages: [PageInterface]
} models:
Page:
fields: '*' query {
readPages {
title
content
}
} Works. Now I add a subclass: class TestPage extends Page
{
private static $db = ['TestField' => 'Varchar'];
} query {
readPages {
title
content
}
} Works. Because the only fields I'm asking for are part of the interface. Now let's add some TestPage fields: models:
Page:
fields: '*'
TestPage:
fields: '*' query {
readPages {
title
content
}
} Still works. Now let's query that field. query {
readPages {
title
content
... on TestPage {
testField
}
}
} Still works. As long as we always return interfaces, polymorphic queries are opt-in, rather than required (as in unions). TradeoffsThe dev experience suffers a bit because unlike unions, the intellisense doesn't know every implementation possible (it should, though?), so you don't get autocomplete for the |
@unclecheese You've solved this by merging #393, right? |
Yup. Let's close this! |
Overview
It's hard to sync an ActiveRecord-style inheritance tree with a type system that doesn't know about inheritance. We've tried to reconcile this by creating unions in Scaffolding object type inheritance. But the proposal was flawed, because it misunderstood how unions work in GraphQL. The code examples provided illustrate the assumption that fields shared by all types in the union can be queried without defining the type context. That would make sense, and is technically possible, but not part of the GraphQL language spec.
There are two problems with the current implementation:
I'm proposing that we flatten subclasses, and put them on a specialised
extended
field instead.Example Before
Type system:
Query:
Example After
Type system:
Query:
Using
SiteTree
as an example is a bit misleading, because the query above still looks awkward. Most devs regardPage
as the "pivot" in their data model, and would choose to express that in their GraphQL queries as well.The text was updated successfully, but these errors were encountered: