-- Lazy, zero-copy decode view over protobuf wire bytes. -- -- Built once via pb.decode_lazy(desc, bytes). Indexes the wire bytes at -- construct time (single pass), then materializes individual fields on -- access. Nested messages, repeated fields, and maps are themselves -- returned as lazy sub-views; values are decoded on `:get` / `:at`. -- -- The underlying Lua string is kept GC-anchored on the view table so -- substring-based passthrough remains valid for the view's lifetime. -- -- Phase 1: read-only API (:get / :has / :which / :iter / :names plus -- ArrayView :len/:at/:iter and MapView :get/:has/:keys/:iter). Mutation -- and passthrough re-encode arrive in phase 2. -- -- WKT and any descriptor carrying `desc.decode` are eagerly decoded and -- wrapped in an EagerView with the same getter surface, so callers don't -- have to special-case Timestamp/Duration/Struct/etc. local wire = require('pb.wire') local codec = require('pb.codec') local M = {} -- Forward decls: views can reference each other. local build_msg_view local build_array_view local build_map_view -- --------------------------------------------------------------------------- -- Index build -- -- Single pass over `bytes`. Each tag+value occupies one `segment`: -- {id = , -- tag_start = <1-based offset of tag's first byte>, -- val_start = <1-based offset of value's first byte>, -- next_start = <1-based offset just past this segment>, -- wt = } -- -- `segments` is the wire-order list (used by iter and by passthrough re-encode -- in phase 2). `by_id` maps id -> list-of-segments, even for singular fields -- (multiple wire entries for the same singular id are legal per spec: scalar -- last-wins, message-merge — both consumers can walk the list). -- --------------------------------------------------------------------------- local function index_bytes(desc, bytes) local pos, lim = 1, #bytes local segments = {} local by_id = {} local fbi = desc.field_by_id while pos <= lim do local tag_start = pos local id, wt, npos = wire.decode_tag(bytes, pos) local val_start = npos local next_start = wire.skip_field(bytes, npos, wt) local seg = { id = id, tag_start = tag_start, val_start = val_start, next_start = next_start, wt = wt, } segments[#segments + 1] = seg if fbi[id] ~= nil then local list = by_id[id] if list == nil then by_id[id] = {seg} else list[#list + 1] = seg end end pos = next_start end return segments, by_id end -- --------------------------------------------------------------------------- -- Per-field materialization -- --------------------------------------------------------------------------- -- Decode a single value from one segment, given the singular field shape. -- For message fields this returns another lazy view; for scalar/enum it -- returns the materialized Lua/cdata value. local function read_singular(field, bytes, seg) local kind = field.kind if kind == 'scalar' then local h = wire.TYPE_INFO[field.proto_type] local v = h.decode(bytes, seg.val_start) return v elseif kind == 'enum' then local u = wire.decode_varint(bytes, seg.val_start) return tonumber(u) elseif kind == 'message' then local payload = wire.decode_len(bytes, seg.val_start) if field.message.decode ~= nil then -- WKT (or any custom-decode override). Eager. return field.message.decode(payload) end return build_msg_view(field.message, payload) end error("read_singular: unknown kind " .. tostring(kind), 0) end -- For singular fields, semantics for multiple wire entries with the same id: -- - scalar/enum: last wins. -- - message: merged. We delegate to the eager codec by concatenating the -- per-entry payloads and feeding them to pb.codec.decode, which honors -- proto3 merge rules. (Multi-entry singular messages are rare; this is -- the off-fast-path correctness branch.) local function read_singular_list(field, bytes, list) if field.kind ~= 'message' or #list == 1 then return read_singular(field, bytes, list[#list]) end -- Multi-segment message: concatenate the inner payloads and eager-decode. -- This loses the lazy sub-view but is the right thing semantically. local parts = {} for i = 1, #list do local seg = list[i] local payload = wire.decode_len(bytes, seg.val_start) parts[i] = payload end local merged = table.concat(parts) if field.message.decode ~= nil then return field.message.decode(merged) end return build_msg_view(field.message, merged) end -- --------------------------------------------------------------------------- -- ArrayView: lazy view over a repeated field. -- -- For unpacked repeated, each wire entry is one element (one segment). -- For packed repeated (scalars/enums), one segment contains a length-delimited -- payload with all elements; we walk the payload on demand. We never -- materialize the full element array unless the user iterates it all. -- --------------------------------------------------------------------------- local ArrayView = {} ArrayView.__index = ArrayView -- Pre-expand packed payloads into a uniform per-element segment list at -- construct time. Cheaper to do once than to re-scan on every :at(i). local function expand_packed(field, bytes, packed_seg) local h = field.kind == 'scalar' and wire.TYPE_INFO[field.proto_type] or nil -- Decode the length prefix to know where elements live. local payload_start = packed_seg.val_start local b = bytes:byte(payload_start) local payload_len, hdr_end if b < 0x80 then payload_len = b hdr_end = payload_start + 1 else local v, npos = wire.decode_varint(bytes, payload_start) payload_len = tonumber(v) hdr_end = npos end local lim = hdr_end + payload_len local elems = {} local p = hdr_end while p < lim do local v_start = p local v_next if field.kind == 'scalar' then -- Skip according to the scalar's actual wire type. v_next = wire.skip_field(bytes, p, h.wire) else -- Packed enums: varint per element. v_next = wire.skip_field(bytes, p, wire.WIRE_VARINT) end elems[#elems + 1] = {val_start = v_start, val_next = v_next} p = v_next end return elems end local function build_array_view_impl(field, bytes, segs) -- For packed payloads (single segment, WIRE_LEN, but scalar field uses -- a non-LEN wire type), elements live inside that one segment. -- For unpacked, each segment is one element. local mode -- 'packed' | 'unpacked' local elements -- list of {val_start = ...} entries if field.kind == 'scalar' then local h = wire.TYPE_INFO[field.proto_type] if h.wire ~= wire.WIRE_LEN and #segs == 1 and segs[1].wt == wire.WIRE_LEN then mode = 'packed' elements = expand_packed(field, bytes, segs[1]) else mode = 'unpacked' elements = segs end elseif field.kind == 'enum' then if #segs == 1 and segs[1].wt == wire.WIRE_LEN then mode = 'packed' elements = expand_packed(field, bytes, segs[1]) else mode = 'unpacked' elements = segs end else -- repeated message: always one segment per element, never packed. mode = 'unpacked' elements = segs end return setmetatable({ _field = field, _bytes = bytes, _mode = mode, _elements = elements, }, ArrayView) end build_array_view = build_array_view_impl function ArrayView:len() return #self._elements end function ArrayView:at(i) local entry = self._elements[i] if entry == nil then return nil end local field, bytes = self._field, self._bytes if self._mode == 'packed' then if field.kind == 'enum' then local u = wire.decode_varint(bytes, entry.val_start) return tonumber(u) end local h = wire.TYPE_INFO[field.proto_type] return (h.decode(bytes, entry.val_start)) end return read_singular(field, bytes, entry) end function ArrayView:iter() local view, i = self, 0 local n = #self._elements return function() i = i + 1 if i > n then return nil end return i, view:at(i) end end function ArrayView:tolist() local out = {} for i = 1, #self._elements do out[i] = self:at(i) end return out end -- --------------------------------------------------------------------------- -- MapView: lazy view over a map field. -- -- Each wire entry is one length-delimited submessage with two fields: -- id=1 (key), id=2 (value). The submessage may omit either when the -- value equals its proto3 default. -- -- For O(1) `:get(k)`, we decode keys lazily but cache the key->entry map on -- first key-lookup or first :iter. Until then, only the per-entry payload -- offsets are known. -- --------------------------------------------------------------------------- local MapView = {} MapView.__index = MapView local function decode_map_entry(field, bytes, seg) local key_field, val_field = field.key, field.value -- Read the outer LEN to find the entry payload. local b = bytes:byte(seg.val_start) local payload_len, hdr_end if b < 0x80 then payload_len = b; hdr_end = seg.val_start + 1 else local v, npos = wire.decode_varint(bytes, seg.val_start) payload_len = tonumber(v); hdr_end = npos end local lim = hdr_end + payload_len local key, val local p = hdr_end while p < lim do local eid, ewt, np = wire.decode_tag(bytes, p) p = np if eid == 1 then if key_field.kind == 'scalar' then key, p = wire.TYPE_INFO[key_field.proto_type].decode(bytes, p) elseif key_field.kind == 'enum' then local u; u, p = wire.decode_varint(bytes, p); key = tonumber(u) end elseif eid == 2 then if val_field.kind == 'scalar' then val, p = wire.TYPE_INFO[val_field.proto_type].decode(bytes, p) elseif val_field.kind == 'enum' then local u; u, p = wire.decode_varint(bytes, p); val = tonumber(u) elseif val_field.kind == 'message' then local payload; payload, p = wire.decode_len(bytes, p) if val_field.message.decode ~= nil then val = val_field.message.decode(payload) else val = build_msg_view(val_field.message, payload) end end else p = wire.skip_field(bytes, p, ewt) end end if key == nil then if key_field.kind == 'scalar' then local pt = key_field.proto_type if pt == 'string' or pt == 'bytes' then key = '' elseif pt == 'bool' then key = false else key = 0 end else key = 0 end end if val == nil then if val_field.kind == 'scalar' then local pt = val_field.proto_type if pt == 'string' or pt == 'bytes' then val = '' elseif pt == 'bool' then val = false else val = 0 end elseif val_field.kind == 'enum' then val = 0 elseif val_field.kind == 'message' then val = {} end end return key, val end local function build_map_view_impl(field, bytes, segs) return setmetatable({ _field = field, _bytes = bytes, _segs = segs, -- _by_key populated lazily on first :get/:has/:iter call. }, MapView) end build_map_view = build_map_view_impl local function map_ensure_index(self) if self._by_key ~= nil then return end local field, bytes, segs = self._field, self._bytes, self._segs local by_key = {} local keys = {} for i = 1, #segs do local k, v = decode_map_entry(field, bytes, segs[i]) if by_key[k] == nil then keys[#keys + 1] = k end by_key[k] = v -- duplicate keys: last wins (matches eager decode) end self._by_key = by_key self._keys = keys end function MapView:get(k) map_ensure_index(self) return self._by_key[k] end function MapView:has(k) map_ensure_index(self) return self._by_key[k] ~= nil end function MapView:keys() map_ensure_index(self) local out = {} for i = 1, #self._keys do out[i] = self._keys[i] end return out end function MapView:iter() map_ensure_index(self) local keys, by_key = self._keys, self._by_key local i = 0 return function() i = i + 1 local k = keys[i] if k == nil then return nil end return k, by_key[k] end end function MapView:totable() map_ensure_index(self) local out = {} for k, v in pairs(self._by_key) do out[k] = v end return out end -- --------------------------------------------------------------------------- -- MessageView (top-level) -- --------------------------------------------------------------------------- local MessageView = {} MessageView.__index = MessageView local function build_msg_view_impl(desc, bytes) if desc.decode ~= nil then -- WKT / custom-decode descriptor. Eager-wrap so the API stays uniform. local materialized = desc.decode(bytes) return setmetatable({ _desc = desc, _eager = materialized, _eager_only = true, }, MessageView) end local segments, by_id = index_bytes(desc, bytes) return setmetatable({ _desc = desc, _bytes = bytes, _segments = segments, _by_id = by_id, _cache = {}, -- Parallel array of cached sub-MessageViews so :is_dirty can -- ipairs over it instead of pairs(_cache) — `pairs` over a hash -- compiles to bytecode ISNEXT, NYI in Tarantool's LuaJIT 2.1. _sub_msg_views = {}, }, MessageView) end build_msg_view = build_msg_view_impl M.build = build_msg_view_impl -- :get(name) -> decoded value, or nil if not on wire (and no _cache entry). function MessageView:get(name) if self._eager_only then return self._eager[name] end local cache = self._cache local v = cache[name] if v ~= nil then return v end local field = self._desc.field_by_name[name] if field == nil then return nil end local segs = self._by_id[field.id] if segs == nil then return nil end if field.kind == 'map' then v = build_map_view(field, self._bytes, segs) elseif field.repeated then v = build_array_view(field, self._bytes, segs) else v = read_singular_list(field, self._bytes, segs) end cache[name] = v -- Track sub-MessageViews on a flat array so :is_dirty can walk -- with ipairs (see _sub_msg_views note above). if type(v) == 'table' and getmetatable(v) == MessageView then local s = self._sub_msg_views s[#s + 1] = v end return v end -- :has(name) -> was this field present on the wire? function MessageView:has(name) if self._eager_only then return self._eager[name] ~= nil end local field = self._desc.field_by_name[name] if field == nil then return false end return self._by_id[field.id] ~= nil end -- :which(oneof_name) -> name of the active branch, or nil. -- Proto3 last-wins: if multiple branches appeared on the wire, the one -- whose final segment came last in wire order is active. function MessageView:which(oneof_name) if self._eager_only then local oneofs = self._desc.oneofs if oneofs == nil then return nil end local members = oneofs[oneof_name] if members == nil then return nil end for i = 1, #members do if self._eager[members[i]] ~= nil then return members[i] end end return nil end local oneofs = self._desc.oneofs if oneofs == nil then return nil end local members = oneofs[oneof_name] if members == nil then return nil end local member_set = {} for i = 1, #members do member_set[members[i]] = true end local fbn = self._desc.field_by_name local active, active_pos local segs = self._segments for i = 1, #segs do local f = self._desc.field_by_id[segs[i].id] if f ~= nil and member_set[f.name] then if active_pos == nil or segs[i].tag_start > active_pos then active = f.name active_pos = segs[i].tag_start end end end return active end -- :names() -> iterator yielding present field names in wire order -- (deduplicated; each field appears once even when it has multiple wire entries). function MessageView:names() if self._eager_only then local fields, i = self._desc.fields, 0 local eager = self._eager return function() while true do i = i + 1 local f = fields[i] if f == nil then return nil end if eager[f.name] ~= nil then return f.name end end end end local segments = self._segments local fbi = self._desc.field_by_id local emitted = {} local i = 0 return function() while true do i = i + 1 local seg = segments[i] if seg == nil then return nil end local f = fbi[seg.id] if f ~= nil and emitted[f.name] == nil then emitted[f.name] = true return f.name end end end end -- :iter() -> iterator yielding (name, value) for present fields, -- decoding each value on demand. Order = wire order, deduplicated. function MessageView:iter() if self._eager_only then local fields, i = self._desc.fields, 0 local eager = self._eager return function() while true do i = i + 1 local f = fields[i] if f == nil then return nil end local v = eager[f.name] if v ~= nil then return f.name, v end end end end local segments = self._segments local fbi = self._desc.field_by_id local emitted = {} local view = self local i = 0 return function() while true do i = i + 1 local seg = segments[i] if seg == nil then return nil end local f = fbi[seg.id] if f ~= nil and emitted[f.name] == nil then emitted[f.name] = true return f.name, view:get(f.name) end end end end -- --------------------------------------------------------------------------- -- Mutation -- --------------------------------------------------------------------------- -- :set(name, value) marks a field dirty. Subsequent :encode() emits the -- new value via the codec; other fields passthrough their original bytes. -- The provided value can be any shape the eager encoder accepts (Lua -- table/string/number/cdata/array). Passing a MessageView as `value` is -- supported but materializes it via :totable() on encode. function MessageView:set(name, value) if self._eager_only then self._eager[name] = value self._eager_dirty = true return end local field = self._desc.field_by_name[name] if field == nil then error("unknown field '" .. tostring(name) .. "' on " .. self._desc.name, 0) end if self._dirty == nil then self._dirty = {} end self._dirty[name] = true self._cache[name] = value end -- :is_dirty() returns true if this view has had :set called, OR if any -- cached sub-view (e.g. a nested MessageView accessed via :get) has been -- mutated. Used by parent :encode to decide whether to splice or re-encode. -- -- We walk _sub_msg_views (a flat array of cached sub-MessageViews) with -- ipairs instead of pairs(_cache). Hash-keyed pairs compiles to ISNEXT -- which is NYI in Tarantool's LuaJIT 2.1 — keeping is_dirty on an array -- lets the encode-time JIT trace stay attached. Array fields and map -- fields aren't recursed into: phase 2 mutation is on MessageView only. function MessageView:is_dirty() if self._eager_only then return self._eager_dirty == true end local d = self._dirty if d ~= nil and next(d) ~= nil then return true end local subs = self._sub_msg_views for i = 1, #subs do if subs[i]:is_dirty() then return true end end return false end -- Materialize a possibly-view value so encode_field can consume it. -- Lazy sub-views are converted to plain tables; raw values pass through. local function materialize(value) if type(value) ~= 'table' then return value end local m = getmetatable(value) if m == MessageView then return value:totable() elseif m == ArrayView then return value:tolist() elseif m == MapView then return value:totable() end return value end -- Recursively materialize a value to a plain Lua structure suitable for -- the eager codec. For a MessageView, we walk every present field — -- this is the cost of mutation; pure-read messages stay lazy. function MessageView:totable() if self._eager_only then return self._eager end local out = {} for name in self:names() do local v = self:get(name) out[name] = materialize(v) end -- Preserve unknown fields for round-trip. if self._segments then local unknown = {} local fbi = self._desc.field_by_id for i = 1, #self._segments do local seg = self._segments[i] if fbi[seg.id] == nil then unknown[#unknown + 1] = self._bytes:sub(seg.tag_start, seg.next_start - 1) end end if #unknown > 0 then out._unknown_fields = table.concat(unknown) end end return out end -- :encode() emits bytes. Three modes: -- 1. WKT eager-wrap: delegate to desc.encode on the materialized table. -- 2. Untouched (no dirty fields, no dirty sub-views): emit the original -- bytes verbatim — perfect byte-for-byte passthrough. -- 3. Mixed: walk fields in id order, splice clean segments, encode dirty -- values fresh. Unknown segments are emitted at the end. function MessageView:encode() if self._eager_only then return self._desc.encode(self._eager) end if not self:is_dirty() then return self._bytes end local out = {} local fields = self._desc.fields local bytes = self._bytes local by_id = self._by_id local dirty = self._dirty or {} local cache = self._cache -- Active-oneof resolution mirrors codec.encode_message: -- a oneof field is only emitted if it's the active branch. local active local oneofs_list = self._desc.oneofs_list if oneofs_list then active = {} for i = 1, #oneofs_list do local oo = oneofs_list[i] local members = oo.members for j = 1, #members do local fname = members[j] if dirty[fname] or by_id[self._desc.field_by_name[fname].id] then active[oo.name] = fname end end end end for i = 1, #fields do local f = fields[i] local fname = f.name local is_dirty = dirty[fname] == true local is_sub_dirty = false if not is_dirty then local cached = cache[fname] if cached ~= nil and type(cached) == 'table' and getmetatable(cached) == MessageView and cached:is_dirty() then is_sub_dirty = true end end if f.oneof and active and active[f.oneof] ~= fname then -- Inactive oneof branch: skip entirely. elseif is_dirty or is_sub_dirty then local v = cache[fname] v = materialize(v) codec.encode_field(f, v, out, f.optional or (f.oneof ~= nil)) elseif by_id[f.id] then local segs = by_id[f.id] for j = 1, #segs do local seg = segs[j] out[#out + 1] = bytes:sub(seg.tag_start, seg.next_start - 1) end end end -- Unknown segments preserved at the end (matches codec's _unknown_fields -- trailer convention). local fbi = self._desc.field_by_id local segments = self._segments for i = 1, #segments do local seg = segments[i] if fbi[seg.id] == nil then out[#out + 1] = bytes:sub(seg.tag_start, seg.next_start - 1) end end return table.concat(out) end -- --------------------------------------------------------------------------- -- Public entry -- --------------------------------------------------------------------------- M.MessageView = MessageView M.ArrayView = ArrayView M.MapView = MapView return M