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