Skip to content

Commit 7421980

Browse files
committed
chore: fix classic payloads and add tests
Signed-off-by: Jairus Tanaka <me@jairus.dev>
1 parent 87c9e20 commit 7421980

2 files changed

Lines changed: 98 additions & 6 deletions

File tree

assembly/__tests__/lazy-fields.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,90 @@ describe("absent no-default non-nullable ref fields resolve to the type default
282282
expect(c.narr.length).toBe(0);
283283
expect(c.nst.n).toBe(3);
284284
});
285+
286+
// Regression (citm_catalog.lazy): a *present* `null` value for a lazy
287+
// nullable-string field must materialize to null, not abort. Unlike the absent
288+
// slot above (lz==0 -> default), here the slot holds a real range pointing at
289+
// the `null` literal. `isString<string | null>()` is true, so `JSON.__deserialize`
290+
// would otherwise fall into the string branch and try to parse `null` as a
291+
// quoted string - aborting under NAIVE, silently corrupting under SWAR/SIMD.
292+
@json({ lazy: "auto" })
293+
class NullableStrings {
294+
name: string = ""; // non-null string, present
295+
subjectCode: string | null = null; // nullable string, present-as-null
296+
subtitle: string | null = null; // nullable string, present-as-null
297+
}
298+
299+
describe("present `null` for a lazy nullable-string field materializes to null", () => {
300+
const r = JSON.parse<NullableStrings>(
301+
'{"name":"hello","subjectCode":null,"subtitle":null}',
302+
);
303+
// Reading the non-null string AND a nullable-null on the same struct is the
304+
// exact pattern that aborted (citm name + subjectCode); needs both touched.
305+
expect(r.name).toBe("hello");
306+
expect(changetype<usize>(r.subjectCode) == 0 ? "null" : "set").toBe("null");
307+
expect(changetype<usize>(r.subtitle) == 0 ? "null" : "set").toBe("null");
308+
// Untouched round-trip keeps the present nulls verbatim.
309+
expect(
310+
JSON.stringify(
311+
JSON.parse<NullableStrings>(
312+
'{"name":"hello","subjectCode":null,"subtitle":null}',
313+
),
314+
),
315+
).toBe('{"name":"hello","subjectCode":null,"subtitle":null}');
316+
});
317+
318+
describe("lazy nullable-string field keeps a present non-null value", () => {
319+
const r = JSON.parse<NullableStrings>(
320+
'{"name":"hi","subjectCode":"SC","subtitle":null}',
321+
);
322+
expect(r.name).toBe("hi");
323+
expect(r.subjectCode!).toBe("SC");
324+
expect(changetype<usize>(r.subtitle) == 0 ? "null" : "set").toBe("null");
325+
});
326+
327+
328+
@json class LazyNullableStr {
329+
s!: JSON.Lazy<string | null>;
330+
}
331+
332+
describe("JSON.Lazy<string | null> materializes a present `null` to null", () => {
333+
const nul = JSON.parse<LazyNullableStr>('{"s":null}');
334+
expect(changetype<usize>(nul.s) == 0 ? "null" : "set").toBe("null");
335+
const set = JSON.parse<LazyNullableStr>('{"s":"x"}');
336+
expect(set.s!).toBe("x");
337+
});
338+
339+
// The citm_catalog.lazy shape: a map of lazy structs, each materialized on
340+
// `.values()`, then a non-null string and a nullable-null string read per entry.
341+
@json({ lazy: "auto" })
342+
class Ev {
343+
name: string = "";
344+
subjectCode: string | null = null;
345+
}
346+
347+
348+
@json({ lazy: "auto" })
349+
class EvRoot {
350+
events: Map<string, Ev> = new Map<string, Ev>();
351+
}
352+
353+
describe("lazy nullable-string nulls survive map-of-struct materialization", () => {
354+
const r = JSON.parse<EvRoot>(
355+
'{"events":{"1":{"name":"a","subjectCode":null},"2":{"name":"b","subjectCode":"x"}}}',
356+
);
357+
const evs = r.events.values();
358+
let names = "";
359+
let nulls = 0;
360+
let subjects = "";
361+
for (let i = 0, n = evs.length; i < n; i++) {
362+
const e = unchecked(evs[i]);
363+
names += e.name; // non-null string field
364+
const sc = e.subjectCode; // nullable string field
365+
if (sc === null) nulls++;
366+
else subjects += sc;
367+
}
368+
expect(names).toBe("ab");
369+
expect(nulls).toBe(1);
370+
expect(subjects).toBe("x");
371+
});

assembly/index.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2351,19 +2351,24 @@ export namespace JSON {
23512351
: deserializeUnsigned<T>(srcStart, srcEnd);
23522352
} else if (isFloat<T>()) {
23532353
return deserializeFloat<T>(srcStart, srcEnd);
2354+
} else if (
2355+
isNullable<T>() &&
2356+
srcEnd - srcStart == 8 &&
2357+
load<u64>(srcStart) == NULL_WORD_U64
2358+
) {
2359+
// A `null` literal must be matched before the string branch: a nullable
2360+
// string (`string | null`) reports `isString<T>() == true`, so without
2361+
// this `null` would be (mis)handled as a quoted string and abort. Mirrors
2362+
// the same-ordered check in `parseInternal`. Reached by lazy-field
2363+
// materialization, which routes every slot value through `__deserialize`.
2364+
return null;
23542365
} else if (isString<T>()) {
23552366
if (srcEnd - srcStart < 4)
23562367
throw new Error(
23572368
"Cannot parse data as string because it was formatted incorrectly!",
23582369
);
23592370

23602371
return deserializeString(srcStart, srcEnd) as T;
2361-
} else if (
2362-
isNullable<T>() &&
2363-
srcEnd - srcStart == 8 &&
2364-
load<u64>(srcStart) == NULL_WORD_U64
2365-
) {
2366-
return null;
23672372
} else {
23682373
let type: nonnull<T> = changetype<nonnull<T>>(0);
23692374
// @ts-expect-error: Defined by transform

0 commit comments

Comments
 (0)