feat(schema): add Deriver.withInstance and Deriver.withModifier APIs#1348
Conversation
There was a problem hiding this comment.
Pull request overview
Adds reusable “configure-once, use-everywhere” derivation configuration by introducing Deriver.withInstance and Deriver.withModifier, implemented via a wrapper deriver that accumulates overrides and relies on existing DerivationBuilder override-resolution logic.
Changes:
- Add
finalwithInstance/withModifierAPIs toDeriver[TC]that return a wrapped deriver with accumulated overrides. - Introduce
DeriverWithOverrideswrapper to delegate derivation while exposing combined override lists. - Add JSON/CSV tests and a runnable example demonstrating deriver reuse across multiple derivations.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| schema/shared/src/main/scala/zio/blocks/schema/derive/Deriver.scala | Adds new public withInstance / withModifier APIs to Deriver. |
| schema/shared/src/main/scala/zio/blocks/schema/derive/DeriverWithOverrides.scala | Implements wrapper deriver that delegates derivation methods and unions override lists. |
| schema/shared/src/test/scala/zio/blocks/schema/derive/DeriverWithInstanceSpec.scala | Adds JSON-focused test coverage for deriver-level overrides and chaining. |
| schema-csv/src/test/scala/zio/blocks/schema/csv/DeriverWithInstanceCsvSpec.scala | Adds CSV-focused tests intended to cover the new deriver override behavior. |
| schema-examples/src/main/scala/customcodec/CustomCodecOverrideExample.scala | Adds an end-to-end example showing configure-once deriver reuse. |
| test("multiple withInstance calls accumulate") { | ||
| val stringifyInt: JsonCodec[Int] = new JsonCodec[Int] { | ||
| def decodeValue(in: JsonReader): Int = in.readStringAsInt() | ||
|
|
||
| def encodeValue(x: Int, out: JsonWriter): Unit = out.writeValAsString(x) | ||
|
|
||
| override def decodeValue(json: Json): Int = json match { | ||
| case s: Json.String => s.value.toInt | ||
| case _ => error("expected Json.String") | ||
| } | ||
|
|
||
| override def encodeValue(x: Int): Json = new Json.String(x.toString) | ||
| } | ||
|
|
||
| val deriver = JsonCodecDeriver | ||
| .withInstance[LocalDate](customLocalDateCodec) | ||
| .withInstance[Int](stringifyInt) | ||
|
|
||
| val codec = Schema[Event].deriving(deriver).derive | ||
| val event = Event("test", LocalDate.of(2025, 1, 1)) | ||
| val json = jsonEncode(codec, event) | ||
| // LocalDate uses custom format, but Event has no Int field so just check date | ||
| assertTrue(json == """{"name":"test","date":"01/01/2025"}""") | ||
| } |
There was a problem hiding this comment.
The test "multiple withInstance calls accumulate" doesn’t actually validate that multiple overrides are honored: the derived Event codec has no Int field, so a broken withInstance[Int](...) implementation (or broken accumulation) would still pass. Consider deriving a type that contains both LocalDate and Int (or nesting one) and asserting both custom encodings are applied.
| def spec = suite("DeriverWithInstanceCsvSpec")( | ||
| test("withInstance wraps CsvCodecDeriver and still roundtrips") { | ||
| val deriver = CsvCodecDeriver.withModifier( | ||
| TypeId.of[Meeting], | ||
| "title", | ||
| Modifier.alias("subject") | ||
| ) | ||
| val codec = Schema[Meeting].deriving(deriver).derive | ||
| val meeting = Meeting("retro", LocalDate.of(2025, 8, 1)) | ||
| assertTrue(csvRoundTrip(codec, meeting) == Right(meeting)) | ||
| }, | ||
| test("chained overrides accumulate and still roundtrip") { | ||
| val deriver = CsvCodecDeriver | ||
| .withModifier(TypeId.of[Meeting], "title", Modifier.alias("subject")) | ||
| .withModifier(TypeId.of[Meeting], "date", Modifier.alias("when")) | ||
| val codec = Schema[Meeting].deriving(deriver).derive | ||
| val meeting = Meeting("standup", LocalDate.of(2025, 7, 4)) | ||
| assertTrue(csvRoundTrip(codec, meeting) == Right(meeting)) |
There was a problem hiding this comment.
This spec is named DeriverWithInstanceCsvSpec, but the first two tests only exercise withModifier and the first test name says "withInstance wraps..." even though it doesn’t call withInstance. To cover the new API across formats, add at least one CSV test that uses CsvCodecDeriver.withInstance(...) (e.g., override the LocalDate codec) and adjust the test name(s) accordingly.
| */ | ||
| final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))), |
There was a problem hiding this comment.
withInstance(typeId, termName, ...) takes instance: => TC[Any], which forces callers to cast (see tests) and loses useful type inference. Consider changing the signature to accept a type parameter for the field instance (e.g., def withInstance[P, B](typeId: TypeId[P], termName: String, instance: => TC[B])) or a wildcard (TC[_]) so typical usage does not require asInstanceOf at the call site.
| */ | |
| final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] = | |
| new DeriverWithOverrides[TC]( | |
| self, | |
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))), | |
| * | |
| * @param typeId | |
| * the identifier of the parent type containing the field override | |
| * @param termName | |
| * the name of the field to override | |
| * @param instance | |
| * the type class instance to use for the matching field | |
| * @tparam A | |
| * the parent type containing the field | |
| * @tparam B | |
| * the field type for which the instance is supplied | |
| * @return | |
| * a new deriver that uses the supplied instance for the matching field | |
| * | |
| * @example | |
| * {{{ | |
| * val overridden = deriver.withInstance(TypeId[Parent], "fieldName", fieldInstance) | |
| * }}} | |
| */ | |
| final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] = | |
| new DeriverWithOverrides[TC]( | |
| self, | |
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))), |
| /** | ||
| * Returns a new deriver that pre-registers a type-level instance override. | ||
| * During derivation, every occurrence of type `A` will use the supplied | ||
| * instance instead of deriving one. | ||
| */ | ||
| final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))), | ||
| Chunk.empty | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a field-level instance override. | ||
| * During derivation, the field named `termName` inside the parent type | ||
| * identified by `typeId` will use the supplied instance. | ||
| */ | ||
| final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))), | ||
| Chunk.empty | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a reflect-level modifier override. | ||
| * During derivation, every occurrence of the type identified by `typeId` will | ||
| * have the modifier prepended to its modifiers. | ||
| */ | ||
| final def withModifier[A](typeId: TypeId[A], modifier: Modifier.Reflect): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk.empty, | ||
| Chunk(new ModifierReflectOverrideByType[A](typeId, modifier)) | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a term-level modifier override. | ||
| * During derivation, the field named `termName` inside the parent type | ||
| * identified by `typeId` will have the modifier applied. | ||
| */ | ||
| final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk.empty, | ||
| Chunk(new ModifierTermOverrideByType[A](typeId, termName, modifier)) | ||
| ) |
There was a problem hiding this comment.
Override precedence is currently surprising when combining a pre-configured deriver with DerivationBuilder overrides: DerivationBuilder.derive builds maps from instanceOverrides ++ deriver.instanceOverrides, and .toMap keeps the last duplicate key, so deriver-level withInstance / withModifier overrides will win over later call-site builder.instance(...) / builder.modifier(...) overrides for the same target. If the intent is “local builder overrides win”, the merge order (or map construction) needs to be adjusted; otherwise please document this precedence explicitly in these Scaladocs.
| /** | ||
| * Returns a new deriver that pre-registers a type-level instance override. | ||
| * During derivation, every occurrence of type `A` will use the supplied | ||
| * instance instead of deriving one. | ||
| */ | ||
| final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))), | ||
| Chunk.empty | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a field-level instance override. | ||
| * During derivation, the field named `termName` inside the parent type | ||
| * identified by `typeId` will use the supplied instance. | ||
| */ | ||
| final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))), | ||
| Chunk.empty | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a reflect-level modifier override. | ||
| * During derivation, every occurrence of the type identified by `typeId` will | ||
| * have the modifier prepended to its modifiers. | ||
| */ | ||
| final def withModifier[A](typeId: TypeId[A], modifier: Modifier.Reflect): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk.empty, | ||
| Chunk(new ModifierReflectOverrideByType[A](typeId, modifier)) | ||
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a term-level modifier override. | ||
| * During derivation, the field named `termName` inside the parent type | ||
| * identified by `typeId` will have the modifier applied. | ||
| */ | ||
| final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk.empty, | ||
| Chunk(new ModifierTermOverrideByType[A](typeId, termName, modifier)) | ||
| ) |
There was a problem hiding this comment.
These are new public APIs on Deriver, but the Scaladocs are fairly minimal and don’t follow the more detailed style used elsewhere (e.g., missing @param tags and important behavioral notes like “termName overrides are silently ignored if the term doesn’t exist”, matching DerivationBuilder.instance docs). Also, since this is a public API addition, the user-facing docs under docs/reference/schema/type-class-derivation.md should be updated to include withInstance/withModifier and the “configure once, use everywhere” pattern.
2346f8e to
5586ee4
Compare
| * When the returned deriver is passed to [[DerivationBuilder]], overrides | ||
| * registered here take precedence over builder-level overrides for the same | ||
| * target because `DerivationBuilder.derive` merges them as | ||
| * `builderOverrides ++ deriver.instanceOverrides` and later entries win in | ||
| * the resulting `.toMap`. |
There was a problem hiding this comment.
DerivationBuilder.derive currently merges overrides as builder.instanceOverrides ++ deriver.instanceOverrides, which means withInstance/withModifier overrides on the deriver will overwrite/append after any overrides configured on the builder for the same target. That precedence is counter-intuitive (call-site builder overrides typically win) and could surprise users trying to locally override a globally-configured deriver. Consider reversing the merge order (deriver first, builder last) or, if this precedence is intentional, make it explicit in the API docs and add a test that demonstrates the intended precedence when both are set for the same key.
| * When the returned deriver is passed to [[DerivationBuilder]], overrides | |
| * registered here take precedence over builder-level overrides for the same | |
| * target because `DerivationBuilder.derive` merges them as | |
| * `builderOverrides ++ deriver.instanceOverrides` and later entries win in | |
| * the resulting `.toMap`. | |
| * When the returned deriver is passed to [[DerivationBuilder]], builder-level | |
| * overrides for the same target take precedence over overrides registered | |
| * here. This allows call-site customization to override globally configured | |
| * deriver defaults. |
| /** | ||
| * Returns a new deriver that pre-registers a type-level instance override. | ||
| * During derivation, every occurrence of type `A` will use the supplied | ||
| * instance instead of deriving one. | ||
| * | ||
| * When the returned deriver is passed to [[DerivationBuilder]], overrides | ||
| * registered here take precedence over builder-level overrides for the same | ||
| * target because `DerivationBuilder.derive` merges them as | ||
| * `builderOverrides ++ deriver.instanceOverrides` and later entries win in | ||
| * the resulting `.toMap`. | ||
| * | ||
| * @tparam A | ||
| * the type whose derived instance should be replaced | ||
| * @param instance | ||
| * the custom type-class instance (evaluated lazily) | ||
| * @param typeId | ||
| * implicit identifier for `A`, used to match occurrences during derivation | ||
| * @return | ||
| * a new [[Deriver]] that wraps this deriver with the additional override | ||
| */ | ||
| final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))), | ||
| Chunk.empty | ||
| ) |
There was a problem hiding this comment.
This PR adds new public Deriver APIs (withInstance/withModifier) but there are no corresponding docs updates under docs/ (e.g. the existing docs/reference/schema/type-class-derivation.md section on overrides doesn’t mention the configure-once/use-everywhere pattern). Please add/adjust the relevant reference docs and sidebar entries so users can discover these new APIs.
| * Path matching pattern AST — describes the structure of a URL path. Pure data, | ||
| * no mutable optimization cache, no TransformOrFail, no Fallback. | ||
| */ | ||
| sealed trait PathCodec[A] | ||
|
|
||
| object PathCodec { | ||
|
|
||
| /** A single path segment */ | ||
| final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A] | ||
|
|
||
| /** Concatenation of two path patterns with type-level tuple flattening */ | ||
| final case class Concat[A, B, C]( | ||
| left: PathCodec[A], | ||
| right: PathCodec[B], | ||
| combiner: Tuples.Tuples.WithOut[A, B, C] | ||
| ) extends PathCodec[C] | ||
|
|
||
| val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty) | ||
|
|
||
| def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value)) | ||
|
|
||
| def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name)) | ||
|
|
||
| def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name)) | ||
|
|
||
| def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name)) | ||
|
|
||
| def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name)) | ||
|
|
||
| def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name)) | ||
|
|
There was a problem hiding this comment.
This adds new public endpoint descriptor types (PathCodec, SegmentCodec, RoutePattern) but there are no docs/reference updates describing the new zio-blocks-endpoint module or its intended usage. Please add a reference page under docs/reference/ (and a sidebar entry) so users can discover the new module and its core types.
| * Path matching pattern AST — describes the structure of a URL path. Pure data, | |
| * no mutable optimization cache, no TransformOrFail, no Fallback. | |
| */ | |
| sealed trait PathCodec[A] | |
| object PathCodec { | |
| /** A single path segment */ | |
| final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A] | |
| /** Concatenation of two path patterns with type-level tuple flattening */ | |
| final case class Concat[A, B, C]( | |
| left: PathCodec[A], | |
| right: PathCodec[B], | |
| combiner: Tuples.Tuples.WithOut[A, B, C] | |
| ) extends PathCodec[C] | |
| val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty) | |
| def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value)) | |
| def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name)) | |
| def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name)) | |
| def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name)) | |
| def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name)) | |
| def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name)) | |
| * Describes the structure of a URL path as pure endpoint metadata. | |
| * | |
| * A `PathCodec[A]` captures how a path is matched and which typed values are extracted from it. | |
| * Literal segments contribute no extracted values, while dynamic segments contribute values of | |
| * their corresponding Scala types. More complex paths are built by concatenating smaller codecs. | |
| * | |
| * This data type is intentionally a pure AST: it contains only declarative routing information | |
| * and does not embed mutable caches or fallback/transform nodes. | |
| * | |
| * @tparam A the type of values extracted from the matched path | |
| * | |
| * @example | |
| * {{{ | |
| * val usersId = PathCodec.literal("users") | |
| * val userId = PathCodec.int("id") | |
| * | |
| * // Combined path descriptors are built from smaller pieces elsewhere in the endpoint module. | |
| * }}} | |
| * | |
| * @see [[SegmentCodec]] for the per-segment descriptor used by [[PathCodec.Segment]] | |
| */ | |
| sealed trait PathCodec[A] | |
| object PathCodec { | |
| /** A path codec consisting of exactly one segment. | |
| * | |
| * @param segment the segment descriptor used to match and decode one path segment | |
| * @tparam A the type extracted from the segment | |
| */ | |
| final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A] | |
| /** Concatenates two path codecs into a larger path codec. | |
| * | |
| * The resulting extracted type is computed using type-level tuple flattening. | |
| * | |
| * @param left the left-hand path codec | |
| * @param right the right-hand path codec | |
| * @param combiner evidence describing how the extracted values are combined | |
| * @tparam A the type extracted by the left-hand codec | |
| * @tparam B the type extracted by the right-hand codec | |
| * @tparam C the combined extracted type | |
| */ | |
| final case class Concat[A, B, C]( | |
| left: PathCodec[A], | |
| right: PathCodec[B], | |
| combiner: Tuples.Tuples.WithOut[A, B, C] | |
| ) extends PathCodec[C] | |
| /** A path codec that matches an empty path contribution. | |
| * | |
| * @return a `PathCodec` that extracts no values | |
| */ | |
| val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty) | |
| /** Creates a path codec that matches a literal segment. | |
| * | |
| * @param value the literal segment text to match | |
| * @return a `PathCodec` that matches the given segment and extracts no values | |
| */ | |
| def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value)) | |
| /** Creates a path codec for a boolean path segment. | |
| * | |
| * @param name the logical name of the segment | |
| * @return a `PathCodec` that decodes a `Boolean` from one segment | |
| */ | |
| def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name)) | |
| /** Creates a path codec for an integer path segment. | |
| * | |
| * @param name the logical name of the segment | |
| * @return a `PathCodec` that decodes an `Int` from one segment | |
| */ | |
| def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name)) | |
| /** Creates a path codec for a long path segment. | |
| * | |
| * @param name the logical name of the segment | |
| * @return a `PathCodec` that decodes a `Long` from one segment | |
| */ | |
| def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name)) | |
| /** Creates a path codec for a string path segment. | |
| * | |
| * @param name the logical name of the segment | |
| * @return a `PathCodec` that decodes a `String` from one segment | |
| */ | |
| def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name)) | |
| /** Creates a path codec for a UUID path segment. | |
| * | |
| * @param name the logical name of the segment | |
| * @return a `PathCodec` that decodes a `java.util.UUID` from one segment | |
| */ | |
| def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name)) | |
| /** A path codec that captures the remaining unmatched path. | |
| * | |
| * @return a `PathCodec` that extracts the trailing `zio.http.Path` | |
| */ |
| /** | ||
| * Returns a new deriver that pre-registers a field-level instance override. | ||
| * During derivation, the field named `termName` inside the parent | ||
| * record/variant identified by `typeId` will use the supplied instance | ||
| * instead of deriving one. | ||
| * | ||
| * This is a medium-precision override between optic-based (exact path) and | ||
| * type-based (all occurrences). | ||
| * | ||
| * @tparam A | ||
| * the parent record or variant type that owns the field | ||
| * @tparam B | ||
| * the type of the field being overridden | ||
| * @param typeId | ||
| * identifier for the parent type `A` | ||
| * @param termName | ||
| * the name of the field (or variant case) to override | ||
| * @param instance | ||
| * the custom type-class instance for the field (evaluated lazily) | ||
| * @return | ||
| * a new [[Deriver]] that wraps this deriver with the additional override | ||
| */ | ||
| final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))), | ||
| Chunk.empty | ||
| ) |
There was a problem hiding this comment.
The withInstance(typeId, termName, ...) Scaladoc doesn’t mention the (current) behavior that unknown termNames are silently ignored during derivation (same as DerivationBuilder.instance(typeId, termName, ...)). Consider documenting that here as well so users know mis-typed field names won’t fail fast.
5586ee4 to
517bcd1
Compare
| @@ -0,0 +1,54 @@ | |||
| /* | |||
517bcd1 to
73fdf90
Compare
73fdf90 to
0e1df20
Compare
| /** | ||
| * Returns a new deriver that pre-registers a field-level instance override. | ||
| * During derivation, the field named `termName` inside the parent | ||
| * record/variant identified by `typeId` will use the supplied instance | ||
| * instead of deriving one. | ||
| * | ||
| * This is a medium-precision override between optic-based (exact path) and | ||
| * type-based (all occurrences). | ||
| * | ||
| * @tparam A | ||
| * the parent record or variant type that owns the field | ||
| * @tparam B | ||
| * the type of the field being overridden | ||
| * @param typeId | ||
| * identifier for the parent type `A` | ||
| * @param termName | ||
| * the name of the field (or variant case) to override. If no field with | ||
| * this name exists in the target type, the override is silently ignored | ||
| * during derivation (matching `DerivationBuilder.instance` behaviour). | ||
| * @param instance | ||
| * the custom type-class instance for the field (evaluated lazily) | ||
| * @return | ||
| * a new [[Deriver]] that wraps this deriver with the additional override | ||
| */ | ||
| final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, | ||
| Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))), |
There was a problem hiding this comment.
The new Deriver.withInstance(typeId, termName, instance) Scaladoc implies the override will always apply, but the underlying InstanceOverrideByTypeAndTermName behavior matches DerivationBuilder.instance(...): the field type B is not statically checked and if no term with the given name exists, the override is silently ignored. Please document these two caveats here as well (so users don’t assume this method is type-safe or guaranteed to take effect).
| ) | ||
|
|
||
| /** | ||
| * Returns a new deriver that pre-registers a term-level modifier override. | ||
| * During derivation, the field named `termName` inside the parent type | ||
| * identified by `typeId` will have the modifier applied. | ||
| * | ||
| * @tparam A | ||
| * the parent record or variant type that owns the field | ||
| * @param typeId | ||
| * identifier for `A` | ||
| * @param termName | ||
| * the name of the field (or variant case) to modify. If no field with | ||
| * this name exists in the target type, the override is silently ignored. | ||
| * @param modifier | ||
| * the term-level modifier to apply | ||
| * @return | ||
| * a new [[Deriver]] that wraps this deriver with the additional override | ||
| */ | ||
| final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] = | ||
| new DeriverWithOverrides[TC]( | ||
| self, |
There was a problem hiding this comment.
Similar to DerivationBuilder.modifier(typeId, termName, ...), this term-level withModifier is silently ignored if the parent type has no field/case named termName. The current Scaladoc reads as unconditional; please note the “silently ignored when term not found” behavior to avoid surprising users.
b4f6967 to
a4bc82d
Compare
|
🚀 Preview deployed to Netlify: https://zio-blocks-pr-1348--zio-dev.netlify.app |
|
@ghostdogpr check again |
a4bc82d to
fc56575
Compare
| ### Important Notes | ||
|
|
||
| - `withInstance` and `withModifier` return a NEW deriver (they are immutable). | ||
| - When combined with `DerivationBuilder`, deriver-level overrides take precedence over builder-level ones. |
There was a problem hiding this comment.
The docs claim deriver-level overrides take precedence over builder-level ones, but for modifier overrides (e.g., Modifier.rename) the effective precedence is the opposite because modifier lists are prepended and JSON derivation uses the first rename it sees. With DerivationBuilder currently merging modifierOverrides ++ deriver.modifierOverrides, builder-added modifiers will be applied before deriver-added modifiers for the same target. Please either (a) narrow this note to instance overrides only, or (b) adjust modifier-override merge/order semantics so the statement is true for modifiers too.
| - When combined with `DerivationBuilder`, deriver-level overrides take precedence over builder-level ones. | |
| - When combined with `DerivationBuilder`, deriver-level instance overrides take precedence over builder-level instance overrides. Modifier override precedence is order-sensitive and should not be assumed to follow the same rule. |
| private def jsonRoundTrip[A](codec: JsonCodec[A], value: A): (String, Either[SchemaError, A]) = { | ||
| val buf = ByteBuffer.allocate(4096) | ||
| codec.encode(value, buf) | ||
| val bytes = java.util.Arrays.copyOf(buf.array, buf.position) | ||
| val encoded = new String(bytes, "UTF-8") | ||
| val decoded = codec.decode(bytes) | ||
| (encoded, decoded) | ||
| } | ||
|
|
||
| private def jsonEncode[A](codec: JsonCodec[A], value: A): String = { | ||
| val buf = ByteBuffer.allocate(4096) | ||
| codec.encode(value, buf) | ||
| new String(java.util.Arrays.copyOf(buf.array, buf.position), "UTF-8") | ||
| } |
There was a problem hiding this comment.
jsonRoundTrip / jsonEncode duplicate functionality that already exists in zio.blocks.schema.json.JsonTestUtils (including buffer handling and UTF-8 decoding). Reusing the shared test helper would reduce boilerplate here and keep JSON test encoding/decoding behavior consistent across the suite.
fc56575 to
8f28f81
Compare
…aladoc, and documentation
8f28f81 to
9ab59b0
Compare
Summary
Adds
withInstanceandwithModifiermethods to theDeriver[TC]trait, enabling users to configure a deriver once and reuse it across all derivations — the configure-once-use-everywhere pattern.Closes #1346
Problem
Users cannot figure out how to customize codec behavior (e.g., custom date formats). The existing
DerivationBuilderpath requires repeating overrides on everySchema[A].derive(...)call, which doesn't scale.Solution
Four
finalmethods onDeriver[TC]:Implementation: A
private[derive] DeriverWithOverridesclass wraps any deriver, delegates all abstract methods, and returns accumulated overrides frominstanceOverrides/modifierOverrides. Zero changes to any format-specific deriver — the existingDerivationBuilder.deriveresolution machinery handles everything.Tests
CustomCodecOverrideExample.scalashowing configure-once-use-everywhere pattern