diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d85c150f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +Guidance for Claude when working in this repo. This is a **stub** — expand over time. + +## Conventions + +- [Scala coding standards](CLAUDE/scala-coding-standards.md) — file naming, and more over time. diff --git a/CLAUDE/scala-coding-standards.md b/CLAUDE/scala-coding-standards.md new file mode 100644 index 00000000..d8fe5229 --- /dev/null +++ b/CLAUDE/scala-coding-standards.md @@ -0,0 +1,9 @@ +# Scala coding standards + +Stub — expand over time (see OXY-168). + +## File naming + +- A file with a **single** top-level class/object/trait uses the **PascalCase** name of that type (e.g. `RowRepr.scala`). +- A file with **multiple** top-level classes uses a **lower-case** name (e.g. `aggregateType.scala` holding `SumType` + `AvgType`). + - **Exception:** a typeclass paired with its low-priority companion — `MyTypeclass` + `MyTypeclassLowPriority` — stays under the PascalCase `MyTypeclass.scala`. diff --git a/docs/docs/sql/queries.md b/docs/docs/sql/queries.md index 73dbe92d..ad49a0eb 100644 --- a/docs/docs/sql/queries.md +++ b/docs/docs/sql/queries.md @@ -128,9 +128,49 @@ 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)` / `sum.orNull(a.field)` / `avg(a.field)` / `min(a.field)` / `max(a.field)` | scalar aggregate | | `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). + +`avg` / `min` / `max` are SQL `NULL` over an empty result set, so they decode to an `Option`. `sum` +comes in two forms: + +- `sum(col)` / `sum.orZero(col)` → `COALESCE(SUM(col), 0)` — a **non-null** result (`0` over an empty set). +- `sum.orNull(col)` → `SUM(col)` — an `Option` (`None` over an empty set). + +```scala +@compile +val totalAgeInGroup: QueryIO[UUID, Long] = + for { + groupId <- input[UUID] + p <- select[Person] + _ <- where if p.groupId == groupId + } yield sum(p.age) // COALESCE(SUM(p.age), 0); 0 when the group is empty + +// use `sum.orNull(p.age)` for `Option[Long]` (None when the group is empty) +``` + +The widened `Out` type follows Postgres' own widening rules (`avg` / `min` / `max` and `sum.orNull` +wrap it in `Option`): + +| Aggregate | Column type | Postgres type | Widened `Out` | +|-----------|-------------|---------------|---------------| +| `sum` | `Short` / `Int` | `bigint` | `Long` | +| `sum` | `Long` / `BigInt` | `numeric` | `BigInt` | +| `sum` | `BigDecimal` | `numeric` | `BigDecimal` | +| `sum` | `Float` | `real` | `Float` | +| `sum` | `Double` | `double precision` | `Double` | +| `avg` | `Short` / `Int` / `Long` / `BigInt` / `BigDecimal` | `numeric` | `BigDecimal` | +| `avg` | `Float` / `Double` | `double precision` | `Double` | +| `min` / `max` | any orderable column `A` | same as `A` | `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 `Long`, `sum.orNull` is `Option[Long]`). + A join example returning a tuple: ```scala diff --git a/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/DecoderBuilder.scala b/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/DecoderBuilder.scala index b055c316..4d172f88 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/DecoderBuilder.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/DecoderBuilder.scala @@ -38,10 +38,45 @@ 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, coalesceZero, inner) => + // SUM/AVG/MIN/MAX over an empty result set return SQL NULL -> decode as `Option[_]`. + // The `sum(_)`/`sum.orZero(_)` variants wrap the result in `COALESCE(_, 0)`, so they are + // never null and decode to a non-optional `Out`. The DSL declares the widened result type + // (see `SumType`/`AvgType`), so the full term's type is already `Out` / `Option[Out]`. + 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 => + // non-COALESCE result type is `Option[Out]`; COALESCE result type is `Out` directly. + val outTpe: Option[TypeRepr] = if coalesceZero then Some(resultTpe) else resultTpe.typeArgs.headOption + outTpe match + case Some(outTpe) => + convert.aggregateDecoder(outTpe, optional = !coalesceZero) 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}") + + /** Result decoder for a widened SUM/AVG output type; `optional` wraps it for the nullable variants. */ + private def aggregateDecoder(outTpe: TypeRepr, optional: Boolean)(using Quotes): Option[TypeclassExpr.ResultDecoder] = { + val base: Option[Expr[oxygen.sql.schema.ResultDecoder[?]]] = + if outTpe =:= TypeRepr.of[Long] then Some('{ oxygen.sql.schema.RowRepr.long.decoder }) + else if outTpe =:= TypeRepr.of[Double] then Some('{ oxygen.sql.schema.RowRepr.double.decoder }) + else if outTpe =:= TypeRepr.of[Float] then Some('{ oxygen.sql.schema.RowRepr.float.decoder }) + else if outTpe =:= TypeRepr.of[BigInt] then Some('{ oxygen.sql.schema.RowRepr.bigInt.decoder }) + else if outTpe =:= TypeRepr.of[BigDecimal] then Some('{ oxygen.sql.schema.RowRepr.bigDecimal.decoder }) + else None + base.map { dec => + val full: Expr[oxygen.sql.schema.ResultDecoder[?]] = if optional then '{ $dec.optional } else dec + TypeclassExpr.ResultDecoder(full) + } + } def composite(queryExpr: QueryExpr.Composite, parentContext: Option[TypeclassExpr.RowRepr])(using ParseContext, Quotes): ParseResult[GeneratedResultDecoder] = queryExpr match diff --git a/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/FragmentBuilder.scala b/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/FragmentBuilder.scala index 7233af96..187a084f 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/FragmentBuilder.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/generic/generation/FragmentBuilder.scala @@ -307,8 +307,13 @@ 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, coalesceZero, inner) => + queryExprToFragment(inner, None).map { frag => + if coalesceZero then GeneratedFragment.of(s"COALESCE(${fn.sql}(", frag, "), 0)") + else GeneratedFragment.of(s"${fn.sql}(", frag, ")") + } def composite(queryExpr: QueryExpr.Composite, parentContext: Option[TypeclassExpr.RowRepr])(using ParseContext, GenerationContext, Quotes): ParseResult[GeneratedFragment] = queryExpr match { diff --git a/modules/sql/core/src/main/scala/oxygen/sql/generic/model/QueryExpr.scala b/modules/sql/core/src/main/scala/oxygen/sql/generic/model/QueryExpr.scala index b48d584c..1c9a3694 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/generic/model/QueryExpr.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/generic/model/QueryExpr.scala @@ -341,8 +341,9 @@ 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, coalesceZero, inner) => if coalesceZero then s"COALESCE( ${fn.sql.cyanFg}( ${inner.show} ), ${"0".magentaFg} )" else s"${fn.sql.cyanFg}( ${inner.show} )" + case QueryExpr.Static(_, out, _) => out.cyanFg.toString } @@ -350,6 +351,10 @@ private[generic] object QueryExpr extends Parser[RawQueryExpr, QueryExpr] { override def queryRefs: Growable[VariableReference] = inner.queryRefs } + final case class AggregateWithArg(fullTerm: Term, fn: AggregateFunction, coalesceZero: Boolean, 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 } @@ -438,6 +443,11 @@ private[generic] object QueryExpr extends Parser[RawQueryExpr, QueryExpr] { case _ => ParseResult.error(rhs.fullTerm, "right-hand side of `in`/`notIn` must be a runtime input collection (e.g. `input[Seq[A]]`)") } } yield QueryExpr.InList(fullTerm, lhs, notIn, rhs) + case RawQueryExpr.AggregateWithArg(fullTerm, fn, coalesceZero, inner) => + parse(inner).flatMap { + case inner: QueryExpr.QueryVariableReferenceLike => ParseResult.Success(QueryExpr.AggregateWithArg(fullTerm, fn, coalesceZero, inner)) + case inner => ParseResult.error(inner.fullTerm, s"can only ${fn.sql}( _ ) a single column") + } } } diff --git a/modules/sql/core/src/main/scala/oxygen/sql/generic/parsing/RawQueryExpr.scala b/modules/sql/core/src/main/scala/oxygen/sql/generic/parsing/RawQueryExpr.scala index ffd55393..e6d9ece5 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/generic/parsing/RawQueryExpr.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/generic/parsing/RawQueryExpr.scala @@ -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 { /** @@ -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, coalesceZero, inner) => if coalesceZero then s"COALESCE( ${fn.sql.cyanFg}( ${inner.show} ), ${"0".magentaFg} )" else 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")}" @@ -284,6 +292,28 @@ private[generic] object RawQueryExpr extends Parser[(Term, RefMap), RawQueryExpr } + // `coalesceZero` -> emit `COALESCE(fn(col), 0)` and decode a non-optional result (only `SUM`'s apply/orZero). + final case class AggregateWithArg(fullTerm: Term, fn: AggregateFunction, coalesceZero: Boolean, 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 + + def of(fn: AggregateFunction, coalesceZero: Boolean, innerExpr: Expr[?]): ParseResult[AggregateWithArg] = + RawQueryExpr.parse((innerExpr.toTerm, refs)).map(AggregateWithArg(term, fn, coalesceZero, _)) + + term.asExpr match + case '{ Q.sum.orNull[a]($innerExpr)(using $ev) } => { val _ = ev; of(AggregateFunction.Sum, false, innerExpr) } + case '{ Q.sum.orZero[a]($innerExpr)(using $ev) } => { val _ = ev; of(AggregateFunction.Sum, true, innerExpr) } + case '{ Q.sum.apply[a]($innerExpr)(using $ev) } => { val _ = ev; of(AggregateFunction.Sum, true, innerExpr) } + case '{ Q.avg[a]($innerExpr)(using $ev) } => { val _ = ev; of(AggregateFunction.Avg, false, innerExpr) } + case '{ Q.min[a]($innerExpr) } => of(AggregateFunction.Min, false, innerExpr) + case '{ Q.max[a]($innerExpr) } => of(AggregateFunction.Max, false, innerExpr) + case _ => ParseResult.unknown(term, "not a scalar aggregate") + } + + } + final case class RandomUUID(fullTerm: Term) extends RawQueryExpr.BuiltIn object RandomUUID extends Parser[(Term, RefMap), RandomUUID] { @@ -415,6 +445,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 diff --git a/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala index c4217be9..c15be50f 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala @@ -75,6 +75,24 @@ object Q { def _1: Long = macroOnly } + // scalar aggregates over the whole result (no GROUP BY). + + /** + * `SUM` over the whole result. + * - `sum(col)` / `sum.orZero(col)` -> `COALESCE(SUM(col), 0)`, a non-null `ev.Out` (`0` over an empty result set). + * - `sum.orNull(col)` -> `SUM(col)`, `Option[ev.Out]` (SQL `NULL` -> `None` over an empty result set). + */ + object sum { + def apply[A](toSum: A)(using ev: SumType[A]): ev.Out = macroOnly + def orZero[A](toSum: A)(using ev: SumType[A]): ev.Out = macroOnly + def orNull[A](toSum: A)(using ev: SumType[A]): Option[ev.Out] = macroOnly + } + + // avg/min/max return `Option`: over an empty result set the aggregate is SQL NULL -> `None`. + def avg[A](toAvg: A)(using ev: AvgType[A]): Option[ev.Out] = macroOnly + def min[A](toMin: A): Option[A] = macroOnly + def max[A](toMax: A): Option[A] = macroOnly + 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) diff --git a/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/aggregateType.scala b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/aggregateType.scala new file mode 100644 index 00000000..061f821d --- /dev/null +++ b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/aggregateType.scala @@ -0,0 +1,62 @@ +package oxygen.sql.query.dsl + +/** + * Type-level widening for the `SUM(_)` aggregate, mirroring Postgres' result types. + * + * Postgres widens the result of `SUM`: + * - `smallint` / `int` -> `bigint` (Scala `Long`) + * - `bigint` -> `numeric` (Scala `BigInt`, an integral `numeric`) + * - `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, BigInt] = make + given bigInt: SumType.Aux[BigInt, BigInt] = 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`) + * + * (a Scala `BigInt` column is itself a `numeric`, so `AVG` over it is `BigDecimal`.) + * + * 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 bigInt: AvgType.Aux[BigInt, BigDecimal] = make + given float: AvgType.Aux[Float, Double] = make + given double: AvgType.Aux[Double, Double] = make + given bigDecimal: AvgType.Aux[BigDecimal, BigDecimal] = make + +} diff --git a/modules/sql/core/src/main/scala/oxygen/sql/schema/Column.scala b/modules/sql/core/src/main/scala/oxygen/sql/schema/Column.scala index 4e0adeee..39bf9409 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/schema/Column.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/schema/Column.scala @@ -42,7 +42,7 @@ object Column { case object BigInt extends Type.Single("BIGINT") case object Real extends Type.Single("REAL") case object DoublePrecision extends Type.Single("DOUBLE PRECISION") - // TODO (KR) : decimal + case object Numeric extends Type.Single("NUMERIC") // Character Types case object Text extends Type.Single("TEXT") diff --git a/modules/sql/core/src/main/scala/oxygen/sql/schema/RowRepr.scala b/modules/sql/core/src/main/scala/oxygen/sql/schema/RowRepr.scala index f1e80b94..eea00984 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/schema/RowRepr.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/schema/RowRepr.scala @@ -231,7 +231,12 @@ object RowRepr extends RowReprLowPriority.LowPriority1 { java.lang.Long.valueOf(_), ) - // TODO (KR) : bigInt + given bigInt: RowRepr[BigInt] = + RowRepr.ColumnRepr.simplePF( + Column.Type.Numeric, + { case value: java.math.BigDecimal => BigInt(value.toBigInteger) }, + value => new java.math.BigDecimal(value.bigInteger), + ) given float: RowRepr[Float] = RowRepr.ColumnRepr.simplePF( @@ -247,7 +252,12 @@ object RowRepr extends RowReprLowPriority.LowPriority1 { java.lang.Double.valueOf(_), ) - // TODO (KR) : bigDecimal + given bigDecimal: RowRepr[BigDecimal] = + RowRepr.ColumnRepr.simplePF( + Column.Type.Numeric, + { case value: java.math.BigDecimal => BigDecimal(value) }, + _.bigDecimal, + ) // =====| Character Types |===== diff --git a/modules/sql/core/src/test/scala/oxygen/sql/schema/RowSchemaSpec.scala b/modules/sql/core/src/test/scala/oxygen/sql/schema/RowSchemaSpec.scala index 09679df0..c6addb3f 100644 --- a/modules/sql/core/src/test/scala/oxygen/sql/schema/RowSchemaSpec.scala +++ b/modules/sql/core/src/test/scala/oxygen/sql/schema/RowSchemaSpec.scala @@ -56,6 +56,8 @@ object RowSchemaSpec extends OxygenSpecDefault { singleColumnTest[Short](Column.Type.SmallInt), singleColumnTest[Int](Column.Type.Int), singleColumnTest[Long](Column.Type.BigInt), + singleColumnTest[BigInt](Column.Type.Numeric), + singleColumnTest[BigDecimal](Column.Type.Numeric), singleColumnTest[Float](Column.Type.Real), singleColumnTest[Double](Column.Type.DoublePrecision), singleColumnTest[String](Column.Type.Text), diff --git a/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala b/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala index b63fbc28..1957c011 100644 --- a/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala +++ b/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala @@ -272,6 +272,64 @@ 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 + sumOrNull <- queries.personAgeSumOrNullByGroup.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 (COALESCE `sum` -> 0) + sumEmpty <- queries.personAgeSumByGroup.execute(emptyGroupId).single + sumOrNullEmpty <- queries.personAgeSumOrNullByGroup.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` COALESCEs to a non-optional Long + sum == 60L, + sumOrNull == 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 -> COALESCE `sum` -> 0, `sum.orNull`/avg/min/max -> None + sumEmpty == 0L, + sumOrNullEmpty == Option.empty[Long], + avgEmpty == Option.empty[BigDecimal], + minEmpty == Option.empty[Int], + maxEmpty == Option.empty[Int], + ) + }, + test("BigInt / BigDecimal columns round-trip through `numeric`") { + // values chosen to exceed Long / Double range, so a lossy codec would be caught + val big = BigInt("123456789012345678901234567890") + val dec = BigDecimal("1234567890123456789.0123456789") + val row = Nums(bi = big, bd = dec, biOpt = big.some, bdOpt = None) + for { + _ <- Nums.insert.all(row).unit + got <- Nums.selectAll.execute().single + } yield assertTrue( + got.bi == big, + got.biOpt == big.some, + got.bdOpt.isEmpty, + // numeric compare (scale-insensitive) — `numeric` preserves the exact value + got.bd.compare(dec) == 0, + ) + }, test("insert from select") { for { groupId <- Random.nextUUID @@ -529,7 +587,7 @@ object CustomQuerySpec extends OxygenSpec[Database] { LayerProvider.provideShared[Env]( Helpers.testContainerLayer, Helpers.databaseLayer >>> MigrationService.migrateUnverifiedLayer, - MigrationTestUtil.stagedConfigLayer(Person.tableRepr, Note.tableRepr, Note2.tableRepr, Ints.tableRepr, MultiPK1.tableRepr, MultiPK2.tableRepr, LTreeEx.tableRepr), + MigrationTestUtil.stagedConfigLayer(Person.tableRepr, Note.tableRepr, Note2.tableRepr, Ints.tableRepr, Nums.tableRepr, MultiPK1.tableRepr, MultiPK2.tableRepr, LTreeEx.tableRepr), ) } diff --git a/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala b/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala index a9256d2f..015a0eab 100644 --- a/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala +++ b/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala @@ -139,6 +139,14 @@ final case class Ints( ) object Ints extends TableCompanion[Ints, Unit](TableRepr.derived[Ints]) +final case class Nums( + bi: BigInt, + bd: BigDecimal, + biOpt: Option[BigInt], + bdOpt: Option[BigDecimal], +) +object Nums extends TableCompanion[Nums, Unit](TableRepr.derived[Nums]) + final case class Arrays( _1: List[Int], _2: Set[String], @@ -477,6 +485,50 @@ 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 (except `sum`'s COALESCE variant -> 0). + + // `sum(_)` == `sum.orZero(_)` -> COALESCE(SUM(_), 0), non-optional (0 over an empty group). + @compile + val personAgeSumByGroup: QueryIO[UUID, Long] = + for { + groupId <- input[UUID] + p <- select[Person] + _ <- where if p.groupId == groupId + } yield Q.sum(p.age) + + // `sum.orNull(_)` -> SUM(_), Option (None over an empty group). + @compile + val personAgeSumOrNullByGroup: QueryIO[UUID, Option[Long]] = + for { + groupId <- input[UUID] + p <- select[Person] + _ <- where if p.groupId == groupId + } yield Q.sum.orNull(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 { diff --git a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/dbToDomain.scala b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/dbToDomain.scala index c3bfd685..2de1a8f3 100644 --- a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/dbToDomain.scala +++ b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/dbToDomain.scala @@ -42,6 +42,7 @@ object dbToDomain { case ColumnColumn.Type.BigInt => Column.Type.BigInt case ColumnColumn.Type.Real => Column.Type.Real case ColumnColumn.Type.DoublePrecision => Column.Type.DoublePrecision + case ColumnColumn.Type.Numeric => Column.Type.Numeric case ColumnColumn.Type.Text => Column.Type.Text case ColumnColumn.Type.Timestamp => Column.Type.Timestamp case ColumnColumn.Type.ZonedTimestamp => Column.Type.ZonedTimestamp diff --git a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/domainToDb.scala b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/domainToDb.scala index 9eaa638c..c5142a4e 100644 --- a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/domainToDb.scala +++ b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/conversion/domainToDb.scala @@ -42,6 +42,7 @@ object domainToDb { case Column.Type.BigInt => ColumnColumn.Type.BigInt case Column.Type.Real => ColumnColumn.Type.Real case Column.Type.DoublePrecision => ColumnColumn.Type.DoublePrecision + case Column.Type.Numeric => ColumnColumn.Type.Numeric case Column.Type.Text => ColumnColumn.Type.Text case Column.Type.Timestamp => ColumnColumn.Type.Timestamp case Column.Type.ZonedTimestamp => ColumnColumn.Type.ZonedTimestamp diff --git a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/model/ColumnColumn.scala b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/model/ColumnColumn.scala index ed704c0f..8bae360e 100644 --- a/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/model/ColumnColumn.scala +++ b/modules/sql/migration/src/main/scala/oxygen/sql/migration/persistence/model/ColumnColumn.scala @@ -27,7 +27,7 @@ object ColumnColumn { case object BigInt extends Type.Single("BIGINT") case object Real extends Type.Single("REAL") case object DoublePrecision extends Type.Single("DOUBLE PRECISION") - // TODO (KR) : decimal + case object Numeric extends Type.Single("NUMERIC") // Character Types case object Text extends Type.Single("TEXT")