@zio.dev/zio-blocks 0.0.33 → 0.0.55

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.
Files changed (215) hide show
  1. package/adr/2026-07-18-data-migration.md +123 -0
  2. package/guides/async-getting-started.md +687 -0
  3. package/guides/compile-time-resource-safety-with-scope.md +21 -16
  4. package/guides/getting-started-with-mux.md +1395 -0
  5. package/guides/query-dsl-extending.md +161 -102
  6. package/guides/query-dsl-fluent-builder.md +217 -157
  7. package/guides/query-dsl-reified-optics.md +12 -10
  8. package/guides/query-dsl-sql.md +640 -165
  9. package/guides/sql-checked-interpolation.md +173 -0
  10. package/guides/sql-transactions.md +286 -0
  11. package/guides/telemetry-guide.md +1130 -0
  12. package/guides/zio-schema-migration.md +29 -22
  13. package/index.md +248 -389
  14. package/package.json +1 -1
  15. package/plans/config-follow-up-prs.md +188 -0
  16. package/plans/config-pr-assessment-roadmap.md +310 -0
  17. package/reference/MuxDataFlow.jsx +250 -0
  18. package/reference/async.md +1499 -0
  19. package/reference/chunk.md +3533 -308
  20. package/reference/codegen/case-class.md +436 -0
  21. package/reference/codegen/emitter-config.md +383 -0
  22. package/reference/codegen/examples.md +664 -0
  23. package/reference/codegen/field.md +316 -0
  24. package/reference/codegen/index.md +317 -0
  25. package/reference/codegen/scala-emitter.md +392 -0
  26. package/reference/codegen/scala-file.md +276 -0
  27. package/reference/codegen/sealed-trait.md +408 -0
  28. package/reference/codegen/type-definition.md +340 -0
  29. package/reference/codegen/type-ref.md +201 -0
  30. package/reference/combinators.md +347 -117
  31. package/reference/config/config-decoder.md +460 -0
  32. package/reference/config/config-source.md +489 -0
  33. package/reference/config/errors.md +278 -0
  34. package/reference/config/flags.md +369 -0
  35. package/reference/config/formats.md +314 -0
  36. package/reference/config/index.md +304 -0
  37. package/reference/config/rollout.md +336 -0
  38. package/reference/context.md +9 -52
  39. package/reference/data-migration.md +269 -0
  40. package/reference/datastar/attributes.md +302 -0
  41. package/reference/datastar/events.md +234 -0
  42. package/reference/datastar/index.md +256 -0
  43. package/reference/datastar/signals.md +230 -0
  44. package/reference/datastar/sse.md +295 -0
  45. package/reference/datastar.md +346 -0
  46. package/reference/docs.md +1461 -345
  47. package/reference/endpoint/auth-type.md +146 -0
  48. package/reference/endpoint/bulk-creation.md +96 -0
  49. package/reference/endpoint/endpoint.md +297 -0
  50. package/reference/endpoint/http-codec.md +249 -0
  51. package/reference/endpoint/index.md +745 -0
  52. package/reference/endpoint/path-codec.md +225 -0
  53. package/reference/endpoint/route-pattern.md +194 -0
  54. package/reference/endpoint/route-tree.md +111 -0
  55. package/reference/endpoint/segment-codec.md +199 -0
  56. package/reference/html.md +1424 -0
  57. package/reference/htmx/attribute-values.md +359 -0
  58. package/reference/htmx/hx-encoding.md +111 -0
  59. package/reference/htmx/hx-params.md +204 -0
  60. package/reference/htmx/hx-swap.md +276 -0
  61. package/reference/htmx/hx-sync.md +251 -0
  62. package/reference/htmx/hx-target.md +314 -0
  63. package/reference/htmx/hx-trigger.md +457 -0
  64. package/reference/htmx/hx-url-update.md +239 -0
  65. package/reference/htmx/index.md +807 -0
  66. package/reference/htmx/response-headers.md +240 -0
  67. package/reference/http-model/headers.md +735 -0
  68. package/reference/http-model/index.md +49 -0
  69. package/reference/http-model/model.md +1517 -0
  70. package/reference/http-model/schema-codecs.md +522 -0
  71. package/reference/http-model/schema.md +750 -0
  72. package/reference/http-model/server-sent-event.md +341 -0
  73. package/reference/jwt.md +195 -0
  74. package/reference/maybe.md +943 -0
  75. package/reference/media-type.md +2 -2
  76. package/reference/mux.md +254 -0
  77. package/reference/mux.mdx +828 -0
  78. package/reference/openapi.md +1351 -0
  79. package/reference/projection.md +654 -0
  80. package/reference/resource-management/defer-handle.md +1 -1
  81. package/reference/resource-management/resource.md +31 -98
  82. package/reference/resource-management/scope.md +28 -220
  83. package/reference/resource-management/wire.md +5 -55
  84. package/reference/ringbuffer/MpmcDiagram.jsx +717 -0
  85. package/reference/ringbuffer/MpscDiagram.jsx +618 -0
  86. package/reference/ringbuffer/SpmcDiagram.jsx +680 -0
  87. package/reference/ringbuffer/SpscDiagram.jsx +677 -0
  88. package/reference/ringbuffer/advanced.mdx +109 -0
  89. package/reference/ringbuffer/index.mdx +145 -0
  90. package/reference/ringbuffer/mpmc.mdx +185 -0
  91. package/reference/ringbuffer/mpsc.mdx +164 -0
  92. package/reference/ringbuffer/spmc.mdx +108 -0
  93. package/reference/ringbuffer/spsc.mdx +416 -0
  94. package/reference/{allows.md → schema/allows.md} +4 -100
  95. package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
  96. package/reference/{binding.md → schema/binding.md} +3 -4
  97. package/reference/schema/built-in-codecs/avro.md +451 -0
  98. package/reference/schema/built-in-codecs/bson.md +510 -0
  99. package/reference/schema/built-in-codecs/csv.md +564 -0
  100. package/reference/schema/built-in-codecs/index.md +77 -0
  101. package/reference/schema/built-in-codecs/json/index.md +295 -0
  102. package/reference/schema/built-in-codecs/json/json-config.md +217 -0
  103. package/reference/{json-patch.md → schema/built-in-codecs/json/json-patch.md} +5 -5
  104. package/reference/{json-schema.md → schema/built-in-codecs/json/json-schema.md} +14 -47
  105. package/reference/schema/built-in-codecs/json/json-selection.md +322 -0
  106. package/reference/{json.md → schema/built-in-codecs/json/json.md} +32 -64
  107. package/reference/schema/built-in-codecs/messagepack.md +508 -0
  108. package/reference/schema/built-in-codecs/thrift.md +433 -0
  109. package/reference/schema/built-in-codecs/toon.md +1078 -0
  110. package/reference/{xml.md → schema/built-in-codecs/xml.md} +13 -9
  111. package/reference/schema/built-in-codecs/yaml.md +552 -0
  112. package/reference/{codec.md → schema/codec.md} +11 -11
  113. package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +196 -5
  114. package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
  115. package/reference/schema/format.md +92 -0
  116. package/reference/schema/index.md +52 -0
  117. package/reference/schema/migration.md +297 -0
  118. package/reference/{modifier.md → schema/modifier.md} +58 -7
  119. package/reference/{optics.md → schema/optics.md} +2 -2
  120. package/reference/{patch.md → schema/patch.md} +1 -1
  121. package/{path-interpolator.md → reference/schema/path-interpolator.md} +167 -72
  122. package/reference/schema/reflect-transformer.md +140 -0
  123. package/reference/{schema-evolution → schema/schema-evolution}/as.md +8 -8
  124. package/reference/{schema-evolution → schema/schema-evolution}/index.md +2 -2
  125. package/reference/{schema-evolution → schema/schema-evolution}/into.md +8 -8
  126. package/reference/{schema-expr.md → schema/schema-expr.md} +110 -175
  127. package/reference/schema/schema-search.md +263 -0
  128. package/reference/{schema.md → schema/schema.md} +22 -2
  129. package/reference/{structural-types.md → schema/structural-types.md} +1 -1
  130. package/reference/{type-class-derivation.md → schema/type-class-derivation.md} +63 -1
  131. package/reference/smithy.md +1032 -0
  132. package/reference/sql/db-codec-deriver.md +71 -0
  133. package/reference/sql/db-codec.md +687 -0
  134. package/reference/sql/db-con.md +271 -0
  135. package/reference/sql/db-connection.md +153 -0
  136. package/reference/sql/db-param-writer.md +77 -0
  137. package/reference/sql/db-param.md +66 -0
  138. package/reference/sql/db-result-reader.md +148 -0
  139. package/reference/sql/db-tx.md +114 -0
  140. package/reference/sql/db-value.md +41 -0
  141. package/reference/sql/ddl.md +85 -0
  142. package/reference/sql/frag.md +288 -0
  143. package/reference/sql/index.md +341 -0
  144. package/reference/sql/repo.md +600 -0
  145. package/reference/sql/sql-dialect.md +73 -0
  146. package/reference/sql/sql-logger.md +62 -0
  147. package/reference/sql/sql-name-mapper.md +70 -0
  148. package/reference/sql/table-metadata.md +134 -0
  149. package/reference/sql/table.md +448 -0
  150. package/reference/sql/transactor-zio.md +399 -0
  151. package/reference/sql/transactor.md +363 -0
  152. package/reference/sql-zio.md +112 -0
  153. package/reference/streams/core/index.md +32 -0
  154. package/reference/streams/core/pipeline.md +854 -0
  155. package/reference/streams/core/sink.md +1404 -0
  156. package/reference/streams/core/stream.md +3236 -0
  157. package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
  158. package/reference/streams/execution-and-compatibility/index.md +35 -0
  159. package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
  160. package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
  161. package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
  162. package/reference/streams/index.md +726 -0
  163. package/reference/streams/primitives/index.md +30 -0
  164. package/reference/streams/primitives/reader.md +1992 -0
  165. package/reference/streams/primitives/writer.md +1201 -0
  166. package/reference/telemetry/common/any-value.md +90 -0
  167. package/reference/telemetry/common/attribute-key.md +87 -0
  168. package/reference/telemetry/common/attributes.md +118 -0
  169. package/reference/telemetry/common/index.md +39 -0
  170. package/reference/telemetry/common/instrumentation-scope.md +24 -0
  171. package/reference/telemetry/common/resource.md +34 -0
  172. package/reference/telemetry/index.md +311 -0
  173. package/reference/telemetry/logging/index.md +197 -0
  174. package/reference/telemetry/logging/log-enrichment.md +72 -0
  175. package/reference/telemetry/logging/log-formatter.md +100 -0
  176. package/reference/telemetry/logging/log-record-processor.md +56 -0
  177. package/reference/telemetry/logging/log-record.md +44 -0
  178. package/reference/telemetry/logging/log-writer.md +64 -0
  179. package/reference/telemetry/logging/logger-provider.md +142 -0
  180. package/reference/telemetry/logging/logger.md +83 -0
  181. package/reference/telemetry/logging/severity.md +62 -0
  182. package/reference/telemetry/metrics/index.md +150 -0
  183. package/reference/telemetry/metrics/instruments.md +183 -0
  184. package/reference/telemetry/metrics/labeled-instruments.md +74 -0
  185. package/reference/telemetry/metrics/meter-provider.md +76 -0
  186. package/reference/telemetry/metrics/meter.md +98 -0
  187. package/reference/telemetry/metrics/metric-data.md +57 -0
  188. package/reference/telemetry/otel/custom-exporter.md +216 -0
  189. package/reference/telemetry/otel/index.md +212 -0
  190. package/reference/telemetry/tracing/index.md +155 -0
  191. package/reference/telemetry/tracing/sampler.md +89 -0
  192. package/reference/telemetry/tracing/span-builder.md +57 -0
  193. package/reference/telemetry/tracing/span-context.md +39 -0
  194. package/reference/telemetry/tracing/span-data.md +32 -0
  195. package/reference/telemetry/tracing/span-kind.md +55 -0
  196. package/reference/telemetry/tracing/span-processor.md +53 -0
  197. package/reference/telemetry/tracing/span-status.md +47 -0
  198. package/reference/telemetry/tracing/span.md +117 -0
  199. package/reference/telemetry/tracing/tracer-provider.md +91 -0
  200. package/reference/telemetry/tracing/tracer.md +52 -0
  201. package/reference/typeid.md +5 -83
  202. package/sidebars.js +376 -43
  203. package/undocumented-report.md +528 -270
  204. package/reference/formats.md +0 -694
  205. package/reference/http-model.md +0 -1716
  206. package/reference/streams.md +0 -989
  207. package/ringbuffer.md +0 -249
  208. /package/reference/{json-differ.md → schema/built-in-codecs/json/json-differ.md} +0 -0
  209. /package/reference/{dynamic-value.md → schema/dynamic-value.md} +0 -0
  210. /package/reference/{lazy.md → schema/lazy.md} +0 -0
  211. /package/reference/{reflect.md → schema/reflect.md} +0 -0
  212. /package/reference/{registers.md → schema/registers.md} +0 -0
  213. /package/reference/{schema-error.md → schema/schema-error.md} +0 -0
  214. /package/reference/{syntax.md → schema/syntax.md} +0 -0
  215. /package/reference/{validation.md → schema/validation.md} +0 -0
@@ -9,7 +9,7 @@ This is Part 2 of the Query DSL series. [Part 1](./query-dsl-reified-optics.md)
9
9
 
10
10
  **What we'll cover:**
11
11
 
12
- - Interpreting `SchemaExpr` as a sealed AST via pattern matching
12
+ - Interpreting `SchemaExpr` while keeping the typed API at the boundary
13
13
  - Extracting column names from optic paths using `DynamicOptic`
14
14
  - Translating relational, logical, arithmetic, and string operations to SQL
15
15
  - Building complete `SELECT ... FROM ... WHERE ...` statements
@@ -36,14 +36,14 @@ def findProducts(category: Option[String], maxPrice: Option[Double], inStock: Op
36
36
 
37
37
  This is fragile, repetitive, and vulnerable to SQL injection. Every new query shape requires new string-building code. The query logic is duplicated -- once as a `SchemaExpr` for in-memory filtering, and again as hand-written SQL for the database.
38
38
 
39
- Since `SchemaExpr` is a sealed trait, we can write a single interpreter that translates *any* query expression into SQL. Write the interpreter once, and every query you build with the Part 1 DSL automatically gets a SQL translation.
39
+ `SchemaExpr` is the user-facing query API. Internally it wraps a `DynamicSchemaExpr` — a sealed trait whose cases represent the full expression AST. That means we can write a single interpreter that accepts `SchemaExpr`, then crosses into the dynamic AST internally to translate *any* query expression into SQL. Write the interpreter once, and every query you build with the Part 1 DSL automatically gets a SQL translation.
40
40
 
41
41
  ## Prerequisites
42
42
 
43
43
  This guide builds on [Part 1: Expressions](./query-dsl-reified-optics.md). You should be comfortable building `SchemaExpr` values with optic operators (`===`, `>`, `&&`, etc.).
44
44
 
45
45
  ```scala
46
- libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.33"
46
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.55"
47
47
  ```
48
48
 
49
49
  ```scala
@@ -74,24 +74,33 @@ object Product extends CompanionOptics[Product] {
74
74
  }
75
75
  ```
76
76
 
77
- ## The SchemaExpr AST
77
+ ## The SchemaExpr API
78
78
 
79
- Before we build the interpreter, let's understand the structure we are interpreting. `SchemaExpr` is a sealed trait with these cases:
79
+ Before we build the interpreter, keep the API boundary in mind: application code builds `SchemaExpr[A, B]` values, while interpreter code may inspect the underlying `DynamicSchemaExpr` through `.dynamic`.
80
80
 
81
81
  ```
82
- SchemaExpr[A, B]
83
- ├── Literal[S, A](value, schema) -- a constant value
84
- ├── Optic[A, B](optic) -- a field reference
85
- ├── StringRegexMatch[A](regex, string) -- regex pattern matching
86
- ├── StringLength[A](string) -- string length calculation
87
- ├── UnaryOp[A, B] -- abstract trait for unary operations
88
- │ └── Not[A](expr) -- boolean negation
89
- └── BinaryOp[A, B, C] -- abstract trait for binary operations
90
- ├── Relational[A, B](left, right, operator) -- comparison operations
91
- ├── Logical[A](left, right, operator) -- boolean operations
92
- ├── Arithmetic[S, A](left, right, operator, isNumeric) -- numeric operations
93
- └── StringConcat[A](left, right) -- string concatenation
82
+ SchemaExpr[A, B] -- user-facing, typed API
83
+ └── .dynamic: DynamicSchemaExpr -- interpreter/runtime boundary
84
+ ├── Select(path: DynamicOptic) -- field reference
85
+ ├── Literal(value: DynamicValue, schema: Schema[_]) -- constant value
86
+ ├── Relational(left, right, op) -- comparisons
87
+ ├── Logical(left, right, op) -- boolean operators
88
+ ├── Not(expr) -- negation
89
+ ├── Arithmetic(left, right, op, _) -- numeric operators
90
+ ├── StringConcat(left, right) -- string concatenation
91
+ ├── StringRegexMatch(regex, string) -- pattern matching
92
+ └── StringLength(string) -- string length
93
+ ```
94
+
95
+ Most users never need to construct `DynamicSchemaExpr` directly. The normal workflow is:
96
+
97
+ 1. Build a typed `SchemaExpr` with optics and operators.
98
+ 2. Pass that `SchemaExpr` to your interpreter.
99
+ 3. Let the interpreter read `.dynamic` internally.
94
100
 
101
+ The dynamic cases are still worth understanding because they are what your interpreter will pattern-match on:
102
+
103
+ ```
95
104
  RelationalOperator
96
105
  ├── LessThan
97
106
  ├── GreaterThan
@@ -110,7 +119,7 @@ ArithmeticOperator
110
119
  └── Multiply
111
120
  ```
112
121
 
113
- Each case carries enough information to produce SQL: `Optic` nodes carry field paths, `Literal` nodes carry values, and operator nodes carry the operation type. Our interpreter walks this tree and emits SQL fragments.
122
+ Each dynamic case carries enough information to produce SQL: `Select` nodes carry field paths, `Literal` nodes carry values, and operator nodes carry the operation type. Our interpreter walks this tree and emits SQL fragments.
114
123
 
115
124
  ## Extracting Column Names from Optics
116
125
 
@@ -121,6 +130,9 @@ def columnName(optic: zio.blocks.schema.Optic[?, ?]): String = {
121
130
  val nodes = optic.toDynamic.nodes
122
131
  nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
123
132
  }
133
+
134
+ def columnName(path: DynamicOptic): String =
135
+ path.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
124
136
  ```
125
137
 
126
138
  This converts the optic path to a column name. For a simple field like `Product.price`, it produces `"price"`. For a nested path, it joins field names with underscores (we will refine this for table-qualified names later).
@@ -136,92 +148,106 @@ columnName(Product.category)
136
148
 
137
149
  ## Translating Literals to SQL
138
150
 
139
- Literal values need proper SQL formatting -- strings must be quoted, booleans converted to SQL syntax:
151
+ Once we cross the interpreter boundary, literal values appear as `DynamicValue`. We need a function to format them as SQL:
140
152
 
141
153
  ```scala
142
- def sqlLiteral(value: Any): String = value match {
143
- case s: String => s"'${s.replace("'", "''")}'"
144
- case b: Boolean => if (b) "TRUE" else "FALSE"
145
- case n: Number => n.toString
146
- case other => other.toString
154
+ def sqlLiteralDV(dv: DynamicValue): String = dv match {
155
+ case DynamicValue.Primitive(pv) =>
156
+ pv match {
157
+ case PrimitiveValue.String(s) => s"'${s.replace("'", "''")}'"
158
+ case PrimitiveValue.Boolean(b) => if (b) "TRUE" else "FALSE"
159
+ case PrimitiveValue.Int(n) => n.toString
160
+ case PrimitiveValue.Long(n) => n.toString
161
+ case PrimitiveValue.Double(n) => n.toString
162
+ case PrimitiveValue.Float(n) => n.toString
163
+ case PrimitiveValue.Short(n) => n.toString
164
+ case PrimitiveValue.Byte(n) => n.toString
165
+ case other => other.toString
166
+ }
167
+ case other => other.toString
147
168
  }
148
169
  ```
149
170
 
150
171
  ## Building the SQL Interpreter
151
172
 
152
- Now we build the core interpreter. It pattern-matches on each `SchemaExpr` case and produces a SQL string:
173
+ Now we build the core interpreter. The public entry point accepts `SchemaExpr`; the internal helper does the `DynamicSchemaExpr` pattern matching:
153
174
 
154
175
  ```scala
155
- def toSql[A, B](expr: SchemaExpr[A, B]): String = expr match {
176
+ def toSql[A, B](expr: SchemaExpr[A, B]): String = toSqlDynamic(expr.dynamic)
177
+
178
+ private def toSqlDynamic(expr: DynamicSchemaExpr): String = expr match {
156
179
 
157
180
  // Field reference → column name
158
- case SchemaExpr.Optic(optic) =>
159
- columnName(optic)
181
+ case DynamicSchemaExpr.Select(path) =>
182
+ columnName(path)
160
183
 
161
184
  // Constant value → SQL literal
162
- case SchemaExpr.Literal(value, _) =>
163
- sqlLiteral(value)
185
+ case DynamicSchemaExpr.Literal(value, _) =>
186
+ sqlLiteralDV(value)
164
187
 
165
188
  // Comparison operators → SQL relational operators
166
- case SchemaExpr.Relational(left, right, op) =>
189
+ case DynamicSchemaExpr.Relational(left, right, op) =>
167
190
  val sqlOp = op match {
168
- case SchemaExpr.RelationalOperator.Equal => "="
169
- case SchemaExpr.RelationalOperator.NotEqual => "<>"
170
- case SchemaExpr.RelationalOperator.LessThan => "<"
171
- case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
172
- case SchemaExpr.RelationalOperator.GreaterThan => ">"
173
- case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
191
+ case DynamicSchemaExpr.RelationalOperator.Equal => "="
192
+ case DynamicSchemaExpr.RelationalOperator.NotEqual => "<>"
193
+ case DynamicSchemaExpr.RelationalOperator.LessThan => "<"
194
+ case DynamicSchemaExpr.RelationalOperator.LessThanOrEqual => "<="
195
+ case DynamicSchemaExpr.RelationalOperator.GreaterThan => ">"
196
+ case DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
174
197
  }
175
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
198
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
176
199
 
177
200
  // Boolean operators → AND / OR
178
- case SchemaExpr.Logical(left, right, op) =>
201
+ case DynamicSchemaExpr.Logical(left, right, op) =>
179
202
  val sqlOp = op match {
180
- case SchemaExpr.LogicalOperator.And => "AND"
181
- case SchemaExpr.LogicalOperator.Or => "OR"
203
+ case DynamicSchemaExpr.LogicalOperator.And => "AND"
204
+ case DynamicSchemaExpr.LogicalOperator.Or => "OR"
182
205
  }
183
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
206
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
184
207
 
185
208
  // Negation → NOT
186
- case SchemaExpr.Not(inner) =>
187
- s"NOT (${toSql(inner)})"
209
+ case DynamicSchemaExpr.Not(inner) =>
210
+ s"NOT (${toSqlDynamic(inner)})"
188
211
 
189
212
  // Arithmetic → SQL math operators
190
- case SchemaExpr.Arithmetic(left, right, op, _) =>
213
+ case DynamicSchemaExpr.Arithmetic(left, right, op, _) =>
191
214
  val sqlOp = op match {
192
- case SchemaExpr.ArithmeticOperator.Add => "+"
193
- case SchemaExpr.ArithmeticOperator.Subtract => "-"
194
- case SchemaExpr.ArithmeticOperator.Multiply => "*"
215
+ case DynamicSchemaExpr.ArithmeticOperator.Add => "+"
216
+ case DynamicSchemaExpr.ArithmeticOperator.Subtract => "-"
217
+ case DynamicSchemaExpr.ArithmeticOperator.Multiply => "*"
218
+ case _ => "?"
195
219
  }
196
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
220
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
197
221
 
198
222
  // String concatenation → CONCAT()
199
- case SchemaExpr.StringConcat(left, right) =>
200
- s"CONCAT(${toSql(left)}, ${toSql(right)})"
223
+ case DynamicSchemaExpr.StringConcat(left, right) =>
224
+ s"CONCAT(${toSqlDynamic(left)}, ${toSqlDynamic(right)})"
201
225
 
202
226
  // Regex match → column LIKE pattern (simplified)
203
- case SchemaExpr.StringRegexMatch(regex, string) =>
204
- s"(${toSql(string)} LIKE ${toSql(regex)})"
227
+ case DynamicSchemaExpr.StringRegexMatch(regex, string) =>
228
+ s"(${toSqlDynamic(string)} LIKE ${toSqlDynamic(regex)})"
205
229
 
206
230
  // String length → LENGTH()
207
- case SchemaExpr.StringLength(string) =>
208
- s"LENGTH(${toSql(string)})"
231
+ case DynamicSchemaExpr.StringLength(string) =>
232
+ s"LENGTH(${toSqlDynamic(string)})"
233
+
234
+ case _ => "?"
209
235
  }
210
236
  ```
211
237
 
212
- The mapping from `SchemaExpr` to SQL is direct:
238
+ The mapping from `DynamicSchemaExpr` to SQL is direct, but that dynamic matching stays inside the interpreter implementation:
213
239
 
214
- | SchemaExpr Case | SQL Output |
215
- |---------------------|--------------------------------------|
216
- | `Optic(optic)` | Column name from `toDynamic` |
217
- | `Literal(v, _)` | SQL literal (`'text'`, `42`, `TRUE`) |
218
- | `Relational(_, _, op)` | `=`, `<>`, `<`, `>`, `<=`, `>=` |
219
- | `Logical(_, _, op)` | `AND`, `OR` |
220
- | `Not(expr)` | `NOT (...)` |
221
- | `Arithmetic(_, _, op, _)` | `+`, `-`, `*` |
222
- | `StringConcat` | `CONCAT(a, b)` |
223
- | `StringRegexMatch` | `LIKE` (pattern matching) |
224
- | `StringLength` | `LENGTH(col)` |
240
+ | DynamicSchemaExpr Case | SQL Output |
241
+ |------------------------|--------------------------------------|
242
+ | `Select(path)` | Column name from `DynamicOptic` |
243
+ | `Literal(value, schema)` | SQL literal (`'text'`, `42`, `TRUE`) |
244
+ | `Relational(_, _, op)` | `=`, `<>`, `<`, `>`, `<=`, `>=` |
245
+ | `Logical(_, _, op)` | `AND`, `OR` |
246
+ | `Not(expr)` | `NOT (...)` |
247
+ | `Arithmetic(_, _, op, _)` | `+`, `-`, `*` |
248
+ | `StringConcat` | `CONCAT(a, b)` |
249
+ | `StringRegexMatch` | `LIKE` (pattern matching) |
250
+ | `StringLength` | `LENGTH(col)` |
225
251
 
226
252
  ## Generating SQL from Queries
227
253
 
@@ -367,63 +393,80 @@ The `toSql` function above inlines literal values directly into the SQL string.
367
393
  ```scala
368
394
  case class SqlQuery(sql: String, params: List[Any])
369
395
 
370
- def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = expr match {
371
-
372
- case SchemaExpr.Optic(optic) =>
373
- SqlQuery(columnName(optic), Nil)
374
-
375
- case SchemaExpr.Literal(value, _) =>
376
- SqlQuery("?", List(value))
396
+ def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = toParameterizedDynamic(expr.dynamic)
397
+
398
+ private def toParameterizedDynamic(expr: DynamicSchemaExpr): SqlQuery = expr match {
399
+
400
+ case DynamicSchemaExpr.Select(path) =>
401
+ SqlQuery(columnName(path), Nil)
402
+
403
+ case DynamicSchemaExpr.Literal(value, _) =>
404
+ val param = value match {
405
+ case DynamicValue.Primitive(pv) => pv match {
406
+ case PrimitiveValue.String(s) => s
407
+ case PrimitiveValue.Boolean(b) => b
408
+ case PrimitiveValue.Int(n) => n
409
+ case PrimitiveValue.Long(n) => n
410
+ case PrimitiveValue.Double(n) => n
411
+ case PrimitiveValue.Float(n) => n
412
+ case PrimitiveValue.Short(n) => n
413
+ case PrimitiveValue.Byte(n) => n
414
+ case PrimitiveValue.BigInt(n) => n
415
+ case PrimitiveValue.BigDecimal(n) => n
416
+ case PrimitiveValue.Char(c) => c
417
+ case other => other.toString
418
+ }
419
+ case other => other.toString
420
+ }
421
+ SqlQuery("?", List(param))
377
422
 
378
- case SchemaExpr.Relational(left, right, op) =>
379
- val l = toParameterized(left)
380
- val r = toParameterized(right)
423
+ case DynamicSchemaExpr.Relational(left, right, op) =>
424
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
381
425
  val sqlOp = op match {
382
- case SchemaExpr.RelationalOperator.Equal => "="
383
- case SchemaExpr.RelationalOperator.NotEqual => "<>"
384
- case SchemaExpr.RelationalOperator.LessThan => "<"
385
- case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
386
- case SchemaExpr.RelationalOperator.GreaterThan => ">"
387
- case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
426
+ case DynamicSchemaExpr.RelationalOperator.Equal => "="
427
+ case DynamicSchemaExpr.RelationalOperator.NotEqual => "<>"
428
+ case DynamicSchemaExpr.RelationalOperator.LessThan => "<"
429
+ case DynamicSchemaExpr.RelationalOperator.LessThanOrEqual => "<="
430
+ case DynamicSchemaExpr.RelationalOperator.GreaterThan => ">"
431
+ case DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
388
432
  }
389
433
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
390
434
 
391
- case SchemaExpr.Logical(left, right, op) =>
392
- val l = toParameterized(left)
393
- val r = toParameterized(right)
435
+ case DynamicSchemaExpr.Logical(left, right, op) =>
436
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
394
437
  val sqlOp = op match {
395
- case SchemaExpr.LogicalOperator.And => "AND"
396
- case SchemaExpr.LogicalOperator.Or => "OR"
438
+ case DynamicSchemaExpr.LogicalOperator.And => "AND"
439
+ case DynamicSchemaExpr.LogicalOperator.Or => "OR"
397
440
  }
398
441
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
399
442
 
400
- case SchemaExpr.Not(inner) =>
401
- val i = toParameterized(inner)
443
+ case DynamicSchemaExpr.Not(inner) =>
444
+ val i = toParameterizedDynamic(inner)
402
445
  SqlQuery(s"NOT (${i.sql})", i.params)
403
446
 
404
- case SchemaExpr.Arithmetic(left, right, op, _) =>
405
- val l = toParameterized(left)
406
- val r = toParameterized(right)
447
+ case DynamicSchemaExpr.Arithmetic(left, right, op, _) =>
448
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
407
449
  val sqlOp = op match {
408
- case SchemaExpr.ArithmeticOperator.Add => "+"
409
- case SchemaExpr.ArithmeticOperator.Subtract => "-"
410
- case SchemaExpr.ArithmeticOperator.Multiply => "*"
450
+ case DynamicSchemaExpr.ArithmeticOperator.Add => "+"
451
+ case DynamicSchemaExpr.ArithmeticOperator.Subtract => "-"
452
+ case DynamicSchemaExpr.ArithmeticOperator.Multiply => "*"
453
+ case _ => "?"
411
454
  }
412
455
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
413
456
 
414
- case SchemaExpr.StringConcat(left, right) =>
415
- val l = toParameterized(left)
416
- val r = toParameterized(right)
457
+ case DynamicSchemaExpr.StringConcat(left, right) =>
458
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
417
459
  SqlQuery(s"CONCAT(${l.sql}, ${r.sql})", l.params ++ r.params)
418
460
 
419
- case SchemaExpr.StringRegexMatch(regex, string) =>
420
- val s = toParameterized(string)
421
- val r = toParameterized(regex)
461
+ case DynamicSchemaExpr.StringRegexMatch(regex, string) =>
462
+ val s = toParameterizedDynamic(string); val r = toParameterizedDynamic(regex)
422
463
  SqlQuery(s"(${s.sql} LIKE ${r.sql})", s.params ++ r.params)
423
464
 
424
- case SchemaExpr.StringLength(string) =>
425
- val s = toParameterized(string)
465
+ case DynamicSchemaExpr.StringLength(string) =>
466
+ val s = toParameterizedDynamic(string)
426
467
  SqlQuery(s"LENGTH(${s.sql})", s.params)
468
+
469
+ case _ => SqlQuery("?", Nil)
427
470
  }
428
471
  ```
429
472
 
@@ -549,90 +592,128 @@ object Product extends CompanionOptics[Product] {
549
592
  def columnName(optic: zio.blocks.schema.Optic[?, ?]): String =
550
593
  optic.toDynamic.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
551
594
 
552
- def sqlLiteral(value: Any): String = value match {
553
- case s: String => s"'${s.replace("'", "''")}'"
554
- case b: Boolean => if (b) "TRUE" else "FALSE"
555
- case n: Number => n.toString
556
- case other => other.toString
595
+ def columnName(path: DynamicOptic): String =
596
+ path.nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
597
+
598
+ def sqlLiteralDV(dv: DynamicValue): String = dv match {
599
+ case DynamicValue.Primitive(pv) =>
600
+ pv match {
601
+ case PrimitiveValue.String(s) => s"'${s.replace("'", "''")}'"
602
+ case PrimitiveValue.Boolean(b) => if (b) "TRUE" else "FALSE"
603
+ case PrimitiveValue.Int(n) => n.toString
604
+ case PrimitiveValue.Long(n) => n.toString
605
+ case PrimitiveValue.Double(n) => n.toString
606
+ case PrimitiveValue.Float(n) => n.toString
607
+ case PrimitiveValue.Short(n) => n.toString
608
+ case PrimitiveValue.Byte(n) => n.toString
609
+ case other => other.toString
610
+ }
611
+ case other => other.toString
557
612
  }
558
613
 
559
- def toSql[A, B](expr: SchemaExpr[A, B]): String = expr match {
560
- case SchemaExpr.Optic(optic) => columnName(optic)
561
- case SchemaExpr.Literal(value, _) => sqlLiteral(value)
562
- case SchemaExpr.Relational(left, right, op) =>
614
+ def toSql[A, B](expr: SchemaExpr[A, B]): String = toSqlDynamic(expr.dynamic)
615
+
616
+ private def toSqlDynamic(expr: DynamicSchemaExpr): String = expr match {
617
+ case DynamicSchemaExpr.Select(path) => columnName(path)
618
+ case DynamicSchemaExpr.Literal(value, _) => sqlLiteralDV(value)
619
+ case DynamicSchemaExpr.Relational(left, right, op) =>
563
620
  val sqlOp = op match {
564
- case SchemaExpr.RelationalOperator.Equal => "="
565
- case SchemaExpr.RelationalOperator.NotEqual => "<>"
566
- case SchemaExpr.RelationalOperator.LessThan => "<"
567
- case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
568
- case SchemaExpr.RelationalOperator.GreaterThan => ">"
569
- case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
621
+ case DynamicSchemaExpr.RelationalOperator.Equal => "="
622
+ case DynamicSchemaExpr.RelationalOperator.NotEqual => "<>"
623
+ case DynamicSchemaExpr.RelationalOperator.LessThan => "<"
624
+ case DynamicSchemaExpr.RelationalOperator.LessThanOrEqual => "<="
625
+ case DynamicSchemaExpr.RelationalOperator.GreaterThan => ">"
626
+ case DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
570
627
  }
571
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
572
- case SchemaExpr.Logical(left, right, op) =>
628
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
629
+ case DynamicSchemaExpr.Logical(left, right, op) =>
573
630
  val sqlOp = op match {
574
- case SchemaExpr.LogicalOperator.And => "AND"
575
- case SchemaExpr.LogicalOperator.Or => "OR"
631
+ case DynamicSchemaExpr.LogicalOperator.And => "AND"
632
+ case DynamicSchemaExpr.LogicalOperator.Or => "OR"
576
633
  }
577
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
578
- case SchemaExpr.Not(inner) => s"NOT (${toSql(inner)})"
579
- case SchemaExpr.Arithmetic(left, right, op, _) =>
634
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
635
+ case DynamicSchemaExpr.Not(inner) => s"NOT (${toSqlDynamic(inner)})"
636
+ case DynamicSchemaExpr.Arithmetic(left, right, op, _) =>
580
637
  val sqlOp = op match {
581
- case SchemaExpr.ArithmeticOperator.Add => "+"
582
- case SchemaExpr.ArithmeticOperator.Subtract => "-"
583
- case SchemaExpr.ArithmeticOperator.Multiply => "*"
638
+ case DynamicSchemaExpr.ArithmeticOperator.Add => "+"
639
+ case DynamicSchemaExpr.ArithmeticOperator.Subtract => "-"
640
+ case DynamicSchemaExpr.ArithmeticOperator.Multiply => "*"
641
+ case _ => "?"
584
642
  }
585
- s"(${toSql(left)} $sqlOp ${toSql(right)})"
586
- case SchemaExpr.StringConcat(left, right) => s"CONCAT(${toSql(left)}, ${toSql(right)})"
587
- case SchemaExpr.StringRegexMatch(regex, string) => s"(${toSql(string)} LIKE ${toSql(regex)})"
588
- case SchemaExpr.StringLength(string) => s"LENGTH(${toSql(string)})"
643
+ s"(${toSqlDynamic(left)} $sqlOp ${toSqlDynamic(right)})"
644
+ case DynamicSchemaExpr.StringConcat(left, right) => s"CONCAT(${toSqlDynamic(left)}, ${toSqlDynamic(right)})"
645
+ case DynamicSchemaExpr.StringRegexMatch(regex, string) => s"(${toSqlDynamic(string)} LIKE ${toSqlDynamic(regex)})"
646
+ case DynamicSchemaExpr.StringLength(string) => s"LENGTH(${toSqlDynamic(string)})"
647
+ case _ => "?"
589
648
  }
590
649
 
591
650
  // --- Parameterized queries ---
592
651
 
593
652
  case class SqlQuery(sql: String, params: List[Any])
594
653
 
595
- def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = expr match {
596
- case SchemaExpr.Optic(optic) => SqlQuery(columnName(optic), Nil)
597
- case SchemaExpr.Literal(value, _) => SqlQuery("?", List(value))
598
- case SchemaExpr.Relational(left, right, op) =>
599
- val l = toParameterized(left); val r = toParameterized(right)
654
+ def toParameterized[A, B](expr: SchemaExpr[A, B]): SqlQuery = toParameterizedDynamic(expr.dynamic)
655
+
656
+ private def toParameterizedDynamic(expr: DynamicSchemaExpr): SqlQuery = expr match {
657
+ case DynamicSchemaExpr.Select(path) => SqlQuery(columnName(path), Nil)
658
+ case DynamicSchemaExpr.Literal(value, _) =>
659
+ val param = value match {
660
+ case DynamicValue.Primitive(pv) => pv match {
661
+ case PrimitiveValue.String(s) => s
662
+ case PrimitiveValue.Boolean(b) => b
663
+ case PrimitiveValue.Int(n) => n
664
+ case PrimitiveValue.Long(n) => n
665
+ case PrimitiveValue.Double(n) => n
666
+ case PrimitiveValue.Float(n) => n
667
+ case PrimitiveValue.Short(n) => n
668
+ case PrimitiveValue.Byte(n) => n
669
+ case PrimitiveValue.BigInt(n) => n
670
+ case PrimitiveValue.BigDecimal(n) => n
671
+ case PrimitiveValue.Char(c) => c
672
+ case other => other.toString
673
+ }
674
+ case other => other.toString
675
+ }
676
+ SqlQuery("?", List(param))
677
+ case DynamicSchemaExpr.Relational(left, right, op) =>
678
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
600
679
  val sqlOp = op match {
601
- case SchemaExpr.RelationalOperator.Equal => "="
602
- case SchemaExpr.RelationalOperator.NotEqual => "<>"
603
- case SchemaExpr.RelationalOperator.LessThan => "<"
604
- case SchemaExpr.RelationalOperator.LessThanOrEqual => "<="
605
- case SchemaExpr.RelationalOperator.GreaterThan => ">"
606
- case SchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
680
+ case DynamicSchemaExpr.RelationalOperator.Equal => "="
681
+ case DynamicSchemaExpr.RelationalOperator.NotEqual => "<>"
682
+ case DynamicSchemaExpr.RelationalOperator.LessThan => "<"
683
+ case DynamicSchemaExpr.RelationalOperator.LessThanOrEqual => "<="
684
+ case DynamicSchemaExpr.RelationalOperator.GreaterThan => ">"
685
+ case DynamicSchemaExpr.RelationalOperator.GreaterThanOrEqual => ">="
607
686
  }
608
687
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
609
- case SchemaExpr.Logical(left, right, op) =>
610
- val l = toParameterized(left); val r = toParameterized(right)
688
+ case DynamicSchemaExpr.Logical(left, right, op) =>
689
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
611
690
  val sqlOp = op match {
612
- case SchemaExpr.LogicalOperator.And => "AND"
613
- case SchemaExpr.LogicalOperator.Or => "OR"
691
+ case DynamicSchemaExpr.LogicalOperator.And => "AND"
692
+ case DynamicSchemaExpr.LogicalOperator.Or => "OR"
614
693
  }
615
694
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
616
- case SchemaExpr.Not(inner) =>
617
- val i = toParameterized(inner)
695
+ case DynamicSchemaExpr.Not(inner) =>
696
+ val i = toParameterizedDynamic(inner)
618
697
  SqlQuery(s"NOT (${i.sql})", i.params)
619
- case SchemaExpr.Arithmetic(left, right, op, _) =>
620
- val l = toParameterized(left); val r = toParameterized(right)
698
+ case DynamicSchemaExpr.Arithmetic(left, right, op, _) =>
699
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
621
700
  val sqlOp = op match {
622
- case SchemaExpr.ArithmeticOperator.Add => "+"
623
- case SchemaExpr.ArithmeticOperator.Subtract => "-"
624
- case SchemaExpr.ArithmeticOperator.Multiply => "*"
701
+ case DynamicSchemaExpr.ArithmeticOperator.Add => "+"
702
+ case DynamicSchemaExpr.ArithmeticOperator.Subtract => "-"
703
+ case DynamicSchemaExpr.ArithmeticOperator.Multiply => "*"
704
+ case _ => "?"
625
705
  }
626
706
  SqlQuery(s"(${l.sql} $sqlOp ${r.sql})", l.params ++ r.params)
627
- case SchemaExpr.StringConcat(left, right) =>
628
- val l = toParameterized(left); val r = toParameterized(right)
707
+ case DynamicSchemaExpr.StringConcat(left, right) =>
708
+ val l = toParameterizedDynamic(left); val r = toParameterizedDynamic(right)
629
709
  SqlQuery(s"CONCAT(${l.sql}, ${r.sql})", l.params ++ r.params)
630
- case SchemaExpr.StringRegexMatch(regex, string) =>
631
- val s = toParameterized(string); val r = toParameterized(regex)
710
+ case DynamicSchemaExpr.StringRegexMatch(regex, string) =>
711
+ val s = toParameterizedDynamic(string); val r = toParameterizedDynamic(regex)
632
712
  SqlQuery(s"(${s.sql} LIKE ${r.sql})", s.params ++ r.params)
633
- case SchemaExpr.StringLength(string) =>
634
- val s = toParameterized(string)
713
+ case DynamicSchemaExpr.StringLength(string) =>
714
+ val s = toParameterizedDynamic(string)
635
715
  SqlQuery(s"LENGTH(${s.sql})", s.params)
716
+ case _ => SqlQuery("?", Nil)
636
717
  }
637
718
 
638
719
  // --- Complete SELECT builder ---
@@ -668,13 +749,407 @@ println(toSql(Product.price * 0.9))
668
749
  // (price * 0.9)
669
750
  ```
670
751
 
752
+ ## Upsert (ON CONFLICT)
753
+
754
+ Upsert (insert-or-update) combines an `INSERT` with a conflict handler so the
755
+ statement is idempotent. When a row with the conflicting key already exists, the
756
+ database either skips the insert or updates specified columns.
757
+
758
+ All identifiers (table name, column names, conflict column) are validated through
759
+ `SqlIdentifier.validate` and assignment columns are additionally checked against
760
+ `Table.columns`. Invalid or unknown names throw `IllegalArgumentException` at
761
+ build time, not at execution time.
762
+
763
+ ### Table-aware builders
764
+
765
+ The high-level `Upsert` builders accept a `Table[A]` and an entity. The table
766
+ provides column names and a codec that extracts `DbValue` parameters.
767
+
768
+ `Upsert.insertDoNothing` builds `INSERT ... ON CONFLICT ("id") DO NOTHING`:
769
+
770
+ ```scala
771
+ import zio.blocks.sql.*
772
+ import zio.blocks.schema.Schema
773
+
774
+ case class User(id: Int, name: String, email: String)
775
+ object User { implicit val schema: Schema[User] = Schema.derived }
776
+
777
+ val table: Table[User] = Table.derived[User]
778
+ val user = User(42, "Alice", "alice@example.com")
779
+
780
+ // INSERT INTO "user" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO NOTHING
781
+ val frag: Frag = Upsert.insertDoNothing(table, user, conflictColumn = "id")
782
+ ```
783
+
784
+ `Upsert.insertDoUpdate` builds `INSERT ... ON CONFLICT ("id") DO UPDATE SET`
785
+ for **all** non-conflict columns:
786
+
787
+ ```scala
788
+ import zio.blocks.sql.*
789
+ import zio.blocks.schema.Schema
790
+
791
+ case class User(id: Int, name: String, email: String)
792
+ object User { implicit val schema: Schema[User] = Schema.derived }
793
+
794
+ val table: Table[User] = Table.derived[User]
795
+ val user = User(42, "Alice", "alice@example.com")
796
+
797
+ // INSERT INTO "user" ("id", "name", "email") VALUES (?, ?, ?)
798
+ // ON CONFLICT ("id") DO UPDATE SET "name" = ?, "email" = ?
799
+ val frag: Frag = Upsert.insertDoUpdate(table, user, conflictColumn = "id")
800
+ ```
801
+
802
+ Pass `updateColumns` to restrict which columns are overwritten on conflict:
803
+
804
+ ```scala
805
+ import zio.blocks.sql.*
806
+ import zio.blocks.schema.Schema
807
+
808
+ case class User(id: Int, name: String, email: String)
809
+ object User { implicit val schema: Schema[User] = Schema.derived }
810
+
811
+ val table: Table[User] = Table.derived[User]
812
+ val user = User(42, "Alice", "alice@example.com")
813
+
814
+ // Only "name" is updated on conflict; "email" keeps its original value
815
+ val frag: Frag = Upsert.insertDoUpdate(table, user, conflictColumn = "id", updateColumns = Seq("name"))
816
+ ```
817
+
818
+ ### Low-level builders
819
+
820
+ When you need full control over column names and values (e.g. computed or
821
+ transformed data), use the low-level `Upsert.doNothing`, `Upsert.doNothingRaw`,
822
+ and `Upsert.doUpdate` builders:
823
+
824
+ ```scala
825
+ import zio.blocks.sql.*
826
+
827
+ // Low-level DO NOTHING with explicit columns and values
828
+ val frag1: Frag = Upsert.doNothing(
829
+ tableName = "users",
830
+ columns = IndexedSeq("id", "name", "email"),
831
+ values = IndexedSeq(DbValue.DbInt(1), DbValue.DbString("Bob"), DbValue.DbString("bob@example.com")),
832
+ conflictColumn = "id"
833
+ )
834
+
835
+ // Comma-joined column string variant
836
+ val frag2: Frag = Upsert.doNothingRaw(
837
+ tableName = "users",
838
+ allColumns = "id, name, email",
839
+ values = IndexedSeq(DbValue.DbInt(1), DbValue.DbString("Bob"), DbValue.DbString("bob@example.com")),
840
+ conflictColumn = "id"
841
+ )
842
+
843
+ // Low-level DO UPDATE with explicit assignments
844
+ val frag3: Frag = Upsert.doUpdate(
845
+ tableName = "users",
846
+ columns = IndexedSeq("id", "name", "email"),
847
+ values = IndexedSeq(DbValue.DbInt(1), DbValue.DbString("Bob"), DbValue.DbString("bob@example.com")),
848
+ conflictColumn = "id",
849
+ assignments = IndexedSeq("name" -> DbValue.DbString("Bob"), "email" -> DbValue.DbString("bob@example.com"))
850
+ )
851
+ ```
852
+
853
+ ### Suffix builders
854
+
855
+ To append an `ON CONFLICT` clause to an existing `INSERT` `Frag`, use the suffix
856
+ builders:
857
+
858
+ ```scala
859
+ import zio.blocks.sql.*
860
+
861
+ val base: Frag = Frag.literal("INSERT INTO users (id, name) VALUES (?, ?)")
862
+
863
+ // Append DO NOTHING suffix
864
+ val withNothing: Frag = base ++ Upsert.doNothingSuffix(conflictColumn = "id")
865
+
866
+ // Append DO UPDATE suffix with explicit assignments
867
+ val withUpdate: Frag = base ++ Upsert.doUpdateSuffix(
868
+ conflictColumn = "id",
869
+ assignments = IndexedSeq("name" -> DbValue.DbString("updated"))
870
+ )
871
+ ```
872
+
873
+ ### Repository integration
874
+
875
+ `Repo` provides `insertOrUpdate` and `insertOrUpdateBatch` as convenience
876
+ wrappers that use `Upsert.insertDoUpdate` under the hood. The conflict target is
877
+ the repository's validated ID column, and all non-ID columns are overwritten with
878
+ the entity's values.
879
+
880
+ ```scala
881
+ import zio.blocks.sql.*
882
+ import zio.blocks.schema.Schema
883
+
884
+ case class User(id: Int, name: String, email: String)
885
+ object User { implicit val schema: Schema[User] = Schema.derived }
886
+
887
+ given DbCon = ???
888
+
889
+ val table: Table[User] = Table.derived[User]
890
+ val repo: Repo[User, Int] = ???
891
+
892
+ val user = User(42, "Alice", "alice@example.com")
893
+
894
+ // Single upsert
895
+ val affected: Int = repo.insertOrUpdate(user)
896
+
897
+ // Batch upsert
898
+ val users: List[User] = List(user, User(43, "Bob", "bob@example.com"))
899
+ val totalAffected: Int = repo.insertOrUpdateBatch(users)
900
+ ```
901
+
902
+ These generate SQL like:
903
+
904
+ ```sql
905
+ INSERT INTO "user" ("id", "name", "email") VALUES (?, ?, ?)
906
+ ON CONFLICT ("id") DO UPDATE SET "name" = ?, "email" = ?
907
+ ```
908
+
909
+ `insertOrUpdateBatch` uses a JDBC batch for efficiency, mirroring the pattern of
910
+ `insertBatch`. Both return the total affected row count.
911
+
912
+ ## Keyset Pagination
913
+
914
+ Keyset (cursor) pagination avoids the cost and drift of `OFFSET` by seeking
915
+ after the last seen key: `WHERE id > ? ORDER BY id ASC LIMIT n`. The row
916
+ identified by the cursor is excluded (`>` not `>=`) so consecutive pages do
917
+ not duplicate the boundary row.
918
+
919
+ `Repo` exposes this directly for primary-key cursors:
920
+
921
+ ```scala
922
+ import zio.blocks.sql.*
923
+ import zio.blocks.schema.Schema
924
+
925
+ case class User(id: Int, name: String, email: String)
926
+ object User { implicit val schema: Schema[User] = Schema.derived }
927
+
928
+ given DbCon = ???
929
+
930
+ val repo: Repo[User, Int] = ??? // e.g. Repo(table, "id", idCodec, _.id)
931
+
932
+ val firstPage: List[User] = repo.pageAfter(cursorId = 0, limit = 20)
933
+ val nextPage: List[User] = repo.pageAfter(cursorId = firstPage.last.id, limit = 20)
934
+ // when cursorId == last id, nextPage is empty
935
+ ```
936
+
937
+ It renders as:
938
+
939
+ ```sql
940
+ SELECT "id", "name", "email" FROM "user" WHERE "id" > ? ORDER BY "id" ASC LIMIT 20
941
+ ```
942
+
943
+ where `?` is bound via `idCodec.toDbValues(cursorId)`. `limit` must be `> 0`.
944
+
945
+ For non-ID orderings or ad-hoc queries, `Frag.keysetAfter` builds the
946
+ portable `WHERE col > ? ORDER BY col ASC LIMIT n` fragment without a `Repo`:
947
+
948
+ ```scala
949
+ import zio.blocks.sql.*
950
+
951
+ case class User(id: Int, name: String, email: String)
952
+ import zio.blocks.schema.Schema
953
+ object User { implicit val schema: Schema[User] = Schema.derived }
954
+ val table: Table[User] = Table.derived[User]
955
+
956
+ // Table-validated: rejects unknown columns
957
+ val frag: Frag = Frag.keysetAfter(table, orderCol = "id", lastValue = DbValue.DbInt(42), limit = 20)
958
+ // frag.sql(dialect) == " WHERE id > ? ORDER BY id ASC LIMIT 20"
959
+
960
+ // Without a table: identifier-only validation
961
+ val frag2: Frag = Frag.keysetAfter(orderCol = "created_at", lastValue = DbValue.DbLong(1000L), limit = 10)
962
+ ```
963
+
964
+ - `Frag.keysetAfter(table, orderCol, lastValue, limit)` validates `orderCol`
965
+ with `SqlIdentifier.validate` and checks membership in `table.columns`;
966
+ unknown columns throw `IllegalArgumentException`.
967
+ - `Frag.keysetAfter(orderCol, lastValue, limit)` validates the identifier only.
968
+ - `limit` must be `> 0`; single-column cursors only (composite cursors are v2).
969
+
970
+ Compose with a base `SELECT`:
971
+
972
+ ```scala
973
+ import zio.blocks.sql.*
974
+ import zio.blocks.schema.Schema
975
+
976
+ case class User(id: Int, name: String, email: String)
977
+ object User { implicit val schema: Schema[User] = Schema.derived }
978
+ val table: Table[User] = Table.derived[User]
979
+
980
+ val base = Frag.literal("SELECT id, name, email FROM user")
981
+ val pageFrag = base ++ Frag.keysetAfter(table, "id", DbValue.DbInt(42), 20)
982
+ // Rendering the SQL does not require a DbCon:
983
+ val sql: String = pageFrag.sql(SqlDialect.SQLite) // SELECT id, name, email FROM user WHERE id > ? ORDER BY id ASC LIMIT 20
984
+ // Executing needs givens at the call site:
985
+ // given DbCon = ???
986
+ // given DbCodec[User] = table.codec
987
+ // val rows: List[User] = pageFrag.query[User]
988
+ ```
989
+
990
+ ## Inspecting SQL
991
+
992
+ The custom interpreter above produces raw strings useful for debugging. The `sql` module's query IR (`zio.blocks.sql.query.SqlQuery`) provides richer inspection APIs.
993
+
994
+ ### explain(dialect): String
995
+
996
+ The `explain` method renders the full SQL text with numbered parameter placeholders (`?1`, `?2`, ...) and a trailing comment listing each parameter's position and type:
997
+
998
+ ```scala
999
+ import zio.blocks.sql._
1000
+ import zio.blocks.sql.query.{Rel, SqlQuery => Select}
1001
+
1002
+ val userTable = Table.derived[User]
1003
+ val repoTable = Table.derived[Repo]
1004
+
1005
+ val q = Select
1006
+ .from(userTable)
1007
+ .innerJoin(Rel(repoTable, "owner_id", userTable, "id"))
1008
+ .filter(Frag(IndexedSeq("""t0."name" = """, ""), IndexedSeq(DbValue.DbString("alice"))))
1009
+
1010
+ println(q.explain(SqlDialect.PostgreSQL))
1011
+ // SELECT t0."id", t0."name", t1."id", t1."owner_id", t1."name" FROM "user" AS t0 INNER JOIN "repo" AS t1 ON t1."owner_id" = t0."id" WHERE t0."name" = ?1
1012
+ // -- params: 1:String
1013
+ ```
1014
+
1015
+ `explain` renders a single-line SQL string with numbered `?N` placeholders. The `?N`
1016
+ placeholders correspond one-to-one with the parameter list you can obtain separately via
1017
+ `toFrag(dialect).params`. This makes `explain` useful for logging and visual debugging without touching a
1018
+ database.
1019
+
1020
+ ### Inspecting the query IR
1021
+
1022
+ The query itself is a case class, so every part is available programmatically:
1023
+
1024
+ ```scala
1025
+ q.source // Table[User] for "user"
1026
+ q.joins // Vector(JoinNode(repoTable, "t1", Inner, <on-frag>))
1027
+ q.filters // Vector(Frag(t0."name" = ?, DbString(alice)))
1028
+ q.groupBy // Vector.empty
1029
+ q.orderBy // Vector.empty
1030
+ q.limit // None
1031
+ q.offset // None
1032
+ q.toFrag // Frag (re-renderable to SQL via frag.sql(dialect))
1033
+ ```
1034
+
1035
+ Inspecting the IR directly lets you examine joins, filters, ordering, and limits programmatically. This is useful for building monitoring dashboards, query analyzers, or dynamic query modification layers.
1036
+
1037
+ ### sql(dialect): String
1038
+
1039
+ The query IR also provides a simpler `sql` method that renders `?` placeholders for execution:
1040
+
1041
+ ```scala
1042
+ println(q.sql(SqlDialect.PostgreSQL))
1043
+ // SELECT t0."id", t0."name", t1."id", t1."owner_id", t1."name" FROM "user" AS t0 INNER JOIN "repo" AS t1 ON t1."owner_id" = t0."id" WHERE t0."name" = ?
1044
+ ```
1045
+
1046
+ ### previewSql() for Migrations
1047
+
1048
+ When using `SmallMigrator` or `LargeMigrator` from the `data-migration` module, `previewSql()` returns the full sequence of SQL statements the migrator would execute, without opening any database connection:
1049
+
1050
+ ```scala
1051
+ import zio.blocks.data.migration._
1052
+
1053
+ given transactor: Transactor = tx
1054
+
1055
+ val migrator = SmallMigrator(
1056
+ repoV1 = userRepo,
1057
+ repoV2 = userRepoV2,
1058
+ migration = userMigration,
1059
+ queueTable = "migration_queue",
1060
+ batchSize = 100,
1061
+ target = TargetStrategy.InPlace
1062
+ )
1063
+
1064
+ val statements: Vector[String] = migrator.previewSql()
1065
+ // Vector(
1066
+ // "CREATE TABLE ...", -- queue DDL
1067
+ // "CREATE TABLE ...", -- shadow table
1068
+ // "CREATE TRIGGER ...", -- capture triggers
1069
+ // "SELECT ...", -- dequeue template
1070
+ // "ALTER TABLE ..." -- finalize (rename)
1071
+ // )
1072
+ ```
1073
+
1074
+ This gives you a dry run of the migration SQL before any schema changes are applied.
1075
+
1076
+ ## Compile-time SQL Dumps
1077
+
1078
+ The `Dump` object emits SQL files at compile time. When the JVM property `zib.sql.dumpDir` is set, inline macro calls to `Dump.dumpTable` or `Dump.dumpQuery` write `.sql` files to that directory. When the property is absent, the calls become no-ops with zero runtime cost.
1079
+
1080
+ ### Enabling Dumps
1081
+
1082
+ The `Dump` macros read `System.getProperty("zib.sql.dumpDir")` at compile time from the JVM running sbt's compiler. This is a JVM system property, not a scalac flag. Passing it via `scalacOptions` does nothing.
1083
+
1084
+ The simplest way to set it is as a JVM flag on the sbt command line:
1085
+
1086
+ ```bash
1087
+ sbt -Dzib.sql.dumpDir=target/sql-dumps "++3.8.3; sqlJVM/compile"
1088
+ ```
1089
+
1090
+ If you prefer not to type `-D` every time, export it through `SBT_OPTS`:
1091
+
1092
+ ```bash
1093
+ SBT_OPTS="-Dzib.sql.dumpDir=target/sql-dumps" sbt compile
1094
+ ```
1095
+
1096
+ Both approaches set the property on the sbt process, which is the same JVM that runs the Scala compiler and the macros within it.
1097
+
1098
+ ### Entry Points
1099
+
1100
+ There are two inline macro entry points, all in `zio.blocks.sql.Dump`:
1101
+
1102
+ ```scala
1103
+ import zio.blocks.sql._
1104
+
1105
+ // Dump a Table's CREATE TABLE DDL (both PostgreSQL and SQLite)
1106
+ Dump.dumpTable(userTable)
1107
+
1108
+ // Dump a query IR's SELECT
1109
+ Dump.dumpQuery(queryIr)
1110
+ ```
1111
+
1112
+ Each call emits one file per dialect (PostgreSQL and SQLite by default).
1113
+
1114
+ ### Naming Scheme
1115
+
1116
+ Files are named `<owner>-<dialect>.sql` where `<owner>` is derived from the enclosing symbol and `<dialect>` is the lowercased dialect name:
1117
+
1118
+ ```
1119
+ target/sql-dumps/
1120
+ user-postgresql.sql
1121
+ user-sqlite.sql
1122
+ repo-postgresql.sql
1123
+ repo-sqlite.sql
1124
+ ```
1125
+
1126
+ For `dumpTable`, the owner comes from the `Table`'s type name. For `dumpQuery`, the macro walks the call site to extract the enclosing method, val name, or argument name. If the macro cannot determine a meaningful name, it falls back to `query`.
1127
+
1128
+ ### Content-hash Skip
1129
+
1130
+ Each dump file is written only when its content differs from the existing file. The macro compares the new bytes against any existing file at the target path. If they match, the write is skipped. This means incremental compilations do not produce noisy diffs or unnecessary filesystem writes.
1131
+
1132
+ All files use UTF-8 encoding with a trailing newline.
1133
+
1134
+ ### Limitations
1135
+
1136
+ Compile-time dumps work well for statically constructed queries, but some SQL patterns cannot be dumped:
1137
+
1138
+ - **Dynamic `Frag` chains.** Fragments built at runtime from user input, database lookups, or conditional branching are invisible to the macro. Only the static structure known at compile time appears in the dump.
1139
+ - **Repo internals.** The `Repo` abstraction's generated queries (insert, update, delete, select-by-id) are assembled at runtime from the `DbCodec` and `Table` metadata. `Dump.dumpTable` captures the DDL, but the CRUD queries themselves are not emitted.
1140
+ - **Phase 2 note.** A future phase may extend `Dump` to cover `Repo`-level CRUD operations and dynamic fragment composition. For now, treat the dump as a DDL and static-query snapshot, not a complete representation of every SQL statement your application will execute.
1141
+
1142
+ :::tip
1143
+ Pair `Dump.dumpTable` with `previewSql()` for a fuller picture: `dumpTable` captures the schema DDL at compile time, while `previewSql` captures the migration SQL at runtime before execution.
1144
+ :::
1145
+
671
1146
  ## Going Further
672
1147
 
673
1148
  - **[Part 1: Expressions](./query-dsl-reified-optics.md)** -- Building query expressions with reified optics
674
1149
  - **[Part 3: Extending the Expression Language](./query-dsl-extending.md)** -- Adding custom operators (IN, BETWEEN, aggregates) beyond SchemaExpr
675
1150
  - **[Part 4: A Fluent SQL Builder](./query-dsl-fluent-builder.md)** -- Type-safe SELECT, UPDATE, INSERT, DELETE with seamless condition mixing
676
- - **[SchemaExpr Reference](../reference/schema-expr.md)** -- Full API coverage of expression types
677
- - **[Optics Reference](../reference/optics.md)** -- Lens, Prism, Optional, and Traversal
678
- - **[DynamicOptic Reference](../reference/dynamic-optic.md)** -- Runtime optic paths for programmatic field extraction
1151
+ - **[SchemaExpr Reference](../reference/schema/schema-expr.md)** -- Full API coverage of expression types
1152
+ - **[Optics Reference](../reference/schema/optics.md)** -- Lens, Prism, Optional, and Traversal
1153
+ - **[DynamicOptic Reference](../reference/schema/dynamic-optic.md)** -- Runtime optic paths for programmatic field extraction
679
1154
 
680
- The interpreter pattern shown here extends naturally to other query targets. Because `SchemaExpr` is a sealed trait and `DynamicOptic` carries full path metadata, you can write interpreters for MongoDB filters, Elasticsearch queries, GraphQL filters, or any other query language using the same approach: pattern match on the AST, map operators, and extract field names from optic paths.
1155
+ The interpreter pattern shown here extends naturally to other query targets. Because `SchemaExpr` wraps a `DynamicSchemaExpr` sealed trait and `DynamicOptic` carries full path metadata, you can write interpreters for MongoDB filters, Elasticsearch queries, GraphQL filters, or any other query language using the same approach: access `.dynamic`, pattern match on the AST, map operators, and extract field names from optic paths.