Home Reference Source Repository

src/legacy/store/__tests__/GraphQLStoreQueryResolver-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')
  .unmock('GraphQLStoreQueryResolver');

const GraphQLStoreQueryResolver = require('GraphQLStoreQueryResolver');
const Relay = require('Relay');
const RelayStoreData = require('RelayStoreData');
const RelayTestUtils = require('RelayTestUtils');

const readRelayQueryData = require('readRelayQueryData');
const transformRelayQueryPayload = require('transformRelayQueryPayload');

describe('GraphQLStoreQueryResolver', () => {
  let changeEmitter;
  let storeData;

  let dataID;
  let mockCallback;
  let mockQueryFragment;
  let mockPluralQueryFragment;

  const {getNode} = RelayTestUtils;

  function mockReader(mockResult) {
    readRelayQueryData.mockImplementation((_, __, dataIDArg) => {
      return {
        dataIDs: {[dataIDArg]: true},
        data: mockResult[dataIDArg],
      };
    });
  }

  beforeEach(() => {
    jest.resetModuleRegistry();

    storeData = new RelayStoreData();
    changeEmitter = storeData.getChangeEmitter();

    dataID = '1038750002';
    mockCallback = jest.fn();
    mockQueryFragment = getNode(Relay.QL`fragment on Node{id,name}`);
    mockPluralQueryFragment = getNode(Relay.QL`
      fragment on Node @relay(plural:true) {
        id
        name
      }
    `);

    jasmine.addMatchers(RelayTestUtils.matchers);
  });

  it('should resolve a pointer', () => {
    const mockResult = {__dataID__: '1038750002', id: '1038750002', name: 'Tim'};
    readRelayQueryData.mockReturnValue({data: mockResult});

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );
    const resolved = resolver.resolve(mockQueryFragment, dataID);

    expect(resolved).toBe(mockResult);

    expect(readRelayQueryData).toBeCalled();
    expect(readRelayQueryData.mock.calls[0][1]).toBe(mockQueryFragment);
    expect(readRelayQueryData.mock.calls[0][2]).toEqual(dataID);
  });

  it('should subscribe to IDs in resolved pointer', () => {
    const mockResult = {
      '1038750002': {__dataID__: '1038750002', id: '1038750002', name: 'Tim'},
    };
    mockReader(mockResult);

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );
    resolver.resolve(mockQueryFragment, dataID);

    const addListenersForIDs = changeEmitter.addListenerForIDs;
    expect(addListenersForIDs).toBeCalled();
    expect(addListenersForIDs.mock.calls[0][0]).toEqual(['1038750002']);
  });

  it('should not re-resolve pointers without change events', () => {
    const mockResultA = {__dataID__: '1038750002', id: '1038750002', name: 'Tim'};
    const mockResultB = {__dataID__: '1038750002', id: '1038750002', name: 'Tim'};

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );

    readRelayQueryData.mockReturnValue({data: mockResultA});
    const resolvedA = resolver.resolve(mockQueryFragment, dataID);

    readRelayQueryData.mockReturnValue({data: mockResultB});
    const resolvedB = resolver.resolve(mockQueryFragment, dataID);

    expect(readRelayQueryData.mock.calls.length).toBe(1);
    expect(resolvedA).toBe(resolvedB);
  });

  it('should re-resolve pointers with change events', () => {
    const mockResultA = {__dataID__: '1038750002', id: '1038750002', name: 'Tim'};
    const mockResultB = {__dataID__: '1038750002', id: '1038750002', name: 'Tee'};

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );

    mockReader({
      [mockResultA.id]: mockResultA,
    });
    const resolvedA = resolver.resolve(mockQueryFragment, dataID);

    const callback = changeEmitter.addListenerForIDs.mock.calls[0][1];
    callback(['1038750002']);

    mockReader({
      [mockResultB.id]: mockResultB,
    });
    const resolvedB = resolver.resolve(mockQueryFragment, dataID);

    expect(readRelayQueryData.mock.calls.length).toBe(2);
    expect(resolvedA).toBe(mockResultA);
    expect(resolvedB).toBe(mockResultB);
  });

  it('should re-resolve pointers whose calls differ', () => {
    const dataIDA = 'client:123_first(10)';
    const dataIDB = 'client:123_first(20)';

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );

    require('GraphQLStoreRangeUtils').getCanonicalClientID =
      // The canonical ID of a range customarily excludes the calls
      jest.fn(() => 'client:123');

    resolver.resolve(mockQueryFragment, dataIDA);
    resolver.resolve(mockQueryFragment, dataIDB);

    expect(readRelayQueryData.mock.calls.length).toBe(2);
  });

  it('should invoke the callback when change events fire', () => {
    const mockResult = {
      '1038750002': {__dataID__: '1038750002', id: '1038750002', name: 'Tim'},
    };

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockQueryFragment,
      mockCallback
    );

    mockReader(mockResult);
    resolver.resolve(mockQueryFragment, dataID);

    const callback = changeEmitter.addListenerForIDs.mock.calls[0][1];
    callback(['1038750002']);

    expect(mockCallback).toBeCalled();
  });

  it('should resolve an array of pointers', () => {
    const mockResults = {
      '1': {__dataID__: '1', name: 'One'},
      '2': {__dataID__: '2', name: 'Two'},
    };
    mockReader(mockResults);

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockPluralQueryFragment,
      mockCallback
    );

    const resolved = resolver.resolve(mockPluralQueryFragment, ['1', '2']);
    expect(resolved.length).toBe(2);
    expect(resolved[0]).toBe(mockResults['1']);
    expect(resolved[1]).toBe(mockResults['2']);

    expect(readRelayQueryData.mock.calls[0][2]).toEqual('1');
    expect(readRelayQueryData.mock.calls[1][2]).toEqual('2');
  });

  it('should not re-resolve if the pointer array has no changes', () => {
    const mockResults = {
      '1': {__dataID__: '1', name: 'One'},
      '2': {__dataID__: '2', name: 'Two'},
    };
    mockReader(mockResults);

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockPluralQueryFragment,
      mockCallback
    );

    const resolvedA = resolver.resolve(mockPluralQueryFragment, ['1', '2']);
    const resolvedB = resolver.resolve(mockPluralQueryFragment, ['1', '2']);

    expect(resolvedA).toBe(resolvedB);
  });

  it('should only re-resolve pointers with changes in an array', () => {
    const mockResults = {
      '1': {__dataID__: '1', name: 'One'},
      '2': {__dataID__: '2', name: 'Two'},
    };
    mockReader(mockResults);

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockPluralQueryFragment,
      mockCallback
    );

    const resolvedA = resolver.resolve(mockPluralQueryFragment, ['1', '2']);

    mockResults['1'] = {__dataID__: '1', name: 'Won'};
    const callback = changeEmitter.addListenerForIDs.mock.calls[0][1];
    callback(['1']);

    const resolvedB = resolver.resolve(mockPluralQueryFragment, ['1', '2']);

    expect(resolvedA).not.toBe(resolvedB);

    expect(resolvedB.length).toBe(2);
    expect(resolvedB[0]).toBe(mockResults['1']);
    expect(resolvedB[1]).toBe(mockResults['2']);

    expect(readRelayQueryData.mock.calls.length).toBe(3);
    expect(readRelayQueryData.mock.calls[2][2]).toEqual('1');
  });

  it('should create a new array if the pointer array shortens', () => {
    const mockResults = {
      '1': {__dataID__: '1', name: 'One'},
      '2': {__dataID__: '2', name: 'Two'},
    };
    mockReader(mockResults);

    const resolver = new GraphQLStoreQueryResolver(
      storeData,
      mockPluralQueryFragment,
      mockCallback
    );

    const resolvedA = resolver.resolve(mockPluralQueryFragment, ['1', '2']);
    const resolvedB = resolver.resolve(mockPluralQueryFragment, ['1']);

    expect(resolvedA).not.toBe(resolvedB);

    expect(resolvedA.length).toBe(2);
    expect(resolvedB.length).toBe(1);
  });

  describe('garbage collection', () => {
    let fragment;

    beforeEach(() => {
      storeData.initializeGarbageCollector(run => {
        while (run()) {}
      });
      const containerFragment = RelayTestUtils.createContainerFragment(Relay.QL`
        fragment on NewsFeedConnection {
          edges {
            node {
              id
            }
          }
        }
      `);
      const concreteFragment = Relay.QL`
        fragment on Viewer {
          actor {
            id
          }
          newsFeed(first: "1") {
            ${containerFragment}
          }
        }
      `;
      const query = getNode(Relay.QL`
        query {
          viewer {
            ${concreteFragment}
          }
        }
      `);
      const payload = {
        viewer: {
          actor: {
            __typename: 'User',
            id: '123',
          },
          newsFeed: {
            edges: [
              {
                node: {
                  __typename: 'Story',
                  id: '456',
                },
              },
            ],
          },
        },
      };
      storeData.handleQueryPayload(
        query,
        transformRelayQueryPayload(query, payload),
        1
      );
      dataID = 'client:1';
      fragment = getNode(concreteFragment);
    });

    it('increments references to read data', () => {
      const queryResolver = new GraphQLStoreQueryResolver(
        storeData,
        fragment,
        jest.fn()
      );
      // read data and set up subscriptions
      queryResolver.resolve(fragment, dataID);
      // evict unreferenced nodes
      storeData.getGarbageCollector().collect();
      jest.runAllTimers();
      // nodes referenced by the fragment should not be evicted
      expect(Object.keys(storeData.getNodeData())).toEqual([
        '123',      // viewer.actor
        'client:1', // viewer
        'client:2', // viewer.newsFeed
      ]);
    });

    it('decrements references to previously read fields', () => {
      const queryResolver = new GraphQLStoreQueryResolver(
        storeData,
        fragment,
        jest.fn()
      );
      // read data and increment GC ref counts
      queryResolver.resolve(fragment, dataID);
      const callback =
        storeData.getChangeEmitter().addListenerForIDs.mock.calls[0][1];

      // Remove the link to viewer.actor and broadcast an update
      storeData.getRecordWriter().putField('client:1', 'actor', null);
      storeData.getRecordWriter().putField('client:1', 'newsFeed', null);
      callback(['client:1']);

      // re-read and increment/decrement GC ref counts
      queryResolver.resolve(fragment, dataID);

      // evict unreferenced nodes
      storeData.getGarbageCollector().collect();
      jest.runAllTimers();
      // nodes referenced by the fragment should not be evicted
      expect(Object.keys(storeData.getNodeData())).toEqual([
        // '123' (actor) is unreferenced and collected
        // 'client:2' (viewer.newsFeed) is unreferenced and collected
        'client:1', // viewer
      ]);
    });

    it('decrements references when disposed', () => {
      const queryResolver = new GraphQLStoreQueryResolver(
        storeData,
        fragment,
        jest.fn()
      );
      // read data and increment GC ref counts
      queryResolver.resolve(fragment, dataID);
      // reset the resolver; should unreference all nodes
      queryResolver.dispose();

      // evict unreferenced nodes
      storeData.getGarbageCollector().collect();
      jest.runAllTimers();
      // all nodes are unreferenced and should be removed
      expect(storeData.getNodeData()).toEqual({});
    });
  });
});