@zio.dev/zio-blocks 0.0.21 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ ---
2
+ id: modifier
3
+ title: "Modifier"
4
+ ---
5
+
6
+ `Modifier` is a sealed trait that provides a mechanism to attach metadata and configuration to schema elements. Modifiers serve as annotations for record fields, variant cases, and reflect values, enabling format-specific customization without polluting domain types.
7
+
8
+ Modifiers are designed to be **pure data** values that can be serialized, making them ideal for runtime introspection and cross-process schema exchange. When deriving schemas, modifiers are collected and attached to the corresponding fields or types, allowing codecs to read and interpret them accordingly. They are extended with `StaticAnnotation` to also support annotation syntax:
9
+
10
+ ```scala
11
+ sealed trait Modifier extends StaticAnnotation
12
+ object Modifier {
13
+ sealed trait Term extends Modifier
14
+ // ... term modifiers (transient, rename, alias, config) ...
15
+ sealed trait Reflect extends Modifier
16
+ // ... reflect modifiers (config) ...
17
+ }
18
+ ```
19
+
20
+ Modifiers can be applied in two ways:
21
+
22
+ 1. **Programmatic API**: Using the `Schema#modifier` and `Schema#modifiers` methods to attach modifiers to the entire schema or, for field-level modifiers, attach them to specific fields using optics when deriving codecs. This approach keeps your domain types clean and allows you to separate schema configuration from your data model:
23
+
24
+ ```scala
25
+ import zio.blocks.schema._
26
+ import zio.blocks.schema.json._
27
+
28
+ // Clean domain type - zero dependencies
29
+ case class User(
30
+ id: String,
31
+ name: String,
32
+ cache: Map[String, String] = Map.empty
33
+ )
34
+
35
+ // Modifiers applied separately to schema and codecs
36
+ object User extends CompanionOptics[User] {
37
+ implicit val schema: Schema[User] = Schema
38
+ .derived[User]
39
+ .modifier(Modifier.config("db.table-name", "users"))
40
+
41
+ implicit val jsonCodec: JsonBinaryCodec[User] =
42
+ schema
43
+ .deriving[JsonBinaryCodec](JsonBinaryCodecDeriver)
44
+ .modifier(User.name, Modifier.rename("username"))
45
+ .modifier(User.cache, Modifier.transient())
46
+ .derive
47
+
48
+ lazy val id : Lens[User, String] = $(_.id)
49
+ lazy val name : Lens[User, String] = $(_.name)
50
+ lazy val cache: Lens[User, Map[String, String]] = $(_.cache)
51
+ }
52
+ ```
53
+
54
+ In the above example, we derived a JSON codec for `User` and applied the `rename` and `transient` modifiers to the `name` and `cache` fields respectively, while keeping the domain type free of any schema-related annotations. Now when encoding a `User` to JSON, the `name` field will be serialized as `username`, and the `cache` field will be omitted. During decoding, the codec will look for `username` in the input JSON and populate the `name` field accordingly:
55
+
56
+ ```scala
57
+ val user = User(
58
+ id = "123",
59
+ name = "Alice",
60
+ cache = Map("lastLogin" -> "2024-06-01T12:00:00Z")
61
+ )
62
+ val json: String = User.jsonCodec.encodeToString(user)
63
+ println(json)
64
+ // Prints: {"id":"123","username":"Alice"}
65
+ val decodedUser: Either[SchemaError, User] = User.jsonCodec.decode(json)
66
+ println(decodedUser)
67
+ // Prints: Right(User(123,Alice,Map()))
68
+ ```
69
+
70
+ Please note that when deriving codecs, you can access these modifiers programmatically, allowing you to build custom logic based on the presence of certain modifiers. For example, your SQL codec could check for the presence of `db.table-name` in the schema modifiers to determine which table to read from or write to.
71
+
72
+ 2. **Annotation Syntax**: Using the `@` syntax to annotate fields and cases directly in your case classes and sealed traits. These annotations are processed during schema derivation to attach the corresponding modifiers to the schema elements. At runtime, you can access these modifiers through the `Reflect` structure of the schema.
73
+
74
+ ```scala
75
+ import zio.blocks.schema._
76
+ import zio.blocks.schema.Modifier._
77
+
78
+ @Modifier.config("db.table-name", "users")
79
+ case class User(
80
+ id: String,
81
+ @Modifier.rename("username") name: String,
82
+ @Modifier.transient() cache: Map[String, String] = Map.empty
83
+ )
84
+
85
+ object User extends CompanionOptics[User] {
86
+ implicit val schema: Schema[User] =
87
+ Schema.derived[User]
88
+
89
+ implicit val jsonCodec: JsonBinaryCodec[User] =
90
+ schema
91
+ .derive[JsonBinaryCodec](JsonBinaryCodecDeriver)
92
+ }
93
+ ```
94
+
95
+ In this example, we applied the same modifiers as in the programmatic example, but using annotation syntax directly on the case class fields. Let's try encoding and decoding a `User` instance:
96
+
97
+ ```scala
98
+ val user = User(
99
+ id = "123",
100
+ name = "Alice",
101
+ cache = Map("lastLogin" -> "2024-06-01T12:00:00Z")
102
+ )
103
+
104
+ val json: String = User.jsonCodec.encodeToString(user)
105
+ println(json)
106
+ // Prints: {"id":"123","username":"Alice"}
107
+ val decodedUser: Either[SchemaError, User] = User.jsonCodec.decode(json)
108
+ println(decodedUser)
109
+ // Prints: Right(User(123,Alice,Map()))
110
+ ```
111
+
112
+ ## Modifier Hierarchy
113
+
114
+ Modifiers are organized into two main categories:
115
+
116
+ 1. **Term modifiers** - annotate record fields or variant cases (the data structure elements)
117
+ 2. **Reflect modifiers** - annotate schemas/reflect values themselves (the metadata about types)
118
+
119
+ ```
120
+ Modifier
121
+ ├── Modifier.Term (annotates record fields and variant cases)
122
+ │ ├── transient() : exclude from serialization
123
+ │ ├── rename(name) : change serialized name
124
+ │ ├── alias(name) : add alternative name
125
+ │ └── config(key, val) : attach key-value metadata
126
+ └── Modifier.Reflect (annotates reflect values / types)
127
+ └── config(key, val) : attach key-value metadata
128
+ ```
129
+
130
+ As you can see, `config` is the only modifier that extends both `Term` and `Reflect`, allowing it to be used on both fields and types.
131
+
132
+ ## Term Modifiers
133
+
134
+ Term modifiers annotate record fields and variant cases. They are used to control how individual fields or cases are serialized and deserialized, as well as to attach additional metadata that can be interpreted by codecs or other tools.
135
+
136
+ ### transient
137
+
138
+ The `transient` modifier marks a field as transient, meaning it will be excluded from serialization. This is useful for computed fields, caches, or sensitive data that shouldn't be persisted.
139
+
140
+ ### rename
141
+
142
+ The `rename` modifier changes the serialized name of a field or variant case. This is useful when the field name in your Scala code differs from the expected name in the serialized format.
143
+
144
+ ```scala
145
+ import zio.blocks.schema._
146
+
147
+ case class Person(
148
+ @Modifier.rename("user_name") name: String,
149
+ @Modifier.rename("user_age") age: Int
150
+ )
151
+
152
+ object Person {
153
+ implicit val schema: Schema[Person] = Schema.derived
154
+ }
155
+ ```
156
+
157
+ You can also use `rename` on variant cases to customize the discriminator value:
158
+
159
+ ```scala
160
+ import zio.blocks.schema._
161
+
162
+ sealed trait PaymentMethod
163
+
164
+ object PaymentMethod {
165
+ @Modifier.rename("credit_card")
166
+ case class CreditCard(number: String, cvv: String) extends PaymentMethod
167
+
168
+ @Modifier.rename("bank_transfer")
169
+ case class BankTransfer(iban: String) extends PaymentMethod
170
+
171
+ implicit val schema: Schema[PaymentMethod] = Schema.derived
172
+ }
173
+ ```
174
+
175
+ ### alias
176
+
177
+ The `alias` modifier provides an alternative name for a term during decoding. This is useful for supporting multiple names during schema evolution or data migration.
178
+
179
+ ```scala
180
+ import zio.blocks.schema._
181
+
182
+ case class MyClass(
183
+ @Modifier.rename("NewName")
184
+ @Modifier.alias("OldName")
185
+ @Modifier.alias("LegacyName")
186
+ value: String
187
+ )
188
+
189
+ object MyClass {
190
+ implicit val schema: Schema[MyClass] = Schema.derived
191
+ }
192
+ ```
193
+
194
+ With this configuration:
195
+ - **Encoding** always uses the `rename` value: `"NewName"`
196
+ - **Decoding** accepts any of: `"NewName"`, `"OldName"`, or `"LegacyName"`
197
+
198
+ This pattern is particularly useful when migrating data formats without breaking compatibility with existing data.
199
+
200
+ ### config
201
+
202
+ The `config` modifier attaches arbitrary key-value metadata to a term (record fields or variant cases) or a type itself. The convention for keys is `<format>.<property>`, allowing format-specific configuration.
203
+
204
+ ```scala
205
+ import zio.blocks.schema._
206
+
207
+ case class Event(
208
+ @Modifier.config("protobuf.field-id", "1") id: Long,
209
+ @Modifier.config("protobuf.field-id", "2") name: String
210
+ )
211
+
212
+ object Event {
213
+ implicit val schema: Schema[Event] = Schema.derived
214
+ }
215
+ ```
216
+
217
+ The `config` modifier extends both `Term` and `Reflect`, making it usable on both fields and types. We will discuss using `config` on types in the reflect modifiers section below.
218
+
219
+ ## Reflect Modifiers
220
+
221
+ Reflect modifiers annotate reflect values (types themselves). Currently, only `config` is a reflect modifier.
222
+
223
+ ### config
224
+
225
+ You can attach configuration to the type itself using the `Schema#modifier` method:
226
+
227
+ ```scala
228
+ import zio.blocks.schema._
229
+
230
+ case class Person(name: String, age: Int)
231
+
232
+ object Person {
233
+ implicit val schema: Schema[Person] = Schema.derived
234
+ .modifier(Modifier.config("db.table-name", "person_table"))
235
+ .modifier(Modifier.config("schema.version", "v2"))
236
+ }
237
+ ```
238
+
239
+ Or add multiple modifiers at once:
240
+
241
+ ```scala
242
+ import zio.blocks.schema._
243
+
244
+ case class Person(name: String, age: Int)
245
+
246
+ object Person {
247
+ implicit val schema: Schema[Person] = Schema.derived
248
+ .modifiers(
249
+ Seq(
250
+ Modifier.config("db.table-name", "person_table"),
251
+ Modifier.config("schema.version", "v2")
252
+ )
253
+ )
254
+ }
255
+ ```
256
+
257
+ Or annotate the case class directly:
258
+
259
+ ```scala
260
+ import zio.blocks.schema._
261
+
262
+ @Modifier.config("db.table-name", "person_table")
263
+ @Modifier.config("schema.version", "v2")
264
+ case class Person(name: String, age: Int)
265
+
266
+ object Person {
267
+ implicit val schema: Schema[Person] = Schema.derived
268
+ }
269
+ ```
270
+
271
+ ## Programmatic Modifier Access
272
+
273
+ You can access modifiers programmatically through the `Reflect` structure:
274
+
275
+ ```scala
276
+ import zio.blocks.schema._
277
+
278
+ case class Person(
279
+ @Modifier.rename("full_name") name: String,
280
+ @Modifier.transient cache: String = ""
281
+ )
282
+
283
+ object Person {
284
+ implicit val schema: Schema[Person] = Schema.derived
285
+ }
286
+
287
+ // Access field modifiers through the reflect
288
+ val reflect = Schema[Person].reflect
289
+ reflect match {
290
+ case record: Reflect.Record[_, _] =>
291
+ record.fields.foreach { field =>
292
+ println(s"Field: ${field.name}")
293
+ println(s"Modifiers: ${field.modifiers}")
294
+ }
295
+ case _ => ()
296
+ }
297
+ ```
298
+
299
+ ## Built-in Schema Support
300
+
301
+ All modifier types have built-in `Schema` instances, enabling them to be serialized and deserialized:
302
+
303
+ ```scala
304
+ import zio.blocks.schema._
305
+
306
+ // Schema instances for individual modifiers
307
+ Schema[Modifier.transient]
308
+ Schema[Modifier.rename]
309
+ Schema[Modifier.alias]
310
+ Schema[Modifier.config]
311
+
312
+ // Schema instances for modifier traits
313
+ Schema[Modifier.Term]
314
+ Schema[Modifier.Reflect]
315
+ Schema[Modifier]
316
+ ```
317
+
318
+ This means you can serialize modifiers as part of your schema metadata, allowing you to persist and exchange schema information with full modifier details.
319
+
320
+ [//]: # (## Format Support)
321
+ [//]: # ()
322
+ [//]: # (Different serialization formats interpret modifiers according to their semantics.)
323
+ [//]: # ()
324
+ [//]: # (TODO: Add a table comparing how each modifier is supported across formats like JSON, BSON, Avro, Protobuf, etc. For example:)
325
+ [//]: # (| Modifier | JSON | BSON | Avro | Protobuf |)
326
+ [//]: # (|-------------|----------------------|----------------------|-------------------|-------------------|)
327
+ [//]: # (| `transient` | Field omitted | Field omitted | Field omitted | Field omitted |)
328
+ [//]: # (| `rename` | Custom field name | Custom field name | Custom field name | Custom field name |)
329
+ [//]: # (| `alias` | Accepts alternatives | Accepts alternatives | - | - |)
330
+ [//]: # (| `config` | Format-specific | Format-specific | Format-specific | Format-specific |)
331
+
332
+ ## Best Practices
333
+
334
+ 1. **Use `rename` for external APIs**: When integrating with external systems that use different naming conventions (snake_case vs camelCase), use `rename` to match the expected format.
335
+
336
+ 2. **Use `alias` for migrations**: When evolving your data model, add `alias` modifiers to support reading old data while writing with new names.
337
+
338
+ 3. **Use `transient` sparingly**: Only mark fields as transient when they are truly derived or temporary. Remember that transient fields need default values.
339
+
340
+ 4. **Use namespaced keys for `config`**: Follow the `<format>.<property>` convention to avoid conflicts between different formats or tools.
@@ -5,6 +5,10 @@ title: "Optics"
5
5
 
6
6
  Optics are a fundamental feature of ZIO Blocks that enable type-safe, composable access and modification of nested data structures. What sets ZIO Blocks apart is its implementation of **reflective optics** — a novel construct that combines the operational capabilities of traditional optics with embedded structural metadata, enabling both data manipulation AND introspection.
7
7
 
8
+ :::tip
9
+ For a practical walkthrough of building query DSLs with optics, see the [Writing a Query DSL with Reified Optics](../guides/query-dsl-reified-optics.md) guide.
10
+ :::
11
+
8
12
  ## What Are Optics?
9
13
 
10
14
  Optics are abstractions that allow you to focus on a specific part of a data structure. They provide a way to **view**, **update**, and **traverse** nested fields in immutable data types without boilerplate code: