@zio.dev/zio-blocks 0.0.26 → 0.0.28
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/guides/query-dsl-extending.md +1 -1
- package/guides/query-dsl-fluent-builder.md +1 -1
- package/guides/query-dsl-reified-optics.md +1 -1
- package/guides/query-dsl-sql.md +1 -1
- package/guides/zio-schema-migration.md +1195 -0
- package/index.md +21 -12
- package/package.json +1 -1
- package/reference/allows.md +1377 -0
- package/reference/binding-resolver.md +469 -0
- package/reference/binding.md +1 -1
- package/reference/codec.md +10 -10
- package/reference/docs.md +1 -1
- package/reference/dynamic-optic.md +5 -0
- package/reference/dynamic-schema.md +602 -0
- package/reference/dynamic-value.md +5 -0
- package/reference/json-patch.md +803 -0
- package/reference/json.md +1 -1
- package/reference/media-type.md +2 -2
- package/reference/modifier.md +9 -9
- package/reference/patch.md +4 -0
- package/reference/schema-error.md +569 -0
- package/reference/schema-evolution/as.md +587 -0
- package/reference/schema-evolution/index.md +50 -0
- package/reference/schema-evolution/into.md +1027 -0
- package/reference/schema-expr.md +2 -2
- package/reference/schema.md +29 -0
- package/reference/structural-types.md +369 -0
- package/reference/type-class-derivation.md +329 -324
- package/reference/validation.md +1 -1
- package/reference/xml.md +1304 -0
- package/scope.md +241 -17
- package/sidebars.js +21 -1
- package/reference/schema-evolution.md +0 -540
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: allows
|
|
3
|
+
title: "Allows"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import Tabs from '@theme/Tabs';
|
|
7
|
+
import TabItem from '@theme/TabItem';
|
|
8
|
+
|
|
9
|
+
`Allows[A, S]` is a compile-time capability token that proves, at the call site, that type `A` satisfies the structural grammar `S`. A capability token is a compile-time phantom proof value — it carries no runtime data and exists solely to pass evidence through the type system that a structural constraint has been satisfied.
|
|
10
|
+
|
|
11
|
+
`Allows` does **not** require or use `Schema[A]`. It inspects the Scala type structure of `A` directly at compile time, using nothing but the Scala type system. Any `Schema[A]` that appears alongside `Allows` in examples is the library author's own separate constraint — it is not imposed by `Allows` itself.
|
|
12
|
+
|
|
13
|
+
```scala
|
|
14
|
+
sealed abstract class Allows[A, S <: Allows.Structural]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
The gap `Allows` fills is **structural preconditions** at the call site, at compile time, with precise error messages. Structural preconditions are constraints on the shape of a type's fields (e.g., "all fields must be scalars"), unlike runtime checks which happen during execution and produce exceptions or errors.
|
|
20
|
+
|
|
21
|
+
## Motivation
|
|
22
|
+
|
|
23
|
+
ZIO Blocks gives library authors a powerful way to build data-oriented DSLs. A library can accept `A: Schema` and use the schema at runtime to serialize, deserialize, query, or transform values of `A`. A data-oriented DSL is a generic API built around a data description (Schema) rather than a fixed interface, allowing one function to serialize, validate, or transform any conforming type. Many generic functions have **structural preconditions** that don't require a schema.
|
|
24
|
+
|
|
25
|
+
Consider these real-world scenarios:
|
|
26
|
+
|
|
27
|
+
- A CSV serializer requires flat records of scalars — nested records should fail at the call site, not deep inside the serializer.
|
|
28
|
+
- An RDBMS layer cannot handle nested records as column values — the error should name the problematic field.
|
|
29
|
+
- An event bus expects a sealed trait of flat record cases — violations should be caught before publishing.
|
|
30
|
+
- A JSON document store allows arbitrarily nested records but not `DynamicValue` leaves — the schema validation should be precise. DynamicValue is the schema-less escape hatch that can hold arbitrary data — a DynamicValue leaf bypasses compile-time checking entirely, making it impossible for the compiler to enforce any structural grammar.
|
|
31
|
+
|
|
32
|
+
Without `Allows`, these constraints can only be checked at runtime, producing confusing errors deep inside library internals. With `Allows[A, S]`, the constraint is verified at the **call site**, at compile time, with precise, path-aware error messages and concrete fix suggestions.
|
|
33
|
+
|
|
34
|
+
## The Upper Bound Semantics
|
|
35
|
+
|
|
36
|
+
`Allows[A, S]` is an upper bound. A type `A` that uses only a strict subset of what `S` permits also satisfies it — just as `A <: Foo` does not require that `A` uses every method of `Foo`. Upper bound semantics is the right choice because a lower bound would require using every shape (impractical), exact matching would require naming every shape used (too rigid), whereas upper bound says "your type may use any of these shapes" — a permission, not a mandate.
|
|
37
|
+
|
|
38
|
+
```scala
|
|
39
|
+
import zio.blocks.schema.comptime.Allows
|
|
40
|
+
import Allows._
|
|
41
|
+
|
|
42
|
+
// Both satisfy Record[Primitive | Optional[Primitive]] — the upper bound
|
|
43
|
+
|
|
44
|
+
case class UserRow(name: String, age: Int)
|
|
45
|
+
// UserRow satisfies the grammar: all fields are Primitive
|
|
46
|
+
|
|
47
|
+
case class UserRowOpt(name: String, age: Int, email: Option[String])
|
|
48
|
+
// UserRowOpt also satisfies the grammar: all fields are Primitive or Optional[Primitive]
|
|
49
|
+
|
|
50
|
+
val ev1: Allows[UserRow, Record[Primitive | Optional[Primitive]]] = implicitly
|
|
51
|
+
val ev2: Allows[UserRowOpt, Record[Primitive | Optional[Primitive]]] = implicitly
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Creating Instances
|
|
55
|
+
|
|
56
|
+
`Allows[A, S]` is not instantiated directly. Instead, you summon an evidence value at the point where you need the constraint. The macro automatically verifies the constraint at compile time.
|
|
57
|
+
|
|
58
|
+
<Tabs groupId="scala-version" defaultValue="scala2">
|
|
59
|
+
<TabItem value="scala2" label="Scala 2">
|
|
60
|
+
|
|
61
|
+
```scala
|
|
62
|
+
import zio.blocks.schema.comptime.Allows
|
|
63
|
+
import Allows._
|
|
64
|
+
|
|
65
|
+
def toJson[A](doc: A)(implicit ev: Allows[A, Record[Primitive]]): String = ???
|
|
66
|
+
|
|
67
|
+
// Or summon at the call site:
|
|
68
|
+
val evidence = implicitly[Allows[Int, Primitive]]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
</TabItem>
|
|
72
|
+
<TabItem value="scala3" label="Scala 3">
|
|
73
|
+
|
|
74
|
+
```scala
|
|
75
|
+
import zio.blocks.schema.comptime.Allows
|
|
76
|
+
import Allows._
|
|
77
|
+
|
|
78
|
+
def toJson[A](doc: A)(using Allows[A, Record[Primitive]]): String = ???
|
|
79
|
+
|
|
80
|
+
// Calling the function:
|
|
81
|
+
case class Person(name: String, age: Int)
|
|
82
|
+
val json = toJson(Person("Alice", 30)) // Compiles if Person satisfies Record[Primitive]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
</TabItem>
|
|
86
|
+
</Tabs>
|
|
87
|
+
|
|
88
|
+
The constraint is checked once, at the call site. If the type `A` does not satisfy `S`, you get a compile-time error with a precise message showing exactly which field violates the grammar.
|
|
89
|
+
|
|
90
|
+
## Grammar Nodes
|
|
91
|
+
|
|
92
|
+
All grammar nodes extend `Allows.Structural`.
|
|
93
|
+
|
|
94
|
+
| Node | Matches |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `Primitive` | **Any** scalar — catch-all for all 30 Schema 2 primitive types |
|
|
97
|
+
| `Primitive.Boolean` | `scala.Boolean` only |
|
|
98
|
+
| `Primitive.Int` | `scala.Int` only |
|
|
99
|
+
| `Primitive.Long` | `scala.Long` only |
|
|
100
|
+
| `Primitive.Double` | `scala.Double` only |
|
|
101
|
+
| `Primitive.Float` | `scala.Float` only |
|
|
102
|
+
| `Primitive.String` | `java.lang.String` only |
|
|
103
|
+
| `Primitive.BigDecimal` | `scala.BigDecimal` only |
|
|
104
|
+
| `Primitive.BigInt` | `scala.BigInt` only |
|
|
105
|
+
| `Primitive.Unit` | `scala.Unit` only |
|
|
106
|
+
| `Primitive.Byte` | `scala.Byte` only |
|
|
107
|
+
| `Primitive.Short` | `scala.Short` only |
|
|
108
|
+
| `Primitive.Char` | `scala.Char` only |
|
|
109
|
+
| `Primitive.UUID` | `java.util.UUID` only |
|
|
110
|
+
| `Primitive.Currency` | `java.util.Currency` only |
|
|
111
|
+
| `Primitive.Instant` / `LocalDate` / `LocalDateTime` / … | Each specific `java.time.*` type |
|
|
112
|
+
| | |
|
|
113
|
+
| `Record[A]` | A case class / product type whose every field satisfies `A`. Vacuously true for zero-field records. Sealed traits and enums are **automatically unwrapped**: each case is checked individually, so no `Variant` node is needed. |
|
|
114
|
+
| `Sequence[A]` | Any collection (`List`, `Vector`, `Set`, `Array`, `Chunk`, …) whose element type satisfies `A` |
|
|
115
|
+
| `Map[K, V]` | `Map`, `HashMap`, … whose key satisfies `K` and value satisfies `V` |
|
|
116
|
+
| `Optional[A]` | `Option[X]` where the inner type `X` satisfies `A` |
|
|
117
|
+
| `Wrapped[A]` | A ZIO Prelude `Newtype`/`Subtype` wrapper whose underlying type satisfies `A` |
|
|
118
|
+
| | |
|
|
119
|
+
| `Self` | Recursive self-reference back to the entire enclosing `Allows[A, S]` grammar |
|
|
120
|
+
| `Dynamic` | `DynamicValue` — the schema-less escape hatch |
|
|
121
|
+
| `IsType[A]` | Exact nominal type match: satisfied only when the checked type is exactly `A` (`=:=`) |
|
|
122
|
+
| `` `\|` `` | Union of two grammar nodes: `A \| B`. In Scala 2 write `` A `\|` B `` in infix position. |
|
|
123
|
+
|
|
124
|
+
Every specific `Primitive.Xxx` node also satisfies the top-level `Primitive` node (which matches any of the 30 primitive types). This means a type annotated with `Primitive.Int` is valid wherever `Primitive` or `Primitive | Primitive.Long` is required.
|
|
125
|
+
|
|
126
|
+
## Core Operations
|
|
127
|
+
|
|
128
|
+
`Allows[A, S]` is a **proof token**, not an ordinary value. It carries zero public methods that you call directly. Instead, you use it in three ways:
|
|
129
|
+
|
|
130
|
+
1. **As a constraint in function signatures** — Declare `Allows[A, S]` as an implicit/using parameter to require that callers pass only types satisfying the grammar.
|
|
131
|
+
2. **To summon evidence** — Use `implicitly[Allows[A, S]]` (Scala 2) or `summon[Allows[A, S]]` (Scala 3) at a call site to check the constraint and get an error message if it fails.
|
|
132
|
+
3. **In type aliases** — Define type aliases like `type FlatRecord = Allows[_, Record[Primitive | Optional[Primitive]]]` to name constraints and reuse them across functions.
|
|
133
|
+
|
|
134
|
+
The macro that powers `Allows` checks the constraint **at compile time** and emits nothing but a reference to a single private singleton at runtime, so there is zero per-call-site overhead.
|
|
135
|
+
|
|
136
|
+
## Specific Primitives
|
|
137
|
+
|
|
138
|
+
The `Primitive` parent class is the catch-all: it accepts any of the 30 Schema 2 primitive types. For stricter control — such as when the target serialisation format only supports a subset — use the specific subtype nodes in `Allows.Primitive`:
|
|
139
|
+
|
|
140
|
+
```scala
|
|
141
|
+
import zio.blocks.schema.comptime.Allows
|
|
142
|
+
import Allows._
|
|
143
|
+
|
|
144
|
+
// Only JSON-representable scalars (no UUID, Char, java.time.*)
|
|
145
|
+
type JsonPrimitive =
|
|
146
|
+
Primitive.Boolean | Primitive.Int | Primitive.Long |
|
|
147
|
+
Primitive.Double | Primitive.String | Primitive.BigDecimal |
|
|
148
|
+
Primitive.BigInt | Primitive.Unit
|
|
149
|
+
|
|
150
|
+
def toJson[A](doc: A)(using Allows[A, Record[JsonPrimitive | Self]]): String = ???
|
|
151
|
+
|
|
152
|
+
// Only numeric types
|
|
153
|
+
type Numeric = Primitive.Int | Primitive.Long | Primitive.Double | Primitive.Float |
|
|
154
|
+
Primitive.BigInt | Primitive.BigDecimal
|
|
155
|
+
|
|
156
|
+
def aggregate[A](data: A)(using Allows[A, Record[Numeric]]): Double = ???
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A type annotated with `Primitive.Int` satisfies `Primitive` (the catch-all) because `Primitive.Int extends Primitive`:
|
|
160
|
+
|
|
161
|
+
```scala
|
|
162
|
+
import zio.blocks.schema.comptime.Allows
|
|
163
|
+
import Allows._
|
|
164
|
+
|
|
165
|
+
val ev: Allows[Int, Primitive] = implicitly // Primitive (catch-all) — ✓
|
|
166
|
+
val sp: Allows[Int, Primitive.Int] = implicitly // Primitive.Int (specific) — ✓
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### JSON Document Store Example
|
|
170
|
+
|
|
171
|
+
JSON's primitive value set is `null | boolean | number | string`. Types such as `UUID`, `Char`, and all `java.time.*` types have no native JSON representation and must be encoded as strings at the application layer. Using `JsonPrimitive` instead of the catch-all `Primitive` enforces this at compile time.
|
|
172
|
+
|
|
173
|
+
A JSON document grammar is straightforward: a JSON value is either a record (JSON object) or a sequence (JSON array), and `Self` handles all nesting:
|
|
174
|
+
|
|
175
|
+
```scala
|
|
176
|
+
import zio.blocks.schema.comptime.Allows
|
|
177
|
+
import Allows._
|
|
178
|
+
|
|
179
|
+
type JsonPrimitive =
|
|
180
|
+
Primitive.Boolean | Primitive.Int | Primitive.Long | Primitive.Double |
|
|
181
|
+
Primitive.String | Primitive.BigDecimal | Primitive.BigInt | Primitive.Unit
|
|
182
|
+
|
|
183
|
+
type Json = Record[JsonPrimitive | Self] | Sequence[JsonPrimitive | Self]
|
|
184
|
+
|
|
185
|
+
def toJson[A](doc: A)(using Allows[A, Json]): String = ???
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`Self` recurses back to `Json` at every nested position, so `List[String]` satisfies `Sequence[JsonPrimitive | Self]` (String is JsonPrimitive), `List[Author]` satisfies it too (Author satisfies `Record[JsonPrimitive | Self]` via Self), and top-level arrays work directly.
|
|
189
|
+
|
|
190
|
+
A type with a UUID or Instant field fails at compile time with this error:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
[error] Schema shape violation at WithUUID.id: found Primitive(java.util.UUID),
|
|
194
|
+
required Primitive.Boolean | Primitive.Int | ... | Primitive.String | ...
|
|
195
|
+
UUID is not a JSON-native type — encode it as Primitive.String.
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Union Syntax
|
|
199
|
+
|
|
200
|
+
Union types express "or" in the grammar.
|
|
201
|
+
|
|
202
|
+
<Tabs groupId="scala-version" defaultValue="scala2">
|
|
203
|
+
<TabItem value="scala2" label="Scala 2">
|
|
204
|
+
|
|
205
|
+
Uses the infix operator `` Primitive `|` Optional[Primitive] `` from `Allows`:
|
|
206
|
+
|
|
207
|
+
```scala
|
|
208
|
+
import zio.blocks.schema.comptime.Allows
|
|
209
|
+
import Allows._
|
|
210
|
+
|
|
211
|
+
def writeCsv[A](rows: Seq[A])(implicit
|
|
212
|
+
ev: Allows[A, Record[Primitive | Optional[Primitive]]]
|
|
213
|
+
): Unit = ???
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
</TabItem>
|
|
217
|
+
<TabItem value="scala3" label="Scala 3">
|
|
218
|
+
|
|
219
|
+
Uses native union type syntax:
|
|
220
|
+
|
|
221
|
+
```scala
|
|
222
|
+
import zio.blocks.schema.comptime.Allows
|
|
223
|
+
import Allows._
|
|
224
|
+
|
|
225
|
+
def writeCsv[A](rows: Seq[A])(using
|
|
226
|
+
Allows[A, Record[Primitive | Optional[Primitive]]]
|
|
227
|
+
): Unit = ???
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
</TabItem>
|
|
231
|
+
</Tabs>
|
|
232
|
+
|
|
233
|
+
Both spellings compile and produce the same semantic behavior. The grammar is identical — the only difference is how the union type is expressed.
|
|
234
|
+
|
|
235
|
+
## Use Cases
|
|
236
|
+
|
|
237
|
+
### Flat Record (CSV, RDBMS Row)
|
|
238
|
+
|
|
239
|
+
```scala
|
|
240
|
+
import zio.blocks.schema.Schema
|
|
241
|
+
import zio.blocks.schema.comptime.Allows
|
|
242
|
+
import Allows._
|
|
243
|
+
|
|
244
|
+
// Flat record: only primitives and optional primitives allowed
|
|
245
|
+
def writeCsv[A: Schema](rows: Seq[A])(using
|
|
246
|
+
Allows[A, Record[Primitive | Optional[Primitive]]]
|
|
247
|
+
): Unit = ???
|
|
248
|
+
|
|
249
|
+
// RDBMS INSERT: primitives, optional primitives, or string-keyed maps (JSONB)
|
|
250
|
+
def insert[A: Schema](value: A)(using
|
|
251
|
+
Allows[A, Record[Primitive | Optional[Primitive] | Allows.Map[Primitive, Primitive]]]
|
|
252
|
+
): String = ???
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
If a user passes a type with nested records, they get a precise compile-time error like this:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
[error] Schema shape violation at UserWithAddress.address: found Record(Address),
|
|
259
|
+
required Primitive | Optional[Primitive] | Map[Primitive, Primitive]
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Event Bus / Message Broker
|
|
263
|
+
|
|
264
|
+
Published events are typically sealed traits of flat record cases. No `Variant` node is needed — sealed traits are automatically unwrapped:
|
|
265
|
+
|
|
266
|
+
```scala
|
|
267
|
+
import zio.blocks.schema.Schema
|
|
268
|
+
import zio.blocks.schema.comptime.Allows
|
|
269
|
+
import Allows._
|
|
270
|
+
|
|
271
|
+
// DomainEvent is a sealed trait; its cases must each satisfy Record[Primitive | Sequence[Primitive]]
|
|
272
|
+
def publish[A: Schema](event: A)(using
|
|
273
|
+
Allows[A, Record[Primitive | Optional[Primitive] | Sequence[Primitive]]]
|
|
274
|
+
): Unit = ???
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
If a case of the sealed trait has a nested record field, the error names that case and field like this:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
[error] Schema shape violation at DomainEvent.OrderPlaced.items.<element>:
|
|
281
|
+
found Record(OrderItem), required Primitive | Optional[Primitive] | Sequence[Primitive]
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### JSON Document Store (Recursive)
|
|
285
|
+
|
|
286
|
+
A document store accepts arbitrarily nested records but not `DynamicValue` leaves. The `Self` node expresses the recursive grammar:
|
|
287
|
+
|
|
288
|
+
```scala
|
|
289
|
+
import zio.blocks.schema.Schema
|
|
290
|
+
import zio.blocks.schema.comptime.Allows
|
|
291
|
+
import Allows._
|
|
292
|
+
|
|
293
|
+
type JsonDocument =
|
|
294
|
+
Record[Primitive | Self | Optional[Primitive | Self] | Sequence[Primitive | Self] | Allows.Map[Primitive, Primitive | Self]]
|
|
295
|
+
|
|
296
|
+
def toJson[A: Schema](doc: A)(using Allows[A, JsonDocument]): String = ???
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This grammar allows:
|
|
300
|
+
- `case class Author(name: String, email: String)` — Record[Primitive] ✓
|
|
301
|
+
- `case class Book(title: String, author: Author, tags: List[String])` — Record with Self-nested record and Sequence[Primitive] ✓
|
|
302
|
+
- `case class Category(name: String, subcategories: List[Category])` — recursive ✓
|
|
303
|
+
|
|
304
|
+
But rejects:
|
|
305
|
+
- `case class Bad(name: String, payload: DynamicValue)` — DynamicValue is not in the grammar ✗
|
|
306
|
+
|
|
307
|
+
### GraphQL / Tree Structures (Self)
|
|
308
|
+
|
|
309
|
+
```scala
|
|
310
|
+
import zio.blocks.schema.Schema
|
|
311
|
+
import zio.blocks.schema.comptime.Allows
|
|
312
|
+
import Allows._
|
|
313
|
+
|
|
314
|
+
def graphqlType[A: Schema]()(using
|
|
315
|
+
Allows[A, Record[Primitive | Optional[Self] | Sequence[Self]]]
|
|
316
|
+
): String = ???
|
|
317
|
+
|
|
318
|
+
// Works:
|
|
319
|
+
case class TreeNode(value: Int, children: List[TreeNode])
|
|
320
|
+
object TreeNode { implicit val schema: Schema[TreeNode] = Schema.derived }
|
|
321
|
+
// graphqlType[TreeNode]() — compiles fine
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Sequence Subtypes
|
|
325
|
+
|
|
326
|
+
The `Sequence[A]` node accepts any collection type. When a DSL needs to restrict to a specific kind of collection — for example, a DynamoDB `Set` operation that is only valid on sets, not lists — use the `Sequence` subtypes:
|
|
327
|
+
|
|
328
|
+
```scala
|
|
329
|
+
import zio.blocks.schema.comptime.Allows
|
|
330
|
+
import Allows._
|
|
331
|
+
|
|
332
|
+
// Only an immutable List is accepted
|
|
333
|
+
val listOnly: Allows[List[Int], Sequence.List[Primitive]] = implicitly
|
|
334
|
+
|
|
335
|
+
// Only an immutable Set is accepted
|
|
336
|
+
val setOnly: Allows[Set[Int], Sequence.Set[Primitive]] = implicitly
|
|
337
|
+
|
|
338
|
+
// Only a Vector
|
|
339
|
+
val vecOnly: Allows[Vector[String], Sequence.Vector[Primitive]] = implicitly
|
|
340
|
+
|
|
341
|
+
// Only an Array
|
|
342
|
+
val arrOnly: Allows[Array[Int], Sequence.Array[Primitive]] = implicitly
|
|
343
|
+
|
|
344
|
+
// Only a Chunk
|
|
345
|
+
import zio.blocks.chunk.Chunk
|
|
346
|
+
val chkOnly: Allows[Chunk[String], Sequence.Chunk[Primitive]] = implicitly
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Each subtype extends `Sequence[A]`, so a grammar written with the parent `Sequence` still accepts all collection kinds. A grammar written with a subtype rejects other kinds at compile time:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
// Set[Int] does NOT satisfy Sequence.List[Primitive] — compile error:
|
|
353
|
+
[error] Shape violation at Set: found Sequence[scala.Int], required Sequence.List[...]
|
|
354
|
+
val bad: Allows[Set[Int], Sequence.List[Primitive]] = implicitly
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### DynamoDB-style set operations
|
|
358
|
+
|
|
359
|
+
A DynamoDB grammar can encode the distinction between set types and list types exactly, without any additional runtime proof. We use `Sequence.Set` to narrow the grammar to sets-only operations:
|
|
360
|
+
|
|
361
|
+
```scala
|
|
362
|
+
import zio.blocks.schema.comptime.Allows
|
|
363
|
+
import Allows._
|
|
364
|
+
|
|
365
|
+
type N = Primitive.Int | Primitive.Long | Primitive.Float | Primitive.Double | Primitive.Short
|
|
366
|
+
type S = Primitive.String
|
|
367
|
+
type NS = Sequence.Set[N | Wrapped[N]]
|
|
368
|
+
type SS = Sequence.Set[S | Wrapped[S]]
|
|
369
|
+
|
|
370
|
+
// addSet is only callable with a Set — List[Int] or Vector[Int] would fail at compile time
|
|
371
|
+
def addSet[A](set: scala.collection.immutable.Set[A])(implicit
|
|
372
|
+
ev: Allows[scala.collection.immutable.Set[A], NS | SS]
|
|
373
|
+
): String = "ok"
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## `IsType[A]`
|
|
377
|
+
|
|
378
|
+
`IsType[A]` is a nominal type predicate. It is satisfied only when the checked Scala type is exactly `A` (i.e. `checked =:= A`). It is most useful as an element constraint inside `Sequence` subtypes, where it aligns the element type of a collection with a polymorphic method type parameter.
|
|
379
|
+
|
|
380
|
+
The primary motivation (GitHub issue #1172) is DSL methods that must constrain both the container kind and the element type in a single `Allows` expression. Without `IsType`, a separate type class (like `Containable`) is needed to connect `A` in `contains[A]` to the element type of the collection. With `IsType`, the connection is expressed directly in the grammar.
|
|
381
|
+
|
|
382
|
+
To use `IsType[A]` with a polymorphic `A`, require `IsNominalType[A]` from `zio-blocks-typeid` at the call site. This ensures the macro always sees a concrete type when it evaluates `IsType[A]`:
|
|
383
|
+
|
|
384
|
+
```scala
|
|
385
|
+
import zio.blocks.schema.comptime.Allows
|
|
386
|
+
import Allows._
|
|
387
|
+
import zio.blocks.typeid.IsNominalType
|
|
388
|
+
|
|
389
|
+
// `To` must be a Set whose element type is exactly `A`.
|
|
390
|
+
// IsNominalType[A] ensures A is concrete at the call site — an unresolved
|
|
391
|
+
// type parameter would fail to produce IsNominalType and the call site
|
|
392
|
+
// would not compile.
|
|
393
|
+
def contains[To, From, A: IsNominalType](a: A)(implicit
|
|
394
|
+
ev: Allows[To, Sequence.Set[IsType[A]]]
|
|
395
|
+
): Boolean = ev.ne(null)
|
|
396
|
+
|
|
397
|
+
// Compiles: Set[Int] satisfies Sequence.Set[IsType[Int]]
|
|
398
|
+
val r1: Boolean = contains[Set[Int], Nothing, Int](42)
|
|
399
|
+
|
|
400
|
+
// Compiles: Set[String] satisfies Sequence.Set[IsType[String]]
|
|
401
|
+
val r2: Boolean = contains[Set[String], Nothing, String]("hello")
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
A mismatch between the element type and `A` is a compile-time error:
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
[error] Shape violation at ...<element>: found Primitive(java.lang.String),
|
|
408
|
+
required IsType[Int]
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`IsType[A]` can also appear as a standalone constraint, or anywhere a grammar node is accepted:
|
|
412
|
+
|
|
413
|
+
```scala
|
|
414
|
+
import zio.blocks.schema.comptime.Allows
|
|
415
|
+
|
|
416
|
+
// Int satisfies IsType[Int] exactly
|
|
417
|
+
val ev: Allows[Int, Allows.IsType[Int]] = implicitly
|
|
418
|
+
|
|
419
|
+
// List[String] satisfies Sequence[IsType[String]]
|
|
420
|
+
val ev2: Allows[List[String], Allows.Sequence[Allows.IsType[String]]] = implicitly
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## The `Self` Grammar Node
|
|
424
|
+
|
|
425
|
+
`Self` refers back to the entire enclosing `Allows[A, S]` grammar. It allows the grammar to describe recursive data structures.
|
|
426
|
+
|
|
427
|
+
**Non-recursive types** satisfy `Self`-containing grammars without issue: if no field ever recurses back to the root type, the `Self` position is never reached, and the constraint is vacuously satisfied.
|
|
428
|
+
|
|
429
|
+
**Mutual recursion** between two or more distinct types is a compile-time error reported as:
|
|
430
|
+
|
|
431
|
+
```
|
|
432
|
+
[error] Mutually recursive types are not supported by Allows.
|
|
433
|
+
Cycle: Forest -> Tree -> Forest
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## `Wrapped[A]` and Newtypes
|
|
437
|
+
|
|
438
|
+
The `Wrapped[A]` node matches ZIO Prelude `Newtype` and `Subtype` wrappers. The underlying type must satisfy `A`. Here's an example:
|
|
439
|
+
|
|
440
|
+
```scala
|
|
441
|
+
import zio.prelude.Newtype
|
|
442
|
+
import zio.blocks.schema.Schema
|
|
443
|
+
import zio.blocks.schema.comptime.Allows
|
|
444
|
+
import Allows._
|
|
445
|
+
|
|
446
|
+
// ZIO Prelude Newtype pattern:
|
|
447
|
+
object ProductCode extends Newtype[String]
|
|
448
|
+
type ProductCode = ProductCode.Type
|
|
449
|
+
|
|
450
|
+
given Schema[ProductCode] =
|
|
451
|
+
Schema[String].transform(_.asInstanceOf[ProductCode], _.asInstanceOf[String])
|
|
452
|
+
|
|
453
|
+
// ProductCode satisfies Wrapped[Primitive] — its underlying String is Primitive
|
|
454
|
+
val ev: Allows[ProductCode, Wrapped[Primitive]] = implicitly
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Scala 3 opaque types** are resolved to their underlying type by the macro (they are transparent), so an opaque alias like this satisfies `Primitive` directly:
|
|
458
|
+
|
|
459
|
+
```scala
|
|
460
|
+
opaque type UserId = java.util.UUID
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Sealed Traits and Enums (Auto-Unwrap)
|
|
464
|
+
|
|
465
|
+
Sealed traits and enums are **automatically unwrapped** by the macro. Whenever a sealed type is encountered at any grammar check position, the macro recursively checks every case against the same grammar. This makes a `Variant` grammar node unnecessary.
|
|
466
|
+
|
|
467
|
+
```scala
|
|
468
|
+
import zio.blocks.schema.comptime.Allows
|
|
469
|
+
import Allows._
|
|
470
|
+
|
|
471
|
+
sealed trait Shape
|
|
472
|
+
case class Circle(radius: Double) extends Shape
|
|
473
|
+
case class Rectangle(width: Double, height: Double) extends Shape
|
|
474
|
+
case object Point extends Shape
|
|
475
|
+
|
|
476
|
+
// No Variant node — Shape is auto-unwrapped, all cases checked against Record[Primitive]
|
|
477
|
+
val ev: Allows[Shape, Record[Primitive]] = implicitly
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Auto-unwrap is recursive: if a case is itself a sealed trait, its cases are unwrapped too, to any depth.
|
|
481
|
+
|
|
482
|
+
Union branches (`A | B`) work naturally with auto-unwrap: unused branches are fine under `Allows` upper-bound semantics.
|
|
483
|
+
|
|
484
|
+
## Error Messages
|
|
485
|
+
|
|
486
|
+
When a type does not satisfy the grammar, the macro reports:
|
|
487
|
+
|
|
488
|
+
1. **The path** to the violating field: `Order.items.<element>`
|
|
489
|
+
2. **What was found**: `Record(OrderItem)`
|
|
490
|
+
3. **What was required**: `Primitive | Sequence[Primitive]`
|
|
491
|
+
4. **A hint** where applicable
|
|
492
|
+
|
|
493
|
+
Multiple violations are reported in a single compilation pass — the user sees all problems at once, for example:
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
[error] Schema shape violation at UserWithAddress.address: found Record(Address),
|
|
497
|
+
required Primitive | Optional[Primitive] | Map[Primitive, Primitive]
|
|
498
|
+
[error] Hint: Type 'Address' does not match any allowed shape
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
## Singleton / Zero-Field Records
|
|
502
|
+
|
|
503
|
+
`Record[A]` is vacuously true for case objects and zero-field records, since there are no fields to violate the constraint:
|
|
504
|
+
|
|
505
|
+
```scala
|
|
506
|
+
import zio.blocks.schema.Schema
|
|
507
|
+
import zio.blocks.schema.comptime.Allows
|
|
508
|
+
import Allows._
|
|
509
|
+
|
|
510
|
+
case object EmptyEvent
|
|
511
|
+
implicit val schema: Schema[EmptyEvent.type] = Schema.derived
|
|
512
|
+
|
|
513
|
+
val ev: Allows[EmptyEvent.type, Record[Primitive]] = implicitly // vacuously true
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## Runtime Cost
|
|
517
|
+
|
|
518
|
+
`Allows[A, S]` carries **zero runtime overhead**. The macro emits a reference to a single private singleton `Allows.instance` cast to the required type. There is no per-call-site allocation.
|
|
519
|
+
|
|
520
|
+
## Scala 2 vs Scala 3
|
|
521
|
+
|
|
522
|
+
| Feature | Scala 2 | Scala 3 |
|
|
523
|
+
|---|---|---|
|
|
524
|
+
| Union syntax | `` A `\|` B `` infix | `A \| B` native union type |
|
|
525
|
+
| Summon syntax | `implicitly[Allows[A, S]]` | `summon[Allows[A, S]]` or `implicitly` |
|
|
526
|
+
| Evidence parameter | `(implicit ev: Allows[A, S])` | `(using Allows[A, S])` |
|
|
527
|
+
| Opaque type detection | ZIO Prelude only | Scala 3 opaque types + ZIO Prelude + neotype |
|
|
528
|
+
| Derivation keyword | `Schema.derived` implicit | `Schema.derived` or `derives Schema` |
|
|
529
|
+
|
|
530
|
+
Both Scala versions produce the same macro behavior and the same error messages.
|
|
531
|
+
|
|
532
|
+
## Integration with Schema
|
|
533
|
+
|
|
534
|
+
`Allows` and `Schema` are complementary but independent:
|
|
535
|
+
|
|
536
|
+
- **`Schema[A]`** describes what an `A` looks like at runtime — how to serialize, deserialize, introspect, or transform it. It requires explicit derivation and handles the full type signature.
|
|
537
|
+
- **`Allows[A, S]`** describes what an `A` *may* look like at compile time — a structural grammar that `A` must satisfy. It requires no schema and uses only the Scala type system.
|
|
538
|
+
|
|
539
|
+
You can use `Allows` **without** `Schema`:
|
|
540
|
+
|
|
541
|
+
```scala
|
|
542
|
+
import zio.blocks.schema.comptime.Allows
|
|
543
|
+
import Allows._
|
|
544
|
+
|
|
545
|
+
// Pure shape constraint, no Schema required
|
|
546
|
+
def writeCsv[A](rows: Seq[A])(using Allows[A, Record[Primitive | Optional[Primitive]]]): Unit = ???
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Or combine them when runtime encoding **and** shape validation are both needed:
|
|
550
|
+
|
|
551
|
+
```scala
|
|
552
|
+
import zio.blocks.schema.Schema
|
|
553
|
+
import zio.blocks.schema.comptime.Allows
|
|
554
|
+
import Allows._
|
|
555
|
+
|
|
556
|
+
// Shape constraint + runtime encoding
|
|
557
|
+
def writeCsv[A: Schema](rows: Seq[A])(using
|
|
558
|
+
Allows[A, Record[Primitive | Optional[Primitive]]]
|
|
559
|
+
): Unit = ???
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
When combined, `Allows` enforces the structural guarantee that `Schema` can use — for example, a CSV serializer can assume that every field is a primitive or optional primitive and skip defensive type checks.
|
|
563
|
+
|
|
564
|
+
See [Schema](./schema.md) for more on runtime encoding and decoding with schemas.
|
|
565
|
+
|
|
566
|
+
## Running the Examples
|
|
567
|
+
|
|
568
|
+
All code from this guide is available as runnable examples in the `schema-examples` module.
|
|
569
|
+
|
|
570
|
+
**1. Clone the repository and navigate to the project:**
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
git clone https://github.com/zio/zio-blocks.git
|
|
574
|
+
cd zio-blocks
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**2. Run individual examples with sbt:**
|
|
578
|
+
|
|
579
|
+
**CSV serializer with flat record compile-time constraints**
|
|
580
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/AllowsCsvExample.scala))
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
sbt "schema-examples/runMain comptime.AllowsCsvExample"
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
```scala title="schema-examples/src/main/scala/comptime/AllowsCsvExample.scala"
|
|
587
|
+
package comptime
|
|
588
|
+
|
|
589
|
+
import zio.blocks.schema._
|
|
590
|
+
import zio.blocks.schema.comptime.Allows
|
|
591
|
+
import Allows.{Primitive, Record, `|`}
|
|
592
|
+
import Allows.{Optional => AOptional}
|
|
593
|
+
import util.ShowExpr.show
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// CSV serializer example using Allows[A, S] compile-time shape constraints
|
|
597
|
+
//
|
|
598
|
+
// A CSV row is a flat record: every field must be a primitive scalar or an
|
|
599
|
+
// optional primitive (for nullable columns). Nested records, sequences, and
|
|
600
|
+
// maps are all rejected at compile time.
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
// Compatible: flat record of primitives and optional primitives
|
|
604
|
+
case class Employee(name: String, department: String, salary: BigDecimal, active: Boolean)
|
|
605
|
+
object Employee { implicit val schema: Schema[Employee] = Schema.derived }
|
|
606
|
+
|
|
607
|
+
case class SensorReading(sensorId: String, timestamp: Long, value: Double, unit: Option[String])
|
|
608
|
+
object SensorReading { implicit val schema: Schema[SensorReading] = Schema.derived }
|
|
609
|
+
|
|
610
|
+
object CsvSerializer {
|
|
611
|
+
|
|
612
|
+
type FlatRow = Primitive | AOptional[Primitive]
|
|
613
|
+
|
|
614
|
+
/** Serialize a sequence of flat records to CSV format. */
|
|
615
|
+
def toCsv[A](rows: Seq[A])(implicit schema: Schema[A], ev: Allows[A, Record[FlatRow]]): String = {
|
|
616
|
+
val reflect = schema.reflect.asRecord.get
|
|
617
|
+
val header = reflect.fields.map(_.name).mkString(",")
|
|
618
|
+
val lines = rows.map { row =>
|
|
619
|
+
val dv = schema.toDynamicValue(row)
|
|
620
|
+
dv match {
|
|
621
|
+
case DynamicValue.Record(fields) =>
|
|
622
|
+
fields.map { case (_, v) => csvEscape(dvToString(v)) }.mkString(",")
|
|
623
|
+
case _ => ""
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
(header +: lines).mkString("\n")
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private def dvToString(dv: DynamicValue): String = dv match {
|
|
630
|
+
case DynamicValue.Primitive(PrimitiveValue.String(s)) => s
|
|
631
|
+
case DynamicValue.Primitive(PrimitiveValue.Boolean(b)) => b.toString
|
|
632
|
+
case DynamicValue.Primitive(PrimitiveValue.Int(n)) => n.toString
|
|
633
|
+
case DynamicValue.Primitive(PrimitiveValue.Long(n)) => n.toString
|
|
634
|
+
case DynamicValue.Primitive(PrimitiveValue.Double(n)) => n.toString
|
|
635
|
+
case DynamicValue.Primitive(PrimitiveValue.Float(n)) => n.toString
|
|
636
|
+
case DynamicValue.Primitive(PrimitiveValue.BigDecimal(n)) => n.toString
|
|
637
|
+
case DynamicValue.Primitive(v) => v.toString
|
|
638
|
+
case DynamicValue.Null => ""
|
|
639
|
+
case DynamicValue.Variant(tag, inner) if tag == "Some" => dvToString(inner)
|
|
640
|
+
case DynamicValue.Variant(tag, _) if tag == "None" => ""
|
|
641
|
+
case DynamicValue.Record(fields) =>
|
|
642
|
+
fields.headOption.map { case (_, v) => dvToString(v) }.getOrElse("")
|
|
643
|
+
case other => other.toString
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private def csvEscape(s: String): String =
|
|
647
|
+
if (s.contains(",") || s.contains("\"") || s.contains("\n"))
|
|
648
|
+
"\"" + s.replace("\"", "\"\"") + "\""
|
|
649
|
+
else s
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Demonstration
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
object AllowsCsvExample extends App {
|
|
657
|
+
|
|
658
|
+
// Flat records of primitives — compiles fine
|
|
659
|
+
val employees = Seq(
|
|
660
|
+
Employee("Alice", "Engineering", BigDecimal("120000.00"), true),
|
|
661
|
+
Employee("Bob", "Marketing", BigDecimal("95000.50"), true),
|
|
662
|
+
Employee("Carol", "Engineering", BigDecimal("115000.00"), false)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
// CSV output for a flat record of primitives
|
|
666
|
+
show(CsvSerializer.toCsv(employees))
|
|
667
|
+
|
|
668
|
+
// Flat record with optional fields — also compiles
|
|
669
|
+
val readings = Seq(
|
|
670
|
+
SensorReading("temp-01", 1709712000L, 23.5, Some("celsius")),
|
|
671
|
+
SensorReading("temp-02", 1709712060L, 72.1, None)
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
// Optional fields become empty CSV cells when None
|
|
675
|
+
show(CsvSerializer.toCsv(readings))
|
|
676
|
+
|
|
677
|
+
// The following would NOT compile — uncomment to see the error:
|
|
678
|
+
//
|
|
679
|
+
// case class Nested(name: String, address: Address)
|
|
680
|
+
// object Nested { implicit val schema: Schema[Nested] = Schema.derived }
|
|
681
|
+
// CsvSerializer.toCsv(Seq(Nested("Alice", Address("1 Main St", "NY", "10001"))))
|
|
682
|
+
// [error] Schema shape violation at Nested.address: found Record(Address),
|
|
683
|
+
// required Primitive | Optional[Primitive]
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**Event bus with sealed trait auto-unwrap and nested hierarchies**
|
|
688
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/AllowsEventBusExample.scala))
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
sbt "schema-examples/runMain comptime.AllowsEventBusExample"
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
```scala title="schema-examples/src/main/scala/comptime/AllowsEventBusExample.scala"
|
|
695
|
+
package comptime
|
|
696
|
+
|
|
697
|
+
import zio.blocks.schema._
|
|
698
|
+
import zio.blocks.schema.comptime.Allows
|
|
699
|
+
import Allows.{Primitive, Record, Sequence, `|`}
|
|
700
|
+
import Allows.{Optional => AOptional}
|
|
701
|
+
import util.ShowExpr.show
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
// Event bus / message broker example using Allows[A, S]
|
|
705
|
+
//
|
|
706
|
+
// Published events are typically sealed traits of flat record cases. Sealed
|
|
707
|
+
// traits are automatically unwrapped by the Allows macro — each case is
|
|
708
|
+
// checked individually against the grammar. No Variant node is needed.
|
|
709
|
+
//
|
|
710
|
+
// This example also shows nested sealed traits (auto-unwrap is recursive).
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
// Domain events — a sealed trait hierarchy
|
|
714
|
+
sealed trait AccountEvent
|
|
715
|
+
case class AccountOpened(accountId: String, owner: String, initialBalance: BigDecimal) extends AccountEvent
|
|
716
|
+
case class FundsDeposited(accountId: String, amount: BigDecimal) extends AccountEvent
|
|
717
|
+
case class FundsWithdrawn(accountId: String, amount: BigDecimal) extends AccountEvent
|
|
718
|
+
case class AccountClosed(accountId: String, reason: Option[String]) extends AccountEvent
|
|
719
|
+
object AccountEvent { implicit val schema: Schema[AccountEvent] = Schema.derived }
|
|
720
|
+
|
|
721
|
+
// Nested sealed trait — InventoryEvent has a sub-hierarchy
|
|
722
|
+
sealed trait InventoryEvent
|
|
723
|
+
case class ItemAdded(sku: String, quantity: Int) extends InventoryEvent
|
|
724
|
+
case class ItemRemoved(sku: String, quantity: Int) extends InventoryEvent
|
|
725
|
+
|
|
726
|
+
sealed trait InventoryAlert extends InventoryEvent
|
|
727
|
+
case class LowStock(sku: String, remaining: Int) extends InventoryAlert
|
|
728
|
+
case class OutOfStock(sku: String) extends InventoryAlert
|
|
729
|
+
|
|
730
|
+
object InventoryEvent { implicit val schema: Schema[InventoryEvent] = Schema.derived }
|
|
731
|
+
|
|
732
|
+
// Event with sequence fields (e.g. tags or batch items)
|
|
733
|
+
sealed trait BatchEvent
|
|
734
|
+
case class BatchImport(batchId: String, itemIds: List[String]) extends BatchEvent
|
|
735
|
+
case class BatchComplete(batchId: String, count: Int) extends BatchEvent
|
|
736
|
+
object BatchEvent { implicit val schema: Schema[BatchEvent] = Schema.derived }
|
|
737
|
+
|
|
738
|
+
object EventBus {
|
|
739
|
+
|
|
740
|
+
type EventShape = Primitive | AOptional[Primitive]
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Publish a domain event. All cases of the sealed trait must be flat records.
|
|
744
|
+
*/
|
|
745
|
+
def publish[A](event: A)(implicit schema: Schema[A], ev: Allows[A, Record[EventShape]]): String = {
|
|
746
|
+
val dv = schema.toDynamicValue(event)
|
|
747
|
+
val (typeName, payload) = dv match {
|
|
748
|
+
case DynamicValue.Variant(name, inner) => (name, inner.toJson.toString)
|
|
749
|
+
case _ => (schema.reflect.typeId.name, dv.toJson.toString)
|
|
750
|
+
}
|
|
751
|
+
s"PUBLISH topic=${schema.reflect.typeId.name} type=$typeName payload=$payload"
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Publish events that may contain sequence fields (e.g. batch operations).
|
|
756
|
+
*/
|
|
757
|
+
def publishBatch[A](event: A)(implicit
|
|
758
|
+
schema: Schema[A],
|
|
759
|
+
ev: Allows[A, Record[Primitive | Sequence[Primitive]]]
|
|
760
|
+
): String = {
|
|
761
|
+
val dv = schema.toDynamicValue(event)
|
|
762
|
+
val (typeName, payload) = dv match {
|
|
763
|
+
case DynamicValue.Variant(name, inner) => (name, inner.toJson.toString)
|
|
764
|
+
case _ => (schema.reflect.typeId.name, dv.toJson.toString)
|
|
765
|
+
}
|
|
766
|
+
s"PUBLISH topic=${schema.reflect.typeId.name} type=$typeName payload=$payload"
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Demonstration
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
object AllowsEventBusExample extends App {
|
|
775
|
+
|
|
776
|
+
// Flat sealed trait — all cases are records of primitives/optionals
|
|
777
|
+
show(EventBus.publish[AccountEvent](AccountOpened("acc-001", "Alice", BigDecimal("1000.00"))))
|
|
778
|
+
show(EventBus.publish[AccountEvent](FundsDeposited("acc-001", BigDecimal("500.00"))))
|
|
779
|
+
show(EventBus.publish[AccountEvent](AccountClosed("acc-001", Some("customer request"))))
|
|
780
|
+
|
|
781
|
+
// Nested sealed trait — auto-unwrap is recursive
|
|
782
|
+
// InventoryAlert extends InventoryEvent, both are unwrapped
|
|
783
|
+
show(EventBus.publish[InventoryEvent](ItemAdded("SKU-100", 50)))
|
|
784
|
+
show(EventBus.publish[InventoryEvent](LowStock("SKU-100", 3)))
|
|
785
|
+
show(EventBus.publish[InventoryEvent](OutOfStock("SKU-100")))
|
|
786
|
+
|
|
787
|
+
// Events with sequence fields use a wider grammar
|
|
788
|
+
show(EventBus.publishBatch[BatchEvent](BatchImport("batch-42", List("item-1", "item-2", "item-3"))))
|
|
789
|
+
show(EventBus.publishBatch[BatchEvent](BatchComplete("batch-42", 3)))
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
**GraphQL / tree structures using Self for recursive grammars**
|
|
794
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/AllowsGraphQLTreeExample.scala))
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
sbt "schema-examples/runMain comptime.AllowsGraphQLTreeExample"
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
```scala title="schema-examples/src/main/scala/comptime/AllowsGraphQLTreeExample.scala"
|
|
801
|
+
package comptime
|
|
802
|
+
|
|
803
|
+
import zio.blocks.schema._
|
|
804
|
+
import zio.blocks.schema.comptime.Allows
|
|
805
|
+
import Allows.{Primitive, Record, Sequence, `|`}
|
|
806
|
+
import Allows.{Optional => AOptional, Self => ASelf}
|
|
807
|
+
import util.ShowExpr.show
|
|
808
|
+
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// GraphQL / tree structure example using Self for recursive grammars
|
|
811
|
+
//
|
|
812
|
+
// Self refers back to the entire enclosing Allows[A, S] grammar, allowing
|
|
813
|
+
// the constraint to describe recursive data structures like trees, linked
|
|
814
|
+
// lists, and nested menus.
|
|
815
|
+
//
|
|
816
|
+
// Non-recursive types also satisfy Self-containing grammars — the Self
|
|
817
|
+
// position is never reached, so the constraint is vacuously satisfied.
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
// Recursive tree: children reference the same type
|
|
821
|
+
case class TreeNode(value: Int, children: List[TreeNode])
|
|
822
|
+
object TreeNode { implicit val schema: Schema[TreeNode] = Schema.derived }
|
|
823
|
+
|
|
824
|
+
// Recursive category hierarchy (common in e-commerce, CMS, etc.)
|
|
825
|
+
case class NavCategory(name: String, slug: String, subcategories: List[NavCategory])
|
|
826
|
+
object NavCategory { implicit val schema: Schema[NavCategory] = Schema.derived }
|
|
827
|
+
|
|
828
|
+
// Linked list via Optional[Self]
|
|
829
|
+
case class Chain(label: String, next: Option[Chain])
|
|
830
|
+
object Chain { implicit val schema: Schema[Chain] = Schema.derived }
|
|
831
|
+
|
|
832
|
+
// Non-recursive type — satisfies Self-containing grammars vacuously
|
|
833
|
+
case class FlatNode(id: Int, label: String)
|
|
834
|
+
object FlatNode { implicit val schema: Schema[FlatNode] = Schema.derived }
|
|
835
|
+
|
|
836
|
+
object GraphQL {
|
|
837
|
+
|
|
838
|
+
type TreeShape = Primitive | Sequence[ASelf] | AOptional[ASelf]
|
|
839
|
+
|
|
840
|
+
/** Generate a simplified GraphQL type definition for a recursive type. */
|
|
841
|
+
def graphqlType[A](implicit schema: Schema[A], ev: Allows[A, Record[TreeShape]]): String = {
|
|
842
|
+
val reflect = schema.reflect.asRecord.get
|
|
843
|
+
val fields = reflect.fields.map { f =>
|
|
844
|
+
s" ${f.name}: ${gqlType(resolve(f.value), schema.reflect.typeId.name)}"
|
|
845
|
+
}
|
|
846
|
+
s"type ${schema.reflect.typeId.name} {\n${fields.mkString("\n")}\n}"
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Unwrap Deferred to get the actual Reflect node. */
|
|
850
|
+
private def resolve(r: Reflect.Bound[_]): Reflect.Bound[_] = r match {
|
|
851
|
+
case d: Reflect.Deferred[_, _] => resolve(d.value.asInstanceOf[Reflect.Bound[_]])
|
|
852
|
+
case other => other
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private def gqlType(r: Reflect.Bound[_], selfName: String): String = r match {
|
|
856
|
+
case _: Reflect.Sequence[_, _, _] => s"[$selfName]"
|
|
857
|
+
case p: Reflect.Primitive[_, _] =>
|
|
858
|
+
p.primitiveType match {
|
|
859
|
+
case PrimitiveType.Int(_) => "Int"
|
|
860
|
+
case PrimitiveType.Long(_) => "Int"
|
|
861
|
+
case PrimitiveType.Float(_) => "Float"
|
|
862
|
+
case PrimitiveType.Double(_) => "Float"
|
|
863
|
+
case PrimitiveType.String(_) => "String"
|
|
864
|
+
case PrimitiveType.Boolean(_) => "Boolean"
|
|
865
|
+
case _ => "String"
|
|
866
|
+
}
|
|
867
|
+
case _ => selfName
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// Demonstration
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
object AllowsGraphQLTreeExample extends App {
|
|
876
|
+
|
|
877
|
+
// Recursive tree with Sequence[Self]
|
|
878
|
+
show(GraphQL.graphqlType[TreeNode])
|
|
879
|
+
|
|
880
|
+
// Recursive categories — same grammar, different domain
|
|
881
|
+
show(GraphQL.graphqlType[NavCategory])
|
|
882
|
+
|
|
883
|
+
// Linked list via Optional[Self]
|
|
884
|
+
show(GraphQL.graphqlType[Chain])
|
|
885
|
+
|
|
886
|
+
// Non-recursive type also satisfies the grammar (vacuously — Self is never reached)
|
|
887
|
+
show(GraphQL.graphqlType[FlatNode])
|
|
888
|
+
|
|
889
|
+
// Show that recursive data actually works at runtime
|
|
890
|
+
val tree = TreeNode(
|
|
891
|
+
1,
|
|
892
|
+
List(
|
|
893
|
+
TreeNode(2, List(TreeNode(4, Nil), TreeNode(5, Nil))),
|
|
894
|
+
TreeNode(3, Nil)
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
show(Schema[TreeNode].toDynamicValue(tree).toJson.toString)
|
|
898
|
+
|
|
899
|
+
val nav = NavCategory(
|
|
900
|
+
"Electronics",
|
|
901
|
+
"electronics",
|
|
902
|
+
List(
|
|
903
|
+
NavCategory("Phones", "phones", Nil),
|
|
904
|
+
NavCategory(
|
|
905
|
+
"Laptops",
|
|
906
|
+
"laptops",
|
|
907
|
+
List(
|
|
908
|
+
NavCategory("Gaming", "gaming", Nil)
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
show(Schema[NavCategory].toDynamicValue(nav).toJson.toString)
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
**Sealed trait auto-unwrap with nested hierarchies and case objects**
|
|
918
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/AllowsSealedTraitExample.scala))
|
|
919
|
+
|
|
920
|
+
```bash
|
|
921
|
+
sbt "schema-examples/runMain comptime.AllowsSealedTraitExample"
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
```scala title="schema-examples/src/main/scala/comptime/AllowsSealedTraitExample.scala"
|
|
925
|
+
package comptime
|
|
926
|
+
|
|
927
|
+
import zio.blocks.schema._
|
|
928
|
+
import zio.blocks.schema.comptime.Allows
|
|
929
|
+
import Allows.{Primitive, Record}
|
|
930
|
+
import util.ShowExpr.show
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
// Sealed trait auto-unwrap example
|
|
934
|
+
//
|
|
935
|
+
// Sealed traits and enums are automatically unwrapped by the Allows macro.
|
|
936
|
+
// Each case is checked individually against the grammar — no Variant node
|
|
937
|
+
// is needed.
|
|
938
|
+
//
|
|
939
|
+
// Auto-unwrap is recursive: if a case is itself a sealed trait, its cases
|
|
940
|
+
// are unwrapped too, to any depth.
|
|
941
|
+
//
|
|
942
|
+
// Zero-field records (case objects) are vacuously true for any Record[A]
|
|
943
|
+
// constraint.
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
// Simple sealed trait with case classes and a case object
|
|
947
|
+
sealed trait Shape
|
|
948
|
+
case class Circle(radius: Double) extends Shape
|
|
949
|
+
case class Rectangle(width: Double, height: Double) extends Shape
|
|
950
|
+
case object Point extends Shape
|
|
951
|
+
object Shape { implicit val schema: Schema[Shape] = Schema.derived }
|
|
952
|
+
|
|
953
|
+
// Nested sealed trait hierarchy — two levels deep
|
|
954
|
+
sealed trait Expr
|
|
955
|
+
sealed trait BinaryOp extends Expr
|
|
956
|
+
case class Add(left: Double, right: Double) extends BinaryOp
|
|
957
|
+
case class Multiply(left: Double, right: Double) extends BinaryOp
|
|
958
|
+
case class Literal(value: Double) extends Expr
|
|
959
|
+
case object Zero extends Expr
|
|
960
|
+
object Expr { implicit val schema: Schema[Expr] = Schema.derived }
|
|
961
|
+
|
|
962
|
+
// All-singleton enum (all case objects)
|
|
963
|
+
sealed trait Color
|
|
964
|
+
case object Red extends Color
|
|
965
|
+
case object Green extends Color
|
|
966
|
+
case object Blue extends Color
|
|
967
|
+
object Color { implicit val schema: Schema[Color] = Schema.derived }
|
|
968
|
+
|
|
969
|
+
object SealedTraitValidator {
|
|
970
|
+
|
|
971
|
+
/** Validate that a value's type has a flat record structure. */
|
|
972
|
+
def validate[A](value: A)(implicit schema: Schema[A], ev: Allows[A, Record[Primitive]]): String = {
|
|
973
|
+
val dv = schema.toDynamicValue(value)
|
|
974
|
+
dv match {
|
|
975
|
+
case DynamicValue.Variant(caseName, inner) =>
|
|
976
|
+
s"Valid variant case '$caseName': ${inner.toJson}"
|
|
977
|
+
case DynamicValue.Record(fields) =>
|
|
978
|
+
s"Valid record with ${fields.size} field(s): ${fields.map(_._1).mkString(", ")}"
|
|
979
|
+
case _ =>
|
|
980
|
+
s"Valid: ${dv.toJson}"
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
// Demonstration
|
|
987
|
+
// ---------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
object AllowsSealedTraitExample extends App {
|
|
990
|
+
|
|
991
|
+
// Simple sealed trait — all cases checked against Record[Primitive]
|
|
992
|
+
// Circle: Record(radius: Double) — satisfies Record[Primitive]
|
|
993
|
+
// Rectangle: Record(width: Double, height: Double) — satisfies Record[Primitive]
|
|
994
|
+
// Point: zero-field case object — vacuously true
|
|
995
|
+
show(SealedTraitValidator.validate[Shape](Circle(3.14)))
|
|
996
|
+
show(SealedTraitValidator.validate[Shape](Rectangle(4.0, 5.0)))
|
|
997
|
+
show(SealedTraitValidator.validate[Shape](Point))
|
|
998
|
+
|
|
999
|
+
// Nested sealed trait — auto-unwrap is recursive
|
|
1000
|
+
// BinaryOp is itself sealed with Add and Multiply
|
|
1001
|
+
// All leaf cases have only Double fields — satisfies Record[Primitive]
|
|
1002
|
+
show(SealedTraitValidator.validate[Expr](Add(1.0, 2.0)))
|
|
1003
|
+
show(SealedTraitValidator.validate[Expr](Multiply(3.0, 4.0)))
|
|
1004
|
+
show(SealedTraitValidator.validate[Expr](Literal(42.0)))
|
|
1005
|
+
show(SealedTraitValidator.validate[Expr](Zero))
|
|
1006
|
+
|
|
1007
|
+
// All-singleton enum — every case is a zero-field record (vacuously true)
|
|
1008
|
+
show(SealedTraitValidator.validate[Color](Red))
|
|
1009
|
+
show(SealedTraitValidator.validate[Color](Green))
|
|
1010
|
+
show(SealedTraitValidator.validate[Color](Blue))
|
|
1011
|
+
}
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
**RDBMS library with CREATE TABLE and INSERT using flat record constraints** (compile-only)
|
|
1015
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/RdbmsExample.scala))
|
|
1016
|
+
|
|
1017
|
+
Demonstrates how Allows constraints are verified at compile time — the code below shows valid examples that compile successfully, and includes comments showing which patterns would be rejected:
|
|
1018
|
+
|
|
1019
|
+
```scala title="schema-examples/src/main/scala/comptime/RdbmsExample.scala"
|
|
1020
|
+
package comptime
|
|
1021
|
+
|
|
1022
|
+
import zio.blocks.schema._
|
|
1023
|
+
import zio.blocks.schema.comptime.Allows
|
|
1024
|
+
import Allows.{Primitive, Record, `|`}
|
|
1025
|
+
import Allows.{Map => AMap, Optional => AOptional}
|
|
1026
|
+
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// Realistic RDBMS example using Allows[A, S] compile-time shape constraints
|
|
1029
|
+
//
|
|
1030
|
+
// Demonstrates how a library can require that user-supplied types have a
|
|
1031
|
+
// structure compatible with what a relational database can represent:
|
|
1032
|
+
// - Flat records of primitives, optional primitives, or primitive-valued maps
|
|
1033
|
+
// - Top-level variants (enum tables) whose cases are flat records
|
|
1034
|
+
//
|
|
1035
|
+
// Incompatible types (nested records, sequences of records, etc.) are
|
|
1036
|
+
// rejected at the call site with a precise compile-time error message.
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
// Domain types — vary from compatible to incompatible
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
// Compatible: flat record of primitives and optional primitives
|
|
1044
|
+
case class UserRow(
|
|
1045
|
+
id: java.util.UUID,
|
|
1046
|
+
name: String,
|
|
1047
|
+
email: Option[String],
|
|
1048
|
+
age: Int,
|
|
1049
|
+
active: Boolean
|
|
1050
|
+
)
|
|
1051
|
+
object UserRow {
|
|
1052
|
+
implicit val schema: Schema[UserRow] = Schema.derived
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Compatible: flat record with a string-keyed map column (stored as JSON/JSONB)
|
|
1056
|
+
case class ProductRow(
|
|
1057
|
+
id: java.util.UUID,
|
|
1058
|
+
name: String,
|
|
1059
|
+
price: BigDecimal,
|
|
1060
|
+
attributes: scala.collection.immutable.Map[String, String]
|
|
1061
|
+
)
|
|
1062
|
+
object ProductRow {
|
|
1063
|
+
implicit val schema: Schema[ProductRow] = Schema.derived
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Compatible: event table — a variant (sealed trait) of flat record cases
|
|
1067
|
+
sealed trait DomainEvent
|
|
1068
|
+
case class UserCreated(id: java.util.UUID, name: String, email: String) extends DomainEvent
|
|
1069
|
+
case class UserDeleted(id: java.util.UUID) extends DomainEvent
|
|
1070
|
+
case class OrderPlaced(id: java.util.UUID, userId: java.util.UUID, total: BigDecimal) extends DomainEvent
|
|
1071
|
+
object DomainEvent {
|
|
1072
|
+
implicit val schema: Schema[DomainEvent] = Schema.derived
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Incompatible: contains a nested record (Address is not a primitive)
|
|
1076
|
+
case class Address(street: String, city: String, zip: String)
|
|
1077
|
+
object Address { implicit val schema: Schema[Address] = Schema.derived }
|
|
1078
|
+
|
|
1079
|
+
case class UserWithAddress(
|
|
1080
|
+
id: java.util.UUID,
|
|
1081
|
+
name: String,
|
|
1082
|
+
address: Address // ← incompatible: nested record
|
|
1083
|
+
)
|
|
1084
|
+
object UserWithAddress {
|
|
1085
|
+
implicit val schema: Schema[UserWithAddress] = Schema.derived
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ---------------------------------------------------------------------------
|
|
1089
|
+
// Simulated RDBMS library API
|
|
1090
|
+
//
|
|
1091
|
+
// The Allows constraint is checked at the CALL SITE — the library author
|
|
1092
|
+
// writes these signatures once. Users get a compile-time error if their type
|
|
1093
|
+
// doesn't match, with a message pointing to the exact violating field.
|
|
1094
|
+
// ---------------------------------------------------------------------------
|
|
1095
|
+
|
|
1096
|
+
object Rdbms {
|
|
1097
|
+
|
|
1098
|
+
// A flat record row: primitives, optional primitives, or string-keyed maps
|
|
1099
|
+
type FlatRow = Primitive | AOptional[Primitive] | AMap[Primitive, Primitive]
|
|
1100
|
+
|
|
1101
|
+
/** Generate a CREATE TABLE DDL statement for a flat record type. */
|
|
1102
|
+
def createTable[A](implicit
|
|
1103
|
+
schema: Schema[A],
|
|
1104
|
+
ev: Allows[A, Record[FlatRow]]
|
|
1105
|
+
): String = {
|
|
1106
|
+
val fields = schema.reflect.asRecord.get.fields
|
|
1107
|
+
val cols = fields.map { f =>
|
|
1108
|
+
val tpe = sqlType(f.value)
|
|
1109
|
+
s" ${f.name} $tpe"
|
|
1110
|
+
}
|
|
1111
|
+
s"CREATE TABLE ${tableName(schema)} (\n${cols.mkString(",\n")}\n)"
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Generate an INSERT statement for a single flat record row. */
|
|
1115
|
+
def insert[A](value: A)(implicit
|
|
1116
|
+
schema: Schema[A],
|
|
1117
|
+
ev: Allows[A, Record[FlatRow]]
|
|
1118
|
+
): String = {
|
|
1119
|
+
val dv = schema.toDynamicValue(value)
|
|
1120
|
+
dv match {
|
|
1121
|
+
case DynamicValue.Record(fields) =>
|
|
1122
|
+
val cols = fields.map(_._1).mkString(", ")
|
|
1123
|
+
val vals = fields.map { case (_, v) => sqlLiteralDv(v) }.mkString(", ")
|
|
1124
|
+
s"INSERT INTO ${tableName(schema)} ($cols) VALUES ($vals)"
|
|
1125
|
+
case _ => s"INSERT INTO ${tableName(schema)} VALUES (?)"
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Insert an event into an event-sourcing table.
|
|
1131
|
+
*
|
|
1132
|
+
* The type must be a sealed trait / enum whose cases are flat records. No
|
|
1133
|
+
* explicit Variant node is needed — sealed traits are auto-unwrapped by the
|
|
1134
|
+
* macro.
|
|
1135
|
+
*/
|
|
1136
|
+
def insertEvent[A](event: A)(implicit
|
|
1137
|
+
schema: Schema[A],
|
|
1138
|
+
ev: Allows[A, Record[FlatRow]]
|
|
1139
|
+
): String = {
|
|
1140
|
+
val typeName = schema.toDynamicValue(event) match {
|
|
1141
|
+
case DynamicValue.Variant(caseName, _) => caseName
|
|
1142
|
+
case _ => schema.reflect.typeId.name
|
|
1143
|
+
}
|
|
1144
|
+
val payload = schema.toDynamicValue(event).toJson.toString
|
|
1145
|
+
s"INSERT INTO events (type, payload) VALUES ('$typeName', '$payload')"
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
// Helpers
|
|
1150
|
+
// ---------------------------------------------------------------------------
|
|
1151
|
+
|
|
1152
|
+
private def tableName[A](schema: Schema[A]): String =
|
|
1153
|
+
schema.reflect.modifiers.collectFirst {
|
|
1154
|
+
case Modifier.config(k, v) if k == "sql.table_name" => v
|
|
1155
|
+
}.getOrElse(schema.reflect.typeId.name.toLowerCase + "s")
|
|
1156
|
+
|
|
1157
|
+
private def sqlLiteralDv(dv: DynamicValue): String = dv match {
|
|
1158
|
+
case DynamicValue.Primitive(PrimitiveValue.String(s)) => s"'${s.replace("'", "''")}'"
|
|
1159
|
+
case DynamicValue.Primitive(PrimitiveValue.Boolean(b)) => if (b) "TRUE" else "FALSE"
|
|
1160
|
+
case DynamicValue.Primitive(v) => v.toString
|
|
1161
|
+
case DynamicValue.Null => "NULL"
|
|
1162
|
+
case other => s"'${other.toString.replace("'", "''")}'"
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private def sqlType(reflect: Reflect.Bound[_]): String = reflect match {
|
|
1166
|
+
case p: Reflect.Primitive[_, _] =>
|
|
1167
|
+
p.primitiveType match {
|
|
1168
|
+
case PrimitiveType.Int(_) => "INTEGER"
|
|
1169
|
+
case PrimitiveType.Long(_) => "BIGINT"
|
|
1170
|
+
case PrimitiveType.String(_) => "TEXT"
|
|
1171
|
+
case PrimitiveType.Boolean(_) => "BOOLEAN"
|
|
1172
|
+
case PrimitiveType.Double(_) => "DOUBLE PRECISION"
|
|
1173
|
+
case PrimitiveType.Float(_) => "REAL"
|
|
1174
|
+
case PrimitiveType.BigDecimal(_) => "NUMERIC"
|
|
1175
|
+
case PrimitiveType.UUID(_) => "UUID"
|
|
1176
|
+
case PrimitiveType.Instant(_) => "TIMESTAMPTZ"
|
|
1177
|
+
case PrimitiveType.LocalDate(_) => "DATE"
|
|
1178
|
+
case PrimitiveType.LocalDateTime(_) => "TIMESTAMP"
|
|
1179
|
+
case _ => "TEXT"
|
|
1180
|
+
}
|
|
1181
|
+
case _: Reflect.Map[_, _, _, _] => "JSONB"
|
|
1182
|
+
case _: Reflect.Sequence[_, _, _] => "TEXT[]"
|
|
1183
|
+
case _ => "TEXT"
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ---------------------------------------------------------------------------
|
|
1188
|
+
// Demonstration — these all compile
|
|
1189
|
+
// ---------------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
object RdbmsDemo {
|
|
1192
|
+
|
|
1193
|
+
// Flat rows compile fine
|
|
1194
|
+
val createUser: String = Rdbms.createTable[UserRow]
|
|
1195
|
+
val createProduct: String = Rdbms.createTable[ProductRow]
|
|
1196
|
+
val insertUser: String = Rdbms.insert(UserRow(new java.util.UUID(0, 0), "Alice", Some("a@b.com"), 30, true))
|
|
1197
|
+
val insertEvent: String = Rdbms.insertEvent[DomainEvent](UserCreated(new java.util.UUID(0, 0), "Alice", "a@b.com"))
|
|
1198
|
+
|
|
1199
|
+
// The following would NOT compile — uncomment to see the error:
|
|
1200
|
+
//
|
|
1201
|
+
// val bad = Rdbms.createTable[UserWithAddress]
|
|
1202
|
+
// [error] Schema shape violation at UserWithAddress.address: found Record(Address), required
|
|
1203
|
+
// Primitive | Optional[Primitive] | Map[Primitive, Primitive]
|
|
1204
|
+
}
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
**JSON document store with specific primitives and recursive Self grammar** (compile-only)
|
|
1208
|
+
([source](https://github.com/zio/zio-blocks/blob/main/schema-examples/src/main/scala/comptime/DocumentStoreExample.scala))
|
|
1209
|
+
|
|
1210
|
+
Demonstrates how Allows enforces recursive schema constraints at compile time:
|
|
1211
|
+
|
|
1212
|
+
```scala title="schema-examples/src/main/scala/comptime/DocumentStoreExample.scala"
|
|
1213
|
+
package comptime
|
|
1214
|
+
|
|
1215
|
+
import zio.blocks.schema._
|
|
1216
|
+
import zio.blocks.schema.comptime.Allows
|
|
1217
|
+
import Allows.{Record, Sequence, `|`}
|
|
1218
|
+
import Allows.{Optional => AOptional, Self => ASelf}
|
|
1219
|
+
|
|
1220
|
+
// ---------------------------------------------------------------------------
|
|
1221
|
+
// Realistic JSON document-store example using Allows[A, S]
|
|
1222
|
+
//
|
|
1223
|
+
// JSON has a limited primitive value set:
|
|
1224
|
+
// - null → Unit / Option
|
|
1225
|
+
// - boolean → Boolean
|
|
1226
|
+
// - number → Int, Long, Float, Double, BigDecimal, BigInt
|
|
1227
|
+
// - string → String
|
|
1228
|
+
//
|
|
1229
|
+
// Notably absent from JSON: Char, Byte, Short, UUID, Currency,
|
|
1230
|
+
// and ALL java.time.* types. A JSON library should use SPECIFIC primitive
|
|
1231
|
+
// nodes to reject non-JSON scalars at the call site.
|
|
1232
|
+
//
|
|
1233
|
+
// The grammar is simply:
|
|
1234
|
+
// type Json = Record[JsonPrimitive | Self] | Sequence[JsonPrimitive | Self]
|
|
1235
|
+
//
|
|
1236
|
+
// Self handles all nesting: a field may be a primitive or another Json value.
|
|
1237
|
+
// Sequences of primitives (List[String]) and sequences of records
|
|
1238
|
+
// (List[Author]) both satisfy Sequence[JsonPrimitive | Self].
|
|
1239
|
+
// Optional fields (Option[X]) satisfy Record[...] because Option is recognised
|
|
1240
|
+
// by the Optional grammar node which falls through to the field constraint.
|
|
1241
|
+
// ---------------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
// ---------------------------------------------------------------------------
|
|
1244
|
+
// Domain types
|
|
1245
|
+
// ---------------------------------------------------------------------------
|
|
1246
|
+
|
|
1247
|
+
// Compatible: flat document
|
|
1248
|
+
case class Author(name: String, email: String)
|
|
1249
|
+
object Author { implicit val schema: Schema[Author] = Schema.derived }
|
|
1250
|
+
|
|
1251
|
+
// Compatible: document with nested documents and sequences
|
|
1252
|
+
case class BookChapter(title: String, wordCount: Int)
|
|
1253
|
+
object BookChapter { implicit val schema: Schema[BookChapter] = Schema.derived }
|
|
1254
|
+
|
|
1255
|
+
case class Book(
|
|
1256
|
+
title: String,
|
|
1257
|
+
author: Author, // nested record — satisfied via Self
|
|
1258
|
+
chapters: List[BookChapter], // sequence of records — satisfied via Sequence[Self]
|
|
1259
|
+
tags: List[String], // sequence of primitives — satisfied via Sequence[JsonPrimitive]
|
|
1260
|
+
rating: Option[Double] // optional primitive — satisfied via Optional[JsonPrimitive | Self]
|
|
1261
|
+
)
|
|
1262
|
+
object Book { implicit val schema: Schema[Book] = Schema.derived }
|
|
1263
|
+
|
|
1264
|
+
// Compatible: recursive document
|
|
1265
|
+
case class Category(name: String, subcategories: List[Category])
|
|
1266
|
+
object Category { implicit val schema: Schema[Category] = Schema.derived }
|
|
1267
|
+
|
|
1268
|
+
// Compatible: variant of search results (for indexing)
|
|
1269
|
+
sealed trait SearchResult
|
|
1270
|
+
case class BookResult(title: String, score: Double) extends SearchResult
|
|
1271
|
+
case class AuthorResult(name: String, bookCount: Int) extends SearchResult
|
|
1272
|
+
object SearchResult { implicit val schema: Schema[SearchResult] = Schema.derived }
|
|
1273
|
+
|
|
1274
|
+
// INCOMPATIBLE: UUID is not a JSON-native scalar
|
|
1275
|
+
case class WithUUID(id: java.util.UUID, name: String)
|
|
1276
|
+
object WithUUID { implicit val schema: Schema[WithUUID] = Schema.derived }
|
|
1277
|
+
|
|
1278
|
+
// INCOMPATIBLE: Instant is not a JSON-native scalar
|
|
1279
|
+
case class WithTimestamp(name: String, createdAt: java.time.Instant)
|
|
1280
|
+
object WithTimestamp { implicit val schema: Schema[WithTimestamp] = Schema.derived }
|
|
1281
|
+
|
|
1282
|
+
// ---------------------------------------------------------------------------
|
|
1283
|
+
// Document store library API
|
|
1284
|
+
// ---------------------------------------------------------------------------
|
|
1285
|
+
|
|
1286
|
+
object DocumentStore {
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* JSON-representable scalar types.
|
|
1290
|
+
*
|
|
1291
|
+
* Excludes Char, Byte, Short, UUID, Currency, and all java.time.* types —
|
|
1292
|
+
* none of these have a native JSON encoding. Authors who need java.time
|
|
1293
|
+
* values in JSON should store them as Primitive.String (ISO-8601 etc.).
|
|
1294
|
+
*/
|
|
1295
|
+
type JsonPrimitive =
|
|
1296
|
+
Allows.Primitive.Boolean | Allows.Primitive.Int | Allows.Primitive.Long | Allows.Primitive.Float |
|
|
1297
|
+
Allows.Primitive.Double | Allows.Primitive.String | Allows.Primitive.BigDecimal | Allows.Primitive.BigInt |
|
|
1298
|
+
Allows.Primitive.Unit
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* A JSON value is either a JSON object (Record) or a JSON array (Sequence).
|
|
1302
|
+
* Self recurses back to this same grammar, so nesting works at any depth.
|
|
1303
|
+
* Optional covers nullable fields (JSON null / absent key).
|
|
1304
|
+
*/
|
|
1305
|
+
type Json = Record[JsonPrimitive | AOptional[JsonPrimitive | ASelf] | ASelf] | Sequence[JsonPrimitive | ASelf]
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Encode a value to its JSON string representation.
|
|
1309
|
+
*
|
|
1310
|
+
* Accepts both JSON objects (records) and JSON arrays (sequences) at the top
|
|
1311
|
+
* level. Fields may be primitives, nested documents, sequences, or optionals
|
|
1312
|
+
* — all handled via Self and the JsonPrimitive constraint. Types containing
|
|
1313
|
+
* UUID, Instant, Char etc. are rejected at compile time.
|
|
1314
|
+
*/
|
|
1315
|
+
def toJson[A: Schema](doc: A)(implicit ev: Allows[A, Json]): String =
|
|
1316
|
+
Schema[A].toDynamicValue(doc).toJson.toString
|
|
1317
|
+
|
|
1318
|
+
/** Serialize to DynamicValue for further processing. */
|
|
1319
|
+
def serialize[A: Schema](doc: A)(implicit ev: Allows[A, Json]): DynamicValue =
|
|
1320
|
+
Schema[A].toDynamicValue(doc)
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Index a search result. The type must be a sealed trait of flat JSON
|
|
1324
|
+
* records. No explicit Variant node needed — sealed traits are
|
|
1325
|
+
* auto-unwrapped.
|
|
1326
|
+
*/
|
|
1327
|
+
def index[A: Schema](result: A)(implicit
|
|
1328
|
+
ev: Allows[A, Record[JsonPrimitive | AOptional[JsonPrimitive | ASelf] | Sequence[JsonPrimitive | ASelf]]]
|
|
1329
|
+
): String = {
|
|
1330
|
+
val typeName = Schema[A].toDynamicValue(result) match {
|
|
1331
|
+
case DynamicValue.Variant(name, _) => name
|
|
1332
|
+
case _ => Schema[A].reflect.typeId.name
|
|
1333
|
+
}
|
|
1334
|
+
val payload = Schema[A].toDynamicValue(result).toJson.toString
|
|
1335
|
+
s"""{"_type":"$typeName","_doc":$payload}"""
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ---------------------------------------------------------------------------
|
|
1340
|
+
// Demonstration
|
|
1341
|
+
// ---------------------------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
object DocumentStoreDemo {
|
|
1344
|
+
|
|
1345
|
+
// JSON objects compile fine
|
|
1346
|
+
val bookJson: String = DocumentStore.toJson(
|
|
1347
|
+
Book(
|
|
1348
|
+
"ZIO Blocks",
|
|
1349
|
+
Author("John", "john@example.com"),
|
|
1350
|
+
List(BookChapter("Intro", 1000)),
|
|
1351
|
+
List("scala", "zio"),
|
|
1352
|
+
Some(4.9)
|
|
1353
|
+
)
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
val categoryJson: String = DocumentStore.toJson(
|
|
1357
|
+
Category("Programming", List(Category("Scala", Nil)))
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
// JSON arrays also satisfy Json (top-level Sequence)
|
|
1361
|
+
val tagListJson: String = DocumentStore.toJson(List("scala", "zio", "functional"))
|
|
1362
|
+
val authorListJson: String = DocumentStore.toJson(List(Author("Alice", "a@b.com"), Author("Bob", "b@b.com")))
|
|
1363
|
+
|
|
1364
|
+
val indexed: String = DocumentStore.index[SearchResult](BookResult("ZIO Blocks", 0.99))
|
|
1365
|
+
|
|
1366
|
+
// The following would NOT compile — uncomment to see the errors:
|
|
1367
|
+
//
|
|
1368
|
+
// DocumentStore.toJson(WithUUID(new java.util.UUID(0, 0), "Alice"))
|
|
1369
|
+
// [error] Schema shape violation at WithUUID.id: found Primitive(java.util.UUID),
|
|
1370
|
+
// required JsonPrimitive (Boolean | Int | Long | Float | Double | String | ...)
|
|
1371
|
+
// UUID is not a JSON-native type — encode it as Primitive.String.
|
|
1372
|
+
//
|
|
1373
|
+
// DocumentStore.toJson(WithTimestamp("Alice", java.time.Instant.EPOCH))
|
|
1374
|
+
// [error] Schema shape violation at WithTimestamp.createdAt: found Primitive(java.time.Instant)
|
|
1375
|
+
// Instant is not a JSON-native type — encode it as Primitive.String (ISO-8601).
|
|
1376
|
+
}
|
|
1377
|
+
```
|