Skip to content

Commit cd94afa

Browse files
committed
feat(jsonb): Support raw JSON body literals under JSONB via SAX replay
`StreamingJsonbBuilder::add_raw` was a no-op stub; raw string bodies (`req.body = R"({"temp":22.5})"`) were a compile error under `NOTE_JSONB`. With the SAX-events-in refactor in place, the lexer can now drive any `JsonBuilder` — so the natural fix is to SAX-parse the fragment and replay the events as JSONB opcodes. New nested `ReplaySink : JsonSink` (private to `StreamingJsonbBuilder`, gets access to the private emit_* helpers). Tracks object/array context with a 32-bit nesting stack and re-emits opcodes: - on_object_begin/on_array_begin: kItem(outer_key) at depth 0, then kItem(key) for object members at depth > 0, then the begin opcode - on_object_end/on_array_end: matching end opcode, pop context - on_bool/on_string/on_null/on_number: kItem only when in an object; on_number sniffs `.`/`e`/`E` to pick kInt32 vs kDouble Re-enable the raw-string `BodyValue` ctor under JSONB by changing the gate from `#if NOTE_JSONB` to `#if NOTE_JSONB && NOTE_MINIMAL`. The GCC-14+ consteval validation path still applies, so on the supported toolchain a malformed literal is rejected at compile time the same way it is under JSON. The `NOTE_MINIMAL` carve-out: a JSONB+MINIMAL build can't afford the ~6 KB the SAX parser + parse_double + snprintf-%g pull in via the vtable, so under that combination `add_raw` stays a no-op and the BodyValue ctor stays disabled (avoids silent data loss). AVR (NOTE_MINIMAL=1) flash holds at 27,886 B — unchanged from baseline. Tests (`tests/test_jsonb.cpp`, gated `#if !NOTE_MINIMAL`): - flat object with bool field (full byte-by-byte assertion) - mixed primitives (int / float / string / null) with cursor walk - nested object - primitive array (verifies no kItem between array elements) - empty fragment short-circuit `tests/compile_fail/jsonb_raw_body.cpp` moves to `tests/compile_check/jsonb_raw_body.cpp` and flips polarity (raw body now compiles, guarded `#if !NOTE_MINIMAL`). `tests/compile_check/jsonb_body_alternatives.cpp` extended with two runtime-parameter variants (lambda capture and struct construction) to show how dynamic data enters a body — raw string literals stay the compile-time-fixed path; the lambda/struct shapes are for runtime values. Docs updated: - `docs/jsonb.md` Limitations bullet rewritten around the lexer cost and the NOTE_MINIMAL carve-out. - `docs/known-issues.md` "JSONB raw bodies" section reframed as the NOTE_MINIMAL caveat; closes "resolved outside MINIMAL" loop. - `docs/troubleshooting.md` See-also bullet drops "JSONB raw bodies" from the top-level known-issues list. Verified host build (2031 cases / 7704 assertions pass) and AVR build (27,886 / 782, baseline match).
1 parent e1d3c72 commit cd94afa

11 files changed

Lines changed: 334 additions & 52 deletions

File tree

docs/jsonb.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ JSONB does not use CRC. The `"crc":"XXXX:YYYYYYYY"` field is a JSON-specific mec
8787
8888
## Limitations
8989
90-
- **No raw JSON embedding:** `add_raw()` is a no-op in JSONB mode. Raw JSON fragments (e.g., body lambdas that emit pre-formatted JSON) cannot be embedded in JSONB without conversion.
90+
- **Raw JSON embedding pulls the SAX lexer in:** `add_raw()` works under JSONB — it SAX-parses the fragment and re-emits opcodes — but the lexer must be linked in (~6 KB of full-text parser + float printf). Under `NOTE_MINIMAL` the impl is gated off and the `BodyValue` raw-string constructor is disabled, so raw bodies are a compile error on the AVR profile. Use `body(lambda)` or `body(struct)` instead; those shapes never go through `add_raw`. See [known-issues.md](known-issues.md) for the carve-out.
9191
- **Compile-time only:** The wire format is selected at compile time via `NOTE_JSONB`. Runtime switching is not supported.
9292
- **Firmware requirement:** Requires Notecard firmware 11.x or later. Earlier firmware does not support JSONB.
9393

docs/known-issues.md

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,28 +37,31 @@ with the fix. The `#if !defined(__clang__)` guards can be removed once
3737
the bug is resolved. The compile-fail tests have `#error` skips for
3838
Clang so they'll start failing (correctly) when the fix lands.
3939

40-
## JSONB: raw JSON string bodies are a compile error
40+
## JSONB + NOTE_MINIMAL: raw JSON string bodies remain a compile error
4141

42-
**Affects:** builds with `NOTE_JSONB=1` (including `NOTE_MINIMAL`).
42+
**Affects:** builds with `NOTE_JSONB=1` *and* `NOTE_MINIMAL=1` (the
43+
AVR / 32 KB-flash profile).
4344

44-
**Symptom:** `req.body = R"({"temp":22.5})"` or `req.body("json string")`
45-
fails to compile with "no viable overloaded '='".
45+
**Symptom:** `req.body = R"({"temp":22.5})"` fails with "no viable
46+
overloaded `=`" on the AVR build.
4647

47-
**Cause:** JSONB cannot embed raw JSON text fragments. The `add_raw()`
48-
builder method is a no-op in JSONB mode, so the raw-string `BodyValue`
49-
constructors are disabled to prevent silent data loss.
48+
**Cause:** The general `add_raw()` implementation under JSONB
49+
SAX-parses the fragment and replays the events as opcodes. That pulls
50+
~6 KB of full-text JSON parser, `parse_double`, and `snprintf("%g", …)`
51+
into the binary — more than the ATmega328P's 32 KB flash budget can
52+
absorb. Under `NOTE_MINIMAL` the impl is therefore a no-op, and the
53+
`BodyValue` raw-string constructor is correspondingly disabled to
54+
prevent silent data loss.
5055

51-
**Workaround:** Use a builder lambda or typed struct instead:
56+
**Workaround:** Use a builder lambda or typed struct — both shapes
57+
never go through `add_raw` and so don't pull the lexer in:
5258

5359
```cpp
54-
// Lambda body — works with both JSON and JSONB
55-
req.body(note::body([](note::JsonBuilder& b) {
56-
b.add("temp", 22.5);
57-
}));
58-
59-
// Typed struct body — works with both JSON and JSONB
60+
req.body(note::body([](note::JsonBuilder& b) { b.add("temp", 22.5); }));
6061
req.body(Readings{.temperature = 22.5, .humidity = 60});
6162
```
6263
63-
A compile-fail test (`tests/compile_fail/jsonb_raw_body.cpp`) verifies
64-
this behavior.
64+
**Resolved at runtime under `NOTE_JSONB && !NOTE_MINIMAL`:** non-AVR
65+
JSONB builds support `req.body = R"(...)"` directly — the same surface
66+
as under JSON. See `tests/compile_check/jsonb_raw_body.cpp` and the
67+
`add_raw` cases in `tests/test_jsonb.cpp`.

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,6 @@ Fix: enable wire tracing (`NOTE_DEBUG_ENABLED=1` plus a listener that dumps both
140140

141141
## See also
142142

143-
- [`known-issues.md`](known-issues.md) — confirmed library bugs with workarounds (Apple Clang `consteval`, JSONB raw bodies)
143+
- [`known-issues.md`](known-issues.md) — confirmed library bugs with workarounds (Apple Clang `consteval`)
144144
- [`debugging.md`](debugging.md) — wire tracing, transport diagnostics, debug categories
145145
- [`getting-started.md`](getting-started.md) — top-down walkthrough from a clean project to your first request

src/note/body.hpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ class BodyValue {
5050

5151
constexpr BodyValue() = default;
5252

53-
#if NOTE_JSONB
54-
// Raw JSON string bodies are not supported with JSONB wire format.
55-
// Use body() with a lambda or typed struct instead.
53+
#if NOTE_JSONB && NOTE_MINIMAL
54+
// Raw JSON string bodies are unavailable under the JSONB + MINIMAL
55+
// combination: the SAX-replay add_raw impl (jsonb.hpp) would pull
56+
// ~6 KB of full-text parser + float printf into the AVR build, which
57+
// doesn't fit. Use body() with a lambda or typed struct instead —
58+
// those shapes never go through add_raw. See docs/known-issues.md.
5659
#elif __cplusplus >= 202002L && !defined(__clang__) && __GNUC__ >= 14
5760
// String literal: validated at compile time as well-formed JSON object.
5861
//
@@ -62,6 +65,11 @@ class BodyValue {
6265
// expression" on GCC 13.x — including 13.4, even though the direct call
6366
// form (`constexpr BodyValue v = "..."`) compiles cleanly. Stay on the
6467
// GCC 14+ gate until the inherited-ctor case works empirically.
68+
//
69+
// Wire format: in JSON the fragment is embedded verbatim via
70+
// `add_raw`; in JSONB it's SAX-parsed and re-encoded as opcodes by
71+
// `StreamingJsonbBuilder::add_raw`. Either way the user-facing
72+
// surface is the same.
6573
template<std::size_t N>
6674
consteval BodyValue(const char (&s)[N])
6775
: str_(string_view(s, N - 1)), write_fn_(&write_string) {

src/note/jsonb.hpp

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
/// transport path).
1212

1313
#include "json.hpp"
14+
#include "json_sax.hpp"
1415
#include "json_sax_streaming.hpp"
1516
#include "lexer/sax_adapter.hpp"
1617
#include "link/cobs.hpp"
@@ -105,10 +106,27 @@ class StreamingJsonbBuilder : public JsonBuilder {
105106
return *this;
106107
}
107108

108-
JsonBuilder& add_raw(string_view, string_view) override {
109-
// No-op — raw JSON fragments cannot be embedded in JSONB.
110-
// BodyValue's raw-string constructor is disabled when NOTE_JSONB=1
111-
// so this path is unreachable in normal use.
109+
JsonBuilder& add_raw(string_view key, string_view json_fragment) override {
110+
#if !NOTE_MINIMAL
111+
// Re-encode the raw JSON fragment as JSONB opcodes by SAX-parsing
112+
// it through ReplaySink (defined below). The fragment must be a
113+
// JSON object — sax_parse rejects other shapes — so a body like
114+
// `R"({"temp":22.5})"` lands as `kItem(key) + kBeginObject + ...
115+
// + kEndObject` in the wire stream.
116+
//
117+
// Gated off under NOTE_MINIMAL so the AVR/JSONB build doesn't
118+
// pull the full-text SAX parser (sax_parse, parse_double,
119+
// snprintf-%g) into the binary — that combination is ~6 KB of
120+
// flash that the 32 KB Uno target can't absorb. The BodyValue
121+
// raw-string constructor is correspondingly disabled in body.hpp
122+
// under the same combination, so callers can't reach this code
123+
// path with a no-op underneath.
124+
if (json_fragment.empty()) return *this;
125+
ReplaySink sink(*this, key);
126+
(void)sax_parse(json_fragment, sink);
127+
#else
128+
(void)key; (void)json_fragment;
129+
#endif
112130
return *this;
113131
}
114132

@@ -182,6 +200,89 @@ class StreamingJsonbBuilder : public JsonBuilder {
182200
JsonWriter& writer_;
183201
bool closed_ = false;
184202

203+
// SAX → JSONB-opcode replay. Used by add_raw to translate a raw JSON
204+
// fragment (e.g. a body literal) into JSONB opcodes one event at a
205+
// time. Nested so it can reach the private emit_* helpers directly.
206+
//
207+
// Context tracking: a single uint32_t stack records "are we in an
208+
// array?" per nesting level (1 bit per level, LSB = current). 32
209+
// levels is far beyond the depth of any Notecard body in practice.
210+
struct ReplaySink : JsonSink {
211+
StreamingJsonbBuilder& b;
212+
string_view outer_key;
213+
uint32_t array_stack = 0;
214+
int depth = 0;
215+
216+
ReplaySink(StreamingJsonbBuilder& bb, string_view k) : b(bb), outer_key(k) {}
217+
218+
void push(bool is_arr) { array_stack = (array_stack << 1) | (is_arr ? 1u : 0u); }
219+
void pop() { array_stack >>= 1; }
220+
bool in_array() const { return (array_stack & 1u) != 0; }
221+
222+
// Emit kItem(k) when the current value is a field of an object.
223+
// Skipped for array elements (no kItem) and for the root (the
224+
// outer_key was emitted by the caller via on_object_begin /
225+
// on_array_begin at depth 0).
226+
void emit_key(string_view k) {
227+
if (depth > 0 && !in_array()) b.emit_item(k);
228+
}
229+
230+
void on_object_begin(string_view k) override {
231+
if (depth == 0) b.emit_item(outer_key);
232+
else if (!in_array()) b.emit_item(k);
233+
b.emit(jsonb::kBeginObject);
234+
push(false);
235+
depth++;
236+
}
237+
void on_object_end(string_view) override {
238+
b.emit(jsonb::kEndObject);
239+
depth--;
240+
pop();
241+
}
242+
void on_array_begin(string_view k) override {
243+
if (depth == 0) b.emit_item(outer_key);
244+
else if (!in_array()) b.emit_item(k);
245+
b.emit(jsonb::kBeginArray);
246+
push(true);
247+
depth++;
248+
}
249+
void on_array_end(string_view) override {
250+
b.emit(jsonb::kEndArray);
251+
depth--;
252+
pop();
253+
}
254+
void on_null(string_view k) override {
255+
emit_key(k);
256+
b.emit(jsonb::kNull);
257+
}
258+
void on_bool(string_view k, bool v) override {
259+
emit_key(k);
260+
b.emit(v ? jsonb::kTrue : jsonb::kFalse);
261+
}
262+
void on_string(string_view k, string_view v) override {
263+
emit_key(k);
264+
b.emit(jsonb::kString);
265+
b.writer_.write(v.data(), v.size());
266+
b.emit('\0');
267+
}
268+
void on_number(string_view k, string_view raw) override {
269+
emit_key(k);
270+
bool is_float = false;
271+
for (char c : raw) {
272+
if (c == '.' || c == 'e' || c == 'E') { is_float = true; break; }
273+
}
274+
if (is_float) {
275+
double d = note::parse_double(raw);
276+
b.emit(jsonb::kDouble);
277+
b.emit_bytes(&d, 8);
278+
} else {
279+
json_int_t i = note::parse_int(raw);
280+
b.emit(jsonb::kInt32);
281+
b.emit_le32(static_cast<int32_t>(i));
282+
}
283+
}
284+
};
285+
185286
void emit(uint8_t opcode) {
186287
writer_.write(reinterpret_cast<const char*>(&opcode), 1);
187288
}

tests/compile_check/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ Each file is self-contained and compiled exactly once:
2828
- `field_types.cpp` — instantiates every generated field type to
2929
catch `ResponseField` template instantiation errors that might
3030
otherwise only appear per-consumer.
31-
- `jsonb_body_alternatives.cpp` — under `NOTE_JSONB=1`, raw string
32-
bodies are a compile error (intentional). This file compiles the
33-
*supported* body-setter shapes (`body(...)` lambdas, typed
34-
structs) to ensure they still work.
31+
- `jsonb_body_alternatives.cpp` — under `NOTE_JSONB=1`, compiles the
32+
`body(...)` lambda and typed-struct shapes, which never go through
33+
`add_raw` and so avoid pulling the SAX lexer into the build.
34+
- `jsonb_raw_body.cpp` — under `NOTE_JSONB=1`, raw string body
35+
literals (`req.body = R"(...)"`) compile and produce JSONB opcodes
36+
via `add_raw`'s SAX-replay path (skipped under `NOTE_MINIMAL`).
3537
- `minimal_mode.cpp``-DNOTE_MINIMAL=1` full build-check. Most AVR
3638
targets use this flag combination; this guards against a compile-
3739
time regression.
Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
// Compile-check: lambda and typed struct bodies work under NOTE_JSONB.
2-
// These are the recommended alternatives to raw JSON string bodies.
2+
// These shapes never go through add_raw, so they avoid pulling the SAX
3+
// lexer into the build path that raw string bodies use.
4+
//
5+
// Each shape is shown twice: once with literal values, and once with
6+
// runtime-supplied values, to demonstrate how dynamic data enters the
7+
// body. Raw string bodies (`req.body = R"(...)"`) carry no runtime
8+
// substitution — they're for compile-time-fixed JSON only; reach for
9+
// the lambda or struct shape when any field varies at runtime.
310
#define NOTE_JSONB 1
411
#include <note/api/note_add.hpp>
512
#include <note/body.hpp>
613

14+
#include <cstdint>
15+
#include <cstdlib> // std::rand
16+
717
struct Readings {
818
float temperature;
919
int32_t humidity;
10-
NOTE_FIELDS(temperature, humidity)
20+
int32_t sequence;
21+
NOTE_FIELDS(temperature, humidity, sequence)
1122
};
1223

13-
void test_lambda_body() {
24+
// Static lambda body — equivalent to a raw string literal, but without
25+
// the SAX-replay cost on JSONB builds.
26+
void test_lambda_body_static() {
1427
note::api::NoteAdd req;
1528
req.file = "sensors.qo";
1629
req.body(note::body([](note::JsonBuilder& b) {
@@ -19,8 +32,38 @@ void test_lambda_body() {
1932
}));
2033
}
2134

22-
void test_typed_struct_body() {
35+
// Lambda body with runtime values — the closure captures by reference,
36+
// so each field can be a runtime expression. This is the shape to
37+
// reach for when "build a JSON body with a random integer" or any
38+
// other dynamic data needs to land on the wire.
39+
void test_lambda_body_runtime(float measured_temp, int32_t measured_humidity) {
40+
int32_t sequence = std::rand();
41+
note::api::NoteAdd req;
42+
req.file = "sensors.qo";
43+
req.body(note::body([&](note::JsonBuilder& b) {
44+
b.add("temperature", static_cast<double>(measured_temp));
45+
b.add("humidity", measured_humidity);
46+
b.add("sequence", sequence);
47+
}));
48+
}
49+
50+
// Typed-struct body, fixed values.
51+
void test_typed_struct_body_static() {
52+
note::api::NoteAdd req;
53+
req.file = "sensors.qo";
54+
req.body(Readings{22.5f, 60, 0});
55+
}
56+
57+
// Typed-struct body with runtime values — the struct is built normally
58+
// in C++ and serialised field-by-field via StructSink. NOTE_FIELDS
59+
// enumerates the fields the library should walk.
60+
void test_typed_struct_body_runtime(float measured_temp, int32_t measured_humidity) {
61+
Readings r{
62+
.temperature = measured_temp,
63+
.humidity = measured_humidity,
64+
.sequence = std::rand(),
65+
};
2366
note::api::NoteAdd req;
2467
req.file = "sensors.qo";
25-
req.body(Readings{22.5f, 60});
68+
req.body(r);
2669
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Compile-check: raw JSON string body literals compile under NOTE_JSONB
2+
// (when NOTE_MINIMAL is not set — see docs/known-issues.md for the AVR
3+
// carve-out). The JSONB builder SAX-parses the literal and re-encodes
4+
// it as opcodes, so the same surface that works under JSON works here.
5+
#define NOTE_JSONB 1
6+
#include <note/api/note_add.hpp>
7+
#if !NOTE_MINIMAL
8+
void test() {
9+
note::api::NoteAdd req;
10+
req.body = R"({"temp":22.5})"; // expected: compiles cleanly
11+
}
12+
#endif

tests/compile_fail/README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ Some backend modes forbid certain input shapes:
5858
- `buffered_requires_string.cpp`, `debug_requires_string.cpp`
5959
callbacks that take `string_view` can't accept `const char*` on
6060
certain configurations.
61-
- `jsonb_raw_body.cpp` — raw JSON string body literals are forbidden
62-
when `NOTE_JSONB=1`.
6361

6462
### Generic API guards
6563

tests/compile_fail/jsonb_raw_body.cpp

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)