src/traversal/__tests__/diffRelayQuery_connection-test.js
/**
* Copyright (c) 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @emails oncall+relay
*/
'use strict';
require('configureForRelayOSS');
jest
.unmock('GraphQLRange')
.unmock('GraphQLSegment')
.mock('warning');
const Relay = require('Relay');
const RelayConnectionInterface = require('RelayConnectionInterface');
const RelayQueryTracker = require('RelayQueryTracker');
const RelayRecordStore = require('RelayRecordStore');
const RelayRecordWriter = require('RelayRecordWriter');
const RelayTestUtils = require('RelayTestUtils');
const diffRelayQuery = require('diffRelayQuery');
describe('diffRelayQuery', () => {
const {getNode, getVerbatimNode, writePayload} = RelayTestUtils;
let HAS_NEXT_PAGE, HAS_PREV_PAGE, PAGE_INFO;
let rootCallMap;
beforeEach(() => {
jest.resetModuleRegistry();
({HAS_NEXT_PAGE, HAS_PREV_PAGE, PAGE_INFO} = RelayConnectionInterface);
rootCallMap = {
'viewer': {'': 'client:1'},
};
jasmine.addMatchers(RelayTestUtils.matchers);
});
it('returns unfetched connections as-is', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const tracker = new RelayQueryTracker();
const query = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
id
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(query, store, tracker);
expect(diffQueries.length).toBe(1);
expect(diffQueries[0]).toBeQueryRoot(query);
});
it('removes completely fetched connections', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
const payload = {
viewer: {
newsFeed: {
edges: [
{cursor: 'c1', node: {id: 's1'}},
{cursor: 'c2', node: {id: 's2'}},
{cursor: 'c3', node: {id: 's3'}},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const query = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
id
}
}
}
}
}
`);
// Write full data for all 3 items
writePayload(store, writer, query, payload, tracker);
// Everything can be diffed out
const diffQueries = diffRelayQuery(query, store, tracker);
expect(diffQueries.length).toBe(0);
});
it('returns range extensions for partially fetched connections', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
// Write full data for 3 of 5 records, nothing for edges 4-5
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
id: 's1',
__typename: 'Story',
},
},
{
cursor: 'c2',
node: {
id: 's2',
__typename: 'Story',
},
},
{
cursor: 'c3',
node: {
id: 's3',
__typename: 'Story',
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const query = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"5") {
edges {
node {
id
}
}
}
}
}
`);
writePayload(store, writer, query, payload, tracker);
// Nothing to fetch for records 1-3, fetch extension of range for 4-5
const diffQueries = diffRelayQuery(query, store, tracker);
expect(diffQueries.length).toBe(1);
expect(diffQueries[0]).toEqualQueryRoot(getNode(Relay.QL`
query {
viewer {
newsFeed(after:"c3",first:$count) {
edges {
node {
id
}
}
}
}
}
`, {
count: 2,
}));
});
it('does not fetch missing `edges` data for generated `node` ids', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
// Provide empty IDs to simulate non-refetchable nodes
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
__typename: 'Story',
message: {text: 's1'},
},
},
{
cursor: 'c2',
node: {
__typename: 'Story',
message: {text: 's2'},
},
},
{
cursor: 'c3',
node: {
__typename: 'Story',
message: {text: 's3'},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
writePayload(store, writer, writeQuery, payload, tracker);
// @relay(isConnectionWithoutNodeID: true) should silence the warning.
const fetchQueryA = getNode(Relay.QL`
query {
viewer {
newsFeed(first: "3") @relay(isConnectionWithoutNodeID: true) {
edges {
node {
feedback {
id
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQueryA, store, tracker);
expect(diffQueries.length).toBe(0);
expect([
'RelayDiffQueryBuilder: Field `node` on connection `%s` cannot be ' +
'retrieved if it does not have an `id` field. If you expect fields ' +
'to be retrieved on this field, add an `id` field in the schema. ' +
'If you choose to ignore this warning, you can silence it by ' +
'adding `@relay(isConnectionWithoutNodeID: true)` to the ' +
'connection field.',
'newsFeed',
]).toBeWarnedNTimes(0);
// `feedback{id}` is missing but there is no way to refetch it
// Warn that data cannot be refetched
const fetchQueryB = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
feedback {
id
}
}
}
}
}
}
`);
diffRelayQuery(fetchQueryB, store, tracker);
expect([
'RelayDiffQueryBuilder: Field `node` on connection `%s` cannot be ' +
'retrieved if it does not have an `id` field. If you expect fields ' +
'to be retrieved on this field, add an `id` field in the schema. ' +
'If you choose to ignore this warning, you can silence it by ' +
'adding `@relay(isConnectionWithoutNodeID: true)` to the ' +
'connection field.',
'newsFeed',
]).toBeWarnedNTimes(3);
});
it('does not warn about unrefetchable `edges` when there is no missing data', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
// Provide empty IDs to simulate non-refetchable nodes
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
__typename: 'Story',
message: {text: 's1'},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
writePayload(store, writer, writeQuery, payload, tracker);
// `message{text}` available in the store.
// Does not warn that data cannot be refetched sine no data is missing.
const fetchQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries.length).toBe(0);
expect([
'RelayDiffQueryBuilder: Field `node` on connection `%s` cannot be ' +
'retrieved if it does not have an `id` field. If you expect fields ' +
'to be retrieved on this field, add an `id` field in the schema. ' +
'If you choose to ignore this warning, you can silence it by ' +
'adding `@relay(isConnectionWithoutNodeID: true)` to the ' +
'connection field.',
'newsFeed',
]).toBeWarnedNTimes(0);
});
it('fetches split queries under unrefetchable `edges`', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
// Provide empty IDs to simulate non-refetchable nodes
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
node {
feedback {
id
comments(first:"1") {
edges {
node {
id
}
}
}
}
}
}
}
}
}
`);
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
__typename: 'Story',
feedback: {
__typename: 'Feedback',
id: 'feedbackid',
comments: {
edges: [
{
cursor: 'commentcurser1',
node: {
__typename: 'Comment',
id: 'commentid',
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
writePayload(store, writer, writeQuery, payload, tracker);
// Missing the `body{text}` on comment.
const fetchQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
node {
feedback {
id
comments(first:"1") {
edges {
node {
id
body {text}
}
}
}
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries.length).toBe(1);
expect(diffQueries[0]).toEqualQueryRoot(getNode(Relay.QL`
query {
node(id:"commentid"){
__typename
... on Comment {id, body {text}}
}
}
`));
expect([
'RelayDiffQueryBuilder: Field `node` on connection `%s` cannot be ' +
'retrieved if it does not have an `id` field. If you expect fields ' +
'to be retrieved on this field, add an `id` field in the schema. ' +
'If you choose to ignore this warning, you can silence it by ' +
'adding `@relay(isConnectionWithoutNodeID: true)` to the ' +
'connection field.',
'newsFeed',
]).toBeWarnedNTimes(0);
});
it('fetches missing `node` data via a `node()` query', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
id: 's1',
__typename: 'Story',
message: {text: 's1'},
},
},
{
cursor: 'c2',
node: {
id: 's2',
__typename: 'Story',
message: {text: 's2'},
},
},
{
cursor: 'c3',
node: {
id: 's3',
__typename: 'Story',
message: {text: 's3'},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
writePayload(store, writer, writeQuery, payload, tracker);
// Split one `node()` query per edge to fetch missing `feedback{id}`
const fetchQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
feedback {
id
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries.length).toBe(3);
expect(diffQueries[0]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s1") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[1]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s2") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[2]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s3") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
});
it('fetches missing `node` data via a `node()` query and missing `edges` ' +
'data via a `connection.find()` query if connection is findable', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
const payload = {
viewer: {
newsFeed: {
edges: [
{
cursor: 'c1',
node: {
id: 's1',
__typename: 'Story',
message: {text: 's1'},
},
},
{
cursor: 'c2',
node: {
id: 's2',
__typename: 'Story',
message: {text: 's2'},
},
},
{
cursor: 'c3',
node: {
id: 's3',
__typename: 'Story',
message: {text: 's3'},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
writePayload(store, writer, writeQuery, payload, tracker);
// node: `feedback{id}` is missing (fetch via node() query)
// edges: `sortKey` is missing (fetch via .find() query)
const fetchQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
sortKey
node {
id
__typename
feedback {
id
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries.length).toBe(6);
expect(diffQueries[0]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s1") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[1]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
viewer {
newsFeed(find:"s1") {
edges {
cursor
node {
id
__typename
}
sortKey
}
}
}
}
`));
expect(diffQueries[2]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s2") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[3]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
viewer {
newsFeed(find:"s2") {
edges {
cursor
node {
id
__typename
}
sortKey
}
}
}
}
`));
expect(diffQueries[4]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s3") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[5]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
viewer {
newsFeed(find:"s3") {
edges {
cursor
node {
id
__typename
}
sortKey
}
}
}
}
`));
// Ensure that a `__typename` field is generated
const typeField = diffQueries[5]
.getFieldByStorageKey('newsFeed')
.getFieldByStorageKey('edges')
.getFieldByStorageKey('node')
.getFieldByStorageKey('__typename');
expect(typeField).toBeTruthy();
});
it('fetches missing `node` data via a `node()` query and warns about ' +
'unfetchable `edges` data if connection is not findable', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
const payload = {
viewer: {
notificationStories: {
edges: [
{
cursor: 'c1',
node: {
id: 's1',
__typename: 'Story',
message: {text: 's1'},
},
},
{
cursor: 'c2',
node: {
id: 's2',
__typename: 'Story',
message: {text: 's2'},
},
},
{
cursor: 'c3',
node: {
id: 's3',
__typename: 'Story',
message: {text: 's3'},
},
},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const writeQuery = getNode(Relay.QL`
query {
viewer {
notificationStories(first:"3") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
writePayload(store, writer, writeQuery, payload, tracker);
// node: `feedback{id}` is missing (fetch via node() query)
// edges: `showBeeper` is missing but cannot be refetched because
// `notificationStories` does not support `.find()`
const fetchQuery = getNode(Relay.QL`
query {
viewer {
notificationStories(first:"3") {
edges {
showBeeper
node {
feedback {
id
}
}
}
}
}
}
`);
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries.length).toBe(3);
expect(diffQueries[0]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s1") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[1]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s2") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect(diffQueries[2]).toEqualQueryRoot(getVerbatimNode(Relay.QL`
query {
node(id:"s3") {
id
__typename
... on FeedUnit {
feedback {
id
}
id
__typename
}
}
}
`));
expect([
'RelayDiffQueryBuilder: connection `edges{*}` fields can only be ' +
'refetched if the connection supports the `find` call. Cannot ' +
'refetch data for field `%s`.',
'notificationStories',
]).toBeWarnedNTimes(3);
});
it('does not flatten fragments when creating new root queries', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
const payload = {
viewer: {
newsFeed: {
edges: [
{cursor: 'c1', node: {id:'s1', message:{text:'s1'}}},
],
[PAGE_INFO]: {
[HAS_NEXT_PAGE]: true,
[HAS_PREV_PAGE]: false,
},
},
},
};
const writeQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
writePayload(store, writer, writeQuery, payload, tracker);
// node: `feedback{id}` is missing (fetch via node() query)
// edges: `sortKey` is missing (fetch via .find() query)
const edgeFragment = Relay.QL`fragment on NewsFeedEdge{sortKey}`;
const nodeFragment = Relay.QL`fragment on FeedUnit{feedback{id}}`;
const fetchQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"1") {
edges {
${edgeFragment}
node {
${nodeFragment}
}
}
}
}
}
`);
// skip flattening to check fragment structure
const diffQueries = diffRelayQuery(fetchQuery, store, tracker);
expect(diffQueries[0]).toContainQueryNode(getNode(nodeFragment));
expect(diffQueries[1]).toContainQueryNode(getNode(edgeFragment));
});
it('tracks fragments for null connections', () => {
const records = {};
const store = new RelayRecordStore({records}, {rootCallMap});
const writer = new RelayRecordWriter(records, rootCallMap, false);
const tracker = new RelayQueryTracker();
// Create the first query with a selection on a connection.
const firstQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
id
actor {
name
}
}
}
}
}
}
`);
const firstPayload = {
viewer: {
newsFeed: null,
},
};
writePayload(store, writer, firstQuery, firstPayload, tracker);
let trackedQueries = tracker.trackNodeForID.mock.calls;
expect(trackedQueries.length).toBe(1);
expect(trackedQueries[0][1]).toBe('client:1');
expect(trackedQueries[0][0]).toEqualQueryRoot(firstQuery);
// Create a second query that requests a different selection on the null
// connection.
const secondQuery = getNode(Relay.QL`
query {
viewer {
newsFeed(first:"3") {
edges {
node {
message {
text
}
}
}
}
}
}
`);
// Everything can be diffed out, connection is null.
const diffQueries = diffRelayQuery(secondQuery, store, tracker);
expect(diffQueries.length).toBe(0);
// Ensure the new `message { text }` field is tracked.
trackedQueries = tracker.trackNodeForID.mock.calls;
expect(trackedQueries.length).toBe(2);
expect(trackedQueries[1][1]).toBe('client:1');
expect(trackedQueries[1][0]).toEqualQueryRoot(secondQuery);
});
});