diff --git a/runtime/pb/json.lua b/runtime/pb/json.lua index 28e9a6c748b2393a30434e830b763a1345ee05cb..144207847903e9214a26b3b795010c20d3c0ed83 100644 --- a/runtime/pb/json.lua +++ b/runtime/pb/json.lua @@ -900,7 +900,13 @@ out[key] = setmetatable({}, {__serialize='seq'}) end else local emit = true - if not emit_defaults and not (f.optional or f.oneof) then + -- Default-elision only applies to proto3 implicit-presence + -- shapes. Anything with explicit presence (`optional`, + -- oneof, proto2 `required`) emits its value verbatim — even + -- when it equals the type's zero/default — because the + -- absence of the field is observably distinct from being + -- set-to-default. + if not emit_defaults and not (f.optional or f.oneof or f.required) then if f.kind == 'scalar' then local pt = f.proto_type if pt == 'string' or pt == 'bytes' then diff --git a/runtime/pb/text.lua b/runtime/pb/text.lua index dfa3d31f9fe5b52f195b53386fddf2e2a3456743..279295f05b82b0c46b1f8aefc0f604d0b9ab3fee 100644 --- a/runtime/pb/text.lua +++ b/runtime/pb/text.lua @@ -99,7 +99,10 @@ return tostring(v) end local function is_proto3_default(f, v) - if f.optional or f.oneof then return false end + -- Skip elision for any presence-tracked field — proto3 explicit + -- `optional`, oneof branches, and proto2 `optional`/`required` all + -- carry observable absence-vs-set-to-default distinctions. + if f.optional or f.oneof or f.required then return false end if f.kind == 'scalar' then local pt = f.proto_type if pt == 'string' or pt == 'bytes' then return v == '' end diff --git a/test/proto2_test.lua b/test/proto2_test.lua index bf01975b8c4e7f55bd6f018c3568fc45c3276dc0..f02255b365f5029e99eff5ed5df03aae1042883e 100644 --- a/test/proto2_test.lua +++ b/test/proto2_test.lua @@ -239,6 +239,42 @@ 'dynamic and static must agree byte-for-byte') end end +-- JSON and text format: proto2 fields are all presence-tracked, so the +-- elision rules built around proto3's implicit zero defaults must not +-- fire on required-set-to-zero or optional-set-to-default. +do + local g = t.group('proto2_basic.codecs') + local pb = require('pb') + local full = require('full.proto2_basic.proto2_basic_pb') + + g.test_json_required_zero_emitted = function() + local s = pb.json.encode(full.Cardinality_descriptor, {r = 0}) + local d = pb.json.decode(full.Cardinality_descriptor, s) + t.assert_equals(d.r, 0) + t.assert(s:find('"r"%s*:%s*0'), 'required int32=0 must appear in JSON: ' .. s) + end + + g.test_json_optional_default_emitted_when_set = function() + local s = pb.json.encode(full.Defaults_descriptor, {i = 17, b = false}) + t.assert(s:find('"i"'), 'optional int32 at declared default must be emitted') + t.assert(s:find('"b"%s*:%s*false'), 'optional bool=false must be emitted') + end + + g.test_json_absent_optional_not_emitted = function() + -- Even with emit_defaults, proto2 presence-tracked fields stay + -- absent if the user didn't set them. We don't auto-materialize + -- declared defaults into JSON output. + local s = pb.json.encode(full.Defaults_descriptor, {}, {emit_defaults = true}) + t.assert_not(s:find('"i"'), 'absent optional must not be emitted') + t.assert_not(s:find('"color"'), 'absent enum optional must not be emitted') + end + + g.test_text_required_zero_emitted = function() + local s = pb.text.encode(full.Cardinality_descriptor, {r = 0}) + t.assert(s:find('r:%s*0'), 'required int32=0 must appear in text format: ' .. s) + end +end + -- Parity: full and runtime modes must produce byte-identical output for -- the same input. Equivalent to the existing parity.full_vs_runtime group. do