@zio.dev/zio-blocks 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ ---
2
+ id: query-dsl-reified-optics
3
+ title: "Query DSL with Reified Optics — Part 1: Expressions"
4
+ ---
5
+
6
+ In this guide, we will build a type-safe query DSL for filtering, comparing, and computing over domain data using ZIO Blocks' reified optics and schema expressions. By the end, you will have a composable query language that works on any schema-equipped data type, supporting equality checks, comparisons, boolean logic, arithmetic, and string operations.
7
+
8
+ We'll take an incremental approach: starting with simple field-level equality checks, then adding comparison operators, boolean combinators, arithmetic expressions, and string operations until we have a complete, expressive query DSL.
9
+
10
+ **What we'll cover:**
11
+
12
+ - Defining domain types with schemas and optics
13
+ - Building equality and comparison queries using `===`, `>`, `<`, `>=`, `<=`
14
+ - Combining queries with `&&` and `||`
15
+ - Using arithmetic operators (`+`, `-`, `*`) in expressions
16
+ - Working with string operations (`matches`, `concat`, `length`)
17
+ - Querying through nested structures and collections
18
+ - Evaluating queries against data
19
+
20
+ ## The Problem
21
+
22
+ When you need to query or filter collections of structured data in Scala, you typically write ad-hoc predicate functions:
23
+
24
+ ```scala
25
+ case class Product(name: String, price: Double, category: String, inStock: Boolean)
26
+
27
+ val products: List[Product] = loadProducts()
28
+
29
+ // Filtering with ad-hoc predicates
30
+ val results = products.filter(p =>
31
+ p.category == "Electronics" && p.price < 500.0 && p.inStock
32
+ )
33
+ ```
34
+
35
+ This works, but the predicate `p => p.category == "Electronics" && p.price < 500.0 && p.inStock` is an opaque function. You cannot inspect it, serialize it, translate it to SQL, send it to a remote service, or optimize it. It is a black box.
36
+
37
+ If you need to build a query builder for a database, a filter language for an API, or a rule engine, you need queries as **data** -- inspectable, composable, serializable expression trees. Building these by hand means defining an AST, writing an evaluator, and maintaining type safety across all operators -- a significant amount of boilerplate for every domain type.
38
+
39
+ In this guide, we'll solve this by using ZIO Blocks' `SchemaExpr` and reified optics, which give us composable, type-safe query expressions for free, derived directly from your data model's schema.
40
+
41
+ ## Prerequisites
42
+
43
+ Add the ZIO Blocks Schema dependency:
44
+
45
+ ```scala
46
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.24"
47
+ ```
48
+
49
+ ```scala
50
+ import zio.blocks.schema._
51
+ ```
52
+
53
+ This guide assumes familiarity with ZIO Blocks schemas and basic optics. See the [Schema](../reference/schema.md) and [Optics](../reference/optics.md) reference pages for background.
54
+
55
+ ## Defining Your Domain
56
+
57
+ We'll build a product catalog query DSL. First, define the domain types with schemas and optics:
58
+
59
+ ```scala
60
+ case class Product(
61
+ name: String,
62
+ price: Double,
63
+ category: String,
64
+ inStock: Boolean,
65
+ rating: Int
66
+ )
67
+
68
+ object Product extends CompanionOptics[Product] {
69
+ implicit val schema: Schema[Product] = Schema.derived
70
+
71
+ val name: Lens[Product, String] = optic(_.name)
72
+ val price: Lens[Product, Double] = optic(_.price)
73
+ val category: Lens[Product, String] = optic(_.category)
74
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
75
+ val rating: Lens[Product, Int] = optic(_.rating)
76
+ }
77
+ ```
78
+
79
+ Three things are required for each queryable type:
80
+
81
+ 1. **`Schema.derived`** -- captures the type's structure at runtime
82
+ 2. **`CompanionOptics[T]`** -- provides the `optic` macro for deriving lenses
83
+ 3. **Named lenses** for each field you want to query on
84
+
85
+ Each lens is a *reified* field accessor: unlike `_.price`, the lens `Product.price` is a first-class value that carries the field name, the source schema, and the focus schema. This metadata is what makes the query DSL possible.
86
+
87
+ ## Equality and Comparison Queries
88
+
89
+ Every optic in ZIO Blocks has built-in operators that create `SchemaExpr` values -- expression trees representing queries:
90
+
91
+ ```scala
92
+ // Equality check
93
+ val isElectronics: SchemaExpr[Product, Boolean] =
94
+ Product.category === "Electronics"
95
+
96
+ // Greater-than comparison
97
+ val expensiveItems: SchemaExpr[Product, Boolean] =
98
+ Product.price > 100.0
99
+
100
+ // Less-than-or-equal
101
+ val budgetFriendly: SchemaExpr[Product, Boolean] =
102
+ Product.price <= 50.0
103
+
104
+ // Comparison against a literal
105
+ val highRated: SchemaExpr[Product, Boolean] =
106
+ Product.rating >= 4
107
+ ```
108
+
109
+ The full set of comparison operators:
110
+
111
+ | Operator | Meaning |
112
+ |----------|------------------------|
113
+ | `===` | Equal to |
114
+ | `!=` | Not equal to |
115
+ | `>` | Greater than |
116
+ | `>=` | Greater than or equal |
117
+ | `<` | Less than |
118
+ | `<=` | Less than or equal |
119
+
120
+ Each operator works in two forms:
121
+ - **Optic vs. literal**: `Product.price > 100.0` -- compare a field to a value
122
+ - **Optic vs. optic**: `Product.rating === Product.rating` -- compare two fields
123
+
124
+ ## Evaluating Queries
125
+
126
+ A `SchemaExpr[A, Boolean]` is a predicate over `A`. Evaluate it with `.eval`:
127
+
128
+ ```scala
129
+ val laptop = Product("Laptop", 999.99, "Electronics", true, 5)
130
+ val pen = Product("Pen", 2.50, "Office", true, 3)
131
+ ```
132
+
133
+ ```scala
134
+ isElectronics.eval(laptop)
135
+ // res0: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
136
+ isElectronics.eval(pen)
137
+ // res1: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
138
+
139
+ expensiveItems.eval(laptop)
140
+ // res2: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
141
+ expensiveItems.eval(pen)
142
+ // res3: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
143
+ ```
144
+
145
+ The result type is `Either[OpticCheck, Seq[Boolean]]`:
146
+ - `Right(Seq(true))` or `Right(Seq(false))` for successful evaluation
147
+ - `Left(opticCheck)` if the optic path fails (e.g., a prism encounters the wrong variant case)
148
+
149
+ For lenses, evaluation always succeeds because lenses always resolve.
150
+
151
+ ## Combining Queries with Boolean Logic
152
+
153
+ Combine queries with `&&` (and), `||` (or), and `!` (not):
154
+
155
+ ```scala
156
+ // AND: electronics under $500
157
+ val affordableElectronics: SchemaExpr[Product, Boolean] =
158
+ (Product.category === "Electronics") && (Product.price < 500.0)
159
+
160
+ // OR: either cheap or highly rated
161
+ val goodDeal: SchemaExpr[Product, Boolean] =
162
+ (Product.price < 10.0) || (Product.rating >= 5)
163
+
164
+ // NOT: items that are out of stock
165
+ val outOfStock: SchemaExpr[Product, Boolean] =
166
+ !Product.inStock
167
+ ```
168
+
169
+ ```scala
170
+ affordableElectronics.eval(laptop)
171
+ // res4: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
172
+ goodDeal.eval(laptop)
173
+ // res5: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
174
+ goodDeal.eval(pen)
175
+ // res6: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
176
+ outOfStock.eval(laptop)
177
+ // res7: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
178
+ ```
179
+
180
+ Boolean combinators also work on `SchemaExpr` values, not just optics. This means you can build complex compound queries:
181
+
182
+ ```scala
183
+ val complexQuery: SchemaExpr[Product, Boolean] =
184
+ ((Product.category === "Electronics") && (Product.price < 500.0)) ||
185
+ ((Product.category === "Office") && (Product.rating >= 4))
186
+ ```
187
+
188
+ ```scala
189
+ complexQuery.eval(laptop)
190
+ // res8: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
191
+ complexQuery.eval(pen)
192
+ // res9: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
193
+ ```
194
+
195
+ ## Arithmetic Expressions
196
+
197
+ Optics on numeric fields support `+`, `-`, and `*`:
198
+
199
+ ```scala
200
+ val discountedPrice: SchemaExpr[Product, Double] =
201
+ Product.price * 0.9
202
+
203
+ val priceWithTax: SchemaExpr[Product, Double] =
204
+ Product.price * 1.08
205
+ ```
206
+
207
+ ```scala
208
+ discountedPrice.eval(laptop)
209
+ // res10: Either[OpticCheck, Seq[Double]] = Right(IndexedSeq(899.991))
210
+ priceWithTax.eval(pen)
211
+ // res11: Either[OpticCheck, Seq[Double]] = Right(IndexedSeq(2.7))
212
+ ```
213
+
214
+ Arithmetic operators are available for all numeric types: `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, `BigInt`, and `BigDecimal`.
215
+
216
+ ## String Operations
217
+
218
+ Optics on `String` fields provide string-specific operations:
219
+
220
+ ```scala
221
+ // Regex matching
222
+ val startsWithL: SchemaExpr[Product, Boolean] =
223
+ Product.name.matches("L.*")
224
+
225
+ // String concatenation
226
+ val labeledName: SchemaExpr[Product, String] =
227
+ Product.name.concat(" [SALE]")
228
+
229
+ // String length
230
+ val nameLength: SchemaExpr[Product, Int] =
231
+ Product.name.length
232
+ ```
233
+
234
+ ```scala
235
+ startsWithL.eval(laptop)
236
+ // res12: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
237
+ startsWithL.eval(pen)
238
+ // res13: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(false))
239
+
240
+ labeledName.eval(laptop)
241
+ // res14: Either[OpticCheck, Seq[String]] = Right(IndexedSeq("Laptop [SALE]"))
242
+
243
+ nameLength.eval(laptop)
244
+ // res15: Either[OpticCheck, Seq[Int]] = Right(IndexedSeq(6))
245
+ nameLength.eval(pen)
246
+ // res16: Either[OpticCheck, Seq[Int]] = Right(IndexedSeq(3))
247
+ ```
248
+
249
+ | String Operation | Signature | Description |
250
+ |------------------|------------------------------------|---------------------------|
251
+ | `matches` | `(regex: String) => SchemaExpr[S, Boolean]` | Regex match |
252
+ | `concat` | `(suffix: String) => SchemaExpr[S, String]` | Append a string |
253
+ | `length` | `SchemaExpr[S, Int]` | String length |
254
+
255
+ ## Dynamic Evaluation
256
+
257
+ Every `SchemaExpr` can also evaluate to `DynamicValue`, which is useful when you need format-agnostic results (e.g., serializing query results to JSON):
258
+
259
+ ```scala
260
+ val priceExpr: SchemaExpr[Product, Double] = Product.price * 0.9
261
+ ```
262
+
263
+ ```scala
264
+ priceExpr.evalDynamic(laptop)
265
+ // res17: Either[OpticCheck, Seq[DynamicValue]] = Right(
266
+ // IndexedSeq(Primitive(Double(899.991)))
267
+ // )
268
+ ```
269
+
270
+ The `evalDynamic` method converts results to `DynamicValue` representations, enabling integration with serialization formats without knowing the concrete type.
271
+
272
+ ## Querying Nested Structures
273
+
274
+ The real power of reified optics emerges with nested data. Define a richer domain:
275
+
276
+ ```scala
277
+ import zio.blocks.schema._
278
+
279
+ case class Address(city: String, country: String)
280
+ object Address {
281
+ implicit val schema: Schema[Address] = Schema.derived
282
+ }
283
+
284
+ case class Seller(name: String, address: Address, rating: Double)
285
+ object Seller extends CompanionOptics[Seller] {
286
+ implicit val schema: Schema[Seller] = Schema.derived
287
+
288
+ val name: Lens[Seller, String] = optic(_.name)
289
+ val rating: Lens[Seller, Double] = optic(_.rating)
290
+ // Compose through nested structure directly
291
+ val city: Lens[Seller, String] = optic(_.address.city)
292
+ val country: Lens[Seller, String] = optic(_.address.country)
293
+ }
294
+ ```
295
+
296
+ The `optic(_.address.city)` macro composes a lens from `Seller` to `Address` with a lens from `Address` to `String`, producing a single `Lens[Seller, String]`. Now we can query nested fields as if they were top-level:
297
+
298
+ ```scala
299
+ val localSeller: SchemaExpr[Seller, Boolean] =
300
+ (Seller.city === "Berlin") && (Seller.rating >= 4.0)
301
+
302
+ val seller = Seller("TechShop", Address("Berlin", "Germany"), 4.5)
303
+ ```
304
+
305
+ ```scala
306
+ localSeller.eval(seller)
307
+ // res19: Either[OpticCheck, Seq[Boolean]] = Right(IndexedSeq(true))
308
+ ```
309
+
310
+ ## Querying Through Collections
311
+
312
+ For collection fields, use traversals to query across all elements:
313
+
314
+ ```scala
315
+ import zio.blocks.schema._
316
+
317
+ case class LineItem(sku: String, price: Double, quantity: Int)
318
+ object LineItem {
319
+ implicit val schema: Schema[LineItem] = Schema.derived
320
+ }
321
+
322
+ case class Order(id: String, items: List[LineItem])
323
+ object Order extends CompanionOptics[Order] {
324
+ implicit val schema: Schema[Order] = Schema.derived
325
+
326
+ val id: Lens[Order, String] = optic(_.id)
327
+ val allPrices: Traversal[Order, Double] = optic(_.items.each.price)
328
+ val allSkus: Traversal[Order, String] = optic(_.items.each.sku)
329
+ val allQuantities: Traversal[Order, Int] = optic(_.items.each.quantity)
330
+ }
331
+ ```
332
+
333
+ Traversals produce `SchemaExpr` values that evaluate to **multiple results**:
334
+
335
+ ```scala
336
+ val order = Order("ORD-1", List(
337
+ LineItem("SKU-A", 29.99, 2),
338
+ LineItem("SKU-B", 149.99, 1),
339
+ LineItem("SKU-C", 9.99, 5)
340
+ ))
341
+
342
+ // Each element is evaluated independently
343
+ val hasExpensiveItem: SchemaExpr[Order, Boolean] =
344
+ Order.allPrices > 100.0
345
+ ```
346
+
347
+ ```scala
348
+ hasExpensiveItem.eval(order)
349
+ // res21: Either[OpticCheck, Seq[Boolean]] = Right(List(false, true, false))
350
+ ```
351
+
352
+ :::tip
353
+ When a traversal-based expression produces multiple results, each element in the sequence corresponds to one focused value from the traversal. This makes it straightforward to check whether *any* or *all* elements satisfy a condition by examining the result sequence.
354
+ :::
355
+
356
+ ## Filtering a Collection
357
+
358
+ With `SchemaExpr` as a reified predicate, you can build a generic filter function that works with any schema-equipped type:
359
+
360
+ ```scala
361
+ import zio.blocks.schema._
362
+
363
+ case class Product(
364
+ name: String,
365
+ price: Double,
366
+ category: String,
367
+ inStock: Boolean,
368
+ rating: Int
369
+ )
370
+
371
+ object Product extends CompanionOptics[Product] {
372
+ implicit val schema: Schema[Product] = Schema.derived
373
+
374
+ val name: Lens[Product, String] = optic(_.name)
375
+ val price: Lens[Product, Double] = optic(_.price)
376
+ val category: Lens[Product, String] = optic(_.category)
377
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
378
+ val rating: Lens[Product, Int] = optic(_.rating)
379
+ }
380
+
381
+ def filter[A](items: List[A], predicate: SchemaExpr[A, Boolean]): List[A] =
382
+ items.filter(item =>
383
+ predicate.eval(item) match {
384
+ case Right(results) => results.forall(_ == true)
385
+ case Left(_) => false
386
+ }
387
+ )
388
+
389
+ val catalog = List(
390
+ Product("Laptop", 999.99, "Electronics", true, 5),
391
+ Product("Mouse", 29.99, "Electronics", true, 4),
392
+ Product("Pen", 2.50, "Office", true, 3),
393
+ Product("Monitor", 349.99, "Electronics", false, 5),
394
+ Product("Notebook", 5.99, "Office", true, 4)
395
+ )
396
+
397
+ val query = (Product.category === "Electronics") && (Product.inStock === true) && (Product.price < 500.0)
398
+ ```
399
+
400
+ ```scala
401
+ filter(catalog, query).map(_.name)
402
+ // res23: List[String] = List("Mouse")
403
+ ```
404
+
405
+ The `filter` function knows nothing about `Product` -- it works with any `SchemaExpr[A, Boolean]`. The query is data, not a lambda, so it could be serialized, logged, or translated to a database query.
406
+
407
+ ## Putting It Together
408
+
409
+ Here is a complete, self-contained example combining all the techniques from this guide:
410
+
411
+ ```scala
412
+ import zio.blocks.schema._
413
+
414
+ // --- Domain ---
415
+
416
+ case class Address(city: String, country: String)
417
+ object Address {
418
+ implicit val schema: Schema[Address] = Schema.derived
419
+ }
420
+
421
+ case class Product(
422
+ name: String,
423
+ price: Double,
424
+ category: String,
425
+ inStock: Boolean,
426
+ rating: Int,
427
+ warehouse: Address
428
+ )
429
+
430
+ object Product extends CompanionOptics[Product] {
431
+ implicit val schema: Schema[Product] = Schema.derived
432
+
433
+ val name: Lens[Product, String] = optic(_.name)
434
+ val price: Lens[Product, Double] = optic(_.price)
435
+ val category: Lens[Product, String] = optic(_.category)
436
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
437
+ val rating: Lens[Product, Int] = optic(_.rating)
438
+ val city: Lens[Product, String] = optic(_.warehouse.city)
439
+ }
440
+
441
+ // --- Generic query filter ---
442
+
443
+ def filter[A](items: List[A], predicate: SchemaExpr[A, Boolean]): List[A] =
444
+ items.filter(item =>
445
+ predicate.eval(item) match {
446
+ case Right(results) => results.forall(_ == true)
447
+ case Left(_) => false
448
+ }
449
+ )
450
+
451
+ // --- Usage ---
452
+
453
+ val catalog = List(
454
+ Product("Laptop", 999.99, "Electronics", true, 5, Address("Berlin", "Germany")),
455
+ Product("Mouse", 29.99, "Electronics", true, 4, Address("Berlin", "Germany")),
456
+ Product("Pen", 2.50, "Office", true, 3, Address("London", "UK")),
457
+ Product("Monitor", 349.99, "Electronics", false, 5, Address("Berlin", "Germany")),
458
+ Product("Notebook", 5.99, "Office", true, 4, Address("London", "UK"))
459
+ )
460
+
461
+ // Compose a query: in-stock electronics under $500, from Berlin, highly rated
462
+ val query =
463
+ (Product.category === "Electronics") &&
464
+ (Product.inStock === true) &&
465
+ (Product.price < 500.0) &&
466
+ (Product.city === "Berlin") &&
467
+ (Product.rating >= 4)
468
+
469
+ val results = filter(catalog, query)
470
+ // results: List(Product("Mouse", 29.99, "Electronics", true, 4, Address("Berlin", "Germany")))
471
+
472
+ // String operations
473
+ val searchQuery = Product.name.matches(".*top$")
474
+ val matches = filter(catalog, searchQuery)
475
+ // matches: List(Product("Laptop", ...))
476
+
477
+ // Arithmetic: compute discounted prices
478
+ val discounted = Product.price * 0.8
479
+ catalog.foreach { p =>
480
+ println(s"${Product.name.get(p)}: ${discounted.eval(p)}")
481
+ }
482
+ ```
483
+
484
+ ## Going Further
485
+
486
+ - **[Part 2: SQL Generation](./query-dsl-sql.md)** -- Translating query expressions to SQL
487
+ - **[Part 3: Extending the Expression Language](./query-dsl-extending.md)** -- Adding custom operators (IN, BETWEEN, aggregates) beyond SchemaExpr
488
+ - **[Part 4: A Fluent SQL Builder](./query-dsl-fluent-builder.md)** -- Type-safe SELECT, UPDATE, INSERT, DELETE with seamless condition mixing
489
+ - **[Optics Reference](../reference/optics.md)** -- Full API coverage of Lens, Prism, Optional, and Traversal
490
+ - **[DynamicOptic Reference](../reference/dynamic-optic.md)** -- Runtime optic paths for programmatic query construction
491
+ - **[Schema Reference](../reference/schema.md)** -- Schema derivation and type-level metadata
492
+ - **[Path Interpolator](../path-interpolator.md)** -- String-based path construction with `p"..."` syntax
493
+
494
+ The `SchemaExpr` expression tree is a sealed trait, making it straightforward to write interpreters that translate queries to SQL, MongoDB filters, Elasticsearch queries, or any other target language. Because each optic carries its `DynamicOptic` path (via `toDynamic`), you can extract field names and paths programmatically for these translations.