@zio.dev/zio-blocks 0.0.21 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ ---
2
+ id: query-dsl-extending
3
+ title: "Query DSL with Reified Optics — Part 3: Extending the Expression Language"
4
+ ---
5
+
6
+ In this guide, we will extend the ZIO Blocks query DSL with an expression language that goes beyond what `SchemaExpr` provides out of the box. By the end, you will have an `Expr` ADT that adds SQL-specific predicates (`IN`, `BETWEEN`, `IS NULL`, `LIKE`), type-safe aggregate functions (`COUNT`, `SUM`, `AVG`), and conditional expressions (`CASE WHEN`) — all composable with the built-in `SchemaExpr` operators from Parts 1 and 2.
7
+
8
+ This is Part 3 of the Query DSL series. [Part 1](./query-dsl-reified-optics.md) covered building query expressions, and [Part 2](./query-dsl-sql.md) covered translating them to SQL. Here, we handle the cases where the built-in expression language is not enough.
9
+
10
+ **What we'll cover:**
11
+
12
+ - Why `SchemaExpr` is deliberately closed and what that means for extension
13
+ - Designing an `Expr` ADT that is a superset of `SchemaExpr`
14
+ - Translating `SchemaExpr` into `Expr` via `fromSchemaExpr`
15
+ - Adding SQL-specific predicates: `IN`, `BETWEEN`, `IS NULL`, `LIKE`
16
+ - Writing bridge extension methods for seamless `SchemaExpr` + `Expr` composition
17
+ - Building a single unified SQL interpreter
18
+ - Adding type-safe aggregate functions and `CASE WHEN` for advanced SQL generation
19
+
20
+ ## The Problem
21
+
22
+ The built-in `SchemaExpr` operators cover the fundamentals: equality, comparisons, boolean logic, arithmetic, and basic string operations. But real-world SQL requires more. Consider these common queries:
23
+
24
+ ```sql
25
+ -- Membership test
26
+ SELECT * FROM products WHERE category IN ('Electronics', 'Books', 'Toys')
27
+
28
+ -- Range check
29
+ SELECT * FROM products WHERE price BETWEEN 10.0 AND 100.0
30
+
31
+ -- Null handling
32
+ SELECT * FROM products WHERE description IS NULL
33
+
34
+ -- Pattern matching with SQL wildcards
35
+ SELECT * FROM products WHERE name LIKE 'Lap%'
36
+
37
+ -- Aggregation
38
+ SELECT category, COUNT(*), AVG(price)
39
+ FROM products GROUP BY category HAVING COUNT(*) > 2
40
+
41
+ -- Conditional logic
42
+ SELECT name, CASE WHEN price > 100 THEN 'expensive' ELSE 'cheap' END AS tier
43
+ FROM products
44
+ ```
45
+
46
+ None of these can be expressed with `SchemaExpr` alone. You could generate the SQL strings manually, but then you lose composability — you can no longer mix these operations with the type-safe `SchemaExpr` predicates from Parts 1 and 2.
47
+
48
+ Since `SchemaExpr` is a sealed trait, you cannot add new cases to it. Instead, we define an `Expr` ADT that is a superset of `SchemaExpr` — it includes equivalent nodes for everything `SchemaExpr` can express, plus our custom SQL-specific operations. A `fromSchemaExpr` function translates `SchemaExpr` values into `Expr`, enabling seamless interoperability with a single unified interpreter.
49
+
50
+ ## Prerequisites
51
+
52
+ This guide builds on [Part 1: Expressions](./query-dsl-reified-optics.md) and [Part 2: SQL Generation](./query-dsl-sql.md). You should be comfortable building `SchemaExpr` values and translating them to SQL.
53
+
54
+ ```scala
55
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.24"
56
+ ```
57
+
58
+ ```scala
59
+ import zio.blocks.schema._
60
+ ```
61
+
62
+ ## Domain Setup
63
+
64
+ We reuse the product catalog domain from the earlier guides:
65
+
66
+ ```scala
67
+ case class Product(
68
+ name: String,
69
+ price: Double,
70
+ category: String,
71
+ inStock: Boolean,
72
+ rating: Int
73
+ )
74
+
75
+ object Product extends CompanionOptics[Product] {
76
+ implicit val schema: Schema[Product] = Schema.derived
77
+
78
+ val name: Lens[Product, String] = optic(_.name)
79
+ val price: Lens[Product, Double] = optic(_.price)
80
+ val category: Lens[Product, String] = optic(_.category)
81
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
82
+ val rating: Lens[Product, Int] = optic(_.rating)
83
+ }
84
+ ```
85
+
86
+ ## Designing the Expr ADT
87
+
88
+ The key insight is the **translation pattern**: define your own sealed trait whose node types are a superset of `SchemaExpr`'s, then provide a `fromSchemaExpr` function that converts any `SchemaExpr` into your ADT. This gives you a single unified interpreter.
89
+
90
+ ```
91
+ Built-in (sealed, not extensible) Your extension (superset)
92
+ ┌───────────────────────────────┐ ┌───────────────────────────────────────┐
93
+ │ SchemaExpr[S, A] │ │ Expr[S, A] │
94
+ │ ├── Literal │──────▶│ ├── Lit(value, schema) │
95
+ │ ├── Optic │──────▶│ ├── Column(Optic) │
96
+ │ ├── Relational │──────▶│ ├── Relational(left, right, RelOp) │
97
+ │ ├── Logical (And/Or) │──────▶│ ├── And / Or │
98
+ │ ├── Not │──────▶│ ├── Not │
99
+ │ ├── Arithmetic │──────▶│ ├── Arithmetic(left, right, ArithOp) │
100
+ │ ├── StringConcat │──────▶│ ├── StringConcat │
101
+ │ ├── StringRegexMatch │──────▶│ ├── StringRegexMatch │
102
+ │ └── StringLength │──────▶│ ├── StringLength │
103
+ └───────────────────────────────┘ │ ├── In(expr, values, schema) ← new │
104
+ fromSchemaExpr ────────────────│ ├── Between(expr, low, high, schema) ← new │
105
+ │ ├── IsNull(expr) ← new │
106
+ │ ├── Like(expr, pattern) ← new │
107
+ │ ├── Agg(function, expr) ← new │
108
+ │ └── CaseWhen(branches, else) ← new │
109
+ └───────────────────────────────────────┘
110
+ ```
111
+
112
+ The `Expr` ADT includes mirrored nodes for every `SchemaExpr` case, plus SQL-specific extensions. It uses its own operator types (`RelOp`, `ArithOp`) and type-safe aggregate functions (`AggFunction[A, B]`).
113
+
114
+ Here is the full `Expr` ADT with its supporting types:
115
+
116
+ ```scala
117
+ sealed trait Expr[S, A]
118
+
119
+ object Expr {
120
+
121
+ // --- Core nodes (superset of SchemaExpr's nodes) ---
122
+ final case class Column[S, A](optic: Optic[S, A]) extends Expr[S, A]
123
+ final case class Lit[S, A](value: A, schema: Schema[A]) extends Expr[S, A]
124
+
125
+ // Relational
126
+ final case class Relational[S, A](left: Expr[S, A], right: Expr[S, A], op: RelOp) extends Expr[S, Boolean]
127
+
128
+ // Logical
129
+ final case class And[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
130
+ final case class Or[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
131
+ final case class Not[S](expr: Expr[S, Boolean]) extends Expr[S, Boolean]
132
+
133
+ // Arithmetic
134
+ final case class Arithmetic[S, A](left: Expr[S, A], right: Expr[S, A], op: ArithOp) extends Expr[S, A]
135
+
136
+ // String
137
+ final case class StringConcat[S](left: Expr[S, String], right: Expr[S, String]) extends Expr[S, String]
138
+ final case class StringRegexMatch[S](regex: Expr[S, String], string: Expr[S, String]) extends Expr[S, Boolean]
139
+ final case class StringLength[S](string: Expr[S, String]) extends Expr[S, Int]
140
+
141
+ // --- SQL-specific extensions (no SchemaExpr equivalents) ---
142
+ final case class In[S, A](expr: Expr[S, A], values: List[A], schema: Schema[A]) extends Expr[S, Boolean]
143
+ final case class Between[S, A](expr: Expr[S, A], low: A, high: A, schema: Schema[A]) extends Expr[S, Boolean]
144
+ final case class IsNull[S, A](expr: Expr[S, A]) extends Expr[S, Boolean]
145
+ final case class Like[S](expr: Expr[S, String], pattern: String) extends Expr[S, Boolean]
146
+
147
+ // --- Aggregates ---
148
+ final case class Agg[S, A, B](function: AggFunction[A, B], expr: Expr[S, A]) extends Expr[S, B]
149
+
150
+ // --- Conditional ---
151
+ final case class CaseWhen[S, A](
152
+ branches: List[(Expr[S, Boolean], Expr[S, A])],
153
+ otherwise: Option[Expr[S, A]]
154
+ ) extends Expr[S, A]
155
+
156
+ // --- Factory methods ---
157
+ def col[S, A](optic: Optic[S, A]): Expr[S, A] = Column(optic)
158
+ def lit[S, A](value: A)(implicit schema: Schema[A]): Expr[S, A] = Lit(value, schema)
159
+
160
+ def count[S, A](expr: Expr[S, A]): Expr[S, Long] = Agg(AggFunction.Count(), expr)
161
+ def sum[S](expr: Expr[S, Double]): Expr[S, Double] = Agg(AggFunction.Sum, expr)
162
+ def avg[S](expr: Expr[S, Double]): Expr[S, Double] = Agg(AggFunction.Avg, expr)
163
+ def min[S, A](expr: Expr[S, A]): Expr[S, A] = Agg(AggFunction.Min(), expr)
164
+ def max[S, A](expr: Expr[S, A]): Expr[S, A] = Agg(AggFunction.Max(), expr)
165
+
166
+ def caseWhen[S, A](branches: (Expr[S, Boolean], Expr[S, A])*): CaseWhenBuilder[S, A] =
167
+ CaseWhenBuilder(branches.toList)
168
+
169
+ case class CaseWhenBuilder[S, A](branches: List[(Expr[S, Boolean], Expr[S, A])]) {
170
+ def otherwise(value: Expr[S, A]): Expr[S, A] = CaseWhen(branches, Some(value))
171
+ def end: Expr[S, A] = CaseWhen(branches, None)
172
+ }
173
+
174
+ // --- Translation from SchemaExpr ---
175
+ def fromSchemaExpr[S, A](se: SchemaExpr[S, A]): Expr[S, A] = {
176
+ val result = se match {
177
+ case SchemaExpr.Optic(optic) => Column(optic)
178
+ case l: SchemaExpr.Literal[_, _] => Lit(l.value, l.schema)
179
+
180
+ case SchemaExpr.Relational(l, r, op) =>
181
+ val relOp = op match {
182
+ case SchemaExpr.RelationalOperator.Equal => RelOp.Equal
183
+ case SchemaExpr.RelationalOperator.NotEqual => RelOp.NotEqual
184
+ case SchemaExpr.RelationalOperator.LessThan => RelOp.LessThan
185
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => RelOp.LessThanOrEqual
186
+ case SchemaExpr.RelationalOperator.GreaterThan => RelOp.GreaterThan
187
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => RelOp.GreaterThanOrEqual
188
+ }
189
+ Relational(fromSchemaExpr(l), fromSchemaExpr(r), relOp)
190
+
191
+ case SchemaExpr.Logical(l, r, op) => op match {
192
+ case SchemaExpr.LogicalOperator.And => And(fromSchemaExpr(l), fromSchemaExpr(r))
193
+ case SchemaExpr.LogicalOperator.Or => Or(fromSchemaExpr(l), fromSchemaExpr(r))
194
+ }
195
+
196
+ case SchemaExpr.Not(inner) => Not(fromSchemaExpr(inner))
197
+
198
+ case SchemaExpr.Arithmetic(l, r, op, _) =>
199
+ val arithOp = op match {
200
+ case SchemaExpr.ArithmeticOperator.Add => ArithOp.Add
201
+ case SchemaExpr.ArithmeticOperator.Subtract => ArithOp.Subtract
202
+ case SchemaExpr.ArithmeticOperator.Multiply => ArithOp.Multiply
203
+ }
204
+ Arithmetic(fromSchemaExpr(l), fromSchemaExpr(r), arithOp)
205
+
206
+ case SchemaExpr.StringConcat(l, r) => StringConcat(fromSchemaExpr(l), fromSchemaExpr(r))
207
+ case SchemaExpr.StringRegexMatch(regex, string) => StringRegexMatch(fromSchemaExpr(regex), fromSchemaExpr(string))
208
+ case SchemaExpr.StringLength(string) => StringLength(fromSchemaExpr(string))
209
+ }
210
+ result.asInstanceOf[Expr[S, A]]
211
+ }
212
+ }
213
+
214
+ // --- Operators ---
215
+
216
+ sealed trait RelOp
217
+ object RelOp {
218
+ case object Equal extends RelOp
219
+ case object NotEqual extends RelOp
220
+ case object LessThan extends RelOp
221
+ case object LessThanOrEqual extends RelOp
222
+ case object GreaterThan extends RelOp
223
+ case object GreaterThanOrEqual extends RelOp
224
+ }
225
+
226
+ sealed trait ArithOp
227
+ object ArithOp {
228
+ case object Add extends ArithOp
229
+ case object Subtract extends ArithOp
230
+ case object Multiply extends ArithOp
231
+ }
232
+
233
+ // Typed aggregate functions
234
+ sealed trait AggFunction[A, B] {
235
+ def name: String
236
+ }
237
+ object AggFunction {
238
+ case class Count[A]() extends AggFunction[A, Long] { val name = "COUNT" }
239
+ case object Sum extends AggFunction[Double, Double] { val name = "SUM" }
240
+ case object Avg extends AggFunction[Double, Double] { val name = "AVG" }
241
+ case class Min[A]() extends AggFunction[A, A] { val name = "MIN" }
242
+ case class Max[A]() extends AggFunction[A, A] { val name = "MAX" }
243
+ }
244
+ ```
245
+
246
+ Here are some keynotes on the design:
247
+
248
+ - **Type-safe aggregates** — `AggFunction[A, B]` encodes the return type: `COUNT` returns `Long`, `SUM`/`AVG` return `Double`, `MIN`/`MAX` preserve the input type.
249
+ - **Typed literals and predicates** — `Lit(value, schema)`, `In(expr, values, schema)`, and `Between(expr, low, high, schema)` all carry a `Schema[A]` so the SQL renderer can format values correctly using the schema rather than runtime type checks.
250
+ - **`fromSchemaExpr`** — one-way translation recursively converts every `SchemaExpr` node into its `Expr` equivalent, mapping operators along the way.
251
+
252
+ ## Extension Methods
253
+
254
+ To make the new operations feel natural, we define implicit classes on `Optic`, `Expr`, and `SchemaExpr`. The bridge implicit class on `SchemaExpr` auto-translates at the boundary via `fromSchemaExpr`, so `SchemaExpr` and `Expr` values compose seamlessly with `&&` and `||`:
255
+
256
+ ```scala
257
+ implicit final class OpticExprOps[S, A](private val optic: Optic[S, A]) {
258
+ def in(values: A*)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.In(Expr.col(optic), values.toList, schema)
259
+ def between(low: A, high: A)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.Between(Expr.col(optic), low, high, schema)
260
+ def isNull: Expr[S, Boolean] = Expr.IsNull(Expr.col(optic))
261
+ def isNotNull: Expr[S, Boolean] = Expr.Not(Expr.IsNull(Expr.col(optic)))
262
+ }
263
+
264
+ implicit final class StringOpticExprOps[S](private val optic: Optic[S, String]) {
265
+ def like(pattern: String): Expr[S, Boolean] = Expr.Like(Expr.col(optic), pattern)
266
+ }
267
+
268
+ // Boolean combinators — accept both Expr and SchemaExpr on the right
269
+ implicit final class ExprBooleanOps[S](private val self: Expr[S, Boolean]) {
270
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, other)
271
+ def &&(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, Expr.fromSchemaExpr(other))
272
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, other)
273
+ def ||(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, Expr.fromSchemaExpr(other))
274
+ def unary_! : Expr[S, Boolean] = Expr.Not(self)
275
+ }
276
+
277
+ // Bridge: SchemaExpr on the left, Expr on the right
278
+ implicit final class SchemaExprBooleanBridge[S](private val self: SchemaExpr[S, Boolean]) {
279
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(Expr.fromSchemaExpr(self), other)
280
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(Expr.fromSchemaExpr(self), other)
281
+ def toExpr: Expr[S, Boolean] = Expr.fromSchemaExpr(self)
282
+ }
283
+ ```
284
+
285
+ The bridge implicit classes are the key to ergonomic composition. When you write `Product.category.in("Electronics") && (Product.rating >= 4)`, the `&&` on `Expr[S, Boolean]` sees a `SchemaExpr[S, Boolean]` on the right and auto-translates it. Similarly, `(Product.rating >= 4) && Product.category.in("Electronics")` uses the `SchemaExpr` bridge to translate the left side. No explicit `.toExpr` is needed in most cases.
286
+
287
+ :::tip
288
+ The `.toExpr` method is still available for cases where you need to explicitly lift a `SchemaExpr[S, Boolean]` — for example, when building `CASE WHEN` branch conditions.
289
+ :::
290
+
291
+ ## The Unified SQL Interpreter
292
+
293
+ With the `Expr` ADT, we write a single interpreter that handles all cases directly:
294
+
295
+ ```scala
296
+ def columnName(optic: zio.blocks.schema.Optic[_, _]): String =
297
+ optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
298
+
299
+ def sqlLiteral[A](value: A, schema: Schema[A]): String = {
300
+ val dv = schema.toDynamicValue(value)
301
+ dv match {
302
+ case p: DynamicValue.Primitive => p.value match {
303
+ case _: PrimitiveValue.String => s"'${value.toString.replace("'", "''")}'"
304
+ case b: PrimitiveValue.Boolean => if (b.value) "TRUE" else "FALSE"
305
+ case _ => value.toString
306
+ }
307
+ case _ => value.toString
308
+ }
309
+ }
310
+
311
+ def exprToSql[S, A](expr: Expr[S, A]): String = expr match {
312
+ case Expr.Column(optic) => columnName(optic)
313
+ case Expr.Lit(value, schema) => sqlLiteral(value, schema)
314
+
315
+ case Expr.Relational(left, right, op) =>
316
+ val sqlOp = op match {
317
+ case RelOp.Equal => "="
318
+ case RelOp.NotEqual => "<>"
319
+ case RelOp.LessThan => "<"
320
+ case RelOp.LessThanOrEqual => "<="
321
+ case RelOp.GreaterThan => ">"
322
+ case RelOp.GreaterThanOrEqual => ">="
323
+ }
324
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
325
+
326
+ case Expr.And(l, r) => s"(${exprToSql(l)} AND ${exprToSql(r)})"
327
+ case Expr.Or(l, r) => s"(${exprToSql(l)} OR ${exprToSql(r)})"
328
+ case Expr.Not(e) => s"NOT (${exprToSql(e)})"
329
+
330
+ case Expr.Arithmetic(left, right, op) =>
331
+ val sqlOp = op match {
332
+ case ArithOp.Add => "+"
333
+ case ArithOp.Subtract => "-"
334
+ case ArithOp.Multiply => "*"
335
+ }
336
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
337
+
338
+ case Expr.StringConcat(l, r) => s"CONCAT(${exprToSql(l)}, ${exprToSql(r)})"
339
+ case Expr.StringRegexMatch(regex, s) => s"(${exprToSql(s)} LIKE ${exprToSql(regex)})"
340
+ case Expr.StringLength(s) => s"LENGTH(${exprToSql(s)})"
341
+
342
+ // SQL-specific
343
+ case Expr.In(e, values, schema) =>
344
+ s"${exprToSql(e)} IN (${values.map(v => sqlLiteral(v, schema)).mkString(", ")})"
345
+ case Expr.Between(e, low, high, schema) =>
346
+ s"(${exprToSql(e)} BETWEEN ${sqlLiteral(low, schema)} AND ${sqlLiteral(high, schema)})"
347
+ case Expr.IsNull(e) => s"${exprToSql(e)} IS NULL"
348
+ case Expr.Like(e, pattern) => s"${exprToSql(e)} LIKE '${pattern.replace("'", "''")}'"
349
+
350
+ // Aggregates
351
+ case Expr.Agg(func, e) => s"${func.name}(${exprToSql(e)})"
352
+
353
+ // CASE WHEN
354
+ case Expr.CaseWhen(branches, otherwise) =>
355
+ val cases = branches.map { case (cond, value) =>
356
+ s"WHEN ${exprToSql(cond)} THEN ${exprToSql(value)}"
357
+ }.mkString(" ")
358
+ val elseClause = otherwise.map(e => s" ELSE ${exprToSql(e)}").getOrElse("")
359
+ s"CASE $cases$elseClause END"
360
+ }
361
+ ```
362
+
363
+ The typed `sqlLiteral[A](value, schema)` uses the `Schema` carried by `Lit`, `In`, and `Between` to format values correctly — strings get quoted, booleans become `TRUE`/`FALSE`, numbers stay as-is. Every AST node that holds literal values carries a `Schema[A]`, so a single `sqlLiteral` function handles all formatting with no untyped fallbacks.
364
+
365
+ ## SQL-Specific Predicates
366
+
367
+ With the ADT, extensions, and interpreter in place, the new operators work directly on optics:
368
+
369
+ ```scala
370
+ exprToSql(Product.category.in("Electronics", "Books", "Toys"))
371
+ // res0: String = "category IN ('Electronics', 'Books', 'Toys')"
372
+
373
+ exprToSql(Product.price.between(10.0, 100.0))
374
+ // res1: String = "(price BETWEEN 10.0 AND 100.0)"
375
+
376
+ exprToSql(Product.name.isNull)
377
+ // res2: String = "name IS NULL"
378
+
379
+ exprToSql(Product.name.isNotNull)
380
+ // res3: String = "NOT (name IS NULL)"
381
+
382
+ exprToSql(Product.name.like("Lap%"))
383
+ // res4: String = "name LIKE 'Lap%'"
384
+ ```
385
+
386
+ Each extension method on `Optic` returns an `Expr` node. The interpreter handles it and produces the corresponding SQL fragment.
387
+
388
+ ## Composing with SchemaExpr
389
+
390
+ The bridge implicit classes handle the translation automatically. You can freely mix `SchemaExpr` predicates (from `===`, `>`, etc.) with `Expr` predicates (from `.in`, `.between`, etc.) using `&&` and `||`:
391
+
392
+ ```scala
393
+ // SchemaExpr values from built-in operators
394
+ val highRated: SchemaExpr[Product, Boolean] = Product.rating >= 4
395
+
396
+ // Expr values from extension methods
397
+ val inCategory: Expr[Product, Boolean] = Product.category.in("Electronics", "Books")
398
+ val priceRange: Expr[Product, Boolean] = Product.price.between(10.0, 500.0)
399
+
400
+ // Seamless composition — bridge auto-translates at the boundary
401
+ val combined: Expr[Product, Boolean] =
402
+ inCategory && priceRange && highRated
403
+ ```
404
+
405
+ ```scala
406
+ exprToSql(combined)
407
+ // res5: String = "((category IN ('Electronics', 'Books') AND (price BETWEEN 10.0 AND 500.0)) AND (rating >= 4))"
408
+ ```
409
+
410
+ The `&&` between `priceRange` (an `Expr`) and `highRated` (a `SchemaExpr`) triggers the overloaded `&&` that accepts `SchemaExpr` on the right. It calls `fromSchemaExpr` internally, so no explicit `.toExpr` is needed.
411
+
412
+ You can also start from a `SchemaExpr` on the left — the bridge implicit class handles it:
413
+
414
+ ```scala
415
+ val query: Expr[Product, Boolean] =
416
+ Product.category.in("Electronics", "Books") &&
417
+ Product.price.between(10.0, 500.0) &&
418
+ (Product.rating >= 4) &&
419
+ Product.name.like("M%")
420
+ ```
421
+
422
+ ```scala
423
+ exprToSql(query)
424
+ // res6: String = "(((category IN ('Electronics', 'Books') AND (price BETWEEN 10.0 AND 500.0)) AND (rating >= 4)) AND name LIKE 'M%')"
425
+ ```
426
+
427
+ A helper function to generate full SELECT statements from `Expr` predicates:
428
+
429
+ ```scala
430
+ def selectWhere(table: String, predicate: Expr[_, Boolean]): String =
431
+ s"SELECT * FROM $table WHERE ${exprToSql(predicate)}"
432
+ ```
433
+
434
+ ```scala
435
+ selectWhere("products", query)
436
+ // res7: String = "SELECT * FROM products WHERE (((category IN ('Electronics', 'Books') AND (price BETWEEN 10.0 AND 500.0)) AND (rating >= 4)) AND name LIKE 'M%')"
437
+ ```
438
+
439
+ ## Aggregate Expressions
440
+
441
+ The `Agg` node wraps any column expression with a type-safe aggregate function. The return type reflects SQL semantics: `COUNT` returns `Expr[S, Long]`, `SUM`/`AVG` return `Expr[S, Double]`, and `MIN`/`MAX` preserve the input type:
442
+
443
+ ```scala
444
+ exprToSql(Expr.count(Expr.col(Product.name)))
445
+ // res8: String = "COUNT(name)"
446
+
447
+ exprToSql(Expr.avg(Expr.col(Product.price)))
448
+ // res9: String = "AVG(price)"
449
+
450
+ exprToSql(Expr.max(Expr.col(Product.rating)))
451
+ // res10: String = "MAX(rating)"
452
+ ```
453
+
454
+ Aggregates compose with the rest of the ADT. Build a `GROUP BY` query by combining aggregate SQL fragments with a select builder:
455
+
456
+ ```scala
457
+ def selectGroupBy(
458
+ table: String,
459
+ columns: List[String],
460
+ groupBy: List[String],
461
+ having: Option[String] = None
462
+ ): String = {
463
+ val base = s"SELECT ${columns.mkString(", ")} FROM $table GROUP BY ${groupBy.mkString(", ")}"
464
+ having.fold(base)(h => s"$base HAVING $h")
465
+ }
466
+ ```
467
+
468
+ ```scala
469
+ selectGroupBy(
470
+ "products",
471
+ columns = List(
472
+ "category",
473
+ s"${exprToSql(Expr.count(Expr.col(Product.name)))} AS product_count",
474
+ s"${exprToSql(Expr.avg(Expr.col(Product.price)))} AS avg_price"
475
+ ),
476
+ groupBy = List("category"),
477
+ having = Some(s"${exprToSql(Expr.count(Expr.col(Product.name)))} > 2")
478
+ )
479
+ // res11: String = "SELECT category, COUNT(name) AS product_count, AVG(price) AS avg_price FROM products GROUP BY category HAVING COUNT(name) > 2"
480
+ ```
481
+
482
+ ## CASE WHEN Expressions
483
+
484
+ The `CaseWhen` node represents SQL's conditional expression. Use the `Expr.caseWhen` builder with `(condition -> result)` pairs and an optional `.otherwise` clause:
485
+
486
+ ```scala
487
+ val priceLabel: Expr[Product, String] = Expr.caseWhen[Product, String](
488
+ (Product.price > 100.0).toExpr -> Expr.lit[Product, String]("expensive"),
489
+ (Product.price > 10.0).toExpr -> Expr.lit[Product, String]("moderate")
490
+ ).otherwise(Expr.lit[Product, String]("cheap"))
491
+ ```
492
+
493
+ ```scala
494
+ exprToSql(priceLabel)
495
+ // res12: String = "CASE WHEN (price > 100.0) THEN 'expensive' WHEN (price > 10.0) THEN 'moderate' ELSE 'cheap' END"
496
+ ```
497
+
498
+ `CASE WHEN` is useful for computed columns in SELECT lists:
499
+
500
+ ```scala
501
+ val stockStatus: Expr[Product, String] = Expr.caseWhen[Product, String](
502
+ (Product.inStock === true).toExpr -> Expr.lit[Product, String]("available")
503
+ ).otherwise(Expr.lit[Product, String]("out of stock"))
504
+ ```
505
+
506
+ ```scala
507
+ val selectSql = s"SELECT name, price, ${exprToSql(priceLabel)} AS tier, ${exprToSql(stockStatus)} AS status FROM products"
508
+ // selectSql: String = "SELECT name, price, CASE WHEN (price > 100.0) THEN 'expensive' WHEN (price > 10.0) THEN 'moderate' ELSE 'cheap' END AS tier, CASE WHEN (inStock = TRUE) THEN 'available' ELSE 'out of stock' END AS status FROM products"
509
+ println(selectSql)
510
+ // SELECT name, price, CASE WHEN (price > 100.0) THEN 'expensive' WHEN (price > 10.0) THEN 'moderate' ELSE 'cheap' END AS tier, CASE WHEN (inStock = TRUE) THEN 'available' ELSE 'out of stock' END AS status FROM products
511
+ ```
512
+
513
+ ## Putting It Together
514
+
515
+ Here is a complete, self-contained example that defines the independent expression ADT, translates from `SchemaExpr`, and generates advanced SQL:
516
+
517
+ ```scala
518
+ import zio.blocks.schema._
519
+
520
+ // --- Domain ---
521
+
522
+ case class Product(
523
+ name: String,
524
+ price: Double,
525
+ category: String,
526
+ inStock: Boolean,
527
+ rating: Int
528
+ )
529
+
530
+ object Product extends CompanionOptics[Product] {
531
+ implicit val schema: Schema[Product] = Schema.derived
532
+
533
+ val name: Lens[Product, String] = optic(_.name)
534
+ val price: Lens[Product, Double] = optic(_.price)
535
+ val category: Lens[Product, String] = optic(_.category)
536
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
537
+ val rating: Lens[Product, Int] = optic(_.rating)
538
+ }
539
+
540
+ // --- Independent Expr ADT ---
541
+
542
+ sealed trait Expr[S, A]
543
+
544
+ object Expr {
545
+ final case class Column[S, A](optic: Optic[S, A]) extends Expr[S, A]
546
+ final case class Lit[S, A](value: A, schema: Schema[A]) extends Expr[S, A]
547
+
548
+ final case class Relational[S, A](left: Expr[S, A], right: Expr[S, A], op: RelOp) extends Expr[S, Boolean]
549
+ final case class And[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
550
+ final case class Or[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
551
+ final case class Not[S](expr: Expr[S, Boolean]) extends Expr[S, Boolean]
552
+ final case class Arithmetic[S, A](left: Expr[S, A], right: Expr[S, A], op: ArithOp) extends Expr[S, A]
553
+ final case class StringConcat[S](left: Expr[S, String], right: Expr[S, String]) extends Expr[S, String]
554
+ final case class StringRegexMatch[S](regex: Expr[S, String], string: Expr[S, String]) extends Expr[S, Boolean]
555
+ final case class StringLength[S](string: Expr[S, String]) extends Expr[S, Int]
556
+
557
+ final case class In[S, A](expr: Expr[S, A], values: List[A], schema: Schema[A]) extends Expr[S, Boolean]
558
+ final case class Between[S, A](expr: Expr[S, A], low: A, high: A, schema: Schema[A]) extends Expr[S, Boolean]
559
+ final case class IsNull[S, A](expr: Expr[S, A]) extends Expr[S, Boolean]
560
+ final case class Like[S](expr: Expr[S, String], pattern: String) extends Expr[S, Boolean]
561
+
562
+ final case class Agg[S, A, B](function: AggFunction[A, B], expr: Expr[S, A]) extends Expr[S, B]
563
+ final case class CaseWhen[S, A](
564
+ branches: List[(Expr[S, Boolean], Expr[S, A])],
565
+ otherwise: Option[Expr[S, A]]
566
+ ) extends Expr[S, A]
567
+
568
+ def col[S, A](optic: Optic[S, A]): Expr[S, A] = Column(optic)
569
+ def lit[S, A](value: A)(implicit schema: Schema[A]): Expr[S, A] = Lit(value, schema)
570
+ def count[S, A](expr: Expr[S, A]): Expr[S, Long] = Agg(AggFunction.Count(), expr)
571
+ def sum[S](expr: Expr[S, Double]): Expr[S, Double] = Agg(AggFunction.Sum, expr)
572
+ def avg[S](expr: Expr[S, Double]): Expr[S, Double] = Agg(AggFunction.Avg, expr)
573
+ def min[S, A](expr: Expr[S, A]): Expr[S, A] = Agg(AggFunction.Min(), expr)
574
+ def max[S, A](expr: Expr[S, A]): Expr[S, A] = Agg(AggFunction.Max(), expr)
575
+
576
+ def caseWhen[S, A](branches: (Expr[S, Boolean], Expr[S, A])*): CaseWhenBuilder[S, A] =
577
+ CaseWhenBuilder(branches.toList)
578
+
579
+ case class CaseWhenBuilder[S, A](branches: List[(Expr[S, Boolean], Expr[S, A])]) {
580
+ def otherwise(value: Expr[S, A]): Expr[S, A] = CaseWhen(branches, Some(value))
581
+ def end: Expr[S, A] = CaseWhen(branches, None)
582
+ }
583
+
584
+ def fromSchemaExpr[S, A](se: SchemaExpr[S, A]): Expr[S, A] = {
585
+ val result = se match {
586
+ case SchemaExpr.Optic(optic) => Column(optic)
587
+ case l: SchemaExpr.Literal[_, _] => Lit(l.value, l.schema)
588
+ case SchemaExpr.Relational(l, r, op) =>
589
+ val relOp = op match {
590
+ case SchemaExpr.RelationalOperator.Equal => RelOp.Equal
591
+ case SchemaExpr.RelationalOperator.NotEqual => RelOp.NotEqual
592
+ case SchemaExpr.RelationalOperator.LessThan => RelOp.LessThan
593
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => RelOp.LessThanOrEqual
594
+ case SchemaExpr.RelationalOperator.GreaterThan => RelOp.GreaterThan
595
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => RelOp.GreaterThanOrEqual
596
+ }
597
+ Relational(fromSchemaExpr(l), fromSchemaExpr(r), relOp)
598
+ case SchemaExpr.Logical(l, r, op) => op match {
599
+ case SchemaExpr.LogicalOperator.And => And(fromSchemaExpr(l), fromSchemaExpr(r))
600
+ case SchemaExpr.LogicalOperator.Or => Or(fromSchemaExpr(l), fromSchemaExpr(r))
601
+ }
602
+ case SchemaExpr.Not(inner) => Not(fromSchemaExpr(inner))
603
+ case SchemaExpr.Arithmetic(l, r, op, _) =>
604
+ val arithOp = op match {
605
+ case SchemaExpr.ArithmeticOperator.Add => ArithOp.Add
606
+ case SchemaExpr.ArithmeticOperator.Subtract => ArithOp.Subtract
607
+ case SchemaExpr.ArithmeticOperator.Multiply => ArithOp.Multiply
608
+ }
609
+ Arithmetic(fromSchemaExpr(l), fromSchemaExpr(r), arithOp)
610
+ case SchemaExpr.StringConcat(l, r) => StringConcat(fromSchemaExpr(l), fromSchemaExpr(r))
611
+ case SchemaExpr.StringRegexMatch(regex, string) => StringRegexMatch(fromSchemaExpr(regex), fromSchemaExpr(string))
612
+ case SchemaExpr.StringLength(string) => StringLength(fromSchemaExpr(string))
613
+ }
614
+ result.asInstanceOf[Expr[S, A]]
615
+ }
616
+ }
617
+
618
+ sealed trait RelOp
619
+ object RelOp {
620
+ case object Equal extends RelOp
621
+ case object NotEqual extends RelOp
622
+ case object LessThan extends RelOp
623
+ case object LessThanOrEqual extends RelOp
624
+ case object GreaterThan extends RelOp
625
+ case object GreaterThanOrEqual extends RelOp
626
+ }
627
+
628
+ sealed trait ArithOp
629
+ object ArithOp {
630
+ case object Add extends ArithOp
631
+ case object Subtract extends ArithOp
632
+ case object Multiply extends ArithOp
633
+ }
634
+
635
+ sealed trait AggFunction[A, B] { def name: String }
636
+ object AggFunction {
637
+ case class Count[A]() extends AggFunction[A, Long] { val name = "COUNT" }
638
+ case object Sum extends AggFunction[Double, Double] { val name = "SUM" }
639
+ case object Avg extends AggFunction[Double, Double] { val name = "AVG" }
640
+ case class Min[A]() extends AggFunction[A, A] { val name = "MIN" }
641
+ case class Max[A]() extends AggFunction[A, A] { val name = "MAX" }
642
+ }
643
+
644
+ // --- Extension methods with bridge ---
645
+
646
+ implicit final class OpticExprOps[S, A](private val optic: Optic[S, A]) {
647
+ def in(values: A*)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.In(Expr.col(optic), values.toList, schema)
648
+ def between(low: A, high: A)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.Between(Expr.col(optic), low, high, schema)
649
+ def isNull: Expr[S, Boolean] = Expr.IsNull(Expr.col(optic))
650
+ def isNotNull: Expr[S, Boolean] = Expr.Not(Expr.IsNull(Expr.col(optic)))
651
+ }
652
+
653
+ implicit final class StringOpticExprOps[S](private val optic: Optic[S, String]) {
654
+ def like(pattern: String): Expr[S, Boolean] = Expr.Like(Expr.col(optic), pattern)
655
+ }
656
+
657
+ implicit final class ExprBooleanOps[S](private val self: Expr[S, Boolean]) {
658
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, other)
659
+ def &&(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, Expr.fromSchemaExpr(other))
660
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, other)
661
+ def ||(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, Expr.fromSchemaExpr(other))
662
+ def unary_! : Expr[S, Boolean] = Expr.Not(self)
663
+ }
664
+
665
+ implicit final class SchemaExprBooleanBridge[S](private val self: SchemaExpr[S, Boolean]) {
666
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(Expr.fromSchemaExpr(self), other)
667
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(Expr.fromSchemaExpr(self), other)
668
+ def toExpr: Expr[S, Boolean] = Expr.fromSchemaExpr(self)
669
+ }
670
+
671
+ // --- SQL rendering ---
672
+
673
+ def columnName(optic: zio.blocks.schema.Optic[_, _]): String =
674
+ optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
675
+
676
+ def sqlLiteral[A](value: A, schema: Schema[A]): String = {
677
+ val dv = schema.toDynamicValue(value)
678
+ dv match {
679
+ case p: DynamicValue.Primitive => p.value match {
680
+ case _: PrimitiveValue.String => s"'${value.toString.replace("'", "''")}'"
681
+ case b: PrimitiveValue.Boolean => if (b.value) "TRUE" else "FALSE"
682
+ case _ => value.toString
683
+ }
684
+ case _ => value.toString
685
+ }
686
+ }
687
+
688
+ def exprToSql[S, A](expr: Expr[S, A]): String = expr match {
689
+ case Expr.Column(optic) => columnName(optic)
690
+ case Expr.Lit(value, schema) => sqlLiteral(value, schema)
691
+ case Expr.Relational(left, right, op) =>
692
+ val sqlOp = op match {
693
+ case RelOp.Equal => "="; case RelOp.NotEqual => "<>"
694
+ case RelOp.LessThan => "<"; case RelOp.LessThanOrEqual => "<="
695
+ case RelOp.GreaterThan => ">"; case RelOp.GreaterThanOrEqual => ">="
696
+ }
697
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
698
+ case Expr.And(l, r) => s"(${exprToSql(l)} AND ${exprToSql(r)})"
699
+ case Expr.Or(l, r) => s"(${exprToSql(l)} OR ${exprToSql(r)})"
700
+ case Expr.Not(e) => s"NOT (${exprToSql(e)})"
701
+ case Expr.Arithmetic(left, right, op) =>
702
+ val sqlOp = op match {
703
+ case ArithOp.Add => "+"; case ArithOp.Subtract => "-"; case ArithOp.Multiply => "*"
704
+ }
705
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
706
+ case Expr.StringConcat(l, r) => s"CONCAT(${exprToSql(l)}, ${exprToSql(r)})"
707
+ case Expr.StringRegexMatch(regex, s) => s"(${exprToSql(s)} LIKE ${exprToSql(regex)})"
708
+ case Expr.StringLength(s) => s"LENGTH(${exprToSql(s)})"
709
+ case Expr.In(e, values, schema) =>
710
+ s"${exprToSql(e)} IN (${values.map(v => sqlLiteral(v, schema)).mkString(", ")})"
711
+ case Expr.Between(e, low, high, schema) =>
712
+ s"(${exprToSql(e)} BETWEEN ${sqlLiteral(low, schema)} AND ${sqlLiteral(high, schema)})"
713
+ case Expr.IsNull(e) => s"${exprToSql(e)} IS NULL"
714
+ case Expr.Like(e, pattern) => s"${exprToSql(e)} LIKE '${pattern.replace("'", "''")}'"
715
+ case Expr.Agg(func, e) => s"${func.name}(${exprToSql(e)})"
716
+ case Expr.CaseWhen(branches, otherwise) =>
717
+ val cases = branches.map { case (cond, value) =>
718
+ s"WHEN ${exprToSql(cond)} THEN ${exprToSql(value)}"
719
+ }.mkString(" ")
720
+ val elseClause = otherwise.map(e => s" ELSE ${exprToSql(e)}").getOrElse("")
721
+ s"CASE $cases$elseClause END"
722
+ }
723
+
724
+ // --- Usage ---
725
+
726
+ // 1. SQL-specific predicates — seamless composition
727
+ val q1 = Product.category.in("Electronics", "Books") &&
728
+ Product.price.between(10.0, 500.0) &&
729
+ (Product.rating >= 4) &&
730
+ Product.name.like("M%")
731
+
732
+ println(s"SELECT * FROM products WHERE ${exprToSql(q1)}")
733
+
734
+ // 2. Type-safe aggregation
735
+ val countExpr: Expr[Product, Long] = Expr.count(Expr.col(Product.name))
736
+ val avgExpr: Expr[Product, Double] = Expr.avg(Expr.col(Product.price))
737
+ val countSql = exprToSql(countExpr)
738
+ val avgSql = exprToSql(avgExpr)
739
+ println(s"SELECT category, $countSql AS cnt, $avgSql AS avg_price FROM products GROUP BY category HAVING $countSql > 2")
740
+
741
+ // 3. CASE WHEN
742
+ val tier = Expr.caseWhen[Product, String](
743
+ (Product.price > 100.0).toExpr -> Expr.lit[Product, String]("expensive"),
744
+ (Product.price > 10.0).toExpr -> Expr.lit[Product, String]("moderate")
745
+ ).otherwise(Expr.lit[Product, String]("cheap"))
746
+
747
+ println(s"SELECT name, price, ${exprToSql(tier)} AS tier FROM products")
748
+ ```
749
+
750
+ ## Going Further
751
+
752
+ - **[Part 1: Expressions](./query-dsl-reified-optics.md)** -- Building query expressions with reified optics
753
+ - **[Part 2: SQL Generation](./query-dsl-sql.md)** -- Translating built-in expressions to SQL
754
+ - **[Part 4: A Fluent SQL Builder](./query-dsl-fluent-builder.md)** -- Type-safe SELECT, UPDATE, INSERT, DELETE with seamless condition mixing
755
+ - **[SchemaExpr Reference](../reference/schema-expr.md)** -- Full API coverage of expression types
756
+ - **[Optics Reference](../reference/optics.md)** -- Lens, Prism, Optional, and Traversal
757
+
758
+ The translation pattern shown here extends to any domain where `SchemaExpr` falls short. The same approach works for MongoDB operators (`$in`, `$exists`, `$elemMatch`), Elasticsearch queries (`terms`, `range`, `exists`), or GraphQL filters. Define an independent ADT, provide a `fromSchemaExpr` translation, add your domain-specific nodes, and write a single unified interpreter.