Prepare for new search logic with feature toggle

This commit is contained in:
eikek
2022-05-30 22:45:46 +02:00
parent 04ccad2ce0
commit 1266cdefe1
27 changed files with 1341 additions and 582 deletions

View File

@ -12,6 +12,7 @@ import docspell.backend.BackendCommands.EventContext
import docspell.backend.auth.Login
import docspell.backend.fulltext.CreateIndex
import docspell.backend.ops._
import docspell.backend.ops.search.OSearch
import docspell.backend.signup.OSignup
import docspell.common.bc.BackendCommandRunner
import docspell.ftsclient.FtsClient
@ -58,6 +59,7 @@ trait BackendApp[F[_]] {
def itemLink: OItemLink[F]
def downloadAll: ODownloadAll[F]
def addons: OAddons[F]
def search: OSearch[F]
def commands(eventContext: Option[EventContext]): BackendCommandRunner[F, Unit]
}
@ -130,6 +132,7 @@ object BackendApp {
joexImpl
)
)
searchImpl <- Resource.pure(OSearch(store, ftsClient))
} yield new BackendApp[F] {
val pubSub = pubSubT
val login = loginImpl
@ -162,6 +165,7 @@ object BackendApp {
val downloadAll = downloadAllImpl
val addons = addonsImpl
val attachment = attachImpl
val search = searchImpl
def commands(eventContext: Option[EventContext]) =
BackendCommands.fromBackend(this, eventContext)

View File

@ -181,7 +181,7 @@ object OFulltext {
q = Query
.all(account)
.withFix(_.copy(query = itemIdsQuery.some))
res <- store.transact(QItem.searchStats(now.toUtcDate)(q))
res <- store.transact(QItem.searchStats(now.toUtcDate, None)(q))
} yield res
}
@ -242,7 +242,7 @@ object OFulltext {
.getOrElse(Attr.ItemId.notExists)
qnext = q.withFix(_.copy(query = itemIdsQuery.some))
now <- Timestamp.current[F]
res <- store.transact(QItem.searchStats(now.toUtcDate)(qnext))
res <- store.transact(QItem.searchStats(now.toUtcDate, None)(qnext))
} yield res
// Helper

View File

@ -180,7 +180,7 @@ object OItemSearch {
Timestamp
.current[F]
.map(_.toUtcDate)
.flatMap(today => store.transact(QItem.searchStats(today)(q)))
.flatMap(today => store.transact(QItem.searchStats(today, None)(q)))
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] =
store

View File

@ -0,0 +1,212 @@
/*
* Copyright 2020 Eike K. & Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package docspell.backend.ops.search
import java.time.LocalDate
import cats.effect._
import cats.syntax.all._
import cats.~>
import fs2.Stream
import docspell.backend.ops.OItemSearch.{ListItemWithTags, SearchSummary}
import docspell.common.{AccountId, SearchMode}
import docspell.ftsclient.{FtsClient, FtsQuery}
import docspell.query.{FulltextExtract, ItemQuery, ItemQueryParser}
import docspell.store.Store
import docspell.store.impl.TempFtsTable
import docspell.store.qb.Batch
import docspell.store.queries._
import doobie.{ConnectionIO, WeakAsync}
/** Combine fulltext search and sql search into one operation.
*
* To allow for paging the results from fulltext search are brought into the sql database
* by creating a temporary table.
*/
trait OSearch[F[_]] {
/** Searches at sql database with the given query joining it optionally with results
* from fulltext search. Any "fulltext search" query node is discarded. It is assumed
* that the fulltext search node has been extracted into the argument.
*/
def search(maxNoteLen: Int, today: LocalDate, batch: Batch)(
q: Query,
fulltextQuery: Option[String]
): F[Vector[ListItem]]
/** Same as `search` above, but runs additionally queries per item (!) to retrieve more
* details.
*/
def searchWithDetails(
maxNoteLen: Int,
today: LocalDate,
batch: Batch
)(
q: Query,
fulltextQuery: Option[String]
): F[Vector[ListItemWithTags]]
/** Run multiple database calls with the give query to collect a summary. */
def searchSummary(
mode: SearchMode,
today: LocalDate
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
/** Parses a query string and creates a `Query` object, to be used with the other
* methods. The query object contains the parsed query amended with more conditions to
* restrict to valid items only (as specified with `mode`).
*/
def parseQueryString(
accountId: AccountId,
mode: SearchMode,
qs: String
): QueryParseResult
}
object OSearch {
def apply[F[_]: Async](
store: Store[F],
ftsClient: FtsClient[F]
): OSearch[F] =
new OSearch[F] {
def parseQueryString(
accountId: AccountId,
mode: SearchMode,
qs: String
): QueryParseResult =
ItemQueryParser.parse(qs) match {
case Right(iq) =>
val validItemQuery =
mode match {
case SearchMode.Trashed => ItemQuery.Expr.Trashed
case SearchMode.Normal => ItemQuery.Expr.ValidItemStates
case SearchMode.All => ItemQuery.Expr.ValidItemsOrTrashed
}
FulltextExtract.findFulltext(iq.expr) match {
case FulltextExtract.Result.SuccessNoFulltext(expr) =>
val qf = Query.Fix(accountId, Some(validItemQuery), None)
val qq = Query.QueryExpr(expr)
val q = Query(qf, qq)
QueryParseResult.Success(q, None)
case FulltextExtract.Result.SuccessNoExpr(fts) =>
val qf = Query.Fix(accountId, Some(validItemQuery), Option(_.byScore))
val qq = Query.QueryExpr(None)
val q = Query(qf, qq)
QueryParseResult.Success(q, Some(fts))
case FulltextExtract.Result.SuccessBoth(expr, fts) =>
val qf = Query.Fix(accountId, Some(validItemQuery), None)
val qq = Query.QueryExpr(expr)
val q = Query(qf, qq)
QueryParseResult.Success(q, Some(fts))
case f: FulltextExtract.FailureResult =>
QueryParseResult.FulltextMismatch(f)
}
case Left(err) =>
QueryParseResult.ParseFailed(err).cast
}
def search(maxNoteLen: Int, today: LocalDate, batch: Batch)(
q: Query,
fulltextQuery: Option[String]
): F[Vector[ListItem]] =
fulltextQuery match {
case Some(ftq) =>
for {
ftq <- createFtsQuery(q.fix.account, batch, ftq)
results <- WeakAsync.liftK[F, ConnectionIO].use { nat =>
val tempTable = temporaryFtsTable(ftq, nat)
store
.transact(
Stream
.eval(tempTable)
.flatMap(tt =>
QItem.queryItems(q, today, maxNoteLen, batch, tt.some)
)
)
.compile
.toVector
}
} yield results
case None =>
store
.transact(QItem.queryItems(q, today, maxNoteLen, batch, None))
.compile
.toVector
}
def searchWithDetails(
maxNoteLen: Int,
today: LocalDate,
batch: Batch
)(
q: Query,
fulltextQuery: Option[String]
): F[Vector[ListItemWithTags]] =
for {
items <- search(maxNoteLen, today, batch)(q, fulltextQuery)
resolved <- store
.transact(
QItem.findItemsWithTags(q.fix.account.collective, Stream.emits(items))
)
.compile
.toVector
} yield resolved
def searchSummary(
mode: SearchMode,
today: LocalDate
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
fulltextQuery match {
case Some(ftq) =>
for {
ftq <- createFtsQuery(q.fix.account, Batch.limit(500), ftq)
results <- WeakAsync.liftK[F, ConnectionIO].use { nat =>
val tempTable = temporaryFtsTable(ftq, nat)
store.transact(
tempTable.flatMap(tt => QItem.searchStats(today, tt.some)(q))
)
}
} yield results
case None =>
store.transact(QItem.searchStats(today, None)(q))
}
private def createFtsQuery(
account: AccountId,
batch: Batch,
ftq: String
): F[FtsQuery] =
store
.transact(QFolder.getMemberFolders(account))
.map(folders =>
FtsQuery(ftq, account.collective, batch.limit, batch.offset)
.withFolders(folders)
)
def temporaryFtsTable(
ftq: FtsQuery,
nat: F ~> ConnectionIO
): ConnectionIO[TempFtsTable.Table] =
ftsClient
.searchAll(ftq)
.translate(nat)
.through(TempFtsTable.prepareTable(store.dbms, "fts_result"))
.compile
.lastOrError
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2020 Eike K. & Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package docspell.backend.ops.search
import docspell.query.{FulltextExtract, ParseFailure}
import docspell.store.queries.Query
sealed trait QueryParseResult {
def cast: QueryParseResult = this
def get: Option[(Query, Option[String])]
def isSuccess: Boolean = get.isDefined
def isFailure: Boolean = !isSuccess
}
object QueryParseResult {
final case class Success(q: Query, ftq: Option[String]) extends QueryParseResult {
val get = Some(q -> ftq)
}
final case class ParseFailed(error: ParseFailure) extends QueryParseResult {
val get = None
}
final case class FulltextMismatch(error: FulltextExtract.FailureResult)
extends QueryParseResult {
val get = None
}
}