From 8814de3c38c95157c5b0ca1c3c827afdb269cd14 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Thu, 2 Jan 2020 19:59:46 +0100
Subject: [PATCH] Allow simple search when listing meta data

---
 .../docspell/backend/ops/OEquipment.scala     |  6 +-
 .../docspell/backend/ops/OOrganization.scala  | 24 +++---
 .../scala/docspell/backend/ops/OTag.scala     |  6 +-
 .../main/scala/docspell/common/Ident.scala    |  9 ++-
 .../src/main/resources/docspell-openapi.yml   | 13 ++++
 .../restserver/routes/EquipmentRoutes.scala   |  5 +-
 .../routes/OrganizationRoutes.scala           |  7 +-
 .../restserver/routes/PersonRoutes.scala      |  7 +-
 .../restserver/routes/TagRoutes.scala         |  5 +-
 .../store/queries/QOrganization.scala         | 73 +++++++++++++++++--
 .../docspell/store/records/REquipment.scala   | 12 ++-
 .../store/records/ROrganization.scala         | 15 +++-
 .../docspell/store/records/RPerson.scala      | 15 +++-
 .../scala/docspell/store/records/RTag.scala   | 12 ++-
 14 files changed, 163 insertions(+), 46 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OEquipment.scala b/modules/backend/src/main/scala/docspell/backend/ops/OEquipment.scala
index 279b8db3..5f6dc1ca 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OEquipment.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OEquipment.scala
@@ -8,7 +8,7 @@ import docspell.store.records.{REquipment, RItem}
 
 trait OEquipment[F[_]] {
 
-  def findAll(account: AccountId): F[Vector[REquipment]]
+  def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[REquipment]]
 
   def add(s: REquipment): F[AddResult]
 
@@ -21,8 +21,8 @@ object OEquipment {
 
   def apply[F[_]: Effect](store: Store[F]): Resource[F, OEquipment[F]] =
     Resource.pure(new OEquipment[F] {
-      def findAll(account: AccountId): F[Vector[REquipment]] =
-        store.transact(REquipment.findAll(account.collective, _.name))
+      def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[REquipment]] =
+        store.transact(REquipment.findAll(account.collective, nameQuery, _.name))
 
       def add(e: REquipment): F[AddResult] = {
         def insert = REquipment.insert(e)
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OOrganization.scala b/modules/backend/src/main/scala/docspell/backend/ops/OOrganization.scala
index 5bf7cd11..a00b7afd 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OOrganization.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OOrganization.scala
@@ -9,17 +9,17 @@ import OOrganization._
 import docspell.store.queries.QOrganization
 
 trait OOrganization[F[_]] {
-  def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]]
+  def findAllOrg(account: AccountId, query: Option[String]): F[Vector[OrgAndContacts]]
 
-  def findAllOrgRefs(account: AccountId): F[Vector[IdRef]]
+  def findAllOrgRefs(account: AccountId, nameQuery: Option[String]): F[Vector[IdRef]]
 
   def addOrg(s: OrgAndContacts): F[AddResult]
 
   def updateOrg(s: OrgAndContacts): F[AddResult]
 
-  def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]]
+  def findAllPerson(account: AccountId, query: Option[String]): F[Vector[PersonAndContacts]]
 
-  def findAllPersonRefs(account: AccountId): F[Vector[IdRef]]
+  def findAllPersonRefs(account: AccountId, nameQuery: Option[String]): F[Vector[IdRef]]
 
   def addPerson(s: PersonAndContacts): F[AddResult]
 
@@ -39,15 +39,15 @@ object OOrganization {
   def apply[F[_]: Effect](store: Store[F]): Resource[F, OOrganization[F]] =
     Resource.pure(new OOrganization[F] {
 
-      def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]] =
+      def findAllOrg(account: AccountId, query: Option[String]): F[Vector[OrgAndContacts]] =
         store
-          .transact(QOrganization.findOrgAndContact(account.collective, _.name))
+          .transact(QOrganization.findOrgAndContact(account.collective, query, _.name))
           .map({ case (org, cont) => OrgAndContacts(org, cont) })
           .compile
           .toVector
 
-      def findAllOrgRefs(account: AccountId): F[Vector[IdRef]] =
-        store.transact(ROrganization.findAllRef(account.collective, _.name))
+      def findAllOrgRefs(account: AccountId, nameQuery: Option[String]): F[Vector[IdRef]] =
+        store.transact(ROrganization.findAllRef(account.collective, nameQuery, _.name))
 
       def addOrg(s: OrgAndContacts): F[AddResult] =
         QOrganization.addOrg(s.org, s.contacts, s.org.cid)(store)
@@ -55,15 +55,15 @@ object OOrganization {
       def updateOrg(s: OrgAndContacts): F[AddResult] =
         QOrganization.updateOrg(s.org, s.contacts, s.org.cid)(store)
 
-      def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]] =
+      def findAllPerson(account: AccountId, query: Option[String]): F[Vector[PersonAndContacts]] =
         store
-          .transact(QOrganization.findPersonAndContact(account.collective, _.name))
+          .transact(QOrganization.findPersonAndContact(account.collective, query, _.name))
           .map({ case (person, cont) => PersonAndContacts(person, cont) })
           .compile
           .toVector
 
-      def findAllPersonRefs(account: AccountId): F[Vector[IdRef]] =
-        store.transact(RPerson.findAllRef(account.collective, _.name))
+      def findAllPersonRefs(account: AccountId, nameQuery: Option[String]): F[Vector[IdRef]] =
+        store.transact(RPerson.findAllRef(account.collective, nameQuery, _.name))
 
       def addPerson(s: PersonAndContacts): F[AddResult] =
         QOrganization.addPerson(s.person, s.contacts, s.person.cid)(store)
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala b/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala
index 1414c3fc..29a9748c 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala
@@ -8,7 +8,7 @@ import docspell.store.records.{RTag, RTagItem}
 
 trait OTag[F[_]] {
 
-  def findAll(account: AccountId): F[Vector[RTag]]
+  def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[RTag]]
 
   def add(s: RTag): F[AddResult]
 
@@ -21,8 +21,8 @@ object OTag {
 
   def apply[F[_]: Effect](store: Store[F]): Resource[F, OTag[F]] =
     Resource.pure(new OTag[F] {
-      def findAll(account: AccountId): F[Vector[RTag]] =
-        store.transact(RTag.findAll(account.collective, _.name))
+      def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[RTag]] =
+        store.transact(RTag.findAll(account.collective, nameQuery, _.name))
 
       def add(t: RTag): F[AddResult] = {
         def insert = RTag.insert(t)
diff --git a/modules/common/src/main/scala/docspell/common/Ident.scala b/modules/common/src/main/scala/docspell/common/Ident.scala
index 11cee38d..199bd225 100644
--- a/modules/common/src/main/scala/docspell/common/Ident.scala
+++ b/modules/common/src/main/scala/docspell/common/Ident.scala
@@ -3,14 +3,17 @@ package docspell.common
 import java.security.SecureRandom
 import java.util.UUID
 
+import cats.Eq
+import cats.implicits._
 import cats.effect.Sync
 import io.circe.{Decoder, Encoder}
 import scodec.bits.ByteVector
 
-case class Ident(id: String) {
-}
+case class Ident(id: String) {}
 
 object Ident {
+  implicit val identEq: Eq[Ident] =
+    Eq.by(_.id)
 
   val chars: Set[Char] = (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_").toSet
 
@@ -46,8 +49,6 @@ object Ident {
   def unapply(arg: String): Option[Ident] =
     fromString(arg).toOption
 
-
-
   implicit val encodeIdent: Encoder[Ident] =
     Encoder.encodeString.contramap(_.id)
 
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 563e5687..88eba465 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -255,6 +255,8 @@ paths:
         Return a list of all configured tags.
       security:
         - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/q"
       responses:
         200:
           description: Ok
@@ -329,6 +331,7 @@ paths:
         - authTokenHeader: []
       parameters:
         - $ref: "#/components/parameters/full"
+        - $ref: "#/components/parameters/q"
       responses:
         200:
           description: Ok
@@ -421,6 +424,7 @@ paths:
         - authTokenHeader: []
       parameters:
         - $ref: "#/components/parameters/full"
+        - $ref: "#/components/parameters/q"
       responses:
         200:
           description: Ok
@@ -511,6 +515,8 @@ paths:
         Return a list of all configured equipments.
       security:
         - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/q"
       responses:
         200:
           description: Ok
@@ -2169,3 +2175,10 @@ components:
       required: true
       schema:
         type: string
+    q:
+      name: q
+      in: query
+      description: A query string.
+      required: false
+      schema:
+        type: string
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/EquipmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/EquipmentRoutes.scala
index 6dfeede7..eec9431f 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/EquipmentRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/EquipmentRoutes.scala
@@ -19,9 +19,10 @@ object EquipmentRoutes {
     import dsl._
 
     HttpRoutes.of {
-      case GET -> Root =>
+      case req @ GET -> Root =>
+        val q = req.params.get("q").map(_.trim).filter(_.nonEmpty)
         for {
-          data <- backend.equipment.findAll(user.account)
+          data <- backend.equipment.findAll(user.account, q)
           resp <- Ok(EquipmentList(data.map(mkEquipment).toList))
         } yield resp
 
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/OrganizationRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/OrganizationRoutes.scala
index dc4fc494..1ce3a882 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/OrganizationRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/OrganizationRoutes.scala
@@ -20,15 +20,16 @@ object OrganizationRoutes {
     import dsl._
 
     HttpRoutes.of {
-      case GET -> Root :? FullQueryParamMatcher(full) =>
+      case req @ GET -> Root :? FullQueryParamMatcher(full) =>
+        val q = req.params.get("q").map(_.trim).filter(_.nonEmpty)
         if (full.getOrElse(false)) {
           for {
-            data <- backend.organization.findAllOrg(user.account)
+            data <- backend.organization.findAllOrg(user.account, q)
             resp <- Ok(OrganizationList(data.map(mkOrg).toList))
           } yield resp
         } else {
           for {
-            data <- backend.organization.findAllOrgRefs(user.account)
+            data <- backend.organization.findAllOrgRefs(user.account, q)
             resp <- Ok(ReferenceList(data.map(mkIdName).toList))
           } yield resp
         }
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
index b7fe174a..71d1926f 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/PersonRoutes.scala
@@ -23,15 +23,16 @@ object PersonRoutes {
     import dsl._
 
     HttpRoutes.of {
-      case GET -> Root :? FullQueryParamMatcher(full) =>
+      case req @ GET -> Root :? FullQueryParamMatcher(full) =>
+        val q = req.params.get("q").map(_.trim).filter(_.nonEmpty)
         if (full.getOrElse(false)) {
           for {
-            data <- backend.organization.findAllPerson(user.account)
+            data <- backend.organization.findAllPerson(user.account, q)
             resp <- Ok(PersonList(data.map(mkPerson).toList))
           } yield resp
         } else {
           for {
-            data <- backend.organization.findAllPersonRefs(user.account)
+            data <- backend.organization.findAllPersonRefs(user.account, q)
             resp <- Ok(ReferenceList(data.map(mkIdName).toList))
           } yield resp
         }
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/TagRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/TagRoutes.scala
index 0fc8579e..5a9fdd8e 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/TagRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/TagRoutes.scala
@@ -20,9 +20,10 @@ object TagRoutes {
     import dsl._
 
     HttpRoutes.of {
-      case GET -> Root =>
+      case req @ GET -> Root =>
+        val q = req.params.get("q").map(_.trim).filter(_.nonEmpty)
         for {
-          all  <- backend.tag.findAll(user.account)
+          all  <- backend.tag.findAll(user.account, q)
           resp <- Ok(TagList(all.size, all.map(mkTag).toList))
         } yield resp
 
diff --git a/modules/store/src/main/scala/docspell/store/queries/QOrganization.scala b/modules/store/src/main/scala/docspell/store/queries/QOrganization.scala
index bb89bc30..e9e00631 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QOrganization.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QOrganization.scala
@@ -3,9 +3,11 @@ package docspell.store.queries
 import fs2._
 import cats.implicits._
 import doobie._
+import doobie.implicits._
 import docspell.common._
 import docspell.store.{AddResult, Store}
 import docspell.store.impl.Column
+import docspell.store.impl.Implicits._
 import docspell.store.records.ROrganization.{Columns => OC}
 import docspell.store.records.RPerson.{Columns => PC}
 import docspell.store.records._
@@ -14,16 +16,75 @@ object QOrganization {
 
   def findOrgAndContact(
       coll: Ident,
+      query: Option[String],
       order: OC.type => Column
-  ): Stream[ConnectionIO, (ROrganization, Vector[RContact])] =
-    ROrganization
-      .findAll(coll, order)
-      .evalMap(ro => RContact.findAllOrg(ro.oid).map(cs => (ro, cs)))
+  ): Stream[ConnectionIO, (ROrganization, Vector[RContact])] = {
+    val oColl  = ROrganization.Columns.cid.prefix("o")
+    val oName  = ROrganization.Columns.name.prefix("o")
+    val oNotes = ROrganization.Columns.notes.prefix("o")
+    val oId    = ROrganization.Columns.oid.prefix("o")
+    val cOrg   = RContact.Columns.orgId.prefix("c")
+    val cVal   = RContact.Columns.value.prefix("c")
+
+    val cols = ROrganization.Columns.all.map(_.prefix("o")) ++ RContact.Columns.all
+      .map(_.prefix("c"))
+    val from = ROrganization.table ++ fr"o LEFT JOIN" ++
+      RContact.table ++ fr"c ON" ++ cOrg.is(oId)
+
+    val q = Seq(oColl.is(coll)) ++ (query match {
+      case Some(str) =>
+        val v = s"%$str%"
+        Seq(or(cVal.lowerLike(v), oName.lowerLike(v), oNotes.lowerLike(v)))
+      case None =>
+        Seq.empty
+    })
+
+    (selectSimple(cols, from, and(q)) ++ orderBy(order(OC).f))
+      .query[(ROrganization, Option[RContact])]
+      .stream
+      .groupAdjacentBy(_._1)
+      .map({
+        case (ro, chunk) =>
+          val cs = chunk.toVector.flatMap(_._2)
+          (ro, cs)
+      })
+  }
+
   def findPersonAndContact(
       coll: Ident,
+      query: Option[String],
       order: PC.type => Column
-  ): Stream[ConnectionIO, (RPerson, Vector[RContact])] =
-    RPerson.findAll(coll, order).evalMap(ro => RContact.findAllPerson(ro.pid).map(cs => (ro, cs)))
+  ): Stream[ConnectionIO, (RPerson, Vector[RContact])] = {
+    val pColl  = PC.cid.prefix("p")
+    val pName  = RPerson.Columns.name.prefix("p")
+    val pNotes = RPerson.Columns.notes.prefix("p")
+    val pId    = RPerson.Columns.pid.prefix("p")
+    val cPers  = RContact.Columns.personId.prefix("c")
+    val cVal   = RContact.Columns.value.prefix("c")
+
+    val cols = RPerson.Columns.all.map(_.prefix("p")) ++ RContact.Columns.all
+      .map(_.prefix("c"))
+    val from = RPerson.table ++ fr"p LEFT JOIN" ++
+      RContact.table ++ fr"c ON" ++ cPers.is(pId)
+
+    val q = Seq(pColl.is(coll)) ++ (query match {
+      case Some(str) =>
+        val v = s"%${str.toLowerCase}%"
+        Seq(or(cVal.lowerLike(v), pName.lowerLike(v), pNotes.lowerLike(v)))
+      case None =>
+        Seq.empty
+    })
+
+    (selectSimple(cols, from, and(q)) ++ orderBy(order(PC).f))
+      .query[(RPerson, Option[RContact])]
+      .stream
+      .groupAdjacentBy(_._1)
+      .map({
+        case (ro, chunk) =>
+          val cs = chunk.toVector.flatMap(_._2)
+          (ro, cs)
+      })
+  }
 
   def addOrg[F[_]](
       org: ROrganization,
diff --git a/modules/store/src/main/scala/docspell/store/records/REquipment.scala b/modules/store/src/main/scala/docspell/store/records/REquipment.scala
index 964bec4f..ed2b8d0f 100644
--- a/modules/store/src/main/scala/docspell/store/records/REquipment.scala
+++ b/modules/store/src/main/scala/docspell/store/records/REquipment.scala
@@ -47,8 +47,16 @@ object REquipment {
     sql.query[REquipment].option
   }
 
-  def findAll(coll: Ident, order: Columns.type => Column): ConnectionIO[Vector[REquipment]] = {
-    val sql = selectSimple(all, table, cid.is(coll)) ++ orderBy(order(Columns).f)
+  def findAll(
+      coll: Ident,
+      nameQ: Option[String],
+      order: Columns.type => Column
+  ): ConnectionIO[Vector[REquipment]] = {
+    val q = Seq(cid.is(coll)) ++ (nameQ match {
+      case Some(str) => Seq(name.lowerLike(s"%${str.toLowerCase}%"))
+      case None      => Seq.empty
+    })
+    val sql = selectSimple(all, table, and(q)) ++ orderBy(order(Columns).f)
     sql.query[REquipment].to[Vector]
   }
 
diff --git a/modules/store/src/main/scala/docspell/store/records/ROrganization.scala b/modules/store/src/main/scala/docspell/store/records/ROrganization.scala
index d4b333ee..13a84f29 100644
--- a/modules/store/src/main/scala/docspell/store/records/ROrganization.scala
+++ b/modules/store/src/main/scala/docspell/store/records/ROrganization.scala
@@ -1,5 +1,6 @@
 package docspell.store.records
 
+import cats.Eq
 import fs2.Stream
 import doobie._
 import doobie.implicits._
@@ -20,6 +21,8 @@ case class ROrganization(
 ) {}
 
 object ROrganization {
+  implicit val orgEq: Eq[ROrganization] =
+    Eq.by[ROrganization, Ident](_.oid)
 
   val table = fr"organization"
 
@@ -105,8 +108,16 @@ object ROrganization {
     sql.query[ROrganization].stream
   }
 
-  def findAllRef(coll: Ident, order: Columns.type => Column): ConnectionIO[Vector[IdRef]] = {
-    val sql = selectSimple(List(oid, name), table, cid.is(coll)) ++ orderBy(order(Columns).f)
+  def findAllRef(
+      coll: Ident,
+      nameQ: Option[String],
+      order: Columns.type => Column
+  ): ConnectionIO[Vector[IdRef]] = {
+    val q = Seq(cid.is(coll)) ++ (nameQ match {
+      case Some(str) => Seq(name.lowerLike(s"%${str.toLowerCase}%"))
+      case None      => Seq.empty
+    })
+    val sql = selectSimple(List(oid, name), table, and(q)) ++ orderBy(order(Columns).f)
     sql.query[IdRef].to[Vector]
   }
 
diff --git a/modules/store/src/main/scala/docspell/store/records/RPerson.scala b/modules/store/src/main/scala/docspell/store/records/RPerson.scala
index 184367b2..0497b0c0 100644
--- a/modules/store/src/main/scala/docspell/store/records/RPerson.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RPerson.scala
@@ -1,6 +1,7 @@
 package docspell.store.records
 
 import fs2.Stream
+import cats.Eq
 import doobie._
 import doobie.implicits._
 import docspell.common.{IdRef, _}
@@ -21,6 +22,8 @@ case class RPerson(
 ) {}
 
 object RPerson {
+  implicit val personEq: Eq[RPerson] =
+    Eq.by(_.pid)
 
   val table = fr"person"
 
@@ -116,8 +119,16 @@ object RPerson {
     sql.query[RPerson].stream
   }
 
-  def findAllRef(coll: Ident, order: Columns.type => Column): ConnectionIO[Vector[IdRef]] = {
-    val sql = selectSimple(List(pid, name), table, cid.is(coll)) ++ orderBy(order(Columns).f)
+  def findAllRef(
+      coll: Ident,
+      nameQ: Option[String],
+      order: Columns.type => Column
+  ): ConnectionIO[Vector[IdRef]] = {
+    val q = Seq(cid.is(coll)) ++ (nameQ match {
+      case Some(str) => Seq(name.lowerLike(s"%${str.toLowerCase}%"))
+      case None      => Seq.empty
+    })
+    val sql = selectSimple(List(pid, name), table, and(q)) ++ orderBy(order(Columns).f)
     sql.query[IdRef].to[Vector]
   }
 
diff --git a/modules/store/src/main/scala/docspell/store/records/RTag.scala b/modules/store/src/main/scala/docspell/store/records/RTag.scala
index 52ac5f2c..a702515c 100644
--- a/modules/store/src/main/scala/docspell/store/records/RTag.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RTag.scala
@@ -65,8 +65,16 @@ object RTag {
     sql.query[Int].unique.map(_ > 0)
   }
 
-  def findAll(coll: Ident, order: Columns.type => Column): ConnectionIO[Vector[RTag]] = {
-    val sql = selectSimple(all, table, cid.is(coll)) ++ orderBy(order(Columns).f)
+  def findAll(
+      coll: Ident,
+      nameQ: Option[String],
+      order: Columns.type => Column
+  ): ConnectionIO[Vector[RTag]] = {
+    val q = Seq(cid.is(coll)) ++ (nameQ match {
+      case Some(str) => Seq(name.lowerLike(s"%${str.toLowerCase}%"))
+      case None      => Seq.empty
+    })
+    val sql = selectSimple(all, table, and(q)) ++ orderBy(order(Columns).f)
     sql.query[RTag].to[Vector]
   }