Return item notes with search results

In order to not make the response very large, a admin can define a
limit on how much to return.
This commit is contained in:
Eike Kettner 2020-08-04 22:45:35 +02:00
parent f1e776ae3d
commit 09d74b7e80
11 changed files with 75 additions and 25 deletions

View File

@ -15,20 +15,20 @@ import docspell.store.records.RJob
trait OFulltext[F[_]] { trait OFulltext[F[_]] {
def findItems( def findItems(maxNoteLen: Int)(
q: Query, q: Query,
fts: OFulltext.FtsInput, fts: OFulltext.FtsInput,
batch: Batch batch: Batch
): F[Vector[OFulltext.FtsItem]] ): F[Vector[OFulltext.FtsItem]]
/** Same as `findItems` but does more queries per item to find all tags. */ /** Same as `findItems` but does more queries per item to find all tags. */
def findItemsWithTags( def findItemsWithTags(maxNoteLen: Int)(
q: Query, q: Query,
fts: OFulltext.FtsInput, fts: OFulltext.FtsInput,
batch: Batch batch: Batch
): F[Vector[OFulltext.FtsItemWithTags]] ): F[Vector[OFulltext.FtsItemWithTags]]
def findIndexOnly( def findIndexOnly(maxNoteLen: Int)(
fts: OFulltext.FtsInput, fts: OFulltext.FtsInput,
account: AccountId, account: AccountId,
batch: Batch batch: Batch
@ -92,7 +92,7 @@ object OFulltext {
else queue.insertIfNew(job) *> joex.notifyAllNodes else queue.insertIfNew(job) *> joex.notifyAllNodes
} yield () } yield ()
def findIndexOnly( def findIndexOnly(maxNoteLen: Int)(
ftsQ: OFulltext.FtsInput, ftsQ: OFulltext.FtsInput,
account: AccountId, account: AccountId,
batch: Batch batch: Batch
@ -120,7 +120,7 @@ object OFulltext {
.transact( .transact(
QItem.findItemsWithTags( QItem.findItemsWithTags(
account.collective, account.collective,
QItem.findSelectedItems(QItem.Query.empty(account), select) QItem.findSelectedItems(QItem.Query.empty(account), maxNoteLen, select)
) )
) )
.take(batch.limit.toLong) .take(batch.limit.toLong)
@ -133,15 +133,23 @@ object OFulltext {
} yield res } yield res
} }
def findItems(q: Query, ftsQ: FtsInput, batch: Batch): F[Vector[FtsItem]] = def findItems(
findItemsFts(q, ftsQ, batch.first, itemSearch.findItems, convertFtsData[ListItem]) maxNoteLen: Int
)(q: Query, ftsQ: FtsInput, batch: Batch): F[Vector[FtsItem]] =
findItemsFts(
q,
ftsQ,
batch.first,
itemSearch.findItems(maxNoteLen),
convertFtsData[ListItem]
)
.drop(batch.offset.toLong) .drop(batch.offset.toLong)
.take(batch.limit.toLong) .take(batch.limit.toLong)
.map({ case (li, fd) => FtsItem(li, fd) }) .map({ case (li, fd) => FtsItem(li, fd) })
.compile .compile
.toVector .toVector
def findItemsWithTags( def findItemsWithTags(maxNoteLen: Int)(
q: Query, q: Query,
ftsQ: FtsInput, ftsQ: FtsInput,
batch: Batch batch: Batch
@ -150,7 +158,7 @@ object OFulltext {
q, q,
ftsQ, ftsQ,
batch.first, batch.first,
itemSearch.findItemsWithTags, itemSearch.findItemsWithTags(maxNoteLen),
convertFtsData[ListItemWithTags] convertFtsData[ListItemWithTags]
) )
.drop(batch.offset.toLong) .drop(batch.offset.toLong)

View File

@ -17,10 +17,12 @@ import doobie.implicits._
trait OItemSearch[F[_]] { trait OItemSearch[F[_]] {
def findItem(id: Ident, collective: Ident): F[Option[ItemData]] def findItem(id: Ident, collective: Ident): F[Option[ItemData]]
def findItems(q: Query, batch: Batch): F[Vector[ListItem]] def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]]
/** Same as `findItems` but does more queries per item to find all tags. */ /** Same as `findItems` but does more queries per item to find all tags. */
def findItemsWithTags(q: Query, batch: Batch): F[Vector[ListItemWithTags]] def findItemsWithTags(
maxNoteLen: Int
)(q: Query, batch: Batch): F[Vector[ListItemWithTags]]
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]]
@ -97,14 +99,16 @@ object OItemSearch {
.transact(QItem.findItem(id)) .transact(QItem.findItem(id))
.map(opt => opt.flatMap(_.filterCollective(collective))) .map(opt => opt.flatMap(_.filterCollective(collective)))
def findItems(q: Query, batch: Batch): F[Vector[ListItem]] = def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] =
store store
.transact(QItem.findItems(q, batch).take(batch.limit.toLong)) .transact(QItem.findItems(q, maxNoteLen, batch).take(batch.limit.toLong))
.compile .compile
.toVector .toVector
def findItemsWithTags(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = { def findItemsWithTags(
val search = QItem.findItems(q, batch) maxNoteLen: Int
)(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = {
val search = QItem.findItems(q, maxNoteLen: Int, batch)
store store
.transact( .transact(
QItem.findItemsWithTags(q.account.collective, search).take(batch.limit.toLong) QItem.findItemsWithTags(q.account.collective, search).take(batch.limit.toLong)

View File

@ -82,7 +82,7 @@ object NotifyDueItemsTask {
) )
res <- res <-
ctx.store ctx.store
.transact(QItem.findItems(q, Batch.limit(maxItems)).take(maxItems.toLong)) .transact(QItem.findItems(q, 0, Batch.limit(maxItems)).take(maxItems.toLong))
.compile .compile
.toVector .toVector
} yield res } yield res

View File

@ -3860,6 +3860,10 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Tag" $ref: "#/components/schemas/Tag"
notes:
description: |
Some prefix of the item notes.
type: string
highlighting: highlighting:
description: | description: |
Optional contextual information of a search query. Each Optional contextual information of a search query. Each

View File

@ -24,6 +24,12 @@ docspell.server {
# depending on the available resources. # depending on the available resources.
max-item-page-size = 200 max-item-page-size = 200
# The number of characters to return for each item notes when
# searching. Item notes may be very long, when returning them with
# all the results from a search, they add quite some data to return.
# In order to keep this low, a limit can be defined here.
max-note-length = 180
# Authentication. # Authentication.
auth { auth {

View File

@ -16,6 +16,7 @@ case class Config(
auth: Login.Config, auth: Login.Config,
integrationEndpoint: Config.IntegrationEndpoint, integrationEndpoint: Config.IntegrationEndpoint,
maxItemPageSize: Int, maxItemPageSize: Int,
maxNoteLength: Int,
fullTextSearch: Config.FullTextSearch fullTextSearch: Config.FullTextSearch
) )

View File

@ -197,6 +197,7 @@ trait Conversions {
i.folder.map(mkIdName), i.folder.map(mkIdName),
i.fileCount, i.fileCount,
Nil, Nil,
i.notes,
Nil Nil
) )

View File

@ -40,7 +40,7 @@ object ItemRoutes {
resp <- mask.fullText match { resp <- mask.fullText match {
case Some(fq) if cfg.fullTextSearch.enabled => case Some(fq) if cfg.fullTextSearch.enabled =>
for { for {
items <- backend.fulltext.findItems( items <- backend.fulltext.findItems(cfg.maxNoteLength)(
query, query,
OFulltext.FtsInput(fq), OFulltext.FtsInput(fq),
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
@ -49,7 +49,7 @@ object ItemRoutes {
} yield ok } yield ok
case _ => case _ =>
for { for {
items <- backend.itemSearch.findItems( items <- backend.itemSearch.findItems(cfg.maxNoteLength)(
query, query,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
) )
@ -67,7 +67,7 @@ object ItemRoutes {
resp <- mask.fullText match { resp <- mask.fullText match {
case Some(fq) if cfg.fullTextSearch.enabled => case Some(fq) if cfg.fullTextSearch.enabled =>
for { for {
items <- backend.fulltext.findItemsWithTags( items <- backend.fulltext.findItemsWithTags(cfg.maxNoteLength)(
query, query,
OFulltext.FtsInput(fq), OFulltext.FtsInput(fq),
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
@ -76,7 +76,7 @@ object ItemRoutes {
} yield ok } yield ok
case _ => case _ =>
for { for {
items <- backend.itemSearch.findItemsWithTags( items <- backend.itemSearch.findItemsWithTags(cfg.maxNoteLength)(
query, query,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
) )
@ -92,7 +92,7 @@ object ItemRoutes {
case q if q.length > 1 => case q if q.length > 1 =>
val ftsIn = OFulltext.FtsInput(q) val ftsIn = OFulltext.FtsInput(q)
for { for {
items <- backend.fulltext.findIndexOnly( items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)(
ftsIn, ftsIn,
user.account, user.account,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)

View File

@ -121,4 +121,8 @@ case class Column(name: String, ns: String = "", alias: String = "") {
def decrement[A: Put](a: A): Fragment = def decrement[A: Put](a: A): Fragment =
f ++ fr"=" ++ f ++ fr"- $a" f ++ fr"=" ++ f ++ fr"- $a"
def substring(from: Int, many: Int): Fragment =
if (many <= 0 || from < 0) fr"${""}"
else fr"SUBSTRING(" ++ f ++ fr"FROM $from FOR $many)"
} }

View File

@ -156,7 +156,8 @@ object QItem {
corrPerson: Option[IdRef], corrPerson: Option[IdRef],
concPerson: Option[IdRef], concPerson: Option[IdRef],
concEquip: Option[IdRef], concEquip: Option[IdRef],
folder: Option[IdRef] folder: Option[IdRef],
notes: Option[String]
) )
case class Query( case class Query(
@ -228,6 +229,7 @@ object QItem {
private def findItemsBase( private def findItemsBase(
q: Query, q: Query,
distinct: Boolean, distinct: Boolean,
noteMaxLen: Int,
moreCols: Seq[Fragment], moreCols: Seq[Fragment],
ctes: (String, Fragment)* ctes: (String, Fragment)*
): Fragment = { ): Fragment = {
@ -264,6 +266,9 @@ object QItem {
EC.name.prefix("e1").f, EC.name.prefix("e1").f,
FC.id.prefix("f1").f, FC.id.prefix("f1").f,
FC.name.prefix("f1").f, FC.name.prefix("f1").f,
// sql uses 1 for first character
IC.notes.prefix("i").substring(1, noteMaxLen),
// last column is only for sorting
q.orderAsc match { q.orderAsc match {
case Some(co) => case Some(co) =>
coalesce(co(IC).prefix("i").f, IC.created.prefix("i").f) coalesce(co(IC).prefix("i").f, IC.created.prefix("i").f)
@ -307,14 +312,16 @@ object QItem {
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"))
} }
def findItems(q: Query, batch: Batch): Stream[ConnectionIO, ListItem] = { def findItems(
q: Query,
maxNoteLen: Int,
batch: Batch
): Stream[ConnectionIO, ListItem] = {
val IC = RItem.Columns val IC = RItem.Columns
val PC = RPerson.Columns val PC = RPerson.Columns
val OC = ROrganization.Columns val OC = ROrganization.Columns
val EC = REquipment.Columns val EC = REquipment.Columns
val query = findItemsBase(q, true, Seq.empty)
// inclusive tags are AND-ed // inclusive tags are AND-ed
val tagSelectsIncl = q.tagsInclude val tagSelectsIncl = q.tagsInclude
.map(tid => .map(tid =>
@ -404,6 +411,7 @@ object QItem {
if (batch == Batch.all) Fragment.empty if (batch == Batch.all) Fragment.empty
else fr"LIMIT ${batch.limit} OFFSET ${batch.offset}" else fr"LIMIT ${batch.limit} OFFSET ${batch.offset}"
val query = findItemsBase(q, true, maxNoteLen, Seq.empty)
val frag = val frag =
query ++ fr"WHERE" ++ cond ++ order ++ limitOffset query ++ fr"WHERE" ++ cond ++ order ++ limitOffset
logger.trace(s"List $batch items: $frag") logger.trace(s"List $batch items: $frag")
@ -413,6 +421,7 @@ object QItem {
case class SelectedItem(itemId: Ident, weight: Double) case class SelectedItem(itemId: Ident, weight: Double)
def findSelectedItems( def findSelectedItems(
q: Query, q: Query,
maxNoteLen: Int,
items: Set[SelectedItem] items: Set[SelectedItem]
): Stream[ConnectionIO, ListItem] = ): Stream[ConnectionIO, ListItem] =
if (items.isEmpty) Stream.empty if (items.isEmpty) Stream.empty
@ -425,6 +434,7 @@ object QItem {
val from = findItemsBase( val from = findItemsBase(
q, q,
true, true,
maxNoteLen,
Seq(fr"tids.weight"), Seq(fr"tids.weight"),
("tids(item_id, weight)", fr"(VALUES" ++ values ++ fr")") ("tids(item_id, weight)", fr"(VALUES" ++ values ++ fr")")
) ++ ) ++

View File

@ -14,6 +14,7 @@ let
app-id = "rest1"; app-id = "rest1";
base-url = "http://localhost:7880"; base-url = "http://localhost:7880";
max-item-page-size = 200; max-item-page-size = 200;
max-note-length = 180;
bind = { bind = {
address = "localhost"; address = "localhost";
port = 7880; port = 7880;
@ -124,6 +125,17 @@ in {
''; '';
}; };
max-note-length = mkOption {
type = types.int;
default = defaults.max-note-length;
description = ''
The number of characters to return for each item notes when
searching. Item notes may be very long, when returning them with
all the results from a search, they add quite some data to return.
In order to keep this low, a limit can be defined here.
'';
};
bind = mkOption { bind = mkOption {
type = types.submodule({ type = types.submodule({
options = { options = {