From 070c2b5e5f8ffb4efa713a9f072c01ca6eba028a Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 6 Aug 2020 21:43:27 +0200 Subject: [PATCH] Allow to search by tag categories The server accepts a list of tag categories for inclusion and exclusion. The categories in the include list imply to return items that have at least one tag of each category. The categories in the exclude list imply to return all items that have no tag in any of these categories. --- .../src/main/resources/docspell-openapi.yml | 8 +++ .../restserver/conv/Conversions.scala | 2 + .../scala/docspell/store/impl/Column.scala | 3 + .../scala/docspell/store/queries/QItem.scala | 22 +++---- .../docspell/store/records/TagItemName.scala | 60 +++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/records/TagItemName.scala diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c5451378..96f66c07 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3752,6 +3752,14 @@ components: items: type: string format: ident + tagCategoriesInclude: + type: array + items: + type: string + tagCategoriesExclude: + type: array + items: + type: string inbox: type: boolean offset: 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 5cf79d9b..ef732d30 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -124,6 +124,8 @@ trait Conversions { m.folder, m.tagsInclude.map(Ident.unsafe), m.tagsExclude.map(Ident.unsafe), + m.tagCategoriesInclude, + m.tagCategoriesExclude, m.dateFrom, m.dateUntil, m.dueDateFrom, diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index de495170..578dd213 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -26,6 +26,9 @@ case class Column(name: String, ns: String = "", alias: String = "") { def is[A: Put](value: A): Fragment = f ++ fr" = $value" + def lowerIs[A: Put](value: A): Fragment = + fr"lower(" ++ f ++ fr") = $value" + def is[A: Put](ov: Option[A]): Fragment = ov match { case Some(v) => f ++ fr" = $v" 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 1ce0e976..1240d4a7 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -172,6 +172,8 @@ object QItem { folder: Option[Ident], tagsInclude: List[Ident], tagsExclude: List[Ident], + tagCategoryIncl: List[String], + tagCategoryExcl: List[String], dateFrom: Option[Timestamp], dateTo: Option[Timestamp], dueDateFrom: Option[Timestamp], @@ -195,6 +197,8 @@ object QItem { None, Nil, Nil, + Nil, + Nil, None, None, None, @@ -323,25 +327,21 @@ object QItem { val EC = REquipment.Columns // inclusive tags are AND-ed - val tagSelectsIncl = q.tagsInclude + val tagSelectsIncl = (q.tagsInclude .map(tid => selectSimple( List(RTagItem.Columns.itemId), RTagItem.table, RTagItem.Columns.tagId.is(tid) ) - ) + ) ++ q.tagCategoryIncl.map(cat => + TagItemName.itemsInCategory(NonEmptyList.of(cat)) + )) .map(f => sql"(" ++ f ++ sql") ") // exclusive tags are OR-ed val tagSelectsExcl = - if (q.tagsExclude.isEmpty) Fragment.empty - else - selectSimple( - List(RTagItem.Columns.itemId), - RTagItem.table, - RTagItem.Columns.tagId.isOneOf(q.tagsExclude) - ) + TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl) val iFolder = IC.folder.prefix("i") val name = q.name.map(_.toLowerCase).map(queryWildcard) @@ -370,11 +370,11 @@ object QItem { RPerson.Columns.pid.prefix("p1").isOrDiscard(q.concPerson), REquipment.Columns.eid.prefix("e1").isOrDiscard(q.concEquip), RFolder.Columns.id.prefix("f1").isOrDiscard(q.folder), - if (q.tagsInclude.isEmpty) Fragment.empty + if (q.tagsInclude.isEmpty && q.tagCategoryIncl.isEmpty) Fragment.empty else IC.id.prefix("i") ++ sql" IN (" ++ tagSelectsIncl .reduce(_ ++ fr"INTERSECT" ++ _) ++ sql")", - if (q.tagsExclude.isEmpty) Fragment.empty + if (q.tagsExclude.isEmpty && q.tagCategoryExcl.isEmpty) Fragment.empty else IC.id.prefix("i").f ++ sql" NOT IN (" ++ tagSelectsExcl ++ sql")", q.dateFrom .map(d => diff --git a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala new file mode 100644 index 00000000..05689ffd --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala @@ -0,0 +1,60 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Implicits._ +import cats.data.NonEmptyList +import doobie._ +import doobie.implicits._ + +/** A helper class combining information from `RTag` and `RTagItem`. + * This is not a "record", there is no corresponding table. + */ +case class TagItemName( + tagId: Ident, + collective: Ident, + name: String, + category: Option[String], + tagItemId: Ident, + itemId: Ident +) + +object TagItemName { + + def itemsInCategory(cats: NonEmptyList[String]): Fragment = { + val catsLower = cats.map(_.toLowerCase) + val tiItem = RTagItem.Columns.itemId.prefix("ti") + val tiTag = RTagItem.Columns.tagId.prefix("ti") + val tCat = RTag.Columns.category.prefix("t") + val tId = RTag.Columns.tid.prefix("t") + + val from = RTag.table ++ fr"t INNER JOIN" ++ + RTagItem.table ++ fr"ti ON" ++ tiTag.is(tId) + + if (cats.tail.isEmpty) + selectSimple(List(tiItem), from, tCat.lowerIs(catsLower.head)) + else + selectSimple(List(tiItem), from, tCat.isLowerIn(catsLower)) + } + + def itemsWithTagOrCategory(tags: List[Ident], cats: List[String]): Fragment = { + val catsLower = cats.map(_.toLowerCase) + val tiItem = RTagItem.Columns.itemId.prefix("ti") + val tiTag = RTagItem.Columns.tagId.prefix("ti") + val tCat = RTag.Columns.category.prefix("t") + val tId = RTag.Columns.tid.prefix("t") + + val from = RTag.table ++ fr"t INNER JOIN" ++ + RTagItem.table ++ fr"ti ON" ++ tiTag.is(tId) + + (NonEmptyList.fromList(tags), NonEmptyList.fromList(catsLower)) match { + case (Some(tagNel), Some(catNel)) => + selectSimple(List(tiItem), from, or(tId.isIn(tagNel), tCat.isLowerIn(catNel))) + case (Some(tagNel), None) => + selectSimple(List(tiItem), from, tId.isIn(tagNel)) + case (None, Some(catNel)) => + selectSimple(List(tiItem), from, tCat.isLowerIn(catNel)) + case (None, None) => + Fragment.empty + } + } +}