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.
This commit is contained in:
Eike Kettner 2020-08-06 21:43:27 +02:00
parent cf3e051e83
commit 070c2b5e5f
5 changed files with 84 additions and 11 deletions

View File

@ -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:

View File

@ -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,

View File

@ -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"

View File

@ -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 =>

View File

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