diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala index c5ede0e0..52197e17 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala @@ -164,7 +164,7 @@ object OFulltext { .flatMap(r => Stream.emits(r.results.map(_.itemId))) .compile .to(Set) - q = Query.empty(account).withCond(_.copy(itemIds = itemIds.some)) + q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some)) res <- store.transact(QItem.searchStats(q)) } yield res } @@ -220,7 +220,7 @@ object OFulltext { .flatMap(r => Stream.emits(r.results.map(_.itemId))) .compile .to(Set) - qnext = q.withCond(_.copy(itemIds = items.some)) + qnext = q.withFix(_.copy(itemIds = items.some)) res <- store.transact(QItem.searchStats(qnext)) } yield res diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala new file mode 100644 index 00000000..0ec0d903 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -0,0 +1,13 @@ +package docspell.backend.ops + +import docspell.backend.ops.OItemSearch.ListItemWithTags +import docspell.common.ItemQueryString +import docspell.store.qb.Batch + +trait OSimpleSearch[F[_]] { + + def searchByString(q: ItemQueryString, batch: Batch): F[Vector[ListItemWithTags]] + +} + +object OSimpleSearch {} diff --git a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala new file mode 100644 index 00000000..49bc878f --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala @@ -0,0 +1,3 @@ +package docspell.common + +case class ItemQueryString(query: String) diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala index 43b4523a..1000d630 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -73,8 +73,8 @@ object NotifyDueItemsTask { Query .empty(ctx.args.account) .withOrder(orderAsc = _.dueDate) - .withCond( - _.copy( + .withCond(_ => + Query.QueryForm.empty.copy( states = ItemState.validStates.toList, tagsInclude = ctx.args.tagsInclude, tagsExclude = ctx.args.tagsExclude, diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index 7fc3a87e..985c5be7 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -12,7 +12,7 @@ object ItemQueryParser { ExprParser.exprParser .parseAll(input.trim) .left - .map(_.toString) + .map(pe => s"Error parsing: '${input.trim}': $pe") .map(expr => ItemQuery(expr, Some(input.trim))) def parseUnsafe(input: String): ItemQuery = diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index ecd4cfc0..a6516bc0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -145,8 +145,8 @@ trait Conversions { def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query = OItemSearch.Query( - OItemSearch.Query.Fix(account, None), - OItemSearch.Query.QueryCond( + OItemSearch.Query.Fix(account, None, None), + OItemSearch.Query.QueryForm( m.name, if (m.inbox) Seq(ItemState.Created) else ItemState.validStates.toList, diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index aa846c7e..de4c7090 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -25,5 +25,9 @@ object QueryParam { object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q") + object Query extends OptionalQueryParamDecoderMatcher[String]("q") + object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") + object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") + object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index e0b0c5b8..729f2f88 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -4,21 +4,20 @@ import cats.Monoid import cats.data.NonEmptyList import cats.effect._ import cats.implicits._ - import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.backend.ops.OFulltext -import docspell.backend.ops.OItemSearch.Batch +import docspell.backend.ops.OItemSearch.{Batch, Query} import docspell.common._ import docspell.common.syntax.all._ +import docspell.query.ItemQueryParser import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.conv.Conversions import docspell.restserver.http4s.BinaryUtil import docspell.restserver.http4s.Responses import docspell.restserver.http4s.{QueryParam => QP} - import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ @@ -46,6 +45,33 @@ object ItemRoutes { resp <- Ok(Conversions.basicResult(res, "Task submitted")) } yield resp + case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( + offset + ) => + val query = + q.map(ItemQueryParser.parse) match { + case Some(Right(q)) => + Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q))) + case Some(Left(err)) => + Left(err) + case None => + Right(Query(Query.Fix(user.account, None, None), Query.QueryForm.empty)) + } + val li = limit.getOrElse(cfg.maxItemPageSize) + val of = offset.getOrElse(0) + query match { + case Left(err) => + BadRequest(BasicResult(false, err)) + case Right(sq) => + for { + items <- backend.itemSearch.findItems(cfg.maxNoteLength)( + sq, + Batch(of, li).restrictLimitTo(cfg.maxItemPageSize) + ) + ok <- Ok(Conversions.mkItemList(items)) + } yield ok + } + case req @ POST -> Root / "search" => for { mask <- req.as[ItemSearch] diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 3d3d348b..aa07a4cc 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -5,14 +5,14 @@ import cats.effect.Sync import cats.effect.concurrent.Ref import cats.implicits._ import fs2.Stream - import docspell.common.syntax.all._ import docspell.common.{IdRef, _} +import docspell.query.ItemQuery import docspell.store.Store import docspell.store.qb.DSL._ import docspell.store.qb._ +import docspell.store.qb.generator.{ItemQueryGenerator, Tables} import docspell.store.records._ - import doobie.implicits._ import doobie.{Query => _, _} import org.log4s.getLogger @@ -172,10 +172,13 @@ object QItem { .leftJoin(pers1, pers1.pid === i.concPerson && pers1.cid === coll) .leftJoin(equip, equip.eid === i.concEquipment && equip.cid === coll), where( - i.cid === coll && or( - i.folder.isNull, - i.folder.in(QFolder.findMemberFolderIds(q.account)) + i.cid === coll &&? q.itemIds.map(s => + Nel.fromList(s.toList).map(nel => i.id.in(nel)).getOrElse(i.id.isNull) ) + && or( + i.folder.isNull, + i.folder.in(QFolder.findMemberFolderIds(q.account)) + ) ) ).distinct.orderBy( q.orderAsc @@ -184,7 +187,7 @@ object QItem { ) } - def queryCondition(coll: Ident, q: Query.QueryCond): Condition = + def queryCondFromForm(coll: Ident, q: Query.QueryForm): Condition = Condition.unit &&? q.direction.map(d => i.incoming === d) &&? q.name.map(n => i.name.like(QueryWildcard.lower(n))) &&? @@ -221,6 +224,19 @@ object QItem { findCustomFieldValuesForColl(coll, q.customValues) .map(itemIds => i.id.in(itemIds)) + def queryCondFromExpr(coll: Ident, q: ItemQuery): Condition = { + val tables = Tables(i, org, pers0, pers1, equip, f, a, m) + ItemQueryGenerator.fromExpr(tables, coll)(q.expr) + } + + def queryCondition(coll: Ident, cond: Query.QueryCond): Condition = + cond match { + case fm: Query.QueryForm => + queryCondFromForm(coll, fm) + case expr: Query.QueryExpr => + queryCondFromExpr(coll, expr.q) + } + def findItems( q: Query, maxNoteLen: Int, diff --git a/modules/store/src/main/scala/docspell/store/queries/Query.scala b/modules/store/src/main/scala/docspell/store/queries/Query.scala index 083884d6..879d15b0 100644 --- a/modules/store/src/main/scala/docspell/store/queries/Query.scala +++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala @@ -1,6 +1,7 @@ package docspell.store.queries import docspell.common._ +import docspell.query.ItemQuery import docspell.store.qb.Column import docspell.store.records.RItem @@ -9,14 +10,23 @@ case class Query(fix: Query.Fix, cond: Query.QueryCond) { copy(cond = f(cond)) def withOrder(orderAsc: RItem.Table => Column[_]): Query = - copy(fix = fix.copy(orderAsc = Some(orderAsc))) + withFix(_.copy(orderAsc = Some(orderAsc))) + + def withFix(f: Query.Fix => Query.Fix): Query = + copy(fix = f(fix)) } object Query { - case class Fix(account: AccountId, orderAsc: Option[RItem.Table => Column[_]]) + case class Fix( + account: AccountId, + itemIds: Option[Set[Ident]], + orderAsc: Option[RItem.Table => Column[_]] + ) - case class QueryCond( + sealed trait QueryCond + + case class QueryForm( name: Option[String], states: Seq[ItemState], direction: Option[Direction], @@ -37,10 +47,10 @@ object Query { itemIds: Option[Set[Ident]], customValues: Seq[CustomValue], source: Option[String] - ) - object QueryCond { + ) extends QueryCond + object QueryForm { val empty = - QueryCond( + QueryForm( None, Seq.empty, None, @@ -64,7 +74,9 @@ object Query { ) } + case class QueryExpr(q: ItemQuery) extends QueryCond + def empty(account: AccountId): Query = - Query(Fix(account, None), QueryCond.empty) + Query(Fix(account, None, None), QueryForm.empty) }