~bigbes/tarantool

tarantool-protobuf

7e9e3e43ae121408deeb9a55e6cef220215c37d2 — Eugene Blikh 3 months ago 06b2978
json: M.encode(desc, t, opts) for use_proto_names / emit_defaults / indent

Canonical protojson options were silently ignored. Plumb a single opts
table through encode_message via a CURRENT_ENCODE_OPTS state (mirroring
the decode-side CURRENT_OPTS):

  * use_proto_names         — emit snake_case field names (proto wire
                              names) instead of the spec-default
                              lowerCamelCase.
  * emit_defaults           — emit zero-valued implicit-presence scalars,
    (alias: always_emit_zero_value)  empty repeated lists, and empty
                              maps. Explicit-presence fields (optional /
                              oneof) and singular message fields remain
                              absent — matches protobuf-go's behavior.
  * indent                  — pretty-print with the given indent string;
                              empty arrays/objects stay compact.

Tests parameterize over both codegen modes (descriptor-table contract is
shared between full and runtime) plus a dedicated parity group that
asserts byte-identical JSON across modes for each option.
2 files changed, 270 insertions(+), 6 deletions(-)

M runtime/pb/json.lua
M test/json_test.lua
M runtime/pb/json.lua => runtime/pb/json.lua +125 -6
@@ 552,6 552,44 @@ encode_json = function(v)
    error('JSON encode: unsupported type ' .. ty, 0)
end

-- Pretty-printer used when `M.encode(desc, t, {indent = "  "})` is set.
-- Indent is repeated per nesting level; key/value separator becomes `: `;
-- empty arrays/objects stay on one line as `[]` / `{}`.
local encode_json_pretty
encode_json_pretty = function(v, indent, depth)
    if v == nil then return 'null' end
    local ty = type(v)
    if ty == 'cdata' then
        if v == box.NULL then return 'null' end
        return (tostring(v):gsub('U?LL$', ''))
    end
    if ty == 'boolean' then return v and 'true' or 'false' end
    if ty == 'number' then return encode_json_number(v) end
    if ty == 'string' then return encode_json_string(v) end
    if ty == 'table' then
        local mt = getmetatable(v)
        local outer = string.rep(indent, depth)
        local inner = string.rep(indent, depth + 1)
        if is_array_table(v, mt) then
            if #v == 0 then return '[]' end
            local parts = {}
            for i = 1, #v do parts[i] = encode_json_pretty(v[i], indent, depth + 1) end
            return '[\n' .. inner .. table.concat(parts, ',\n' .. inner)
                   .. '\n' .. outer .. ']'
        end
        local parts, n = {}, 0
        for k, vv in pairs(v) do
            n = n + 1
            parts[n] = encode_json_string(tostring(k)) .. ': '
                       .. encode_json_pretty(vv, indent, depth + 1)
        end
        if n == 0 then return '{}' end
        return '{\n' .. inner .. table.concat(parts, ',\n' .. inner)
               .. '\n' .. outer .. '}'
    end
    error('JSON encode: unsupported type ' .. ty, 0)
end

-- ---------------------------------------------------------------------------
-- Encode (proto-Lua table -> Lua structure suitable for our JSON emitter)
-- ---------------------------------------------------------------------------


@@ 789,6 827,41 @@ local function encode_wkt(desc, v)
    return nil
end

-- Set by M.encode for the duration of a call. See CURRENT_OPTS for the
-- decode-side analogue. Read by encode_message to honor:
--   * use_proto_names      — emit snake_case field names (proto wire names)
--                            instead of the spec-default lowerCamelCase
--   * emit_defaults        — emit zero-valued scalars/enums, empty
--   (alias: always_emit_zero_value)  repeated/map fields, even when
--                            implicit-presence semantics would skip them.
--                            Explicit-presence fields (`optional`/`oneof`)
--                            and singular message fields remain absent
--                            when unset.
local CURRENT_ENCODE_OPTS = nil

-- Zero value to emit for an implicit-presence scalar/enum field when
-- emit_defaults is on and the field is absent from the input table.
-- Returns nil for kinds that should never be synthesized (message,
-- repeated, map — handled separately).
local FFI_INT64_ZERO  = ffi.cast(INT64_T, 0)
local FFI_UINT64_ZERO = ffi.cast(UINT64_T, 0)
local function zero_value_for_field(f)
    if f.kind == 'scalar' then
        local pt = f.proto_type
        if pt == 'string' or pt == 'bytes' then return '' end
        if pt == 'bool' then return false end
        if pt == 'int64' or pt == 'sint64' or pt == 'sfixed64' then
            return FFI_INT64_ZERO
        end
        if pt == 'uint64' or pt == 'fixed64' then
            return FFI_UINT64_ZERO
        end
        return 0
    end
    if f.kind == 'enum' then return 0 end
    return nil
end

encode_message = function(desc, t)
    if rawequal(t, nil) then return nil end
    local override = encode_wkt(desc, t)


@@ 796,30 869,38 @@ encode_message = function(desc, t)
    -- legitimately return null as its override.
    if not rawequal(override, nil) then return override end

    local opts = CURRENT_ENCODE_OPTS
    local emit_defaults = opts and opts.emit_defaults
    local use_proto_names = opts and opts.use_proto_names

    local out = setmetatable({}, {__serialize='map'})
    for _, f in ipairs(desc.fields) do
        local v = t[f.name]
        local key = use_proto_names and f.name or to_camel(f.name)
        -- box.NULL == nil under Tarantool's __eq metamethod; use rawequal
        -- so a deliberately-stored null sentinel survives the field walk.
        if not rawequal(v, nil) then
            local key = to_camel(f.name)
            if f.kind == 'map' then
                if next(v) ~= nil then
                    local obj = {}
                    local obj = setmetatable({}, {__serialize='map'})
                    for k, mv in pairs(v) do
                        obj[encode_map_key(f.key, k)] = encode_field_value(f.value, mv)
                    end
                    out[key] = obj
                elseif emit_defaults then
                    out[key] = setmetatable({}, {__serialize='map'})
                end
            elseif f.repeated then
                if #v > 0 then
                    local arr = setmetatable({}, {__serialize='seq'})
                    for i = 1, #v do arr[i] = encode_field_value(f, v[i]) end
                    out[key] = arr
                elseif emit_defaults then
                    out[key] = setmetatable({}, {__serialize='seq'})
                end
            else
                local emit = true
                if not (f.optional or f.oneof) then
                if not emit_defaults and not (f.optional or f.oneof) then
                    if f.kind == 'scalar' then
                        local pt = f.proto_type
                        if pt == 'string' or pt == 'bytes' then


@@ 827,7 908,7 @@ encode_message = function(desc, t)
                        elseif pt == 'bool' then
                            if v == false then emit = false end
                        else
                            if v == 0 or (type(v) == 'cdata' and v == ffi.cast(INT64_T, 0)) then
                            if v == 0 or (type(v) == 'cdata' and v == FFI_INT64_ZERO) then
                                emit = false
                            end
                        end


@@ 837,6 918,19 @@ encode_message = function(desc, t)
                end
                if emit then out[key] = encode_field_value(f, v) end
            end
        elseif emit_defaults then
            -- Field is absent from input. Emit a default only for
            -- implicit-presence shapes (proto3 non-optional, non-oneof,
            -- non-message). Maps and repeated come out as empty
            -- containers; scalars/enums as the zero value.
            if f.kind == 'map' then
                out[key] = setmetatable({}, {__serialize='map'})
            elseif f.repeated then
                out[key] = setmetatable({}, {__serialize='seq'})
            elseif not (f.optional or f.oneof) and f.kind ~= 'message' then
                local z = zero_value_for_field(f)
                if z ~= nil then out[key] = encode_field_value(f, z) end
            end
        end
    end
    return out


@@ 844,8 938,33 @@ end

to_json_value = encode_message

function M.encode(desc, t)
    return encode_json(encode_message(desc, t))
function M.encode(desc, t, opts)
    if opts ~= nil and type(opts) ~= 'table' then
        error('pb.json.encode: opts must be a table, got ' .. type(opts), 0)
    end
    -- Normalize once so the hot path reads a single boolean.
    local norm
    if opts ~= nil then
        norm = {
            use_proto_names = opts.use_proto_names and true or false,
            emit_defaults   = (opts.emit_defaults or opts.always_emit_zero_value)
                              and true or false,
            indent          = opts.indent,
        }
        if norm.indent ~= nil and type(norm.indent) ~= 'string' then
            error('pb.json.encode: indent must be a string', 0)
        end
        if norm.indent == '' then norm.indent = nil end
    end
    local prev = CURRENT_ENCODE_OPTS
    CURRENT_ENCODE_OPTS = norm
    local ok, root_or_err = pcall(encode_message, desc, t)
    CURRENT_ENCODE_OPTS = prev
    if not ok then error(root_or_err, 0) end
    if norm and norm.indent then
        return encode_json_pretty(root_or_err, norm.indent, 0)
    end
    return encode_json(root_or_err)
end

-- ---------------------------------------------------------------------------

M test/json_test.lua => test/json_test.lua +145 -0
@@ 403,3 403,148 @@ gstrict.test_fieldmask_rejects_underscore_in_json_input = function()
    t.assert_not(ok)
    t.assert_str_contains(err, 'underscore')
end

-- ---------------------------------------------------------------------------
-- M.encode(desc, t, opts): canonical proto3 JSON options
--   * use_proto_names  -> snake_case field names
--   * emit_defaults    -> zero scalars + empty containers preserved
--   * indent           -> pretty-printed output
--
-- Parameterized over both codegen modes. JSON encoding is driven by the
-- descriptor table, which is mode-independent by contract — running each
-- assertion against both `full` and `runtime` descriptors pins that
-- "one shape, four producers" guarantee for this surface.
-- ---------------------------------------------------------------------------
local MODES = {
    full    = require('full.hello.hello_pb'),
    runtime = require('runtime.hello.hello_pb'),
}

for mode, mod in pairs(MODES) do
    local gopts = t.group('json.encode_opts.' .. mode)
    local PERSON = mod.Person_descriptor

    gopts.test_use_proto_names_emits_snake_case = function()
        local enc = pb.json.encode(PERSON,
            {user_id = ffi.cast('uint64_t', 7), lucky_numbers = {1, 2}},
            {use_proto_names = true})
        t.assert_str_contains(enc, '"user_id"')
        t.assert_str_contains(enc, '"lucky_numbers"')
        t.assert_not_str_contains(enc, '"userId"')
        t.assert_not_str_contains(enc, '"luckyNumbers"')
    end

    gopts.test_use_proto_names_round_trips_via_decoder = function()
        -- The decoder accepts both spellings; this verifies that the
        -- snake_case output we just produced decodes back to the same shape.
        local original = {name = 'Bob', user_id = ffi.cast('uint64_t', 42)}
        local enc = pb.json.encode(PERSON, original, {use_proto_names = true})
        local p = pb.json.decode(PERSON, enc)
        t.assert_equals(p.name, 'Bob')
        t.assert_equals(p.user_id, ffi.cast('uint64_t', 42))
    end

    gopts.test_emit_defaults_keeps_zero_scalar_present_in_input = function()
        -- name = '' is the canonical regression: today this is silently
        -- dropped, breaking parity with grpc-gateway / etcd Status responses
        -- that explicitly carry a zero-valued field on the wire.
        local enc = pb.json.encode(PERSON,
            {name = '', age = 0}, {emit_defaults = true})
        local obj = reparse(enc)
        t.assert_equals(obj.name, '')
        t.assert_equals(obj.age, 0)
    end

    gopts.test_emit_defaults_synthesizes_absent_implicit_fields = function()
        -- Empty input table — every implicit-presence scalar gets a zero,
        -- every repeated becomes [], every map becomes {}. Optional/oneof
        -- fields stay absent (presence semantics).
        local enc = pb.json.encode(PERSON, {}, {emit_defaults = true})
        local obj = reparse(enc)
        t.assert_equals(obj.name, '')
        t.assert_equals(obj.age, 0)
        t.assert_equals(obj.emails, {})
        t.assert_equals(obj.luckyNumbers, {})
        -- Map fields emit as objects, not arrays.
        t.assert_equals(type(obj.agesByNickname), 'table')
        t.assert_equals(next(obj.agesByNickname), nil)
    end

    gopts.test_emit_defaults_alias_always_emit_zero_value = function()
        -- protojson v2 renamed the flag; accept both spellings.
        local enc = pb.json.encode(PERSON, {name = ''},
            {always_emit_zero_value = true})
        t.assert_str_contains(enc, '"name":""')
    end

    gopts.test_emit_defaults_skips_message_field = function()
        -- Singular message fields always have presence semantics in proto3;
        -- emit_defaults must not synthesize an empty object for them.
        local enc = pb.json.encode(PERSON, {}, {emit_defaults = true})
        t.assert_not_str_contains(enc, '"address"')
    end

    gopts.test_indent_pretty_prints = function()
        local enc = pb.json.encode(PERSON,
            {name = 'Alice', emails = {'a@x', 'b@x'}}, {indent = '  '})
        t.assert_str_contains(enc, '\n  "')
        -- Object keys land on their own indented lines.
        t.assert_str_contains(enc, '  "name": "Alice"')
        -- Round-trips through the JSON parser.
        t.assert_equals(reparse(enc).name, 'Alice')
        t.assert_equals(reparse(enc).emails, {'a@x', 'b@x'})
    end

    gopts.test_indent_empty_containers_stay_compact = function()
        local enc = pb.json.encode(PERSON,
            {emails = {}}, {emit_defaults = true, indent = '  '})
        t.assert_str_contains(enc, '"emails": []')
    end

    gopts.test_unknown_opts_key_is_ignored = function()
        -- Forward-compat: extra option keys must not error.
        local enc = pb.json.encode(PERSON, {name = 'Alice'},
            {some_future_option = true})
        t.assert_equals(reparse(enc).name, 'Alice')
    end

    gopts.test_opts_rejects_non_table = function()
        local ok, err = pcall(pb.json.encode, PERSON, {}, 'oops')
        t.assert_not(ok)
        t.assert_str_contains(err, 'opts must be a table')
    end
end

-- Direct cross-mode parity: identical input + opts must produce
-- byte-identical JSON regardless of which generator emitted the
-- descriptor. Catches a future divergence in the descriptor contract
-- that today's per-mode groups would mask (each runs in isolation).
local gparity = t.group('json.encode_opts.parity_full_vs_runtime')

local function assert_parity(input, opts)
    local a = pb.json.encode(MODES.full.Person_descriptor, input, opts)
    local b = pb.json.encode(MODES.runtime.Person_descriptor, input, opts)
    t.assert_equals(a, b, 'full vs runtime JSON diverged for opts='
                          .. require('json').encode(opts or {}))
end

gparity.test_use_proto_names = function()
    assert_parity({user_id = ffi.cast('uint64_t', 7), lucky_numbers = {1, 2}},
                  {use_proto_names = true})
end

gparity.test_emit_defaults_on_absent = function()
    assert_parity({}, {emit_defaults = true})
end

gparity.test_emit_defaults_on_present_zero = function()
    assert_parity({name = '', age = 0}, {emit_defaults = true})
end

gparity.test_indent = function()
    -- Indented output has stable formatting; the only nondeterminism in
    -- the encoder is `pairs` iteration order over the output object,
    -- which is keyed off Lua's hash. Both modes share the same hash, so
    -- the byte output stays equal.
    assert_parity({name = 'Alice', emails = {'a@x'}}, {indent = '  '})
end