M runtime/pb/json.lua => runtime/pb/json.lua +7 -1
@@ 900,7 900,13 @@ encode_message = function(desc, t)
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
M runtime/pb/text.lua => runtime/pb/text.lua +4 -1
@@ 99,7 99,10 @@ local function enum_token(enum_desc, 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
M test/proto2_test.lua => test/proto2_test.lua +36 -0
@@ 239,6 239,42 @@ do
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