// eslint-env jest /// import { normalize, MemoCache } from '@data-client/normalizr'; import { denormalize as plainDenormalize } from '@data-client/normalizr'; import { IDEntity } from '__tests__/new'; import { SimpleMemoCache } from './denormalize'; import { schema } from '../..'; let dateSpy: jest.Spied; beforeAll(() => { dateSpy = jest .spyOn(global.Date, 'now') .mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf()); }); afterAll(() => { dateSpy.mockRestore(); }); class Building extends IDEntity { readonly name: string = ''; readonly floors: number = 1; } class Manager extends IDEntity { readonly name: string = ''; } class Department extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; readonly manager: Manager = {} as any; static schema = { buildings: new schema.Lazy([Building]), manager: Manager, }; } class SingleRefDepartment extends IDEntity { readonly name: string = ''; readonly mainBuilding: string = ''; static schema = { mainBuilding: new schema.Lazy(Building), }; } describe('Lazy schema', () => { describe('normalize → denormalize round-trip', () => { const apiResponse = { id: 'dept-1', name: 'Engineering', manager: { id: 'mgr-1', name: 'Alice' }, buildings: [ { id: 'bldg-1', name: 'Building A', floors: 3 }, { id: 'bldg-2', name: 'Building B', floors: 5 }, ], }; test('normalize stores entities correctly through Lazy', () => { const result = normalize(Department, apiResponse, []); expect(result.result).toBe('dept-1'); expect(Object.keys(result.entities.Building)).toEqual([ 'bldg-1', 'bldg-2', ]); expect(result.entities.Building['bldg-1']).toEqual({ id: 'bldg-1', name: 'Building A', floors: 3, }); expect(result.entities.Building['bldg-2']).toEqual({ id: 'bldg-2', name: 'Building B', floors: 5, }); expect(result.entities.Manager['mgr-1']).toEqual({ id: 'mgr-1', name: 'Alice', }); expect(result.entities.Department['dept-1'].buildings).toEqual([ 'bldg-1', 'bldg-2', ]); expect(result.entities.Department['dept-1'].manager).toBe('mgr-1'); }); test('denormalize resolves non-Lazy fields but keeps Lazy fields as raw IDs', () => { const { result, entities } = normalize(Department, apiResponse, []); const dept: any = plainDenormalize(Department, result, entities); expect(dept.id).toBe('dept-1'); expect(dept.name).toBe('Engineering'); // non-Lazy field Manager is fully resolved expect(dept.manager).toBeInstanceOf(Manager); expect(dept.manager.id).toBe('mgr-1'); expect(dept.manager.name).toBe('Alice'); // Lazy field buildings stays as raw PK array expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); expect(typeof dept.buildings[0]).toBe('string'); expect(typeof dept.buildings[1]).toBe('string'); }); test('full pipeline: normalize → parent denorm → LazyQuery resolves', () => { const { result, entities } = normalize(Department, apiResponse, []); const dept: any = plainDenormalize(Department, result, entities); // Parent has raw IDs expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); // Use LazyQuery to resolve those IDs into full Building entities const memo = new MemoCache(); const state = { entities, indexes: {} }; const queryResult = memo.query( Department.schema.buildings.query, [dept.buildings], state, ); const buildings = queryResult.data as any[]; expect(buildings).toHaveLength(2); expect(buildings[0]).toBeInstanceOf(Building); expect(buildings[0].id).toBe('bldg-1'); expect(buildings[0].name).toBe('Building A'); expect(buildings[0].floors).toBe(3); expect(buildings[1]).toBeInstanceOf(Building); expect(buildings[1].id).toBe('bldg-2'); expect(buildings[1].name).toBe('Building B'); expect(buildings[1].floors).toBe(5); }); }); describe('normalization', () => { test('single entity ref normalizes correctly through Lazy', () => { const result = normalize( SingleRefDepartment, { id: 'dept-1', name: 'Engineering', mainBuilding: { id: 'bldg-1', name: 'HQ', floors: 10 }, }, [], ); expect(result.entities.SingleRefDepartment['dept-1'].mainBuilding).toBe( 'bldg-1', ); expect(result.entities.Building['bldg-1']).toEqual({ id: 'bldg-1', name: 'HQ', floors: 10, }); }); test('normalizing Lazy field with empty array', () => { const result = normalize( Department, { id: 'dept-empty', name: 'Empty Dept', manager: { id: 'mgr-1', name: 'Bob' }, buildings: [], }, [], ); expect(result.entities.Department['dept-empty'].buildings).toEqual([]); expect(result.entities.Building).toBeUndefined(); }); }); describe('denormalization preserves raw IDs', () => { const entities = { Department: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1', 'bldg-2'], manager: 'mgr-1', }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, }, Manager: { 'mgr-1': { id: 'mgr-1', name: 'Alice' }, }, }; test('plainDenormalize keeps Lazy array as string IDs', () => { const dept: any = plainDenormalize(Department, 'dept-1', entities); expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); expect(dept.buildings[0]).not.toBeInstanceOf(Building); // non-Lazy Manager IS resolved expect(dept.manager).toBeInstanceOf(Manager); expect(dept.manager.name).toBe('Alice'); }); test('SimpleMemoCache keeps Lazy array as string IDs', () => { const memo = new SimpleMemoCache(); const dept: any = memo.denormalize(Department, 'dept-1', entities); expect(typeof dept).toBe('object'); expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); expect(dept.manager).toBeInstanceOf(Manager); }); test('single entity Lazy field stays as string PK', () => { const singleEntities = { SingleRefDepartment: { 'dept-1': { id: 'dept-1', name: 'Engineering', mainBuilding: 'bldg-1', }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'HQ', floors: 10 }, }, }; const dept: any = plainDenormalize( SingleRefDepartment, 'dept-1', singleEntities, ); expect(dept.mainBuilding).toBe('bldg-1'); expect(typeof dept.mainBuilding).toBe('string'); }); test('parent paths exclude lazy entity dependencies', () => { const memo = new MemoCache(); const result = memo.denormalize(Department, 'dept-1', entities); expect(result.paths.some(p => p.key === 'Building')).toBe(false); expect(result.paths.some(p => p.key === 'Department')).toBe(true); // Manager IS in paths because it's a non-Lazy field expect(result.paths.some(p => p.key === 'Manager')).toBe(true); }); }); describe('LazyQuery resolution via .query', () => { const state = { entities: { Department: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1', 'bldg-2'], manager: 'mgr-1', }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, 'bldg-3': { id: 'bldg-3', name: 'Building C', floors: 2 }, }, Manager: { 'mgr-1': { id: 'mgr-1', name: 'Alice' }, }, SingleRefDepartment: { 'dept-1': { id: 'dept-1', name: 'Engineering', mainBuilding: 'bldg-1', }, }, }, indexes: {}, }; test('.query getter always returns the same instance', () => { const lazy = Department.schema.buildings; expect(lazy.query).toBe(lazy.query); }); test('resolves array of IDs into Building instances', () => { const memo = new MemoCache(); const result = memo.query( Department.schema.buildings.query, [['bldg-1', 'bldg-2']], state, ); const buildings = result.data as any[]; expect(buildings).toHaveLength(2); expect(buildings[0]).toBeInstanceOf(Building); expect(buildings[0].id).toBe('bldg-1'); expect(buildings[0].name).toBe('Building A'); expect(buildings[0].floors).toBe(3); expect(buildings[1]).toBeInstanceOf(Building); expect(buildings[1].id).toBe('bldg-2'); expect(buildings[1].name).toBe('Building B'); expect(buildings[1].floors).toBe(5); }); test('resolved entities track Building dependencies', () => { const memo = new MemoCache(); const result = memo.query( Department.schema.buildings.query, [['bldg-1', 'bldg-2']], state, ); const buildingPaths = result.paths.filter(p => p.key === 'Building'); expect(buildingPaths).toHaveLength(2); expect(buildingPaths.map(p => p.pk).sort()).toEqual(['bldg-1', 'bldg-2']); // Department should NOT be in paths — we're only resolving buildings expect(result.paths.some(p => p.key === 'Department')).toBe(false); }); test('subset of IDs resolves only those buildings', () => { const memo = new MemoCache(); const result = memo.query( Department.schema.buildings.query, [['bldg-3']], state, ); const buildings = result.data as any[]; expect(buildings).toHaveLength(1); expect(buildings[0].id).toBe('bldg-3'); expect(buildings[0].name).toBe('Building C'); expect(buildings[0].floors).toBe(2); }); test('empty IDs array resolves to empty array', () => { const memo = new MemoCache(); const result = memo.query(Department.schema.buildings.query, [[]], state); expect(result.data).toEqual([]); expect(result.paths).toEqual([]); }); test('IDs referencing missing entities are filtered out', () => { const memo = new MemoCache(); const result = memo.query( Department.schema.buildings.query, [['bldg-1', 'nonexistent', 'bldg-2']], state, ); const buildings = result.data as any[]; expect(buildings).toHaveLength(2); expect(buildings[0].id).toBe('bldg-1'); expect(buildings[1].id).toBe('bldg-2'); }); test('Entity inner schema: delegates to Building.queryKey for single entity lookup', () => { const memo = new MemoCache(); const result = memo.query( SingleRefDepartment.schema.mainBuilding.query, [{ id: 'bldg-1' }], state, ); const building = result.data as any; expect(building).toBeInstanceOf(Building); expect(building.id).toBe('bldg-1'); expect(building.name).toBe('Building A'); expect(building.floors).toBe(3); }); test('Entity inner schema: missing entity returns undefined', () => { const memo = new MemoCache(); const result = memo.query( SingleRefDepartment.schema.mainBuilding.query, [{ id: 'nonexistent' }], state, ); expect(result.data).toBeUndefined(); }); }); describe('memoization isolation', () => { test('parent referential equality is preserved when lazy entity updates', () => { const entities1 = { Department: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1'], manager: 'mgr-1', }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, }, Manager: { 'mgr-1': { id: 'mgr-1', name: 'Alice' }, }, }; // Building entity changes, Department entity object stays the same ref const entities2 = { Department: entities1.Department, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A RENAMED', floors: 4 }, }, Manager: entities1.Manager, }; const memo = new MemoCache(); const result1 = memo.denormalize(Department, 'dept-1', entities1); const result2 = memo.denormalize(Department, 'dept-1', entities2); // Parent entity denorm is referentially equal — Building change is invisible expect(result1.data).toBe(result2.data); const dept: any = result1.data; expect(dept.name).toBe('Engineering'); expect(dept.buildings).toEqual(['bldg-1']); expect(dept.manager).toBeInstanceOf(Manager); }); test('LazyQuery result DOES update when lazy entity changes', () => { const lazyQuery = Department.schema.buildings.query; const state1 = { entities: { Building: { 'bldg-1': { id: 'bldg-1', name: 'Original', floors: 3 }, }, }, indexes: {}, }; const state2 = { entities: { Building: { 'bldg-1': { id: 'bldg-1', name: 'Updated', floors: 4 }, }, }, indexes: {}, }; const memo = new MemoCache(); const r1 = memo.query(lazyQuery, [['bldg-1']], state1); const r2 = memo.query(lazyQuery, [['bldg-1']], state2); expect((r1.data as any)[0].name).toBe('Original'); expect((r1.data as any)[0].floors).toBe(3); expect((r2.data as any)[0].name).toBe('Updated'); expect((r2.data as any)[0].floors).toBe(4); expect(r1.data).not.toBe(r2.data); }); test('LazyQuery result maintains referential equality on unchanged state', () => { const lazyQuery = Department.schema.buildings.query; const state = { entities: { Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, }, }, indexes: {}, }; const memo = new MemoCache(); const r1 = memo.query(lazyQuery, [['bldg-1']], state); const r2 = memo.query(lazyQuery, [['bldg-1']], state); expect(r1.data).toBe(r2.data); }); }); describe('nested Lazy fields', () => { class Room extends IDEntity { readonly label: string = ''; } class LazyBuilding extends IDEntity { readonly name: string = ''; readonly rooms: string[] = []; static schema = { rooms: new schema.Lazy([Room]), }; } class LazyDepartment extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; static schema = { buildings: new schema.Lazy([LazyBuilding]), }; } test('resolved entity still has its own Lazy fields as raw IDs', () => { const state = { entities: { LazyBuilding: { 'bldg-1': { id: 'bldg-1', name: 'Building A', rooms: ['room-1', 'room-2'], }, }, Room: { 'room-1': { id: 'room-1', label: '101' }, 'room-2': { id: 'room-2', label: '102' }, }, }, indexes: {}, }; const memo = new MemoCache(); const result = memo.query( LazyDepartment.schema.buildings.query, [['bldg-1']], state, ); const buildings = result.data as any[]; expect(buildings).toHaveLength(1); expect(buildings[0]).toBeInstanceOf(LazyBuilding); expect(buildings[0].name).toBe('Building A'); // Building's own Lazy field stays as raw IDs expect(buildings[0].rooms).toEqual(['room-1', 'room-2']); expect(typeof buildings[0].rooms[0]).toBe('string'); }); test('second-level LazyQuery resolves deeper relationships', () => { const state = { entities: { Room: { 'room-1': { id: 'room-1', label: '101' }, 'room-2': { id: 'room-2', label: '102' }, }, }, indexes: {}, }; const memo = new MemoCache(); const result = memo.query( LazyBuilding.schema.rooms.query, [['room-1', 'room-2']], state, ); const rooms = result.data as any[]; expect(rooms).toHaveLength(2); expect(rooms[0]).toBeInstanceOf(Room); expect(rooms[0].label).toBe('101'); expect(rooms[1].label).toBe('102'); }); }); describe('bidirectional Lazy prevents stack overflow', () => { class BidirBuilding extends IDEntity { readonly name: string = ''; readonly departments: string[] = []; } class BidirDepartment extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; } (BidirDepartment as any).schema = { buildings: new schema.Lazy([BidirBuilding]), }; (BidirBuilding as any).schema = { departments: new schema.Lazy([BidirDepartment]), }; function buildChain(length: number) { const departmentEntities: Record = {}; const buildingEntities: Record = {}; for (let i = 0; i < length; i++) { departmentEntities[`dept-${i}`] = { id: `dept-${i}`, name: `Department ${i}`, buildings: [`bldg-${i}`], }; buildingEntities[`bldg-${i}`] = { id: `bldg-${i}`, name: `Building ${i}`, departments: i < length - 1 ? [`dept-${i + 1}`] : [], }; } return { BidirDepartment: departmentEntities, BidirBuilding: buildingEntities, }; } test('1500-node chain does not overflow (plainDenormalize)', () => { const entities = buildChain(1500); expect(() => plainDenormalize(BidirDepartment, 'dept-0', entities), ).not.toThrow(); const dept: any = plainDenormalize(BidirDepartment, 'dept-0', entities); expect(dept.id).toBe('dept-0'); expect(dept.name).toBe('Department 0'); expect(dept.buildings).toEqual(['bldg-0']); }); test('1500-node chain does not overflow (SimpleMemoCache)', () => { const entities = buildChain(1500); const memo = new SimpleMemoCache(); expect(() => memo.denormalize(BidirDepartment, 'dept-0', entities), ).not.toThrow(); }); test('chain entities can still be resolved individually via LazyQuery', () => { const entities = buildChain(5); const state = { entities, indexes: {} }; const memo = new MemoCache(); const deptBuildingsQuery = ( (BidirDepartment as any).schema.buildings as schema.Lazy ).query; const bldgDeptsQuery = ( (BidirBuilding as any).schema.departments as schema.Lazy ).query; // Resolve dept-0's buildings const r = memo.query(deptBuildingsQuery, [['bldg-0']], state); const buildings = r.data as any[]; expect(buildings).toHaveLength(1); expect(buildings[0]).toBeInstanceOf(BidirBuilding); expect(buildings[0].id).toBe('bldg-0'); expect(buildings[0].name).toBe('Building 0'); // Building's departments field is also Lazy — raw IDs expect(buildings[0].departments).toEqual(['dept-1']); // Resolve building-0's departments const r2 = memo.query(bldgDeptsQuery, [['dept-1']], state); const depts = r2.data as any[]; expect(depts).toHaveLength(1); expect(depts[0]).toBeInstanceOf(BidirDepartment); expect(depts[0].id).toBe('dept-1'); expect(depts[0].buildings).toEqual(['bldg-1']); }); }); describe('explicit schema.Array inner schema', () => { class ArrayDepartment extends IDEntity { readonly name: string = ''; readonly buildings: string[] = []; static schema = { buildings: new schema.Lazy(new schema.Array(Building)), }; } const state = { entities: { ArrayDepartment: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildings: ['bldg-1', 'bldg-2'], }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, }, }, indexes: {}, }; test('normalize stores entities correctly through Lazy(schema.Array)', () => { const result = normalize( ArrayDepartment, { id: 'dept-1', name: 'Engineering', buildings: [ { id: 'bldg-1', name: 'Building A', floors: 3 }, { id: 'bldg-2', name: 'Building B', floors: 5 }, ], }, [], ); expect(result.entities.ArrayDepartment['dept-1'].buildings).toEqual([ 'bldg-1', 'bldg-2', ]); expect(result.entities.Building['bldg-1']).toEqual({ id: 'bldg-1', name: 'Building A', floors: 3, }); }); test('denormalize keeps Lazy(schema.Array) as raw IDs', () => { const dept: any = plainDenormalize( ArrayDepartment, 'dept-1', state.entities, ); expect(dept.buildings).toEqual(['bldg-1', 'bldg-2']); expect(typeof dept.buildings[0]).toBe('string'); }); test('LazyQuery resolves explicit schema.Array into Building instances', () => { const memo = new MemoCache(); const result = memo.query( ArrayDepartment.schema.buildings.query, [['bldg-1', 'bldg-2']], state, ); const buildings = result.data as any[]; expect(buildings).toHaveLength(2); expect(buildings[0]).toBeInstanceOf(Building); expect(buildings[0].id).toBe('bldg-1'); expect(buildings[0].name).toBe('Building A'); expect(buildings[1]).toBeInstanceOf(Building); expect(buildings[1].id).toBe('bldg-2'); expect(buildings[1].name).toBe('Building B'); }); test('LazyQuery with empty array resolves to empty for schema.Array', () => { const memo = new MemoCache(); const result = memo.query( ArrayDepartment.schema.buildings.query, [[]], state, ); expect(result.data).toEqual([]); }); }); describe('explicit schema.Values inner schema', () => { class ValuesDepartment extends IDEntity { readonly name: string = ''; readonly buildingMap: Record = {}; static schema = { buildingMap: new schema.Lazy(new schema.Values(Building)), }; } const state = { entities: { ValuesDepartment: { 'dept-1': { id: 'dept-1', name: 'Engineering', buildingMap: { north: 'bldg-1', south: 'bldg-2' }, }, }, Building: { 'bldg-1': { id: 'bldg-1', name: 'Building A', floors: 3 }, 'bldg-2': { id: 'bldg-2', name: 'Building B', floors: 5 }, }, }, indexes: {}, }; test('normalize stores entities correctly through Lazy(schema.Values)', () => { const result = normalize( ValuesDepartment, { id: 'dept-1', name: 'Engineering', buildingMap: { north: { id: 'bldg-1', name: 'Building A', floors: 3 }, south: { id: 'bldg-2', name: 'Building B', floors: 5 }, }, }, [], ); expect(result.entities.ValuesDepartment['dept-1'].buildingMap).toEqual({ north: 'bldg-1', south: 'bldg-2', }); }); test('denormalize keeps Lazy(schema.Values) as raw IDs', () => { const dept: any = plainDenormalize( ValuesDepartment, 'dept-1', state.entities, ); expect(dept.buildingMap).toEqual({ north: 'bldg-1', south: 'bldg-2' }); expect(typeof dept.buildingMap.north).toBe('string'); }); test('LazyQuery resolves explicit schema.Values into Building instances', () => { const memo = new MemoCache(); const result = memo.query( ValuesDepartment.schema.buildingMap.query, [{ north: 'bldg-1', south: 'bldg-2' }], state, ); const buildingMap = result.data as any; expect(buildingMap.north).toBeInstanceOf(Building); expect(buildingMap.north.id).toBe('bldg-1'); expect(buildingMap.north.name).toBe('Building A'); expect(buildingMap.south).toBeInstanceOf(Building); expect(buildingMap.south.id).toBe('bldg-2'); expect(buildingMap.south.name).toBe('Building B'); }); }); describe('Lazy.queryKey returns undefined', () => { test('Lazy itself is not queryable', () => { const lazy = new schema.Lazy([Building]); // eslint-disable-next-line @typescript-eslint/no-empty-function expect(lazy.queryKey([], () => {}, {} as any)).toBeUndefined(); }); }); });