From 2dbb1db2fd14b4c2c5c42ba54d6196c27cbde761 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 7 Dec 2020 19:30:10 +0100 Subject: [PATCH] Initial outline for a simple query builder --- .../main/scala/docspell/store/qb/Column.scala | 5 ++ .../scala/docspell/store/qb/Condition.scala | 20 +++++ .../scala/docspell/store/qb/DBFunction.scala | 26 ++++++ .../main/scala/docspell/store/qb/DML.scala | 62 +++++++++++++++ .../main/scala/docspell/store/qb/DSL.scala | 79 +++++++++++++++++++ .../scala/docspell/store/qb/FromExpr.scala | 28 +++++++ .../scala/docspell/store/qb/GroupBy.scala | 3 + .../main/scala/docspell/store/qb/Join.scala | 10 +++ .../scala/docspell/store/qb/Operator.scala | 14 ++++ .../scala/docspell/store/qb/OrderBy.scala | 14 ++++ .../main/scala/docspell/store/qb/Select.scala | 49 ++++++++++++ .../scala/docspell/store/qb/SelectExpr.scala | 11 +++ .../main/scala/docspell/store/qb/Setter.scala | 17 ++++ .../scala/docspell/store/qb/TableDef.scala | 7 ++ .../store/qb/impl/ConditionBuilder.scala | 74 +++++++++++++++++ .../docspell/store/qb/impl/DoobieQuery.scala | 72 +++++++++++++++++ .../store/qb/impl/FromExprBuilder.scala | 36 +++++++++ .../store/qb/impl/SelectExprBuilder.scala | 30 +++++++ .../docspell/store/qb/QueryBuilderTest.scala | 37 +++++++++ .../store/qb/impl/DoobieQueryTest.scala | 50 ++++++++++++ .../store/qb/model/CourseRecord.scala | 34 ++++++++ .../store/qb/model/PersonRecord.scala | 38 +++++++++ 22 files changed, 716 insertions(+) create mode 100644 modules/store/src/main/scala/docspell/store/qb/Column.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/Condition.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/DBFunction.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/DML.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/DSL.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/FromExpr.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/GroupBy.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/Join.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/Operator.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/OrderBy.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/Select.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/SelectExpr.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/Setter.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/TableDef.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/impl/DoobieQuery.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/impl/FromExprBuilder.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/impl/SelectExprBuilder.scala create mode 100644 modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala create mode 100644 modules/store/src/test/scala/docspell/store/qb/impl/DoobieQueryTest.scala create mode 100644 modules/store/src/test/scala/docspell/store/qb/model/CourseRecord.scala create mode 100644 modules/store/src/test/scala/docspell/store/qb/model/PersonRecord.scala diff --git a/modules/store/src/main/scala/docspell/store/qb/Column.scala b/modules/store/src/main/scala/docspell/store/qb/Column.scala new file mode 100644 index 00000000..3f3fa1ab --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Column.scala @@ -0,0 +1,5 @@ +package docspell.store.qb + +case class Column[A](name: String, table: TableDef, alias: Option[String] = None) + +object Column {} diff --git a/modules/store/src/main/scala/docspell/store/qb/Condition.scala b/modules/store/src/main/scala/docspell/store/qb/Condition.scala new file mode 100644 index 00000000..45f9a4c7 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Condition.scala @@ -0,0 +1,20 @@ +package docspell.store.qb + +import doobie._ + +sealed trait Condition {} + +object Condition { + + case class CompareVal[A](column: Column[A], op: Operator, value: A)(implicit + val P: Put[A] + ) extends Condition + + case class CompareCol[A](col1: Column[A], op: Operator, col2: Column[A]) + extends Condition + + case class And(c: Condition, cs: Vector[Condition]) extends Condition + case class Or(c: Condition, cs: Vector[Condition]) extends Condition + case class Not(c: Condition) extends Condition + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala new file mode 100644 index 00000000..1e597efc --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala @@ -0,0 +1,26 @@ +package docspell.store.qb + +sealed trait DBFunction { + def alias: String + + def as(alias: String): DBFunction +} + +object DBFunction { + + def countAllAs(alias: String) = + CountAll(alias) + + def countAs[A](column: Column[A], alias: String): DBFunction = + Count(column, alias) + + case class CountAll(alias: String) extends DBFunction { + def as(a: String) = + copy(alias = a) + } + + case class Count(column: Column[_], alias: String) extends DBFunction { + def as(a: String) = + copy(alias = a) + } +} diff --git a/modules/store/src/main/scala/docspell/store/qb/DML.scala b/modules/store/src/main/scala/docspell/store/qb/DML.scala new file mode 100644 index 00000000..dbbe1c79 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/DML.scala @@ -0,0 +1,62 @@ +package docspell.store.qb + +import docspell.store.qb.impl._ + +import doobie._ +import doobie.implicits._ + +object DML { + private val comma = fr"," + + def delete(table: TableDef, cond: Condition): Fragment = + fr"DELETE FROM" ++ FromExprBuilder.buildTable(table) ++ fr"WHERE" ++ ConditionBuilder + .build(cond) + + def insert(table: TableDef, cols: Seq[Column[_]], values: Fragment): Fragment = + fr"INSERT INTO" ++ FromExprBuilder.buildTable(table) ++ sql"(" ++ + cols + .map(SelectExprBuilder.columnNoPrefix) + .reduce(_ ++ comma ++ _) ++ fr") VALUES (" ++ + values ++ fr")" + + def update( + table: TableDef, + cond: Condition, + setter: Seq[Setter[_]] + ): ConnectionIO[Int] = + update(table, Some(cond), setter).update.run + + def update( + table: TableDef, + cond: Option[Condition], + setter: Seq[Setter[_]] + ): Fragment = { + val condFrag = cond.map(DoobieQuery.cond).getOrElse(Fragment.empty) + fr"UPDATE" ++ FromExprBuilder.buildTable(table) ++ fr"SET" ++ + setter + .map(s => buildSetter(s)) + .reduce(_ ++ comma ++ _) ++ + condFrag + } + + private def buildSetter[A](setter: Setter[A]): Fragment = + setter match { + case s @ Setter.SetValue(column, value) => + SelectExprBuilder.columnNoPrefix(column) ++ fr" =" ++ ConditionBuilder.buildValue( + value + )(s.P) + + case s @ Setter.SetOptValue(column, optValue) => + SelectExprBuilder.columnNoPrefix(column) ++ fr" =" ++ ConditionBuilder + .buildOptValue( + optValue + )(s.P) + + case Setter.Increment(column, amount) => + val colFrag = SelectExprBuilder.columnNoPrefix(column) + colFrag ++ fr" =" ++ colFrag ++ fr" + $amount" + } + + def set(s: Setter[_], more: Setter[_]*): Seq[Setter[_]] = + more :+ s +} diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala new file mode 100644 index 00000000..63eb7083 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala @@ -0,0 +1,79 @@ +package docspell.store.qb + +import docspell.store.impl.DoobieMeta +import docspell.store.qb.impl.DoobieQuery + +import doobie.{Fragment, Put} + +trait DSL extends DoobieMeta { + + def run(projection: Seq[SelectExpr], from: FromExpr, where: Condition): Fragment = + DoobieQuery(Select(projection, from, where)) + + def select(dbf: DBFunction): Seq[SelectExpr] = + Seq(SelectExpr.SelectFun(dbf)) + + def select(c: Column[_], cs: Column[_]*): Seq[SelectExpr] = + select(c :: cs.toList) + + def select(seq: Seq[Column[_]], seqs: Seq[Column[_]]*): Seq[SelectExpr] = + (seq ++ seqs.flatten).map(SelectExpr.SelectColumn.apply) + + def from(table: TableDef): FromExpr = + FromExpr.From(table) + + def count(c: Column[_]): DBFunction = + DBFunction.Count(c, "cn") + + def and(c: Condition, cs: Condition*): Condition = + Condition.And(c, cs.toVector) + + def or(c: Condition, cs: Condition*): Condition = + Condition.Or(c, cs.toVector) + + def where(c: Condition, cs: Condition*): Condition = + and(c, cs: _*) + + implicit final class ColumnOps[A](col: Column[A]) { + + def setTo(value: A)(implicit P: Put[A]): Setter[A] = + Setter.SetValue(col, value) + + def setTo(value: Option[A])(implicit P: Put[A]): Setter[Option[A]] = + Setter.SetOptValue(col, value) + + def increment(amount: Int): Setter[A] = + Setter.Increment(col, amount) + + def asc: OrderBy = + OrderBy(SelectExpr.SelectColumn(col), OrderBy.OrderType.Asc) + + def desc: OrderBy = + OrderBy(SelectExpr.SelectColumn(col), OrderBy.OrderType.Desc) + + def ===(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.Eq, value) + + def like(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.LowerLike, value) + + def <=(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.Lte, value) + + def >=(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.Gte, value) + + def >(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.Gt, value) + + def <(value: A)(implicit P: Put[A]): Condition = + Condition.CompareVal(col, Operator.Lt, value) + + def ===(other: Column[A]): Condition = + Condition.CompareCol(col, Operator.Eq, other) + + } + +} + +object DSL extends DSL diff --git a/modules/store/src/main/scala/docspell/store/qb/FromExpr.scala b/modules/store/src/main/scala/docspell/store/qb/FromExpr.scala new file mode 100644 index 00000000..f066cccc --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/FromExpr.scala @@ -0,0 +1,28 @@ +package docspell.store.qb + +sealed trait FromExpr { + + def innerJoin(other: TableDef, on: Condition): FromExpr + + def leftJoin(other: TableDef, on: Condition): FromExpr +} + +object FromExpr { + + case class From(table: TableDef) extends FromExpr { + def innerJoin(other: TableDef, on: Condition): Joined = + Joined(this, Vector(Join.InnerJoin(other, on))) + + def leftJoin(other: TableDef, on: Condition): Joined = + Joined(this, Vector(Join.LeftJoin(other, on))) + } + + case class Joined(from: From, joins: Vector[Join]) extends FromExpr { + def innerJoin(other: TableDef, on: Condition): Joined = + Joined(from, joins :+ Join.InnerJoin(other, on)) + + def leftJoin(other: TableDef, on: Condition): Joined = + Joined(from, joins :+ Join.LeftJoin(other, on)) + + } +} diff --git a/modules/store/src/main/scala/docspell/store/qb/GroupBy.scala b/modules/store/src/main/scala/docspell/store/qb/GroupBy.scala new file mode 100644 index 00000000..a12e6448 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/GroupBy.scala @@ -0,0 +1,3 @@ +package docspell.store.qb + +case class GroupBy(name: SelectExpr, names: Vector[SelectExpr], having: Option[Condition]) diff --git a/modules/store/src/main/scala/docspell/store/qb/Join.scala b/modules/store/src/main/scala/docspell/store/qb/Join.scala new file mode 100644 index 00000000..a51a3b70 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Join.scala @@ -0,0 +1,10 @@ +package docspell.store.qb + +sealed trait Join + +object Join { + + case class InnerJoin(table: TableDef, cond: Condition) extends Join + + case class LeftJoin(table: TableDef, cond: Condition) extends Join +} diff --git a/modules/store/src/main/scala/docspell/store/qb/Operator.scala b/modules/store/src/main/scala/docspell/store/qb/Operator.scala new file mode 100644 index 00000000..c05559ca --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Operator.scala @@ -0,0 +1,14 @@ +package docspell.store.qb + +sealed trait Operator + +object Operator { + + case object Eq extends Operator + case object Gt extends Operator + case object Lt extends Operator + case object Gte extends Operator + case object Lte extends Operator + case object LowerLike extends Operator + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/OrderBy.scala b/modules/store/src/main/scala/docspell/store/qb/OrderBy.scala new file mode 100644 index 00000000..24d42cd4 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/OrderBy.scala @@ -0,0 +1,14 @@ +package docspell.store.qb + +import docspell.store.qb.OrderBy.OrderType + +final case class OrderBy(expr: SelectExpr, orderType: OrderType) + +object OrderBy { + + sealed trait OrderType + object OrderType { + case object Asc extends OrderType + case object Desc extends OrderType + } +} diff --git a/modules/store/src/main/scala/docspell/store/qb/Select.scala b/modules/store/src/main/scala/docspell/store/qb/Select.scala new file mode 100644 index 00000000..accb90a0 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Select.scala @@ -0,0 +1,49 @@ +package docspell.store.qb + +import docspell.store.qb.impl.DoobieQuery + +import doobie._ + +sealed trait Select { + def distinct: Fragment = + DoobieQuery.distinct(this) + + def run: Fragment = + DoobieQuery(this) + + def orderBy(ob: OrderBy, obs: OrderBy*): Select = + Select.Ordered(this, ob, obs.toVector) + + def orderBy(c: Column[_]): Select = + orderBy(OrderBy(SelectExpr.SelectColumn(c), OrderBy.OrderType.Asc)) +} + +object Select { + + def apply( + projection: Seq[SelectExpr], + from: FromExpr, + where: Condition + ) = SimpleSelect(projection, from, Some(where), None) + + def apply( + projection: Seq[SelectExpr], + from: FromExpr, + where: Option[Condition] = None, + groupBy: Option[GroupBy] = None + ) = SimpleSelect(projection, from, where, groupBy) + + case class SimpleSelect( + projection: Seq[SelectExpr], + from: FromExpr, + where: Option[Condition], + groupBy: Option[GroupBy] + ) extends Select + + case class Union(q: Select, qs: Vector[Select]) extends Select + + case class Intersect(q: Select, qs: Vector[Select]) extends Select + + case class Ordered(q: Select, orderBy: OrderBy, orderBys: Vector[OrderBy]) + extends Select +} diff --git a/modules/store/src/main/scala/docspell/store/qb/SelectExpr.scala b/modules/store/src/main/scala/docspell/store/qb/SelectExpr.scala new file mode 100644 index 00000000..1ccb3b90 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/SelectExpr.scala @@ -0,0 +1,11 @@ +package docspell.store.qb + +sealed trait SelectExpr + +object SelectExpr { + + case class SelectColumn(column: Column[_]) extends SelectExpr + + case class SelectFun(fun: DBFunction) extends SelectExpr + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/Setter.scala b/modules/store/src/main/scala/docspell/store/qb/Setter.scala new file mode 100644 index 00000000..d86af800 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/Setter.scala @@ -0,0 +1,17 @@ +package docspell.store.qb + +import doobie._ + +sealed trait Setter[A] + +object Setter { + + case class SetOptValue[A](column: Column[A], value: Option[A])(implicit val P: Put[A]) + extends Setter[Option[A]] + + case class SetValue[A](column: Column[A], value: A)(implicit val P: Put[A]) + extends Setter[A] + + case class Increment[A](column: Column[A], amount: Int) extends Setter[A] + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/TableDef.scala b/modules/store/src/main/scala/docspell/store/qb/TableDef.scala new file mode 100644 index 00000000..13da97cf --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/TableDef.scala @@ -0,0 +1,7 @@ +package docspell.store.qb + +trait TableDef { + def tableName: String + + def alias: Option[String] +} diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala new file mode 100644 index 00000000..5a37733f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala @@ -0,0 +1,74 @@ +package docspell.store.qb.impl + +import docspell.store.qb._ + +import _root_.doobie.implicits._ +import _root_.doobie.{Query => _, _} + +object ConditionBuilder { + val or = fr"OR" + val and = fr"AND" + val parenOpen = Fragment.const0("(") + val parenClose = Fragment.const0(")") + + def build(expr: Condition): Fragment = + expr match { + case c @ Condition.CompareVal(col, op, value) => + val opFrag = operator(op) + val valFrag = buildValue(value)(c.P) + val colFrag = op match { + case Operator.LowerLike => + lower(col) + case _ => + SelectExprBuilder.column(col) + } + colFrag ++ opFrag ++ valFrag + + case Condition.CompareCol(c1, op, c2) => + val (c1Frag, c2Frag) = op match { + case Operator.LowerLike => + (lower(c1), lower(c2)) + case _ => + (SelectExprBuilder.column(c1), SelectExprBuilder.column(c2)) + } + c1Frag ++ operator(op) ++ c2Frag + + case Condition.And(c, cs) => + val inner = cs.prepended(c).map(build).reduce(_ ++ and ++ _) + if (cs.isEmpty) inner + else parenOpen ++ inner ++ parenClose + + case Condition.Or(c, cs) => + val inner = cs.prepended(c).map(build).reduce(_ ++ or ++ _) + if (cs.isEmpty) inner + else parenOpen ++ inner ++ parenClose + + case Condition.Not(c) => + fr"NOT" ++ build(c) + } + + def operator(op: Operator): Fragment = + op match { + case Operator.Eq => + fr" =" + case Operator.Gt => + fr" >" + case Operator.Lt => + fr" <" + case Operator.Gte => + fr" >=" + case Operator.Lte => + fr" <=" + case Operator.LowerLike => + fr" LIKE" + } + + def buildValue[A: Put](v: A): Fragment = + fr"$v" + + def buildOptValue[A: Put](v: Option[A]): Fragment = + fr"$v" + + def lower(col: Column[_]): Fragment = + Fragment.const0("LOWER(") ++ SelectExprBuilder.column(col) ++ sql")" +} diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/DoobieQuery.scala b/modules/store/src/main/scala/docspell/store/qb/impl/DoobieQuery.scala new file mode 100644 index 00000000..e20d9e72 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/impl/DoobieQuery.scala @@ -0,0 +1,72 @@ +package docspell.store.qb.impl + +import docspell.store.qb._ + +import _root_.doobie.implicits._ +import _root_.doobie.{Query => _, _} + +object DoobieQuery { + val comma = fr"," + val asc = fr" ASC" + val desc = fr" DESC" + val intersect = fr"INTERSECT" + val union = fr"UNION ALL" + + def apply(q: Select): Fragment = + build(false)(q) + + def distinct(q: Select): Fragment = + build(true)(q) + + def build(distinct: Boolean)(q: Select): Fragment = + q match { + case sq: Select.SimpleSelect => + val sel = if (distinct) fr"SELECT DISTINCT" else fr"SELECT" + sel ++ buildSimple(sq) + + case Select.Union(q, qs) => + qs.prepended(q).map(build(false)).reduce(_ ++ union ++ _) + + case Select.Intersect(q, qs) => + qs.prepended(q).map(build(false)).reduce(_ ++ intersect ++ _) + + case Select.Ordered(q, ob, obs) => + val order = obs.prepended(ob).map(orderBy).reduce(_ ++ comma ++ _) + build(distinct)(q) ++ fr"ORDER BY" ++ order + + } + + def buildSimple(sq: Select.SimpleSelect): Fragment = { + val f0 = sq.projection.map(selectExpr).reduce(_ ++ comma ++ _) + val f1 = fromExpr(sq.from) + val f2 = sq.where.map(cond).getOrElse(Fragment.empty) + val f3 = sq.groupBy.map(groupBy).getOrElse(Fragment.empty) + f0 ++ f1 ++ f2 ++ f3 + } + + def orderBy(ob: OrderBy): Fragment = { + val f1 = selectExpr(ob.expr) + val f2 = ob.orderType match { + case OrderBy.OrderType.Asc => + asc + case OrderBy.OrderType.Desc => + desc + } + f1 ++ f2 + } + + def selectExpr(se: SelectExpr): Fragment = + SelectExprBuilder.build(se) + + def fromExpr(fr: FromExpr): Fragment = + FromExprBuilder.build(fr) + + def cond(c: Condition): Fragment = + fr" WHERE" ++ ConditionBuilder.build(c) + + def groupBy(gb: GroupBy): Fragment = { + val f0 = gb.names.prepended(gb.name).map(selectExpr).reduce(_ ++ comma ++ _) + val f1 = gb.having.map(cond).getOrElse(Fragment.empty) + fr"GROUP BY" ++ f0 ++ f1 + } +} diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/FromExprBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/FromExprBuilder.scala new file mode 100644 index 00000000..39e10864 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/impl/FromExprBuilder.scala @@ -0,0 +1,36 @@ +package docspell.store.qb.impl + +import docspell.store.qb._ + +import _root_.doobie.implicits._ +import _root_.doobie.{Query => _, _} + +object FromExprBuilder { + + def build(expr: FromExpr): Fragment = + expr match { + case FromExpr.From(table) => + fr" FROM" ++ buildTable(table) + + case FromExpr.Joined(from, joins) => + build(from) ++ + joins.map(buildJoin).foldLeft(Fragment.empty)(_ ++ _) + } + + def buildTable(table: TableDef): Fragment = + Fragment.const(table.tableName) ++ table.alias + .map(a => Fragment.const0(a)) + .getOrElse(Fragment.empty) + + def buildJoin(join: Join): Fragment = + join match { + case Join.InnerJoin(table, cond) => + val c = fr" ON" ++ ConditionBuilder.build(cond) + fr" INNER JOIN" ++ buildTable(table) ++ c + + case Join.LeftJoin(table, cond) => + val c = fr" ON" ++ ConditionBuilder.build(cond) + fr" LEFT JOIN" ++ buildTable(table) ++ c + } + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/SelectExprBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/SelectExprBuilder.scala new file mode 100644 index 00000000..e60308bd --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/impl/SelectExprBuilder.scala @@ -0,0 +1,30 @@ +package docspell.store.qb.impl + +import docspell.store.qb._ + +import _root_.doobie.implicits._ +import _root_.doobie.{Query => _, _} + +object SelectExprBuilder { + + def build(expr: SelectExpr): Fragment = + expr match { + case SelectExpr.SelectColumn(col) => + column(col) + + case SelectExpr.SelectFun(DBFunction.CountAll(alias)) => + fr"COUNT(*) AS" ++ Fragment.const(alias) + + case SelectExpr.SelectFun(DBFunction.Count(col, alias)) => + fr"COUNT(" ++ column(col) ++ fr") AS" ++ Fragment.const(alias) + } + + def column(col: Column[_]): Fragment = { + val prefix = + Fragment.const0(col.table.alias.getOrElse(col.table.tableName)) + prefix ++ Fragment.const0(".") ++ Fragment.const0(col.name) + } + + def columnNoPrefix(col: Column[_]): Fragment = + Fragment.const0(col.name) +} diff --git a/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala b/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala new file mode 100644 index 00000000..f7912f4a --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/qb/QueryBuilderTest.scala @@ -0,0 +1,37 @@ +package docspell.store.qb + +import minitest._ +import docspell.store.qb.model._ +import docspell.store.qb.DSL._ + +object QueryBuilderTest extends SimpleTestSuite { + + test("simple") { + val c = CourseRecord.as("c") + val owner = PersonRecord.as("p1") + val lecturer = PersonRecord.as("p2") + + val proj = select(c.all, owner.all, lecturer.all) + + val tables = + from(c) + .innerJoin(owner, c.ownerId === owner.id) + .leftJoin(lecturer, c.lecturerId === lecturer.id) + + val cond = + where( + c.name.like("%scala%"), + c.lessons <= 15, + or( + owner.name.like("%"), + lecturer.id >= 1 + ) + ) + + // val order = + // orderBy(c.name.asc) + + val q = Select(proj, tables, cond) + println(q) + } +} diff --git a/modules/store/src/test/scala/docspell/store/qb/impl/DoobieQueryTest.scala b/modules/store/src/test/scala/docspell/store/qb/impl/DoobieQueryTest.scala new file mode 100644 index 00000000..9b38d94f --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/qb/impl/DoobieQueryTest.scala @@ -0,0 +1,50 @@ +package docspell.store.qb.impl + +import minitest._ +import docspell.store.qb._ +import docspell.store.qb.model._ +import docspell.store.qb.DSL._ +import docspell.common._ + +object DoobieQueryTest extends SimpleTestSuite { + + test("basic fragment") { + val c = CourseRecord.as("c") + val owner = PersonRecord.as("o") + val lecturer = PersonRecord.as("l") + + val proj = select(c.all) + val table = from(c) + .innerJoin(owner, c.ownerId === owner.id) + .leftJoin(lecturer, c.lecturerId === lecturer.id) + val cond = where( + c.name.like("%test%"), + owner.name === "Harald" + ) + + val q = Select(proj, table, cond) + val frag = DoobieQuery.select(q) + assertEquals( + frag.toString, + """Fragment("SELECT c.id, c.name, c.owner_id, c.lecturer_id, c.lessons FROM course c INNER JOIN person o ON c.owner_id = o.id LEFT JOIN person l ON c.lecturer_id = l.id WHERE (LOWER(c.name) LIKE ? AND o.name = ? )")""" + ) + } + + test("basic update") { + val p = PersonRecord.table + + val update = PersonRecord.update(p.name.set("john"), p.id.set(15L)).where(p.id >= 2) + + println(DoobieQuery.update(update)) + + } + + test("basic insert") { + val p = PersonRecord(1, "John", Timestamp.Epoch) + + val insert = PersonRecord.insertAll(p) + + println(insert) + + } +} diff --git a/modules/store/src/test/scala/docspell/store/qb/model/CourseRecord.scala b/modules/store/src/test/scala/docspell/store/qb/model/CourseRecord.scala new file mode 100644 index 00000000..867a41de --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/qb/model/CourseRecord.scala @@ -0,0 +1,34 @@ +package docspell.store.qb.model + +import docspell.store.qb._ + +case class CourseRecord( + id: Long, + name: String, + ownerId: Long, + lecturerId: Option[Long], + lessons: Int +) + +object CourseRecord { + + final case class Table(alias: Option[String]) extends TableDef { + + override val tableName = "course" + + val id = Column[Long]("id", this) + val name = Column[String]("name", this) + val ownerId = Column[Long]("owner_id", this) + val lecturerId = Column[Long]("lecturer_id", this) + val lessons = Column[Int]("lessons", this) + + val all = List(id, name, ownerId, lecturerId, lessons) + } + + def as(alias: String): Table = + Table(Some(alias)) + + def update: UpdateTable = + UpdateTable(Table(None), None, Seq.empty) + +} diff --git a/modules/store/src/test/scala/docspell/store/qb/model/PersonRecord.scala b/modules/store/src/test/scala/docspell/store/qb/model/PersonRecord.scala new file mode 100644 index 00000000..bfcee143 --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/qb/model/PersonRecord.scala @@ -0,0 +1,38 @@ +package docspell.store.qb.model + +import docspell.store.qb._ +import docspell.common._ +import doobie.implicits._ +import docspell.store.impl.DoobieMeta._ +import doobie._ + +case class PersonRecord(id: Long, name: String, created: Timestamp) + +object PersonRecord { + + final case class Table(alias: Option[String]) extends TableDef { + + val tableName = "person" + + val id = Column[Long]("id", this) + val name = Column[String]("name", this) + val created = Column[Timestamp]("created", this) + + val all = List(id, name, created) + } + + def as(alias: String): Table = + Table(Some(alias)) + + def table: Table = Table(None) + + def update(set: UpdateTable.Setter[_], sets: UpdateTable.Setter[_]*): UpdateTable = + UpdateTable(table, None, sets :+ set) + + def insertAll(v: PersonRecord): ConnectionIO[Int] = + InsertTable( + table, + table.all, + fr"${v.id},${v.name},${v.created}" + ).toFragment.update.run +}