@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.
- package/guides/query-dsl-extending.md +758 -0
- package/guides/query-dsl-fluent-builder.md +1287 -0
- package/guides/query-dsl-reified-optics.md +494 -0
- package/guides/query-dsl-sql.md +680 -0
- package/index.md +42 -39
- package/package.json +1 -1
- package/reference/codec.md +20 -18
- package/reference/docs.md +1 -1
- package/reference/dynamic-optic.md +4 -0
- package/reference/json-schema.md +1 -1
- package/reference/json.md +2 -2
- package/reference/media-type.md +460 -0
- package/reference/optics.md +4 -0
- package/reference/schema-expr.md +669 -0
- package/reference/schema.md +1 -0
- package/reference/type-class-derivation.md +2 -1
- package/scope.md +744 -490
- package/sidebars.js +12 -0
- package/undocumented-report.md +331 -0
|
@@ -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.
|