Skip to content

Commit 907d08b

Browse files
authored
fix: Correctly handle null values in JSON variations. (#569)
During the typescript implementation null values were removed during JSON de-serialization. This was over-zealous as variations can be JSON which contains null values. This retains null value removal for everything aside from variations. The reason this was originally done is to simplify all code which interacts with the data model (It only needs to check undefined versus null/undefined.). It also simplifies the ability to produce a compact representation that omits any null fields. In the future we may want to consider removing this behavior. Fixes #568
1 parent 4792391 commit 907d08b

File tree

2 files changed

+220
-17
lines changed

2 files changed

+220
-17
lines changed

packages/shared/sdk-server/__tests__/store/serialization.test.ts

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { AttributeReference } from '@launchdarkly/js-sdk-common';
2+
13
import { Flag } from '../../src/evaluation/data/Flag';
24
import { Segment } from '../../src/evaluation/data/Segment';
35
import {
46
deserializeAll,
57
deserializeDelete,
68
deserializePatch,
9+
nullReplacer,
710
replacer,
8-
reviver,
11+
serializeFlag,
912
serializeSegment,
1013
} from '../../src/store/serialization';
1114

@@ -152,6 +155,38 @@ const segmentWithBucketBy = {
152155
deleted: false,
153156
};
154157

158+
const flagWithNullInJsonVariation = {
159+
key: 'flagName',
160+
on: true,
161+
fallthrough: { variation: 1 },
162+
variations: [[true, null, 'potato'], [null, null], { null: null }, { arr: [null] }],
163+
version: 1,
164+
};
165+
166+
const flagWithManyNulls = {
167+
key: 'test-after-value1',
168+
on: true,
169+
rules: [
170+
{
171+
variation: 0,
172+
id: 'ruleid',
173+
clauses: [
174+
{
175+
attribute: 'attrname',
176+
op: 'after',
177+
values: ['not valid'],
178+
negate: null,
179+
},
180+
],
181+
trackEvents: null,
182+
},
183+
],
184+
offVariation: null,
185+
fallthrough: { variation: 1 },
186+
variations: [true, false],
187+
version: 1,
188+
};
189+
155190
function makeAllData(flag?: any, segment?: any): any {
156191
const allData: any = {
157192
data: {
@@ -239,6 +274,42 @@ describe('when deserializing all data', () => {
239274
const ref = parsed?.data.flags.flagName.rules?.[0].rollout?.bucketByAttributeReference;
240275
expect(ref?.isValid).toBeTruthy();
241276
});
277+
278+
it('does not replace null in Objects or array JSON variations', () => {
279+
const jsonString = makeSerializedAllData(flagWithNullInJsonVariation);
280+
const parsed = deserializeAll(jsonString);
281+
282+
expect(parsed?.data.flags.flagName.variations).toStrictEqual(
283+
flagWithNullInJsonVariation.variations,
284+
);
285+
});
286+
287+
it('removes null values outside variations', () => {
288+
const jsonString = makeSerializedAllData(flagWithManyNulls);
289+
const parsed = deserializeAll(jsonString);
290+
291+
expect(parsed?.data.flags.flagName).toStrictEqual({
292+
key: 'test-after-value1',
293+
on: true,
294+
rules: [
295+
{
296+
variation: 0,
297+
id: 'ruleid',
298+
clauses: [
299+
{
300+
attribute: 'attrname',
301+
attributeReference: new AttributeReference('attrname'),
302+
op: 'after',
303+
values: ['not valid'],
304+
},
305+
],
306+
},
307+
],
308+
fallthrough: { variation: 1 },
309+
variations: [true, false],
310+
version: 1,
311+
});
312+
});
242313
});
243314

244315
describe('when deserializing patch data', () => {
@@ -290,9 +361,45 @@ describe('when deserializing patch data', () => {
290361
const ref = (parsed?.data as Flag).rules?.[0].rollout?.bucketByAttributeReference;
291362
expect(ref?.isValid).toBeTruthy();
292363
});
364+
365+
it('does not replace null in Objects or array JSON variations', () => {
366+
const jsonString = makeSerializedPatchData(flagWithNullInJsonVariation);
367+
const parsed = deserializePatch(jsonString);
368+
369+
expect((parsed?.data as Flag)?.variations).toStrictEqual(
370+
flagWithNullInJsonVariation.variations,
371+
);
372+
});
373+
374+
it('removes null values outside variations', () => {
375+
const jsonString = makeSerializedPatchData(flagWithManyNulls);
376+
const parsed = deserializePatch(jsonString);
377+
378+
expect(parsed?.data as Flag).toStrictEqual({
379+
key: 'test-after-value1',
380+
on: true,
381+
rules: [
382+
{
383+
variation: 0,
384+
id: 'ruleid',
385+
clauses: [
386+
{
387+
attribute: 'attrname',
388+
attributeReference: new AttributeReference('attrname'),
389+
op: 'after',
390+
values: ['not valid'],
391+
},
392+
],
393+
},
394+
],
395+
fallthrough: { variation: 1 },
396+
variations: [true, false],
397+
version: 1,
398+
});
399+
});
293400
});
294401

295-
it('removes null elements', () => {
402+
it('removes null elements that are not part of arrays', () => {
296403
const baseData = {
297404
a: 'b',
298405
b: 'c',
@@ -306,10 +413,49 @@ it('removes null elements', () => {
306413
polluted.c.f = null;
307414

308415
const stringPolluted = JSON.stringify(polluted);
309-
const parsed = JSON.parse(stringPolluted, reviver);
416+
const parsed = JSON.parse(stringPolluted);
417+
nullReplacer(parsed);
310418
expect(parsed).toStrictEqual(baseData);
311419
});
312420

421+
it('does not remove null in arrays', () => {
422+
const data = {
423+
a: ['b', null, { arr: [null] }],
424+
c: {
425+
d: ['e', null, { arr: [null] }],
426+
},
427+
};
428+
429+
const parsed = JSON.parse(JSON.stringify(data));
430+
nullReplacer(parsed);
431+
expect(parsed).toStrictEqual(data);
432+
});
433+
434+
it('does remove null from objects that are inside of arrays', () => {
435+
const data = {
436+
a: ['b', null, { null: null, notNull: true }],
437+
c: {
438+
d: ['e', null, { null: null, notNull: true }],
439+
},
440+
};
441+
442+
const parsed = JSON.parse(JSON.stringify(data));
443+
nullReplacer(parsed);
444+
expect(parsed).toStrictEqual({
445+
a: ['b', null, { notNull: true }],
446+
c: {
447+
d: ['e', null, { notNull: true }],
448+
},
449+
});
450+
});
451+
452+
it('can handle attempting to replace nulls for an undefined or null value', () => {
453+
expect(() => {
454+
nullReplacer(null);
455+
nullReplacer(undefined);
456+
}).not.toThrow();
457+
});
458+
313459
it.each([
314460
[flagWithAttributeNameInClause, undefined],
315461
[flagWithAttributeReferenceInClause, undefined],
@@ -450,3 +596,11 @@ it('serialization converts sets back to arrays for includedContexts/excludedCont
450596
expect(jsonDeserialized.includedContexts[0].generated_valuesSet).toBeUndefined();
451597
expect(jsonDeserialized.excludedContexts[0].generated_valuesSet).toBeUndefined();
452598
});
599+
600+
it('serializes null values without issue', () => {
601+
const jsonString = makeSerializedAllData(flagWithNullInJsonVariation);
602+
const parsed = deserializeAll(jsonString);
603+
const serialized = serializeFlag(parsed!.data.flags.flagName);
604+
// After serialization nulls should still be there, and any memo generated items should be gone.
605+
expect(JSON.parse(serialized)).toEqual(flagWithNullInJsonVariation);
606+
});

packages/shared/sdk-server/src/store/serialization.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,6 @@ import VersionedDataKinds, { VersionedDataKind } from './VersionedDataKinds';
1313
// The max size where we use an array instead of a set.
1414
const TARGET_LIST_ARRAY_CUTOFF = 100;
1515

16-
/**
17-
* @internal
18-
*/
19-
export function reviver(this: any, key: string, value: any): any {
20-
// Whenever a null is included we want to remove the field.
21-
// In this way validation checks do not have to consider null, only undefined.
22-
if (value === null) {
23-
return undefined;
24-
}
25-
26-
return value;
27-
}
28-
2916
export interface FlagsAndSegments {
3017
flags: { [name: string]: Flag };
3118
segments: { [name: string]: Segment };
@@ -35,6 +22,61 @@ export interface AllData {
3522
data: FlagsAndSegments;
3623
}
3724

25+
/**
26+
* Performs deep removal of null values.
27+
*
28+
* Does not remove null values from arrays.
29+
*
30+
* Note: This is a non-recursive implementation for performance and to avoid
31+
* potential stack overflows.
32+
*
33+
* @param target The target to remove null values from.
34+
* @param excludeKeys A list of top-level keys to exclude from null removal.
35+
*/
36+
export function nullReplacer(target: any, excludeKeys?: string[]): void {
37+
const stack: {
38+
key: string;
39+
value: any;
40+
parent: any;
41+
}[] = [];
42+
43+
if (target === null || target === undefined) {
44+
return;
45+
}
46+
47+
const filteredEntries = Object.entries(target).filter(
48+
([key, _value]) => !excludeKeys?.includes(key),
49+
);
50+
51+
stack.push(
52+
...filteredEntries.map(([key, value]) => ({
53+
key,
54+
value,
55+
parent: target,
56+
})),
57+
);
58+
59+
while (stack.length) {
60+
const item = stack.pop()!;
61+
// Do not remove items from arrays.
62+
if (item.value === null && !Array.isArray(item.parent)) {
63+
delete item.parent[item.key];
64+
} else if (typeof item.value === 'object' && item.value !== null) {
65+
// Add all the children to the stack. This includes array children.
66+
// The items in the array could themselves be objects which need nulls
67+
// removed from them.
68+
stack.push(
69+
...Object.entries(item.value).map(([key, value]) => ({
70+
key,
71+
value,
72+
parent: item.value,
73+
skip: false,
74+
})),
75+
);
76+
}
77+
}
78+
}
79+
3880
/**
3981
* For use when serializing flags/segments. This will ensure local types
4082
* are converted to the appropriate JSON representation.
@@ -54,6 +96,10 @@ export function replacer(this: any, key: string, value: any): any {
5496
return undefined;
5597
}
5698
}
99+
// Allow null/undefined values to pass through without modification.
100+
if (value === null || value === undefined) {
101+
return value;
102+
}
57103
if (value.generated_includedSet) {
58104
value.included = [...value.generated_includedSet];
59105
delete value.generated_includedSet;
@@ -108,6 +154,8 @@ function processRollout(rollout?: Rollout) {
108154
* @internal
109155
*/
110156
export function processFlag(flag: Flag) {
157+
nullReplacer(flag, ['variations']);
158+
111159
if (flag.fallthrough && flag.fallthrough.rollout) {
112160
const rollout = flag.fallthrough.rollout!;
113161
processRollout(rollout);
@@ -131,6 +179,7 @@ export function processFlag(flag: Flag) {
131179
* @internal
132180
*/
133181
export function processSegment(segment: Segment) {
182+
nullReplacer(segment);
134183
if (segment?.included?.length && segment.included.length > TARGET_LIST_ARRAY_CUTOFF) {
135184
segment.generated_includedSet = new Set(segment.included);
136185
delete segment.included;
@@ -183,7 +232,7 @@ export function processSegment(segment: Segment) {
183232

184233
function tryParse(data: string): any {
185234
try {
186-
return JSON.parse(data, reviver);
235+
return JSON.parse(data);
187236
} catch {
188237
return undefined;
189238
}

0 commit comments

Comments
 (0)