Skip to content

Commit a7c21ae

Browse files
committed
complete v1 implementation
1 parent 1061976 commit a7c21ae

File tree

4 files changed

+447
-399
lines changed

4 files changed

+447
-399
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { QueryResult } from '../api';
19+
20+
import {
21+
Field,
22+
isNormalizeable,
23+
isScalar,
24+
isShallowSelectionSet as isSelectionSet,
25+
QueryData,
26+
SelectionSet,
27+
StubDataObject,
28+
StubDataObjectList
29+
} from './util';
30+
31+
/**
32+
* Interface for a stub result tree, with fields which are stub data objects.
33+
* @public
34+
*/
35+
export interface StubResultTree {
36+
[alias: string]:
37+
| SelectionSet
38+
| SelectionSet[]
39+
| StubDataObject
40+
| StubDataObjectList;
41+
}
42+
43+
/**
44+
* A class used to hold an entity's normalized cached values across all queries.
45+
* @public
46+
*/
47+
class BackingDataObject {
48+
/** Stable unique key identifying the entity across types. Format: TypeName|ID */
49+
readonly typedKey: string;
50+
51+
/** Represents values received from the server. */
52+
private serverValues: Map<string, Field>;
53+
54+
/** Values modified locally for latency compensation. */
55+
private localValues: Map<string, Field> = new Map();
56+
57+
/**
58+
* A map of (BDO field name --> StubDataObjects that need to be updated when the
59+
* value of field changes).
60+
*/
61+
readonly listeners: Map<string, Set<StubDataObject>>;
62+
63+
constructor(typedKey: string, serverValues: Map<string, Field>) {
64+
this.typedKey = typedKey;
65+
this.serverValues = serverValues;
66+
this.listeners = new Map<string, Set<StubDataObject>>();
67+
}
68+
69+
/**
70+
* Retrieves the value for a given key.
71+
* @param key The key of the value to retrieve.
72+
* @returns The value associated with the key, or undefined if not found.
73+
*/
74+
protected value(key: string): Field | undefined {
75+
return this.serverValues.get(key);
76+
}
77+
78+
/** Update a field's listeners to notify them of a new value. */
79+
private updateListeners(fieldName: string, value: Field): number {
80+
const sdos = this.listeners.get(fieldName);
81+
if (!sdos) {
82+
return 0;
83+
}
84+
for (const sdo of sdos) {
85+
sdo[fieldName] = value;
86+
}
87+
return sdos.size;
88+
}
89+
90+
/**
91+
* Updates the value for a named property from the server and notifies all listeners which depend
92+
* on that value.
93+
* @param value The new value from the server.
94+
* @param key The key of the property to update.
95+
*/
96+
updateFromServer(fieldName: string, value: Field): number {
97+
this.serverValues.set(fieldName, value);
98+
return this.updateListeners(fieldName, value);
99+
}
100+
101+
/**
102+
* Updates the value for a named property locally.
103+
* @param value The new local value.
104+
* @param key The key of the property to update.
105+
*/
106+
updateLocal(fieldName: string, value: Field): number {
107+
this.localValues.set(fieldName, value);
108+
return this.updateListeners(fieldName, value);
109+
}
110+
}
111+
112+
/**
113+
* A class representing the cache for query results and entity data.
114+
* @public
115+
*/
116+
export class Cache {
117+
/** A map of (srtCacheKey --> StubResultTree returned from that query). */
118+
private srtCache = new Map<string, StubResultTree>();
119+
120+
/**
121+
* Creates a unique StrubResultTree cache key for a given query and its variables.
122+
* @param queryName The name of the query.
123+
* @param vars The variables used in the query.
124+
* @returns A unique cache key string.
125+
*/
126+
static srtCacheKey(queryName: string, vars: unknown): string {
127+
const sortedVars = Object.entries(vars).sort();
128+
return queryName + '|' + JSON.stringify(sortedVars);
129+
}
130+
131+
/** A map of [entity typename + id] --> BackingDataObject for that entity. */
132+
private bdoCache = new Map<string, BackingDataObject>();
133+
134+
/**
135+
* Creates a unique BackingDataObject cache key for a given entity.
136+
* @param typename The typename of the entity being cached.
137+
* @param id The unique id / primary key of this entity.
138+
* @returns A unique cache key string.
139+
*/
140+
static bdoCacheKey(typename: string, id: unknown): string {
141+
return typename + '|' + JSON.stringify(id);
142+
}
143+
144+
// TODO: implement normalization algorithm from scratch!!! use the first pass implementation as a reference/guide
145+
146+
/**
147+
* Updates the cache with the results of a query. This is the main entry point.
148+
* @param queryResult The result of the query.
149+
*/
150+
updateCache<Data extends QueryData, Variables>(
151+
queryResult: QueryResult<Data, Variables>
152+
): void {
153+
const resultTreeCacheKey = Cache.srtCacheKey(
154+
queryResult.ref.name,
155+
queryResult.ref.variables
156+
);
157+
const stubResultTree = this.createSrt(queryResult.data);
158+
this.srtCache.set(resultTreeCacheKey, stubResultTree);
159+
}
160+
161+
/**
162+
* Creates a StubResultTree based on the data returned from a query
163+
* @param data the data property of the query result
164+
* @returns
165+
*/
166+
private createSrt(data: QueryData): StubResultTree {
167+
const srt: StubResultTree = {};
168+
for (const [alias, selectionSet] of Object.entries(data)) {
169+
if (Array.isArray(selectionSet)) {
170+
srt[alias] = selectionSet.map(this.normalizeSelectionSet);
171+
} else {
172+
srt[alias] = this.normalizeSelectionSet(selectionSet);
173+
}
174+
}
175+
return srt;
176+
}
177+
178+
/**
179+
* Attempts to normalize the set by recursively traversing it's, fields, normalizing them along
180+
* the way.
181+
*
182+
* @param selectionSet The data to attempt to normalize.
183+
* @returns the top-level selection set (which may be an SDO if it was normalizeable).
184+
*/
185+
private normalizeSelectionSet(
186+
selectionSet: SelectionSet
187+
): SelectionSet | StubDataObject {
188+
const cachedSelectionSet: SelectionSet = {};
189+
190+
// recursively traverse selection set, creating a new selection set which will be cached.
191+
for (const [field, value] of Object.entries(selectionSet)) {
192+
if (Array.isArray(value)) {
193+
const cachedField = value.map(this.cacheField);
194+
// type assertion because typescript thinks this could be a mixed array
195+
if (cachedField.every(isScalar) || cachedField.every(isSelectionSet)) {
196+
cachedSelectionSet[field] = cachedField;
197+
} else {
198+
// mixed array, should never happen
199+
// TODO: what do we do in this case?
200+
cachedSelectionSet[field] = value;
201+
}
202+
} else {
203+
cachedSelectionSet[field] = this.cacheField(value);
204+
}
205+
}
206+
207+
// link the current SelectionSet to a BDO
208+
if (isNormalizeable(cachedSelectionSet)) {
209+
const bdoCacheKey = Cache.bdoCacheKey(
210+
cachedSelectionSet.__typename,
211+
cachedSelectionSet.__id
212+
);
213+
const existingBdo = this.bdoCache.get(bdoCacheKey);
214+
if (existingBdo) {
215+
this.updateBdo(existingBdo, selectionSet, cachedSelectionSet);
216+
} else {
217+
this.createBdo(bdoCacheKey, selectionSet, cachedSelectionSet);
218+
}
219+
return cachedSelectionSet;
220+
}
221+
return cachedSelectionSet;
222+
}
223+
224+
private cacheField(value: Field): Field {
225+
if (isSelectionSet(value)) {
226+
// recurse, and replace cacheable selection sets with SDOs
227+
return this.normalizeSelectionSet(value);
228+
}
229+
// return scalars
230+
return value;
231+
}
232+
233+
/**
234+
* Creates a new BackingDataObject and adds it to the cache. This obejct
235+
* @param bdoCacheKey The cache key for the new BDO.
236+
* @param data The entity data from the server.
237+
* @param stubDataObject The first stub to listen to this BDO.
238+
*/
239+
private createBdo(
240+
bdoCacheKey: string,
241+
data: SelectionSet,
242+
stubDataObject: StubDataObject
243+
): void {
244+
const serverValues = new Map<string, Field>();
245+
const newBdo = new BackingDataObject(bdoCacheKey, serverValues);
246+
for (const field of Object.keys(data)) {
247+
newBdo.listeners.set(field, new Set([stubDataObject]));
248+
}
249+
this.bdoCache.set(bdoCacheKey, newBdo);
250+
}
251+
252+
/**
253+
* Updates an existing BackingDataObject with new data and a new listener.
254+
* @param backingDataObject The existing BackingDataObject to update.
255+
* @param data The new entity data from the server.
256+
* @param stubDataObject The new stub to add as a listener.
257+
*/
258+
private updateBdo(
259+
backingDataObject: BackingDataObject,
260+
data: SelectionSet,
261+
stubDataObject: StubDataObject
262+
): void {
263+
for (const [fieldName, value] of Object.entries(data)) {
264+
if (Array.isArray(value)) {
265+
if (value.every(isScalar)) {
266+
// every item is a scalar
267+
backingDataObject.updateFromServer(fieldName, value);
268+
} else if (!value.every(isNormalizeable)) {
269+
// every item is a selection set that's non-normalizeable
270+
// TODO: there might be normalizeable selection sets nested inside this one...
271+
backingDataObject.updateFromServer(fieldName, value);
272+
}
273+
// else, item is an SDO which has it's own BDO
274+
} else if (isScalar(value)) {
275+
backingDataObject.updateFromServer(fieldName, value);
276+
} else if (isSelectionSet(value)) {
277+
if (isNormalizeable(value)) {
278+
}
279+
}
280+
// add this SDO as a listener to each BDO field
281+
backingDataObject.listeners.get(fieldName).add(stubDataObject);
282+
}
283+
}
284+
}

0 commit comments

Comments
 (0)