Skip to content
Merged
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
85 changes: 79 additions & 6 deletions src/main/scala/usql/dao/Rep.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package usql.dao

import usql.dao.Rep.SqlRep
import usql.{DataType, Sql, SqlInterpolationParameter, sql}
import usql.dao.Rep.{IsNumber, IsString, SqlRep}
import usql.{DataType, SqlInterpolationParameter, SqlParameters, Sql, UnOption, sql}

import scala.annotation.unused
import scala.language.implicitConversions
Expand All @@ -18,19 +18,19 @@ trait Rep[T] {
SqlRep(sql"${toInterpolationParameter} <> ${rep.toInterpolationParameter}")
}

def <(rep: Rep[T]): Rep[Boolean] = {
def <(using IsNumber[T])(rep: Rep[T]): Rep[Boolean] = {
SqlRep(sql"${toInterpolationParameter} < ${rep.toInterpolationParameter}")
}

def >(rep: Rep[T]): Rep[Boolean] = {
def >(using IsNumber[T])(rep: Rep[T]): Rep[Boolean] = {
SqlRep(sql"${toInterpolationParameter} > ${rep.toInterpolationParameter}")
}

def <=(rep: Rep[T]): Rep[Boolean] = {
def <=(using IsNumber[T])(rep: Rep[T]): Rep[Boolean] = {
SqlRep(sql"${toInterpolationParameter} <= ${rep.toInterpolationParameter}")
}

def >=(rep: Rep[T]): Rep[Boolean] = {
def >=(using IsNumber[T])(rep: Rep[T]): Rep[Boolean] = {
SqlRep(sql"${toInterpolationParameter} >= ${rep.toInterpolationParameter}")
}

Expand All @@ -53,6 +53,58 @@ trait Rep[T] {
def unary_!(using T =:= Boolean): Rep[Boolean] = {
SqlRep(sql"NOT (${toInterpolationParameter})")
}

// Arithmetic operations

def +(using IsNumber[T])(rep: Rep[T]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) + (${rep.toInterpolationParameter})")
}

def -(using IsNumber[T])(rep: Rep[T]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) - (${rep.toInterpolationParameter})")
}

def *(using IsNumber[T])(rep: Rep[T]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) * (${rep.toInterpolationParameter})")
}

def /(using IsNumber[T])(rep: Rep[T]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) / (${rep.toInterpolationParameter})")
}

def %(using IsNumber[T])(rep: Rep[T]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) % (${rep.toInterpolationParameter})")
}

def unary_-(using IsNumber[T]): Rep[T] = {
SqlRep(sql"-(${toInterpolationParameter})")
}

// String operations

def like(using IsString[T])(pattern: Rep[String]): Rep[Boolean] = {
SqlRep(sql"${toInterpolationParameter} LIKE ${pattern.toInterpolationParameter}")
}

def ++(using IsString[T])(rep: Rep[String]): Rep[T] = {
SqlRep(sql"(${toInterpolationParameter}) || (${rep.toInterpolationParameter})")
}

// Set and range operations

def in(values: Seq[UnOption[T]])(using DataType[UnOption[T]]): Rep[Boolean] = {
if values.isEmpty then {
SqlRep(sql"FALSE")
} else {
SqlRep(sql"${toInterpolationParameter} IN (${SqlParameters(values)})")
}
}

def between(low: Rep[T], high: Rep[T]): Rep[Boolean] = {
SqlRep(
sql"${toInterpolationParameter} BETWEEN ${low.toInterpolationParameter} AND ${high.toInterpolationParameter}"
)
}
}

object Rep {
Expand All @@ -72,4 +124,25 @@ object Rep {
implicit def raw[T: DataType](value: T): Rep[T] = RawValue(value)
implicit def rawOpt[T](value: T)(using dt: DataType[T]): Rep[Option[T]] = opt(raw(value))
implicit def opt[T](rep: Rep[T]): Rep[Option[T]] = SomeRep(rep)

/** T is a numeric SQL type. */
trait IsNumber[T]
object IsNumber {
given int: IsNumber[Int] with {}
given long: IsNumber[Long] with {}
given short: IsNumber[Short] with {}
given byte: IsNumber[Byte] with {}
given float: IsNumber[Float] with {}
given double: IsNumber[Double] with {}
given bigDecimal: IsNumber[BigDecimal] with {}

given opt[T](using IsNumber[T]): IsNumber[Option[T]] with {}
}

/** T is a string SQL type. */
trait IsString[T]
object IsString {
given string: IsString[String] with {}
given stringOpt: IsString[Option[String]] with {}
}
}
98 changes: 98 additions & 0 deletions src/test/scala/usql/dao/RepTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package usql.dao

import usql.util.TestBaseWithH2

class RepTest extends TestBaseWithH2 {
override protected def baseSql: String =
"""
|CREATE TABLE item (
| id INT PRIMARY KEY,
| name VARCHAR NOT NULL,
| price INT NOT NULL,
| discount INT,
| description VARCHAR
|);
|""".stripMargin

case class Item(
id: Int,
name: String,
price: Int,
discount: Option[Int] = None,
description: Option[String] = None
) derives SqlTabular

object Item extends KeyedCrudBase[Int, Item] {
override def key: KeyColumnPath = cols.id

override lazy val tabular: SqlTabular[Item] = summon
}

trait Env {
val widget = Item(1, "Widget", 100, Some(10), Some("A basic widget"))
val gadget = Item(2, "Gadget", 200, None, Some("A fancy gadget"))
val thingamajig = Item(3, "Thingamajig", 50, Some(5), None)

Item.insert(widget, gadget, thingamajig)
}

case class FilterCase(
name: String,
filter: ColumnBasePath[Item] => Rep[Boolean],
expected: Seq[String]
)

val cases: Seq[FilterCase] = Seq(
FilterCase("addition", i => i.price + Rep.raw(50) > Rep.raw(150), Seq("Gadget")),
FilterCase("subtraction", i => i.price - i.price === Rep.raw(0), Seq("Widget", "Gadget", "Thingamajig")),
FilterCase("multiplication", i => i.price * Rep.raw(2) > Rep.raw(150), Seq("Widget", "Gadget")),
FilterCase("division", i => i.price / Rep.raw(10) >= Rep.raw(10), Seq("Widget", "Gadget")),
FilterCase("modulo", i => i.price % Rep.raw(100) === Rep.raw(0), Seq("Widget", "Gadget")),
FilterCase("unary negation", i => -i.price < Rep.raw(-50), Seq("Widget", "Gadget")),
FilterCase("LIKE on string", _.name.like("W%"), Seq("Widget")),
FilterCase("LIKE on optional string", _.description.like("%gadget%"), Seq("Gadget")),
FilterCase("IN clause", _.price.in(Seq(100, 200)), Seq("Widget", "Gadget")),
FilterCase("IN clause empty", _.price.in(Seq.empty[Int]), Seq.empty),
FilterCase("IN on optional column", _.discount.in(Seq(10)), Seq("Widget")),
FilterCase("BETWEEN", _.price.between(Rep.raw(50), Rep.raw(150)), Seq("Widget", "Thingamajig")),
FilterCase(
"combined arithmetic and LIKE",
i => i.price * Rep.raw(2) > Rep.raw(100) && i.name.like("G%"),
Seq("Gadget")
),
FilterCase(
"precedence of chained arithmetic",
i => (i.price + Rep.raw(50)) * Rep.raw(2) > Rep.raw(150),
Seq("Widget", "Gadget", "Thingamajig")
),
FilterCase(
"precedence of multiply before add",
i => i.price + Rep.raw(50) * Rep.raw(2) > Rep.raw(150),
Seq("Widget", "Gadget")
)
)

cases.foreach { c =>
it should s"support ${c.name}" in new Env {
val results = Item.query.filter(c.filter).map(_.name).all()
results should contain theSameElementsAs c.expected
}
}

it should "support string concatenation" in new Env {
val results = Item.query
.filter(i => (i.name ++ Rep.raw(" item")).like("%Widget item%"))
.map(_.name)
.all()
results shouldBe Seq("Widget")
}

it should "support arithmetic on optional columns" in new Env {
val results = Item.query
.filter(_.discount.isNotNull)
.filter(i => i.discount + i.discount > Rep.rawOpt(12))
.map(_.name)
.all()
results should contain theSameElementsAs Seq("Widget")
}
}
Loading