Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fec3fff
feat(sql): scaffold zio-blocks-sql module with build.sbt
987Nabil Mar 14, 2026
927cdb0
feat(sql): add DbValue and SqlDialect core types
987Nabil Mar 14, 2026
e9f65f8
feat(sql): implement Deriver[DbCodec] for Schema-driven codec derivation
987Nabil Mar 14, 2026
99d1632
feat(sql): add SqlNameMapper for column naming with snake_case default
987Nabil Mar 14, 2026
d9154ad
feat(sql): scaffold zio-blocks-sql module with build.sbt
987Nabil Mar 14, 2026
7195151
feat(sql): add JDBC backend (JdbcConnection, ResultSet mapping)
987Nabil Mar 14, 2026
08a343e
feat(sql): add sql"" interpolator with Frag type for parameterized qu…
987Nabil Mar 14, 2026
6d2cf84
feat(sql): add DDL generation (CREATE TABLE, DROP TABLE)
987Nabil Mar 14, 2026
201d377
feat(sql): add Table[A] derivation from Schema metadata
987Nabil Mar 14, 2026
a4dbf1c
feat(sql): add Transactor, context functions, and query execution
987Nabil Mar 14, 2026
b98397f
feat(sql-zio): add TransactorZIO wrapper for ZIO effect integration
987Nabil Mar 14, 2026
9ea3285
style(sql-zio): fix scalafmt indentation
987Nabil Mar 14, 2026
8817dc7
fix(sql): load SQLite JDBC driver explicitly in TransactorSpec
987Nabil Mar 14, 2026
b6a078f
test(sql): add comprehensive type roundtrip tests and DbParam coverage
987Nabil Mar 14, 2026
b4c2113
fix(build): remove sql modules from cross-version test/doc aliases
987Nabil Mar 14, 2026
c544b51
Revert "fix(build): remove sql modules from cross-version test/doc al…
987Nabil Mar 14, 2026
dc64607
fix(build): support Scala 3.3 LTS + 3.7 for sql modules
987Nabil Mar 14, 2026
14bc482
fix(sql): replace UUID.randomUUID() in shared tests for Scala.js comp…
987Nabil Mar 14, 2026
a417f6d
fix(build): remove Scala-3-only sql modules from cross-version test/d…
987Nabil Mar 14, 2026
c4dd7aa
fix(sql): address Copilot review comments — pluralize, Option, sql in…
987Nabil Mar 14, 2026
c62885f
feat(sql): add Repo[E, ID] with auto-generated CRUD, fixed-name Table…
987Nabil Mar 15, 2026
3bbf4a2
feat(sql): add Frag extensions (.query, .queryOne, .queryLimit, .upda…
987Nabil Mar 15, 2026
3fa0f69
fix(sql): Repo returns affected rows, count via SqlOps, transact safe…
987Nabil Mar 15, 2026
d09e916
feat(sql): support Scala 3 enums and simple sealed traits in DbCodec
987Nabil Mar 15, 2026
6513ea1
feat(sql): add insertReturning, insertAll batch, and SqlLogger
987Nabil Mar 15, 2026
ecaa801
feat(sql): add sql-query fluent builder module
allornothingai Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ lazy val root = project
scope.jvm,
scope.js,
`scope-examples`,
sql.jvm,
sql.js,
`sql-query`.jvm,
`sql-query`.js,
`sql-zio`,
schema.jvm,
schema.js,
`schema-avro`,
Expand Down Expand Up @@ -299,6 +304,63 @@ lazy val scope = crossProject(JSPlatform, JVMPlatform)
coverageMinimumBranchTotal := 65
)

lazy val sql = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Full)
.dependsOn(schema, scope)
.settings(stdSettings("zio-blocks-sql", Seq(BuildHelper.Scala3, BuildHelper.Scala33)))
.settings(crossProjectSettings)
.settings(buildInfoSettings("zio.blocks.sql"))
.enablePlugins(BuildInfoPlugin)
.jvmSettings(mimaSettings(failOnProblem = false))
.jsSettings(jsSettings)
.settings(
libraryDependencies ++= Seq(
"dev.zio" %%% "zio-test" % "2.1.24" % Test,
"dev.zio" %%% "zio-test-sbt" % "2.1.24" % Test
),
coverageMinimumStmtTotal := 0,
coverageMinimumBranchTotal := 0
)
.jvmSettings(
libraryDependencies ++= Seq(
"org.xerial" % "sqlite-jdbc" % "3.49.1.0" % Test,
"org.postgresql" % "postgresql" % "42.7.5" % Test
)
)

lazy val `sql-query` = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Full)
.dependsOn(sql)
.settings(stdSettings("zio-blocks-sql-query", Seq(BuildHelper.Scala3, BuildHelper.Scala33)))
.settings(crossProjectSettings)
.settings(buildInfoSettings("zio.blocks.sql.query"))
.enablePlugins(BuildInfoPlugin)
.jvmSettings(mimaSettings(failOnProblem = false))
.jsSettings(jsSettings)
.settings(
libraryDependencies ++= Seq(
"dev.zio" %%% "zio-test" % "2.1.24" % Test,
"dev.zio" %%% "zio-test-sbt" % "2.1.24" % Test
),
coverageMinimumStmtTotal := 0,
coverageMinimumBranchTotal := 0
)

lazy val `sql-zio` = project
.settings(stdSettings("zio-blocks-sql-zio", Seq(BuildHelper.Scala3, BuildHelper.Scala33)))
.dependsOn(sql.jvm)
.settings(buildInfoSettings("zio.blocks.sql.zio"))
.enablePlugins(BuildInfoPlugin)
.settings(
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % "2.1.24",
"dev.zio" %% "zio-test" % "2.1.24" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.24" % Test
),
coverageMinimumStmtTotal := 0,
coverageMinimumBranchTotal := 0
)

lazy val `scope-examples` = project
.settings(stdSettings("zio-blocks-scope-examples", Seq(BuildHelper.Scala3)))
.dependsOn(scope.jvm)
Expand Down
31 changes: 31 additions & 0 deletions sql-query/shared/src/main/scala/zio/blocks/sql/query/Delete.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package zio.blocks.sql.query

import zio.blocks.schema._
import zio.blocks.sql._

case class DeleteBuilder[A](
table: Table[A],
whereOpt: Option[Frag] = None
) {

def where(expr: SchemaExpr[A, Boolean]): DeleteBuilder[A] = {
val frag = ExprToSql.toSql(expr, table)
val combined = whereOpt match {
case Some(w) => w ++ Frag.const(" AND ") ++ frag
case None => Frag.const(" WHERE ") ++ frag
}
this.copy(whereOpt = Some(combined))
}

def toFrag: Frag = {
var f = Frag.const(s"DELETE FROM ${table.name}")
whereOpt.foreach { w => f = f ++ w }
f
}
}

object Delete {
def from[A](using dialect: SqlDialect, schema: Schema[A]): DeleteBuilder[A] = {
DeleteBuilder(Table.derived[A](dialect)(schema))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package zio.blocks.sql.query

import zio.blocks.schema._
import zio.blocks.sql._

object ExprToSql {

def toSql[S, A](expr: SchemaExpr[S, A], table: Table[S]): Frag = {
expr match {
case SchemaExpr.Literal(value, schema) =>
val dbValue = toDbValue(schema.toDynamicValue(value))
Frag(IndexedSeq("", ""), IndexedSeq(dbValue))

case SchemaExpr.Optic(optic) =>
val nodes = optic.toDynamic.nodes
// Flatten fields to column name
val parts = nodes.collect {
case f: DynamicOptic.Node.Field => SqlNameMapper.SnakeCase(f.name)
}
val columnName = parts.mkString("_")
Frag.const(columnName)

case SchemaExpr.Relational(left, right, operator) =>
val l = toSql(left, table)
val r = toSql(right, table)
val op = operator match {
case _: SchemaExpr.RelationalOperator.Equal.type => "="
case _: SchemaExpr.RelationalOperator.NotEqual.type => "<>"
case _: SchemaExpr.RelationalOperator.GreaterThan.type => ">"
case _: SchemaExpr.RelationalOperator.LessThan.type => "<"
case _: SchemaExpr.RelationalOperator.GreaterThanOrEqual.type => ">="
case _: SchemaExpr.RelationalOperator.LessThanOrEqual.type => "<="
}
l ++ Frag.const(s" $op ") ++ r

case SchemaExpr.Logical(left, right, operator) =>
val l = toSql(left, table)
val r = toSql(right, table)
val op = operator match {
case _: SchemaExpr.LogicalOperator.And.type => "AND"
case _: SchemaExpr.LogicalOperator.Or.type => "OR"
}
Frag.const("(") ++ l ++ Frag.const(s" $op ") ++ r ++ Frag.const(")")

case not: SchemaExpr.Not[_] =>
Frag.const("NOT (") ++ toSql(not.expr, table) ++ Frag.const(")")

case arith: SchemaExpr.Arithmetic[_, _] =>
val l = toSql(arith.left, table)
val r = toSql(arith.right, table)
val op = arith.operator match {
case _: SchemaExpr.ArithmeticOperator.Add.type => "+"
case _: SchemaExpr.ArithmeticOperator.Subtract.type => "-"
case _: SchemaExpr.ArithmeticOperator.Multiply.type => "*"
}
Frag.const("(") ++ l ++ Frag.const(s" $op ") ++ r ++ Frag.const(")")

case stringConcat: SchemaExpr.StringConcat[_] =>
val l = toSql(stringConcat.left, table)
val r = toSql(stringConcat.right, table)
l ++ Frag.const(" || ") ++ r

case _ =>
throw new IllegalArgumentException(s"Unsupported SchemaExpr: $expr")
}
}

def toDbValue(dynamicValue: DynamicValue): DbValue = {
dynamicValue match {
case DynamicValue.Primitive(primValue) =>
primValue match {
case PrimitiveValue.Int(v) => DbValue.DbInt(v)
case PrimitiveValue.Long(v) => DbValue.DbLong(v)
case PrimitiveValue.Double(v) => DbValue.DbDouble(v)
case PrimitiveValue.Float(v) => DbValue.DbFloat(v)
case PrimitiveValue.Boolean(v) => DbValue.DbBoolean(v)
case PrimitiveValue.String(v) => DbValue.DbString(v)
case PrimitiveValue.Short(v) => DbValue.DbShort(v)
case PrimitiveValue.Byte(v) => DbValue.DbByte(v)
case PrimitiveValue.Char(v) => DbValue.DbChar(v)
case PrimitiveValue.BigDecimal(v) => DbValue.DbBigDecimal(v)
case PrimitiveValue.LocalDate(v) => DbValue.DbLocalDate(v)
case PrimitiveValue.LocalDateTime(v) => DbValue.DbLocalDateTime(v)
case PrimitiveValue.LocalTime(v) => DbValue.DbLocalTime(v)
case PrimitiveValue.Instant(v) => DbValue.DbInstant(v)
case PrimitiveValue.Duration(v) => DbValue.DbDuration(v)
case PrimitiveValue.UUID(v) => DbValue.DbUUID(v)
case _ => throw new IllegalArgumentException(s"Unsupported primitive value: $primValue")
}
case DynamicValue.Null => DbValue.DbNull
case _ => throw new IllegalArgumentException(s"Unsupported dynamic value: $dynamicValue")
}
}

}
30 changes: 30 additions & 0 deletions sql-query/shared/src/main/scala/zio/blocks/sql/query/Insert.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package zio.blocks.sql.query

import zio.blocks.sql._

case class InsertBuilder[A](
table: Table[A],
valuesList: IndexedSeq[A] = IndexedSeq.empty
) {

def values(vs: A*): InsertBuilder[A] =
this.copy(valuesList = valuesList ++ vs)

def toFrag: Frag = {
if (valuesList.isEmpty) throw new IllegalStateException("INSERT requires at least one value")

val columns = table.columns.mkString(", ")

val valueFrags = valuesList.map { v =>
val dbValues = table.codec.toDbValues(v)
// Frag generation needs to interleave.
// E.g. (?, ?, ?) -> parts = ("", ", ", ", ", ""), params = (a, b, c)
val parts = IndexedSeq("(") ++ IndexedSeq.fill(dbValues.size - 1)(", ") ++ IndexedSeq(")")
Frag(parts, dbValues)
}

val combinedValues = valueFrags.reduce(_ ++ Frag.const(", ") ++ _)

Frag.const(s"INSERT INTO ${table.name} ($columns) VALUES ") ++ combinedValues
}
}
64 changes: 64 additions & 0 deletions sql-query/shared/src/main/scala/zio/blocks/sql/query/Select.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package zio.blocks.sql.query

import zio.blocks.schema._
import zio.blocks.sql._

case class SortOrder[S](expr: SchemaExpr[S, ?], isAsc: Boolean) {
def toFrag(using t: Table[S]): Frag =
ExprToSql.toSql(expr, t) ++ Frag.const(if (isAsc) " ASC" else " DESC")
}

extension [S, A](expr: SchemaExpr[S, A]) {
def asc: SortOrder[S] = SortOrder(expr, true)
def desc: SortOrder[S] = SortOrder(expr, false)
}

extension [S, A](optic: Optic[S, A]) {
def asc: SortOrder[S] = SchemaExpr.Optic(optic).asc
def desc: SortOrder[S] = SchemaExpr.Optic(optic).desc
}

case class SelectBuilder[A](
table: Table[A],
whereOpt: Option[Frag] = None,
orderByOpt: Option[Frag] = None,
limitOpt: Option[Int] = None
) {

def where(expr: SchemaExpr[A, Boolean]): SelectBuilder[A] = {
val frag = ExprToSql.toSql(expr, table)
val combined = whereOpt match {
case Some(w) => w ++ Frag.const(" AND ") ++ frag
case None => Frag.const(" WHERE ") ++ frag
}
this.copy(whereOpt = Some(combined))
}

def orderBy(exprs: SortOrder[A]*): SelectBuilder[A] = {
given Table[A] = table
val frag = exprs.map(_.toFrag).reduce(_ ++ Frag.const(", ") ++ _)
val combined = orderByOpt match {
case Some(o) => o ++ Frag.const(", ") ++ frag
case None => Frag.const(" ORDER BY ") ++ frag
}
this.copy(orderByOpt = Some(combined))
}

def limit(n: Int): SelectBuilder[A] =
this.copy(limitOpt = Some(n))

def toFrag: Frag = {
val columns = table.columns.mkString(", ")
var f = Frag.const(s"SELECT $columns FROM ${table.name}")
whereOpt.foreach { w => f = f ++ w }
orderByOpt.foreach { o => f = f ++ o }
limitOpt.foreach { l => f = f ++ Frag.const(s" LIMIT $l") }
f
}
}

object Select {
def from[A](using dialect: SqlDialect, schema: Schema[A]): SelectBuilder[A] = {
SelectBuilder(Table.derived[A](dialect)(schema))
}
}
58 changes: 58 additions & 0 deletions sql-query/shared/src/main/scala/zio/blocks/sql/query/Update.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package zio.blocks.sql.query

import zio.blocks.schema._
import zio.blocks.sql._

case class SetClause[A](expr: SchemaExpr[A, ?], dbValue: DbValue) {
def toFrag(using t: Table[A]): Frag = {
val col = ExprToSql.toSql(expr, t)
col ++ Frag.const(" = ") ++ Frag(IndexedSeq("", ""), IndexedSeq(dbValue))
}
}

extension [S, A](expr: SchemaExpr[S, A]) {
def set(value: A)(using schema: Schema[A]): SetClause[S] = {
val dbValue = ExprToSql.toDbValue(schema.toDynamicValue(value))
SetClause(expr, dbValue)
}
}

extension [S, A](optic: Optic[S, A]) {
def set(value: A)(using schema: Schema[A]): SetClause[S] =
SchemaExpr.Optic(optic).set(value)
}

case class UpdateBuilder[A](
table: Table[A],
setClauses: IndexedSeq[SetClause[A]] = IndexedSeq.empty,
whereOpt: Option[Frag] = None
) {

def set(clauses: SetClause[A]*): UpdateBuilder[A] =
this.copy(setClauses = setClauses ++ clauses)

def where(expr: SchemaExpr[A, Boolean]): UpdateBuilder[A] = {
val frag = ExprToSql.toSql(expr, table)
val combined = whereOpt match {
case Some(w) => w ++ Frag.const(" AND ") ++ frag
case None => Frag.const(" WHERE ") ++ frag
}
this.copy(whereOpt = Some(combined))
}

def toFrag: Frag = {
if (setClauses.isEmpty) throw new IllegalStateException("UPDATE requires at least one SET clause")

given Table[A] = table
val sets = setClauses.map(_.toFrag).reduce(_ ++ Frag.const(", ") ++ _)
var f = Frag.const(s"UPDATE ${table.name} SET ") ++ sets
whereOpt.foreach { w => f = f ++ w }
f
}
}

object Update {
def table[A](using dialect: SqlDialect, schema: Schema[A]): UpdateBuilder[A] = {
UpdateBuilder[A](Table.derived[A](dialect)(schema), IndexedSeq.empty[SetClause[A]], None)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package zio.blocks.sql.query

import zio.test._
import zio.blocks.schema._
import zio.blocks.sql._

object SelectSpec extends ZIOSpecDefault {

case class User(id: Int, name: String, age: Int)
object User extends CompanionOptics[User] {
implicit val schema: Schema[User] = Schema.derived
val id = optic(_.id)
val name = optic(_.name)
val age = optic(_.age)
}

def spec = suite("SelectSpec")(
test("select with where and order by") {
given SqlDialect = SqlDialect.PostgreSQL

val frag = Select.from[User]
.where(User.age > 21)
.orderBy(User.name.asc)
.limit(10)
.toFrag

assertTrue(frag.sql(SqlDialect.PostgreSQL) == "SELECT id, name, age FROM user WHERE age > $1 ORDER BY name ASC LIMIT 10") &&
assertTrue(frag.queryParams == IndexedSeq(DbValue.DbInt(21)))
}
)
}
Loading
Loading