@zio.dev/zio-blocks 0.0.22 → 0.0.25

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,1287 @@
1
+ ---
2
+ id: query-dsl-fluent-builder
3
+ title: "Query DSL with Reified Optics — Part 4: A Fluent SQL Builder"
4
+ ---
5
+
6
+ In this guide, we will build a fluent, type-safe SQL statement builder on top of the query expression language from Parts 1–3. By the end, you will have builder functions for SELECT, UPDATE, INSERT, and DELETE that compose naturally with both built-in `SchemaExpr` operators and extended `Expr` operators — no manual `.toExpr` lifting required.
7
+
8
+ We'll take an incremental approach: starting with seamless condition composition, then adding table references and fluent statement builders for each SQL operation until we have a complete, native-feeling SQL DSL.
9
+
10
+ This is Part 4 of the Query DSL series. [Part 1](./query-dsl-reified-optics.md) covered expressions, [Part 2](./query-dsl-sql.md) covered SQL generation, and [Part 3](./query-dsl-extending.md) covered extending the expression language.
11
+
12
+ **What we'll cover:**
13
+
14
+ - Bridging `SchemaExpr` and `Expr` for seamless `&&` / `||` composition
15
+ - Defining table references with `Table[S]`
16
+ - Deriving table names from `Schema` metadata via `Modifier.config` and auto-pluralization
17
+ - Building fluent SELECT queries with `.columns()`, `.where()`, `.orderBy()`, `.limit()`
18
+ - Building UPDATE statements with `.set()` and `.where()`
19
+ - Building INSERT and DELETE statements
20
+ - Rendering all statement types to SQL strings
21
+
22
+ ## The Problem
23
+
24
+ Parts 1–3 gave us a powerful expression language for building WHERE clauses. But constructing full SQL statements still requires string concatenation:
25
+
26
+ ```scala
27
+ val whereSql = exprToSql(myCondition)
28
+ val sql = s"UPDATE products SET price = 9.99 WHERE $whereSql"
29
+ ```
30
+
31
+ Column names in SET clauses are hand-written strings that can drift from your schema, table names are repeated magic strings, and there is no compile-time connection between the statement structure and your domain types.
32
+
33
+ Additionally, mixing built-in `SchemaExpr` operators (`===`, `>=`) with extended `Expr` operators (`between`, `like`) required explicit `.toExpr` calls in Part 3:
34
+
35
+ ```scala
36
+ // Part 3: explicit .toExpr required
37
+ val q = (Product.rating >= 4).toExpr && Product.price.between(10.0, 500.0)
38
+ ```
39
+
40
+ In a fluent builder, this friction breaks the flow. We want to write:
41
+
42
+ ```scala
43
+ update(Product.table)
44
+ .set(Product.price, 9.99)
45
+ .where(
46
+ Product.category === "Books" &&
47
+ Product.rating >= 4 &&
48
+ Product.price.between(10.0, 30.0) &&
49
+ Product.name.like("M%")
50
+ )
51
+ ```
52
+
53
+ In this guide, we solve both problems: bridge extensions eliminate `.toExpr`, and type-safe statement builders eliminate string manipulation for SQL construction.
54
+
55
+ ## Prerequisites
56
+
57
+ This guide builds on [Part 1: Expressions](./query-dsl-reified-optics.md), [Part 2: SQL Generation](./query-dsl-sql.md), and [Part 3: Extending the Expression Language](./query-dsl-extending.md).
58
+
59
+ ```scala
60
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.25"
61
+ ```
62
+
63
+ ## Domain Setup
64
+
65
+ We carry forward the product catalog domain and the Part 3 independent `Expr` ADT. The key additions in Part 4 are bridge extension methods, schema-driven table names, and statement builder types. The `Expr` ADT used here is the same independent design from Part 3.
66
+
67
+ ```scala
68
+ import zio.blocks.schema._
69
+
70
+ // --- Table reference ---
71
+
72
+ case class Table[S](name: String)
73
+
74
+ object Table {
75
+ def derived[S](implicit schema: Schema[S]): Table[S] = Table(tableName(schema))
76
+ }
77
+
78
+ // --- Table name derivation ---
79
+
80
+ def tableName[S](schema: Schema[S]): String =
81
+ schema.reflect.modifiers.collectFirst {
82
+ case Modifier.config(key, value) if key == "sql.table_name" => value
83
+ }.getOrElse(pluralize(schema.reflect.typeId.name.toLowerCase))
84
+
85
+ def pluralize(word: String): String =
86
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") ||
87
+ word.endsWith("ch") || word.endsWith("sh")) word + "es"
88
+ else if (word.endsWith("y") && !word.endsWith("ay") && !word.endsWith("ey") &&
89
+ !word.endsWith("oy") && !word.endsWith("uy")) word.dropRight(1) + "ies"
90
+ else word + "s"
91
+
92
+ // --- Domain ---
93
+
94
+ case class Product(
95
+ name: String,
96
+ price: Double,
97
+ category: String,
98
+ inStock: Boolean,
99
+ rating: Int
100
+ )
101
+
102
+ object Product extends CompanionOptics[Product] {
103
+ implicit val schema: Schema[Product] = Schema.derived
104
+
105
+ val table: Table[Product] = Table.derived // "Product" → "products"
106
+
107
+ val name: Lens[Product, String] = optic(_.name)
108
+ val price: Lens[Product, Double] = optic(_.price)
109
+ val category: Lens[Product, String] = optic(_.category)
110
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
111
+ val rating: Lens[Product, Int] = optic(_.rating)
112
+ }
113
+
114
+ case class OrderItem(
115
+ orderId: Int,
116
+ productId: Int,
117
+ quantity: Int,
118
+ unitPrice: Double
119
+ )
120
+
121
+ object OrderItem extends CompanionOptics[OrderItem] {
122
+ implicit val schema: Schema[OrderItem] = Schema.derived
123
+ .modifier(Modifier.config("sql.table_name", "order_items"))
124
+
125
+ val table: Table[OrderItem] = Table.derived // annotation → "order_items"
126
+
127
+ val orderId: Lens[OrderItem, Int] = optic(_.orderId)
128
+ val productId: Lens[OrderItem, Int] = optic(_.productId)
129
+ val quantity: Lens[OrderItem, Int] = optic(_.quantity)
130
+ val unitPrice: Lens[OrderItem, Double] = optic(_.unitPrice)
131
+ }
132
+
133
+ // --- Expr ADT (from Part 3) ---
134
+
135
+ sealed trait Expr[S, A]
136
+
137
+ object Expr {
138
+ final case class Column[S, A](optic: Optic[S, A]) extends Expr[S, A]
139
+ final case class Lit[S, A](value: A, schema: Schema[A]) extends Expr[S, A]
140
+
141
+ final case class Relational[S, A](left: Expr[S, A], right: Expr[S, A], op: RelOp) extends Expr[S, Boolean]
142
+ final case class And[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
143
+ final case class Or[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
144
+ final case class Not[S](expr: Expr[S, Boolean]) extends Expr[S, Boolean]
145
+ final case class Arithmetic[S, A](left: Expr[S, A], right: Expr[S, A], op: ArithOp) extends Expr[S, A]
146
+ final case class StringConcat[S](left: Expr[S, String], right: Expr[S, String]) extends Expr[S, String]
147
+ final case class StringRegexMatch[S](regex: Expr[S, String], string: Expr[S, String]) extends Expr[S, Boolean]
148
+ final case class StringLength[S](string: Expr[S, String]) extends Expr[S, Int]
149
+
150
+ final case class In[S, A](expr: Expr[S, A], values: List[A], schema: Schema[A]) extends Expr[S, Boolean]
151
+ final case class Between[S, A](expr: Expr[S, A], low: A, high: A, schema: Schema[A]) extends Expr[S, Boolean]
152
+ final case class IsNull[S, A](expr: Expr[S, A]) extends Expr[S, Boolean]
153
+ final case class Like[S](expr: Expr[S, String], pattern: String) extends Expr[S, Boolean]
154
+
155
+ def col[S, A](optic: Optic[S, A]): Expr[S, A] = Column(optic)
156
+ def lit[S, A](value: A)(implicit schema: Schema[A]): Expr[S, A] = Lit(value, schema)
157
+
158
+ def fromSchemaExpr[S, A](se: SchemaExpr[S, A]): Expr[S, A] = {
159
+ val result = se match {
160
+ case SchemaExpr.Optic(optic) => Column(optic)
161
+ case l: SchemaExpr.Literal[_, _] => Lit(l.value, l.schema)
162
+ case SchemaExpr.Relational(l, r, op) =>
163
+ val relOp = op match {
164
+ case SchemaExpr.RelationalOperator.Equal => RelOp.Equal
165
+ case SchemaExpr.RelationalOperator.NotEqual => RelOp.NotEqual
166
+ case SchemaExpr.RelationalOperator.LessThan => RelOp.LessThan
167
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => RelOp.LessThanOrEqual
168
+ case SchemaExpr.RelationalOperator.GreaterThan => RelOp.GreaterThan
169
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => RelOp.GreaterThanOrEqual
170
+ }
171
+ Relational(fromSchemaExpr(l), fromSchemaExpr(r), relOp)
172
+ case SchemaExpr.Logical(l, r, op) => op match {
173
+ case SchemaExpr.LogicalOperator.And => And(fromSchemaExpr(l), fromSchemaExpr(r))
174
+ case SchemaExpr.LogicalOperator.Or => Or(fromSchemaExpr(l), fromSchemaExpr(r))
175
+ }
176
+ case SchemaExpr.Not(inner) => Not(fromSchemaExpr(inner))
177
+ case SchemaExpr.Arithmetic(l, r, op, _) =>
178
+ val arithOp = op match {
179
+ case SchemaExpr.ArithmeticOperator.Add => ArithOp.Add
180
+ case SchemaExpr.ArithmeticOperator.Subtract => ArithOp.Subtract
181
+ case SchemaExpr.ArithmeticOperator.Multiply => ArithOp.Multiply
182
+ }
183
+ Arithmetic(fromSchemaExpr(l), fromSchemaExpr(r), arithOp)
184
+ case SchemaExpr.StringConcat(l, r) => StringConcat(fromSchemaExpr(l), fromSchemaExpr(r))
185
+ case SchemaExpr.StringRegexMatch(regex, string) => StringRegexMatch(fromSchemaExpr(regex), fromSchemaExpr(string))
186
+ case SchemaExpr.StringLength(string) => StringLength(fromSchemaExpr(string))
187
+ }
188
+ result.asInstanceOf[Expr[S, A]]
189
+ }
190
+ }
191
+
192
+ sealed trait RelOp
193
+ object RelOp {
194
+ case object Equal extends RelOp
195
+ case object NotEqual extends RelOp
196
+ case object LessThan extends RelOp
197
+ case object LessThanOrEqual extends RelOp
198
+ case object GreaterThan extends RelOp
199
+ case object GreaterThanOrEqual extends RelOp
200
+ }
201
+
202
+ sealed trait ArithOp
203
+ object ArithOp {
204
+ case object Add extends ArithOp
205
+ case object Subtract extends ArithOp
206
+ case object Multiply extends ArithOp
207
+ }
208
+
209
+ // --- SQL helpers ---
210
+
211
+ def columnName(optic: zio.blocks.schema.Optic[_, _]): String =
212
+ optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
213
+
214
+ def sqlLiteral[A](value: A, schema: Schema[A]): String = {
215
+ val dv = schema.toDynamicValue(value)
216
+ dv match {
217
+ case p: DynamicValue.Primitive => p.value match {
218
+ case _: PrimitiveValue.String => s"'${value.toString.replace("'", "''")}'"
219
+ case b: PrimitiveValue.Boolean => if (b.value) "TRUE" else "FALSE"
220
+ case _ => value.toString
221
+ }
222
+ case _ => value.toString
223
+ }
224
+ }
225
+
226
+ // --- Optic extension methods ---
227
+
228
+ implicit final class OpticExprOps[S, A](private val optic: Optic[S, A]) {
229
+ def in(values: A*)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.In(Expr.col(optic), values.toList, schema)
230
+ def between(low: A, high: A)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.Between(Expr.col(optic), low, high, schema)
231
+ def isNull: Expr[S, Boolean] = Expr.IsNull(Expr.col(optic))
232
+ def isNotNull: Expr[S, Boolean] = Expr.Not(Expr.IsNull(Expr.col(optic)))
233
+ }
234
+
235
+ implicit final class StringOpticExprOps[S](private val optic: Optic[S, String]) {
236
+ def like(pattern: String): Expr[S, Boolean] = Expr.Like(Expr.col(optic), pattern)
237
+ }
238
+
239
+ // --- Boolean combinators with bridge extensions ---
240
+
241
+ implicit final class ExprBooleanOps[S](private val self: Expr[S, Boolean]) {
242
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, other)
243
+ def &&(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, Expr.fromSchemaExpr(other))
244
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, other)
245
+ def ||(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, Expr.fromSchemaExpr(other))
246
+ def unary_! : Expr[S, Boolean] = Expr.Not(self)
247
+ }
248
+
249
+ implicit final class SchemaExprBooleanBridge[S](private val self: SchemaExpr[S, Boolean]) {
250
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(Expr.fromSchemaExpr(self), other)
251
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(Expr.fromSchemaExpr(self), other)
252
+ def toExpr: Expr[S, Boolean] = Expr.fromSchemaExpr(self)
253
+ }
254
+
255
+ // --- Single unified SQL interpreter ---
256
+
257
+ def exprToSql[S, A](expr: Expr[S, A]): String = expr match {
258
+ case Expr.Column(optic) => columnName(optic)
259
+ case Expr.Lit(value, schema) => sqlLiteral(value, schema)
260
+ case Expr.Relational(left, right, op) =>
261
+ val sqlOp = op match {
262
+ case RelOp.Equal => "="
263
+ case RelOp.NotEqual => "<>"
264
+ case RelOp.LessThan => "<"
265
+ case RelOp.LessThanOrEqual => "<="
266
+ case RelOp.GreaterThan => ">"
267
+ case RelOp.GreaterThanOrEqual => ">="
268
+ }
269
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
270
+ case Expr.And(l, r) => s"(${exprToSql(l)} AND ${exprToSql(r)})"
271
+ case Expr.Or(l, r) => s"(${exprToSql(l)} OR ${exprToSql(r)})"
272
+ case Expr.Not(e) => s"NOT (${exprToSql(e)})"
273
+ case Expr.Arithmetic(left, right, op) =>
274
+ val sqlOp = op match {
275
+ case ArithOp.Add => "+"
276
+ case ArithOp.Subtract => "-"
277
+ case ArithOp.Multiply => "*"
278
+ }
279
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
280
+ case Expr.StringConcat(l, r) => s"CONCAT(${exprToSql(l)}, ${exprToSql(r)})"
281
+ case Expr.StringRegexMatch(regex, s) => s"(${exprToSql(s)} LIKE ${exprToSql(regex)})"
282
+ case Expr.StringLength(s) => s"LENGTH(${exprToSql(s)})"
283
+ case Expr.In(e, values, schema) =>
284
+ s"${exprToSql(e)} IN (${values.map(v => sqlLiteral(v, schema)).mkString(", ")})"
285
+ case Expr.Between(e, low, high, schema) =>
286
+ s"(${exprToSql(e)} BETWEEN ${sqlLiteral(low, schema)} AND ${sqlLiteral(high, schema)})"
287
+ case Expr.IsNull(e) => s"${exprToSql(e)} IS NULL"
288
+ case Expr.Like(e, pattern) => s"${exprToSql(e)} LIKE '${pattern.replace("'", "''")}'"
289
+ }
290
+ ```
291
+
292
+ ## Seamless Condition Composition
293
+
294
+ In Part 3, composing `SchemaExpr` and `Expr` values required explicit `.toExpr` calls because `SchemaExpr.&&` is a direct method that only accepts other `SchemaExpr` values. Part 4 adds **bridge implicit classes** that handle the cross-type case automatically:
295
+
296
+ ```scala
297
+ // When SchemaExpr.&& receives an Expr argument, the direct method
298
+ // doesn't match (Expr is not SchemaExpr), so this implicit class kicks in:
299
+ implicit final class SchemaExprBooleanBridge[S](
300
+ private val self: SchemaExpr[S, Boolean]
301
+ ) extends AnyVal {
302
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = ...
303
+ }
304
+
305
+ // And vice versa — Expr.&& can accept SchemaExpr directly:
306
+ implicit final class ExprBooleanOps[S](
307
+ private val self: Expr[S, Boolean]
308
+ ) extends AnyVal {
309
+ def &&(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = ...
310
+ }
311
+ ```
312
+
313
+ Scala's implicit conversion resolution makes this work:
314
+
315
+ 1. **`SchemaExpr && SchemaExpr`** — the built-in direct method matches, returns `SchemaExpr`
316
+ 2. **`SchemaExpr && Expr`** — the direct method doesn't match, the bridge implicit class kicks in, returns `Expr`
317
+ 3. **`Expr && SchemaExpr`** — the bridge overload on `ExprBooleanOps` matches, returns `Expr`
318
+ 4. **`Expr && Expr`** — the standard `ExprBooleanOps.&&` overload matches, returns `Expr`
319
+
320
+ The result type automatically widens to `Expr` whenever an `Expr` value enters the chain. No `.toExpr` needed:
321
+
322
+ ```scala
323
+ val condition =
324
+ Product.price.between(10.0, 500.0) &&
325
+ (Product.category === "Electronics") &&
326
+ (Product.rating >= 4) &&
327
+ Product.name.like("L%")
328
+ // condition: Expr[Product, Boolean] = And(
329
+ // left = And(
330
+ // left = And(
331
+ // left = Between(
332
+ // expr = Column(
333
+ // LensImpl(
334
+ // sources = Array(
335
+ // Record(
336
+ // fields = Vector(
337
+ // Term(
338
+ // name = "name",
339
+ // value = Primitive(
340
+ // primitiveType = String(None),
341
+ // typeId = Impl(
342
+ // name = "String",
343
+ // owner = Owner(List(Package("java"), Package("lang"))),
344
+ // typeParams = List(),
345
+ // typeArgs = List(),
346
+ // defKind = Class(
347
+ // isFinal = true,
348
+ // isAbstract = false,
349
+ // isCase = false,
350
+ // isValue = false,
351
+ // bases = List(
352
+ // Ref(
353
+ // Impl(
354
+ // name = "CharSequence",
355
+ // owner = Owner(
356
+ // List(Package("java"), Package("lang"))
357
+ // ),
358
+ // typeParams = List(),
359
+ // typeArgs = List(),
360
+ // defKind = Trait(
361
+ // isSealed = false,
362
+ // bases = List()
363
+ // ),
364
+ // selfType = None,
365
+ // aliasedTo = None,
366
+ // representation = None,
367
+ // annotations = List()
368
+ // )
369
+ // ),
370
+ // Ref(
371
+ // Impl(
372
+ // name = "Comparable",
373
+ // owner = Owner(
374
+ // List(Package("java"), Package("lang"))
375
+ // ),
376
+ // typeParams = List(
377
+ // ...
378
+
379
+ exprToSql(condition)
380
+ // res0: String = "((((price BETWEEN 10.0 AND 500.0) AND (category = 'Electronics')) AND (rating >= 4)) AND name LIKE 'L%')"
381
+ ```
382
+
383
+ The first two `&&` calls stay in `SchemaExpr` land (direct method). The third `&&` encounters `between` (returns `Expr`), triggering the bridge. From that point on, everything is `Expr`.
384
+
385
+ ## Schema-Driven Table Names
386
+
387
+ The `Table[S]` type associates a SQL table name with a schema type. Rather than hand-writing each table name, `Table.derived` extracts it from `Schema` metadata:
388
+
389
+ ```scala
390
+ case class Table[S](name: String)
391
+
392
+ object Table {
393
+ def derived[S](implicit schema: Schema[S]): Table[S] = Table(tableName(schema))
394
+ }
395
+ ```
396
+
397
+ The `tableName` function checks the schema's modifiers for an explicit `sql.table_name` annotation. If none is found, it lowercases the case class name and applies English pluralization:
398
+
399
+ ```scala
400
+ def tableName[S](schema: Schema[S]): String =
401
+ schema.reflect.modifiers.collectFirst {
402
+ case Modifier.config(key, value) if key == "sql.table_name" => value
403
+ }.getOrElse(pluralize(schema.reflect.typeId.name.toLowerCase))
404
+ ```
405
+
406
+ This gives you two ways to control the table name:
407
+
408
+ **Auto-pluralization (zero configuration):**
409
+
410
+ ```scala
411
+ object Product extends CompanionOptics[Product] {
412
+ implicit val schema: Schema[Product] = Schema.derived
413
+ val table: Table[Product] = Table.derived // "Product" → "products"
414
+ }
415
+ ```
416
+
417
+ **Explicit annotation (for compound names or irregular plurals):**
418
+
419
+ ```scala
420
+ object OrderItem extends CompanionOptics[OrderItem] {
421
+ implicit val schema: Schema[OrderItem] = Schema.derived
422
+ .modifier(Modifier.config("sql.table_name", "order_items"))
423
+ val table: Table[OrderItem] = Table.derived // annotation → "order_items"
424
+ }
425
+ ```
426
+
427
+ Without the annotation, `OrderItem` would become `orderitems`. The `Modifier.config("sql.table_name", "order_items")` annotation gives you precise control when the default doesn't match your database convention.
428
+
429
+ ```scala
430
+ Product.table.name
431
+ // res1: String = "products"
432
+
433
+ OrderItem.table.name
434
+ // res2: String = "order_items"
435
+ ```
436
+
437
+ You can still use the explicit constructor `Table[S]("my_table")` when you need a name that doesn't come from a schema at all.
438
+
439
+ `Table[S]` is the entry point for every statement builder. The type parameter `S` connects the table to its domain type, ensuring you can only use `Product` lenses in queries against `Product.table`.
440
+
441
+ ## The SELECT Builder
442
+
443
+ The SELECT builder uses immutable case classes with `copy` for a fluent API:
444
+
445
+ ```scala
446
+ sealed trait SortOrder
447
+ object SortOrder {
448
+ case object Asc extends SortOrder
449
+ case object Desc extends SortOrder
450
+ }
451
+
452
+ case class SelectStmt[S](
453
+ table: Table[S],
454
+ columnList: List[String] = List("*"),
455
+ whereExpr: Option[Expr[S, Boolean]] = None,
456
+ orderByList: List[(String, SortOrder)] = Nil,
457
+ limitCount: Option[Int] = None
458
+ ) {
459
+ def columns(optics: Optic[S, _]*): SelectStmt[S] =
460
+ copy(columnList = optics.map(columnName).toList)
461
+ def where(cond: Expr[S, Boolean]): SelectStmt[S] =
462
+ copy(whereExpr = Some(cond))
463
+ def where(cond: SchemaExpr[S, Boolean]): SelectStmt[S] =
464
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
465
+ def orderBy(optic: Optic[S, _], order: SortOrder = SortOrder.Asc): SelectStmt[S] =
466
+ copy(orderByList = orderByList :+ (columnName(optic), order))
467
+ def limit(n: Int): SelectStmt[S] =
468
+ copy(limitCount = Some(n))
469
+ }
470
+
471
+ def select[S](table: Table[S]): SelectStmt[S] = SelectStmt(table)
472
+
473
+ def renderSelect[S](stmt: SelectStmt[S]): String = {
474
+ val cols = stmt.columnList.mkString(", ")
475
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
476
+ val orderBy = if (stmt.orderByList.isEmpty) "" else {
477
+ val orders = stmt.orderByList.map { case (col, order) =>
478
+ val dir = order match { case SortOrder.Asc => "ASC"; case SortOrder.Desc => "DESC" }
479
+ s"$col $dir"
480
+ }.mkString(", ")
481
+ s" ORDER BY $orders"
482
+ }
483
+ val limit = stmt.limitCount.map(n => s" LIMIT $n").getOrElse("")
484
+ s"SELECT $cols FROM ${stmt.table.name}$where$orderBy$limit"
485
+ }
486
+ ```
487
+
488
+ Each builder method returns a new `SelectStmt` with the updated field. The `.where()` method is overloaded to accept both `Expr` and `SchemaExpr` — `SchemaExpr` values are translated via `fromSchemaExpr`, so pure `SchemaExpr` chains and mixed chains both work:
489
+
490
+ ```scala
491
+ // Pure SchemaExpr conditions
492
+ val basicSelect = select(Product.table)
493
+ .columns(Product.name, Product.price)
494
+ .where(Product.inStock === true)
495
+ // basicSelect: SelectStmt[Product] = SelectStmt(
496
+ // table = Table("products"),
497
+ // columnList = List("name", "price"),
498
+ // whereExpr = Some(
499
+ // Relational(
500
+ // left = Column(
501
+ // LensImpl(
502
+ // sources = Array(
503
+ // Record(
504
+ // fields = Vector(
505
+ // Term(
506
+ // name = "name",
507
+ // value = Primitive(
508
+ // primitiveType = String(None),
509
+ // typeId = Impl(
510
+ // name = "String",
511
+ // owner = Owner(List(Package("java"), Package("lang"))),
512
+ // typeParams = List(),
513
+ // typeArgs = List(),
514
+ // defKind = Class(
515
+ // isFinal = true,
516
+ // isAbstract = false,
517
+ // isCase = false,
518
+ // isValue = false,
519
+ // bases = List(
520
+ // Ref(
521
+ // Impl(
522
+ // name = "CharSequence",
523
+ // owner = Owner(
524
+ // List(Package("java"), Package("lang"))
525
+ // ),
526
+ // typeParams = List(),
527
+ // typeArgs = List(),
528
+ // defKind = Trait(isSealed = false, bases = List()),
529
+ // selfType = None,
530
+ // aliasedTo = None,
531
+ // representation = None,
532
+ // annotations = List()
533
+ // )
534
+ // ),
535
+ // Ref(
536
+ // Impl(
537
+ // name = "Comparable",
538
+ // owner = Owner(
539
+ // List(Package("java"), Package("lang"))
540
+ // ),
541
+ // typeParams = List(
542
+ // TypeParam(
543
+ // name = "T",
544
+ // ...
545
+
546
+ renderSelect(basicSelect)
547
+ // res3: String = "SELECT name, price FROM products WHERE (inStock = TRUE)"
548
+
549
+ // Mixed conditions with ordering and limit
550
+ val advancedSelect = select(Product.table)
551
+ .columns(Product.name, Product.price, Product.category)
552
+ .where(
553
+ Product.price.between(10.0, 500.0) &&
554
+ (Product.category === "Electronics") &&
555
+ (Product.rating >= 4)
556
+ )
557
+ .orderBy(Product.price, SortOrder.Desc)
558
+ .limit(10)
559
+ // advancedSelect: SelectStmt[Product] = SelectStmt(
560
+ // table = Table("products"),
561
+ // columnList = List("name", "price", "category"),
562
+ // whereExpr = Some(
563
+ // And(
564
+ // left = And(
565
+ // left = Between(
566
+ // expr = Column(
567
+ // LensImpl(
568
+ // sources = Array(
569
+ // Record(
570
+ // fields = Vector(
571
+ // Term(
572
+ // name = "name",
573
+ // value = Primitive(
574
+ // primitiveType = String(None),
575
+ // typeId = Impl(
576
+ // name = "String",
577
+ // owner = Owner(List(Package("java"), Package("lang"))),
578
+ // typeParams = List(),
579
+ // typeArgs = List(),
580
+ // defKind = Class(
581
+ // isFinal = true,
582
+ // isAbstract = false,
583
+ // isCase = false,
584
+ // isValue = false,
585
+ // bases = List(
586
+ // Ref(
587
+ // Impl(
588
+ // name = "CharSequence",
589
+ // owner = Owner(
590
+ // List(Package("java"), Package("lang"))
591
+ // ),
592
+ // typeParams = List(),
593
+ // typeArgs = List(),
594
+ // defKind = Trait(
595
+ // isSealed = false,
596
+ // bases = List()
597
+ // ),
598
+ // selfType = None,
599
+ // aliasedTo = None,
600
+ // representation = None,
601
+ // annotations = List()
602
+ // )
603
+ // ),
604
+ // Ref(
605
+ // Impl(
606
+ // name = "Comparable",
607
+ // owner = Owner(
608
+ // ...
609
+
610
+ renderSelect(advancedSelect)
611
+ // res4: String = "SELECT name, price, category FROM products WHERE (((price BETWEEN 10.0 AND 500.0) AND (category = 'Electronics')) AND (rating >= 4)) ORDER BY price DESC LIMIT 10"
612
+ ```
613
+
614
+ ## The UPDATE Builder
615
+
616
+ The UPDATE builder accumulates `.set()` calls, each pairing an optic with a value:
617
+
618
+ ```scala
619
+ case class Assignment(column: String, value: String)
620
+
621
+ case class UpdateStmt[S](
622
+ table: Table[S],
623
+ assignments: List[Assignment] = Nil,
624
+ whereExpr: Option[Expr[S, Boolean]] = None
625
+ ) {
626
+ def set[A](optic: Optic[S, A], value: A)(implicit schema: Schema[A]): UpdateStmt[S] =
627
+ copy(assignments = assignments :+ Assignment(columnName(optic), sqlLiteral(value, schema)))
628
+ def where(cond: Expr[S, Boolean]): UpdateStmt[S] =
629
+ copy(whereExpr = Some(cond))
630
+ def where(cond: SchemaExpr[S, Boolean]): UpdateStmt[S] =
631
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
632
+ }
633
+
634
+ def update[S](table: Table[S]): UpdateStmt[S] = UpdateStmt(table)
635
+
636
+ def renderUpdate[S](stmt: UpdateStmt[S]): String = {
637
+ val sets = stmt.assignments.map(a => s"${a.column} = ${a.value}").mkString(", ")
638
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
639
+ s"UPDATE ${stmt.table.name} SET $sets$where"
640
+ }
641
+ ```
642
+
643
+ The `.set()` method uses the optic to extract the column name and `sqlLiteral` (via the implicit `Schema[A]`) to render the value. The type parameter `A` on `set[A](optic: Optic[S, A], value: A)` ensures you cannot assign a `String` to a `Double` field.
644
+
645
+ ```scala
646
+ val basicUpdate =
647
+ update(Product.table)
648
+ .set(Product.price, 9.99)
649
+ .where(
650
+ Product.price.between(10.0, 30.0) &&
651
+ Product.name.like("M%") &&
652
+ (Product.category === "Books") &&
653
+ (Product.rating >= 4) &&
654
+ (Product.inStock === true)
655
+ )
656
+ // basicUpdate: UpdateStmt[Product] = UpdateStmt(
657
+ // table = Table("products"),
658
+ // assignments = List(Assignment(column = "price", value = "9.99")),
659
+ // whereExpr = Some(
660
+ // And(
661
+ // left = And(
662
+ // left = And(
663
+ // left = And(
664
+ // left = Between(
665
+ // expr = Column(
666
+ // LensImpl(
667
+ // sources = Array(
668
+ // Record(
669
+ // fields = Vector(
670
+ // Term(
671
+ // name = "name",
672
+ // value = Primitive(
673
+ // primitiveType = String(None),
674
+ // typeId = Impl(
675
+ // name = "String",
676
+ // owner = Owner(
677
+ // List(Package("java"), Package("lang"))
678
+ // ),
679
+ // typeParams = List(),
680
+ // typeArgs = List(),
681
+ // defKind = Class(
682
+ // isFinal = true,
683
+ // isAbstract = false,
684
+ // isCase = false,
685
+ // isValue = false,
686
+ // bases = List(
687
+ // Ref(
688
+ // Impl(
689
+ // name = "CharSequence",
690
+ // owner = Owner(
691
+ // List(Package("java"), Package("lang"))
692
+ // ),
693
+ // typeParams = List(),
694
+ // typeArgs = List(),
695
+ // defKind = Trait(
696
+ // isSealed = false,
697
+ // bases = List()
698
+ // ),
699
+ // selfType = None,
700
+ // aliasedTo = None,
701
+ // representation = None,
702
+ // annotations = List()
703
+ // )
704
+ // ),
705
+ // ...
706
+
707
+ renderUpdate(basicUpdate)
708
+ // res5: String = "UPDATE products SET price = 9.99 WHERE (((((price BETWEEN 10.0 AND 30.0) AND name LIKE 'M%') AND (category = 'Books')) AND (rating >= 4)) AND (inStock = TRUE))"
709
+ ```
710
+
711
+ Multiple `.set()` calls accumulate:
712
+
713
+ ```scala
714
+ val multiUpdate =
715
+ update(Product.table)
716
+ .set(Product.price, 19.99)
717
+ .set(Product.inStock, false)
718
+ .where(Product.category === "Clearance")
719
+ // multiUpdate: UpdateStmt[Product] = UpdateStmt(
720
+ // table = Table("products"),
721
+ // assignments = List(
722
+ // Assignment(column = "price", value = "19.99"),
723
+ // Assignment(column = "inStock", value = "FALSE")
724
+ // ),
725
+ // whereExpr = Some(
726
+ // Relational(
727
+ // left = Column(
728
+ // LensImpl(
729
+ // sources = Array(
730
+ // Record(
731
+ // fields = Vector(
732
+ // Term(
733
+ // name = "name",
734
+ // value = Primitive(
735
+ // primitiveType = String(None),
736
+ // typeId = Impl(
737
+ // name = "String",
738
+ // owner = Owner(List(Package("java"), Package("lang"))),
739
+ // typeParams = List(),
740
+ // typeArgs = List(),
741
+ // defKind = Class(
742
+ // isFinal = true,
743
+ // isAbstract = false,
744
+ // isCase = false,
745
+ // isValue = false,
746
+ // bases = List(
747
+ // Ref(
748
+ // Impl(
749
+ // name = "CharSequence",
750
+ // owner = Owner(
751
+ // List(Package("java"), Package("lang"))
752
+ // ),
753
+ // typeParams = List(),
754
+ // typeArgs = List(),
755
+ // defKind = Trait(isSealed = false, bases = List()),
756
+ // selfType = None,
757
+ // aliasedTo = None,
758
+ // representation = None,
759
+ // annotations = List()
760
+ // )
761
+ // ),
762
+ // Ref(
763
+ // Impl(
764
+ // name = "Comparable",
765
+ // owner = Owner(
766
+ // List(Package("java"), Package("lang"))
767
+ // ),
768
+ // ...
769
+
770
+ renderUpdate(multiUpdate)
771
+ // res6: String = "UPDATE products SET price = 19.99, inStock = FALSE WHERE (category = 'Clearance')"
772
+ ```
773
+
774
+ ## INSERT and DELETE
775
+
776
+ The INSERT builder collects column-value pairs via `.set()`:
777
+
778
+ ```scala
779
+ case class InsertStmt[S](
780
+ table: Table[S],
781
+ assignments: List[Assignment] = Nil
782
+ ) {
783
+ def set[A](optic: Optic[S, A], value: A)(implicit schema: Schema[A]): InsertStmt[S] =
784
+ copy(assignments = assignments :+ Assignment(columnName(optic), sqlLiteral(value, schema)))
785
+ }
786
+
787
+ def insertInto[S](table: Table[S]): InsertStmt[S] = InsertStmt(table)
788
+
789
+ def renderInsert[S](stmt: InsertStmt[S]): String = {
790
+ val cols = stmt.assignments.map(_.column).mkString(", ")
791
+ val vals = stmt.assignments.map(_.value).mkString(", ")
792
+ s"INSERT INTO ${stmt.table.name} ($cols) VALUES ($vals)"
793
+ }
794
+ ```
795
+
796
+ The DELETE builder takes an optional WHERE clause:
797
+
798
+ ```scala
799
+ case class DeleteStmt[S](
800
+ table: Table[S],
801
+ whereExpr: Option[Expr[S, Boolean]] = None
802
+ ) {
803
+ def where(cond: Expr[S, Boolean]): DeleteStmt[S] =
804
+ copy(whereExpr = Some(cond))
805
+ def where(cond: SchemaExpr[S, Boolean]): DeleteStmt[S] =
806
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
807
+ }
808
+
809
+ def deleteFrom[S](table: Table[S]): DeleteStmt[S] = DeleteStmt(table)
810
+
811
+ def renderDelete[S](stmt: DeleteStmt[S]): String = {
812
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
813
+ s"DELETE FROM ${stmt.table.name}$where"
814
+ }
815
+ ```
816
+
817
+ ```scala
818
+ val ins = insertInto(Product.table)
819
+ .set(Product.name, "Wireless Mouse")
820
+ .set(Product.price, 29.99)
821
+ .set(Product.category, "Electronics")
822
+ .set(Product.inStock, true)
823
+ .set(Product.rating, 4)
824
+ // ins: InsertStmt[Product] = InsertStmt(
825
+ // table = Table("products"),
826
+ // assignments = List(
827
+ // Assignment(column = "name", value = "'Wireless Mouse'"),
828
+ // Assignment(column = "price", value = "29.99"),
829
+ // Assignment(column = "category", value = "'Electronics'"),
830
+ // Assignment(column = "inStock", value = "TRUE"),
831
+ // Assignment(column = "rating", value = "4")
832
+ // )
833
+ // )
834
+
835
+ renderInsert(ins)
836
+ // res7: String = "INSERT INTO products (name, price, category, inStock, rating) VALUES ('Wireless Mouse', 29.99, 'Electronics', TRUE, 4)"
837
+
838
+ val del = deleteFrom(Product.table)
839
+ .where(
840
+ Product.price.between(0.0, 1.0) &&
841
+ (Product.inStock === false)
842
+ )
843
+ // del: DeleteStmt[Product] = DeleteStmt(
844
+ // table = Table("products"),
845
+ // whereExpr = Some(
846
+ // And(
847
+ // left = Between(
848
+ // expr = Column(
849
+ // LensImpl(
850
+ // sources = Array(
851
+ // Record(
852
+ // fields = Vector(
853
+ // Term(
854
+ // name = "name",
855
+ // value = Primitive(
856
+ // primitiveType = String(None),
857
+ // typeId = Impl(
858
+ // name = "String",
859
+ // owner = Owner(List(Package("java"), Package("lang"))),
860
+ // typeParams = List(),
861
+ // typeArgs = List(),
862
+ // defKind = Class(
863
+ // isFinal = true,
864
+ // isAbstract = false,
865
+ // isCase = false,
866
+ // isValue = false,
867
+ // bases = List(
868
+ // Ref(
869
+ // Impl(
870
+ // name = "CharSequence",
871
+ // owner = Owner(
872
+ // List(Package("java"), Package("lang"))
873
+ // ),
874
+ // typeParams = List(),
875
+ // typeArgs = List(),
876
+ // defKind = Trait(
877
+ // isSealed = false,
878
+ // bases = List()
879
+ // ),
880
+ // selfType = None,
881
+ // aliasedTo = None,
882
+ // representation = None,
883
+ // annotations = List()
884
+ // )
885
+ // ),
886
+ // Ref(
887
+ // Impl(
888
+ // name = "Comparable",
889
+ // owner = Owner(
890
+ // List(Package("java"), Package("lang"))
891
+ // ),
892
+ // ...
893
+
894
+ renderDelete(del)
895
+ // res8: String = "DELETE FROM products WHERE ((price BETWEEN 0.0 AND 1.0) AND (inStock = FALSE))"
896
+ ```
897
+
898
+ :::tip
899
+ For batch inserts, create one `InsertStmt` per row and render each separately. The builder pattern keeps each row type-safe.
900
+ :::
901
+
902
+ ## Putting It Together
903
+
904
+ Here is a complete example combining schema-driven table names, bridge extensions, all four statement builders, and the renderers. The `Expr` ADT, extension methods, SQL rendering, and builder types are defined in `Common.scala` and `package.scala` — the usage code stays focused on building queries:
905
+
906
+ ```scala
907
+ import zio.blocks.schema._
908
+
909
+ // --- Table reference with schema-driven names ---
910
+
911
+ case class Table[S](name: String)
912
+
913
+ object Table {
914
+ def derived[S](implicit schema: Schema[S]): Table[S] = Table(tableName(schema))
915
+ }
916
+
917
+ def tableName[S](schema: Schema[S]): String =
918
+ schema.reflect.modifiers.collectFirst {
919
+ case Modifier.config(key, value) if key == "sql.table_name" => value
920
+ }.getOrElse(pluralize(schema.reflect.typeId.name.toLowerCase))
921
+
922
+ def pluralize(word: String): String =
923
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") ||
924
+ word.endsWith("ch") || word.endsWith("sh")) word + "es"
925
+ else if (word.endsWith("y") && !word.endsWith("ay") && !word.endsWith("ey") &&
926
+ !word.endsWith("oy") && !word.endsWith("uy")) word.dropRight(1) + "ies"
927
+ else word + "s"
928
+
929
+ // --- Domain ---
930
+
931
+ case class Product(
932
+ name: String,
933
+ price: Double,
934
+ category: String,
935
+ inStock: Boolean,
936
+ rating: Int
937
+ )
938
+
939
+ object Product extends CompanionOptics[Product] {
940
+ implicit val schema: Schema[Product] = Schema.derived
941
+
942
+ val table: Table[Product] = Table.derived // "Product" → "products"
943
+
944
+ val name: Lens[Product, String] = optic(_.name)
945
+ val price: Lens[Product, Double] = optic(_.price)
946
+ val category: Lens[Product, String] = optic(_.category)
947
+ val inStock: Lens[Product, Boolean] = optic(_.inStock)
948
+ val rating: Lens[Product, Int] = optic(_.rating)
949
+ }
950
+
951
+ case class OrderItem(
952
+ orderId: Int,
953
+ productId: Int,
954
+ quantity: Int,
955
+ unitPrice: Double
956
+ )
957
+
958
+ object OrderItem extends CompanionOptics[OrderItem] {
959
+ implicit val schema: Schema[OrderItem] = Schema.derived
960
+ .modifier(Modifier.config("sql.table_name", "order_items"))
961
+
962
+ val table: Table[OrderItem] = Table.derived // annotation → "order_items"
963
+
964
+ val orderId: Lens[OrderItem, Int] = optic(_.orderId)
965
+ val productId: Lens[OrderItem, Int] = optic(_.productId)
966
+ val quantity: Lens[OrderItem, Int] = optic(_.quantity)
967
+ val unitPrice: Lens[OrderItem, Double] = optic(_.unitPrice)
968
+ }
969
+
970
+ // --- Expr ADT ---
971
+
972
+ sealed trait Expr[S, A]
973
+
974
+ object Expr {
975
+ final case class Column[S, A](optic: Optic[S, A]) extends Expr[S, A]
976
+ final case class Lit[S, A](value: A, schema: Schema[A]) extends Expr[S, A]
977
+
978
+ final case class Relational[S, A](left: Expr[S, A], right: Expr[S, A], op: RelOp) extends Expr[S, Boolean]
979
+ final case class And[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
980
+ final case class Or[S](left: Expr[S, Boolean], right: Expr[S, Boolean]) extends Expr[S, Boolean]
981
+ final case class Not[S](expr: Expr[S, Boolean]) extends Expr[S, Boolean]
982
+ final case class Arithmetic[S, A](left: Expr[S, A], right: Expr[S, A], op: ArithOp) extends Expr[S, A]
983
+ final case class StringConcat[S](left: Expr[S, String], right: Expr[S, String]) extends Expr[S, String]
984
+ final case class StringRegexMatch[S](regex: Expr[S, String], string: Expr[S, String]) extends Expr[S, Boolean]
985
+ final case class StringLength[S](string: Expr[S, String]) extends Expr[S, Int]
986
+
987
+ final case class In[S, A](expr: Expr[S, A], values: List[A], schema: Schema[A]) extends Expr[S, Boolean]
988
+ final case class Between[S, A](expr: Expr[S, A], low: A, high: A, schema: Schema[A]) extends Expr[S, Boolean]
989
+ final case class IsNull[S, A](expr: Expr[S, A]) extends Expr[S, Boolean]
990
+ final case class Like[S](expr: Expr[S, String], pattern: String) extends Expr[S, Boolean]
991
+
992
+ def col[S, A](optic: Optic[S, A]): Expr[S, A] = Column(optic)
993
+ def lit[S, A](value: A)(implicit schema: Schema[A]): Expr[S, A] = Lit(value, schema)
994
+
995
+ def fromSchemaExpr[S, A](se: SchemaExpr[S, A]): Expr[S, A] = {
996
+ val result = se match {
997
+ case SchemaExpr.Optic(optic) => Column(optic)
998
+ case l: SchemaExpr.Literal[_, _] => Lit(l.value, l.schema)
999
+ case SchemaExpr.Relational(l, r, op) =>
1000
+ val relOp = op match {
1001
+ case SchemaExpr.RelationalOperator.Equal => RelOp.Equal
1002
+ case SchemaExpr.RelationalOperator.NotEqual => RelOp.NotEqual
1003
+ case SchemaExpr.RelationalOperator.LessThan => RelOp.LessThan
1004
+ case SchemaExpr.RelationalOperator.LessThanOrEqual => RelOp.LessThanOrEqual
1005
+ case SchemaExpr.RelationalOperator.GreaterThan => RelOp.GreaterThan
1006
+ case SchemaExpr.RelationalOperator.GreaterThanOrEqual => RelOp.GreaterThanOrEqual
1007
+ }
1008
+ Relational(fromSchemaExpr(l), fromSchemaExpr(r), relOp)
1009
+ case SchemaExpr.Logical(l, r, op) => op match {
1010
+ case SchemaExpr.LogicalOperator.And => And(fromSchemaExpr(l), fromSchemaExpr(r))
1011
+ case SchemaExpr.LogicalOperator.Or => Or(fromSchemaExpr(l), fromSchemaExpr(r))
1012
+ }
1013
+ case SchemaExpr.Not(inner) => Not(fromSchemaExpr(inner))
1014
+ case SchemaExpr.Arithmetic(l, r, op, _) =>
1015
+ val arithOp = op match {
1016
+ case SchemaExpr.ArithmeticOperator.Add => ArithOp.Add
1017
+ case SchemaExpr.ArithmeticOperator.Subtract => ArithOp.Subtract
1018
+ case SchemaExpr.ArithmeticOperator.Multiply => ArithOp.Multiply
1019
+ }
1020
+ Arithmetic(fromSchemaExpr(l), fromSchemaExpr(r), arithOp)
1021
+ case SchemaExpr.StringConcat(l, r) => StringConcat(fromSchemaExpr(l), fromSchemaExpr(r))
1022
+ case SchemaExpr.StringRegexMatch(regex, string) => StringRegexMatch(fromSchemaExpr(regex), fromSchemaExpr(string))
1023
+ case SchemaExpr.StringLength(string) => StringLength(fromSchemaExpr(string))
1024
+ }
1025
+ result.asInstanceOf[Expr[S, A]]
1026
+ }
1027
+ }
1028
+
1029
+ sealed trait RelOp
1030
+ object RelOp {
1031
+ case object Equal extends RelOp
1032
+ case object NotEqual extends RelOp
1033
+ case object LessThan extends RelOp
1034
+ case object LessThanOrEqual extends RelOp
1035
+ case object GreaterThan extends RelOp
1036
+ case object GreaterThanOrEqual extends RelOp
1037
+ }
1038
+
1039
+ sealed trait ArithOp
1040
+ object ArithOp {
1041
+ case object Add extends ArithOp
1042
+ case object Subtract extends ArithOp
1043
+ case object Multiply extends ArithOp
1044
+ }
1045
+
1046
+ // --- Extension methods with bridge ---
1047
+
1048
+ implicit final class OpticExprOps[S, A](private val optic: Optic[S, A]) {
1049
+ def in(values: A*)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.In(Expr.col(optic), values.toList, schema)
1050
+ def between(low: A, high: A)(implicit schema: Schema[A]): Expr[S, Boolean] = Expr.Between(Expr.col(optic), low, high, schema)
1051
+ def isNull: Expr[S, Boolean] = Expr.IsNull(Expr.col(optic))
1052
+ def isNotNull: Expr[S, Boolean] = Expr.Not(Expr.IsNull(Expr.col(optic)))
1053
+ }
1054
+
1055
+ implicit final class StringOpticExprOps[S](private val optic: Optic[S, String]) {
1056
+ def like(pattern: String): Expr[S, Boolean] = Expr.Like(Expr.col(optic), pattern)
1057
+ }
1058
+
1059
+ implicit final class ExprBooleanOps[S](private val self: Expr[S, Boolean]) {
1060
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, other)
1061
+ def &&(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.And(self, Expr.fromSchemaExpr(other))
1062
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, other)
1063
+ def ||(other: SchemaExpr[S, Boolean]): Expr[S, Boolean] = Expr.Or(self, Expr.fromSchemaExpr(other))
1064
+ def unary_! : Expr[S, Boolean] = Expr.Not(self)
1065
+ }
1066
+
1067
+ implicit final class SchemaExprBooleanBridge[S](private val self: SchemaExpr[S, Boolean]) {
1068
+ def &&(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.And(Expr.fromSchemaExpr(self), other)
1069
+ def ||(other: Expr[S, Boolean]): Expr[S, Boolean] = Expr.Or(Expr.fromSchemaExpr(self), other)
1070
+ def toExpr: Expr[S, Boolean] = Expr.fromSchemaExpr(self)
1071
+ }
1072
+
1073
+ // --- SQL rendering ---
1074
+
1075
+ def columnName(optic: zio.blocks.schema.Optic[_, _]): String =
1076
+ optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
1077
+
1078
+ def sqlLiteral[A](value: A, schema: Schema[A]): String = {
1079
+ val dv = schema.toDynamicValue(value)
1080
+ dv match {
1081
+ case p: DynamicValue.Primitive => p.value match {
1082
+ case _: PrimitiveValue.String => s"'${value.toString.replace("'", "''")}'"
1083
+ case b: PrimitiveValue.Boolean => if (b.value) "TRUE" else "FALSE"
1084
+ case _ => value.toString
1085
+ }
1086
+ case _ => value.toString
1087
+ }
1088
+ }
1089
+
1090
+ def exprToSql[S, A](expr: Expr[S, A]): String = expr match {
1091
+ case Expr.Column(optic) => columnName(optic)
1092
+ case Expr.Lit(value, schema) => sqlLiteral(value, schema)
1093
+ case Expr.Relational(left, right, op) =>
1094
+ val sqlOp = op match {
1095
+ case RelOp.Equal => "="
1096
+ case RelOp.NotEqual => "<>"
1097
+ case RelOp.LessThan => "<"
1098
+ case RelOp.LessThanOrEqual => "<="
1099
+ case RelOp.GreaterThan => ">"
1100
+ case RelOp.GreaterThanOrEqual => ">="
1101
+ }
1102
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
1103
+ case Expr.And(l, r) => s"(${exprToSql(l)} AND ${exprToSql(r)})"
1104
+ case Expr.Or(l, r) => s"(${exprToSql(l)} OR ${exprToSql(r)})"
1105
+ case Expr.Not(e) => s"NOT (${exprToSql(e)})"
1106
+ case Expr.Arithmetic(left, right, op) =>
1107
+ val sqlOp = op match {
1108
+ case ArithOp.Add => "+"
1109
+ case ArithOp.Subtract => "-"
1110
+ case ArithOp.Multiply => "*"
1111
+ }
1112
+ s"(${exprToSql(left)} $sqlOp ${exprToSql(right)})"
1113
+ case Expr.StringConcat(l, r) => s"CONCAT(${exprToSql(l)}, ${exprToSql(r)})"
1114
+ case Expr.StringRegexMatch(regex, s) => s"(${exprToSql(s)} LIKE ${exprToSql(regex)})"
1115
+ case Expr.StringLength(s) => s"LENGTH(${exprToSql(s)})"
1116
+ case Expr.In(e, values, schema) =>
1117
+ s"${exprToSql(e)} IN (${values.map(v => sqlLiteral(v, schema)).mkString(", ")})"
1118
+ case Expr.Between(e, low, high, schema) =>
1119
+ s"(${exprToSql(e)} BETWEEN ${sqlLiteral(low, schema)} AND ${sqlLiteral(high, schema)})"
1120
+ case Expr.IsNull(e) => s"${exprToSql(e)} IS NULL"
1121
+ case Expr.Like(e, pattern) => s"${exprToSql(e)} LIKE '${pattern.replace("'", "''")}'"
1122
+ }
1123
+
1124
+ // --- Statement builders ---
1125
+
1126
+ sealed trait SortOrder
1127
+ object SortOrder {
1128
+ case object Asc extends SortOrder
1129
+ case object Desc extends SortOrder
1130
+ }
1131
+
1132
+ case class Assignment(column: String, value: String)
1133
+
1134
+ case class SelectStmt[S](
1135
+ table: Table[S],
1136
+ columnList: List[String] = List("*"),
1137
+ whereExpr: Option[Expr[S, Boolean]] = None,
1138
+ orderByList: List[(String, SortOrder)] = Nil,
1139
+ limitCount: Option[Int] = None
1140
+ ) {
1141
+ def columns(optics: Optic[S, _]*): SelectStmt[S] =
1142
+ copy(columnList = optics.map(columnName).toList)
1143
+ def where(cond: Expr[S, Boolean]): SelectStmt[S] =
1144
+ copy(whereExpr = Some(cond))
1145
+ def where(cond: SchemaExpr[S, Boolean]): SelectStmt[S] =
1146
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
1147
+ def orderBy(optic: Optic[S, _], order: SortOrder = SortOrder.Asc): SelectStmt[S] =
1148
+ copy(orderByList = orderByList :+ (columnName(optic), order))
1149
+ def limit(n: Int): SelectStmt[S] =
1150
+ copy(limitCount = Some(n))
1151
+ }
1152
+
1153
+ case class UpdateStmt[S](
1154
+ table: Table[S],
1155
+ assignments: List[Assignment] = Nil,
1156
+ whereExpr: Option[Expr[S, Boolean]] = None
1157
+ ) {
1158
+ def set[A](optic: Optic[S, A], value: A)(implicit schema: Schema[A]): UpdateStmt[S] =
1159
+ copy(assignments = assignments :+ Assignment(columnName(optic), sqlLiteral(value, schema)))
1160
+ def where(cond: Expr[S, Boolean]): UpdateStmt[S] =
1161
+ copy(whereExpr = Some(cond))
1162
+ def where(cond: SchemaExpr[S, Boolean]): UpdateStmt[S] =
1163
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
1164
+ }
1165
+
1166
+ case class InsertStmt[S](
1167
+ table: Table[S],
1168
+ assignments: List[Assignment] = Nil
1169
+ ) {
1170
+ def set[A](optic: Optic[S, A], value: A)(implicit schema: Schema[A]): InsertStmt[S] =
1171
+ copy(assignments = assignments :+ Assignment(columnName(optic), sqlLiteral(value, schema)))
1172
+ }
1173
+
1174
+ case class DeleteStmt[S](
1175
+ table: Table[S],
1176
+ whereExpr: Option[Expr[S, Boolean]] = None
1177
+ ) {
1178
+ def where(cond: Expr[S, Boolean]): DeleteStmt[S] =
1179
+ copy(whereExpr = Some(cond))
1180
+ def where(cond: SchemaExpr[S, Boolean]): DeleteStmt[S] =
1181
+ copy(whereExpr = Some(Expr.fromSchemaExpr(cond)))
1182
+ }
1183
+
1184
+ def select[S](table: Table[S]): SelectStmt[S] = SelectStmt(table)
1185
+ def update[S](table: Table[S]): UpdateStmt[S] = UpdateStmt(table)
1186
+ def insertInto[S](table: Table[S]): InsertStmt[S] = InsertStmt(table)
1187
+ def deleteFrom[S](table: Table[S]): DeleteStmt[S] = DeleteStmt(table)
1188
+
1189
+ // --- Renderers ---
1190
+
1191
+ def renderSelect[S](stmt: SelectStmt[S]): String = {
1192
+ val cols = stmt.columnList.mkString(", ")
1193
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
1194
+ val orderBy = if (stmt.orderByList.isEmpty) "" else {
1195
+ val orders = stmt.orderByList.map { case (col, order) =>
1196
+ val dir = order match { case SortOrder.Asc => "ASC"; case SortOrder.Desc => "DESC" }
1197
+ s"$col $dir"
1198
+ }.mkString(", ")
1199
+ s" ORDER BY $orders"
1200
+ }
1201
+ val limit = stmt.limitCount.map(n => s" LIMIT $n").getOrElse("")
1202
+ s"SELECT $cols FROM ${stmt.table.name}$where$orderBy$limit"
1203
+ }
1204
+
1205
+ def renderUpdate[S](stmt: UpdateStmt[S]): String = {
1206
+ val sets = stmt.assignments.map(a => s"${a.column} = ${a.value}").mkString(", ")
1207
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
1208
+ s"UPDATE ${stmt.table.name} SET $sets$where"
1209
+ }
1210
+
1211
+ def renderInsert[S](stmt: InsertStmt[S]): String = {
1212
+ val cols = stmt.assignments.map(_.column).mkString(", ")
1213
+ val vals = stmt.assignments.map(_.value).mkString(", ")
1214
+ s"INSERT INTO ${stmt.table.name} ($cols) VALUES ($vals)"
1215
+ }
1216
+
1217
+ def renderDelete[S](stmt: DeleteStmt[S]): String = {
1218
+ val where = stmt.whereExpr.map(c => s" WHERE ${exprToSql(c)}").getOrElse("")
1219
+ s"DELETE FROM ${stmt.table.name}$where"
1220
+ }
1221
+
1222
+ // --- Usage ---
1223
+
1224
+ // Schema-driven table names
1225
+ println(Product.table.name) // "products" (auto-pluralized)
1226
+ println(OrderItem.table.name) // "order_items" (from annotation)
1227
+
1228
+ // SELECT with mixed conditions
1229
+ val q = select(Product.table)
1230
+ .columns(Product.name, Product.price, Product.category)
1231
+ .where(
1232
+ Product.category.in("Electronics", "Books") &&
1233
+ Product.price.between(10.0, 500.0) &&
1234
+ (Product.rating >= 4).toExpr
1235
+ )
1236
+ .orderBy(Product.price, SortOrder.Desc)
1237
+ .limit(20)
1238
+
1239
+ println(renderSelect(q))
1240
+
1241
+ // UPDATE with seamless condition mixing
1242
+ val u = update(Product.table)
1243
+ .set(Product.price, 9.99)
1244
+ .where(
1245
+ Product.price.between(10.0, 30.0) &&
1246
+ Product.name.like("M%") &&
1247
+ (Product.category === "Books") &&
1248
+ (Product.rating >= 4) &&
1249
+ (Product.inStock === true)
1250
+ )
1251
+
1252
+ println(renderUpdate(u))
1253
+
1254
+ // INSERT
1255
+ val i = insertInto(Product.table)
1256
+ .set(Product.name, "Wireless Mouse")
1257
+ .set(Product.price, 29.99)
1258
+ .set(Product.category, "Electronics")
1259
+ .set(Product.inStock, true)
1260
+ .set(Product.rating, 4)
1261
+
1262
+ println(renderInsert(i))
1263
+
1264
+ // DELETE
1265
+ val d = deleteFrom(Product.table)
1266
+ .where(Product.price.between(0.0, 1.0) && (Product.inStock === false))
1267
+
1268
+ println(renderDelete(d))
1269
+
1270
+ // Cross-table query with annotated table name
1271
+ val orderQuery = select(OrderItem.table)
1272
+ .columns(OrderItem.orderId, OrderItem.productId, OrderItem.quantity)
1273
+ .where(OrderItem.quantity >= 3)
1274
+ .orderBy(OrderItem.unitPrice, SortOrder.Desc)
1275
+
1276
+ println(renderSelect(orderQuery))
1277
+ ```
1278
+
1279
+ ## Going Further
1280
+
1281
+ - **[Part 1: Expressions](./query-dsl-reified-optics.md)** — Building query expressions with reified optics
1282
+ - **[Part 2: SQL Generation](./query-dsl-sql.md)** — Translating built-in expressions to SQL
1283
+ - **[Part 3: Extending the Expression Language](./query-dsl-extending.md)** — Adding custom operators beyond SchemaExpr
1284
+ - **[SchemaExpr Reference](../reference/schema-expr.md)** — Full API coverage of expression types
1285
+ - **[Optics Reference](../reference/optics.md)** — Lens, Prism, Optional, and Traversal
1286
+
1287
+ The builder pattern shown here extends naturally to JOIN clauses (using lenses from multiple table types), subqueries (nesting `SelectStmt` in WHERE conditions), and parameterized queries (collecting `?` placeholders and parameter values during rendering). Each of these builds on the same foundation: optics for column names, `Expr` for conditions, and immutable builders for statement structure.