Optic from the Avro BINARY WIRE FORM to a native type A — the wire bytes are the default carrier:
AvroPrism[A] <: Optic[Array[Byte], Array[Byte], A, A, Affine]
type X = (Array[Byte], (Array[Byte], BinarySpan))
Reads locate the focused field's byte span via AvroBinaryCursor and decode only that slice; writes re-encode the focus and splice it in place (three arraycopys, union branch index re-synthesised). The ROOT object is never materialised — neither as a case class nor as a generic record; a record-SHAPED focus is materialised only as that branch's own org.apache.avro.generic.IndexedRecord during the slice decode / re-encode. Mirrors dev.constructive.eo.jsoniter.JsoniterPrism shape-for-shape:
Fst[X] = Array[Byte]— original payload;Misscarries it for pass-through (parse failure, path miss, union-branch mismatch, decode failure, unsupported index step).Snd[X] = (Array[Byte], BinarySpan)— payload + located span, sofromcan splice.
The whole capability-gated extension surface lights up on the bytes — .getOption, .modify, .replace, .foldMap, .andThen, … Drill with the same macro sugar as ever (.field(_.x) / .fields(...) / .union[B] / .at(i) / .each / Dynamic field selection).
'''Two usage modes — pick deliberately:'''
- '''Layer on an existing codec''': keep decoding to your case class elsewhere; point a prism at the one or two hot-path fields.
- '''Optics-as-evidence''': the wire
Array[Byte]IS the data structure.codecPrism[S]usesAvroCodec[S]as SCHEMA evidence only — the root decode never runs on the byte path; only the drilled leaf's codec decodes its slice. Do NOT look for an avro4s-style whole-record mapping API here: noSis ever materialised. Consuming signatures demand the weakest capability (def validateId[T](idCarrier: T)(using CanGetOption[T, Id]): Boolean), and a drilled-prism given is the evidence that instantiates theirTatArray[Byte]. See the docs page's migration recipe for the runnable shape.
'''Field navigation honours the SCHEMA field name (issues #35, #95).''' .field(_.x) — and equally .fields(...), selectDynamic and the .each.field / .each.fields traversal siblings, which all share one resolver — maps the case-class field x to whatever schema field the codec actually emitted for it. Resolution happens ONCE, at prism construction, off the cached schema (zero per-operation cost), by two rungs:
- '''By NAME, all-or-nothing.''' If EVERY case field of the parent maps to a DISTINCT schema field — exactly, or uniquely up to
_/-/.and case — then the codec has named the whole correspondence andx's answer is read off that map. Partial or colliding coverage is treated as no signal at all and the rung abstains for every field, because one lucky name match on a schema whose OTHER columns are legacy is how a working call site gets re-aimed at the wrong column. - '''By DECLARATION POSITION''' — the i-th case field is the i-th schema field. This is where a name transform lands (a kindlings snake-case config, a custom
transformFieldNames, a vulcan per-field override map), because a transform REMOVES the literal Scala name by construction, so rung 1 cannot have fired.
'''The positional rung is only right when the codec's schema is positionally 1:1 with the case class.''' Kindlings-derived codecs are, by construction. A hand-written or vulcan.Codec field list need not be: a COMPUTED/derived schema column, a dropped field, or a reordered field list all break it. The name rung recovers most of that population; what it cannot recover is a field list that both renames beyond recognition AND reorders (equal arity, no name hit) — that resolves by position, silently, and is wrong. Two more shapes stay wrong for the same reason: a schema column that BEARS a case field's name but HOLDS a different value (a derived public id, a stale legacy column), and two columns whose names normalise alike. Navigate all of them with AvroPrism.fieldNamed("schema_name"), which bypasses resolution entirely and is itself checked against the schema; ResolutionResidualSpec pins each shape's exact behaviour.
Behaviour change against 0.15.1, for the release notes: a hand-written codec that PERMUTES the Scala names (writes case field a into a schema field literally named b, and vice versa) resolved correctly by position and now resolves by name, i.e. wrongly. No name transform can produce that shape. Map keys are data, not schema-named fields, and keep their literal key.
Two sibling surfaces, one mechanism each (deliberately NOT duplicated here):
- record — the
IndexedRecord-carried optic (AvroRecordPrism) with the Ior-bearing diagnostic surface (get/modify/place/transfer+*Unsafe) overIndexedRecord | Array[Byte] | Stringinput. Use it when you hold parsed records or need accumulated AvroFailure diagnostics. - sliceBytes / graftBytes — the encoded-fragment surface for hashing / shipping / splicing a field's raw encoding across payloads without decoding the focus at all.
Storage decomposition: an AvroPrism[A] holds an AvroFocus (Leaf vs Fields) and a cached root schema; the byte walk uses the focus's path, the slice decode uses its codec.
'''Laws & preconditions''' (normative):
- The Optional laws hold '''up to canonical re-encoding of the focused slice''':
modify(identity)re-encodes the focus, so a payload whose focused slice used non-canonical (but spec-legal) encodings — non-minimal varints, byte-sized array blocks — comes back canonicalised. Byte-for-byte identity holds for payloads from conformant writers (apache-avro's own encoders included); put-get and put-put hold unconditionally. - '''Writes require a decodable current focus''': the Affine
todecodes eagerly, so.replaceonto a span whose current value doesn't decode asA— or a.union[B]focus sitting on a different runtime branch — is a Miss pass-through. graftBytes is the decode-free write (and the only one that can SWITCH union branches). - '''A write that fails to ENCODE the new value is a silent pass-through''' (
fromis total by type — there is nowhere inOptic.fromto put the failure). The payload comes back byte-uncanonicalised but otherwise UNCHANGED, and the call reports success. This is deliberate-but-unsatisfying and is pinned byAvroWriteCorrectnessSpec; the record face's AvroRecordPrism*Iormembers are where such a failure is visible (as AvroFailure.DecodeFailed — the encode is funnelled through the samedecodeOrFailseam). Seedocs/research/2026-09-22-exception-audit.md(class C) for why the fix is a failure-typed write carrier rather than a localthrow— tracked as issue #117. - '''The payload must be encoded under exactly this prism's reader schema.''' This is about PAYLOAD drift — a name absent from the READER schema is a construction-time refusal on both
.fieldand.fieldNamed, not a runtime miss. The byte walk performs no writer/reader schema resolution: structurally drifted payloads Miss silently, and a same-typed field REORDER between writer and reader is undetectable from the bytes — the walk reads the wrong field with full confidence. Confluent-framed payloads are handled by composing ConfluentWire.confluent (a byte Prism that strips the header, resolves the writer schema, and fingerprint-gates) BEFORE this optic —confluent.andThen(thisWalk); past a fingerprint mismatch a mixed-schema topic still needs a resolving decode (the record face with the right schema per payload). - Dynamic field sugar is shadowed by real members: an Avro field named like a member of this class (
record,field,at,union,each,fields, …) must be drilled with the explicit.field(_.record)form.
Attributes
- Companion
- object
- Source
- AvroPrism.scala
- Graph
-
- Supertypes