Skip to content

Commit

Permalink
Merge pull request #1579 from eikek/fix/item-mixed-search
Browse files Browse the repository at this point in the history
Fix/item mixed search
  • Loading branch information
mergify[bot] committed Jun 4, 2022
2 parents 4b27525 + ae265ed commit 7d6e2d8
Show file tree
Hide file tree
Showing 48 changed files with 1,906 additions and 663 deletions.
23 changes: 12 additions & 11 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,17 @@ val addonlib = project
)
.dependsOn(common, files, loggingScribe)

val ftsclient = project
.in(file("modules/fts-client"))
.disablePlugins(RevolverPlugin)
.settings(sharedSettings)
.withTestSettings
.settings(
name := "docspell-fts-client",
libraryDependencies ++= Seq.empty
)
.dependsOn(common, loggingScribe)

val store = project
.in(file("modules/store"))
.disablePlugins(RevolverPlugin)
Expand Down Expand Up @@ -500,6 +511,7 @@ val store = project
files,
notificationApi,
jsonminiq,
ftsclient,
loggingScribe
)

Expand Down Expand Up @@ -623,17 +635,6 @@ val analysis = project
)
.dependsOn(common, files % "test->test", loggingScribe)

val ftsclient = project
.in(file("modules/fts-client"))
.disablePlugins(RevolverPlugin)
.settings(sharedSettings)
.withTestSettings
.settings(
name := "docspell-fts-client",
libraryDependencies ++= Seq.empty
)
.dependsOn(common, loggingScribe)

val ftssolr = project
.in(file("modules/fts-solr"))
.disablePlugins(RevolverPlugin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class AddonExecutorTest extends CatsEffectSuite with Fixtures with TestLoggingCo
val logger = docspell.logging.getLogger[IO]

override def docspellLogConfig =
super.docspellLogConfig.copy(minimumLevel = Level.Trace)
super.docspellLogConfig.copy(minimumLevel = Level.Error)

tempDir.test("select docker if Dockerfile exists") { dir =>
for {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
Expand Down Expand Up @@ -130,6 +132,7 @@ object BackendApp {
joexImpl
)
)
searchImpl <- Resource.pure(OSearch(store, ftsClient))
} yield new BackendApp[F] {
val pubSub = pubSubT
val login = loginImpl
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*
* 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.{Functor, ~>}
import fs2.Stream

import docspell.backend.ops.OItemSearch.{ListItemWithTags, SearchSummary}
import docspell.common.{AccountId, Duration, SearchMode}
import docspell.ftsclient.{FtsClient, FtsQuery}
import docspell.query.{FulltextExtract, ItemQuery, ItemQueryParser}
import docspell.store.Store
import docspell.store.fts.RFtsResult
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]]

/** Selects either `search` or `searchWithDetails`. For the former the items are filled
* with empty details.
*/
final def searchSelect(
withDetails: Boolean,
maxNoteLen: Int,
today: LocalDate,
batch: Batch
)(
q: Query,
fulltextQuery: Option[String]
)(implicit F: Functor[F]): F[Vector[ListItemWithTags]] =
if (withDetails) searchWithDetails(maxNoteLen, today, batch)(q, fulltextQuery)
else search(maxNoteLen, today, batch)(q, fulltextQuery).map(_.map(_.toWithTags))

/** Run multiple database calls with the give query to collect a summary. */
def searchSummary(
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] {
private[this] val logger = docspell.logging.getLogger[F]

def parseQueryString(
accountId: AccountId,
mode: SearchMode,
qs: String
): QueryParseResult = {
val validItemQuery =
mode match {
case SearchMode.Trashed => ItemQuery.Expr.Trashed
case SearchMode.Normal => ItemQuery.Expr.ValidItemStates
case SearchMode.All => ItemQuery.Expr.ValidItemsOrTrashed
}

if (qs.trim.isEmpty) {
val qf = Query.Fix(accountId, Some(validItemQuery), None)
val qq = Query.QueryExpr(None)
val q = Query(qf, qq)
QueryParseResult.Success(q, None)
} else
ItemQueryParser.parse(qs) match {
case Right(iq) =>
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 {
timed <- Duration.stopTime[F]
ftq <- createFtsQuery(q.fix.account, 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
}
duration <- timed
_ <- logger.debug(s"Simple search with fts in: ${duration.formatExact}")
} yield results

case None =>
for {
timed <- Duration.stopTime[F]
results <- store
.transact(QItem.queryItems(q, today, maxNoteLen, batch, None))
.compile
.toVector
duration <- timed
_ <- logger.debug(s"Simple search sql in: ${duration.formatExact}")
} yield results

}

def searchWithDetails(
maxNoteLen: Int,
today: LocalDate,
batch: Batch
)(
q: Query,
fulltextQuery: Option[String]
): F[Vector[ListItemWithTags]] =
for {
items <- search(maxNoteLen, today, batch)(q, fulltextQuery)
timed <- Duration.stopTime[F]
resolved <- store
.transact(
QItem.findItemsWithTags(q.fix.account.collective, Stream.emits(items))
)
.compile
.toVector
duration <- timed
_ <- logger.debug(s"Search: resolved details in: ${duration.formatExact}")
} yield resolved

def searchSummary(
today: LocalDate
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
fulltextQuery match {
case Some(ftq) =>
for {
ftq <- createFtsQuery(q.fix.account, 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,
ftq: String
): F[FtsQuery] =
store
.transact(QFolder.getMemberFolders(account))
.map(folders =>
FtsQuery(ftq, account.collective, 500, 0)
.withFolders(folders)
)

def temporaryFtsTable(
ftq: FtsQuery,
nat: F ~> ConnectionIO
): ConnectionIO[RFtsResult.Table] =
ftsClient
.searchAll(ftq)
.translate(nat)
.through(RFtsResult.prepareTable(store.dbms, "fts_result"))
.compile
.lastOrError
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 {

/** Drop the fulltext search query if disabled. */
def withFtsEnabled(enabled: Boolean) =
if (enabled || ftq.isEmpty) this else copy(ftq = None)

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

0 comments on commit 7d6e2d8

Please sign in to comment.