@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,680 @@
1
+ ---
2
+ id: query-dsl-sql
3
+ title: "Query DSL with Reified Optics — Part 2: SQL Generation"
4
+ ---
5
+
6
+ In this guide, we will build a SQL query generator that translates ZIO Blocks' `SchemaExpr` expression trees into SQL WHERE clauses, SELECT statements, and parameterized queries. By the end, you will have an interpreter that takes any `SchemaExpr`-based query and produces executable SQL, covering comparisons, boolean logic, arithmetic, string operations, nested structures, and safe parameterization.
7
+
8
+ This is Part 2 of the Query DSL series. [Part 1](./query-dsl-reified-optics.md) covered building query expressions with reified optics. Here, we interpret those expressions as SQL.
9
+
10
+ **What we'll cover:**
11
+
12
+ - Interpreting `SchemaExpr` as a sealed AST via pattern matching
13
+ - Extracting column names from optic paths using `DynamicOptic`
14
+ - Translating relational, logical, arithmetic, and string operations to SQL
15
+ - Building complete `SELECT ... FROM ... WHERE ...` statements
16
+ - Generating parameterized queries for SQL injection safety
17
+ - Handling nested structures with table-qualified column names
18
+
19
+ ## The Problem
20
+
21
+ In Part 1, we built composable query expressions as data -- `SchemaExpr` values that can be inspected, combined, and evaluated in-memory. But in real applications, data lives in databases. You need to translate those same queries into SQL.
22
+
23
+ The naive approach is to write SQL strings by hand for every query:
24
+
25
+ ```scala
26
+ // Manual SQL for each query variant
27
+ def findProducts(category: Option[String], maxPrice: Option[Double], inStock: Option[Boolean]): String = {
28
+ val conditions = List.newBuilder[String]
29
+ category.foreach(c => conditions += s"category = '$c'") // SQL injection!
30
+ maxPrice.foreach(p => conditions += s"price < $p")
31
+ inStock.foreach(s => conditions += s"in_stock = $s")
32
+ val where = conditions.result().mkString(" AND ")
33
+ s"SELECT * FROM products" + (if (where.nonEmpty) s" WHERE $where" else "")
34
+ }
35
+ ```
36
+
37
+ This is fragile, repetitive, and vulnerable to SQL injection. Every new query shape requires new string-building code. The query logic is duplicated -- once as a `SchemaExpr` for in-memory filtering, and again as hand-written SQL for the database.
38
+
39
+ Since `SchemaExpr` is a sealed trait, we can write a single interpreter that translates *any* query expression into SQL. Write the interpreter once, and every query you build with the Part 1 DSL automatically gets a SQL translation.
40
+
41
+ ## Prerequisites
42
+
43
+ This guide builds on [Part 1: Expressions](./query-dsl-reified-optics.md). You should be comfortable building `SchemaExpr` values with optic operators (`===`, `>`, `&&`, etc.).
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
+ ## Domain Setup
54
+
55
+ We reuse the product catalog domain from Part 1:
56
+
57
+ ```scala
58
+ case class Product(
59
+ name: String,
60
+ price: Double,
61
+ category: String,
62
+ inStock: Boolean,
63
+ rating: Int
64
+ )
65
+
66
+ object Product extends CompanionOptics[Product] {
67
+ implicit val schema: Schema[Product] = Schema.derived
68
+
69
+ val name: Lens[Product, String] = optic(_.name)
70
+ val price: Lens[Product, Double] = optic(_.price)
71
+ val category: Lens[Product, String] = optic(_.category)
72
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
73
+ val rating: Lens[Product, Int] = optic(_.rating)
74
+ }
75
+ ```
76
+
77
+ ## The SchemaExpr AST
78
+
79
+ Before we build the interpreter, let's understand the structure we are interpreting. `SchemaExpr` is a sealed trait with these cases:
80
+
81
+ ```
82
+ SchemaExpr[A, B]
83
+ ├── Literal[S, A](value, schema) -- a constant value
84
+ ├── Optic[A, B](optic) -- a field reference
85
+ ├── StringRegexMatch[A](regex, string) -- regex pattern matching
86
+ ├── StringLength[A](string) -- string length calculation
87
+ ├── UnaryOp[A, B] -- abstract trait for unary operations
88
+ │ └── Not[A](expr) -- boolean negation
89
+ └── BinaryOp[A, B, C] -- abstract trait for binary operations
90
+ ├── Relational[A, B](left, right, operator) -- comparison operations
91
+ ├── Logical[A](left, right, operator) -- boolean operations
92
+ ├── Arithmetic[S, A](left, right, operator, isNumeric) -- numeric operations
93
+ └── StringConcat[A](left, right) -- string concatenation
94
+
95
+ RelationalOperator
96
+ ├── LessThan
97
+ ├── GreaterThan
98
+ ├── LessThanOrEqual
99
+ ├── GreaterThanOrEqual
100
+ ├── Equal
101
+ └── NotEqual
102
+
103
+ LogicalOperator
104
+ ├── And
105
+ └── Or
106
+
107
+ ArithmeticOperator
108
+ ├── Add
109
+ ├── Subtract
110
+ └── Multiply
111
+ ```
112
+
113
+ Each case carries enough information to produce SQL: `Optic` nodes carry field paths, `Literal` nodes carry values, and operator nodes carry the operation type. Our interpreter walks this tree and emits SQL fragments.
114
+
115
+ ## Extracting Column Names from Optics
116
+
117
+ The first challenge is turning a reified optic into a SQL column name. Every `Optic[S, A]` has a `toDynamic` method that returns a `DynamicOptic` -- a sequence of path nodes. For a lens like `Product.price`, the path is `[Field("price")]`. We extract the field name from the last `Field` node:
118
+
119
+ ```scala
120
+ def columnName(optic: zio.blocks.schema.Optic[?, ?]): String = {
121
+ val nodes = optic.toDynamic.nodes
122
+ nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
123
+ }
124
+ ```
125
+
126
+ This converts the optic path to a column name. For a simple field like `Product.price`, it produces `"price"`. For a nested path, it joins field names with underscores (we will refine this for table-qualified names later).
127
+
128
+ ```scala
129
+ columnName(Product.price)
130
+ // res0: String = "price"
131
+ columnName(Product.name)
132
+ // res1: String = "name"
133
+ columnName(Product.category)
134
+ // res2: String = "category"
135
+ ```
136
+
137
+ ## Translating Literals to SQL
138
+
139
+ Literal values need proper SQL formatting -- strings must be quoted, booleans converted to SQL syntax:
140
+
141
+ ```scala
142
+ def sqlLiteral(value: Any): String = value match {
143
+ case s: String => s"'${s.replace("'", "''")}'"
144
+ case b: Boolean => if (b) "TRUE" else "FALSE"
145
+ case n: Number => n.toString
146
+ case other => other.toString
147
+ }
148
+ ```
149
+
150
+ ## Building the SQL Interpreter
151
+
152
+ Now we build the core interpreter. It pattern-matches on each `SchemaExpr` case and produces a SQL string:
153
+
154
+ ```scala
155
+ def toSql[A, B](expr: SchemaExpr[A, B]): String = expr match {
156
+
157
+ // Field reference → column name
158
+ case SchemaExpr.Optic(optic) =>
159
+ columnName(optic)
160
+
161
+ // Constant value → SQL literal
162
+ case SchemaExpr.Literal(value, _) =>
163
+ sqlLiteral(value)
164
+
165
+ // Comparison operators → SQL relational operators
166
+ case SchemaExpr.Relational(left, right, op) =>
167
+ val sqlOp = op match {
168
+ case SchemaExpr.RelationalOperator.Equal => "="
169
+ case SchemaExpr.RelationalOperator.NotEqual => "<>"
170
+ case SchemaExpr.RelationalOperator.LessThan => "<"
171
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
172
+ case SchemaExpr.RelationalOperator.GreaterThan => ">"
173
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
174
+ }
175
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
176
+
177
+ // Boolean operators → AND / OR
178
+ case SchemaExpr.Logical(left, right, op) =>
179
+ val sqlOp = op match {
180
+ case SchemaExpr.LogicalOperator.And => "AND"
181
+ case SchemaExpr.LogicalOperator.Or => "OR"
182
+ }
183
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
184
+
185
+ // Negation → NOT
186
+ case SchemaExpr.Not(inner) =>
187
+ s"NOT (${toSql(inner)})"
188
+
189
+ // Arithmetic → SQL math operators
190
+ case SchemaExpr.Arithmetic(left, right, op, _) =>
191
+ val sqlOp = op match {
192
+ case SchemaExpr.ArithmeticOperator.Add => "+"
193
+ case SchemaExpr.ArithmeticOperator.Subtract => "-"
194
+ case SchemaExpr.ArithmeticOperator.Multiply => "*"
195
+ }
196
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
197
+
198
+ // String concatenation → CONCAT()
199
+ case SchemaExpr.StringConcat(left, right) =>
200
+ s"CONCAT(${toSql(left)}, ${toSql(right)})"
201
+
202
+ // Regex match → column LIKE pattern (simplified)
203
+ case SchemaExpr.StringRegexMatch(regex, string) =>
204
+ s"(${toSql(string)} LIKE ${toSql(regex)})"
205
+
206
+ // String length → LENGTH()
207
+ case SchemaExpr.StringLength(string) =>
208
+ s"LENGTH(${toSql(string)})"
209
+ }
210
+ ```
211
+
212
+ The mapping from `SchemaExpr` to SQL is direct:
213
+
214
+ | SchemaExpr Case | SQL Output |
215
+ |---------------------|--------------------------------------|
216
+ | `Optic(optic)` | Column name from `toDynamic` |
217
+ | `Literal(v, _)` | SQL literal (`'text'`, `42`, `TRUE`) |
218
+ | `Relational(_, _, op)` | `=`, `<>`, `<`, `>`, `<=`, `>=` |
219
+ | `Logical(_, _, op)` | `AND`, `OR` |
220
+ | `Not(expr)` | `NOT (...)` |
221
+ | `Arithmetic(_, _, op, _)` | `+`, `-`, `*` |
222
+ | `StringConcat` | `CONCAT(a, b)` |
223
+ | `StringRegexMatch` | `LIKE` (pattern matching) |
224
+ | `StringLength` | `LENGTH(col)` |
225
+
226
+ ## Generating SQL from Queries
227
+
228
+ Now we can translate any query expression into a SQL WHERE clause. Let's try it with the queries from Part 1:
229
+
230
+ ```scala
231
+ val isElectronics = Product.category === "Electronics"
232
+ val expensiveItems = Product.price > 100.0
233
+ val highRated = Product.rating >= 4
234
+ ```
235
+
236
+ ```scala
237
+ toSql(isElectronics)
238
+ // res3: String = "(category = 'Electronics')"
239
+ toSql(expensiveItems)
240
+ // res4: String = "(price > 100.0)"
241
+ toSql(highRated)
242
+ // res5: String = "(rating >= 4)"
243
+ ```
244
+
245
+ ## Compound Queries
246
+
247
+ Boolean combinators translate to `AND`, `OR`, and `NOT`:
248
+
249
+ ```scala
250
+ val affordableElectronics =
251
+ (Product.category === "Electronics") && (Product.price < 500.0)
252
+
253
+ val goodDeal =
254
+ (Product.price < 10.0) || (Product.rating >= 5)
255
+
256
+ val outOfStock = !Product.inStock
257
+ ```
258
+
259
+ ```scala
260
+ toSql(affordableElectronics)
261
+ // res6: String = "((category = 'Electronics') AND (price < 500.0))"
262
+ toSql(goodDeal)
263
+ // res7: String = "((price < 10.0) OR (rating >= 5))"
264
+ toSql(outOfStock)
265
+ // res8: String = "NOT (inStock)"
266
+ ```
267
+
268
+ Complex nested queries compose naturally:
269
+
270
+ ```scala
271
+ val complexQuery =
272
+ ((Product.category === "Electronics") && (Product.price < 500.0)) ||
273
+ ((Product.category === "Office") && (Product.rating >= 4))
274
+ ```
275
+
276
+ ```scala
277
+ toSql(complexQuery)
278
+ // res9: String = "(((category = 'Electronics') AND (price < 500.0)) OR ((category = 'Office') AND (rating >= 4)))"
279
+ ```
280
+
281
+ ## Arithmetic in SQL
282
+
283
+ Arithmetic expressions translate directly to SQL math:
284
+
285
+ ```scala
286
+ val discountedPrice = Product.price * 0.9
287
+ val priceWithTax = Product.price * 1.08
288
+ ```
289
+
290
+ ```scala
291
+ toSql(discountedPrice)
292
+ // res10: String = "(price * 0.9)"
293
+ toSql(priceWithTax)
294
+ // res11: String = "(price * 1.08)"
295
+ ```
296
+
297
+ ## String Operations in SQL
298
+
299
+ String operations map to SQL string functions:
300
+
301
+ ```scala
302
+ // Regex match → LIKE
303
+ val startsWithL = Product.name.matches("L%")
304
+
305
+ // Concatenation → CONCAT()
306
+ val labeledName = Product.name.concat(" [SALE]")
307
+
308
+ // String length → LENGTH()
309
+ val nameLength = Product.name.length
310
+ ```
311
+
312
+ ```scala
313
+ toSql(startsWithL)
314
+ // res12: String = "(name LIKE 'L%')"
315
+ toSql(labeledName)
316
+ // res13: String = "CONCAT(name, ' [SALE]')"
317
+ toSql(nameLength)
318
+ // res14: String = "LENGTH(name)"
319
+ ```
320
+
321
+ :::tip
322
+ The `matches` operator uses regex syntax in the `SchemaExpr` evaluator, but SQL's `LIKE` uses `%` and `_` wildcards. When building queries intended for SQL, use SQL-style patterns (`L%` instead of `L.*`). If you need full regex support, replace the `LIKE` translation with your database's regex function (e.g., `REGEXP` in MySQL, `~` in PostgreSQL).
323
+ :::
324
+
325
+ ## Building Complete SELECT Statements
326
+
327
+ With the `toSql` interpreter, building complete SQL statements is straightforward:
328
+
329
+ ```scala
330
+ def select(table: String, predicate: SchemaExpr[?, Boolean]): String =
331
+ s"SELECT * FROM $table WHERE ${toSql(predicate)}"
332
+
333
+ def selectColumns(table: String, columns: List[String], predicate: SchemaExpr[?, Boolean]): String =
334
+ s"SELECT ${columns.mkString(", ")} FROM $table WHERE ${toSql(predicate)}"
335
+
336
+ def selectWithLimit(
337
+ table: String,
338
+ predicate: SchemaExpr[?, Boolean],
339
+ orderBy: Option[String] = None,
340
+ limit: Option[Int] = None
341
+ ): String = {
342
+ val base = s"SELECT * FROM $table WHERE ${toSql(predicate)}"
343
+ val ordered = orderBy.fold(base)(col => s"$base ORDER BY $col")
344
+ limit.fold(ordered)(n => s"$ordered LIMIT $n")
345
+ }
346
+ ```
347
+
348
+ ```scala
349
+ val query = (Product.category === "Electronics") && (Product.inStock === true) && (Product.price < 500.0)
350
+ ```
351
+
352
+ ```scala
353
+ select("products", query)
354
+ // res15: String = "SELECT * FROM products WHERE (((category = 'Electronics') AND (inStock = TRUE)) AND (price < 500.0))"
355
+
356
+ selectColumns("products", List("name", "price"), query)
357
+ // res16: String = "SELECT name, price FROM products WHERE (((category = 'Electronics') AND (inStock = TRUE)) AND (price < 500.0))"
358
+
359
+ selectWithLimit("products", query, orderBy = Some("price ASC"), limit = Some(10))
360
+ // res17: String = "SELECT * FROM products WHERE (((category = 'Electronics') AND (inStock = TRUE)) AND (price < 500.0)) ORDER BY price ASC LIMIT 10"
361
+ ```
362
+
363
+ ## Parameterized Queries
364
+
365
+ The `toSql` function above inlines literal values directly into the SQL string. For production use, you need parameterized queries to prevent SQL injection. We modify the interpreter to collect parameters separately:
366
+
367
+ ```scala
368
+ case class SqlQuery(sql: String, params: List[Any])
369
+
370
+ def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = expr match {
371
+
372
+ case SchemaExpr.Optic(optic) =>
373
+ SqlQuery(columnName(optic), Nil)
374
+
375
+ case SchemaExpr.Literal(value, _) =>
376
+ SqlQuery("?", List(value))
377
+
378
+ case SchemaExpr.Relational(left, right, op) =>
379
+ val l = toParameterized(left)
380
+ val r = toParameterized(right)
381
+ val sqlOp = op match {
382
+ case SchemaExpr.RelationalOperator.Equal => "="
383
+ case SchemaExpr.RelationalOperator.NotEqual => "<>"
384
+ case SchemaExpr.RelationalOperator.LessThan => "<"
385
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
386
+ case SchemaExpr.RelationalOperator.GreaterThan => ">"
387
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
388
+ }
389
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
390
+
391
+ case SchemaExpr.Logical(left, right, op) =>
392
+ val l = toParameterized(left)
393
+ val r = toParameterized(right)
394
+ val sqlOp = op match {
395
+ case SchemaExpr.LogicalOperator.And => "AND"
396
+ case SchemaExpr.LogicalOperator.Or => "OR"
397
+ }
398
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
399
+
400
+ case SchemaExpr.Not(inner) =>
401
+ val i = toParameterized(inner)
402
+ SqlQuery(s"NOT (${i.sql})", i.params)
403
+
404
+ case SchemaExpr.Arithmetic(left, right, op, _) =>
405
+ val l = toParameterized(left)
406
+ val r = toParameterized(right)
407
+ val sqlOp = op match {
408
+ case SchemaExpr.ArithmeticOperator.Add => "+"
409
+ case SchemaExpr.ArithmeticOperator.Subtract => "-"
410
+ case SchemaExpr.ArithmeticOperator.Multiply => "*"
411
+ }
412
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
413
+
414
+ case SchemaExpr.StringConcat(left, right) =>
415
+ val l = toParameterized(left)
416
+ val r = toParameterized(right)
417
+ SqlQuery(s"CONCAT(${l.sql}, ${r.sql})", l.params ++ r.params)
418
+
419
+ case SchemaExpr.StringRegexMatch(regex, string) =>
420
+ val s = toParameterized(string)
421
+ val r = toParameterized(regex)
422
+ SqlQuery(s"(${s.sql} LIKE ${r.sql})", s.params ++ r.params)
423
+
424
+ case SchemaExpr.StringLength(string) =>
425
+ val s = toParameterized(string)
426
+ SqlQuery(s"LENGTH(${s.sql})", s.params)
427
+ }
428
+ ```
429
+
430
+ Now literals become `?` placeholders, with the actual values collected in a parameter list:
431
+
432
+ ```scala
433
+ val q = (Product.category === "Electronics") && (Product.price < 500.0) && (Product.rating >= 4)
434
+ val paramQuery = toParameterized(q)
435
+ ```
436
+
437
+ ```scala
438
+ paramQuery.sql
439
+ // res18: String = "(((category = ?) AND (price < ?)) AND (rating >= ?))"
440
+ paramQuery.params
441
+ // res19: List[Any] = List("Electronics", 500.0, 4)
442
+ ```
443
+
444
+ You can use this with JDBC's `PreparedStatement`:
445
+
446
+ ```scala
447
+ val ps = connection.prepareStatement(s"SELECT * FROM products WHERE ${paramQuery.sql}")
448
+ paramQuery.params.zipWithIndex.foreach { case (value, idx) =>
449
+ value match {
450
+ case s: String => ps.setString(idx + 1, s)
451
+ case d: Double => ps.setDouble(idx + 1, d)
452
+ case i: Int => ps.setInt(idx + 1, i)
453
+ case b: Boolean => ps.setBoolean(idx + 1, b)
454
+ case l: Long => ps.setLong(idx + 1, l)
455
+ }
456
+ }
457
+ val rs = ps.executeQuery()
458
+ ```
459
+
460
+ :::warning
461
+ Always use parameterized queries for user-supplied values. The inline `toSql` function is suitable for logging and debugging, but use `toParameterized` for actual database execution.
462
+ :::
463
+
464
+ ## Nested Structures and Table-Qualified Columns
465
+
466
+ When domain types have nested structures, optic paths contain multiple `Field` nodes. For SQL, these often map to JOIN-based queries with table-qualified column names.
467
+
468
+ ```scala
469
+ import zio.blocks.schema._
470
+
471
+ case class Address(city: String, country: String)
472
+ object Address {
473
+ implicit val schema: Schema[Address] = Schema.derived
474
+ }
475
+
476
+ case class Seller(name: String, address: Address, rating: Double)
477
+ object Seller extends CompanionOptics[Seller] {
478
+ implicit val schema: Schema[Seller] = Schema.derived
479
+
480
+ val name: Lens[Seller, String] = optic(_.name)
481
+ val rating: Lens[Seller, Double] = optic(_.rating)
482
+ val city: Lens[Seller, String] = optic(_.address.city)
483
+ val country: Lens[Seller, String] = optic(_.address.country)
484
+ }
485
+ ```
486
+
487
+ The lens `Seller.city` has the path `[Field("address"), Field("city")]`. We can translate multi-segment paths into table-qualified column names:
488
+
489
+ ```scala
490
+ def qualifiedColumnName(optic: zio.blocks.schema.Optic[?, ?]): String = {
491
+ val fields = optic.toDynamic.nodes.collect {
492
+ case f: DynamicOptic.Node.Field => f.name
493
+ }
494
+ // Single field: use as-is. Multiple fields: table.column convention
495
+ if (fields.length <= 1) fields.mkString
496
+ else s"${fields.init.mkString("_")}.${fields.last}"
497
+ }
498
+ ```
499
+
500
+ ```scala
501
+ qualifiedColumnName(Seller.name)
502
+ // res21: String = "name"
503
+ qualifiedColumnName(Seller.city)
504
+ // res22: String = "address.city"
505
+ qualifiedColumnName(Seller.country)
506
+ // res23: String = "address.country"
507
+ ```
508
+
509
+ This produces `address.city` for nested fields, which maps naturally to a SQL JOIN:
510
+
511
+ ```sql
512
+ SELECT sellers.*, address.city, address.country
513
+ FROM sellers
514
+ JOIN addresses AS address ON sellers.id = address.seller_id
515
+ WHERE address.city = 'Berlin' AND sellers.rating >= 4.0
516
+ ```
517
+
518
+ To generate full JOIN queries, you would extend the interpreter to inspect the optic paths, detect multi-segment paths, and emit appropriate JOIN clauses. The path structure from `DynamicOptic` gives you all the information needed.
519
+
520
+ ## Putting It Together
521
+
522
+ Here is a complete, self-contained example that defines a domain, builds queries, and generates both inline SQL and parameterized queries:
523
+
524
+ ```scala
525
+ import zio.blocks.schema._
526
+
527
+ // --- Domain ---
528
+
529
+ case class Product(
530
+ name: String,
531
+ price: Double,
532
+ category: String,
533
+ inStock: Boolean,
534
+ rating: Int
535
+ )
536
+
537
+ object Product extends CompanionOptics[Product] {
538
+ implicit val schema: Schema[Product] = Schema.derived
539
+
540
+ val name: Lens[Product, String] = optic(_.name)
541
+ val price: Lens[Product, Double] = optic(_.price)
542
+ val category: Lens[Product, String] = optic(_.category)
543
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
544
+ val rating: Lens[Product, Int] = optic(_.rating)
545
+ }
546
+
547
+ // --- SQL Interpreter ---
548
+
549
+ def columnName(optic: zio.blocks.schema.Optic[?, ?]): String =
550
+ optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
551
+
552
+ def sqlLiteral(value: Any): String = value match {
553
+ case s: String => s"'${s.replace("'", "''")}'"
554
+ case b: Boolean => if (b) "TRUE" else "FALSE"
555
+ case n: Number => n.toString
556
+ case other => other.toString
557
+ }
558
+
559
+ def toSql[A, B](expr: SchemaExpr[A, B]): String = expr match {
560
+ case SchemaExpr.Optic(optic) => columnName(optic)
561
+ case SchemaExpr.Literal(value, _) => sqlLiteral(value)
562
+ case SchemaExpr.Relational(left, right, op) =>
563
+ val sqlOp = op match {
564
+ case SchemaExpr.RelationalOperator.Equal => "="
565
+ case SchemaExpr.RelationalOperator.NotEqual => "<>"
566
+ case SchemaExpr.RelationalOperator.LessThan => "<"
567
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
568
+ case SchemaExpr.RelationalOperator.GreaterThan => ">"
569
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
570
+ }
571
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
572
+ case SchemaExpr.Logical(left, right, op) =>
573
+ val sqlOp = op match {
574
+ case SchemaExpr.LogicalOperator.And => "AND"
575
+ case SchemaExpr.LogicalOperator.Or => "OR"
576
+ }
577
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
578
+ case SchemaExpr.Not(inner) => s"NOT (${toSql(inner)})"
579
+ case SchemaExpr.Arithmetic(left, right, op, _) =>
580
+ val sqlOp = op match {
581
+ case SchemaExpr.ArithmeticOperator.Add => "+"
582
+ case SchemaExpr.ArithmeticOperator.Subtract => "-"
583
+ case SchemaExpr.ArithmeticOperator.Multiply => "*"
584
+ }
585
+ s"(${toSql(left)} $sqlOp ${toSql(right)})"
586
+ case SchemaExpr.StringConcat(left, right) => s"CONCAT(${toSql(left)}, ${toSql(right)})"
587
+ case SchemaExpr.StringRegexMatch(regex, string) => s"(${toSql(string)} LIKE ${toSql(regex)})"
588
+ case SchemaExpr.StringLength(string) => s"LENGTH(${toSql(string)})"
589
+ }
590
+
591
+ // --- Parameterized queries ---
592
+
593
+ case class SqlQuery(sql: String, params: List[Any])
594
+
595
+ def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = expr match {
596
+ case SchemaExpr.Optic(optic) => SqlQuery(columnName(optic), Nil)
597
+ case SchemaExpr.Literal(value, _) => SqlQuery("?", List(value))
598
+ case SchemaExpr.Relational(left, right, op) =>
599
+ val l = toParameterized(left); val r = toParameterized(right)
600
+ val sqlOp = op match {
601
+ case SchemaExpr.RelationalOperator.Equal => "="
602
+ case SchemaExpr.RelationalOperator.NotEqual => "<>"
603
+ case SchemaExpr.RelationalOperator.LessThan => "<"
604
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
605
+ case SchemaExpr.RelationalOperator.GreaterThan => ">"
606
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
607
+ }
608
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
609
+ case SchemaExpr.Logical(left, right, op) =>
610
+ val l = toParameterized(left); val r = toParameterized(right)
611
+ val sqlOp = op match {
612
+ case SchemaExpr.LogicalOperator.And => "AND"
613
+ case SchemaExpr.LogicalOperator.Or => "OR"
614
+ }
615
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
616
+ case SchemaExpr.Not(inner) =>
617
+ val i = toParameterized(inner)
618
+ SqlQuery(s"NOT (${i.sql})", i.params)
619
+ case SchemaExpr.Arithmetic(left, right, op, _) =>
620
+ val l = toParameterized(left); val r = toParameterized(right)
621
+ val sqlOp = op match {
622
+ case SchemaExpr.ArithmeticOperator.Add => "+"
623
+ case SchemaExpr.ArithmeticOperator.Subtract => "-"
624
+ case SchemaExpr.ArithmeticOperator.Multiply => "*"
625
+ }
626
+ SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
627
+ case SchemaExpr.StringConcat(left, right) =>
628
+ val l = toParameterized(left); val r = toParameterized(right)
629
+ SqlQuery(s"CONCAT(${l.sql}, ${r.sql})", l.params ++ r.params)
630
+ case SchemaExpr.StringRegexMatch(regex, string) =>
631
+ val s = toParameterized(string); val r = toParameterized(regex)
632
+ SqlQuery(s"(${s.sql} LIKE ${r.sql})", s.params ++ r.params)
633
+ case SchemaExpr.StringLength(string) =>
634
+ val s = toParameterized(string)
635
+ SqlQuery(s"LENGTH(${s.sql})", s.params)
636
+ }
637
+
638
+ // --- Complete SELECT builder ---
639
+
640
+ def select(table: String, predicate: SchemaExpr[?, Boolean]): String =
641
+ s"SELECT * FROM $table WHERE ${toSql(predicate)}"
642
+
643
+ // --- Usage ---
644
+
645
+ val query =
646
+ (Product.category === "Electronics") &&
647
+ (Product.inStock === true) &&
648
+ (Product.price < 500.0) &&
649
+ (Product.rating >= 4)
650
+
651
+ // Inline SQL for debugging
652
+ println(select("products", query))
653
+ // SELECT * FROM products WHERE (((category = 'Electronics') AND (inStock = TRUE)) AND (price < 500.0)) AND (rating >= 4))
654
+
655
+ // Parameterized SQL for execution
656
+ val pq = toParameterized(query)
657
+ println(s"SQL: ${pq.sql}")
658
+ println(s"Params: ${pq.params}")
659
+ // SQL: (((category = ?) AND (inStock = ?)) AND (price < ?)) AND (rating >= ?))
660
+ // Params: List(Electronics, true, 500.0, 4)
661
+
662
+ // String operations in SQL
663
+ println(toSql(Product.name.matches("L%")))
664
+ // (name LIKE 'L%')
665
+
666
+ // Arithmetic in SQL
667
+ println(toSql(Product.price * 0.9))
668
+ // (price * 0.9)
669
+ ```
670
+
671
+ ## Going Further
672
+
673
+ - **[Part 1: Expressions](./query-dsl-reified-optics.md)** -- Building query expressions with reified optics
674
+ - **[Part 3: Extending the Expression Language](./query-dsl-extending.md)** -- Adding custom operators (IN, BETWEEN, aggregates) beyond SchemaExpr
675
+ - **[Part 4: A Fluent SQL Builder](./query-dsl-fluent-builder.md)** -- Type-safe SELECT, UPDATE, INSERT, DELETE with seamless condition mixing
676
+ - **[SchemaExpr Reference](../reference/schema-expr.md)** -- Full API coverage of expression types
677
+ - **[Optics Reference](../reference/optics.md)** -- Lens, Prism, Optional, and Traversal
678
+ - **[DynamicOptic Reference](../reference/dynamic-optic.md)** -- Runtime optic paths for programmatic field extraction
679
+
680
+ The interpreter pattern shown here extends naturally to other query targets. Because `SchemaExpr` is a sealed trait and `DynamicOptic` carries full path metadata, you can write interpreters for MongoDB filters, Elasticsearch queries, GraphQL filters, or any other query language using the same approach: pattern match on the AST, map operators, and extract field names from optic paths.