Files
zeekay bfaa9812dd v1/codegen: emit singular NESTED object fields (zero-copy, typed)
Completes the variable-length codegen (strings/bytes/lists/nested) — the
last shape needed before protoc-gen-zap can map every proto message field
mechanically.

Runtime (v1/nested.go):
- NestedAt[S,N]: read an out-of-line 4-byte object pointer as a typed
  View[N]; null/OOB -> zero View (proto3 unset).
- WriteNested[S,N]: build a flat nested object into the parent tail and
  patch the pointer; the singular peer of WriteList. Composes recursively
  (nested may carry scalars, strings, bytes, lists, further nesting).

Codegen (schema.go, emit.go):
- Field.Nested *NestedMsg (4-byte object pointer, distinct from the 8-byte
  list/tail pointer); IsNested().
- Constructor takes *Value (nil => null), nil-guarded WriteNested build.
- emitNestedSupport: value struct + NestedAt read accessor.
- emitElemWrites: shared scalar/string/bytes element-write body, so list
  elements now also support string/bytes sub-fields (repeated-of-message).
- ListElem/NestedMsg carry Wire (element WireName) for Offset<Wire>_<F>.

Proven end-to-end: runtime roundtrip (nested w/ own string tail + null
case), generator-output tests (go/parser-valid), and a generated-code wire
roundtrip (testpkg/nestwire). Full v1+codegen suite green, no regression.
2026-06-23 16:28:51 -07:00

101 lines
4.4 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zapv1
// Singular nested objects — the out-of-line, single-object peer of the
// [List] machinery. A nested field holds a 4-byte object pointer
// {relOffset} in the parent's fixed payload (NOT the 8-byte {relOffset,
// length} a list holds); the nested object's flat payload lives in the
// parent's object tail, exactly where list elements live.
//
// The nested object is a flat ELEMENT schema (no kind discriminator byte
// at offset 0): the field type is known statically at both the build and
// the read call site (it is the N type parameter), so a runtime kind tag
// would be dead weight. This makes "singular nested" and "one list
// element" the same wire shape — a flat N-sized object reached by a
// relative pointer — differing only in arity (one pointer vs. a
// {pointer,count} pair). Codegen emits N as an Element schema for both.
//
// Composition is recursive and orthogonal: the init closure of
// [WriteNested] may itself call [Write], [WriteList], [WriteString],
// [WriteBytes], or [WriteNested] — so a nested object can carry its own
// scalars, strings, lists, and further-nested objects. On the read side,
// the [View] returned by [NestedAt] is a fully-formed View[N] (it aliases
// the same buffer with the nested object as its root), so every typed
// accessor — [Read], [ListAt], [NestedAt], the generated string/bytes
// readers — works against it unchanged.
// NestedAt reads a singular nested object from the out-of-line object
// pointer at byteOffset within view v, returning a typed View[N] anchored
// at the nested object. A null pointer (the proto3 "field unset" case, or
// any out-of-bounds pointer) yields the zero View[N] — test it with
// [View.IsZero].
//
// Top-level function (not a method on View[S]) for the same reason as
// [Read] and [ListAt]: Go 1.23 does not permit type parameters on methods
// of generic types.
//
// Zero copy: the returned View aliases v's underlying buffer; no bytes are
// moved. The nested View's lifetime is the parent buffer's lifetime.
func NestedAt[S, N Schema](v View[S], offset uint32) View[N] {
if v.data == nil {
return View[N]{}
}
// Re-synthesize the parent's zap.Object and follow the 4-byte
// relative object pointer. Object() returns a Null object on a zero
// relOffset (unset field) or an out-of-bounds target — both map to
// the zero View[N] below.
nested := v.asObject().Object(int(offset))
if nested.IsNull() {
return View[N]{}
}
off := nested.Offset()
var n N
end := off + n.Size()
// Defence in depth: Object() already rejected off < HeaderSize and
// off >= len(data), but it could not know N's fixed size. Reject a
// pointer whose flat payload would run past the buffer end (a
// truncated or hostile message) rather than alias out of bounds.
if off < 0 || end > len(v.data) {
return View[N]{}
}
return View[N]{
data: v.data,
payload: v.data[off:end],
rootOff: int32(off),
}
}
// WriteNested builds a singular nested object of schema N into the parent
// object's tail and stores its 4-byte object pointer at byteOffset in the
// parent's fixed payload. The init closure populates the nested object's
// fields (via [Write]/[WriteString]/[WriteBytes]/[WriteList]/[WriteNested]
// — it composes recursively). The singular peer of [WriteList].
//
// Omitting the call entirely leaves the pointer field zero — a null
// pointer — which [NestedAt] reads back as the zero View[N]. This is how
// codegen encodes an unset proto3 message field: emit the WriteNested only
// inside an `if msg != nil` guard.
//
// Wire layout matches the singular case of [WriteList]: the parent's
// fixed payload is reserved first (so the nested object cannot overwrite
// the still-unwritten pointer field), then the nested object is appended
// to the tail and the pointer patched with its relative offset.
func WriteNested[S, N Schema](s Setter[S], byteOffset uint32, init func(Setter[N])) {
ob, b := s.raw()
var sParent S
var n N
// Reserve the parent's entire fixed payload BEFORE the nested object
// is started — identical to [WriteList]'s ReserveFixed. Without it,
// the nested StartObject would align and begin writing on top of the
// parent's not-yet-written pointer field at byteOffset.
ob.ReserveFixed(sParent.Size())
nob := b.StartObject(n.Size())
init(Setter[N]{ob: nob, b: b})
pos := nob.Finish()
ob.SetObject(int(byteOffset), pos)
}