@zio.dev/zio-blocks 0.0.21 → 0.0.22
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.
- package/index.md +49 -29
- package/package.json +1 -1
- package/path-interpolator.md +24 -23
- package/reference/codec.md +384 -0
- package/reference/docs.md +1 -1
- package/reference/dynamic-optic.md +392 -0
- package/reference/formats.md +68 -12
- package/reference/json-schema.md +13 -10
- package/reference/lazy.md +361 -0
- package/reference/modifier.md +340 -0
- package/reference/syntax.md +11 -11
- package/reference/type-class-derivation.md +1959 -0
- package/scope.md +230 -232
- package/sidebars.js +6 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: lazy
|
|
3
|
+
title: "Lazy"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The `Lazy[A]` data type represents a deferred computation that produces a value of type `A`. Unlike Scala's built-in `lazy val`, ZIO Blocks' `Lazy` provides a powerful, monadic abstraction with explicit error handling, memoization, and stack-safe evaluation through trampolining:
|
|
7
|
+
|
|
8
|
+
```scala
|
|
9
|
+
sealed trait Lazy[+A] {
|
|
10
|
+
def force: A
|
|
11
|
+
def isEvaluated: Boolean
|
|
12
|
+
def map[B](f: A => B): Lazy[B]
|
|
13
|
+
def flatMap[B](f: A => Lazy[B]): Lazy[B]
|
|
14
|
+
// ... more operations
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
object Lazy {
|
|
18
|
+
def apply[A](expression: => A): Lazy[A]
|
|
19
|
+
def fail(throwable: Throwable): Lazy[Nothing]
|
|
20
|
+
// ... more constructors
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Once a `Lazy` computation is evaluated, the result is cached and `isEvaluated` becomes `true`. Subsequent calls to `force` return the cached value without re-executing the computation, as long as the computed result is not `null`.
|
|
25
|
+
|
|
26
|
+
Note: the current implementation uses `null` internally as the sentinel for “not evaluated”. If a `Lazy` computation legitimately returns `null`, it will be recomputed on every `force` call and `isEvaluated` will remain `false`. To benefit from memoization, prefer non-null results (for example, use `Option[A]` instead of returning `null`).
|
|
27
|
+
The `force` method uses trampolining (an explicit stack) to evaluate deeply nested `Lazy` computations without risking stack overflow.
|
|
28
|
+
|
|
29
|
+
## Why Lazy Exists?
|
|
30
|
+
|
|
31
|
+
During type-class derivation, instances for nested types must be created before they are used. `Lazy` allows the derivation machinery to build a structure of deferred computations, resolving them only when the final instance is forced.
|
|
32
|
+
|
|
33
|
+
When deriving a type-class instance for a complex type, the derivation machinery needs to:
|
|
34
|
+
|
|
35
|
+
1. **Traverse the schema tree**: Visit each node (records, variants, sequences, etc.)
|
|
36
|
+
2. **Create instances for nested types**: Before creating an instance for a parent type, instances for child types must exist
|
|
37
|
+
3. **Handle recursion**: For recursive types, the instance for the recursive reference must be deferred
|
|
38
|
+
|
|
39
|
+
Here's a simplified view of how `Lazy` enables this:
|
|
40
|
+
|
|
41
|
+
```scala
|
|
42
|
+
// In DerivationBuilder
|
|
43
|
+
def transformRecord[A](
|
|
44
|
+
fields: IndexedSeq[Term[F, A, ?]],
|
|
45
|
+
metadata: F[BindingType.Record, A],
|
|
46
|
+
// ...
|
|
47
|
+
): Lazy[Reflect.Record[G, A]] = Lazy {
|
|
48
|
+
// Get instances for field types (may trigger evaluation of other Lazy values)
|
|
49
|
+
val fieldInstances = fields.map { field =>
|
|
50
|
+
D.instance(field.value.metadata) // Returns Lazy[TC[FieldType]]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create the record instance
|
|
54
|
+
val instance = deriver.deriveRecord(fields, /* ... */)
|
|
55
|
+
|
|
56
|
+
new Reflect.Record(fields, ..., new BindingInstance(metadata, instance), ...)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The `BindingInstance` class wraps both a `Binding` and a `Lazy[TC[A]]`:
|
|
61
|
+
|
|
62
|
+
```scala
|
|
63
|
+
final case class BindingInstance[TC[_], T, A](
|
|
64
|
+
binding: Binding[T, A],
|
|
65
|
+
instance: Lazy[TC[A]]
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This allows the derivation to build a complete tree of `Lazy` instances, which are only forced when the final type-class instance is needed.
|
|
70
|
+
|
|
71
|
+
## Creating Lazy Values
|
|
72
|
+
|
|
73
|
+
### Basic Construction
|
|
74
|
+
|
|
75
|
+
Create a `Lazy` value by passing a by-name expression to `Lazy.apply`:
|
|
76
|
+
|
|
77
|
+
```scala
|
|
78
|
+
import zio.blocks.schema.Lazy
|
|
79
|
+
|
|
80
|
+
// The expression is NOT evaluated here
|
|
81
|
+
val lazyInt: Lazy[Int] = Lazy {
|
|
82
|
+
println("Computing...")
|
|
83
|
+
42
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
println(lazyInt.isEvaluated) // false
|
|
87
|
+
|
|
88
|
+
// Now the expression is evaluated
|
|
89
|
+
val result = lazyInt.force // prints "Computing..."
|
|
90
|
+
println(result) // 42
|
|
91
|
+
println(lazyInt.isEvaluated) // true
|
|
92
|
+
|
|
93
|
+
// Subsequent calls return the cached value
|
|
94
|
+
val result2 = lazyInt.force // does NOT print "Computing..."
|
|
95
|
+
println(result2) // 42
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Creating Failed Lazy Values
|
|
99
|
+
|
|
100
|
+
Use `Lazy.fail` to create a `Lazy` that will throw an exception when forced:
|
|
101
|
+
|
|
102
|
+
```scala
|
|
103
|
+
val failingLazy: Lazy[Int] =
|
|
104
|
+
Lazy.fail(new RuntimeException("Something went wrong"))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Transforming Lazy Values
|
|
108
|
+
|
|
109
|
+
`Lazy` is a monad, supporting `map`, `flatMap`, and other familiar operations.
|
|
110
|
+
|
|
111
|
+
### map
|
|
112
|
+
|
|
113
|
+
Transform the value inside a `Lazy` without forcing it:
|
|
114
|
+
|
|
115
|
+
```scala
|
|
116
|
+
val lazyInt: Lazy[Int] = Lazy(42)
|
|
117
|
+
val lazyString: Lazy[String] = lazyInt.map(_.toString)
|
|
118
|
+
|
|
119
|
+
println(lazyString.force) // "42"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### flatMap
|
|
123
|
+
|
|
124
|
+
Chain `Lazy` computations together:
|
|
125
|
+
|
|
126
|
+
```scala
|
|
127
|
+
val lazyA: Lazy[Int] = Lazy(10)
|
|
128
|
+
val lazyB: Lazy[Int] = Lazy(20)
|
|
129
|
+
|
|
130
|
+
val lazySum: Lazy[Int] = lazyA.flatMap(a => lazyB.map(b => a + b))
|
|
131
|
+
|
|
132
|
+
println(lazySum.force) // 30
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Using for-comprehension syntax:
|
|
136
|
+
|
|
137
|
+
```scala
|
|
138
|
+
val result: Lazy[String] = for {
|
|
139
|
+
x <- Lazy(10)
|
|
140
|
+
y <- Lazy(20)
|
|
141
|
+
z <- Lazy(30)
|
|
142
|
+
} yield s"Sum: ${x + y + z}"
|
|
143
|
+
|
|
144
|
+
println(result.force) // "Sum: 60"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### as
|
|
148
|
+
|
|
149
|
+
Replace the value with a constant, discarding the original:
|
|
150
|
+
|
|
151
|
+
```scala
|
|
152
|
+
val lazy42: Lazy[Int] = Lazy(42)
|
|
153
|
+
val lazyHello: Lazy[String] = lazy42.as("Hello")
|
|
154
|
+
|
|
155
|
+
println(lazyHello.force) // "Hello"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### unit
|
|
159
|
+
|
|
160
|
+
Discard the value, keeping only the side effects:
|
|
161
|
+
|
|
162
|
+
```scala
|
|
163
|
+
val lazyWithSideEffect: Lazy[Int] = Lazy {
|
|
164
|
+
println("Side effect!")
|
|
165
|
+
42
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
val lazyUnit: Lazy[Unit] = lazyWithSideEffect.unit
|
|
169
|
+
lazyUnit.force // prints "Side effect!", returns ()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### flatten
|
|
173
|
+
|
|
174
|
+
Flatten a nested `Lazy[Lazy[A]]` into `Lazy[A]`:
|
|
175
|
+
|
|
176
|
+
```scala
|
|
177
|
+
val nested: Lazy[Lazy[Int]] = Lazy(Lazy(42))
|
|
178
|
+
val flat: Lazy[Int] = nested.flatten
|
|
179
|
+
|
|
180
|
+
println(flat.force) // 42
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### zip
|
|
184
|
+
|
|
185
|
+
Combine two `Lazy` values into a tuple:
|
|
186
|
+
|
|
187
|
+
```scala
|
|
188
|
+
val lazyA: Lazy[Int] = Lazy(1)
|
|
189
|
+
val lazyB: Lazy[String] = Lazy("hello")
|
|
190
|
+
|
|
191
|
+
val lazyPair: Lazy[(Int, String)] = lazyA.zip(lazyB)
|
|
192
|
+
|
|
193
|
+
println(lazyPair.force) // (1, "hello")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Error Handling
|
|
197
|
+
|
|
198
|
+
### catchAll
|
|
199
|
+
|
|
200
|
+
Recover from errors by providing an alternative `Lazy`:
|
|
201
|
+
|
|
202
|
+
```scala
|
|
203
|
+
val failing: Lazy[Int] = Lazy(throw new RuntimeException("oops"))
|
|
204
|
+
val recovered: Lazy[Int] = failing.catchAll(_ => Lazy(0))
|
|
205
|
+
|
|
206
|
+
println(recovered.force) // 0
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The error handler receives the thrown exception:
|
|
210
|
+
|
|
211
|
+
```scala
|
|
212
|
+
val failing: Lazy[Int] = Lazy(throw new RuntimeException("specific error"))
|
|
213
|
+
val handled: Lazy[Int] = failing.catchAll { error =>
|
|
214
|
+
println(s"Caught: ${error.getMessage}")
|
|
215
|
+
Lazy(-1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
println(handled.force) // prints "Caught: specific error", returns -1
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### ensuring
|
|
222
|
+
|
|
223
|
+
Run a finalizer regardless of success or failure:
|
|
224
|
+
|
|
225
|
+
```scala
|
|
226
|
+
var resourceClosed = false
|
|
227
|
+
|
|
228
|
+
val computation: Lazy[Int] = Lazy {
|
|
229
|
+
42
|
|
230
|
+
}.ensuring(Lazy {
|
|
231
|
+
resourceClosed = true
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
println(computation.force) // 42
|
|
235
|
+
println(resourceClosed) // true
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The finalizer runs even when the main computation fails:
|
|
239
|
+
|
|
240
|
+
```scala
|
|
241
|
+
var finalizerRan = false
|
|
242
|
+
|
|
243
|
+
val failing: Lazy[Int] = Lazy[Int] {
|
|
244
|
+
throw new RuntimeException("error")
|
|
245
|
+
}.ensuring(Lazy {
|
|
246
|
+
finalizerRan = true
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
failing.force
|
|
251
|
+
} catch {
|
|
252
|
+
case _: RuntimeException => ()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
println(finalizerRan) // true
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Working with Collections
|
|
259
|
+
|
|
260
|
+
### collectAll
|
|
261
|
+
|
|
262
|
+
Convert an `IndexedSeq[Lazy[A]]` into a `Lazy[IndexedSeq[A]]`:
|
|
263
|
+
|
|
264
|
+
```scala
|
|
265
|
+
val lazies: IndexedSeq[Lazy[Int]] = IndexedSeq(Lazy(1), Lazy(2), Lazy(3))
|
|
266
|
+
val collected: Lazy[IndexedSeq[Int]] = Lazy.collectAll(lazies)
|
|
267
|
+
|
|
268
|
+
println(collected.force) // IndexedSeq(1, 2, 3)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### foreach
|
|
272
|
+
|
|
273
|
+
Apply a function that returns `Lazy` to each element of a collection:
|
|
274
|
+
|
|
275
|
+
```scala
|
|
276
|
+
val numbers: IndexedSeq[Int] = IndexedSeq(1, 2, 3)
|
|
277
|
+
val doubled: Lazy[IndexedSeq[Int]] = Lazy.foreach(numbers)(n => Lazy(n * 2))
|
|
278
|
+
|
|
279
|
+
println(doubled.force) // IndexedSeq(2, 4, 6)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## How `force` Works: A Deep Dive
|
|
283
|
+
|
|
284
|
+
The `force` method is the heart of the `Lazy` data type. It evaluates the deferred computation and returns the result. Understanding how it works is essential for understanding `Lazy`.
|
|
285
|
+
|
|
286
|
+
### Internal Structure
|
|
287
|
+
|
|
288
|
+
`Lazy` has three internal components:
|
|
289
|
+
|
|
290
|
+
1. **`Defer[A](thunk: () => A)`**: Represents a deferred computation. The `thunk` is a function that, when called, produces the value.
|
|
291
|
+
|
|
292
|
+
2. **`FlatMap[A, B](first: Lazy[A], cont: Cont[A, B])`**: Represents a chained computation where `first` must be evaluated, then its result is passed to the continuation `cont`.
|
|
293
|
+
|
|
294
|
+
3. **`Cont[A, B](ifSuccess: A => Lazy[B], ifError: Throwable => Lazy[B])`**: A continuation that handles both success and error cases.
|
|
295
|
+
|
|
296
|
+
Additionally, each `Lazy` instance has mutable fields for memoization:
|
|
297
|
+
- `value: Any` - Stores the cached successful result
|
|
298
|
+
- `error: Throwable` - Stores the cached exception
|
|
299
|
+
|
|
300
|
+
### Why Trampolining?
|
|
301
|
+
|
|
302
|
+
Without trampolining, deeply nested `flatMap` chains would overflow the stack:
|
|
303
|
+
|
|
304
|
+
```scala
|
|
305
|
+
// This would overflow without trampolining
|
|
306
|
+
var lazy: Lazy[Int] = Lazy(0)
|
|
307
|
+
for (_ <- 1 to 100000) {
|
|
308
|
+
lazy = lazy.flatMap(n => Lazy(n + 1))
|
|
309
|
+
}
|
|
310
|
+
lazy.force // Works! Returns 100000
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The trampolining approach converts recursive calls into an iterative loop with an explicit stack (`List[Cont[Any, Any]]`), consuming constant stack space regardless of nesting depth.
|
|
314
|
+
|
|
315
|
+
## Comparison with Scala's `lazy val`
|
|
316
|
+
|
|
317
|
+
| Feature | `Lazy[A]` | Scala `lazy val` |
|
|
318
|
+
|--------------------|------------------------------|------------------------------|
|
|
319
|
+
| Monadic operations | Yes (`map`, `flatMap`) | No |
|
|
320
|
+
| Error handling | Yes (`catchAll`, `ensuring`) | No |
|
|
321
|
+
| Stack safety | Yes (trampolined `flatMap`) | N/A (no monadic chaining) |
|
|
322
|
+
| Composable | Yes | Limited |
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
## API Reference
|
|
326
|
+
|
|
327
|
+
### Constructors
|
|
328
|
+
|
|
329
|
+
| Method | Description |
|
|
330
|
+
|---------------------------|---------------------------------------------------------------------|
|
|
331
|
+
| `Lazy(expr: => A)` | Create a `Lazy` from a by-name expression |
|
|
332
|
+
| `Lazy.fail(e: Throwable)` | Create a `Lazy` that fails with the given exception |
|
|
333
|
+
| `Lazy.collectAll(values)` | Combine an `IndexedSeq` of `Lazy` into a single `Lazy` |
|
|
334
|
+
| `Lazy.foreach(values)(f)` | Apply a function to each element of an `IndexedSeq`, collecting results |
|
|
335
|
+
|
|
336
|
+
Note: For other collection types, convert them using `.toIndexedSeq` before calling `Lazy.collectAll` or `Lazy.foreach`.
|
|
337
|
+
### Instance Methods
|
|
338
|
+
|
|
339
|
+
| Method | Description |
|
|
340
|
+
|----------------------------------------------|-------------------------------------------|
|
|
341
|
+
| `force: A` | Evaluate and return the result (memoized) |
|
|
342
|
+
| `isEvaluated: Boolean` | Check if the value has been computed |
|
|
343
|
+
| `map[B](f: A => B): Lazy[B]` | Transform the value |
|
|
344
|
+
| `flatMap[B](f: A => Lazy[B]): Lazy[B]` | Chain computations |
|
|
345
|
+
| `flatten: Lazy[B]` | Flatten nested `Lazy` |
|
|
346
|
+
| `as[B](b: => B): Lazy[B]` | Replace the value with a constant |
|
|
347
|
+
| `unit: Lazy[Unit]` | Discard the value |
|
|
348
|
+
| `zip[B](that: Lazy[B]): Lazy[(A, B)]` | Combine with another `Lazy` |
|
|
349
|
+
| `catchAll[B >: A](f: Throwable => Lazy[B]): Lazy[B]` | Handle errors |
|
|
350
|
+
| `ensuring(finalizer: Lazy[Any]): Lazy[A]` | Run finalizer on completion |
|
|
351
|
+
|
|
352
|
+
### Equality and Hashing
|
|
353
|
+
|
|
354
|
+
`Lazy` values are compared by forcing both values and comparing the results:
|
|
355
|
+
|
|
356
|
+
```scala
|
|
357
|
+
Lazy(42) == Lazy(42) // true (forces both)
|
|
358
|
+
Lazy(42).hashCode == Lazy(42).hashCode // true
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Note: Comparing `Lazy` values will force their evaluation. Also, `hashCode` forces the value and then calls `.hashCode` on it; if a `Lazy` evaluates to `null`, `hashCode` will throw a `NullPointerException`, and repeated equality checks may re-run the thunk because `null` is used internally as the “not yet evaluated” sentinel.
|
|
@@ -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.
|