diff --git a/src/main/scala/usql/dao/Rep.scala b/src/main/scala/usql/dao/Rep.scala index 36a78e4..0a468f4 100644 --- a/src/main/scala/usql/dao/Rep.scala +++ b/src/main/scala/usql/dao/Rep.scala @@ -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 @@ -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}") } @@ -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 { @@ -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 {} + } } diff --git a/src/test/scala/usql/dao/RepTest.scala b/src/test/scala/usql/dao/RepTest.scala new file mode 100644 index 0000000..0ed8902 --- /dev/null +++ b/src/test/scala/usql/dao/RepTest.scala @@ -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") + } +}