mirror of
https://github.com/hanzoai/firebase-js-sdk.git
synced 2026-09-24 16:59:10 +00:00
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { expect } from 'chai';
|
|
|
|
import { makeMemoryCacheProvider } from '../../src';
|
|
import {
|
|
EncodingMode,
|
|
EntityNode,
|
|
GLOBAL_ID_KEY
|
|
} from '../../src/cache/EntityNode';
|
|
import { ImpactedQueryRefsAccumulator } from '../../src/cache/ImpactedQueryRefsAccumulator';
|
|
|
|
describe('entity node', () => {
|
|
it('should load data for an array with one entity', async () => {
|
|
const node = new EntityNode();
|
|
/**
|
|
* { movies: [ { title: 'the matrix' } ] }
|
|
* { entityIds: [ { path: ['movies', 0], entityId: 'matrix' } ] }
|
|
*/
|
|
const exampleData = {
|
|
movies: [
|
|
{
|
|
title: 'the matrix'
|
|
}
|
|
]
|
|
};
|
|
const exampleEntityIds = {
|
|
movies: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'matrix'
|
|
}
|
|
]
|
|
};
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const queryId = 'movies';
|
|
await node.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(node.objectLists).to.have.property('movies');
|
|
expect(node.objectLists.movies[0].entityDataKeys).to.have.lengthOf(1);
|
|
expect(node.objectLists.movies[0].entityDataKeys.has('title')).to.be.true;
|
|
expect(
|
|
node.objectLists.movies[0].toJSON(EncodingMode.hydrated)
|
|
).to.deep.equal(exampleData.movies[0]);
|
|
});
|
|
it('should load data for an array with multiple entities', async () => {
|
|
/**
|
|
* { movies: [ { title: 'the matrix' }, { title: 'the dark knight' } ] }
|
|
* { entityIds: [ { path: ['movies'], entityIds: ['matrix', 'the dark knight'] } ] }
|
|
*/
|
|
const node = new EntityNode();
|
|
const exampleData = {
|
|
movies: [
|
|
{
|
|
title: 'the matrix'
|
|
},
|
|
{
|
|
title: 'the dark knight'
|
|
}
|
|
]
|
|
};
|
|
const exampleEntityIds = {
|
|
movies: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'matrix'
|
|
},
|
|
{
|
|
[GLOBAL_ID_KEY]: 'the dark knight'
|
|
}
|
|
]
|
|
};
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const queryId = 'movies';
|
|
await node.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(node.objectLists).to.have.property('movies');
|
|
expect(node.objectLists.movies).to.have.lengthOf(2);
|
|
expect(node.objectLists.movies[0].entityDataKeys.has('title')).to.be.true;
|
|
expect(node.objectLists.movies[1].entityDataKeys.has('title')).to.be.true;
|
|
expect(
|
|
node.objectLists.movies.map(movie => movie.toJSON(EncodingMode.hydrated))
|
|
).to.deep.equal(exampleData.movies);
|
|
});
|
|
it('should load data for an object', async () => {
|
|
/**
|
|
* { movie: { title: 'the matrix' } }
|
|
* { entityIds: [ { path: ['movies'], entityId: 'matrix' } ] }
|
|
*/
|
|
const node = new EntityNode();
|
|
const exampleData = {
|
|
movie: {
|
|
title: 'the matrix'
|
|
}
|
|
};
|
|
const exampleEntityIds = {
|
|
movie: {
|
|
[GLOBAL_ID_KEY]: 'matrix'
|
|
}
|
|
};
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const queryId = 'movies';
|
|
await node.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(node.references.movie.entityDataKeys.has('title')).to.be.true;
|
|
expect(node.toJSON(EncodingMode.hydrated)).to.deep.equal(exampleData);
|
|
});
|
|
it('should keep track of the right fields when two queries have different fields required from entity data objects', async () => {
|
|
/**
|
|
* { movies: [ { title: 'the matrix' }, { title: 'the dark knight' } ] }
|
|
* { entityIds: [ { path: ['movies'], entityIds: ['matrix', 'the dark knight'] } ] }
|
|
*/
|
|
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const genreNode = new EntityNode();
|
|
const genreQueryId = 'genre';
|
|
const exampleExtraGenreData = {
|
|
movies: [
|
|
{
|
|
title: 'the matrix',
|
|
genre: 'sci-fi'
|
|
},
|
|
{
|
|
title: 'the dark knight',
|
|
genre: 'action'
|
|
}
|
|
]
|
|
};
|
|
const exampleExtraGenreEntityIds = {
|
|
movies: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'matrix'
|
|
},
|
|
{
|
|
[GLOBAL_ID_KEY]: 'the dark knight'
|
|
}
|
|
]
|
|
};
|
|
await genreNode.loadData(
|
|
genreQueryId,
|
|
exampleExtraGenreData,
|
|
exampleExtraGenreEntityIds,
|
|
new ImpactedQueryRefsAccumulator(genreQueryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(
|
|
genreNode.objectLists.movies.map(movie =>
|
|
movie.toJSON(EncodingMode.hydrated)
|
|
)
|
|
).to.deep.equal(exampleExtraGenreData.movies);
|
|
expect(genreNode.objectLists.movies[0].entityDataKeys.has('genre')).to.be
|
|
.true;
|
|
expect(genreNode.objectLists.movies[1].entityDataKeys.has('genre')).to.be
|
|
.true;
|
|
const titleNode = new EntityNode();
|
|
const exampleData = {
|
|
movies: [
|
|
{
|
|
title: 'the matrix'
|
|
},
|
|
{
|
|
title: 'the dark knight'
|
|
}
|
|
]
|
|
};
|
|
const exampleEntityIds = {
|
|
movies: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'matrix'
|
|
},
|
|
{
|
|
[GLOBAL_ID_KEY]: 'the dark knight'
|
|
}
|
|
]
|
|
};
|
|
const queryId = 'movies';
|
|
await titleNode.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(
|
|
titleNode.objectLists.movies.map(movie =>
|
|
movie.toJSON(EncodingMode.hydrated)
|
|
)
|
|
).to.deep.equal(exampleData.movies);
|
|
expect(titleNode.objectLists.movies[0].entityDataKeys.has('title')).to.be
|
|
.true;
|
|
expect(titleNode.objectLists.movies[1].entityDataKeys.has('title')).to.be
|
|
.true;
|
|
expect(titleNode.objectLists.movies[0].entityDataKeys.has('genre')).to.be
|
|
.false;
|
|
expect(titleNode.objectLists.movies[1].entityDataKeys.has('genre')).to.be
|
|
.false;
|
|
expect(
|
|
titleNode.objectLists.movies[0].entityData?.getServerValues()?.title
|
|
).to.equal('the matrix');
|
|
expect(
|
|
titleNode.objectLists.movies[1].entityData?.getServerValues()?.title
|
|
).to.equal('the dark knight');
|
|
});
|
|
it('should load data for nested entities', async () => {
|
|
const exampleEntityIds = {
|
|
posts: [
|
|
{
|
|
author: {
|
|
[GLOBAL_ID_KEY]: 'idForAuthorOfPost1'
|
|
}
|
|
}
|
|
]
|
|
};
|
|
const exampleData = {
|
|
posts: [
|
|
{
|
|
title: 'Why I love the matrix',
|
|
author: {
|
|
name: 'Cypher'
|
|
}
|
|
}
|
|
]
|
|
};
|
|
const queryId = 'posts';
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const node = new EntityNode();
|
|
await node.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
expect(
|
|
node.objectLists.posts[0].references['author'].entityDataKeys.has('name')
|
|
).to.be.true;
|
|
expect(
|
|
node.objectLists.posts[0].references[
|
|
'author'
|
|
].entityData?.getServerValues()!.name
|
|
).to.equal('Cypher');
|
|
});
|
|
|
|
it('should load data for complex nested lists and objects like a social media feed', async () => {
|
|
const exampleEntityIds = {
|
|
posts: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'post1',
|
|
author: {
|
|
[GLOBAL_ID_KEY]: 'author1'
|
|
},
|
|
comments: [
|
|
{
|
|
[GLOBAL_ID_KEY]: 'comment1',
|
|
author: {
|
|
[GLOBAL_ID_KEY]: 'author2'
|
|
}
|
|
},
|
|
{
|
|
[GLOBAL_ID_KEY]: 'comment2',
|
|
author: {
|
|
[GLOBAL_ID_KEY]: 'author1'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
[GLOBAL_ID_KEY]: 'post2',
|
|
author: {
|
|
[GLOBAL_ID_KEY]: 'author2'
|
|
},
|
|
comments: []
|
|
}
|
|
]
|
|
};
|
|
|
|
const exampleData = {
|
|
posts: [
|
|
{
|
|
title: 'Hello world',
|
|
author: { name: 'Alice', username: '@alice' },
|
|
comments: [
|
|
{
|
|
content: 'First!',
|
|
author: { name: 'Bob', username: '@bob' }
|
|
},
|
|
{
|
|
content: 'Nice post',
|
|
author: { name: 'Alice', username: '@alice' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Another day',
|
|
author: { name: 'Bob', username: '@bob' },
|
|
comments: []
|
|
}
|
|
]
|
|
};
|
|
|
|
const queryId = 'homePageQuery';
|
|
const cacheId = 'cacheId';
|
|
const memoryCacheProvider = makeMemoryCacheProvider().initialize(cacheId);
|
|
const node = new EntityNode();
|
|
|
|
await node.loadData(
|
|
queryId,
|
|
exampleData,
|
|
exampleEntityIds,
|
|
new ImpactedQueryRefsAccumulator(queryId),
|
|
memoryCacheProvider
|
|
);
|
|
|
|
// Verify root-level entities
|
|
expect(node.objectLists).to.have.property('posts');
|
|
expect(node.objectLists.posts).to.have.lengthOf(2);
|
|
expect(node.objectLists.posts[0].entityDataKeys.has('title')).to.be.true;
|
|
|
|
// Verify nested entities
|
|
expect(
|
|
node.objectLists.posts[0].references['author'].entityDataKeys.has('name')
|
|
).to.be.true;
|
|
expect(
|
|
node.objectLists.posts[0].references[
|
|
'author'
|
|
].entityData?.getServerValues()!.name
|
|
).to.equal('Alice');
|
|
|
|
// Verify deeply nested lists and entities
|
|
expect(node.objectLists.posts[0].objectLists['comments']).to.have.lengthOf(
|
|
2
|
|
);
|
|
expect(
|
|
node.objectLists.posts[0].objectLists['comments'][0].references[
|
|
'author'
|
|
].entityData?.getServerValues()!.name
|
|
).to.equal('Bob');
|
|
expect(
|
|
node.objectLists.posts[0].objectLists['comments'][1].references[
|
|
'author'
|
|
].entityData?.getServerValues()!.name
|
|
).to.equal('Alice');
|
|
|
|
// Verify JSON export constructs the merged object fully
|
|
expect(node.toJSON(EncodingMode.hydrated)).to.deep.equal(exampleData);
|
|
|
|
// Make a second query requesting additional fields for the same entity
|
|
const userProfileQueryId = 'userProfileQuery';
|
|
const profileEntityIds = {
|
|
profile: {
|
|
[GLOBAL_ID_KEY]: 'author1'
|
|
}
|
|
};
|
|
const profileData = {
|
|
profile: {
|
|
name: 'Alice',
|
|
bio: 'Just another user',
|
|
avatarUrl: 'https://example.com/alice.png'
|
|
}
|
|
};
|
|
const profileNode = new EntityNode();
|
|
|
|
await profileNode.loadData(
|
|
userProfileQueryId,
|
|
profileData,
|
|
profileEntityIds,
|
|
new ImpactedQueryRefsAccumulator(userProfileQueryId),
|
|
memoryCacheProvider
|
|
);
|
|
|
|
// The original node's entity should also have received the new fields
|
|
// since both query nodes point to the same global cache manager
|
|
const author1Entity =
|
|
node.objectLists.posts[0].references['author'].entityData;
|
|
expect(author1Entity?.getServerValues()!.name).to.equal('Alice');
|
|
expect(author1Entity?.getServerValues()!.username).to.equal('@alice');
|
|
expect(author1Entity?.getServerValues()!.bio).to.equal('Just another user');
|
|
expect(author1Entity?.getServerValues()!.avatarUrl).to.equal(
|
|
'https://example.com/alice.png'
|
|
);
|
|
|
|
// Make sure we kept the original entity keys for this node correct
|
|
expect(
|
|
node.objectLists.posts[0].references['author'].entityDataKeys.has('name')
|
|
).to.be.true;
|
|
expect(
|
|
node.objectLists.posts[0].references['author'].entityDataKeys.has(
|
|
'username'
|
|
)
|
|
).to.be.true;
|
|
expect(
|
|
node.objectLists.posts[0].references['author'].entityDataKeys.has('bio')
|
|
).to.be.false;
|
|
|
|
// Verify the profileNode's keys as well
|
|
expect(profileNode.references['profile'].entityDataKeys.has('name')).to.be
|
|
.true;
|
|
expect(profileNode.references['profile'].entityDataKeys.has('bio')).to.be
|
|
.true;
|
|
expect(profileNode.references['profile'].entityDataKeys.has('username')).to
|
|
.be.false;
|
|
});
|
|
});
|