@zio.dev/zio-blocks 0.0.21 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +57 -34
- package/package.json +1 -1
- package/path-interpolator.md +24 -23
- package/reference/codec.md +386 -0
- package/reference/docs.md +1 -1
- package/reference/dynamic-optic.md +396 -0
- package/reference/formats.md +68 -12
- package/reference/json-schema.md +14 -11
- package/reference/json.md +2 -2
- package/reference/lazy.md +361 -0
- package/reference/media-type.md +460 -0
- package/reference/modifier.md +340 -0
- package/reference/optics.md +4 -0
- package/reference/schema-expr.md +669 -0
- package/reference/schema.md +1 -0
- package/reference/syntax.md +11 -11
- package/reference/type-class-derivation.md +1960 -0
- package/scope.md +754 -502
- package/sidebars.js +18 -1
- package/undocumented-report.md +331 -0
|
@@ -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.24"
|
|
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.
|