Skip to content
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

Remove FragmentMatcher abstraction from apollo-cache-inmemory. #5073

Merged
merged 3 commits into from
Aug 1, 2019

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Jul 18, 2019

Note: this PR is a breaking change slated for Apollo Client 3.0.

Correctly matching fragments against abstract types like unions or interfaces requires knowledge of the schema, which can be obtained in multiple ways.

Previously, we provided two different FragmentMatcher implementations: the HeuristicFragmentMatcher, which would attempt to match fragments without any knowledge of the schema; and the IntrospectionFragmentMatcher, which would consume an introspection query result to obtain information about possible subtypes.

This commit removes the FragmentMatcher abstraction and both of its implementations. Instead, you can skip the introspection query step and simply pass a possibleTypes map to the InMemoryCache constructor:

new InMemoryCache({
  addTypename: true,
  freezeResults: true,
  possibleTypes: {
    Animal: ["Cat", "Dog", "MiniatureHorse", "Frog"],
    Test: ["PassingTest", "FailingTest", "SkippedTest"],
  },
})

When you provide this information, assuming it is correct and complete, the cache does not need to make any heuristic guesses, so there's really no point in ever having other FragmentMatcher implementations.

More importantly, to maintain backwards compatibility with the existing FragmentMatcher interface, the cache was forced to construct temporary data stores to trick the FragmentMatcher into doing the right thing. This commit is a breaking change that removes all that awkward logic.

Of course, you have to get the possibleTypes map from somewhere, and schema introspection is still a fine way of doing that. However, the possibleTypes map is relatively easy to write by hand, if you're just prototyping/experimenting. Once you're ready to generate it automatically from your schema, it can be easily imported from a generated module.

If the possibleTypes option is not specified, the cache will behave like the old HeuristicFragmentMatcher, which is adequate as long as you're not using fragments with abstract constraints. You only live once, as they say.

@benjamn benjamn added this to the Release 3.0 milestone Jul 18, 2019
@benjamn benjamn requested review from hwillson and jbaxleyiii July 18, 2019 20:17
@benjamn benjamn self-assigned this Jul 18, 2019
@benjamn benjamn force-pushed the remove-fragment-matcher-abstraction branch from 56d14ec to 450107a Compare July 18, 2019 21:00
);
}
} else if (
context.possibleTypes &&
Copy link
Member Author

@benjamn benjamn Jul 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a number of tests that verify warnings about missing fields, but we can’t be completely confident those fields are missing unless we have accurate possibleTypes information. This is especially important if we ever want to pursue the comment below (re: throwing instead of warning).

const workQueue = [possibleTypes[typeCondition]];
// It's important to keep evaluating workQueue.length each time through the
// loop, because the queue can grow while we're iterating over it.
for (let i = 0; i < workQueue.length; ++i) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cycle-safe breadth-first search without using recursion! 🤓🔂🦄

@benjamn
Copy link
Member Author

benjamn commented Jul 18, 2019

@hwillson The LocalState code still has a notion of a FragmentMatcher, which I am also tempted to remove. That would mean passing possibleTypes into both the ApolloClient constructor and the InMemoryCache constructor, which is somewhat repetitive. That said, the server/client types could be different, so the option to pass different possibleTypes might be useful?

@@ -1218,41 +1182,16 @@ describe('client', () => {
],
};

const fancyFragmentMatcher = (
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll down to find out what I was able to replace this fancy fraggle doodad with…

cache: new InMemoryCache({
possibleTypes: {
Item: ['ColorItem', 'MonochromeItem'],
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s just so much shorter!

Copy link
Member

@hwillson hwillson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great @benjamn - simpler is better! 🙂 The only comment I really have is around whether possibleTypes is a bit too generic as a constructor param name, but thinking it over a few times it does seem to be the best name. So, disregard - looks great!

On a related note - should we kickstart a 3.0 branch?

benjamn added 3 commits August 1, 2019 11:25
Correctly matching fragments against abstract types like unions or
interfaces requires knowledge of the schema, which can be obtained in
multiple ways.

Previously, we provided two different FragmentMatcher implementations: the
HeuristicFragmentMatcher, which would attempt to match fragments without
any knowledge of the schema; and the IntrospectionFragmentMatcher, which
would consume an introspection query result to obtain information about
possible subtypes.

This commit removes the FragmentMatcher abstraction and both of its
implementations. Instead, you can skip the introspection query step and
simply pass a possibleTypes map to the InMemoryCache constructor:

  new InMemoryCache({
    addTypename: true,
    freezeResults: true,
    possibleTypes: {
      Animal: ["Cat", "Dog", "MiniatureHorse", "Frog"],
      Test: ["PassingTest", "FailingTest", "SkippedTest"],
    },
  })

When you provide this information, assuming it is correct and complete,
the cache does not need to make any heuristic guesses, so there's really
no point in ever having other FragmentMatcher implementations.

More importantly, to maintain backwards compability with the existing
FragmentMatcher interface, the cache was forced to construct temporary
data stores to trick the FragmentMatcher into doing the right thing. This
commit is a breaking change that removes all that awkward logic.

Of course, you have to get the possibleTypes map from somewhere, and
schema introspection is still a fine way of doing that. However, the
possibleTypes map is relatively easy to write by hand, if you're just
prototyping/experimenting. Once you're ready to generate it automatically
from your schema, it can be easily imported from a generated module.

If the possibleTypes option is not specified, the cache will behave like
the old HeuristicFragmentMatcher, which is adequate as long as you're not
using fragments with abstract constraints.
In principle, the subtypes found in the possibleTypes map could have
subtypes of their own! Instead of ignoring this possibility, we should
embrace it fully and efficiently.

Subtype indirection is not necessarily a mistake, since defining an
abstract type once and then referring to it by name in multiple other
places tends to be more space-efficient than inlining all the concrete
subtypes into every possibleTypes array.
This avoids calling subtypes.indexOf in favor of an object key lookup.

It's also now possible to call cache.addPossibleTypes after constructing
the InMemoryCache, and the new types will be merged appropriately.
@benjamn benjamn force-pushed the remove-fragment-matcher-abstraction branch from 5f4eef3 to 9ccd310 Compare August 1, 2019 15:26
@benjamn benjamn changed the base branch from master to release-3.0 August 1, 2019 15:26
@benjamn benjamn merged commit 9af78cb into release-3.0 Aug 1, 2019
@benjamn benjamn deleted the remove-fragment-matcher-abstraction branch August 1, 2019 15:27
@benjamn benjamn mentioned this pull request Aug 1, 2019
31 tasks
benjamn added a commit that referenced this pull request Dec 13, 2019
Although we removed the FragmentMatcher abstraction in PR #5073, and the
HeuristicFragmentMatcher no longer exists, we kept trying to match
fragments whenever all their fields matched, even if the fragment type
condition was not satisfied by the __typename of the object in question.

I recently came to appreciate just how harmful this best-effort matching
can be, when I realized that it means we call executeSelectionSet (on the
reading side) and processSelectionSet (on the writing side) for every
unmatched fragment, and then just throw away the result if any fields are
missing, but not before recording those fields as missing in a weaker way
than usual, using the ExecResultMissingField.tolerable boolean.

Not only is this strategy potentially costly for queries with lots of
fragments that really should not match, it also only ever made sense on
the writing side, where the fields of the fragment can be compared to the
fields of an incoming result object, so the presence of all the fragment's
fields is a pretty good indication that the fragment matched. On the
reading side, we're comparing the fields of the fragment to all the fields
we've written into the cache so far, which means heuristic fragment
matching is sensitive to the whole history of cache writes for the entity
in question, not just the current query.

We could eliminate heuristic fragment matching just for reading, but then
we'd have to tell people to pass possibleTypes to the InMemoryCache
constructor (the correct, non-heuristic solution), which would make
heuristic fragment matching unnecessary on the writing side, too, so we
might as well completely eliminate heuristic matching altogether.
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants