From 066c856981a697f33b97d31f6939f3218eb7e67e Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 22 Nov 2020 18:25:27 +0100
Subject: [PATCH] Allow to search for custom field values

---
 .../docspell/backend/ops/OItemSearch.scala    |  3 ++
 .../src/main/resources/docspell-openapi.yml   |  5 ++
 .../restserver/conv/Conversions.scala         |  4 ++
 .../scala/docspell/store/queries/QItem.scala  | 50 +++++++++++++++++--
 4 files changed, 58 insertions(+), 4 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
index 41870dce..c546a184 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
@@ -53,6 +53,9 @@ trait OItemSearch[F[_]] {
 
 object OItemSearch {
 
+  type CustomValue = QItem.CustomValue
+  val CustomValue = QItem.CustomValue
+
   type Query = QItem.Query
   val Query = QItem.Query
 
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 8426f129..ed0df51b 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -4952,6 +4952,7 @@ components:
         - inbox
         - offset
         - limit
+        - customValues
       properties:
         tagsInclude:
           type: array
@@ -5031,6 +5032,10 @@ components:
           format: date-time
         itemSubset:
           $ref: "#/components/schemas/IdList"
+        customValues:
+          type: array
+          items:
+            $ref: "#/components/schemas/CustomFieldValue"
     ItemLight:
       description: |
         An item with only a few important properties.
diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
index 2f7f0378..e5d49fc2 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
@@ -143,9 +143,13 @@ trait Conversions {
       m.itemSubset
         .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet)
         .filter(_.nonEmpty),
+      m.customValues.map(mkCustomValue),
       None
     )
 
+  def mkCustomValue(v: CustomFieldValue): OItemSearch.CustomValue =
+    OItemSearch.CustomValue(v.field, v.value)
+
   def mkItemList(v: Vector[OItemSearch.ListItem]): ItemLightList = {
     val groups = v.groupBy(item => item.date.toUtcDate.toString.substring(0, 7))
 
diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 91deca4d..f587ae9f 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -139,7 +139,7 @@ object QItem {
     val sources      = RAttachmentSource.findByItemWithMeta(id)
     val archives     = RAttachmentArchive.findByItemWithMeta(id)
     val tags         = RTag.findByItem(id)
-    val customfields = findCustomFieldValues(id)
+    val customfields = findCustomFieldValuesForItem(id)
 
     for {
       data <- q
@@ -153,7 +153,9 @@ object QItem {
     )
   }
 
-  def findCustomFieldValues(itemId: Ident): ConnectionIO[Vector[ItemFieldValue]] = {
+  def findCustomFieldValuesForItem(
+      itemId: Ident
+  ): ConnectionIO[Vector[ItemFieldValue]] = {
     val cfId    = RCustomField.Columns.id.prefix("cf")
     val cfName  = RCustomField.Columns.name.prefix("cf")
     val cfLabel = RCustomField.Columns.label.prefix("cf")
@@ -191,6 +193,8 @@ object QItem {
       notes: Option[String]
   )
 
+  case class CustomValue(field: Ident, value: String)
+
   case class Query(
       account: AccountId,
       name: Option[String],
@@ -211,6 +215,7 @@ object QItem {
       dueDateTo: Option[Timestamp],
       allNames: Option[String],
       itemIds: Option[Set[Ident]],
+      customValues: Seq[CustomValue],
       orderAsc: Option[RItem.Columns.type => Column]
   )
 
@@ -236,6 +241,7 @@ object QItem {
         None,
         None,
         None,
+        Seq.empty,
         None
       )
   }
@@ -261,6 +267,35 @@ object QItem {
       Batch(0, c)
   }
 
+  private def findCustomFieldValuesForColl(
+      coll: Ident,
+      cv: Seq[CustomValue]
+  ): Seq[(String, Fragment)] = {
+    val cfId    = RCustomField.Columns.id.prefix("cf")
+    val cfName  = RCustomField.Columns.name.prefix("cf")
+    val cfColl  = RCustomField.Columns.cid.prefix("cf")
+    val cvValue = RCustomFieldValue.Columns.value.prefix("cvf")
+    val cvField = RCustomFieldValue.Columns.field.prefix("cvf")
+    val cvItem  = RCustomFieldValue.Columns.itemId.prefix("cvf")
+
+    val cfFrom =
+      RCustomFieldValue.table ++ fr"cvf INNER JOIN" ++ RCustomField.table ++ fr"cf ON" ++ cvField
+        .is(cfId)
+
+    def singleSelect(v: CustomValue) =
+      selectSimple(
+        Seq(cvItem),
+        cfFrom,
+        and(
+          cfColl.is(coll),
+          or(cfName.is(v.field), cfId.is(v.field)),
+          cvValue.is(v.value)
+        )
+      )
+    if (cv.isEmpty) Seq.empty
+    else Seq("customvalues" -> cv.map(singleSelect).reduce(_ ++ fr"INTERSECT" ++ _))
+  }
+
   private def findItemsBase(
       q: Query,
       distinct: Boolean,
@@ -279,6 +314,7 @@ object QItem {
     val orgCols    = List(OC.oid, OC.name)
     val equipCols  = List(EC.eid, EC.name)
     val folderCols = List(FC.id, FC.name)
+    val cvItem     = RCustomFieldValue.Columns.itemId.prefix("cv")
 
     val finalCols = commas(
       Seq(
@@ -325,6 +361,9 @@ object QItem {
     val withAttach = fr"SELECT COUNT(" ++ AC.id.f ++ fr") as num, " ++ AC.itemId.f ++
       fr"from" ++ RAttachment.table ++ fr"GROUP BY (" ++ AC.itemId.f ++ fr")"
 
+    val withCustomValues =
+      findCustomFieldValuesForColl(q.account.collective, q.customValues)
+
     val selectKW = if (distinct) fr"SELECT DISTINCT" else fr"SELECT"
     withCTE(
       (Seq(
@@ -334,7 +373,7 @@ object QItem {
         "equips"  -> withEquips,
         "attachs" -> withAttach,
         "folders" -> withFolder
-      ) ++ ctes): _*
+      ) ++ withCustomValues ++ ctes): _*
     ) ++
       selectKW ++ finalCols ++ fr" FROM items i" ++
       fr"LEFT JOIN attachs a ON" ++ IC.id.prefix("i").is(AC.itemId.prefix("a")) ++
@@ -344,7 +383,10 @@ object QItem {
       fr"LEFT JOIN equips e1 ON" ++ IC.concEquipment
         .prefix("i")
         .is(EC.eid.prefix("e1")) ++
-      fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1"))
+      fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1")) ++
+      (if (q.customValues.isEmpty) Fragment.empty
+       else
+         fr"INNER JOIN customvalues cv ON" ++ cvItem.is(IC.id.prefix("i")))
   }
 
   def findItems(