Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 32 additions & 1 deletion docs/docs/sql/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,40 @@ input makes it a `QueryO`/`Query`. Pass `debug = true` (`@compile(debug = true)`
| `orderBy(a.field.asc, …)`, `limit(n)`, `offset(n)` | ordering / paging |
| `Q.insert[A]` / `Q.update[A]` / `Q.delete[A]` | begin an insert / update / delete |
| `set(_.field := value)` | assignment in an update |
| `count.*` / `count(a.field)` | aggregate |
| `count.*` / `count(a.field)` | `COUNT` aggregate (result `Long`, never null) |
| `sum(a.field)` / `avg(a.field)` / `min(a.field)` / `max(a.field)` | scalar aggregate (result `Option[_]`) |
| `a.tablePK` / `a.tableNPK` | the row's PK / non-PK columns |

### Scalar aggregates

`sum` / `avg` / `min` / `max` aggregate over the **whole** result set (there is no `GROUP BY` yet).
Unlike `count`, these are `NULL` over an empty result set, so they always decode to an `Option`:

```scala
@compile
val totalAgeInGroup: QueryIO[UUID, Option[Long]] =
for {
groupId <- input[UUID]
p <- select[Person]
_ <- where if p.groupId == groupId
} yield sum(p.age) // SUM(p.age); None when the group is empty
```

Result types follow Postgres' own widening rules:

| Aggregate | Column type | Postgres type | Scala result |
|-----------|-------------|---------------|--------------|
| `sum` | `Short` / `Int` | `bigint` | `Option[Long]` |
| `sum` | `Long` / `BigDecimal` | `numeric` | `Option[BigDecimal]` |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only one Im not OK with: sum(Long) -> BigInt

| `sum` | `Float` | `real` | `Option[Float]` |
| `sum` | `Double` | `double precision` | `Option[Double]` |
| `avg` | `Short` / `Int` / `Long` / `BigDecimal` | `numeric` | `Option[BigDecimal]` |
| `avg` | `Float` / `Double` | `double precision` | `Option[Double]` |
| `min` / `max` | any orderable column `A` | same as `A` | `Option[A]` |

The `sum` / `avg` widening is driven by the `SumType` / `AvgType` type-classes, so the query's static
type already reflects the widened result (e.g. `sum` over an `Int` column is `Option[Long]`).

A join example returning a tuple:

```scala
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,35 @@ final class DecoderBuilder {
case _: QueryExpr.BinaryComp => ParseResult.success(GeneratedResultDecoder.single(TypeclassExpr.RowRepr.boolean.resultDecoder, TypeRepr.of[Boolean]))
case _: QueryExpr.BinaryAndOr => ParseResult.success(GeneratedResultDecoder.single(TypeclassExpr.RowRepr.boolean.resultDecoder, TypeRepr.of[Boolean]))

def builtIn(queryExpr: QueryExpr.BuiltIn)(using Quotes): ParseResult[GeneratedResultDecoder] =
def builtIn(queryExpr: QueryExpr.BuiltIn)(using ParseContext, Quotes): ParseResult[GeneratedResultDecoder] =
queryExpr match
case QueryExpr.Static(fullTerm, _, rowRepr) => ParseResult.success(GeneratedResultDecoder.single(rowRepr.resultDecoder, fullTerm.tpe.widen))
case _: QueryExpr.CountWithArg => ParseResult.success(GeneratedResultDecoder.single(TypeclassExpr.RowRepr.long.resultDecoder, TypeRepr.of[Long]))
case QueryExpr.Static(fullTerm, _, rowRepr) => ParseResult.success(GeneratedResultDecoder.single(rowRepr.resultDecoder, fullTerm.tpe.widen))
case _: QueryExpr.CountWithArg => ParseResult.success(GeneratedResultDecoder.single(TypeclassExpr.RowRepr.long.resultDecoder, TypeRepr.of[Long]))
case QueryExpr.AggregateWithArg(fullTerm, fn, inner) =>
// SUM/AVG/MIN/MAX over an empty result set return SQL NULL -> decode as `Option[_]`.
// The DSL declares the widened result type (see `SumType`/`AvgType`), so the full term's
// type is already `Option[Out]`; we just need the matching (optional) decoder.
val resultTpe: TypeRepr = fullTerm.tpe.widen
fn match
case AggregateFunction.Min | AggregateFunction.Max =>
// MIN/MAX keep the column's own type: reuse its `RowRepr`, wrapped in `optional`.
ParseResult.success(GeneratedResultDecoder.single(inner.rowRepr.optional.resultDecoder, resultTpe))
case AggregateFunction.Sum | AggregateFunction.Avg =>
resultTpe.typeArgs.headOption match
case Some(outTpe) =>
convert.aggregateOptionalDecoder(outTpe) match
case Some(dec) => ParseResult.success(GeneratedResultDecoder.single(dec, resultTpe))
case None => ParseResult.error(fullTerm, s"unsupported ${fn.sql} result type: ${outTpe.showAnsiCode}")
case None =>
ParseResult.error(fullTerm, s"expected an Option[_] result type for ${fn.sql}, got: ${resultTpe.showAnsiCode}")

/** Optional result decoder for a widened SUM/AVG output type. */
private def aggregateOptionalDecoder(outTpe: TypeRepr)(using Quotes): Option[TypeclassExpr.ResultDecoder] =
if outTpe =:= TypeRepr.of[Long] then Some(TypeclassExpr.ResultDecoder { '{ oxygen.sql.schema.RowRepr.long.decoder.optional } })
else if outTpe =:= TypeRepr.of[Double] then Some(TypeclassExpr.ResultDecoder { '{ oxygen.sql.schema.RowRepr.double.decoder.optional } })
else if outTpe =:= TypeRepr.of[Float] then Some(TypeclassExpr.ResultDecoder { '{ oxygen.sql.schema.RowRepr.float.decoder.optional } })
else if outTpe =:= TypeRepr.of[BigDecimal] then Some(TypeclassExpr.ResultDecoder { '{ oxygen.sql.schema.ResultDecoder.bigDecimal.optional } })
else None

def composite(queryExpr: QueryExpr.Composite, parentContext: Option[TypeclassExpr.RowRepr])(using ParseContext, Quotes): ParseResult[GeneratedResultDecoder] =
queryExpr match
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,9 @@ final case class FragmentBuilder(inputs: List[InputPart])(using Quotes) {

def builtIn(queryExpr: QueryExpr.BuiltIn)(using ParseContext, GenerationContext, Quotes): ParseResult[GeneratedFragment] =
queryExpr match
case QueryExpr.Static(_, out, _) => ParseResult.Success(GeneratedFragment.sql(out))
case QueryExpr.CountWithArg(_, inner) => queryExprToFragment(inner, None).map { frag => GeneratedFragment.of("COUNT(", frag, ")") }
case QueryExpr.Static(_, out, _) => ParseResult.Success(GeneratedFragment.sql(out))
case QueryExpr.CountWithArg(_, inner) => queryExprToFragment(inner, None).map { frag => GeneratedFragment.of("COUNT(", frag, ")") }
case QueryExpr.AggregateWithArg(_, fn, inner) => queryExprToFragment(inner, None).map { frag => GeneratedFragment.of(s"${fn.sql}(", frag, ")") }

def composite(queryExpr: QueryExpr.Composite, parentContext: Option[TypeclassExpr.RowRepr])(using ParseContext, GenerationContext, Quotes): ParseResult[GeneratedFragment] =
queryExpr match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,20 @@ private[generic] object QueryExpr extends Parser[RawQueryExpr, QueryExpr] {
sealed trait BuiltIn extends QueryExpr {

override final def show(using Quotes): String = this match
case QueryExpr.CountWithArg(_, inner) => s"${"COUNT".cyanFg}( ${inner.show} )"
case QueryExpr.Static(_, out, _) => out.cyanFg.toString
case QueryExpr.CountWithArg(_, inner) => s"${"COUNT".cyanFg}( ${inner.show} )"
case QueryExpr.AggregateWithArg(_, fn, inner) => s"${fn.sql.cyanFg}( ${inner.show} )"
case QueryExpr.Static(_, out, _) => out.cyanFg.toString

}

final case class CountWithArg(fullTerm: Term, inner: QueryVariableReferenceLike) extends BuiltIn {
override def queryRefs: Growable[VariableReference] = inner.queryRefs
}

final case class AggregateWithArg(fullTerm: Term, fn: AggregateFunction, inner: QueryVariableReferenceLike) extends BuiltIn {
override def queryRefs: Growable[VariableReference] = inner.queryRefs
}

final case class Static(fullTerm: Term, out: String, rowRepr: TypeclassExpr.RowRepr) extends BuiltIn {
override def queryRefs: Growable[VariableReference] = Growable.empty
}
Expand Down Expand Up @@ -383,6 +388,11 @@ private[generic] object QueryExpr extends Parser[RawQueryExpr, QueryExpr] {
case inner: QueryExpr.QueryVariableReferenceLike => ParseResult.Success(QueryExpr.CountWithArg(fullTerm, inner))
case inner => ParseResult.error(inner.fullTerm, "can only count( _ ) a unary query")
}
case RawQueryExpr.AggregateWithArg(fullTerm, fn, inner) =>
parse(inner).flatMap {
case inner: QueryExpr.QueryVariableReferenceLike => ParseResult.Success(QueryExpr.AggregateWithArg(fullTerm, fn, inner))
case inner => ParseResult.error(inner.fullTerm, s"can only ${fn.sql}( _ ) a single column")
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import oxygen.sql.query.dsl.Q
import oxygen.sql.schema.TableRepr
import scala.quoted.*

private[generic] enum AggregateFunction(val sql: String) {
case Sum extends AggregateFunction("SUM")
case Avg extends AggregateFunction("AVG")
case Min extends AggregateFunction("MIN")
case Max extends AggregateFunction("MAX")
}

private[generic] sealed trait RawQueryExpr {

/**
Expand All @@ -30,6 +37,7 @@ private[generic] sealed trait RawQueryExpr {
case RawQueryExpr.ConstValue(_, term) => s"{ ${term.showCode} }".cyanFg.toString
case RawQueryExpr.StaticCount(_, out) => s"${"COUNT".cyanFg}( ${out.magentaFg} )"
case RawQueryExpr.CountWithArg(_, inner) => s"${"COUNT".cyanFg}( ${inner.show} )"
case RawQueryExpr.AggregateWithArg(_, fn, inner) => s"${fn.sql.cyanFg}( ${inner.show} )"
case RawQueryExpr.SelectProductField(select, inner) => s"${inner.show}.${select.name.magentaFg}"
case RawQueryExpr.OptionGet(_, inner) => s"${inner.show}.${"get".hexFg("#35A7FF")}"
case RawQueryExpr.OptionNullability(_, inner, showScala, _) => s"${inner.show}.${showScala.hexFg("#35A7FF")}"
Expand Down Expand Up @@ -231,6 +239,22 @@ private[generic] object RawQueryExpr extends Parser[(Term, RefMap), RawQueryExpr

}

final case class AggregateWithArg(fullTerm: Term, fn: AggregateFunction, inner: RawQueryExpr) extends RawQueryExpr.BuiltIn
object AggregateWithArg extends Parser[(Term, RefMap), AggregateWithArg] {

override def parse(input: (Term, RefMap))(using ParseContext, Quotes): ParseResult[AggregateWithArg] = {
val (term, refs) = input

term.asExpr match
case '{ Q.sum[a, b]($innerExpr)(using $ev) } => { val _ = ev; RawQueryExpr.parse((innerExpr.toTerm, refs)).map(AggregateWithArg(term, AggregateFunction.Sum, _)) }
case '{ Q.avg[a, b]($innerExpr)(using $ev) } => { val _ = ev; RawQueryExpr.parse((innerExpr.toTerm, refs)).map(AggregateWithArg(term, AggregateFunction.Avg, _)) }
case '{ Q.min[a]($innerExpr) } => RawQueryExpr.parse((innerExpr.toTerm, refs)).map(AggregateWithArg(term, AggregateFunction.Min, _))
case '{ Q.max[a]($innerExpr) } => RawQueryExpr.parse((innerExpr.toTerm, refs)).map(AggregateWithArg(term, AggregateFunction.Max, _))
case _ => ParseResult.unknown(term, "not a scalar aggregate")
}

}

final case class RandomUUID(fullTerm: Term) extends RawQueryExpr.BuiltIn
object RandomUUID extends Parser[(Term, RefMap), RandomUUID] {

Expand Down Expand Up @@ -361,6 +385,7 @@ private[generic] object RawQueryExpr extends Parser[(Term, RefMap), RawQueryExpr
case ReferencedVariable.optional(res) => res
case StaticCount.optional(res) => res
case CountWithArg.optional(res) => res
case AggregateWithArg.optional(res) => res
case SelectPrimaryKey.optional(res) => res
case SelectNonPrimaryKey.optional(res) => res
case OptionGet.optional(res) => res
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package oxygen.sql.query.dsl

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im ok with multiple classes in the same file in certain cases.

This one feels valid enough, fine.

If there are multiple classes in the same file, the file should have a lower-case name. The exception to this is MyTypeclass + MyTypeclassLowPriority.

Fix this file, and add that sentiment to the claude markdown scaffold.


/**
* Type-level widening for the `SUM(_)` aggregate, mirroring Postgres' result types.
*
* Postgres widens the result of `SUM`:
* - `smallint` / `int` -> `bigint` (Scala `Long`)
* - `bigint` / `numeric` -> `numeric` (Scala `BigDecimal`)
* - `real` -> `real` (Scala `Float`)
* - `double precision` -> `double precision` (Scala `Double`)
*
* The resulting DSL expression decodes to `Option[Out]` (SQL `NULL` over an empty set -> `None`).
*/
sealed trait SumType[A] {
type Out
}
object SumType {

type Aux[A, B] = SumType[A] { type Out = B }

private def make[A, B]: SumType.Aux[A, B] = new SumType[A] { override type Out = B }

given short: SumType.Aux[Short, Long] = make
given int: SumType.Aux[Int, Long] = make
given long: SumType.Aux[Long, BigDecimal] = make
given float: SumType.Aux[Float, Float] = make
given double: SumType.Aux[Double, Double] = make
given bigDecimal: SumType.Aux[BigDecimal, BigDecimal] = make

}

/**
* Type-level widening for the `AVG(_)` aggregate, mirroring Postgres' result types.
*
* Postgres returns:
* - `numeric` for `smallint` / `int` / `bigint` / `numeric` inputs (Scala `BigDecimal`)
* - `double precision` for `real` / `double precision` inputs (Scala `Double`)
*
* The resulting DSL expression decodes to `Option[Out]` (SQL `NULL` over an empty set -> `None`).
*/
sealed trait AvgType[A] {
type Out
}
object AvgType {

type Aux[A, B] = AvgType[A] { type Out = B }

private def make[A, B]: AvgType.Aux[A, B] = new AvgType[A] { override type Out = B }

given short: AvgType.Aux[Short, BigDecimal] = make
given int: AvgType.Aux[Int, BigDecimal] = make
given long: AvgType.Aux[Long, BigDecimal] = make
given float: AvgType.Aux[Float, Double] = make
given double: AvgType.Aux[Double, Double] = make
given bigDecimal: AvgType.Aux[BigDecimal, BigDecimal] = make

}
19 changes: 19 additions & 0 deletions modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ object Q {
def _1: Long = macroOnly
}

// scalar aggregates over the whole result (no GROUP BY).
// all return `Option`: over an empty result set the aggregate is SQL NULL -> `None`.

object sum {
def apply[A, B](toSum: A)(using ev: SumType.Aux[A, B]): Option[B] = macroOnly
}

object avg {
def apply[A, B](toAvg: A)(using ev: AvgType.Aux[A, B]): Option[B] = macroOnly
}
Comment on lines +60 to +66

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these still work if you do it like:

def apply[A](toSum: A)(using ev: SumType[A]): Option[ev.Out] = macroOnly

thats kinda the whole point of the given Aux type pattern...


object min {
def apply[A](toMin: A): Option[A] = macroOnly
}

object max {
def apply[A](toMax: A): Option[A] = macroOnly
}
Comment on lines +68 to +74

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, all 4 of these dont really feel like they deserve an object + apply, just do def thing[A] ...


extension [A](self: A) {
def tablePK(using ev: TableRepr[A]): ev.PrimaryKeyT = ev.pk.get(self)
def tableNPK(using ev: TableRepr[A]): ev.NonPrimaryKeyT = ev.npk.get(self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ object ResultDecoder extends Derivable[ResultDecoder] {

given fromRowRepr: [A: RowRepr as repr] => ResultDecoder[A] = repr.decoder

/**
* Decoder for a Postgres `numeric` value (returned by the JDBC driver as a [[java.math.BigDecimal]]).
*
* There is intentionally no `RowRepr[BigDecimal]` (that would require a `Column.Type` + migration
* support). This decoder exists purely to decode the widened result of `SUM`/`AVG` aggregates.
*/
val bigDecimal: ResultDecoder[BigDecimal] =
ResultDecoder.SingleDecoder.simplePF { case value: java.math.BigDecimal => BigDecimal(value) }

//////////////////////////////////////////////////////////////////////////////////////////////////////
// Generic
//////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,44 @@ object CustomQuerySpec extends OxygenSpec[Database] {
res6 == 2,
)
},
test("scalar aggregates (sum / avg / min / max)") {
for {
groupId <- Random.nextUUID
emptyGroupId <- Random.nextUUID

p1 <- Person.generate(groupId)(age = 10)
p2 <- Person.generate(groupId)(age = 20)
p3 <- Person.generate(groupId)(age = 30)

_ <- Person.insert.all(p1, p2, p3).unit

// non-empty group
sum <- queries.personAgeSumByGroup.execute(groupId).single
avg <- queries.personAgeAvgByGroup.execute(groupId).single
min <- queries.personAgeMinByGroup.execute(groupId).single
max <- queries.personAgeMaxByGroup.execute(groupId).single

// empty group -> SQL NULL -> None
sumEmpty <- queries.personAgeSumByGroup.execute(emptyGroupId).single
avgEmpty <- queries.personAgeAvgByGroup.execute(emptyGroupId).single
minEmpty <- queries.personAgeMinByGroup.execute(emptyGroupId).single
maxEmpty <- queries.personAgeMaxByGroup.execute(emptyGroupId).single

} yield assertTrue(
// SUM(int) widens to bigint -> Long
sum == Option(60L),
// AVG(int) -> numeric -> BigDecimal
avg.map(_.doubleValue) == Option(20.0),
// MIN/MAX keep the column type -> Int
min == Option(10),
max == Option(30),
// empty set -> None for all aggregates
sumEmpty == Option.empty[Long],
avgEmpty == Option.empty[BigDecimal],
minEmpty == Option.empty[Int],
maxEmpty == Option.empty[Int],
)
},
test("insert from select") {
for {
groupId <- Random.nextUUID
Expand Down
34 changes: 34 additions & 0 deletions modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,40 @@ object queries {
_ <- where if p.first == first && p.last == last
} yield count(p)

// scalar aggregates (OXY-166) over a group. NULL over an empty group -> None.

@compile
val personAgeSumByGroup: QueryIO[UUID, Option[Long]] =
for {
groupId <- input[UUID]
p <- select[Person]
_ <- where if p.groupId == groupId
} yield Q.sum(p.age)

@compile
val personAgeAvgByGroup: QueryIO[UUID, Option[BigDecimal]] =
for {
groupId <- input[UUID]
p <- select[Person]
_ <- where if p.groupId == groupId
} yield Q.avg(p.age)

@compile
val personAgeMinByGroup: QueryIO[UUID, Option[Int]] =
for {
groupId <- input[UUID]
p <- select[Person]
_ <- where if p.groupId == groupId
} yield Q.min(p.age)

@compile
val personAgeMaxByGroup: QueryIO[UUID, Option[Int]] =
for {
groupId <- input[UUID]
p <- select[Person]
_ <- where if p.groupId == groupId
} yield Q.max(p.age)

@compile
val selectSubQuery1: QueryO[(Person, Option[Note])] =
for {
Expand Down
Loading
Loading