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: items:
type: string type: string
format: ident format: ident
tagCategoriesInclude:
type: array
items:
type: string
tagCategoriesExclude:
type: array
items:
type: string
inbox: inbox:
type: boolean type: boolean
offset: offset:

View File

@ -124,6 +124,8 @@ trait Conversions {
m.folder, m.folder,
m.tagsInclude.map(Ident.unsafe), m.tagsInclude.map(Ident.unsafe),
m.tagsExclude.map(Ident.unsafe), m.tagsExclude.map(Ident.unsafe),
m.tagCategoriesInclude,
m.tagCategoriesExclude,
m.dateFrom, m.dateFrom,
m.dateUntil, m.dateUntil,
m.dueDateFrom, m.dueDateFrom,

View File

@ -26,6 +26,9 @@ case class Column(name: String, ns: String = "", alias: String = "") {
def is[A: Put](value: A): Fragment = def is[A: Put](value: A): Fragment =
f ++ fr" = $value" f ++ fr" = $value"
def lowerIs[A: Put](value: A): Fragment =
fr"lower(" ++ f ++ fr") = $value"
def is[A: Put](ov: Option[A]): Fragment = def is[A: Put](ov: Option[A]): Fragment =
ov match { ov match {
case Some(v) => f ++ fr" = $v" case Some(v) => f ++ fr" = $v"

View File

@ -172,6 +172,8 @@ object QItem {
folder: Option[Ident], folder: Option[Ident],
tagsInclude: List[Ident], tagsInclude: List[Ident],
tagsExclude: List[Ident], tagsExclude: List[Ident],
tagCategoryIncl: List[String],
tagCategoryExcl: List[String],
dateFrom: Option[Timestamp], dateFrom: Option[Timestamp],
dateTo: Option[Timestamp], dateTo: Option[Timestamp],
dueDateFrom: Option[Timestamp], dueDateFrom: Option[Timestamp],
@ -195,6 +197,8 @@ object QItem {
None, None,
Nil, Nil,
Nil, Nil,
Nil,
Nil,
None, None,
None, None,
None, None,
@ -323,25 +327,21 @@ object QItem {
val EC = REquipment.Columns val EC = REquipment.Columns
// inclusive tags are AND-ed // inclusive tags are AND-ed
val tagSelectsIncl = q.tagsInclude val tagSelectsIncl = (q.tagsInclude
.map(tid => .map(tid =>
selectSimple( selectSimple(
List(RTagItem.Columns.itemId), List(RTagItem.Columns.itemId),
RTagItem.table, RTagItem.table,
RTagItem.Columns.tagId.is(tid) RTagItem.Columns.tagId.is(tid)
) )
) ) ++ q.tagCategoryIncl.map(cat =>
TagItemName.itemsInCategory(NonEmptyList.of(cat))
))
.map(f => sql"(" ++ f ++ sql") ") .map(f => sql"(" ++ f ++ sql") ")
// exclusive tags are OR-ed // exclusive tags are OR-ed
val tagSelectsExcl = val tagSelectsExcl =
if (q.tagsExclude.isEmpty) Fragment.empty TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl)
else
selectSimple(
List(RTagItem.Columns.itemId),
RTagItem.table,
RTagItem.Columns.tagId.isOneOf(q.tagsExclude)
)
val iFolder = IC.folder.prefix("i") val iFolder = IC.folder.prefix("i")
val name = q.name.map(_.toLowerCase).map(queryWildcard) val name = q.name.map(_.toLowerCase).map(queryWildcard)
@ -370,11 +370,11 @@ object QItem {
RPerson.Columns.pid.prefix("p1").isOrDiscard(q.concPerson), RPerson.Columns.pid.prefix("p1").isOrDiscard(q.concPerson),
REquipment.Columns.eid.prefix("e1").isOrDiscard(q.concEquip), REquipment.Columns.eid.prefix("e1").isOrDiscard(q.concEquip),
RFolder.Columns.id.prefix("f1").isOrDiscard(q.folder), RFolder.Columns.id.prefix("f1").isOrDiscard(q.folder),
if (q.tagsInclude.isEmpty) Fragment.empty if (q.tagsInclude.isEmpty && q.tagCategoryIncl.isEmpty) Fragment.empty
else else
IC.id.prefix("i") ++ sql" IN (" ++ tagSelectsIncl IC.id.prefix("i") ++ sql" IN (" ++ tagSelectsIncl
.reduce(_ ++ fr"INTERSECT" ++ _) ++ sql")", .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")", else IC.id.prefix("i").f ++ sql" NOT IN (" ++ tagSelectsExcl ++ sql")",
q.dateFrom q.dateFrom
.map(d => .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
}
}
}