Home Reference Source Repository

src/store/__tests__/RelayRecordStore-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.mock('warning');

const GraphQLRange = require('GraphQLRange');
const Relay = require('Relay');
const RelayQueryPath = require('RelayQueryPath');
const RelayRecordStore = require('RelayRecordStore');
const RelayRecordWriter = require('RelayRecordWriter');
const RelayTestUtils = require('RelayTestUtils');

describe('RelayRecordStore', () => {
  const {getNode} = RelayTestUtils;

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

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

  describe('getRecordState()', () => {
    it('returns "UNKNOWN" if an ID is unfetched', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      expect(store.getRecordState('4')).toBe('UNKNOWN');
    });

    it('returns "NONEXISTENT" if an ID is deleted', () => {
      const records = {'4': null};
      const store = new RelayRecordStore({records});
      expect(store.getRecordState('4')).toBe('NONEXISTENT');
    });

    it('returns "EXISTENT" if the record exists', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });

    it('prefers queued records over non-existent records', () => {
      const queuedRecord = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {},
        queuedRecords: {'4': queuedRecord},
      });
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });

    it('prefers queued records over deleted records', () => {
      const queuedRecord = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': null},
        queuedRecords: {'4': queuedRecord},
      });
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });

    it('prefers queued records when they are deleted', () => {
      const record = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': record},
        queuedRecords: {'4': null},
      });
      expect(store.getRecordState('4')).toBe('NONEXISTENT');
    });

    it('prefers queued records over cached records', () => {
      const record = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        queuedRecords: {'4': record},
        records: {},
        cachedRecords: {'4': null},
      });
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });

    it('prefers existing records over cached records', () => {
      const record = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': record},
        cachedRecords: {'4': null},
      });
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });

    it('falls back to cached records when necessary', () => {
      const record = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {},
        cachedRecords: {'4': record},
      });
      expect(store.getRecordState('4')).toBe('EXISTENT');
    });
  });

  describe('hasOptimisticUpdate', () => {
    it('returns true if record is queued', () => {
      const store = new RelayRecordStore({
        records: {},
        queuedRecords: {'4': {__dataID__: '4'}},
      });
      expect(store.hasOptimisticUpdate('4')).toBe(true);
    });

    it('returns false if record is not queued', () => {
      const store = new RelayRecordStore({
        records: {'4': {__dataID__: '4'}},
        queuedRecords: {},
      });
      expect(store.hasOptimisticUpdate('4')).toBe(false);
    });

    it('returns false if called on a non-queued record store', () => {
      const store = new RelayRecordStore({
        records: {'4': {__dataID__: '4'}},
      });
      expect(store.hasOptimisticUpdate('4')).toBe(false);
    });
  });

  describe('getPathToRecord', () => {
    it('returns undefined for refetchable records', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      const writer = new RelayRecordWriter(records, {}, false);
      const query = getNode(Relay.QL`
        query {
          viewer {
            actor {
              id
            }
          }
        }
      `);
      const actorID = '123';
      const path = RelayQueryPath.getPath(
        RelayQueryPath.create(query),
        query.getFieldByStorageKey('actor'),
        actorID
      );
      writer.putRecord(actorID, 'Type', path);
      expect(store.getPathToRecord(actorID)).toBe(undefined);
    });

    it('returns the path for non-refetchable records', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      const writer = new RelayRecordWriter(records, {}, false);
      const query = getNode(Relay.QL`
        query {
          viewer {
            actor {
              address {
                city
              }
            }
          }
        }
      `);
      const actorID = '123';
      const addressID = 'client:1';
      const path = RelayQueryPath.getPath(
        RelayQueryPath.getPath(
          RelayQueryPath.create(query),
          query.getFieldByStorageKey('actor'),
          actorID
        ),
        query.getFieldByStorageKey('actor').getFieldByStorageKey('address'),
        addressID
      );
      writer.putRecord(addressID, 'Type', path);
      expect(store.getPathToRecord(addressID)).toMatchPath(path);
    });
  });

  describe('getField()', () => {
    it('returns undefined if the record is undefined', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      expect(store.getField('4', 'name')).toBe(undefined);
    });

    it('returns null if the record is deleted', () => {
      const records = {'4': null};
      const store = new RelayRecordStore({records});
      expect(store.getField('4', 'name')).toBe(null);
    });

    it('returns undefined if the field is undefined', () => {
      const records = {'4': {}};
      const store = new RelayRecordStore({records});
      expect(store.getField('4', 'name')).toBe(undefined);
    });

    it('returns null if the field is deleted', () => {
      const records = {'4': {'name': null}};
      const store = new RelayRecordStore({records});
      expect(store.getField('4', 'name')).toBe(null);
    });

    it('returns field values for scalar fields', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
          name: 'Zuck',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getField('4', 'name')).toBe('Zuck');
      expect(store.getField('4', 'id')).toBe('4');

      const queuedStore = new RelayRecordStore({queuedRecords: records});
      expect(queuedStore.getField('4', 'name')).toBe('Zuck');
      expect(queuedStore.getField('4', 'id')).toBe('4');

      const cachedStore = new RelayRecordStore({cachedRecords: records});
      expect(cachedStore.getField('4', 'name')).toBe('Zuck');
      expect(cachedStore.getField('4', 'id')).toBe('4');
    });

    it('prefers fields from queued records', () => {
      const record = {
        id: '4',
        name: 'Zuck',
        __dataID__: '4',
      };
      const queuedRecord = {
        id: '4',
        name: 'Mark',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': record},
        queuedRecords: {'4': queuedRecord},
      });
      expect(store.getField('4', 'name')).toBe('Mark');
    });

    it('prefers fields from existing records over cached records', () => {
      const record = {
        id: '4',
        name: 'Zuck',
        __dataID__: '4',
      };
      const cachedRecord = {
        id: '4',
        name: 'Mark',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': record},
        cachedRecords: {'4': cachedRecord},
      });
      expect(store.getField('4', 'name')).toBe('Zuck');
    });

    it('falls through to existing records for fields not in the queued record', () => {
      const record = {
        id: '4',
        name: 'Zuck',
        __dataID__: '4',
      };
      const queuedRecord = {
        id: '4',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        records: {'4': record},
        queuedRecords: {'4': queuedRecord},
      });
      expect(store.getField('4', 'name')).toBe('Zuck');
    });

    it('falls through to cached records for fields not in the existing record', () => {
      const record = {
        id: '4',
        __dataID__: '4',
      };
      const cachedRecord = {
        id: '4',
        name: 'Mark',
        __dataID__: '4',
      };
      const store = new RelayRecordStore({
        cachedRecords: {'4': cachedRecord},
        records: {'4': record},
      });
      expect(store.getField('4', 'name')).toBe('Mark');
    });
  });

  describe('getLinkedRecordID()', () => {
    it('throws if the data is an unexpected format', () => {
      const records = {
        story: {
          feedback: 'not an object',
        },
      };
      const store = new RelayRecordStore({records});
      expect(() => {
        store.getLinkedRecordID('story', 'feedback');
      }).toThrow();
    });

    it('returns undefined for unfetched objects', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordID('4', 'address')).toBe(undefined);
    });

    it('returns null for deleted linked fields', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
          address: null,
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordID('4', 'address')).toBe(null);
    });

    it('returns the data ID for linked fields', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
          address: {
            __dataID__: 'client:1',
          },
        },
        'client:1': {
          street: '1 Hacker Way',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordID('4', 'address')).toBe('client:1');
    });
  });

  describe('getLinkedRecordIDs()', () => {
    it('throws if the data is an unexpected format', () => {
      const records = {
        'story': {
          actors: ['not an object'],
        },
      };
      const store = new RelayRecordStore({records});
      expect(() => {
        store.getLinkedRecordIDs('story', 'actors');
      }).toThrow();
    });

    it('returns undefined for unfetched fields', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordIDs('4', 'actors')).toBe(undefined);
    });

    it('returns null for deleted linked fields', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
          actors: null,
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordIDs('4', 'actors')).toBe(null);
    });

    it('returns an array of linked data IDs', () => {
      const records = {
        '4': {
          id: '4',
          __dataID__: '4',
          actors: [
            {__dataID__: 'item:1'},
            {__dataID__: 'item:2'},
          ],
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getLinkedRecordIDs('4', 'actors')).toEqual([
        'item:1',
        'item:2',
      ]);
    });
  });

  describe('getRangeMetadata()', () => {
    let mockRange, records;

    beforeEach(() => {
      mockRange = new GraphQLRange();
      records = {
        '4': {
          id: '4',
          __dataID__: '4',
          'friends': {
            __dataID__: 'client:1',
          },
        },
        'client:1': {
          __range__: mockRange,
        },
        'edge:1': {
          __dataID__: 'edge:1',
          node: {
            __dataID__: 'node:1',
          },
        },
        'node:1': {
          __dataID__: 'node:1',
        },
      };
    });

    it('returns null/undefined if the connection ID is null-ish', () => {
      const store = new RelayRecordStore({records: {}});
      expect(store.getRangeMetadata(null, [])).toBe(null);
      expect(store.getRangeMetadata(undefined, [])).toBe(undefined);
    });

    it('returns undefined if the `edges` are unfetched', () => {
      delete records['client:1'].__range__;
      const store = new RelayRecordStore({records});
      const calls = [
        {name: 'first', value: '10'},
        {name: 'orderby', value: 'TOP_STORIES'},
      ];
      expect(store.getRangeMetadata('client:1', calls)).toBe(undefined);
    });

    it('throws if the range is null', () => {
      records['client:1'].__range__ = null;
      const store = new RelayRecordStore({records});
      store.getRangeMetadata('client:1', []);
      expect([
        'RelayRecordStore.getRangeMetadata(): Expected range to exist if ' +
        '`edges` has been fetched.',
      ]).toBeWarnedNTimes(1);
    });

    it('filters out edges without nodes', () => {
      records['node:1'] = null;
      const store = new RelayRecordStore({records});
      mockRange.retrieveRangeInfoForQuery.mockReturnValue({
        requestedEdgeIDs: ['edge:1'],
      });
      const metadata = store.getRangeMetadata(
        'client:1',
        [{name: 'first', value: 1}]
      );
      expect(metadata.filteredEdges).toEqual([]);
    });

    it('returns empty diff calls if range is already fetched', () => {
      const diffCalls = [];
      mockRange.retrieveRangeInfoForQuery.mockReturnValue({diffCalls});
      const store = new RelayRecordStore({records});
      const rangeInfo = store.getRangeMetadata('client:1', []);
      expect(rangeInfo.diffCalls).toEqual([]);
      expect(rangeInfo.filterCalls).toEqual([]);
      expect(rangeInfo.filteredEdges).toEqual([]);
    });

    it('returns diff/filter calls and requested edges from the range', () => {
      mockRange.retrieveRangeInfoForQuery.mockReturnValue({
        requestedEdgeIDs: ['edge:1'],
        diffCalls: [
          {name: 'first', value: '1'},
          {name: 'after', value: 'edge:1'},
        ],
      });
      const store = new RelayRecordStore({records});
      const rangeInfo = store.getRangeMetadata('client:1', [
        {name: 'orderby', value: ['TOP_STORIES']},
        {name: 'first', value: 2},
      ]);
      expect(mockRange.retrieveRangeInfoForQuery).toBeCalled();
      expect(rangeInfo.diffCalls).toEqual([
        {name: 'orderby', value: ['TOP_STORIES']},
        {name: 'first', value: '1'},
        {name: 'after', value: 'edge:1'},
      ]);
      expect(rangeInfo.filteredEdges).toEqual([{
        edgeID: 'edge:1',
        nodeID: 'node:1',
      }]);
      expect(rangeInfo.filterCalls).toEqual([
        {name: 'orderby', value: ['TOP_STORIES']},
      ]);
    });
  });

  describe('getRangeFilterCalls', () => {
    it('returns null/undefined for deleted/unfetched records', () => {
      const records = {
        deleted: null,
        notARange: {},
      };
      const store = new RelayRecordStore({records});

      expect(store.getRangeFilterCalls('unfetched')).toBe(undefined);
      expect(store.getRangeFilterCalls('deleted')).toBe(null);
      expect(store.getRangeFilterCalls('notARange')).toBe(undefined);
    });

    it('returns filter calls for range records', () => {
      const calls = [
        {
          name: 'orderby',
          value: 'TOP_STORIES',
        },
      ];
      const records = {
        'client:1': {
          __range__: new GraphQLRange(),
          __filterCalls__: calls,
        },
      };
      const store = new RelayRecordStore({records});

      expect(store.getRangeFilterCalls('client:1')).toEqual(calls);
    });
  });

  describe('getConnectionIDsForRecord', () => {
    it('returns null for non-existent records', () => {
      const records = {
        deleted: null,
      };
      const store = new RelayRecordStore({records});
      expect(store.getConnectionIDsForRecord('unfetched')).toBe(null);
      expect(store.getConnectionIDsForRecord('deleted')).toBe(null);
    });

    it('returns null if the record is not in a connection', () => {
      const records = {
        '1': {
          __dataID__: '1',
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getConnectionIDsForRecord('1')).toBe(null);
    });

    it('returns the connection ids containing the node', () => {
      const records = {
        '1': {
          __dataID__: '1',
        },
        'range:1': {
          __dataID__: 'range:1',
        },
        'range:2': {
          __dataID__: 'range:2',
        },
      };
      const nodeRangeMap = {
        '1': {
          'range:1': true,
          'range:2': true,
        },
      };
      const store = new RelayRecordStore({records}, null, nodeRangeMap);
      const writer = new RelayRecordWriter(records, {}, false, nodeRangeMap);
      expect(store.getConnectionIDsForRecord('1')).toEqual([
        'range:1',
        'range:2',
      ]);

      // node/connection link is cleared when the node is deleted
      writer.deleteRecord('1');
      expect(store.getConnectionIDsForRecord('1')).toEqual(null);
    });
  });

  describe('getConnectionIDsForField()', () => {
    it('returns null/undefined for non-existent records', () => {
      const records = {
        'deleted': null,
      };
      const store = new RelayRecordStore({records});
      expect(store.getConnectionIDsForField('unfetched', 'news_feed')).toBe(
        undefined
      );
      expect(store.getConnectionIDsForField('deleted', 'news_feed')).toBe(null);
    });

    it('returns undefined if the connection is unfetched', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      const writer = new RelayRecordWriter(records, {}, false);
      writer.putRecord('1', 'Type');
      expect(store.getConnectionIDsForField('1', 'news_feed')).toBe(undefined);
    });

    it('returns all fetched connections', () => {
      const records = {
        '1': {
          __dataID__: '1',
          'photos': {
            __dataID__: '2',
          },
          'photos{orderby:"likes"}': {
            __dataID__: '3',
          },
        },
      };
      const store = new RelayRecordStore({records});
      expect(store.getConnectionIDsForField('1', 'photos')).toEqual(['2', '3']);
    });
  });

  describe('getRootCallID', () => {
    it('returns undefined if unfetched and not cached', () => {
      const records = {};
      const store = new RelayRecordStore({records});
      expect(store.getDataID('viewer')).toBe(undefined);
    });

    it('returns cached id if unfetched', () => {
      const id = 'client:1';
      const cachedRootCallMap = {viewer: {'': id}};
      const rootCallMap = {};
      const records = {};

      const store = new RelayRecordStore(
        {records},
        {rootCallMap, cachedRootCallMap}
      );
      expect(store.getDataID('viewer')).toBe(id);
    });

    it('returns fetched id over cached id', () => {
      const cachedID = 'client:cached';
      const cachedRootCallMap = {viewer: {'': cachedID}};
      const id = 'client:fetched';
      const rootCallMap = {viewer: {'': id}};
      const records = {};

      const store = new RelayRecordStore(
        {records},
        {rootCallMap, cachedRootCallMap}
      );
      expect(store.getDataID('viewer')).toBe(id);
    });
  });

  describe('removeRecord', () => {
    it('completely removes the data from the store', () => {
      const cachedRecords = {'a': {__dataID__: 'a'}};
      const queuedRecords = {'a': {__dataID__: 'a'}};
      const records = {'a': {__dataID__: 'a'}};
      const nodeConnectionMap = {
        a: {'client:1': true},
      };
      const store = new RelayRecordStore(
        {cachedRecords, queuedRecords, records},
        null,
        nodeConnectionMap
      );
      expect(cachedRecords.hasOwnProperty('a')).toBe(true);
      expect(queuedRecords.hasOwnProperty('a')).toBe(true);
      expect(records.hasOwnProperty('a')).toBe(true);
      expect(nodeConnectionMap.hasOwnProperty('a')).toBe(true);
      store.removeRecord('a');
      expect(cachedRecords.hasOwnProperty('a')).toBe(false);
      expect(queuedRecords.hasOwnProperty('a')).toBe(false);
      expect(records.hasOwnProperty('a')).toBe(false);
      expect(nodeConnectionMap.hasOwnProperty('a')).toBe(false);
    });
  });

  describe('hasFragmentData()', () => {
    it('returns true when a fragment has been marked as resolved', () => {
      const records = {
        'a': {'__resolvedFragmentMap__': {'fragID': true}},
      };
      const store = new RelayRecordStore({records});
      expect(store.hasFragmentData('a', 'fragID')).toBe(true);
    });

    it('returns false when a fragment has not been marked as resolved', () => {
      const records = {
        // No resolved fragment map at all
        'a': {},
        // Map does not contain a key corresponding to our fragment
        'b': {'__resolvedFragmentMap__': {'otherFragID': true}},
      };
      const store = new RelayRecordStore({records});
      expect(store.hasFragmentData('a', 'fragID')).toBe(false);
      expect(store.hasFragmentData('b', 'fragID')).toBe(false);
    });
  });
});