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
+}