From 74a79a79d99880d21625ec6e63f0b8bf7319bcca Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 21 Feb 2021 12:04:48 +0100 Subject: [PATCH 01/33] Initial project setup --- build.sbt | 25 +++++++++++++++++-- .../src/main/scala/docspell/query/Query.scala | 3 +++ .../scala/docspell/query/QueryParser.scala | 13 ++++++++++ project/Dependencies.scala | 11 ++++++++ project/plugins.sbt | 2 ++ 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 modules/query/src/main/scala/docspell/query/Query.scala create mode 100644 modules/query/src/main/scala/docspell/query/QueryParser.scala diff --git a/build.sbt b/build.sbt index 2c9db4f1..3063a246 100644 --- a/build.sbt +++ b/build.sbt @@ -264,6 +264,24 @@ ${lines.map(_._1).mkString(",\n")} ) .dependsOn(common) +val query = + crossProject(JSPlatform, JVMPlatform) + .crossType(CrossType.Pure) + .in(file("modules/query")) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := "docspell-query", + libraryDependencies += + Dependencies.catsParseJS.value + ) + .jvmSettings( + libraryDependencies += + Dependencies.scalaJsStubs + ) +val queryJVM = query.jvm +val queryJS = query.js + val store = project .in(file("modules/store")) .disablePlugins(RevolverPlugin) @@ -284,7 +302,7 @@ val store = project Dependencies.calevCore ++ Dependencies.calevFs2 ) - .dependsOn(common) + .dependsOn(common, queryJVM) val extract = project .in(file("modules/extract")) @@ -425,6 +443,7 @@ val webapp = project openapiSpec := (restapi / Compile / resourceDirectory).value / "docspell-openapi.yml", openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline) ) + .dependsOn(queryJS) // --- Application(s) @@ -594,7 +613,9 @@ val root = project backend, webapp, restapi, - restserver + restserver, + queryJVM, + queryJS ) // --- Helpers diff --git a/modules/query/src/main/scala/docspell/query/Query.scala b/modules/query/src/main/scala/docspell/query/Query.scala new file mode 100644 index 00000000..5f6f3b0f --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/Query.scala @@ -0,0 +1,3 @@ +package docspell.query + +case class Query(raw: String) diff --git a/modules/query/src/main/scala/docspell/query/QueryParser.scala b/modules/query/src/main/scala/docspell/query/QueryParser.scala new file mode 100644 index 00000000..0f6278d9 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/QueryParser.scala @@ -0,0 +1,13 @@ +package docspell.query + +import scala.scalajs.js.annotation._ + +@JSExportTopLevel("DsQueryParser") +object QueryParser { + + @JSExport + def parse(input: String): Either[String, Query] = { + Right(Query("parsed: " + input)) + + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a512e9ba..0ce02040 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,6 +1,7 @@ package docspell.build import sbt._ +import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._ object Dependencies { @@ -8,6 +9,7 @@ object Dependencies { val BetterMonadicForVersion = "0.3.1" val BitpeaceVersion = "0.6.0" val CalevVersion = "0.4.1" + val CatsParseVersion = "0.3.1" val CirceVersion = "0.13.0" val ClipboardJsVersion = "2.0.6" val DoobieVersion = "0.10.0" @@ -41,6 +43,15 @@ object Dependencies { val JQueryVersion = "3.5.1" val ViewerJSVersion = "0.5.8" + val catsParse = Seq( + "org.typelevel" %% "cats-parse" % CatsParseVersion + ) + val catsParseJS = + Def.setting("org.typelevel" %%% "cats-parse" % CatsParseVersion) + + val scalaJsStubs = + "org.scala-js" %% "scalajs-stubs" % "1.0.0" % "provided" + val kittens = Seq( "org.typelevel" %% "kittens" % KittensVersion ) diff --git a/project/plugins.sbt b/project/plugins.sbt index 958f4d6d..a3b25d78 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -7,5 +7,7 @@ addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3") addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.0") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.0") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.5") From be5c7ffb8891100e1e340112bec192458e701993 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Tue, 23 Feb 2021 01:24:24 +0100 Subject: [PATCH 02/33] First draft of ast and parser --- build.sbt | 10 +- .../src/main/scala/docspell/query/Date.scala | 14 ++ .../main/scala/docspell/query/ItemQuery.scala | 97 +++++++++ .../docspell/query/ItemQueryParser.scala | 18 ++ .../src/main/scala/docspell/query/Query.scala | 3 - .../scala/docspell/query/QueryParser.scala | 13 -- .../docspell/query/internal/AttrParser.scala | 85 ++++++++ .../docspell/query/internal/BasicParser.scala | 51 +++++ .../docspell/query/internal/DateParser.scala | 41 ++++ .../docspell/query/internal/ExprParser.scala | 30 +++ .../query/internal/OperatorParser.scala | 36 ++++ .../query/internal/SimpleExprParser.scala | 66 ++++++ .../docspell/query/internal/StringUtil.scala | 177 ++++++++++++++++ .../scala/docspell/query/AttrParserTest.scala | 41 ++++ .../docspell/query/BasicParserTest.scala | 35 ++++ .../scala/docspell/query/DateParserTest.scala | 36 ++++ .../scala/docspell/query/ExprParserTest.scala | 48 +++++ .../docspell/query/OperatorParserTest.scala | 23 +++ .../docspell/query/SimpleExprParserTest.scala | 154 ++++++++++++++ .../main/scala/docspell/store/qb/Column.scala | 3 + .../main/scala/docspell/store/qb/DSL.scala | 4 +- .../qb/generator/ItemQueryGenerator.scala | 195 ++++++++++++++++++ .../docspell/store/qb/generator/Tables.scala | 14 ++ .../docspell/store/records/TagItemName.scala | 18 ++ project/Dependencies.scala | 2 + 25 files changed, 1190 insertions(+), 24 deletions(-) create mode 100644 modules/query/src/main/scala/docspell/query/Date.scala create mode 100644 modules/query/src/main/scala/docspell/query/ItemQuery.scala create mode 100644 modules/query/src/main/scala/docspell/query/ItemQueryParser.scala delete mode 100644 modules/query/src/main/scala/docspell/query/Query.scala delete mode 100644 modules/query/src/main/scala/docspell/query/QueryParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/AttrParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/BasicParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/DateParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/ExprParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala create mode 100644 modules/query/src/main/scala/docspell/query/internal/StringUtil.scala create mode 100644 modules/query/src/test/scala/docspell/query/AttrParserTest.scala create mode 100644 modules/query/src/test/scala/docspell/query/BasicParserTest.scala create mode 100644 modules/query/src/test/scala/docspell/query/DateParserTest.scala create mode 100644 modules/query/src/test/scala/docspell/query/ExprParserTest.scala create mode 100644 modules/query/src/test/scala/docspell/query/OperatorParserTest.scala create mode 100644 modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala create mode 100644 modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala diff --git a/build.sbt b/build.sbt index 3063a246..7cb00d86 100644 --- a/build.sbt +++ b/build.sbt @@ -279,8 +279,6 @@ val query = libraryDependencies += Dependencies.scalaJsStubs ) -val queryJVM = query.jvm -val queryJS = query.js val store = project .in(file("modules/store")) @@ -302,7 +300,7 @@ val store = project Dependencies.calevCore ++ Dependencies.calevFs2 ) - .dependsOn(common, queryJVM) + .dependsOn(common, query.jvm) val extract = project .in(file("modules/extract")) @@ -443,7 +441,7 @@ val webapp = project openapiSpec := (restapi / Compile / resourceDirectory).value / "docspell-openapi.yml", openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline) ) - .dependsOn(queryJS) + .dependsOn(query.js) // --- Application(s) @@ -614,8 +612,8 @@ val root = project webapp, restapi, restserver, - queryJVM, - queryJS + query.jvm, + query.js ) // --- Helpers diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/src/main/scala/docspell/query/Date.scala new file mode 100644 index 00000000..30e9dabb --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/Date.scala @@ -0,0 +1,14 @@ +package docspell.query + +sealed trait Date +object Date { + def apply(y: Int, m: Int, d: Int): Date = + Local(y, m, d) + + def apply(ms: Long): Date = + Millis(ms) + + final case class Local(year: Int, month: Int, day: Int) extends Date + + final case class Millis(ms: Long) extends Date +} diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala new file mode 100644 index 00000000..2a6759a2 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -0,0 +1,97 @@ +package docspell.query + +import cats.data.{NonEmptyList => Nel} +import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr} + +/** A query evaluates to `true` or `false` given enough details about + * an item. + * + * It may consist of (field,op,value) tuples that specify some checks + * against a specific field of an item using some operator or a + * combination thereof. + */ +final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String]) + +object ItemQuery { + + sealed trait Operator + object Operator { + case object Eq extends Operator + case object Like extends Operator + case object Gt extends Operator + case object Lt extends Operator + case object Gte extends Operator + case object Lte extends Operator + } + + sealed trait TagOperator + object TagOperator { + case object AllMatch extends TagOperator + case object AnyMatch extends TagOperator + } + + sealed trait Attr + object Attr { + sealed trait StringAttr extends Attr + sealed trait DateAttr extends Attr + + case object ItemName extends StringAttr + case object ItemSource extends StringAttr + case object ItemId extends StringAttr + case object Date extends DateAttr + case object DueDate extends DateAttr + + object Correspondent { + case object OrgId extends StringAttr + case object OrgName extends StringAttr + case object PersonId extends StringAttr + case object PersonName extends StringAttr + } + + object Concerning { + case object PersonId extends StringAttr + case object PersonName extends StringAttr + case object EquipId extends StringAttr + case object EquipName extends StringAttr + } + + object Folder { + case object FolderId extends StringAttr + case object FolderName extends StringAttr + } + } + + sealed trait Property + object Property { + final case class StringProperty(attr: StringAttr, value: String) extends Property + final case class DateProperty(attr: DateAttr, value: Date) extends Property + + } + + sealed trait Expr { + def negate: Expr = + Expr.NotExpr(this) + } + + object Expr { + case class AndExpr(expr: Nel[Expr]) extends Expr + case class OrExpr(expr: Nel[Expr]) extends Expr + case class NotExpr(expr: Expr) extends Expr { + override def negate: Expr = + expr + } + + case class SimpleExpr(op: Operator, prop: Property) extends Expr + case class Exists(field: Attr) extends Expr + case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr + + case class TagIdsMatch(op: TagOperator, tags: Nel[String]) extends Expr + case class TagsMatch(op: TagOperator, tags: Nel[String]) extends Expr + case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr + + case class CustomFieldMatch(name: String, op: Operator, value: String) extends Expr + + case class Fulltext(query: String) extends Expr + } + +} diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala new file mode 100644 index 00000000..23b0c297 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -0,0 +1,18 @@ +package docspell.query + +import docspell.query.internal.ExprParser + +import scala.scalajs.js.annotation._ + +@JSExportTopLevel("DsItemQueryParser") +object ItemQueryParser { + + @JSExport + def parse(input: String): Either[String, ItemQuery] = + ExprParser.exprParser + .parseAll(input.trim) + .left + .map(_.toString) + .map(expr => ItemQuery(expr, Some(input.trim))) + +} diff --git a/modules/query/src/main/scala/docspell/query/Query.scala b/modules/query/src/main/scala/docspell/query/Query.scala deleted file mode 100644 index 5f6f3b0f..00000000 --- a/modules/query/src/main/scala/docspell/query/Query.scala +++ /dev/null @@ -1,3 +0,0 @@ -package docspell.query - -case class Query(raw: String) diff --git a/modules/query/src/main/scala/docspell/query/QueryParser.scala b/modules/query/src/main/scala/docspell/query/QueryParser.scala deleted file mode 100644 index 0f6278d9..00000000 --- a/modules/query/src/main/scala/docspell/query/QueryParser.scala +++ /dev/null @@ -1,13 +0,0 @@ -package docspell.query - -import scala.scalajs.js.annotation._ - -@JSExportTopLevel("DsQueryParser") -object QueryParser { - - @JSExport - def parse(input: String): Either[String, Query] = { - Right(Query("parsed: " + input)) - - } -} diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala new file mode 100644 index 00000000..6cd1c8b3 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala @@ -0,0 +1,85 @@ +package docspell.query.internal + +import cats.parse.{Parser => P} +import docspell.query.ItemQuery.Attr + +object AttrParser { + + val name: P[Attr.StringAttr] = + P.ignoreCase("name").map(_ => Attr.ItemName) + + val source: P[Attr.StringAttr] = + P.ignoreCase("source").map(_ => Attr.ItemSource) + + val id: P[Attr.StringAttr] = + P.ignoreCase("id").map(_ => Attr.ItemId) + + val date: P[Attr.DateAttr] = + P.ignoreCase("date").map(_ => Attr.Date) + + val dueDate: P[Attr.DateAttr] = + P.stringIn(List("dueDate", "due", "due-date")).map(_ => Attr.DueDate) + + val corrOrgId: P[Attr.StringAttr] = + P.stringIn(List("correspondent.org.id", "corr.org.id")) + .map(_ => Attr.Correspondent.OrgId) + + val corrOrgName: P[Attr.StringAttr] = + P.stringIn(List("correspondent.org.name", "corr.org.name")) + .map(_ => Attr.Correspondent.OrgName) + + val corrPersId: P[Attr.StringAttr] = + P.stringIn(List("correspondent.person.id", "corr.pers.id")) + .map(_ => Attr.Correspondent.PersonId) + + val corrPersName: P[Attr.StringAttr] = + P.stringIn(List("correspondent.person.name", "corr.pers.name")) + .map(_ => Attr.Correspondent.PersonName) + + val concPersId: P[Attr.StringAttr] = + P.stringIn(List("concerning.person.id", "conc.pers.id")) + .map(_ => Attr.Concerning.PersonId) + + val concPersName: P[Attr.StringAttr] = + P.stringIn(List("concerning.person.name", "conc.pers.name")) + .map(_ => Attr.Concerning.PersonName) + + val concEquipId: P[Attr.StringAttr] = + P.stringIn(List("concerning.equip.id", "conc.equip.id")) + .map(_ => Attr.Concerning.EquipId) + + val concEquipName: P[Attr.StringAttr] = + P.stringIn(List("concerning.equip.name", "conc.equip.name")) + .map(_ => Attr.Concerning.EquipName) + + val folderId: P[Attr.StringAttr] = + P.ignoreCase("folder.id").map(_ => Attr.Folder.FolderId) + + val folderName: P[Attr.StringAttr] = + P.ignoreCase("folder").map(_ => Attr.Folder.FolderName) + + val dateAttr: P[Attr.DateAttr] = + P.oneOf(List(date, dueDate)) + + val stringAttr: P[Attr.StringAttr] = + P.oneOf( + List( + name, + source, + id, + corrOrgId, + corrOrgName, + corrPersId, + corrPersName, + concPersId, + concPersName, + concEquipId, + concEquipName, + folderId, + folderName + ) + ) + + val anyAttr: P[Attr] = + P.oneOf(List(dateAttr, stringAttr)) +} diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala new file mode 100644 index 00000000..36694b10 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala @@ -0,0 +1,51 @@ +package docspell.query.internal + +import cats.data.{NonEmptyList => Nel} +import cats.parse.{Parser0, Parser => P} + +object BasicParser { + private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void + + val ws0: Parser0[Unit] = whitespace.rep0.void + val ws1: P[Unit] = whitespace.rep(1).void + + private[this] val listSep: P[Unit] = + P.char(',').surroundedBy(BasicParser.ws0).void + + def rep[A](pa: P[A]): P[Nel[A]] = + pa.repSep(listSep) + + private[this] val basicString: P[String] = + P.charsWhile(c => + c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']' + ) + + private[this] val identChars: Set[Char] = + (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet + + val parenAnd: P[Unit] = + P.stringIn(List("(&", "(and")).void.surroundedBy(ws0) + + val parenClose: P[Unit] = + P.char(')').surroundedBy(ws0) + + val parenOr: P[Unit] = + P.stringIn(List("(|", "(or")).void.surroundedBy(ws0) + + val identParser: P[String] = + P.charsWhile(identChars.contains) + + val singleString: P[String] = + basicString.backtrack.orElse(StringUtil.quoted('"')) + + val stringListValue: P[Nel[String]] = rep(singleString).with1 + .between(P.char('['), P.char(']')) + .backtrack + .orElse(rep(singleString)) + + val stringOrMore: P[Nel[String]] = + stringListValue.backtrack.orElse( + singleString.map(v => Nel.of(v)) + ) + +} diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala new file mode 100644 index 00000000..43ae6221 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala @@ -0,0 +1,41 @@ +package docspell.query.internal + +import cats.implicits._ +import cats.parse.{Numbers, Parser => P} +import docspell.query.Date + +object DateParser { + private[this] val longParser: P[Long] = + Numbers.bigInt.map(_.longValue) + + private[this] val digits4: P[Int] = + Numbers.digit + .repExactlyAs[String](4) + .map(_.toInt) + private[this] val digits2: P[Int] = + Numbers.digit + .repExactlyAs[String](2) + .map(_.toInt) + + private[this] val month: P[Int] = + digits2.filter(n => n >= 1 && n <= 12) + + private[this] val day: P[Int] = + digits2.filter(n => n >= 1 && n <= 31) + + private val dateSep: P[Unit] = + P.anyChar.void + + val localDateFromString: P[Date] = + ((digits4 <* dateSep) ~ (month <* dateSep) ~ day).mapFilter { + case ((year, month), day) => + Either.catchNonFatal(Date(year, month, day)).toOption + } + + val dateFromMillis: P[Date] = + longParser.map(Date.apply) + + val localDate: P[Date] = + localDateFromString.backtrack.orElse(dateFromMillis) + +} diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala new file mode 100644 index 00000000..7c7a6d6a --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala @@ -0,0 +1,30 @@ +package docspell.query.internal + +import cats.parse.{Parser => P} +import docspell.query.ItemQuery._ + +object ExprParser { + + def and(inner: P[Expr]): P[Expr.AndExpr] = + inner + .repSep(BasicParser.ws1) + .between(BasicParser.parenAnd, BasicParser.parenClose) + .map(Expr.AndExpr.apply) + + def or(inner: P[Expr]): P[Expr.OrExpr] = + inner + .repSep(BasicParser.ws1) + .between(BasicParser.parenOr, BasicParser.parenClose) + .map(Expr.OrExpr.apply) + + def not(inner: P[Expr]): P[Expr.NotExpr] = + (P.char('!') *> inner).map(Expr.NotExpr.apply) + + val exprParser: P[Expr] = + P.recursive[Expr] { recurse => + val andP = and(recurse) + val orP = or(recurse) + val notP = not(recurse) + P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil) + } +} diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala new file mode 100644 index 00000000..76a14e60 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala @@ -0,0 +1,36 @@ +package docspell.query.internal + +import cats.parse.{Parser => P} +import docspell.query.ItemQuery._ + +object OperatorParser { + private[this] val Eq: P[Operator] = + P.char('=').void.map(_ => Operator.Eq) + + private[this] val Like: P[Operator] = + P.char(':').void.map(_ => Operator.Like) + + private[this] val Gt: P[Operator] = + P.char('>').void.map(_ => Operator.Gt) + + private[this] val Lt: P[Operator] = + P.char('<').void.map(_ => Operator.Lt) + + private[this] val Gte: P[Operator] = + P.string(">=").map(_ => Operator.Gte) + + private[this] val Lte: P[Operator] = + P.string("<=").map(_ => Operator.Lte) + + val op: P[Operator] = + P.oneOf(List(Like, Eq, Gte, Lte, Gt, Lt)) + + private[this] val anyOp: P[TagOperator] = + P.char(':').map(_ => TagOperator.AnyMatch) + + private[this] val allOp: P[TagOperator] = + P.char('=').map(_ => TagOperator.AllMatch) + + val tagOp: P[TagOperator] = + P.oneOf(List(anyOp, allOp)) +} diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala new file mode 100644 index 00000000..5865ad80 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -0,0 +1,66 @@ +package docspell.query.internal + +import cats.parse.{Parser => P} +import docspell.query.ItemQuery.Expr.CustomFieldMatch +import docspell.query.ItemQuery._ + +object SimpleExprParser { + + private[this] val op: P[Operator] = + OperatorParser.op.surroundedBy(BasicParser.ws0) + + val stringExpr: P[Expr.SimpleExpr] = + (AttrParser.stringAttr ~ op ~ BasicParser.singleString).map { + case ((attr, op), value) => + Expr.SimpleExpr(op, Property.StringProperty(attr, value)) + } + + val dateExpr: P[Expr.SimpleExpr] = + (AttrParser.dateAttr ~ op ~ DateParser.localDate).map { case ((attr, op), value) => + Expr.SimpleExpr(op, Property.DateProperty(attr, value)) + } + + val existsExpr: P[Expr.Exists] = + (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr)) + + val fulltextExpr: P[Expr.Fulltext] = + (P.ignoreCase("content:") *> BasicParser.singleString).map(q => Expr.Fulltext(q)) + + val tagIdExpr: P[Expr.TagIdsMatch] = + (P.ignoreCase("tag.id") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + case (op, values) => + Expr.TagIdsMatch(op, values) + } + + val tagExpr: P[Expr.TagsMatch] = + (P.ignoreCase("tag") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + case (op, values) => + Expr.TagsMatch(op, values) + } + + val catExpr: P[Expr.TagCategoryMatch] = + (P.ignoreCase("cat") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + case (op, values) => + Expr.TagCategoryMatch(op, values) + } + + val customFieldExpr: P[Expr.CustomFieldMatch] = + (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map { + case ((name, op), value) => + CustomFieldMatch(name, op, value) + } + + val simpleExpr: P[Expr] = + P.oneOf( + List( + dateExpr, + stringExpr, + existsExpr, + fulltextExpr, + tagIdExpr, + tagExpr, + catExpr, + customFieldExpr + ) + ) +} diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala new file mode 100644 index 00000000..28a24872 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2021 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package docspell.query.internal + +// modified, from +// https://github.com/typelevel/cats-parse/blob/e7a58ef15925358fbe7a4c0c1a204296e366a06c/bench/src/main/scala/cats/parse/bench/self.scala +import cats.parse.{Parser0 => P0, Parser => P} + +object StringUtil { + + def quoted(q: Char): P[String] = + Util.escapedString(q) + + private object Util extends GenericStringUtil { + lazy val decodeTable: Map[Char, Char] = + Map( + ('\\', '\\'), + ('\'', '\''), + ('\"', '\"'), + ('n', '\n'), + ('r', '\r'), + ('t', '\t') + ) + } + abstract private class GenericStringUtil { + protected def decodeTable: Map[Char, Char] + + private val encodeTable = decodeTable.iterator.map { case (v, k) => + (k, s"\\$v") + }.toMap + + private val nonPrintEscape: Array[String] = + (0 until 32).map { c => + val strHex = c.toHexString + val strPad = List.fill(4 - strHex.length)('0').mkString + s"\\u$strPad$strHex" + }.toArray + + val escapedToken: P[Unit] = { + val escapes = P.charIn(decodeTable.keys.toSeq) + + val oct = P.charIn('0' to '7') + val octP = P.char('o') ~ oct ~ oct + + val hex = P.charIn(('0' to '9') ++ ('a' to 'f') ++ ('A' to 'F')) + val hex2 = hex ~ hex + val hexP = P.char('x') ~ hex2 + + val hex4 = hex2 ~ hex2 + val u4 = P.char('u') ~ hex4 + val hex8 = hex4 ~ hex4 + val u8 = P.char('U') ~ hex8 + + val after = P.oneOf[Any](escapes :: octP :: hexP :: u4 :: u8 :: Nil) + (P.char('\\') ~ after).void + } + + /** String content without the delimiter + */ + def undelimitedString(endP: P[Unit]): P[String] = + escapedToken.backtrack + .orElse((!endP).with1 ~ P.anyChar) + .rep + .string + .flatMap { str => + unescape(str) match { + case Right(str1) => P.pure(str1) + case Left(_) => P.fail + } + } + + private val simpleString: P0[String] = + P.charsWhile0(c => c >= ' ' && c != '"' && c != '\\') + + def escapedString(q: Char): P[String] = { + val end: P[Unit] = P.char(q) + end *> ((simpleString <* end).backtrack + .orElse(undelimitedString(end) <* end)) + } + + def escape(quoteChar: Char, str: String): String = { + // We can ignore escaping the opposite character used for the string + // x isn't escaped anyway and is kind of a hack here + val ignoreEscape = + if (quoteChar == '\'') '"' else if (quoteChar == '"') '\'' else 'x' + str.flatMap { c => + if (c == ignoreEscape) c.toString + else + encodeTable.get(c) match { + case None => + if (c < ' ') nonPrintEscape(c.toInt) + else c.toString + case Some(esc) => esc + } + } + } + + def unescape(str: String): Either[Int, String] = { + val sb = new java.lang.StringBuilder + def decodeNum(idx: Int, size: Int, base: Int): Int = { + val end = idx + size + if (end <= str.length) { + val intStr = str.substring(idx, end) + val asInt = + try Integer.parseInt(intStr, base) + catch { case _: NumberFormatException => ~idx } + sb.append(asInt.toChar) + end + } else ~(str.length) + } + @annotation.tailrec + def loop(idx: Int): Int = + if (idx >= str.length) { + // done + idx + } else if (idx < 0) { + // error from decodeNum + idx + } else { + val c0 = str.charAt(idx) + if (c0 != '\\') { + sb.append(c0) + loop(idx + 1) + } else { + // str(idx) == \ + val nextIdx = idx + 1 + if (nextIdx >= str.length) { + // error we expect there to be a character after \ + ~idx + } else { + val c = str.charAt(nextIdx) + decodeTable.get(c) match { + case Some(d) => + sb.append(d) + loop(idx + 2) + case None => + c match { + case 'o' => loop(decodeNum(idx + 2, 2, 8)) + case 'x' => loop(decodeNum(idx + 2, 2, 16)) + case 'u' => loop(decodeNum(idx + 2, 4, 16)) + case 'U' => loop(decodeNum(idx + 2, 8, 16)) + case other => + // \c is interpreted as just \c, if the character isn't escaped + sb.append('\\') + sb.append(other) + loop(idx + 2) + } + } + } + } + } + + val res = loop(0) + if (res < 0) Left(~res) + else Right(sb.toString) + } + } + +} diff --git a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/AttrParserTest.scala new file mode 100644 index 00000000..b79c1103 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/AttrParserTest.scala @@ -0,0 +1,41 @@ +package docspell.query + +import docspell.query.ItemQuery.Attr +import docspell.query.internal.AttrParser +import minitest._ + +object AttrParserTest extends SimpleTestSuite { + + test("string attributes") { + val p = AttrParser.stringAttr + assertEquals(p.parseAll("name"), Right(Attr.ItemName)) + assertEquals(p.parseAll("source"), Right(Attr.ItemSource)) + assertEquals(p.parseAll("id"), Right(Attr.ItemId)) + assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId)) + assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName)) + assertEquals(p.parseAll("correspondent.org.name"), Right(Attr.Correspondent.OrgName)) + assertEquals(p.parseAll("conc.pers.id"), Right(Attr.Concerning.PersonId)) + assertEquals(p.parseAll("conc.pers.name"), Right(Attr.Concerning.PersonName)) + assertEquals(p.parseAll("concerning.person.name"), Right(Attr.Concerning.PersonName)) + assertEquals(p.parseAll("folder"), Right(Attr.Folder.FolderName)) + assertEquals(p.parseAll("folder.id"), Right(Attr.Folder.FolderId)) + } + + test("date attributes") { + val p = AttrParser.dateAttr + assertEquals(p.parseAll("date"), Right(Attr.Date)) + assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate)) + assertEquals(p.parseAll("due"), Right(Attr.DueDate)) + } + + test("all attributes parser") { + val p = AttrParser.anyAttr + assertEquals(p.parseAll("date"), Right(Attr.Date)) + assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate)) + assertEquals(p.parseAll("name"), Right(Attr.ItemName)) + assertEquals(p.parseAll("source"), Right(Attr.ItemSource)) + assertEquals(p.parseAll("id"), Right(Attr.ItemId)) + assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId)) + assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName)) + } +} diff --git a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/BasicParserTest.scala new file mode 100644 index 00000000..80a06d18 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/BasicParserTest.scala @@ -0,0 +1,35 @@ +package docspell.query + +import minitest._ +import cats.data.{NonEmptyList => Nel} +import docspell.query.internal.BasicParser + +object BasicParserTest extends SimpleTestSuite { + test("single string values") { + val p = BasicParser.singleString + assertEquals(p.parseAll("abcde"), Right("abcde")) + assert(p.parseAll("ab cd").isLeft) + assertEquals(p.parseAll(""""ab cd""""), Right("ab cd")) + assertEquals(p.parseAll(""""and \"this\" is""""), Right("""and "this" is""")) + } + + test("string list values") { + val p = BasicParser.stringListValue + assertEquals(p.parseAll("[ab,cd]"), Right(Nel.of("ab", "cd"))) + assertEquals(p.parseAll("[\"ab 12\",cd]"), Right(Nel.of("ab 12", "cd"))) + assertEquals( + p.parseAll("[\"ab, 12\",cd]"), + Right(Nel.of("ab, 12", "cd")) + ) + assertEquals(p.parseAll("ab,cd,123"), Right(Nel.of("ab", "cd", "123"))) + assertEquals(p.parseAll("a,b"), Right(Nel.of("a", "b"))) + assert(p.parseAll("[a,b").isLeft) + } + + test("stringvalue") { + val p = BasicParser.stringOrMore + assertEquals(p.parseAll("abcde"), Right(Nel.of("abcde"))) + assertEquals(p.parseAll(""""a,b,c""""), Right(Nel.of("a,b,c"))) + assertEquals(p.parseAll("[a,b,c]"), Right(Nel.of("a", "b", "c"))) + } +} diff --git a/modules/query/src/test/scala/docspell/query/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/DateParserTest.scala new file mode 100644 index 00000000..ca909a97 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/DateParserTest.scala @@ -0,0 +1,36 @@ +package docspell.query + +import docspell.query.internal.DateParser +import minitest._ + +object DateParserTest extends SimpleTestSuite { + + def ld(year: Int, m: Int, d: Int): Date = + Date(year, m, d) + + test("local date string") { + val p = DateParser.localDateFromString + assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22))) + assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) + assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21))) + assert(p.parseAll("0-0-0").isLeft) + assert(p.parseAll("2021-02-30").isRight) + } + + test("local date millis") { + val p = DateParser.dateFromMillis + assertEquals(p.parseAll("0"), Right(Date(0))) + assertEquals( + p.parseAll("1600000065463"), + Right(Date(1600000065463L)) + ) + } + + test("local date") { + val p = DateParser.localDate + assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22))) + assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) + assertEquals(p.parseAll("0"), Right(Date(0))) + assertEquals(p.parseAll("1600000065463"), Right(Date(1600000065463L))) + } +} diff --git a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/ExprParserTest.scala new file mode 100644 index 00000000..304fd6d0 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/ExprParserTest.scala @@ -0,0 +1,48 @@ +package docspell.query + +import docspell.query.ItemQuery._ +import docspell.query.SimpleExprParserTest.stringExpr +import docspell.query.internal.ExprParser +import minitest._ +import cats.data.{NonEmptyList => Nel} + +object ExprParserTest extends SimpleTestSuite { + + test("simple expr") { + val p = ExprParser.exprParser + assertEquals( + p.parseAll("name:hello"), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello")) + ) + } + + test("and") { + val p = ExprParser.exprParser + assertEquals( + p.parseAll("(& name:hello source=webapp )"), + Right( + Expr.AndExpr( + Nel.of( + stringExpr(Operator.Like, Attr.ItemName, "hello"), + stringExpr(Operator.Eq, Attr.ItemSource, "webapp") + ) + ) + ) + ) + } + + test("or") { + val p = ExprParser.exprParser + assertEquals( + p.parseAll("(| name:hello source=webapp )"), + Right( + Expr.OrExpr( + Nel.of( + stringExpr(Operator.Like, Attr.ItemName, "hello"), + stringExpr(Operator.Eq, Attr.ItemSource, "webapp") + ) + ) + ) + ) + } +} diff --git a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala new file mode 100644 index 00000000..94e9ea35 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala @@ -0,0 +1,23 @@ +package docspell.query + +import minitest._ +import docspell.query.ItemQuery.{Operator, TagOperator} +import docspell.query.internal.OperatorParser + +object OperatorParserTest extends SimpleTestSuite { + test("operator values") { + val p = OperatorParser.op + assertEquals(p.parseAll("="), Right(Operator.Eq)) + assertEquals(p.parseAll(":"), Right(Operator.Like)) + assertEquals(p.parseAll("<"), Right(Operator.Lt)) + assertEquals(p.parseAll(">"), Right(Operator.Gt)) + assertEquals(p.parseAll("<="), Right(Operator.Lte)) + assertEquals(p.parseAll(">="), Right(Operator.Gte)) + } + + test("tag operators") { + val p = OperatorParser.tagOp + assertEquals(p.parseAll(":"), Right(TagOperator.AnyMatch)) + assertEquals(p.parseAll("="), Right(TagOperator.AllMatch)) + } +} diff --git a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala new file mode 100644 index 00000000..298e9c59 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala @@ -0,0 +1,154 @@ +package docspell.query + +import cats.data.{NonEmptyList => Nel} +import docspell.query.ItemQuery._ +import docspell.query.internal.SimpleExprParser +import minitest._ + +object SimpleExprParserTest extends SimpleTestSuite { + + test("string expr") { + val p = SimpleExprParser.stringExpr + assertEquals( + p.parseAll("name:hello"), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello")) + ) + assertEquals( + p.parseAll("name: hello"), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello")) + ) + assertEquals( + p.parseAll("name:\"hello world\""), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello world")) + ) + assertEquals( + p.parseAll("name : \"hello world\""), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello world")) + ) + assertEquals( + p.parseAll("conc.pers.id=Aaiet-aied"), + Right(stringExpr(Operator.Eq, Attr.Concerning.PersonId, "Aaiet-aied")) + ) + } + + test("date expr") { + val p = SimpleExprParser.dateExpr + assertEquals( + p.parseAll("due:2021-03-14"), + Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14))) + ) + assertEquals( + p.parseAll("due<2021-03-14"), + Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14))) + ) + } + + test("exists expr") { + val p = SimpleExprParser.existsExpr + assertEquals(p.parseAll("exists:name"), Right(Expr.Exists(Attr.ItemName))) + assert(p.parseAll("exists:blabla").isLeft) + assertEquals( + p.parseAll("exists:conc.pers.id"), + Right(Expr.Exists(Attr.Concerning.PersonId)) + ) + } + + test("fulltext expr") { + val p = SimpleExprParser.fulltextExpr + assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test"))) + assertEquals( + p.parseAll("content:\"hello world\""), + Right(Expr.Fulltext("hello world")) + ) + } + + test("category expr") { + val p = SimpleExprParser.catExpr + assertEquals( + p.parseAll("cat:expense,doctype"), + Right(Expr.TagCategoryMatch(TagOperator.AnyMatch, Nel.of("expense", "doctype"))) + ) + } + + test("custom field") { + val p = SimpleExprParser.customFieldExpr + assertEquals( + p.parseAll("f:usd=26.66"), + Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66")) + ) + } + + test("tag id expr") { + val p = SimpleExprParser.tagIdExpr + assertEquals( + p.parseAll("tag.id:a,b,c"), + Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c"))) + ) + assertEquals( + p.parseAll("tag.id:a"), + Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a"))) + ) + assertEquals( + p.parseAll("tag.id=a,b,c"), + Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "b", "c"))) + ) + assertEquals( + p.parseAll("tag.id=a"), + Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a"))) + ) + assertEquals( + p.parseAll("tag.id=a,\"x y\""), + Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "x y"))) + ) + } + + test("simple expr") { + val p = SimpleExprParser.simpleExpr + assertEquals( + p.parseAll("name:hello"), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello")) + ) + assertEquals( + p.parseAll("name:hello"), + Right(stringExpr(Operator.Like, Attr.ItemName, "hello")) + ) + assertEquals( + p.parseAll("due:2021-03-14"), + Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14))) + ) + assertEquals( + p.parseAll("due<2021-03-14"), + Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14))) + ) + assertEquals( + p.parseAll("exists:conc.pers.id"), + Right(Expr.Exists(Attr.Concerning.PersonId)) + ) + assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test"))) + assertEquals( + p.parseAll("tag.id:a"), + Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a"))) + ) + assertEquals( + p.parseAll("tag.id=a,b,c"), + Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "b", "c"))) + ) + assertEquals( + p.parseAll("cat:expense,doctype"), + Right(Expr.TagCategoryMatch(TagOperator.AnyMatch, Nel.of("expense", "doctype"))) + ) + assertEquals( + p.parseAll("f:usd=26.66"), + Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66")) + ) + } + + def ld(y: Int, m: Int, d: Int) = + DateParserTest.ld(y, m, d) + + def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr = + Expr.SimpleExpr(op, Property.StringProperty(name, value)) + + def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr = + Expr.SimpleExpr(op, Property.DateProperty(name, value)) +} diff --git a/modules/store/src/main/scala/docspell/store/qb/Column.scala b/modules/store/src/main/scala/docspell/store/qb/Column.scala index 3e59a62c..e5e13749 100644 --- a/modules/store/src/main/scala/docspell/store/qb/Column.scala +++ b/modules/store/src/main/scala/docspell/store/qb/Column.scala @@ -3,6 +3,9 @@ package docspell.store.qb case class Column[A](name: String, table: TableDef) { def inTable(t: TableDef): Column[A] = copy(table = t) + + def cast[B]: Column[B] = + this.asInstanceOf[Column[B]] } object Column {} diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala index e90439bf..fba05543 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala @@ -174,13 +174,13 @@ trait DSL extends DoobieMeta { Condition.CompareVal(col, Operator.LowerEq, value) def ====(value: String): Condition = - Condition.CompareVal(col.asInstanceOf[Column[String]], Operator.Eq, value) + Condition.CompareVal(col.cast[String], Operator.Eq, value) def like(value: A)(implicit P: Put[A]): Condition = Condition.CompareVal(col, Operator.LowerLike, value) def likes(value: String): Condition = - Condition.CompareVal(col.asInstanceOf[Column[String]], Operator.LowerLike, value) + Condition.CompareVal(col.cast[String], Operator.LowerLike, value) def <=(value: A)(implicit P: Put[A]): Condition = Condition.CompareVal(col, Operator.Lte, value) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala new file mode 100644 index 00000000..b43764c4 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -0,0 +1,195 @@ +package docspell.store.qb.generator + +import cats.data.NonEmptyList +import docspell.common._ +import docspell.query.ItemQuery +import docspell.query.ItemQuery.Attr._ +import docspell.query.ItemQuery.Property.{DateProperty, StringProperty} +import docspell.query.ItemQuery.{Attr, Expr, Operator, TagOperator} +import docspell.store.qb.{Operator => QOp, _} +import docspell.store.qb.DSL._ +import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} +import doobie.util.Put + +object ItemQueryGenerator { + + def apply(tables: Tables, coll: Ident)(q: ItemQuery)(implicit + PT: Put[Timestamp] + ): Condition = + fromExpr(tables, coll)(q.expr) + + final def fromExpr(tables: Tables, coll: Ident)( + expr: Expr + )(implicit PT: Put[Timestamp]): Condition = + expr match { + case Expr.AndExpr(inner) => + Condition.And(inner.map(fromExpr(tables, coll))) + + case Expr.OrExpr(inner) => + Condition.Or(inner.map(fromExpr(tables, coll))) + + case Expr.NotExpr(inner) => + inner match { + case Expr.Exists(notExists) => + anyColumn(tables)(notExists).isNull + + case Expr.TagIdsMatch(op, tags) => + val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) + NonEmptyList + .fromList(ids) + .map { nel => + op match { + case TagOperator.AnyMatch => + tables.item.id.notIn(TagItemName.itemsWithEitherTag(nel)) + case TagOperator.AllMatch => + tables.item.id.notIn(TagItemName.itemsWithAllTags(nel)) + } + } + .getOrElse(Condition.unit) + case Expr.TagsMatch(op, tags) => + op match { + case TagOperator.AllMatch => + tables.item.id.notIn(TagItemName.itemsWithAllTagNameOrIds(tags)) + + case TagOperator.AnyMatch => + tables.item.id.notIn(TagItemName.itemsWithEitherTagNameOrIds(tags)) + } + + case Expr.TagCategoryMatch(op, cats) => + op match { + case TagOperator.AllMatch => + tables.item.id.notIn(TagItemName.itemsInAllCategories(cats)) + + case TagOperator.AnyMatch => + tables.item.id.notIn(TagItemName.itemsInEitherCategory(cats)) + } + + case Expr.Fulltext(_) => + Condition.unit + + case _ => + Condition.Not(fromExpr(tables, coll)(inner)) + } + + case Expr.Exists(field) => + anyColumn(tables)(field).isNotNull + + case Expr.SimpleExpr(op, StringProperty(attr, value)) => + val col = stringColumn(tables)(attr) + op match { + case Operator.Like => + Condition.CompareVal(col, makeOp(op), value.toLowerCase) + case _ => + Condition.CompareVal(col, makeOp(op), value) + } + + case Expr.SimpleExpr(op, DateProperty(attr, value)) => + val dt = Timestamp.atUtc(value.atStartOfDay()) + val col = timestampColumn(tables)(attr) + Condition.CompareVal(col, makeOp(op), dt) + + case Expr.InExpr(attr, values) => + val col = stringColumn(tables)(attr) + if (values.tail.isEmpty) col === values.head + else col.in(values) + + case Expr.TagIdsMatch(op, tags) => + val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) + NonEmptyList + .fromList(ids) + .map { nel => + op match { + case TagOperator.AnyMatch => + tables.item.id.in(TagItemName.itemsWithEitherTag(nel)) + case TagOperator.AllMatch => + tables.item.id.in(TagItemName.itemsWithAllTags(nel)) + } + } + .getOrElse(Condition.unit) + + case Expr.TagsMatch(op, tags) => + op match { + case TagOperator.AllMatch => + tables.item.id.in(TagItemName.itemsWithAllTagNameOrIds(tags)) + + case TagOperator.AnyMatch => + tables.item.id.in(TagItemName.itemsWithEitherTagNameOrIds(tags)) + } + + case Expr.TagCategoryMatch(op, cats) => + op match { + case TagOperator.AllMatch => + tables.item.id.in(TagItemName.itemsInAllCategories(cats)) + + case TagOperator.AnyMatch => + tables.item.id.in(TagItemName.itemsInEitherCategory(cats)) + } + + case Expr.CustomFieldMatch(field, op, value) => + tables.item.id.in(itemsWithCustomField(coll, field, makeOp(op), value)) + + case Expr.Fulltext(_) => + // not supported here + Condition.unit + } + + private def anyColumn(tables: Tables)(attr: Attr): Column[_] = + attr match { + case s: StringAttr => + stringColumn(tables)(s) + case t: DateAttr => + timestampColumn(tables)(t) + } + + private def timestampColumn(tables: Tables)(attr: DateAttr) = + attr match { + case Attr.Date => + tables.item.itemDate + case Attr.DueDate => + tables.item.dueDate + } + + private def stringColumn(tables: Tables)(attr: StringAttr): Column[String] = + attr match { + case Attr.ItemId => tables.item.id.cast[String] + case Attr.ItemName => tables.item.name + case Attr.ItemSource => tables.item.source + case Attr.Correspondent.OrgId => tables.corrOrg.oid.cast[String] + case Attr.Correspondent.OrgName => tables.corrOrg.name + case Attr.Correspondent.PersonId => tables.corrPers.pid.cast[String] + case Attr.Correspondent.PersonName => tables.corrPers.name + case Attr.Concerning.PersonId => tables.concPers.pid.cast[String] + case Attr.Concerning.PersonName => tables.concPers.name + case Attr.Concerning.EquipId => tables.concEquip.eid.cast[String] + case Attr.Concerning.EquipName => tables.concEquip.name + case Attr.Folder.FolderId => tables.folder.id.cast[String] + case Attr.Folder.FolderName => tables.folder.name + } + + private def makeOp(operator: Operator): QOp = + operator match { + case Operator.Eq => + QOp.Eq + case Operator.Like => + QOp.LowerLike + case Operator.Gt => + QOp.Gt + case Operator.Lt => + QOp.Lt + case Operator.Gte => + QOp.Gte + case Operator.Lte => + QOp.Lte + } + + def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = { + val cf = RCustomField.as("cf") + val cfv = RCustomFieldValue.as("cfv") + val v = if (op == QOp.LowerLike) value.toLowerCase else value + Select( + select(cfv.itemId), + from(cfv).innerJoin(cf, cf.id === cfv.field), + cf.cid === coll && cf.name ==== field && Condition.CompareVal(cfv.value, op, v) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala new file mode 100644 index 00000000..966b129d --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala @@ -0,0 +1,14 @@ +package docspell.store.qb.generator + +import docspell.store.records._ + +final case class Tables( + item: RItem.Table, + corrOrg: ROrganization.Table, + corrPers: RPerson.Table, + concPers: RPerson.Table, + concEquip: REquipment.Table, + folder: RFolder.Table, + attach: RAttachment.Table, + meta: RAttachmentMeta.Table +) diff --git a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala index 71d261bf..012dad4c 100644 --- a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala +++ b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala @@ -42,9 +42,27 @@ object TagItemName { def itemsWithEitherTag(tags: NonEmptyList[Ident]): Select = Select(ti.itemId.s, from(ti), orTags(tags)).distinct + def itemsWithEitherTagNameOrIds(tags: NonEmptyList[String]): Select = + Select( + ti.itemId.s, + from(ti).innerJoin(t, t.tid === ti.tagId), + ti.tagId.cast[String].in(tags) || t.name.inLower(tags.map(_.toLowerCase)) + ).distinct + def itemsWithAllTags(tags: NonEmptyList[Ident]): Select = intersect(tags.map(tid => Select(ti.itemId.s, from(ti), ti.tagId === tid).distinct)) + def itemsWithAllTagNameOrIds(tags: NonEmptyList[String]): Select = + intersect( + tags.map(tag => + Select( + ti.itemId.s, + from(ti).innerJoin(t, t.tid === ti.tagId), + ti.tagId ==== tag || t.name.lowerEq(tag.toLowerCase) + ).distinct + ) + ) + def itemsWithEitherTagOrCategory( tags: NonEmptyList[Ident], cats: NonEmptyList[String] diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 0ce02040..dac2bb0e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -52,6 +52,8 @@ object Dependencies { val scalaJsStubs = "org.scala-js" %% "scalajs-stubs" % "1.0.0" % "provided" + val catsJS = Def.setting("org.typelevel" %%% "cats-core" % "2.4.2") + val kittens = Seq( "org.typelevel" %% "kittens" % KittensVersion ) From c3cdec416c90ebc3e08ed8f97b27ebfe2dc113c5 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Wed, 24 Feb 2021 00:22:45 +0100 Subject: [PATCH 03/33] Sketching some basic tests --- build.sbt | 6 +- .../docspell/query/ItemQueryParser.scala | 2 + .../qb/generator/ItemQueryGenerator.scala | 27 +++++--- .../scala/docspell/store/StoreFixture.scala | 67 +++++++++++++++++++ .../generator/ItemQueryGeneratorTest.scala | 63 +++++++++++++++++ 5 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 modules/store/src/test/scala/docspell/store/StoreFixture.scala create mode 100644 modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala diff --git a/build.sbt b/build.sbt index 7cb00d86..8b31ed2f 100644 --- a/build.sbt +++ b/build.sbt @@ -47,7 +47,8 @@ val sharedSettings = Seq( val testSettings = Seq( testFrameworks += new TestFramework("minitest.runner.Framework"), - libraryDependencies ++= Dependencies.miniTest ++ Dependencies.logging.map(_ % Test) + libraryDependencies ++= Dependencies.miniTest ++ Dependencies.logging.map(_ % Test), + Test / fork := true ) lazy val noPublish = Seq( @@ -275,6 +276,9 @@ val query = libraryDependencies += Dependencies.catsParseJS.value ) + .jsSettings( + Test / fork := false + ) .jvmSettings( libraryDependencies += Dependencies.scalaJsStubs diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index 23b0c297..7fc3a87e 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -15,4 +15,6 @@ object ItemQueryParser { .map(_.toString) .map(expr => ItemQuery(expr, Some(input.trim))) + def parseUnsafe(input: String): ItemQuery = + parse(input).fold(sys.error, identity) } diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index b43764c4..2f745dd8 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -1,11 +1,11 @@ package docspell.store.qb.generator +import java.time.{Instant, LocalDate} + import cats.data.NonEmptyList import docspell.common._ -import docspell.query.ItemQuery -import docspell.query.ItemQuery.Attr._ -import docspell.query.ItemQuery.Property.{DateProperty, StringProperty} -import docspell.query.ItemQuery.{Attr, Expr, Operator, TagOperator} +import docspell.query.{Date, ItemQuery} +import docspell.query.ItemQuery._ import docspell.store.qb.{Operator => QOp, _} import docspell.store.qb.DSL._ import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} @@ -74,7 +74,7 @@ object ItemQueryGenerator { case Expr.Exists(field) => anyColumn(tables)(field).isNotNull - case Expr.SimpleExpr(op, StringProperty(attr, value)) => + case Expr.SimpleExpr(op, Property.StringProperty(attr, value)) => val col = stringColumn(tables)(attr) op match { case Operator.Like => @@ -83,8 +83,13 @@ object ItemQueryGenerator { Condition.CompareVal(col, makeOp(op), value) } - case Expr.SimpleExpr(op, DateProperty(attr, value)) => - val dt = Timestamp.atUtc(value.atStartOfDay()) + case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) => + val dt = value match { + case Date.Local(year, month, day) => + Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) + case Date.Millis(ms) => + Timestamp(Instant.ofEpochMilli(ms)) + } val col = timestampColumn(tables)(attr) Condition.CompareVal(col, makeOp(op), dt) @@ -135,13 +140,13 @@ object ItemQueryGenerator { private def anyColumn(tables: Tables)(attr: Attr): Column[_] = attr match { - case s: StringAttr => + case s: Attr.StringAttr => stringColumn(tables)(s) - case t: DateAttr => + case t: Attr.DateAttr => timestampColumn(tables)(t) } - private def timestampColumn(tables: Tables)(attr: DateAttr) = + private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) = attr match { case Attr.Date => tables.item.itemDate @@ -149,7 +154,7 @@ object ItemQueryGenerator { tables.item.dueDate } - private def stringColumn(tables: Tables)(attr: StringAttr): Column[String] = + private def stringColumn(tables: Tables)(attr: Attr.StringAttr): Column[String] = attr match { case Attr.ItemId => tables.item.id.cast[String] case Attr.ItemName => tables.item.name diff --git a/modules/store/src/test/scala/docspell/store/StoreFixture.scala b/modules/store/src/test/scala/docspell/store/StoreFixture.scala new file mode 100644 index 00000000..839eb242 --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/StoreFixture.scala @@ -0,0 +1,67 @@ +package docspell.store + +import cats.effect._ +import docspell.common.LenientUri +import docspell.store.impl.StoreImpl +import doobie._ +import org.h2.jdbcx.JdbcConnectionPool + +import scala.concurrent.ExecutionContext + +trait StoreFixture { + def withStore(db: String)(code: Store[IO] => IO[Unit]): Unit = { + //StoreFixture.store(StoreFixture.memoryDB(db)).use(code).unsafeRunSync() + val jdbc = StoreFixture.memoryDB(db) + val xa = StoreFixture.globalXA(jdbc) + val store = new StoreImpl[IO](jdbc, xa) + store.migrate.unsafeRunSync() + code(store).unsafeRunSync() + } + + def withXA(db: String)(code: Transactor[IO] => IO[Unit]): Unit = + StoreFixture.makeXA(StoreFixture.memoryDB(db)).use(code).unsafeRunSync() + +} + +object StoreFixture { + implicit def contextShift: ContextShift[IO] = + IO.contextShift(ExecutionContext.global) + + def memoryDB(dbname: String): JdbcConfig = + JdbcConfig( + LenientUri.unsafe( + s"jdbc:h2:mem:$dbname;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1" + ), + "sa", + "" + ) + + def globalXA(jdbc: JdbcConfig): Transactor[IO] = + Transactor.fromDriverManager( + "org.h2.Driver", + jdbc.url.asString, + jdbc.user, + jdbc.password + ) + + def makeXA(jdbc: JdbcConfig): Resource[IO, Transactor[IO]] = { + def jdbcConnPool = + JdbcConnectionPool.create(jdbc.url.asString, jdbc.user, jdbc.password) + + val makePool = Resource.make(IO(jdbcConnPool))(cp => IO(cp.dispose())) + + for { + ec <- ExecutionContexts.cachedThreadPool[IO] + blocker <- Blocker[IO] + pool <- makePool + xa = Transactor.fromDataSource[IO].apply(pool, ec, blocker) + } yield xa + } + + def store(jdbc: JdbcConfig): Resource[IO, Store[IO]] = + for { + xa <- makeXA(jdbc) + store = new StoreImpl[IO](jdbc, xa) + _ <- Resource.liftF(store.migrate) + } yield store +} diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala new file mode 100644 index 00000000..7bbfcb52 --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -0,0 +1,63 @@ +package docspell.store.generator + +import java.time.LocalDate + +import docspell.store.records._ +import minitest._ +import docspell.common._ +import docspell.query.ItemQueryParser +import docspell.store.qb.DSL._ +import docspell.store.qb.generator.{ItemQueryGenerator, Tables} + +object ItemQueryGeneratorTest extends SimpleTestSuite { + import docspell.store.impl.DoobieMeta._ + + val tables = Tables( + RItem.as("i"), + ROrganization.as("co"), + RPerson.as("cp"), + RPerson.as("np"), + REquipment.as("ne"), + RFolder.as("f"), + RAttachment.as("a"), + RAttachmentMeta.as("m") + ) + + test("migration") { + val q = ItemQueryParser + .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))") + val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q) + val expect = + tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc( + LocalDate.of(2020, 2, 1).atStartOfDay() + ) && (tables.item.source === "expense" || tables.folder.name === "test") + + assertEquals(cond, expect) + } + +// test("migration2") { +// withStore("db2") { store => +// val c = RCollective( +// Ident.unsafe("coll1"), +// CollectiveState.Active, +// Language.German, +// true, +// Timestamp.Epoch +// ) +// val e = +// REquipment( +// Ident.unsafe("equip"), +// Ident.unsafe("coll1"), +// "name", +// Timestamp.Epoch, +// Timestamp.Epoch, +// None +// ) +// +// for { +// _ <- store.transact(RCollective.insert(c)) +// _ <- store.transact(REquipment.insert(e)).map(_ => ()) +// } yield () +// } +// } +} From 186014a1c62337370d884a3046c2e3a609f0ea3c Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Wed, 24 Feb 2021 21:57:41 +0100 Subject: [PATCH 04/33] Refactor search to separate between a base query and user query The `findBase` is adding only strictly required conditions. Everything else comes from the user. --- .../docspell/backend/ops/OFulltext.scala | 8 +- .../docspell/backend/ops/OItemSearch.scala | 4 +- .../joex/notify/NotifyDueItemsTask.scala | 17 +-- .../restserver/conv/Conversions.scala | 53 ++++----- .../scala/docspell/store/queries/QItem.scala | 48 ++++---- .../scala/docspell/store/queries/Query.scala | 109 ++++++++++-------- .../generator/ItemQueryGeneratorTest.scala | 2 +- 7 files changed, 129 insertions(+), 112 deletions(-) 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 52e23571..c5ede0e0 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).copy(itemIds = itemIds.some) + q = Query.empty(account).withCond(_.copy(itemIds = itemIds.some)) res <- store.transact(QItem.searchStats(q)) } yield res } @@ -208,7 +208,7 @@ object OFulltext { search <- itemSearch.findItems(0)(q, Batch.all) fq = FtsQuery( ftsQ.query, - q.account.collective, + q.fix.account.collective, search.map(_.id).toSet, Set.empty, 500, @@ -220,7 +220,7 @@ object OFulltext { .flatMap(r => Stream.emits(r.results.map(_.itemId))) .compile .to(Set) - qnext = q.copy(itemIds = items.some) + qnext = q.withCond(_.copy(itemIds = items.some)) res <- store.transact(QItem.searchStats(qnext)) } yield res @@ -253,7 +253,7 @@ object OFulltext { val sqlResult = search(q, batch) val fq = FtsQuery( ftsQ.query, - q.account.collective, + q.fix.account.collective, Set.empty, Set.empty, 0, diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index 46ec929d..724ee18e 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -138,7 +138,9 @@ object OItemSearch { val search = QItem.findItems(q, maxNoteLen: Int, batch) store .transact( - QItem.findItemsWithTags(q.account.collective, search).take(batch.limit.toLong) + QItem + .findItemsWithTags(q.fix.account.collective, search) + .take(batch.limit.toLong) ) .compile .toVector 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 e31b6fd7..43b4523a 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -72,13 +72,16 @@ object NotifyDueItemsTask { q = Query .empty(ctx.args.account) - .copy( - states = ItemState.validStates.toList, - tagsInclude = ctx.args.tagsInclude, - tagsExclude = ctx.args.tagsExclude, - dueDateFrom = ctx.args.daysBack.map(back => now - Duration.days(back.toLong)), - dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong)), - orderAsc = Some(_.dueDate) + .withOrder(orderAsc = _.dueDate) + .withCond( + _.copy( + states = ItemState.validStates.toList, + tagsInclude = ctx.args.tagsInclude, + tagsExclude = ctx.args.tagsExclude, + dueDateFrom = + ctx.args.daysBack.map(back => now - Duration.days(back.toLong)), + dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong)) + ) ) res <- ctx.store 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 4aebc8c8..ecd4cfc0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -145,31 +145,32 @@ trait Conversions { def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query = OItemSearch.Query( - account, - m.name, - if (m.inbox) Seq(ItemState.Created) - else ItemState.validStates.toList, - m.direction, - m.corrPerson, - m.corrOrg, - m.concPerson, - m.concEquip, - m.folder, - m.tagsInclude.map(Ident.unsafe), - m.tagsExclude.map(Ident.unsafe), - m.tagCategoriesInclude, - m.tagCategoriesExclude, - m.dateFrom, - m.dateUntil, - m.dueDateFrom, - m.dueDateUntil, - m.allNames, - m.itemSubset - .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet) - .filter(_.nonEmpty), - m.customValues.map(mkCustomValue), - m.source, - None + OItemSearch.Query.Fix(account, None), + OItemSearch.Query.QueryCond( + m.name, + if (m.inbox) Seq(ItemState.Created) + else ItemState.validStates.toList, + m.direction, + m.corrPerson, + m.corrOrg, + m.concPerson, + m.concEquip, + m.folder, + m.tagsInclude.map(Ident.unsafe), + m.tagsExclude.map(Ident.unsafe), + m.tagCategoriesInclude, + m.tagCategoriesExclude, + m.dateFrom, + m.dateUntil, + m.dueDateFrom, + m.dueDateUntil, + m.allNames, + m.itemSubset + .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet) + .filter(_.nonEmpty), + m.customValues.map(mkCustomValue), + m.source + ) ) def mkCustomValue(v: CustomFieldValue): OItemSearch.CustomValue = @@ -182,7 +183,7 @@ trait Conversions { ItemLightGroup(g._1, g._2.map(mkItemLight).toList) val gs = - groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) + groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0) ItemLightList(gs) } 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 751d5706..3d3d348b 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -117,7 +117,7 @@ object QItem { .map(nel => intersect(nel.map(singleSelect))) } - private def findItemsBase(q: Query, noteMaxLen: Int): Select = { + private def findItemsBase(q: Query.Fix, noteMaxLen: Int): Select = { object Attachs extends TableDef { val tableName = "attachs" val aliasName = "cta" @@ -128,7 +128,7 @@ object QItem { val coll = q.account.collective - val baseSelect = Select( + Select( select( i.id.s, i.name.s, @@ -172,27 +172,23 @@ object QItem { .leftJoin(pers1, pers1.pid === i.concPerson && pers1.cid === coll) .leftJoin(equip, equip.eid === i.concEquipment && equip.cid === coll), where( - i.cid === coll &&? Nel.fromList(q.states.toList).map(nel => i.state.in(nel)) && - or(i.folder.isNull, i.folder.in(QFolder.findMemberFolderIds(q.account))) + i.cid === coll && or( + i.folder.isNull, + i.folder.in(QFolder.findMemberFolderIds(q.account)) + ) ) ).distinct.orderBy( q.orderAsc .map(of => OrderBy.asc(coalesce(of(i).s, i.created.s).s)) .getOrElse(OrderBy.desc(coalesce(i.itemDate.s, i.created.s).s)) ) - - findCustomFieldValuesForColl(coll, q.customValues) match { - case Some(itemIds) => - baseSelect.changeWhere(c => c && i.id.in(itemIds)) - case None => - baseSelect - } } - def queryCondition(q: Query): Condition = + def queryCondition(coll: Ident, q: Query.QueryCond): Condition = Condition.unit &&? q.direction.map(d => i.incoming === d) &&? q.name.map(n => i.name.like(QueryWildcard.lower(n))) &&? + Nel.fromList(q.states.toList).map(nel => i.state.in(nel)) &&? q.allNames .map(QueryWildcard.lower) .map(n => @@ -221,15 +217,17 @@ object QItem { .map(subsel => i.id.in(subsel)) &&? TagItemName .itemsWithEitherTagOrCategory(q.tagsExclude, q.tagCategoryExcl) - .map(subsel => i.id.notIn(subsel)) + .map(subsel => i.id.notIn(subsel)) &&? + findCustomFieldValuesForColl(coll, q.customValues) + .map(itemIds => i.id.in(itemIds)) def findItems( q: Query, maxNoteLen: Int, batch: Batch ): Stream[ConnectionIO, ListItem] = { - val sql = findItemsBase(q, maxNoteLen) - .changeWhere(c => c && queryCondition(q)) + val sql = findItemsBase(q.fix, maxNoteLen) + .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) .limit(batch) .build logger.trace(s"List $batch items: $sql") @@ -251,10 +249,10 @@ object QItem { .innerJoin(i, i.id === ti.itemId) val tagCloud = - findItemsBase(q, 0).unwrap + findItemsBase(q.fix, 0).unwrap .withSelect(select(tag.all).append(count(i.id).as("num"))) .changeFrom(_.prepend(tagFrom)) - .changeWhere(c => c && queryCondition(q)) + .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) .groupBy(tag.tid) .build .query[TagCount] @@ -264,24 +262,24 @@ object QItem { // are not included they are fetched separately for { existing <- tagCloud - other <- RTag.findOthers(q.account.collective, existing.map(_.tag.tagId)) + other <- RTag.findOthers(q.fix.account.collective, existing.map(_.tag.tagId)) } yield existing ++ other.map(TagCount(_, 0)) } def searchCountSummary(q: Query): ConnectionIO[Int] = - findItemsBase(q, 0).unwrap + findItemsBase(q.fix, 0).unwrap .withSelect(Nel.of(count(i.id).as("num"))) - .changeWhere(c => c && queryCondition(q)) + .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) .build .query[Int] .unique def searchFolderSummary(q: Query): ConnectionIO[List[FolderCount]] = { val fu = RUser.as("fu") - findItemsBase(q, 0).unwrap + findItemsBase(q.fix, 0).unwrap .withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num"))) .changeFrom(_.innerJoin(fu, fu.uid === f.owner)) - .changeWhere(c => c && queryCondition(q)) + .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) .groupBy(f.id, f.name, f.owner, fu.login) .build .query[FolderCount] @@ -295,9 +293,9 @@ object QItem { .innerJoin(i, i.id === cv.itemId) val base = - findItemsBase(q, 0).unwrap + findItemsBase(q.fix, 0).unwrap .changeFrom(_.prepend(fieldJoin)) - .changeWhere(c => c && queryCondition(q)) + .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) .groupBy(GroupBy(cf.all)) val basicFields = Nel.of( @@ -374,7 +372,7 @@ object QItem { ) ) - val from = findItemsBase(q, maxNoteLen) + val from = findItemsBase(q.fix, maxNoteLen) .appendCte(cte) .appendSelect(Tids.weight.s) .changeFrom(_.innerJoin(Tids, Tids.itemId === i.id)) 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 0d68bdef..083884d6 100644 --- a/modules/store/src/main/scala/docspell/store/queries/Query.scala +++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala @@ -1,57 +1,70 @@ package docspell.store.queries import docspell.common._ +import docspell.store.qb.Column import docspell.store.records.RItem -case class Query( - account: AccountId, - name: Option[String], - states: Seq[ItemState], - direction: Option[Direction], - corrPerson: Option[Ident], - corrOrg: Option[Ident], - concPerson: Option[Ident], - concEquip: Option[Ident], - folder: Option[Ident], - tagsInclude: List[Ident], - tagsExclude: List[Ident], - tagCategoryIncl: List[String], - tagCategoryExcl: List[String], - dateFrom: Option[Timestamp], - dateTo: Option[Timestamp], - dueDateFrom: Option[Timestamp], - dueDateTo: Option[Timestamp], - allNames: Option[String], - itemIds: Option[Set[Ident]], - customValues: Seq[CustomValue], - source: Option[String], - orderAsc: Option[RItem.Table => docspell.store.qb.Column[_]] -) +case class Query(fix: Query.Fix, cond: Query.QueryCond) { + def withCond(f: Query.QueryCond => Query.QueryCond): Query = + copy(cond = f(cond)) + + def withOrder(orderAsc: RItem.Table => Column[_]): Query = + copy(fix = fix.copy(orderAsc = Some(orderAsc))) +} object Query { + + case class Fix(account: AccountId, orderAsc: Option[RItem.Table => Column[_]]) + + case class QueryCond( + name: Option[String], + states: Seq[ItemState], + direction: Option[Direction], + corrPerson: Option[Ident], + corrOrg: Option[Ident], + concPerson: Option[Ident], + concEquip: Option[Ident], + folder: Option[Ident], + tagsInclude: List[Ident], + tagsExclude: List[Ident], + tagCategoryIncl: List[String], + tagCategoryExcl: List[String], + dateFrom: Option[Timestamp], + dateTo: Option[Timestamp], + dueDateFrom: Option[Timestamp], + dueDateTo: Option[Timestamp], + allNames: Option[String], + itemIds: Option[Set[Ident]], + customValues: Seq[CustomValue], + source: Option[String] + ) + object QueryCond { + val empty = + QueryCond( + None, + Seq.empty, + None, + None, + None, + None, + None, + None, + Nil, + Nil, + Nil, + Nil, + None, + None, + None, + None, + None, + None, + Seq.empty, + None + ) + } + def empty(account: AccountId): Query = - Query( - account, - None, - Seq.empty, - None, - None, - None, - None, - None, - None, - Nil, - Nil, - Nil, - Nil, - None, - None, - None, - None, - None, - None, - Seq.empty, - None, - None - ) + Query(Fix(account, None), QueryCond.empty) + } diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala index 7bbfcb52..4bb5c57f 100644 --- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -23,7 +23,7 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { RAttachmentMeta.as("m") ) - test("migration") { + test("basic test") { val q = ItemQueryParser .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))") val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q) From e9ed998e3a184dc3232171176dcc6c1b06d58853 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Thu, 25 Feb 2021 22:40:17 +0100 Subject: [PATCH 05/33] Basic poc to search via custom query --- .../docspell/backend/ops/OFulltext.scala | 4 +-- .../docspell/backend/ops/OSimpleSearch.scala | 13 ++++++++ .../docspell/common/ItemQueryString.scala | 3 ++ .../joex/notify/NotifyDueItemsTask.scala | 4 +-- .../docspell/query/ItemQueryParser.scala | 2 +- .../restserver/conv/Conversions.scala | 4 +-- .../restserver/http4s/QueryParam.scala | 4 +++ .../restserver/routes/ItemRoutes.scala | 32 +++++++++++++++++-- .../scala/docspell/store/queries/QItem.scala | 28 ++++++++++++---- .../scala/docspell/store/queries/Query.scala | 26 +++++++++++---- 10 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala create mode 100644 modules/common/src/main/scala/docspell/common/ItemQueryString.scala 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) } From a80d73d5d204d7895cfe524db0be053262a05661 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Thu, 25 Feb 2021 22:42:47 +0100 Subject: [PATCH 06/33] Optimize imports --- modules/query/src/main/scala/docspell/query/ItemQuery.scala | 1 + .../src/main/scala/docspell/query/ItemQueryParser.scala | 4 ++-- .../src/main/scala/docspell/query/internal/AttrParser.scala | 1 + .../main/scala/docspell/query/internal/BasicParser.scala | 2 +- .../src/main/scala/docspell/query/internal/DateParser.scala | 1 + .../src/main/scala/docspell/query/internal/ExprParser.scala | 1 + .../main/scala/docspell/query/internal/OperatorParser.scala | 1 + .../scala/docspell/query/internal/SimpleExprParser.scala | 1 + .../src/main/scala/docspell/query/internal/StringUtil.scala | 2 +- .../main/scala/docspell/restserver/routes/ItemRoutes.scala | 2 ++ .../docspell/store/qb/generator/ItemQueryGenerator.scala | 6 ++++-- .../store/src/main/scala/docspell/store/queries/QItem.scala | 2 ++ 12 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala index 2a6759a2..435d2da2 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -1,6 +1,7 @@ package docspell.query import cats.data.{NonEmptyList => Nel} + import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr} /** A query evaluates to `true` or `false` given enough details about diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index 985c5be7..c2b9ffbe 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -1,9 +1,9 @@ package docspell.query -import docspell.query.internal.ExprParser - import scala.scalajs.js.annotation._ +import docspell.query.internal.ExprParser + @JSExportTopLevel("DsItemQueryParser") object ItemQueryParser { diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala index 6cd1c8b3..f9520a61 100644 --- a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala @@ -1,6 +1,7 @@ package docspell.query.internal import cats.parse.{Parser => P} + import docspell.query.ItemQuery.Attr object AttrParser { diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala index 36694b10..a3e13742 100644 --- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala @@ -1,7 +1,7 @@ package docspell.query.internal import cats.data.{NonEmptyList => Nel} -import cats.parse.{Parser0, Parser => P} +import cats.parse.{Parser => P, Parser0} object BasicParser { private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala index 43ae6221..49cc0b58 100644 --- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala @@ -2,6 +2,7 @@ package docspell.query.internal import cats.implicits._ import cats.parse.{Numbers, Parser => P} + import docspell.query.Date object DateParser { diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala index 7c7a6d6a..d9c7d313 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala @@ -1,6 +1,7 @@ package docspell.query.internal import cats.parse.{Parser => P} + import docspell.query.ItemQuery._ object ExprParser { diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala index 76a14e60..d9d2944d 100644 --- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala @@ -1,6 +1,7 @@ package docspell.query.internal import cats.parse.{Parser => P} + import docspell.query.ItemQuery._ object OperatorParser { diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala index 5865ad80..d10fc231 100644 --- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -1,6 +1,7 @@ package docspell.query.internal import cats.parse.{Parser => P} + import docspell.query.ItemQuery.Expr.CustomFieldMatch import docspell.query.ItemQuery._ diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala index 28a24872..fb81ce14 100644 --- a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala +++ b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala @@ -22,7 +22,7 @@ package docspell.query.internal // modified, from // https://github.com/typelevel/cats-parse/blob/e7a58ef15925358fbe7a4c0c1a204296e366a06c/bench/src/main/scala/cats/parse/bench/self.scala -import cats.parse.{Parser0 => P0, Parser => P} +import cats.parse.{Parser => P, Parser0 => P0} object StringUtil { 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 729f2f88..836927d0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -4,6 +4,7 @@ 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} @@ -18,6 +19,7 @@ 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._ diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 2f745dd8..561ad816 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -3,12 +3,14 @@ package docspell.store.qb.generator import java.time.{Instant, LocalDate} import cats.data.NonEmptyList + import docspell.common._ -import docspell.query.{Date, ItemQuery} import docspell.query.ItemQuery._ -import docspell.store.qb.{Operator => QOp, _} +import docspell.query.{Date, ItemQuery} import docspell.store.qb.DSL._ +import docspell.store.qb.{Operator => QOp, _} import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} + import doobie.util.Put object ItemQueryGenerator { 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 aa07a4cc..7461cc8b 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -5,6 +5,7 @@ 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 @@ -13,6 +14,7 @@ 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 From af73b59ec2ec21f6346cf2a8e0dab5f21cc4d8da Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sat, 27 Feb 2021 18:06:59 +0100 Subject: [PATCH 07/33] Parser improvements - default expressions into a and node - fix parsing string lists that end in whitespace - fix package names of internal classes --- build.sbt | 2 + .../main/scala/docspell/query/ItemQuery.scala | 26 ++++++---- .../docspell/query/ItemQueryParser.scala | 15 ++++-- .../docspell/query/internal/BasicParser.scala | 20 ++------ .../docspell/query/internal/DateParser.scala | 4 ++ .../docspell/query/internal/ExprParser.scala | 10 +++- .../docspell/query/internal/ExprUtil.scala | 50 +++++++++++++++++++ .../query/internal/OperatorParser.scala | 5 +- .../query/internal/SimpleExprParser.scala | 26 +++++++--- .../query/{ => internal}/AttrParserTest.scala | 2 +- .../{ => internal}/BasicParserTest.scala | 13 ++--- .../query/{ => internal}/DateParserTest.scala | 4 +- .../query/{ => internal}/ExprParserTest.scala | 29 +++++++++-- .../query/internal/ItemQueryParserTest.scala | 43 ++++++++++++++++ .../{ => internal}/OperatorParserTest.scala | 3 +- .../{ => internal}/SimpleExprParserTest.scala | 13 ++++- .../restserver/routes/ItemRoutes.scala | 8 ++- .../qb/generator/ItemQueryGenerator.scala | 26 +++++++--- 18 files changed, 229 insertions(+), 70 deletions(-) create mode 100644 modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala rename modules/query/src/test/scala/docspell/query/{ => internal}/AttrParserTest.scala (98%) rename modules/query/src/test/scala/docspell/query/{ => internal}/BasicParserTest.scala (69%) rename modules/query/src/test/scala/docspell/query/{ => internal}/DateParserTest.scala (94%) rename modules/query/src/test/scala/docspell/query/{ => internal}/ExprParserTest.scala (62%) create mode 100644 modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala rename modules/query/src/test/scala/docspell/query/{ => internal}/OperatorParserTest.scala (89%) rename modules/query/src/test/scala/docspell/query/{ => internal}/SimpleExprParserTest.scala (91%) diff --git a/build.sbt b/build.sbt index 8b31ed2f..351f40d7 100644 --- a/build.sbt +++ b/build.sbt @@ -269,6 +269,7 @@ val query = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .in(file("modules/query")) + .disablePlugins(RevolverPlugin) .settings(sharedSettings) .settings(testSettings) .settings( @@ -596,6 +597,7 @@ val website = project val root = project .in(file(".")) + .disablePlugins(RevolverPlugin) .settings(sharedSettings) .settings(noPublish) .settings( diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala index 435d2da2..46b9e051 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -14,10 +14,12 @@ import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr} final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String]) object ItemQuery { + val all = ItemQuery(Expr.Exists(Attr.ItemId), Some("")) sealed trait Operator object Operator { case object Eq extends Operator + case object Neq extends Operator case object Like extends Operator case object Gt extends Operator case object Lt extends Operator @@ -75,24 +77,26 @@ object ItemQuery { } object Expr { - case class AndExpr(expr: Nel[Expr]) extends Expr - case class OrExpr(expr: Nel[Expr]) extends Expr - case class NotExpr(expr: Expr) extends Expr { + final case class AndExpr(expr: Nel[Expr]) extends Expr + final case class OrExpr(expr: Nel[Expr]) extends Expr + final case class NotExpr(expr: Expr) extends Expr { override def negate: Expr = expr } - case class SimpleExpr(op: Operator, prop: Property) extends Expr - case class Exists(field: Attr) extends Expr - case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr + final case class SimpleExpr(op: Operator, prop: Property) extends Expr + final case class Exists(field: Attr) extends Expr + final case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr + final case class InDateExpr(attr: DateAttr, values: Nel[Date]) extends Expr - case class TagIdsMatch(op: TagOperator, tags: Nel[String]) extends Expr - case class TagsMatch(op: TagOperator, tags: Nel[String]) extends Expr - case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr + final case class TagIdsMatch(op: TagOperator, tags: Nel[String]) extends Expr + final case class TagsMatch(op: TagOperator, tags: Nel[String]) extends Expr + final case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr - case class CustomFieldMatch(name: String, op: Operator, value: String) extends Expr + final case class CustomFieldMatch(name: String, op: Operator, value: String) + extends Expr - case class Fulltext(query: String) extends Expr + final case class Fulltext(query: String) extends Expr } } diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index c2b9ffbe..0a7b8d81 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -3,17 +3,22 @@ package docspell.query import scala.scalajs.js.annotation._ import docspell.query.internal.ExprParser +import docspell.query.internal.ExprUtil @JSExportTopLevel("DsItemQueryParser") object ItemQueryParser { @JSExport def parse(input: String): Either[String, ItemQuery] = - ExprParser.exprParser - .parseAll(input.trim) - .left - .map(pe => s"Error parsing: '${input.trim}': $pe") - .map(expr => ItemQuery(expr, Some(input.trim))) + if (input.isEmpty) Right(ItemQuery.all) + else { + val in = if (input.charAt(0) == '(') input else s"(& $input )" + ExprParser + .parseQuery(in) + .left + .map(pe => s"Error parsing: '$input': $pe") + .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) + } def parseUnsafe(input: String): ItemQuery = parse(input).fold(sys.error, identity) diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala index a3e13742..ca2c3462 100644 --- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala @@ -7,17 +7,14 @@ object BasicParser { private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void val ws0: Parser0[Unit] = whitespace.rep0.void - val ws1: P[Unit] = whitespace.rep(1).void + val ws1: P[Unit] = whitespace.rep.void - private[this] val listSep: P[Unit] = - P.char(',').surroundedBy(BasicParser.ws0).void - - def rep[A](pa: P[A]): P[Nel[A]] = - pa.repSep(listSep) + val stringListSep: P[Unit] = + (ws0.with1.soft ~ P.char(',') ~ ws0).void private[this] val basicString: P[String] = P.charsWhile(c => - c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']' + c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']' && c != '(' && c != ')' ) private[this] val identChars: Set[Char] = @@ -38,14 +35,7 @@ object BasicParser { val singleString: P[String] = basicString.backtrack.orElse(StringUtil.quoted('"')) - val stringListValue: P[Nel[String]] = rep(singleString).with1 - .between(P.char('['), P.char(']')) - .backtrack - .orElse(rep(singleString)) - val stringOrMore: P[Nel[String]] = - stringListValue.backtrack.orElse( - singleString.map(v => Nel.of(v)) - ) + singleString.repSep(stringListSep) } diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala index 49cc0b58..25d1dc1c 100644 --- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala @@ -1,5 +1,6 @@ package docspell.query.internal +import cats.data.NonEmptyList import cats.implicits._ import cats.parse.{Numbers, Parser => P} @@ -39,4 +40,7 @@ object DateParser { val localDate: P[Date] = localDateFromString.backtrack.orElse(dateFromMillis) + val localDateOrMore: P[NonEmptyList[Date]] = + localDate.repSep(BasicParser.stringListSep) + } diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala index d9c7d313..693c087d 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala @@ -2,6 +2,7 @@ package docspell.query.internal import cats.parse.{Parser => P} +import docspell.query.ItemQuery import docspell.query.ItemQuery._ object ExprParser { @@ -18,8 +19,8 @@ object ExprParser { .between(BasicParser.parenOr, BasicParser.parenClose) .map(Expr.OrExpr.apply) - def not(inner: P[Expr]): P[Expr.NotExpr] = - (P.char('!') *> inner).map(Expr.NotExpr.apply) + def not(inner: P[Expr]): P[Expr] = + (P.char('!') *> inner).map(_.negate) val exprParser: P[Expr] = P.recursive[Expr] { recurse => @@ -28,4 +29,9 @@ object ExprParser { val notP = not(recurse) P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil) } + + def parseQuery(input: String): Either[P.Error, ItemQuery] = { + val p = BasicParser.ws0 *> exprParser <* (BasicParser.ws0 ~ P.end) + p.parseAll(input).map(expr => ItemQuery(expr, Some(input))) + } } diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala new file mode 100644 index 00000000..082f2b28 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -0,0 +1,50 @@ +package docspell.query.internal + +import docspell.query.ItemQuery.Expr._ +import docspell.query.ItemQuery._ + +object ExprUtil { + + /** Does some basic transformation, like unfolding deeply nested and + * trees containing one value etc. + */ + def reduce(expr: Expr): Expr = + expr match { + case AndExpr(inner) => + if (inner.tail.isEmpty) reduce(inner.head) + else AndExpr(inner.map(reduce)) + + case OrExpr(inner) => + if (inner.tail.isEmpty) reduce(inner.head) + else OrExpr(inner.map(reduce)) + + case NotExpr(inner) => + inner match { + case NotExpr(inner2) => + reduce(inner2) + case _ => + expr + } + + case InExpr(_, _) => + expr + + case InDateExpr(_, _) => + expr + + case TagsMatch(_, _) => + expr + case TagIdsMatch(_, _) => + expr + case Exists(_) => + expr + case Fulltext(_) => + expr + case SimpleExpr(_, _) => + expr + case TagCategoryMatch(_, _) => + expr + case CustomFieldMatch(_, _, _) => + expr + } +} diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala index d9d2944d..f130ed8d 100644 --- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala @@ -8,6 +8,9 @@ object OperatorParser { private[this] val Eq: P[Operator] = P.char('=').void.map(_ => Operator.Eq) + private[this] val Neq: P[Operator] = + P.string("!=").void.map(_ => Operator.Neq) + private[this] val Like: P[Operator] = P.char(':').void.map(_ => Operator.Like) @@ -24,7 +27,7 @@ object OperatorParser { P.string("<=").map(_ => Operator.Lte) val op: P[Operator] = - P.oneOf(List(Like, Eq, Gte, Lte, Gt, Lt)) + P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt)) private[this] val anyOp: P[TagOperator] = P.char(':').map(_ => TagOperator.AnyMatch) diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala index d10fc231..3b23ceea 100644 --- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -10,15 +10,29 @@ object SimpleExprParser { private[this] val op: P[Operator] = OperatorParser.op.surroundedBy(BasicParser.ws0) - val stringExpr: P[Expr.SimpleExpr] = - (AttrParser.stringAttr ~ op ~ BasicParser.singleString).map { - case ((attr, op), value) => + private[this] val inOp: P[Unit] = + P.string("~=").surroundedBy(BasicParser.ws0) + + private[this] val inOrOpStr = + P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore) + + private[this] val inOrOpDate = + P.eitherOr(op ~ DateParser.localDate, inOp *> DateParser.localDateOrMore) + + val stringExpr: P[Expr] = + (AttrParser.stringAttr ~ inOrOpStr).map { + case (attr, Right((op, value))) => Expr.SimpleExpr(op, Property.StringProperty(attr, value)) + case (attr, Left(values)) => + Expr.InExpr(attr, values) } - val dateExpr: P[Expr.SimpleExpr] = - (AttrParser.dateAttr ~ op ~ DateParser.localDate).map { case ((attr, op), value) => - Expr.SimpleExpr(op, Property.DateProperty(attr, value)) + val dateExpr: P[Expr] = + (AttrParser.dateAttr ~ inOrOpDate).map { + case (attr, Right((op, value))) => + Expr.SimpleExpr(op, Property.DateProperty(attr, value)) + case (attr, Left(values)) => + Expr.InDateExpr(attr, values) } val existsExpr: P[Expr.Exists] = diff --git a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala similarity index 98% rename from modules/query/src/test/scala/docspell/query/AttrParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala index b79c1103..0634c83e 100644 --- a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala @@ -1,4 +1,4 @@ -package docspell.query +package docspell.query.internal import docspell.query.ItemQuery.Attr import docspell.query.internal.AttrParser diff --git a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala similarity index 69% rename from modules/query/src/test/scala/docspell/query/BasicParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala index 80a06d18..c272298a 100644 --- a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala @@ -1,4 +1,4 @@ -package docspell.query +package docspell.query.internal import minitest._ import cats.data.{NonEmptyList => Nel} @@ -14,13 +14,7 @@ object BasicParserTest extends SimpleTestSuite { } test("string list values") { - val p = BasicParser.stringListValue - assertEquals(p.parseAll("[ab,cd]"), Right(Nel.of("ab", "cd"))) - assertEquals(p.parseAll("[\"ab 12\",cd]"), Right(Nel.of("ab 12", "cd"))) - assertEquals( - p.parseAll("[\"ab, 12\",cd]"), - Right(Nel.of("ab, 12", "cd")) - ) + val p = BasicParser.stringOrMore assertEquals(p.parseAll("ab,cd,123"), Right(Nel.of("ab", "cd", "123"))) assertEquals(p.parseAll("a,b"), Right(Nel.of("a", "b"))) assert(p.parseAll("[a,b").isLeft) @@ -30,6 +24,7 @@ object BasicParserTest extends SimpleTestSuite { val p = BasicParser.stringOrMore assertEquals(p.parseAll("abcde"), Right(Nel.of("abcde"))) assertEquals(p.parseAll(""""a,b,c""""), Right(Nel.of("a,b,c"))) - assertEquals(p.parseAll("[a,b,c]"), Right(Nel.of("a", "b", "c"))) + + assertEquals(p.parse("a, b, c "), Right((" ", Nel.of("a", "b", "c")))) } } diff --git a/modules/query/src/test/scala/docspell/query/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala similarity index 94% rename from modules/query/src/test/scala/docspell/query/DateParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala index ca909a97..281fc470 100644 --- a/modules/query/src/test/scala/docspell/query/DateParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala @@ -1,7 +1,7 @@ -package docspell.query +package docspell.query.internal -import docspell.query.internal.DateParser import minitest._ +import docspell.query.Date object DateParserTest extends SimpleTestSuite { diff --git a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala similarity index 62% rename from modules/query/src/test/scala/docspell/query/ExprParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala index 304fd6d0..f918e361 100644 --- a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala @@ -1,8 +1,7 @@ -package docspell.query +package docspell.query.internal import docspell.query.ItemQuery._ -import docspell.query.SimpleExprParserTest.stringExpr -import docspell.query.internal.ExprParser +import docspell.query.internal.SimpleExprParserTest.stringExpr import minitest._ import cats.data.{NonEmptyList => Nel} @@ -45,4 +44,28 @@ object ExprParserTest extends SimpleTestSuite { ) ) } + + test("tag list inside and/or") { + val p = ExprParser.exprParser + assertEquals( + p.parseAll("(& tag:a,b,c)"), + Right( + Expr.AndExpr( + Nel.of( + Expr.TagsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c")) + ) + ) + ) + ) + assertEquals( + p.parseAll("(& tag:a,b,c )"), + Right( + Expr.AndExpr( + Nel.of( + Expr.TagsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c")) + ) + ) + ) + ) + } } diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala new file mode 100644 index 00000000..4074748c --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala @@ -0,0 +1,43 @@ +package docspell.query.internal + +import minitest._ +import docspell.query.ItemQueryParser +import docspell.query.ItemQuery + +object ItemQueryParserTest extends SimpleTestSuite { + + test("reduce ands") { + val q = ItemQueryParser.parseUnsafe("(&(&(&(& name:hello))))") + val expr = ExprUtil.reduce(q.expr) + assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr) + } + + test("reduce ors") { + val q = ItemQueryParser.parseUnsafe("(|(|(|(| name:hello))))") + val expr = ExprUtil.reduce(q.expr) + assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr) + } + + test("reduce and/or") { + val q = ItemQueryParser.parseUnsafe("(|(&(&(| name:hello))))") + val expr = ExprUtil.reduce(q.expr) + assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr) + } + + test("reduce inner and/or") { + val q = ItemQueryParser.parseUnsafe("(& name:hello (| name:world))") + val expr = ExprUtil.reduce(q.expr) + assertEquals(expr, ItemQueryParser.parseUnsafe("(& name:hello name:world)").expr) + } + + test("omit and-parens around root structure") { + val q = ItemQueryParser.parseUnsafe("name:hello date>2020-02-02") + val expect = ItemQueryParser.parseUnsafe("(& name:hello date>2020-02-02 )") + assertEquals(expect, q) + } + + test("return all if query is empty") { + val q = ItemQueryParser.parseUnsafe("") + assertEquals(ItemQuery.all, q) + } +} diff --git a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala similarity index 89% rename from modules/query/src/test/scala/docspell/query/OperatorParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala index 94e9ea35..b451289c 100644 --- a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala @@ -1,4 +1,4 @@ -package docspell.query +package docspell.query.internal import minitest._ import docspell.query.ItemQuery.{Operator, TagOperator} @@ -8,6 +8,7 @@ object OperatorParserTest extends SimpleTestSuite { test("operator values") { val p = OperatorParser.op assertEquals(p.parseAll("="), Right(Operator.Eq)) + assertEquals(p.parseAll("!="), Right(Operator.Neq)) assertEquals(p.parseAll(":"), Right(Operator.Like)) assertEquals(p.parseAll("<"), Right(Operator.Lt)) assertEquals(p.parseAll(">"), Right(Operator.Gt)) diff --git a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala similarity index 91% rename from modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala rename to modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala index 298e9c59..f3ee0ae5 100644 --- a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala @@ -1,9 +1,9 @@ -package docspell.query +package docspell.query.internal import cats.data.{NonEmptyList => Nel} import docspell.query.ItemQuery._ -import docspell.query.internal.SimpleExprParser import minitest._ +import docspell.query.Date object SimpleExprParserTest extends SimpleTestSuite { @@ -29,6 +29,11 @@ object SimpleExprParserTest extends SimpleTestSuite { p.parseAll("conc.pers.id=Aaiet-aied"), Right(stringExpr(Operator.Eq, Attr.Concerning.PersonId, "Aaiet-aied")) ) + assert(p.parseAll("conc.pers.id=Aaiet,aied").isLeft) + assertEquals( + p.parseAll("name~=hello,world"), + Right(Expr.InExpr(Attr.ItemName, Nel.of("hello", "world"))) + ) } test("date expr") { @@ -41,6 +46,10 @@ object SimpleExprParserTest extends SimpleTestSuite { p.parseAll("due<2021-03-14"), Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14))) ) + assertEquals( + p.parseAll("due~=2021-03-14,2021-03-13"), + Right(Expr.InDateExpr(Attr.DueDate, Nel.of(ld(2021, 3, 14), ld(2021, 3, 13)))) + ) } test("exists expr") { 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 836927d0..510273a0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -51,13 +51,11 @@ object ItemRoutes { offset ) => val query = - q.map(ItemQueryParser.parse) match { - case Some(Right(q)) => + ItemQueryParser.parse(q.getOrElse("")) match { + case Right(q) => Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q))) - case Some(Left(err)) => + case 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) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 561ad816..2e2b3233 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -12,6 +12,7 @@ import docspell.store.qb.{Operator => QOp, _} import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} import doobie.util.Put +import docspell.store.queries.QueryWildcard object ItemQueryGenerator { @@ -80,18 +81,13 @@ object ItemQueryGenerator { val col = stringColumn(tables)(attr) op match { case Operator.Like => - Condition.CompareVal(col, makeOp(op), value.toLowerCase) + Condition.CompareVal(col, makeOp(op), QueryWildcard.lower(value)) case _ => Condition.CompareVal(col, makeOp(op), value) } case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) => - val dt = value match { - case Date.Local(year, month, day) => - Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) - case Date.Millis(ms) => - Timestamp(Instant.ofEpochMilli(ms)) - } + val dt = dateToTimestamp(value) val col = timestampColumn(tables)(attr) Condition.CompareVal(col, makeOp(op), dt) @@ -100,6 +96,12 @@ object ItemQueryGenerator { if (values.tail.isEmpty) col === values.head else col.in(values) + case Expr.InDateExpr(attr, values) => + val col = timestampColumn(tables)(attr) + val dts = values.map(dateToTimestamp) + if (values.tail.isEmpty) col === dts.head + else col.in(dts) + case Expr.TagIdsMatch(op, tags) => val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) NonEmptyList @@ -140,6 +142,14 @@ object ItemQueryGenerator { Condition.unit } + private def dateToTimestamp(date: Date): Timestamp = + date match { + case Date.Local(year, month, day) => + Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) + case Date.Millis(ms) => + Timestamp(Instant.ofEpochMilli(ms)) + } + private def anyColumn(tables: Tables)(attr: Attr): Column[_] = attr match { case s: Attr.StringAttr => @@ -177,6 +187,8 @@ object ItemQueryGenerator { operator match { case Operator.Eq => QOp.Eq + case Operator.Neq => + QOp.Neq case Operator.Like => QOp.LowerLike case Operator.Gt => From 9013d9264ea013f30bd84616b40ce4a92b4e2e43 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 28 Feb 2021 16:11:25 +0100 Subject: [PATCH 08/33] Add more convenient date parsers and some basic macros --- build.sbt | 4 +- .../docspell/backend/ops/OFulltext.scala | 6 +- .../docspell/backend/ops/OItemSearch.scala | 44 ++++++---- .../joex/notify/NotifyDueItemsTask.scala | 6 +- .../src/main/scala/docspell/query/Date.scala | 32 +++++-- .../main/scala/docspell/query/ItemQuery.scala | 23 +++++ .../docspell/query/ItemQueryParser.scala | 2 +- .../docspell/query/internal/AttrParser.scala | 34 ++++---- .../docspell/query/internal/BasicParser.scala | 6 ++ .../docspell/query/internal/DateParser.scala | 85 ++++++++++++++++--- .../docspell/query/internal/ExprParser.scala | 9 +- .../docspell/query/internal/ExprUtil.scala | 10 +++ .../docspell/query/internal/MacroParser.scala | 65 ++++++++++++++ .../query/internal/OperatorParser.scala | 18 ++-- .../query/internal/SimpleExprParser.scala | 12 ++- .../query/internal/DateParserTest.scala | 51 +++++++++-- .../query/internal/MacroParserTest.scala | 19 +++++ .../query/internal/SimpleExprParserTest.scala | 27 +++++- .../restserver/src/main/resources/logback.xml | 1 + .../qb/generator/ItemQueryGenerator.scala | 62 ++++++++++---- .../scala/docspell/store/queries/QItem.scala | 39 +++++---- .../generator/ItemQueryGeneratorTest.scala | 28 +----- project/Dependencies.scala | 4 + 23 files changed, 445 insertions(+), 142 deletions(-) create mode 100644 modules/query/src/main/scala/docspell/query/internal/MacroParser.scala create mode 100644 modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala diff --git a/build.sbt b/build.sbt index 351f40d7..884234f3 100644 --- a/build.sbt +++ b/build.sbt @@ -275,7 +275,9 @@ val query = .settings( name := "docspell-query", libraryDependencies += - Dependencies.catsParseJS.value + Dependencies.catsParseJS.value, + libraryDependencies += + Dependencies.scalaJavaTime.value ) .jsSettings( Test / fork := false 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 52197e17..0dd2348c 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala @@ -159,13 +159,14 @@ object OFulltext { for { folder <- store.transact(QFolder.getMemberFolders(account)) + now <- Timestamp.current[F] itemIds <- fts .searchAll(fq.withFolders(folder)) .flatMap(r => Stream.emits(r.results.map(_.itemId))) .compile .to(Set) q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some)) - res <- store.transact(QItem.searchStats(q)) + res <- store.transact(QItem.searchStats(now.toUtcDate)(q)) } yield res } @@ -221,7 +222,8 @@ object OFulltext { .compile .to(Set) qnext = q.withFix(_.copy(itemIds = items.some)) - res <- store.transact(QItem.searchStats(qnext)) + now <- Timestamp.current[F] + res <- store.transact(QItem.searchStats(now.toUtcDate)(qnext)) } yield res // Helper diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index 724ee18e..a74e451a 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -127,27 +127,39 @@ object OItemSearch { .map(opt => opt.flatMap(_.filterCollective(collective))) def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] = - store - .transact(QItem.findItems(q, maxNoteLen, batch).take(batch.limit.toLong)) - .compile - .toVector + Timestamp + .current[F] + .map(_.toUtcDate) + .flatMap { today => + store + .transact( + QItem.findItems(q, today, maxNoteLen, batch).take(batch.limit.toLong) + ) + .compile + .toVector + } def findItemsWithTags( maxNoteLen: Int - )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = { - val search = QItem.findItems(q, maxNoteLen: Int, batch) - store - .transact( - QItem - .findItemsWithTags(q.fix.account.collective, search) - .take(batch.limit.toLong) - ) - .compile - .toVector - } + )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = + for { + now <- Timestamp.current[F] + search = QItem.findItems(q, now.toUtcDate, maxNoteLen: Int, batch) + res <- store + .transact( + QItem + .findItemsWithTags(q.fix.account.collective, search) + .take(batch.limit.toLong) + ) + .compile + .toVector + } yield res def findItemsSummary(q: Query): F[SearchSummary] = - store.transact(QItem.searchStats(q)) + Timestamp + .current[F] + .map(_.toUtcDate) + .flatMap(today => store.transact(QItem.searchStats(today)(q))) def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] = store 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 1000d630..4ce26507 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -85,7 +85,11 @@ object NotifyDueItemsTask { ) res <- ctx.store - .transact(QItem.findItems(q, 0, Batch.limit(maxItems)).take(maxItems.toLong)) + .transact( + QItem + .findItems(q, now.toUtcDate, 0, Batch.limit(maxItems)) + .take(maxItems.toLong) + ) .compile .toVector } yield res diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/src/main/scala/docspell/query/Date.scala index 30e9dabb..21ce9b35 100644 --- a/modules/query/src/main/scala/docspell/query/Date.scala +++ b/modules/query/src/main/scala/docspell/query/Date.scala @@ -1,14 +1,32 @@ package docspell.query -sealed trait Date -object Date { - def apply(y: Int, m: Int, d: Int): Date = - Local(y, m, d) +import java.time.LocalDate +import java.time.Period - def apply(ms: Long): Date = +import cats.implicits._ + +sealed trait Date + +object Date { + def apply(y: Int, m: Int, d: Int): Either[Throwable, DateLiteral] = + Either.catchNonFatal(Local(LocalDate.of(y, m, d))) + + def apply(ms: Long): DateLiteral = Millis(ms) - final case class Local(year: Int, month: Int, day: Int) extends Date + sealed trait DateLiteral extends Date - final case class Millis(ms: Long) extends Date + final case class Local(date: LocalDate) extends DateLiteral + + final case class Millis(ms: Long) extends DateLiteral + + case object Today extends DateLiteral + + sealed trait CalcDirection + object CalcDirection { + case object Plus extends CalcDirection + case object Minus extends CalcDirection + } + + case class Calc(date: DateLiteral, calc: CalcDirection, period: Period) extends Date } diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala index 46b9e051..ec824ecb 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -40,6 +40,7 @@ object ItemQuery { case object ItemName extends StringAttr case object ItemSource extends StringAttr + case object ItemNotes extends StringAttr case object ItemId extends StringAttr case object Date extends DateAttr case object DueDate extends DateAttr @@ -69,6 +70,11 @@ object ItemQuery { final case class StringProperty(attr: StringAttr, value: String) extends Property final case class DateProperty(attr: DateAttr, value: Date) extends Property + def apply(sa: StringAttr, value: String): Property = + StringProperty(sa, value) + + def apply(da: DateAttr, value: Date): Property = + DateProperty(da, value) } sealed trait Expr { @@ -88,6 +94,8 @@ object ItemQuery { final case class Exists(field: Attr) extends Expr final case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr final case class InDateExpr(attr: DateAttr, values: Nel[Date]) extends Expr + final case class InboxExpr(inbox: Boolean) extends Expr + final case class DirectionExpr(incoming: Boolean) extends Expr final case class TagIdsMatch(op: TagOperator, tags: Nel[String]) extends Expr final case class TagsMatch(op: TagOperator, tags: Nel[String]) extends Expr @@ -97,6 +105,21 @@ object ItemQuery { extends Expr final case class Fulltext(query: String) extends Expr + + def or(expr0: Expr, exprs: Expr*): OrExpr = + OrExpr(Nel.of(expr0, exprs: _*)) + + def and(expr0: Expr, exprs: Expr*): AndExpr = + AndExpr(Nel.of(expr0, exprs: _*)) + + def string(op: Operator, attr: StringAttr, value: String): SimpleExpr = + SimpleExpr(op, Property(attr, value)) + + def like(attr: StringAttr, value: String): SimpleExpr = + string(Operator.Like, attr, value) + + def date(op: Operator, attr: DateAttr, value: Date): SimpleExpr = + SimpleExpr(op, Property(attr, value)) } } diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index 0a7b8d81..cea6c09e 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -16,7 +16,7 @@ object ItemQueryParser { ExprParser .parseQuery(in) .left - .map(pe => s"Error parsing: '$input': $pe") + .map(pe => s"Error parsing: '$input': $pe") //TODO .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) } diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala index f9520a61..d5289c67 100644 --- a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala @@ -7,57 +7,60 @@ import docspell.query.ItemQuery.Attr object AttrParser { val name: P[Attr.StringAttr] = - P.ignoreCase("name").map(_ => Attr.ItemName) + P.ignoreCase("name").as(Attr.ItemName) val source: P[Attr.StringAttr] = - P.ignoreCase("source").map(_ => Attr.ItemSource) + P.ignoreCase("source").as(Attr.ItemSource) val id: P[Attr.StringAttr] = - P.ignoreCase("id").map(_ => Attr.ItemId) + P.ignoreCase("id").as(Attr.ItemId) val date: P[Attr.DateAttr] = - P.ignoreCase("date").map(_ => Attr.Date) + P.ignoreCase("date").as(Attr.Date) + + val notes: P[Attr.StringAttr] = + P.ignoreCase("notes").as(Attr.ItemNotes) val dueDate: P[Attr.DateAttr] = - P.stringIn(List("dueDate", "due", "due-date")).map(_ => Attr.DueDate) + P.stringIn(List("dueDate", "due", "due-date")).as(Attr.DueDate) val corrOrgId: P[Attr.StringAttr] = P.stringIn(List("correspondent.org.id", "corr.org.id")) - .map(_ => Attr.Correspondent.OrgId) + .as(Attr.Correspondent.OrgId) val corrOrgName: P[Attr.StringAttr] = P.stringIn(List("correspondent.org.name", "corr.org.name")) - .map(_ => Attr.Correspondent.OrgName) + .as(Attr.Correspondent.OrgName) val corrPersId: P[Attr.StringAttr] = P.stringIn(List("correspondent.person.id", "corr.pers.id")) - .map(_ => Attr.Correspondent.PersonId) + .as(Attr.Correspondent.PersonId) val corrPersName: P[Attr.StringAttr] = P.stringIn(List("correspondent.person.name", "corr.pers.name")) - .map(_ => Attr.Correspondent.PersonName) + .as(Attr.Correspondent.PersonName) val concPersId: P[Attr.StringAttr] = P.stringIn(List("concerning.person.id", "conc.pers.id")) - .map(_ => Attr.Concerning.PersonId) + .as(Attr.Concerning.PersonId) val concPersName: P[Attr.StringAttr] = P.stringIn(List("concerning.person.name", "conc.pers.name")) - .map(_ => Attr.Concerning.PersonName) + .as(Attr.Concerning.PersonName) val concEquipId: P[Attr.StringAttr] = P.stringIn(List("concerning.equip.id", "conc.equip.id")) - .map(_ => Attr.Concerning.EquipId) + .as(Attr.Concerning.EquipId) val concEquipName: P[Attr.StringAttr] = P.stringIn(List("concerning.equip.name", "conc.equip.name")) - .map(_ => Attr.Concerning.EquipName) + .as(Attr.Concerning.EquipName) val folderId: P[Attr.StringAttr] = - P.ignoreCase("folder.id").map(_ => Attr.Folder.FolderId) + P.ignoreCase("folder.id").as(Attr.Folder.FolderId) val folderName: P[Attr.StringAttr] = - P.ignoreCase("folder").map(_ => Attr.Folder.FolderName) + P.ignoreCase("folder").as(Attr.Folder.FolderName) val dateAttr: P[Attr.DateAttr] = P.oneOf(List(date, dueDate)) @@ -68,6 +71,7 @@ object AttrParser { name, source, id, + notes, corrOrgId, corrOrgName, corrPersId, diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala index ca2c3462..c4edd070 100644 --- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala @@ -38,4 +38,10 @@ object BasicParser { val stringOrMore: P[Nel[String]] = singleString.repSep(stringListSep) + val bool: P[Boolean] = { + val trueP = P.stringIn(List("yes", "true", "Yes", "True")).as(true) + val falseP = P.stringIn(List("no", "false", "No", "False")).as(false) + trueP | falseP + } + } diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala index 25d1dc1c..33e0d556 100644 --- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala @@ -1,7 +1,8 @@ package docspell.query.internal -import cats.data.NonEmptyList -import cats.implicits._ +import java.time.Period + +import cats.data.{NonEmptyList => Nel} import cats.parse.{Numbers, Parser => P} import docspell.query.Date @@ -26,21 +27,79 @@ object DateParser { digits2.filter(n => n >= 1 && n <= 31) private val dateSep: P[Unit] = - P.anyChar.void + P.charIn('-', '/').void - val localDateFromString: P[Date] = - ((digits4 <* dateSep) ~ (month <* dateSep) ~ day).mapFilter { - case ((year, month), day) => - Either.catchNonFatal(Date(year, month, day)).toOption + private val dateString: P[((Int, Option[Int]), Option[Int])] = + digits4 ~ (dateSep *> month).? ~ (dateSep *> day).? + + private[internal] val dateFromString: P[Date.DateLiteral] = + dateString.mapFilter { case ((year, month), day) => + Date(year, month.getOrElse(1), day.getOrElse(1)).toOption } - val dateFromMillis: P[Date] = - longParser.map(Date.apply) + private[internal] val dateFromMillis: P[Date.DateLiteral] = + P.string("ms") *> longParser.map(Date.apply) - val localDate: P[Date] = - localDateFromString.backtrack.orElse(dateFromMillis) + private val dateFromToday: P[Date.DateLiteral] = + P.string("today").as(Date.Today) - val localDateOrMore: P[NonEmptyList[Date]] = - localDate.repSep(BasicParser.stringListSep) + val dateLiteral: P[Date.DateLiteral] = + P.oneOf(List(dateFromString, dateFromToday, dateFromMillis)) + // val dateLiteralOrMore: P[NonEmptyList[Date.DateLiteral]] = + // dateLiteral.repSep(BasicParser.stringListSep) + + val dateCalcDirection: P[Date.CalcDirection] = + P.oneOf( + List( + P.char('+').as(Date.CalcDirection.Plus), + P.char('-').as(Date.CalcDirection.Minus) + ) + ) + + def periodPart(unitSuffix: Char, f: Int => Period): P[Period] = + ((Numbers.nonZeroDigit ~ Numbers.digits0).void.string.soft <* P.ignoreCaseChar( + unitSuffix + )) + .map(n => f(n.toInt)) + + private[this] val periodMonths: P[Period] = + periodPart('m', n => Period.ofMonths(n)) + + private[this] val periodDays: P[Period] = + periodPart('d', n => Period.ofDays(n)) + + val period: P[Period] = + periodDays.eitherOr(periodMonths).map(_.fold(identity, identity)) + + val periods: P[Period] = + period.rep.map(_.reduceLeft((p0, p1) => p0.plus(p1))) + + val dateRange: P[(Date, Date)] = + ((dateLiteral <* P.char(';')) ~ dateCalcDirection.eitherOr(P.char('/')) ~ period) + .map { case ((date, calc), period) => + calc match { + case Right(Date.CalcDirection.Plus) => + (date, Date.Calc(date, Date.CalcDirection.Plus, period)) + case Right(Date.CalcDirection.Minus) => + (Date.Calc(date, Date.CalcDirection.Minus, period), date) + case Left(_) => + ( + Date.Calc(date, Date.CalcDirection.Minus, period), + Date.Calc(date, Date.CalcDirection.Plus, period) + ) + } + } + + val date: P[Date] = + (dateLiteral ~ (P.char(';') *> dateCalcDirection ~ period).?).map { + case (date, Some((c, p))) => + Date.Calc(date, c, p) + + case (date, None) => + date + } + + val dateOrMore: P[Nel[Date]] = + date.repSep(BasicParser.stringListSep) } diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala index 693c087d..329ec030 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala @@ -24,10 +24,11 @@ object ExprParser { val exprParser: P[Expr] = P.recursive[Expr] { recurse => - val andP = and(recurse) - val orP = or(recurse) - val notP = not(recurse) - P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil) + val andP = and(recurse) + val orP = or(recurse) + val notP = not(recurse) + val macros = MacroParser.all + P.oneOf(SimpleExprParser.simpleExpr :: macros :: andP :: orP :: notP :: Nil) } def parseQuery(input: String): Either[P.Error, ItemQuery] = { diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala index 082f2b28..75007d11 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -22,10 +22,20 @@ object ExprUtil { inner match { case NotExpr(inner2) => reduce(inner2) + case InboxExpr(flag) => + InboxExpr(!flag) + case DirectionExpr(flag) => + DirectionExpr(!flag) case _ => expr } + case DirectionExpr(_) => + expr + + case InboxExpr(_) => + expr + case InExpr(_, _) => expr diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala new file mode 100644 index 00000000..2a2c2d41 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala @@ -0,0 +1,65 @@ +package docspell.query.internal + +import cats.parse.{Parser => P} + +import docspell.query.ItemQuery._ + +object MacroParser { + private[this] val macroDef: P[String] = + P.char('$') *> BasicParser.identParser <* P.char(':') + + def parser[A](macros: Map[String, P[A]]): P[A] = { + val p: P[P[A]] = macroDef.map { name => + macros + .get(name) + .getOrElse(P.failWith(s"Unknown macro: $name")) + } + + val px = (p ~ P.index ~ BasicParser.singleString).map { case ((pexpr, index), str) => + pexpr + .parseAll(str) + .left + .map(err => err.copy(failedAtOffset = err.failedAtOffset + index)) + } + + P.select(px)(P.Fail) + } + + // --- definitions of available macros + + /** Expands in an OR expression that matches name fields of item and + * correspondent/concerning metadata. + */ + val names: P[Expr] = + P.string(P.anyChar.rep.void).map { input => + Expr.or( + Expr.like(Attr.ItemName, input), + Expr.like(Attr.ItemNotes, input), + Expr.like(Attr.Correspondent.OrgName, input), + Expr.like(Attr.Correspondent.PersonName, input), + Expr.like(Attr.Concerning.PersonName, input), + Expr.like(Attr.Concerning.EquipName, input) + ) + } + + def dateRange(attr: Attr.DateAttr): P[Expr] = + DateParser.dateRange.map { case (left, right) => + Expr.and( + Expr.date(Operator.Gte, attr, left), + Expr.date(Operator.Lte, attr, right) + ) + } + + // --- all macro parser + + val allMacros: Map[String, P[Expr]] = + Map( + "names" -> names, + "datein" -> dateRange(Attr.Date), + "duein" -> dateRange(Attr.DueDate) + ) + + val all: P[Expr] = + parser(allMacros) + +} diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala index f130ed8d..de93fb4f 100644 --- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala @@ -6,34 +6,34 @@ import docspell.query.ItemQuery._ object OperatorParser { private[this] val Eq: P[Operator] = - P.char('=').void.map(_ => Operator.Eq) + P.char('=').as(Operator.Eq) private[this] val Neq: P[Operator] = - P.string("!=").void.map(_ => Operator.Neq) + P.string("!=").as(Operator.Neq) private[this] val Like: P[Operator] = - P.char(':').void.map(_ => Operator.Like) + P.char(':').as(Operator.Like) private[this] val Gt: P[Operator] = - P.char('>').void.map(_ => Operator.Gt) + P.char('>').as(Operator.Gt) private[this] val Lt: P[Operator] = - P.char('<').void.map(_ => Operator.Lt) + P.char('<').as(Operator.Lt) private[this] val Gte: P[Operator] = - P.string(">=").map(_ => Operator.Gte) + P.string(">=").as(Operator.Gte) private[this] val Lte: P[Operator] = - P.string("<=").map(_ => Operator.Lte) + P.string("<=").as(Operator.Lte) val op: P[Operator] = P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt)) private[this] val anyOp: P[TagOperator] = - P.char(':').map(_ => TagOperator.AnyMatch) + P.char(':').as(TagOperator.AnyMatch) private[this] val allOp: P[TagOperator] = - P.char('=').map(_ => TagOperator.AllMatch) + P.char('=').as(TagOperator.AllMatch) val tagOp: P[TagOperator] = P.oneOf(List(anyOp, allOp)) diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala index 3b23ceea..f76fe947 100644 --- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -17,7 +17,7 @@ object SimpleExprParser { P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore) private[this] val inOrOpDate = - P.eitherOr(op ~ DateParser.localDate, inOp *> DateParser.localDateOrMore) + P.eitherOr(op ~ DateParser.date, inOp *> DateParser.dateOrMore) val stringExpr: P[Expr] = (AttrParser.stringAttr ~ inOrOpStr).map { @@ -65,6 +65,12 @@ object SimpleExprParser { CustomFieldMatch(name, op, value) } + val inboxExpr: P[Expr.InboxExpr] = + (P.string("inbox:") *> BasicParser.bool).map(Expr.InboxExpr.apply) + + val dirExpr: P[Expr.DirectionExpr] = + (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply) + val simpleExpr: P[Expr] = P.oneOf( List( @@ -75,7 +81,9 @@ object SimpleExprParser { tagIdExpr, tagExpr, catExpr, - customFieldExpr + customFieldExpr, + inboxExpr, + dirExpr ) ) } diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala index 281fc470..ff538363 100644 --- a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala @@ -2,35 +2,68 @@ package docspell.query.internal import minitest._ import docspell.query.Date +import java.time.Period object DateParserTest extends SimpleTestSuite { - def ld(year: Int, m: Int, d: Int): Date = - Date(year, m, d) + def ld(year: Int, m: Int, d: Int): Date.DateLiteral = + Date(year, m, d).fold(throw _, identity) + + def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc = + Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p) + + def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc = + Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p) test("local date string") { - val p = DateParser.localDateFromString + val p = DateParser.dateFromString assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22))) assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21))) assert(p.parseAll("0-0-0").isLeft) - assert(p.parseAll("2021-02-30").isRight) + assert(p.parseAll("2021-02-30").isLeft) } test("local date millis") { val p = DateParser.dateFromMillis - assertEquals(p.parseAll("0"), Right(Date(0))) + assertEquals(p.parseAll("ms0"), Right(Date(0))) assertEquals( - p.parseAll("1600000065463"), + p.parseAll("ms1600000065463"), Right(Date(1600000065463L)) ) } test("local date") { - val p = DateParser.localDate + val p = DateParser.date assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22))) assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) - assertEquals(p.parseAll("0"), Right(Date(0))) - assertEquals(p.parseAll("1600000065463"), Right(Date(1600000065463L))) + assertEquals(p.parseAll("ms0"), Right(Date(0))) + assertEquals(p.parseAll("ms1600000065463"), Right(Date(1600000065463L))) + } + + test("local partial date") { + val p = DateParser.date + assertEquals(p.parseAll("2021-04"), Right(ld(2021, 4, 1))) + assertEquals(p.parseAll("2021-12"), Right(ld(2021, 12, 1))) + assert(p.parseAll("2021-13").isLeft) + assert(p.parseAll("2021-28").isLeft) + assertEquals(p.parseAll("2021"), Right(ld(2021, 1, 1))) + } + + test("date calcs") { + val p = DateParser.date + assertEquals(p.parseAll("2020-02;+2d"), Right(ldPlus(2020, 2, 1, Period.ofDays(2)))) + assertEquals( + p.parseAll("today;-2m"), + Right(Date.Calc(Date.Today, Date.CalcDirection.Minus, Period.ofMonths(2))) + ) + } + + test("period") { + val p = DateParser.periods + assertEquals(p.parseAll("15d"), Right(Period.ofDays(15))) + assertEquals(p.parseAll("15m"), Right(Period.ofMonths(15))) + assertEquals(p.parseAll("15d10m"), Right(Period.ofMonths(10).plus(Period.ofDays(15)))) + assertEquals(p.parseAll("10m15d"), Right(Period.ofMonths(10).plus(Period.ofDays(15)))) } } diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala new file mode 100644 index 00000000..0836884a --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala @@ -0,0 +1,19 @@ +package docspell.query.internal + +import minitest._ +import cats.parse.{Parser => P} + +object MacroParserTest extends SimpleTestSuite { + + test("fail with unkown macro names") { + val p = MacroParser.parser(Map.empty) + assert(p.parseAll("$bla:blup").isLeft) // TODO check error message + } + + test("select correct parser") { + val p = + MacroParser.parser[Int](Map("one" -> P.anyChar.as(1), "two" -> P.anyChar.as(2))) + assertEquals(p.parseAll("$one:y"), Right(1)) + assertEquals(p.parseAll("$two:y"), Right(2)) + } +} diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala index f3ee0ae5..9f79aa51 100644 --- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala @@ -4,6 +4,7 @@ import cats.data.{NonEmptyList => Nel} import docspell.query.ItemQuery._ import minitest._ import docspell.query.Date +import java.time.Period object SimpleExprParserTest extends SimpleTestSuite { @@ -39,8 +40,8 @@ object SimpleExprParserTest extends SimpleTestSuite { test("date expr") { val p = SimpleExprParser.dateExpr assertEquals( - p.parseAll("due:2021-03-14"), - Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14))) + p.parseAll("date:2021-03-14"), + Right(dateExpr(Operator.Like, Attr.Date, ld(2021, 3, 14))) ) assertEquals( p.parseAll("due<2021-03-14"), @@ -50,6 +51,28 @@ object SimpleExprParserTest extends SimpleTestSuite { p.parseAll("due~=2021-03-14,2021-03-13"), Right(Expr.InDateExpr(Attr.DueDate, Nel.of(ld(2021, 3, 14), ld(2021, 3, 13)))) ) + assertEquals( + p.parseAll("due>2021"), + Right(dateExpr(Operator.Gt, Attr.DueDate, ld(2021, 1, 1))) + ) + assertEquals( + p.parseAll("date<2021-01"), + Right(dateExpr(Operator.Lt, Attr.Date, ld(2021, 1, 1))) + ) + assertEquals( + p.parseAll("date<today"), + Right(dateExpr(Operator.Lt, Attr.Date, Date.Today)) + ) + assertEquals( + p.parseAll("date>today;-2m"), + Right( + dateExpr( + Operator.Gt, + Attr.Date, + Date.Calc(Date.Today, Date.CalcDirection.Minus, Period.ofMonths(2)) + ) + ) + ) } test("exists expr") { diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml index f9b2d921..d972abcb 100644 --- a/modules/restserver/src/main/resources/logback.xml +++ b/modules/restserver/src/main/resources/logback.xml @@ -9,6 +9,7 @@ <logger name="docspell" level="debug" /> <logger name="emil" level="debug"/> + <logger name="docspell.store.queries.QItem" level="trace"/> <root level="INFO"> <appender-ref ref="STDOUT" /> diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 2e2b3233..173e8399 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -1,6 +1,7 @@ package docspell.store.qb.generator -import java.time.{Instant, LocalDate} +import java.time.Instant +import java.time.LocalDate import cats.data.NonEmptyList @@ -9,27 +10,27 @@ import docspell.query.ItemQuery._ import docspell.query.{Date, ItemQuery} import docspell.store.qb.DSL._ import docspell.store.qb.{Operator => QOp, _} +import docspell.store.queries.QueryWildcard import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} import doobie.util.Put -import docspell.store.queries.QueryWildcard object ItemQueryGenerator { - def apply(tables: Tables, coll: Ident)(q: ItemQuery)(implicit + def apply(today: LocalDate, tables: Tables, coll: Ident)(q: ItemQuery)(implicit PT: Put[Timestamp] ): Condition = - fromExpr(tables, coll)(q.expr) + fromExpr(today, tables, coll)(q.expr) - final def fromExpr(tables: Tables, coll: Ident)( + final def fromExpr(today: LocalDate, tables: Tables, coll: Ident)( expr: Expr )(implicit PT: Put[Timestamp]): Condition = expr match { case Expr.AndExpr(inner) => - Condition.And(inner.map(fromExpr(tables, coll))) + Condition.And(inner.map(fromExpr(today, tables, coll))) case Expr.OrExpr(inner) => - Condition.Or(inner.map(fromExpr(tables, coll))) + Condition.Or(inner.map(fromExpr(today, tables, coll))) case Expr.NotExpr(inner) => inner match { @@ -71,7 +72,7 @@ object ItemQueryGenerator { Condition.unit case _ => - Condition.Not(fromExpr(tables, coll)(inner)) + Condition.Not(fromExpr(today, tables, coll)(inner)) } case Expr.Exists(field) => @@ -87,9 +88,10 @@ object ItemQueryGenerator { } case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) => - val dt = dateToTimestamp(value) - val col = timestampColumn(tables)(attr) - Condition.CompareVal(col, makeOp(op), dt) + val dt = dateToTimestamp(today)(value) + val col = timestampColumn(tables)(attr) + val noLikeOp = if (op == Operator.Like) Operator.Eq else op + Condition.CompareVal(col, makeOp(noLikeOp), dt) case Expr.InExpr(attr, values) => val col = stringColumn(tables)(attr) @@ -98,10 +100,18 @@ object ItemQueryGenerator { case Expr.InDateExpr(attr, values) => val col = timestampColumn(tables)(attr) - val dts = values.map(dateToTimestamp) + val dts = values.map(dateToTimestamp(today)) if (values.tail.isEmpty) col === dts.head else col.in(dts) + case Expr.DirectionExpr(incoming) => + if (incoming) tables.item.incoming === Direction.Incoming + else tables.item.incoming === Direction.Outgoing + + case Expr.InboxExpr(flag) => + if (flag) tables.item.state === ItemState.created + else tables.item.state === ItemState.confirmed + case Expr.TagIdsMatch(op, tags) => val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) NonEmptyList @@ -142,12 +152,31 @@ object ItemQueryGenerator { Condition.unit } - private def dateToTimestamp(date: Date): Timestamp = + private def dateToTimestamp(today: LocalDate)(date: Date): Timestamp = date match { - case Date.Local(year, month, day) => - Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) + case d: Date.DateLiteral => + val ld = dateLiteralToDate(today)(d) + println(s">>>> date= $ld") + Timestamp.atUtc(ld.atStartOfDay) + case Date.Calc(date, c, period) => + val ld = c match { + case Date.CalcDirection.Plus => + dateLiteralToDate(today)(date).plus(period) + case Date.CalcDirection.Minus => + dateLiteralToDate(today)(date).minus(period) + } + println(s">>>> date= $ld") + Timestamp.atUtc(ld.atStartOfDay()) + } + + private def dateLiteralToDate(today: LocalDate)(dateLit: Date.DateLiteral): LocalDate = + dateLit match { + case Date.Local(date) => + date case Date.Millis(ms) => - Timestamp(Instant.ofEpochMilli(ms)) + Instant.ofEpochMilli(ms).atZone(Timestamp.UTC).toLocalDate() + case Date.Today => + today } private def anyColumn(tables: Tables)(attr: Attr): Column[_] = @@ -171,6 +200,7 @@ object ItemQueryGenerator { case Attr.ItemId => tables.item.id.cast[String] case Attr.ItemName => tables.item.name case Attr.ItemSource => tables.item.source + case Attr.ItemNotes => tables.item.notes case Attr.Correspondent.OrgId => tables.corrOrg.oid.cast[String] case Attr.Correspondent.OrgName => tables.corrOrg.name case Attr.Correspondent.PersonId => tables.corrPers.pid.cast[String] 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 7461cc8b..a79db262 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -1,5 +1,7 @@ package docspell.store.queries +import java.time.LocalDate + import cats.data.{NonEmptyList => Nel} import cats.effect.Sync import cats.effect.concurrent.Ref @@ -226,41 +228,42 @@ object QItem { findCustomFieldValuesForColl(coll, q.customValues) .map(itemIds => i.id.in(itemIds)) - def queryCondFromExpr(coll: Ident, q: ItemQuery): Condition = { + def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery): Condition = { val tables = Tables(i, org, pers0, pers1, equip, f, a, m) - ItemQueryGenerator.fromExpr(tables, coll)(q.expr) + ItemQueryGenerator.fromExpr(today, tables, coll)(q.expr) } - def queryCondition(coll: Ident, cond: Query.QueryCond): Condition = + def queryCondition(today: LocalDate, coll: Ident, cond: Query.QueryCond): Condition = cond match { case fm: Query.QueryForm => queryCondFromForm(coll, fm) case expr: Query.QueryExpr => - queryCondFromExpr(coll, expr.q) + queryCondFromExpr(today, coll, expr.q) } def findItems( q: Query, + today: LocalDate, maxNoteLen: Int, batch: Batch ): Stream[ConnectionIO, ListItem] = { val sql = findItemsBase(q.fix, maxNoteLen) - .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) + .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond)) .limit(batch) .build logger.trace(s"List $batch items: $sql") sql.query[ListItem].stream } - def searchStats(q: Query): ConnectionIO[SearchSummary] = + def searchStats(today: LocalDate)(q: Query): ConnectionIO[SearchSummary] = for { - count <- searchCountSummary(q) - tags <- searchTagSummary(q) - fields <- searchFieldSummary(q) - folders <- searchFolderSummary(q) + count <- searchCountSummary(today)(q) + tags <- searchTagSummary(today)(q) + fields <- searchFieldSummary(today)(q) + folders <- searchFolderSummary(today)(q) } yield SearchSummary(count, tags, fields, folders) - def searchTagSummary(q: Query): ConnectionIO[List[TagCount]] = { + def searchTagSummary(today: LocalDate)(q: Query): ConnectionIO[List[TagCount]] = { val tagFrom = from(ti) .innerJoin(tag, tag.tid === ti.tagId) @@ -270,7 +273,7 @@ object QItem { findItemsBase(q.fix, 0).unwrap .withSelect(select(tag.all).append(count(i.id).as("num"))) .changeFrom(_.prepend(tagFrom)) - .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) + .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond)) .groupBy(tag.tid) .build .query[TagCount] @@ -284,27 +287,27 @@ object QItem { } yield existing ++ other.map(TagCount(_, 0)) } - def searchCountSummary(q: Query): ConnectionIO[Int] = + def searchCountSummary(today: LocalDate)(q: Query): ConnectionIO[Int] = findItemsBase(q.fix, 0).unwrap .withSelect(Nel.of(count(i.id).as("num"))) - .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) + .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond)) .build .query[Int] .unique - def searchFolderSummary(q: Query): ConnectionIO[List[FolderCount]] = { + def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = { val fu = RUser.as("fu") findItemsBase(q.fix, 0).unwrap .withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num"))) .changeFrom(_.innerJoin(fu, fu.uid === f.owner)) - .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) + .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond)) .groupBy(f.id, f.name, f.owner, fu.login) .build .query[FolderCount] .to[List] } - def searchFieldSummary(q: Query): ConnectionIO[List[FieldStats]] = { + def searchFieldSummary(today: LocalDate)(q: Query): ConnectionIO[List[FieldStats]] = { val fieldJoin = from(cv) .innerJoin(cf, cf.id === cv.field) @@ -313,7 +316,7 @@ object QItem { val base = findItemsBase(q.fix, 0).unwrap .changeFrom(_.prepend(fieldJoin)) - .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond)) + .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond)) .groupBy(GroupBy(cf.all)) val basicFields = Nel.of( diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala index 4bb5c57f..e2358f94 100644 --- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -22,11 +22,12 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { RAttachment.as("a"), RAttachmentMeta.as("m") ) + val now: LocalDate = LocalDate.of(2021, 2, 25) test("basic test") { val q = ItemQueryParser .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))") - val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q) + val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q) val expect = tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc( LocalDate.of(2020, 2, 1).atStartOfDay() @@ -35,29 +36,4 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { assertEquals(cond, expect) } -// test("migration2") { -// withStore("db2") { store => -// val c = RCollective( -// Ident.unsafe("coll1"), -// CollectiveState.Active, -// Language.German, -// true, -// Timestamp.Epoch -// ) -// val e = -// REquipment( -// Ident.unsafe("equip"), -// Ident.unsafe("coll1"), -// "name", -// Timestamp.Epoch, -// Timestamp.Epoch, -// None -// ) -// -// for { -// _ <- store.transact(RCollective.insert(c)) -// _ <- store.transact(REquipment.insert(e)).map(_ => ()) -// } yield () -// } -// } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index dac2bb0e..d737e1c9 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -33,6 +33,7 @@ object Dependencies { val PoiVersion = "4.1.2" val PostgresVersion = "42.2.19" val PureConfigVersion = "0.14.1" + val ScalaJavaTimeVersion = "2.2.0" val Slf4jVersion = "1.7.30" val StanfordNlpVersion = "4.2.0" val TikaVersion = "1.25" @@ -54,6 +55,9 @@ object Dependencies { val catsJS = Def.setting("org.typelevel" %%% "cats-core" % "2.4.2") + val scalaJavaTime = + Def.setting("io.github.cquiroz" %%% "scala-java-time" % ScalaJavaTimeVersion) + val kittens = Seq( "org.typelevel" %% "kittens" % KittensVersion ) From d737da768e80379f3d64853f0ea096d2f869f619 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 28 Feb 2021 21:15:21 +0100 Subject: [PATCH 09/33] Move to munit in query module --- build.sbt | 8 ++++++- .../query/internal/AttrParserTest.scala | 4 ++-- .../query/internal/BasicParserTest.scala | 4 ++-- .../query/internal/DateParserTest.scala | 13 ++-------- .../query/internal/ExprParserTest.scala | 5 ++-- .../query/internal/ItemQueryParserTest.scala | 4 ++-- .../query/internal/MacroParserTest.scala | 4 ++-- .../query/internal/OperatorParserTest.scala | 4 ++-- .../query/internal/SimpleExprParserTest.scala | 12 ++-------- .../docspell/query/internal/ValueHelper.scala | 24 +++++++++++++++++++ .../qb/generator/ItemQueryGenerator.scala | 2 -- .../generator/ItemQueryGeneratorTest.scala | 11 +++++---- project/Dependencies.scala | 6 +++++ 13 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala diff --git a/build.sbt b/build.sbt index 884234f3..1c4c170b 100644 --- a/build.sbt +++ b/build.sbt @@ -51,6 +51,11 @@ val testSettings = Seq( Test / fork := true ) +val testSettingsMUnit = Seq( + libraryDependencies ++= Dependencies.munit.map(_ % Test), + testFrameworks += new TestFramework("munit.Framework") +) + lazy val noPublish = Seq( publish := {}, publishLocal := {}, @@ -267,11 +272,12 @@ ${lines.map(_._1).mkString(",\n")} val query = crossProject(JSPlatform, JVMPlatform) + .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Pure) .in(file("modules/query")) .disablePlugins(RevolverPlugin) .settings(sharedSettings) - .settings(testSettings) + .settings(testSettingsMUnit) .settings( name := "docspell-query", libraryDependencies += diff --git a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala index 0634c83e..4c6dce3c 100644 --- a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala @@ -2,9 +2,9 @@ package docspell.query.internal import docspell.query.ItemQuery.Attr import docspell.query.internal.AttrParser -import minitest._ +import munit._ -object AttrParserTest extends SimpleTestSuite { +class AttrParserTest extends FunSuite { test("string attributes") { val p = AttrParser.stringAttr diff --git a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala index c272298a..e397ce9b 100644 --- a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala @@ -1,10 +1,10 @@ package docspell.query.internal -import minitest._ +import munit._ import cats.data.{NonEmptyList => Nel} import docspell.query.internal.BasicParser -object BasicParserTest extends SimpleTestSuite { +class BasicParserTest extends FunSuite { test("single string values") { val p = BasicParser.singleString assertEquals(p.parseAll("abcde"), Right("abcde")) diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala index ff538363..ec836ced 100644 --- a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala @@ -1,19 +1,10 @@ package docspell.query.internal -import minitest._ +import munit._ import docspell.query.Date import java.time.Period -object DateParserTest extends SimpleTestSuite { - - def ld(year: Int, m: Int, d: Int): Date.DateLiteral = - Date(year, m, d).fold(throw _, identity) - - def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc = - Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p) - - def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc = - Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p) +class DateParserTest extends FunSuite with ValueHelper { test("local date string") { val p = DateParser.dateFromString diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala index f918e361..6f0fe07f 100644 --- a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala @@ -1,11 +1,10 @@ package docspell.query.internal import docspell.query.ItemQuery._ -import docspell.query.internal.SimpleExprParserTest.stringExpr -import minitest._ +import munit._ import cats.data.{NonEmptyList => Nel} -object ExprParserTest extends SimpleTestSuite { +class ExprParserTest extends FunSuite with ValueHelper { test("simple expr") { val p = ExprParser.exprParser diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala index 4074748c..61dfdf86 100644 --- a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala @@ -1,10 +1,10 @@ package docspell.query.internal -import minitest._ +import munit._ import docspell.query.ItemQueryParser import docspell.query.ItemQuery -object ItemQueryParserTest extends SimpleTestSuite { +class ItemQueryParserTest extends FunSuite { test("reduce ands") { val q = ItemQueryParser.parseUnsafe("(&(&(&(& name:hello))))") diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala index 0836884a..045d1120 100644 --- a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala @@ -1,9 +1,9 @@ package docspell.query.internal -import minitest._ +import munit._ import cats.parse.{Parser => P} -object MacroParserTest extends SimpleTestSuite { +class MacroParserTest extends FunSuite { test("fail with unkown macro names") { val p = MacroParser.parser(Map.empty) diff --git a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala index b451289c..1a5a8af0 100644 --- a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala @@ -1,10 +1,10 @@ package docspell.query.internal -import minitest._ +import munit._ import docspell.query.ItemQuery.{Operator, TagOperator} import docspell.query.internal.OperatorParser -object OperatorParserTest extends SimpleTestSuite { +class OperatorParserTest extends FunSuite { test("operator values") { val p = OperatorParser.op assertEquals(p.parseAll("="), Right(Operator.Eq)) diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala index 9f79aa51..32c152a0 100644 --- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala @@ -2,11 +2,11 @@ package docspell.query.internal import cats.data.{NonEmptyList => Nel} import docspell.query.ItemQuery._ -import minitest._ +import munit._ import docspell.query.Date import java.time.Period -object SimpleExprParserTest extends SimpleTestSuite { +class SimpleExprParserTest extends FunSuite with ValueHelper { test("string expr") { val p = SimpleExprParser.stringExpr @@ -175,12 +175,4 @@ object SimpleExprParserTest extends SimpleTestSuite { ) } - def ld(y: Int, m: Int, d: Int) = - DateParserTest.ld(y, m, d) - - def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr = - Expr.SimpleExpr(op, Property.StringProperty(name, value)) - - def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr = - Expr.SimpleExpr(op, Property.DateProperty(name, value)) } diff --git a/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala b/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala new file mode 100644 index 00000000..f2729f18 --- /dev/null +++ b/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala @@ -0,0 +1,24 @@ +package docspell.query.internal + +import docspell.query.Date +import docspell.query.ItemQuery._ +import java.time.Period + +trait ValueHelper { + + def ld(year: Int, m: Int, d: Int): Date.DateLiteral = + Date(year, m, d).fold(throw _, identity) + + def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc = + Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p) + + def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc = + Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p) + + def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr = + Expr.SimpleExpr(op, Property.StringProperty(name, value)) + + def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr = + Expr.SimpleExpr(op, Property.DateProperty(name, value)) + +} diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 173e8399..d02e9660 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -156,7 +156,6 @@ object ItemQueryGenerator { date match { case d: Date.DateLiteral => val ld = dateLiteralToDate(today)(d) - println(s">>>> date= $ld") Timestamp.atUtc(ld.atStartOfDay) case Date.Calc(date, c, period) => val ld = c match { @@ -165,7 +164,6 @@ object ItemQueryGenerator { case Date.CalcDirection.Minus => dateLiteralToDate(today)(date).minus(period) } - println(s">>>> date= $ld") Timestamp.atUtc(ld.atStartOfDay()) } diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala index e2358f94..3d9e5b2e 100644 --- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -24,14 +24,17 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { ) val now: LocalDate = LocalDate.of(2021, 2, 25) + def mkTimestamp(year: Int, month: Int, day: Int): Timestamp = + Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) + test("basic test") { val q = ItemQueryParser - .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))") + .parseUnsafe("(& name:hello date>=2020-02-01 (| source:expense* folder=test ))") val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q) val expect = - tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc( - LocalDate.of(2020, 2, 1).atStartOfDay() - ) && (tables.item.source === "expense" || tables.folder.name === "test") + tables.item.name.like("hello") && + tables.item.itemDate >= mkTimestamp(2020, 2, 1) && + (tables.item.source.like("expense%") || tables.folder.name === "test") assertEquals(cond, expect) } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index d737e1c9..8197c338 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -28,6 +28,7 @@ object Dependencies { val LogbackVersion = "1.2.3" val MariaDbVersion = "2.7.2" val MiniTestVersion = "2.9.3" + val MUnitVersion = "0.7.22" val OrganizeImportsVersion = "0.5.0" val PdfboxVersion = "2.0.22" val PoiVersion = "4.1.2" @@ -271,6 +272,11 @@ object Dependencies { "io.monix" %% "minitest-laws" % MiniTestVersion ).map(_ % Test) + val munit = Seq( + "org.scalameta" %% "munit" % MUnitVersion, + "org.scalameta" %% "munit-scalacheck" % MUnitVersion + ) + val kindProjectorPlugin = "org.typelevel" %% "kind-projector" % KindProjectorVersion val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % BetterMonadicForVersion From e079ec198788bf76007691255e4b4395f737d09e Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 28 Feb 2021 22:36:48 +0100 Subject: [PATCH 10/33] Provide custom error structure for parse failures --- .../docspell/query/ItemQueryParser.scala | 7 +- .../scala/docspell/query/ParseFailure.scala | 65 +++++++++++++++++++ .../restserver/routes/ItemRoutes.scala | 2 +- 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 modules/query/src/main/scala/docspell/query/ParseFailure.scala diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index cea6c09e..cf9b491c 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -9,17 +9,18 @@ import docspell.query.internal.ExprUtil object ItemQueryParser { @JSExport - def parse(input: String): Either[String, ItemQuery] = + def parse(input: String): Either[ParseFailure, ItemQuery] = if (input.isEmpty) Right(ItemQuery.all) else { val in = if (input.charAt(0) == '(') input else s"(& $input )" ExprParser .parseQuery(in) .left - .map(pe => s"Error parsing: '$input': $pe") //TODO + .map(ParseFailure.fromError(in)) .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) } def parseUnsafe(input: String): ItemQuery = - parse(input).fold(sys.error, identity) + parse(input).fold(m => sys.error(m.render), identity) + } diff --git a/modules/query/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/src/main/scala/docspell/query/ParseFailure.scala new file mode 100644 index 00000000..05235c03 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/ParseFailure.scala @@ -0,0 +1,65 @@ +package docspell.query + +import cats.data.{NonEmptyList => Nel} +import cats.parse.Parser +import cats.parse.Parser.Expectation.EndOfString +import cats.parse.Parser.Expectation.ExpectedFailureAt +import cats.parse.Parser.Expectation.Fail +import cats.parse.Parser.Expectation.FailWith +import cats.parse.Parser.Expectation.InRange +import cats.parse.Parser.Expectation.Length +import cats.parse.Parser.Expectation.OneOfStr +import cats.parse.Parser.Expectation.StartOfString + +final case class ParseFailure( + input: String, + failedAt: Int, + messages: Nel[ParseFailure.Message] +) { + + def render: String = { + val items = messages.map(_.msg).toList.mkString(", ") + s"Failed to read input near $failedAt: $input\nDetails: $items" + } +} + +object ParseFailure { + + final case class Message(offset: Int, msg: String) + + private[query] def fromError(input: String)(pe: Parser.Error): ParseFailure = + ParseFailure( + input, + pe.failedAtOffset, + Parser.Expectation.unify(pe.expected).map(expectationToMsg) + ) + + private[query] def expectationToMsg(e: Parser.Expectation): Message = + e match { + case StartOfString(offset) => + Message(offset, "Expected start of string") + + case FailWith(offset, message) => + Message(offset, message) + + case InRange(offset, lower, upper) => + if (lower == upper) Message(offset, s"Expected character: $lower") + else Message(offset, s"Expected character from range: [$lower .. $upper]") + + case Length(offset, expected, actual) => + Message(offset, s"Expected input of length $expected, but got $actual") + + case ExpectedFailureAt(offset, matched) => + Message(offset, s"Expected failing, but matched '$matched'") + + case EndOfString(offset, length) => + Message(offset, s"Expected end of string at length: $length") + + case Fail(offset) => + Message(offset, s"Failed to parse near $offset") + + case OneOfStr(offset, strs) => + val options = strs.mkString(", ") + Message(offset, s"Expected one of the following strings: $options") + } +} 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 510273a0..e93e4e95 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -61,7 +61,7 @@ object ItemRoutes { val of = offset.getOrElse(0) query match { case Left(err) => - BadRequest(BasicResult(false, err)) + BadRequest(BasicResult(false, err.render)) case Right(sq) => for { items <- backend.itemSearch.findItems(cfg.maxNoteLength)( From 698ff58aa3c8652aa1577b8191c6ac500d96be4c Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 11:50:07 +0100 Subject: [PATCH 11/33] Provide a more convenient interface to search --- .../docspell/backend/ops/OSimpleSearch.scala | 177 +++++++++++++++++- .../docspell/common/ItemQueryString.scala | 6 + .../scala/docspell/store/queries/Query.scala | 30 ++- 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index 0ec0d903..d4a29a7f 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -1,13 +1,180 @@ package docspell.backend.ops -import docspell.backend.ops.OItemSearch.ListItemWithTags -import docspell.common.ItemQueryString -import docspell.store.qb.Batch +import cats.implicits._ +import docspell.common._ +import docspell.store.qb.Batch +import docspell.store.queries.Query +import docspell.query.{ItemQueryParser, ParseFailure} + +import OSimpleSearch._ +import docspell.store.queries.SearchSummary +import cats.effect.Sync + +/** A "porcelain" api on top of OFulltext and OItemSearch. */ trait OSimpleSearch[F[_]] { - def searchByString(q: ItemQueryString, batch: Batch): F[Vector[ListItemWithTags]] + def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items] + def searchSummary( + settings: Settings + )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] + + def searchByString( + settings: Settings + )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] + def searchSummaryByString( + settings: Settings + )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] } -object OSimpleSearch {} +object OSimpleSearch { + + final case class Settings( + batch: Batch, + useFTS: Boolean, + resolveDetails: Boolean, + maxNoteLen: Int + ) + object Settings { + def plain(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings = + Settings(batch, useFulltext, false, maxNoteLen) + def detailed(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings = + Settings(batch, useFulltext, true, maxNoteLen) + } + + sealed trait Items { + def fold[A]( + f1: Vector[OFulltext.FtsItem] => A, + f2: Vector[OFulltext.FtsItemWithTags] => A, + f3: Vector[OItemSearch.ListItem] => A, + f4: Vector[OItemSearch.ListItemWithTags] => A + ): A + + } + object Items { + def ftsItems(items: Vector[OFulltext.FtsItem]): Items = + FtsItems(items) + + case class FtsItems(items: Vector[OFulltext.FtsItem]) extends Items { + def fold[A]( + f1: Vector[OFulltext.FtsItem] => A, + f2: Vector[OFulltext.FtsItemWithTags] => A, + f3: Vector[OItemSearch.ListItem] => A, + f4: Vector[OItemSearch.ListItemWithTags] => A + ): A = f1(items) + + } + + def ftsItemsFull(items: Vector[OFulltext.FtsItemWithTags]): Items = + FtsItemsFull(items) + + case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags]) extends Items { + def fold[A]( + f1: Vector[OFulltext.FtsItem] => A, + f2: Vector[OFulltext.FtsItemWithTags] => A, + f3: Vector[OItemSearch.ListItem] => A, + f4: Vector[OItemSearch.ListItemWithTags] => A + ): A = f2(items) + } + + def itemsPlain(items: Vector[OItemSearch.ListItem]): Items = + ItemsPlain(items) + + case class ItemsPlain(items: Vector[OItemSearch.ListItem]) extends Items { + def fold[A]( + f1: Vector[OFulltext.FtsItem] => A, + f2: Vector[OFulltext.FtsItemWithTags] => A, + f3: Vector[OItemSearch.ListItem] => A, + f4: Vector[OItemSearch.ListItemWithTags] => A + ): A = f3(items) + } + + def itemsFull(items: Vector[OItemSearch.ListItemWithTags]): Items = + ItemsFull(items) + + case class ItemsFull(items: Vector[OItemSearch.ListItemWithTags]) extends Items { + def fold[A]( + f1: Vector[OFulltext.FtsItem] => A, + f2: Vector[OFulltext.FtsItemWithTags] => A, + f3: Vector[OItemSearch.ListItem] => A, + f4: Vector[OItemSearch.ListItemWithTags] => A + ): A = f4(items) + } + + } + + def apply[F[_]: Sync](fts: OFulltext[F], is: OItemSearch[F]): OSimpleSearch[F] = + new Impl(fts, is) + + final class Impl[F[_]: Sync](fts: OFulltext[F], is: OItemSearch[F]) + extends OSimpleSearch[F] { + def searchByString( + settings: Settings + )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] = + ItemQueryParser + .parse(q.query) + .map(iq => Query(fix, Query.QueryExpr(iq))) + .map(search(settings)(_, None)) //TODO resolve content:xyz expressions + + def searchSummaryByString( + settings: Settings + )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] = + ItemQueryParser + .parse(q.query) + .map(iq => Query(fix, Query.QueryExpr(iq))) + .map(searchSummary(settings)(_, None)) //TODO resolve content:xyz expressions + + def searchSummary( + settings: Settings + )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = + fulltextQuery match { + case Some(ftq) if settings.useFTS => + if (q.isEmpty) + fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) + else + fts + .findItemsSummary(q, OFulltext.FtsInput(ftq)) + + case _ => + is.findItemsSummary(q) + } + + def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items] = + // 1. fulltext only if fulltextQuery.isDefined && q.isEmpty && useFTS + // 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS + // 3. sql-only else (if fulltextQuery.isEmpty || !useFTS) + fulltextQuery match { + case Some(ftq) if settings.useFTS => + if (q.isEmpty) + fts + .findIndexOnly(settings.maxNoteLen)( + OFulltext.FtsInput(ftq), + q.fix.account, + settings.batch + ) + .map(Items.ftsItemsFull) + else if (settings.resolveDetails) + fts + .findItemsWithTags(settings.maxNoteLen)( + q, + OFulltext.FtsInput(ftq), + settings.batch + ) + .map(Items.ftsItemsFull) + else + fts + .findItems(settings.maxNoteLen)(q, OFulltext.FtsInput(ftq), settings.batch) + .map(Items.ftsItems) + + case _ => + if (settings.resolveDetails) + is.findItemsWithTags(settings.maxNoteLen)(q, settings.batch) + .map(Items.itemsFull) + else + is.findItems(settings.maxNoteLen)(q, settings.batch) + .map(Items.itemsPlain) + } + } + +} diff --git a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala index 49bc878f..8e6e887e 100644 --- a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala +++ b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala @@ -1,3 +1,9 @@ package docspell.common case class ItemQueryString(query: String) + +object ItemQueryString { + + def apply(qs: Option[String]): ItemQueryString = + ItemQueryString(qs.getOrElse("")) +} 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 879d15b0..6be04a1e 100644 --- a/modules/store/src/main/scala/docspell/store/queries/Query.scala +++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala @@ -14,6 +14,12 @@ case class Query(fix: Query.Fix, cond: Query.QueryCond) { def withFix(f: Query.Fix => Query.Fix): Query = copy(fix = f(fix)) + + def isEmpty: Boolean = + fix.isEmpty && cond.isEmpty + + def nonEmpty: Boolean = + !isEmpty } object Query { @@ -22,9 +28,18 @@ object Query { account: AccountId, itemIds: Option[Set[Ident]], orderAsc: Option[RItem.Table => Column[_]] - ) + ) { - sealed trait QueryCond + def isEmpty: Boolean = + itemIds.isEmpty + } + + sealed trait QueryCond { + def isEmpty: Boolean + + def nonEmpty: Boolean = + !isEmpty + } case class QueryForm( name: Option[String], @@ -47,7 +62,11 @@ object Query { itemIds: Option[Set[Ident]], customValues: Seq[CustomValue], source: Option[String] - ) extends QueryCond + ) extends QueryCond { + + def isEmpty: Boolean = + this == QueryForm.empty + } object QueryForm { val empty = QueryForm( @@ -74,7 +93,10 @@ object Query { ) } - case class QueryExpr(q: ItemQuery) extends QueryCond + case class QueryExpr(q: ItemQuery) extends QueryCond { + def isEmpty: Boolean = + q.expr == ItemQuery.all.expr + } def empty(account: AccountId): Query = Query(Fix(account, None, None), QueryForm.empty) From dadab0d30868b53e39ce6d75c6557d8021ac4838 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 12:37:25 +0100 Subject: [PATCH 12/33] Implement search by query in endpoints --- .../scala/docspell/backend/BackendApp.scala | 3 + .../docspell/backend/ops/OSimpleSearch.scala | 25 ++- .../src/main/resources/docspell-openapi.yml | 152 +++++++++++++++--- .../restserver/http4s/QueryParam.scala | 7 +- .../restserver/routes/ItemRoutes.scala | 125 ++++++++++---- modules/webapp/src/main/elm/Api.elm | 10 +- 6 files changed, 249 insertions(+), 73 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 81328296..80df397c 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -37,6 +37,7 @@ trait BackendApp[F[_]] { def userTask: OUserTask[F] def folder: OFolder[F] def customFields: OCustomFields[F] + def simpleSearch: OSimpleSearch[F] } object BackendApp { @@ -71,6 +72,7 @@ object BackendApp { userTaskImpl <- OUserTask(utStore, queue, joexImpl) folderImpl <- OFolder(store) customFieldsImpl <- OCustomFields(store) + simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) } yield new BackendApp[F] { val login = loginImpl val signup = signupImpl @@ -90,6 +92,7 @@ object BackendApp { val userTask = userTaskImpl val folder = folderImpl val customFields = customFieldsImpl + val simpleSearch = simpleSearchImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index d4a29a7f..8eb91c7f 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -1,29 +1,28 @@ package docspell.backend.ops +import cats.effect.Sync import cats.implicits._ +import docspell.backend.ops.OSimpleSearch._ import docspell.common._ +import docspell.query.{ItemQueryParser, ParseFailure} import docspell.store.qb.Batch import docspell.store.queries.Query -import docspell.query.{ItemQueryParser, ParseFailure} - -import OSimpleSearch._ import docspell.store.queries.SearchSummary -import cats.effect.Sync /** A "porcelain" api on top of OFulltext and OItemSearch. */ trait OSimpleSearch[F[_]] { def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items] def searchSummary( - settings: Settings + useFTS: Boolean )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] def searchByString( settings: Settings )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] def searchSummaryByString( - settings: Settings + useFTS: Boolean )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] } @@ -36,12 +35,6 @@ object OSimpleSearch { resolveDetails: Boolean, maxNoteLen: Int ) - object Settings { - def plain(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings = - Settings(batch, useFulltext, false, maxNoteLen) - def detailed(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings = - Settings(batch, useFulltext, true, maxNoteLen) - } sealed trait Items { def fold[A]( @@ -118,18 +111,18 @@ object OSimpleSearch { .map(search(settings)(_, None)) //TODO resolve content:xyz expressions def searchSummaryByString( - settings: Settings + useFTS: Boolean )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] = ItemQueryParser .parse(q.query) .map(iq => Query(fix, Query.QueryExpr(iq))) - .map(searchSummary(settings)(_, None)) //TODO resolve content:xyz expressions + .map(searchSummary(useFTS)(_, None)) //TODO resolve content:xyz expressions def searchSummary( - settings: Settings + useFTS: Boolean )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = fulltextQuery match { - case Some(ftq) if settings.useFTS => + case Some(ftq) if useFTS => if (q.isEmpty) fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) else diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8623ade1..a56e911c 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1310,16 +1310,16 @@ paths: $ref: "#/components/schemas/BasicResult" - /sec/item/search: + /sec/item/searchForm: post: - tags: [ Item ] + tags: [ Item Search ] summary: Search for items. description: | Search for items given a search form. The results are grouped by month and are sorted by item date (newest first). Tags and attachments are *not* resolved. The results will always contain an empty list for item tags and attachments. Use - `/searchWithTags` to also retrieve all tags and a list of + `/searchFormWithTags` to also retrieve all tags and a list of attachments of an item. The `fulltext` field can be used to restrict the results by @@ -1328,6 +1328,8 @@ paths: The customfields used in the search query are allowed to be specified by either field id or field name. The values may contain the wildcard `*` at beginning or end. + + **NOTE** This is deprecated in favor for using a search query. security: - authTokenHeader: [] requestBody: @@ -1342,9 +1344,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemLightList" - /sec/item/searchWithTags: + /sec/item/searchFormWithTags: post: - tags: [ Item ] + tags: [ Item Search ] summary: Search for items. description: | Search for items given a search form. The results are grouped @@ -1355,6 +1357,8 @@ paths: The `fulltext` field can be used to restrict the results by using full-text search in the documents contents. + + **NOTE** This is deprecated in favor for using search query. security: - authTokenHeader: [] requestBody: @@ -1369,9 +1373,60 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemLightList" + + /sec/item/search: + get: + tags: [ Item Search ] + summary: Search for items. + description: | + Search for items given a search query. The results are grouped + by month and are sorted by item date (newest first). Tags and + attachments are *not* resolved. The results will always + contain an empty list for item tags and attachments. Set + `withDetails` to `true` for retrieving all tags and a list of + attachments of an item. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/limit" + - $ref: "#/components/parameters/offset" + - $ref: "#/components/parameters/withDetails" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLightList" + post: + tags: [ Item Search ] + summary: Search for items. + description: | + Search for items given a search query. The results are grouped + by month and are sorted by item date (newest first). Tags and + attachments are *not* resolved. The results will always + contain an empty list for item tags and attachments. Use + `withDetails` to also retrieve all tags and a list of + attachments of an item. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemQuery" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLightList" + /sec/item/searchIndex: post: - tags: [ Item ] + tags: [ Item Search ] summary: Search for items using full-text search only. description: | Search for items by only using the full-text search index. @@ -1391,7 +1446,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ItemFtsSearch" + $ref: "#/components/schemas/ItemQuery" responses: 200: description: Ok @@ -1400,12 +1455,14 @@ paths: schema: $ref: "#/components/schemas/ItemLightList" - /sec/item/searchStats: + /sec/item/searchFormStats: post: - tags: [ Item ] + tags: [ Item Search ] summary: Get basic statistics about the data of a search. description: | Takes a search query and returns a summary about the results. + + **NOTE** This is deprecated in favor of using a search query. security: - authTokenHeader: [] requestBody: @@ -1420,6 +1477,44 @@ paths: application/json: schema: $ref: "#/components/schemas/SearchStats" + /sec/item/searchStats: + post: + tags: [ Item Search ] + summary: Get basic statistics about search results. + description: | + Instead of returning the results of a query, uses it to return + a summary. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemQuery" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/SearchStats" + get: + tags: [ Item Search ] + summary: Get basic statistics about search results. + description: | + Instead of returning the results of a query, uses it to return + a summary. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/SearchStats" /sec/item/{id}: get: @@ -3777,22 +3872,12 @@ components: type: array items: $ref: "#/components/schemas/IdName" - FolderMember: + ItemQuery: description: | - Information to add or remove a folder member. - required: - - userId - properties: - userId: - type: string - format: ident - ItemFtsSearch: - description: | - Query description for a full-text only search. + Query description for a search. Is used for fulltext-only + searches and combined searches. required: - query - - offset - - limit properties: offset: type: integer @@ -3804,6 +3889,9 @@ components: The maximum number of results to return. Note that this limit is a soft limit, there is some hard limit on the server, too. + withDetails: + type: boolean + default: false query: type: string description: | @@ -5547,6 +5635,26 @@ components: required: false schema: type: string + limit: + name: limit + in: query + description: A limit for a search query + schema: + type: integer + format: int32 + offset: + name: offset + in: query + description: A offset into the results for a search query + schema: + type: integer + format: int32 + withDetails: + name: withDetails + in: query + description: Whether to return details to each item. + schema: + type: boolean name: name: name in: path 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 de4c7090..6907c2c4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -25,9 +25,10 @@ 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 Query extends OptionalQueryParamDecoderMatcher[String]("q") + object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") + object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") + object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails") 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 e93e4e95..9a0fd9cc 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -10,9 +10,9 @@ import docspell.backend.auth.AuthToken import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.backend.ops.OFulltext import docspell.backend.ops.OItemSearch.{Batch, Query} +import docspell.backend.ops.OSimpleSearch 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 @@ -49,30 +49,96 @@ object ItemRoutes { case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( offset - ) => - val query = - ItemQueryParser.parse(q.getOrElse("")) match { - case Right(q) => - Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q))) - case Left(err) => - Left(err) - } - val li = limit.getOrElse(cfg.maxItemPageSize) - val of = offset.getOrElse(0) - query match { - case Left(err) => - BadRequest(BasicResult(false, err.render)) - case Right(sq) => - for { - items <- backend.itemSearch.findItems(cfg.maxNoteLength)( - sq, - Batch(of, li).restrictLimitTo(cfg.maxItemPageSize) + ) :? QP.WithDetails(detailFlag) => + val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize)) + .restrictLimitTo(cfg.maxItemPageSize) + val itemQuery = ItemQueryString(q) + val settings = OSimpleSearch.Settings( + batch, + cfg.fullTextSearch.enabled, + detailFlag.getOrElse(false), + cfg.maxNoteLength + ) + val fixQuery = Query.Fix(user.account, None, None) + backend.simpleSearch.searchByString(settings)(fixQuery, itemQuery) match { + case Right(results) => + val items = results.map( + _.fold( + Conversions.mkItemListFts, + Conversions.mkItemListWithTagsFts, + Conversions.mkItemList, + Conversions.mkItemListWithTags ) - ok <- Ok(Conversions.mkItemList(items)) - } yield ok + ) + Ok(items) + case Left(fail) => + BadRequest(BasicResult(false, fail.render)) + } + + case GET -> Root / "searchStats" :? QP.Query(q) => + val itemQuery = ItemQueryString(q) + val fixQuery = Query.Fix(user.account, None, None) + backend.simpleSearch + .searchSummaryByString(cfg.fullTextSearch.enabled)(fixQuery, itemQuery) match { + case Right(summary) => + summary.flatMap(s => Ok(Conversions.mkSearchStats(s))) + case Left(fail) => + BadRequest(BasicResult(false, fail.render)) } case req @ POST -> Root / "search" => + for { + userQuery <- req.as[ItemQuery] + batch = Batch( + userQuery.offset.getOrElse(0), + userQuery.limit.getOrElse(cfg.maxItemPageSize) + ).restrictLimitTo( + cfg.maxItemPageSize + ) + itemQuery = ItemQueryString(userQuery.query) + settings = OSimpleSearch.Settings( + batch, + cfg.fullTextSearch.enabled, + userQuery.withDetails.getOrElse(false), + cfg.maxNoteLength + ) + fixQuery = Query.Fix(user.account, None, None) + resp <- backend.simpleSearch + .searchByString(settings)(fixQuery, itemQuery) match { + case Right(results) => + val items = results.map( + _.fold( + Conversions.mkItemListFts, + Conversions.mkItemListWithTagsFts, + Conversions.mkItemList, + Conversions.mkItemListWithTags + ) + ) + Ok(items) + case Left(fail) => + BadRequest(BasicResult(false, fail.render)) + } + } yield resp + + case req @ POST -> Root / "searchStats" => + for { + userQuery <- req.as[ItemQuery] + itemQuery = ItemQueryString(userQuery.query) + fixQuery = Query.Fix(user.account, None, None) + resp <- backend.simpleSearch + .searchSummaryByString(cfg.fullTextSearch.enabled)( + fixQuery, + itemQuery + ) match { + case Right(summary) => + summary.flatMap(s => Ok(Conversions.mkSearchStats(s))) + case Left(fail) => + BadRequest(BasicResult(false, fail.render)) + } + } yield resp + + //DEPRECATED + case req @ POST -> Root / "searchForm" => for { mask <- req.as[ItemSearch] _ <- logger.ftrace(s"Got search mask: $mask") @@ -111,7 +177,8 @@ object ItemRoutes { } } yield resp - case req @ POST -> Root / "searchWithTags" => + //DEPRECATED + case req @ POST -> Root / "searchFormWithTags" => for { mask <- req.as[ItemSearch] _ <- logger.ftrace(s"Got search mask: $mask") @@ -151,7 +218,7 @@ object ItemRoutes { case req @ POST -> Root / "searchIndex" => for { - mask <- req.as[ItemFtsSearch] + mask <- req.as[ItemQuery] resp <- mask.query match { case q if q.length > 1 => val ftsIn = OFulltext.FtsInput(q) @@ -159,7 +226,10 @@ object ItemRoutes { items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)( ftsIn, user.account, - Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize) + Batch( + mask.offset.getOrElse(0), + mask.limit.getOrElse(cfg.maxItemPageSize) + ).restrictLimitTo(cfg.maxItemPageSize) ) ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items)) } yield ok @@ -169,7 +239,8 @@ object ItemRoutes { } } yield resp - case req @ POST -> Root / "searchStats" => + //DEPRECATED + case req @ POST -> Root / "searchFormStats" => for { mask <- req.as[ItemSearch] query = Conversions.mkQuery(mask, user.account) @@ -479,12 +550,12 @@ object ItemRoutes { private val itemSearchMonoid: Monoid[ItemSearch] = cats.derived.semiauto.monoid - def unapply(m: ItemSearch): Option[ItemFtsSearch] = + def unapply(m: ItemSearch): Option[ItemQuery] = m.fullText match { case Some(fq) => val me = m.copy(fullText = None, offset = 0, limit = 0) if (itemSearchMonoid.empty == me) - Some(ItemFtsSearch(m.offset, m.limit, fq)) + Some(ItemQuery(m.offset.some, m.limit.some, Some(false), fq)) else None case _ => None diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index efc1d551..1064da36 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -156,10 +156,10 @@ import Api.Model.ImapSettings exposing (ImapSettings) import Api.Model.ImapSettingsList exposing (ImapSettingsList) import Api.Model.InviteResult exposing (InviteResult) import Api.Model.ItemDetail exposing (ItemDetail) -import Api.Model.ItemFtsSearch exposing (ItemFtsSearch) import Api.Model.ItemInsights exposing (ItemInsights) import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) +import Api.Model.ItemQuery exposing (ItemQuery) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.ItemsAndDate exposing (ItemsAndDate) @@ -1684,14 +1684,14 @@ moveAttachmentBefore flags itemId data receive = itemIndexSearch : Flags - -> ItemFtsSearch + -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg itemIndexSearch flags query receive = Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchIndex" , account = getAccount flags - , body = Http.jsonBody (Api.Model.ItemFtsSearch.encode query) + , body = Http.jsonBody (Api.Model.ItemQuery.encode query) , expect = Http.expectJson receive Api.Model.ItemLightList.decoder } @@ -1699,7 +1699,7 @@ itemIndexSearch flags query receive = itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg itemSearch flags search receive = Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchWithTags" + { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormWithTags" , account = getAccount flags , body = Http.jsonBody (Api.Model.ItemSearch.encode search) , expect = Http.expectJson receive Api.Model.ItemLightList.decoder @@ -1709,7 +1709,7 @@ itemSearch flags search receive = itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg itemSearchStats flags search receive = Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchStats" + { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormStats" , account = getAccount flags , body = Http.jsonBody (Api.Model.ItemSearch.encode search) , expect = Http.expectJson receive Api.Model.SearchStats.decoder From 18992ee3741bcfca7159d8015b3e8883680a3a3e Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 14:12:32 +0100 Subject: [PATCH 13/33] Deprecate search endpoints --- modules/restapi/src/main/resources/docspell-openapi.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index a56e911c..951f2395 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1314,6 +1314,7 @@ paths: post: tags: [ Item Search ] summary: Search for items. + deprecated: true description: | Search for items given a search form. The results are grouped by month and are sorted by item date (newest first). Tags and @@ -1348,6 +1349,7 @@ paths: post: tags: [ Item Search ] summary: Search for items. + deprecated: true description: | Search for items given a search form. The results are grouped by month by default. For each item, its tags and attachments @@ -1459,6 +1461,7 @@ paths: post: tags: [ Item Search ] summary: Get basic statistics about the data of a search. + deprecated: true description: | Takes a search query and returns a summary about the results. From f8307f77c63633539f99285835721bf31c203c82 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 16:51:36 +0100 Subject: [PATCH 14/33] Search by field id or name --- .../docspell/store/qb/generator/ItemQueryGenerator.scala | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index d02e9660..54632acc 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -236,7 +236,11 @@ object ItemQueryGenerator { Select( select(cfv.itemId), from(cfv).innerJoin(cf, cf.id === cfv.field), - cf.cid === coll && cf.name ==== field && Condition.CompareVal(cfv.value, op, v) + cf.cid === coll && (cf.name ==== field || cf.id ==== field) && Condition.CompareVal( + cfv.value, + op, + v + ) ) } } From 889e4f4fb0dfab9d1f0ea6824b76a72ff4fd3ba6 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 17:01:18 +0100 Subject: [PATCH 15/33] SearchMenu uses query string instead of json form --- modules/webapp/src/main/elm/Api.elm | 12 +- .../webapp/src/main/elm/Comp/SearchMenu.elm | 102 +++++---- .../src/main/elm/Data/CustomFieldChange.elm | 1 - .../webapp/src/main/elm/Data/ItemQuery.elm | 202 ++++++++++++++++++ .../webapp/src/main/elm/Page/Home/Data.elm | 8 +- .../webapp/src/main/elm/Page/Home/Update.elm | 14 +- .../webapp/src/main/elm/Page/Home/View.elm | 61 ++---- 7 files changed, 305 insertions(+), 95 deletions(-) create mode 100644 modules/webapp/src/main/elm/Data/ItemQuery.elm diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 1064da36..30202fd6 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -1696,22 +1696,22 @@ itemIndexSearch flags query receive = } -itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg +itemSearch : Flags -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg itemSearch flags search receive = Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormWithTags" + { url = flags.config.baseUrl ++ "/api/v1/sec/item/search" , account = getAccount flags - , body = Http.jsonBody (Api.Model.ItemSearch.encode search) + , body = Http.jsonBody (Api.Model.ItemQuery.encode search) , expect = Http.expectJson receive Api.Model.ItemLightList.decoder } -itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg +itemSearchStats : Flags -> ItemQuery -> (Result Http.Error SearchStats -> msg) -> Cmd msg itemSearchStats flags search receive = Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormStats" + { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchStats" , account = getAccount flags - , body = Http.jsonBody (Api.Model.ItemSearch.encode search) + , body = Http.jsonBody (Api.Model.ItemQuery.encode search) , expect = Http.expectJson receive Api.Model.SearchStats.decoder } diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 5a88fee6..1cc34890 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -3,7 +3,7 @@ module Comp.SearchMenu exposing , Msg(..) , NextState , TextSearchModel - , getItemSearch + , getItemQuery , init , isFulltextSearch , isNamesSearch @@ -21,7 +21,7 @@ import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.FolderStats exposing (FolderStats) import Api.Model.IdName exposing (IdName) import Api.Model.ItemFieldValue exposing (ItemFieldValue) -import Api.Model.ItemSearch exposing (ItemSearch) +import Api.Model.ItemQuery exposing (ItemQuery) import Api.Model.PersonList exposing (PersonList) import Api.Model.ReferenceList exposing (ReferenceList) import Api.Model.SearchStats exposing (SearchStats) @@ -38,6 +38,7 @@ import Data.DropdownStyle as DS import Data.Fields import Data.Flags exposing (Flags) import Data.Icons as Icons +import Data.ItemQuery as Q exposing (ItemQuery) import Data.PersonUse import Data.UiSettings exposing (UiSettings) import DatePicker exposing (DatePicker) @@ -234,11 +235,21 @@ getDirection model = Nothing -getItemSearch : Model -> ItemSearch -getItemSearch model = +getItemQuery : Model -> Maybe ItemQuery +getItemQuery model = let - e = - Api.Model.ItemSearch.empty + when flag body = + if flag then + Just body + + else + Nothing + + whenNot flag body = + when (not flag) body + + whenNotEmpty list f = + whenNot (List.isEmpty list) (f list) amendWildcards s = if String.startsWith "\"" s && String.endsWith "\"" s then @@ -254,35 +265,52 @@ getItemSearch model = textSearch = textSearchValue model.textSearchModel in - { e - | tagsInclude = model.tagSelection.includeTags |> List.map .tag |> List.map .id - , tagsExclude = model.tagSelection.excludeTags |> List.map .tag |> List.map .id - , corrPerson = Comp.Dropdown.getSelected model.corrPersonModel |> List.map .id |> List.head - , corrOrg = Comp.Dropdown.getSelected model.orgModel |> List.map .id |> List.head - , concPerson = Comp.Dropdown.getSelected model.concPersonModel |> List.map .id |> List.head - , concEquip = Comp.Dropdown.getSelected model.concEquipmentModel |> List.map .id |> List.head - , folder = model.selectedFolder |> Maybe.map .id - , direction = - Comp.Dropdown.getSelected model.directionModel - |> List.head - |> Maybe.map Data.Direction.toString - , inbox = model.inboxCheckbox - , dateFrom = model.fromDate - , dateUntil = model.untilDate - , dueDateFrom = model.fromDueDate - , dueDateUntil = model.untilDueDate - , name = - model.nameModel - |> Maybe.map amendWildcards - , allNames = - textSearch.nameSearch - |> Maybe.map amendWildcards - , fullText = textSearch.fullText - , tagCategoriesInclude = model.tagSelection.includeCats |> List.map .name - , tagCategoriesExclude = model.tagSelection.excludeCats |> List.map .name - , customValues = Data.CustomFieldChange.toFieldValues model.customValues - , source = model.sourceModel - } + Q.and + [ when model.inboxCheckbox (Q.Inbox True) + , whenNotEmpty (model.tagSelection.includeTags |> List.map (.tag >> .id)) + (Q.TagIds Q.AllMatch) + , whenNotEmpty (model.tagSelection.excludeTags |> List.map (.tag >> .id)) + (\ids -> Q.Not (Q.TagIds Q.AnyMatch ids)) + , whenNotEmpty (model.tagSelection.includeCats |> List.map .name) + (Q.CatNames Q.AllMatch) + , whenNotEmpty (model.tagSelection.excludeCats |> List.map .name) + (\ids -> Q.Not <| Q.CatNames Q.AnyMatch ids) + , model.selectedFolder |> Maybe.map .id |> Maybe.map (Q.FolderId Q.Eq) + , Comp.Dropdown.getSelected model.orgModel + |> List.map .id + |> List.head + |> Maybe.map (Q.CorrOrgId Q.Eq) + , Comp.Dropdown.getSelected model.corrPersonModel + |> List.map .id + |> List.head + |> Maybe.map (Q.CorrPersId Q.Eq) + , Comp.Dropdown.getSelected model.concPersonModel + |> List.map .id + |> List.head + |> Maybe.map (Q.ConcPersId Q.Eq) + , Comp.Dropdown.getSelected model.concEquipmentModel + |> List.map .id + |> List.head + |> Maybe.map (Q.ConcEquipId Q.Eq) + , whenNotEmpty (Data.CustomFieldChange.toFieldValues model.customValues) + (List.map (Q.CustomField Q.Like) >> Q.And) + , Maybe.map (Q.DateMs Q.Gte) model.fromDate + , Maybe.map (Q.DateMs Q.Lte) model.untilDate + , Maybe.map (Q.DueDateMs Q.Gte) model.fromDueDate + , Maybe.map (Q.DueDateMs Q.Lte) model.untilDueDate + , Maybe.map (Q.Source Q.Like) model.sourceModel + , model.nameModel + |> Maybe.map amendWildcards + |> Maybe.map (Q.ItemName Q.Like) + , textSearch.nameSearch + |> Maybe.map amendWildcards + |> Maybe.map Q.AllNames + , Comp.Dropdown.getSelected model.directionModel + |> List.head + |> Maybe.map Q.Dir + , textSearch.fullText + |> Maybe.map Q.Contents + ] resetModel : Model -> Model @@ -437,7 +465,7 @@ updateDrop ddm flags settings msg model = { model = mdp , cmd = Cmd.batch - [ Api.itemSearchStats flags Api.Model.ItemSearch.empty GetAllTagsResp + [ Api.itemSearchStats flags Api.Model.ItemQuery.empty GetAllTagsResp , Api.getOrgLight flags GetOrgResp , Api.getEquipments flags "" GetEquipResp , Api.getPersons flags "" GetPersonResp @@ -450,7 +478,7 @@ updateDrop ddm flags settings msg model = ResetForm -> { model = resetModel model - , cmd = Api.itemSearchStats flags Api.Model.ItemSearch.empty GetAllTagsResp + , cmd = Api.itemSearchStats flags Api.Model.ItemQuery.empty GetAllTagsResp , stateChange = True , dragDrop = DD.DragDropData ddm Nothing } diff --git a/modules/webapp/src/main/elm/Data/CustomFieldChange.elm b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm index 04f69b1c..2ee75c02 100644 --- a/modules/webapp/src/main/elm/Data/CustomFieldChange.elm +++ b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm @@ -10,7 +10,6 @@ module Data.CustomFieldChange exposing import Api.Model.CustomField exposing (CustomField) import Api.Model.CustomFieldValue exposing (CustomFieldValue) -import Api.Model.ItemFieldValue exposing (ItemFieldValue) import Dict exposing (Dict) diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm new file mode 100644 index 00000000..ab886b3f --- /dev/null +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -0,0 +1,202 @@ +module Data.ItemQuery exposing + ( AttrMatch(..) + , ItemQuery(..) + , TagMatch(..) + , and + , render + , renderMaybe + , request + ) + +{-| Models the query language for the purpose of generating a query string. +-} + +import Api.Model.CustomFieldValue exposing (CustomFieldValue) +import Api.Model.ItemQuery as RQ +import Data.Direction exposing (Direction) + + +type TagMatch + = AnyMatch + | AllMatch + + +type AttrMatch + = Eq + | Neq + | Lt + | Gt + | Lte + | Gte + | Like + + +type ItemQuery + = Inbox Bool + | And (List ItemQuery) + | Or (List ItemQuery) + | Not ItemQuery + | TagIds TagMatch (List String) + | CatNames TagMatch (List String) + | FolderId AttrMatch String + | CorrOrgId AttrMatch String + | CorrPersId AttrMatch String + | ConcPersId AttrMatch String + | ConcEquipId AttrMatch String + | CustomField AttrMatch CustomFieldValue + | DateMs AttrMatch Int + | DueDateMs AttrMatch Int + | Source AttrMatch String + | Dir Direction + | ItemIdIn (List String) + | ItemName AttrMatch String + | AllNames String + | Contents String + + +and : List (Maybe ItemQuery) -> Maybe ItemQuery +and list = + case List.filterMap identity list of + [] -> + Nothing + + es -> + Just (And es) + + +request : Maybe ItemQuery -> RQ.ItemQuery +request mq = + { offset = Nothing + , limit = Nothing + , withDetails = Just True + , query = renderMaybe mq + } + + +renderMaybe : Maybe ItemQuery -> String +renderMaybe mq = + Maybe.map render mq + |> Maybe.withDefault "" + + +render : ItemQuery -> String +render q = + let + boolStr flag = + if flag then + "yes" + + else + "no" + + between left right str = + left ++ str ++ right + + surround lr str = + between lr lr str + + tagMatchStr tm = + case tm of + AnyMatch -> + ":" + + AllMatch -> + "=" + + quoteStr = + --TODO escape quotes + surround "\"" + in + case q of + And inner -> + List.map render inner + |> String.join " " + |> between "(& " " )" + + Or inner -> + List.map render inner + |> String.join " " + |> between "(| " " )" + + Not inner -> + "!" ++ render inner + + Inbox flag -> + "inbox:" ++ boolStr flag + + TagIds m ids -> + List.map quoteStr ids + |> String.join "," + |> between ("tag.id" ++ tagMatchStr m) "" + + CatNames m ids -> + List.map quoteStr ids + |> String.join "," + |> between ("cat" ++ tagMatchStr m) "" + + FolderId m id -> + "folder.id" ++ attrMatch m ++ quoteStr id + + CorrOrgId m id -> + "correspondent.org.id" ++ attrMatch m ++ quoteStr id + + CorrPersId m id -> + "correspondent.person.id" ++ attrMatch m ++ quoteStr id + + ConcPersId m id -> + "concerning.person.id" ++ attrMatch m ++ quoteStr id + + ConcEquipId m id -> + "concerning.equip.id" ++ attrMatch m ++ quoteStr id + + CustomField m kv -> + "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value + + DateMs m ms -> + "date" ++ attrMatch m ++ "ms" ++ String.fromInt ms + + DueDateMs m ms -> + "due" ++ attrMatch m ++ "ms" ++ String.fromInt ms + + Source m str -> + "source" ++ attrMatch m ++ quoteStr str + + Dir dir -> + "incoming:" ++ boolStr (dir == Data.Direction.Incoming) + + ItemIdIn ids -> + "id~=" ++ String.join "," ids + + ItemName m str -> + "name" ++ attrMatch m ++ quoteStr str + + AllNames str -> + "$names:" ++ quoteStr str + + Contents str -> + "content:" ++ quoteStr str + + +attrMatch : AttrMatch -> String +attrMatch am = + case am of + Eq -> + "=" + + Neq -> + "!=" + + Like -> + ":" + + Gt -> + ">" + + Gte -> + ">=" + + Lt -> + "<" + + Lte -> + "<=" diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index b0bf156c..1e13d309 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -31,6 +31,7 @@ import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.ItemNav exposing (ItemNav) +import Data.ItemQuery as Q import Data.Items import Data.UiSettings exposing (UiSettings) import Http @@ -239,12 +240,13 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg doSearchDefaultCmd param model = let smask = - Comp.SearchMenu.getItemSearch model.searchMenuModel + Q.request + (Comp.SearchMenu.getItemQuery model.searchMenuModel) mask = { smask - | limit = param.pageSize - , offset = param.offset + | limit = Just param.pageSize + , offset = Just param.offset } in if param.offset == 0 then diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index a2cddb7f..5a7e7594 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -3,7 +3,7 @@ module Page.Home.Update exposing (update) import Api import Api.Model.IdList exposing (IdList) import Api.Model.ItemLightList exposing (ItemLightList) -import Api.Model.ItemSearch +import Api.Model.ItemQuery import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList @@ -13,6 +13,7 @@ import Comp.LinkTarget exposing (LinkTarget) import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) +import Data.ItemQuery as Q import Data.ItemSelection import Data.Items import Data.UiSettings exposing (UiSettings) @@ -648,16 +649,15 @@ loadChangedItems flags ids = else let - searchInit = - Api.Model.ItemSearch.empty - idList = - IdList (Set.toList ids) + Set.toList ids + + searchInit = + Q.request (Just <| Q.ItemIdIn idList) search = { searchInit - | itemSubset = Just idList - , limit = Set.size ids + | limit = Just <| Set.size ids } in Api.itemSearch flags search ReplaceChangedItemsResp diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index f4d8124d..c12a31c9 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -320,8 +320,9 @@ viewSearchBar flags model = [ a [ classList [ ( "search-menu-toggle ui icon button", True ) - , ( "primary", not (searchMenuFilled model) ) - , ( "secondary", searchMenuFilled model ) + + -- , ( "primary", not (searchMenuFilled model) ) + -- , ( "secondary", searchMenuFilled model ) ] , onClick ToggleSearchMenu , href "#" @@ -332,24 +333,23 @@ viewSearchBar flags model = , div [ class "right menu" ] [ div [ class "fitted item" ] [ div [ class "ui left icon right action input" ] - [ i - [ classList - [ ( "search link icon", not model.searchInProgress ) - , ( "loading spinner icon", model.searchInProgress ) - ] - , href "#" - , onClick (DoSearch model.searchTypeDropdownValue) - ] - (if hasMoreSearch model then - [ i [ class "icons search-corner-icons" ] - [ i [ class "tiny blue circle icon" ] [] - ] - ] - - else - [] - ) - , input + [ -- i + -- [ classList + -- [ ( "search link icon", not model.searchInProgress ) + -- , ( "loading spinner icon", model.searchInProgress ) + -- ] + -- , href "#" + -- , onClick (DoSearch model.searchTypeDropdownValue) + -- ] + -- (if hasMoreSearch model then + -- [ i [ class "icons search-corner-icons" ] + -- [ i [ class "tiny blue circle icon" ] [] + -- ] + -- ] + -- else + -- [] + -- ) + input [ type_ "text" , placeholder (case model.searchTypeDropdownValue of @@ -384,27 +384,6 @@ viewSearchBar flags model = ] -searchMenuFilled : Model -> Bool -searchMenuFilled model = - let - is = - Comp.SearchMenu.getItemSearch model.searchMenuModel - in - is /= Api.Model.ItemSearch.empty - - -hasMoreSearch : Model -> Bool -hasMoreSearch model = - let - is = - Comp.SearchMenu.getItemSearch model.searchMenuModel - - is_ = - { is | allNames = Nothing, fullText = Nothing } - in - is_ /= Api.Model.ItemSearch.empty - - deleteAllDimmer : Comp.YesNoDimmer.Settings deleteAllDimmer = { message = "Really delete all selected items?" From 489581d90b1a851fc75c47cc967d6f7d990f8de3 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 20:26:30 +0100 Subject: [PATCH 16/33] Fix parsing nested expressions Since whitespace is used as a separator, it cannot be consumed by and/or parens. --- .../docspell/query/internal/BasicParser.scala | 6 ++--- .../query/internal/ExprParserTest.scala | 22 +++++++++++++++++++ .../query/internal/SimpleExprParserTest.scala | 4 ++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala index c4edd070..bb38fc90 100644 --- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala @@ -21,13 +21,13 @@ object BasicParser { (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet val parenAnd: P[Unit] = - P.stringIn(List("(&", "(and")).void.surroundedBy(ws0) + P.stringIn(List("(&", "(and")).void <* ws0 val parenClose: P[Unit] = - P.char(')').surroundedBy(ws0) + ws0.soft.with1 *> P.char(')') val parenOr: P[Unit] = - P.stringIn(List("(|", "(or")).void.surroundedBy(ws0) + P.stringIn(List("(|", "(or")).void <* ws0 val identParser: P[String] = P.charsWhile(identChars.contains) diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala index 6f0fe07f..07e14e9b 100644 --- a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala @@ -67,4 +67,26 @@ class ExprParserTest extends FunSuite with ValueHelper { ) ) } + + test("nest and/ with simple expr") { + val p = ExprParser.exprParser + assertEquals( + p.parseAll("(& (& f:usd=\"4.99\" ) source:*test* )"), + Right( + Expr.and( + Expr.and(Expr.CustomFieldMatch("usd", Operator.Eq, "4.99")), + Expr.string(Operator.Like, Attr.ItemSource, "*test*") + ) + ) + ) + assertEquals( + p.parseAll("(& (& f:usd=\"4.99\" ) (| source:*test*) )"), + Right( + Expr.and( + Expr.and(Expr.CustomFieldMatch("usd", Operator.Eq, "4.99")), + Expr.or(Expr.string(Operator.Like, Attr.ItemSource, "*test*")) + ) + ) + ) + } } diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala index 32c152a0..7a107ad7 100644 --- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala @@ -173,6 +173,10 @@ class SimpleExprParserTest extends FunSuite with ValueHelper { p.parseAll("f:usd=26.66"), Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66")) ) + assertEquals( + p.parseAll("f:usd=\"26.66\""), + Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66")) + ) } } From 168f5a1a988b3a0fdebb354bc4afed5327c081eb Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 20:54:35 +0100 Subject: [PATCH 17/33] Fix like search for custom fields --- .../scala/docspell/store/qb/generator/ItemQueryGenerator.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 54632acc..7904b62e 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -232,7 +232,7 @@ object ItemQueryGenerator { def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = { val cf = RCustomField.as("cf") val cfv = RCustomFieldValue.as("cfv") - val v = if (op == QOp.LowerLike) value.toLowerCase else value + val v = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value Select( select(cfv.itemId), from(cfv).innerJoin(cf, cf.id === cfv.field), From b4b5acde13c9bae52a9c5838580f45fcf5bbdc49 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 1 Mar 2021 22:45:17 +0100 Subject: [PATCH 18/33] Enable power search for power users via ui settings A different search bar is presented if enabled in ui settings that allows to search via the new query language. --- .../src/main/elm/Comp/UiSettingsForm.elm | 21 +++++ .../webapp/src/main/elm/Data/ItemQuery.elm | 4 + .../webapp/src/main/elm/Data/UiSettings.elm | 5 ++ .../webapp/src/main/elm/Page/Home/Data.elm | 11 ++- .../webapp/src/main/elm/Page/Home/Update.elm | 11 ++- .../webapp/src/main/elm/Page/Home/View2.elm | 83 ++++++++++++------- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm index 9594c7e5..14cbc18c 100644 --- a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm @@ -58,6 +58,7 @@ type alias Model = , showPatternHelp : Bool , searchStatsVisible : Bool , sideMenuVisible : Bool + , powerSearchEnabled : Bool , openTabs : Set String } @@ -151,6 +152,7 @@ init flags settings = , showPatternHelp = False , searchStatsVisible = settings.searchStatsVisible , sideMenuVisible = settings.sideMenuVisible + , powerSearchEnabled = settings.powerSearchEnabled , openTabs = Set.empty } , Api.getTags flags "" GetTagsResp @@ -178,6 +180,7 @@ type Msg | ToggleSearchStatsVisible | ToggleAkkordionTab String | ToggleSideMenuVisible + | TogglePowerSearch @@ -460,6 +463,15 @@ update sett msg model = , Just { sett | sideMenuVisible = next } ) + TogglePowerSearch -> + let + next = + not model.powerSearchEnabled + in + ( { model | powerSearchEnabled = next } + , Just { sett | powerSearchEnabled = next } + ) + --- View @@ -763,6 +775,15 @@ settingFormTabs flags _ model = , label = "Show basic search statistics by default" } ] + , div [ class "mb-4" ] + [ MB.viewItem <| + MB.Checkbox + { id = "uisetting-powersearch-enabled" + , value = model.powerSearchEnabled + , tagger = \_ -> TogglePowerSearch + , label = "Enable power-user search bar" + } + ] ] } , { title = "Item Cards" diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index ab886b3f..4105bac9 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -52,6 +52,7 @@ type ItemQuery | ItemName AttrMatch String | AllNames String | Contents String + | Fragment String and : List (Maybe ItemQuery) -> Maybe ItemQuery @@ -176,6 +177,9 @@ render q = Contents str -> "content:" ++ quoteStr str + Fragment str -> + "(& " ++ str ++ " )" + attrMatch : AttrMatch -> String attrMatch am = diff --git a/modules/webapp/src/main/elm/Data/UiSettings.elm b/modules/webapp/src/main/elm/Data/UiSettings.elm index 0153b63c..988eb2d4 100644 --- a/modules/webapp/src/main/elm/Data/UiSettings.elm +++ b/modules/webapp/src/main/elm/Data/UiSettings.elm @@ -62,6 +62,7 @@ type alias StoredUiSettings = , cardPreviewFullWidth : Bool , uiTheme : Maybe String , sideMenuVisible : Bool + , powerSearchEnabled : Bool } @@ -92,6 +93,7 @@ type alias UiSettings = , cardPreviewFullWidth : Bool , uiTheme : UiTheme , sideMenuVisible : Bool + , powerSearchEnabled : Bool } @@ -162,6 +164,7 @@ defaults = , cardPreviewFullWidth = False , uiTheme = Data.UiTheme.Light , sideMenuVisible = True + , powerSearchEnabled = False } @@ -213,6 +216,7 @@ merge given fallback = Maybe.andThen Data.UiTheme.fromString given.uiTheme |> Maybe.withDefault fallback.uiTheme , sideMenuVisible = given.sideMenuVisible + , powerSearchEnabled = given.powerSearchEnabled } @@ -249,6 +253,7 @@ toStoredUiSettings settings = , cardPreviewFullWidth = settings.cardPreviewFullWidth , uiTheme = Just (Data.UiTheme.toString settings.uiTheme) , sideMenuVisible = settings.sideMenuVisible + , powerSearchEnabled = settings.powerSearchEnabled } diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 1e13d309..809d2526 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -56,6 +56,7 @@ type alias Model = , dragDropData : DD.DragDropData , scrollToCard : Maybe String , searchStats : SearchStats + , powerSearchInput : Maybe String } @@ -121,6 +122,7 @@ init flags viewMode = , scrollToCard = Nothing , viewMode = viewMode , searchStats = Api.Model.SearchStats.empty + , powerSearchInput = Nothing } @@ -194,6 +196,8 @@ type Msg | SetLinkTarget LinkTarget | SearchStatsResp (Result Http.Error SearchStats) | TogglePreviewFullWidth + | SetPowerSearch String + | KeyUpPowerSearchbarMsg (Maybe KeyCode) type SearchType @@ -240,8 +244,11 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg doSearchDefaultCmd param model = let smask = - Q.request - (Comp.SearchMenu.getItemQuery model.searchMenuModel) + Q.request <| + Q.and + [ Comp.SearchMenu.getItemQuery model.searchMenuModel + , Maybe.map Q.Fragment model.powerSearchInput + ] mask = { smask diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 5a7e7594..d161d58c 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -54,7 +54,7 @@ update mId key flags settings msg model = ResetSearch -> let nm = - { model | searchOffset = 0 } + { model | searchOffset = 0, powerSearchInput = Nothing } in update mId key flags settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm @@ -580,6 +580,15 @@ update mId key flags settings msg model = in noSub ( model, cmd ) + SetPowerSearch str -> + noSub ( { model | powerSearchInput = Util.Maybe.fromString str }, Cmd.none ) + + KeyUpPowerSearchbarMsg (Just Enter) -> + update mId key flags settings (DoSearch model.searchTypeDropdownValue) model + + KeyUpPowerSearchbarMsg _ -> + withSub ( model, Cmd.none ) + --- Helpers diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm index ee8c3bdb..82db65c7 100644 --- a/modules/webapp/src/main/elm/Page/Home/View2.elm +++ b/modules/webapp/src/main/elm/Page/Home/View2.elm @@ -92,7 +92,7 @@ itemsBar flags settings model = defaultMenuBar : Flags -> UiSettings -> Model -> Html Msg -defaultMenuBar flags settings model = +defaultMenuBar _ settings model = let btnStyle = S.secondaryBasicButton ++ " text-sm" @@ -100,6 +100,53 @@ defaultMenuBar flags settings model = searchInput = Comp.SearchMenu.textSearchString model.searchMenuModel.textSearchModel + + simpleSearchBar = + div + [ class "relative flex flex-row" ] + [ input + [ type_ "text" + , placeholder + (case model.searchTypeDropdownValue of + ContentOnlySearch -> + "Content search…" + + BasicSearch -> + "Search in names…" + ) + , onInput SetBasicSearch + , Util.Html.onKeyUpCode KeyUpSearchbarMsg + , Maybe.map value searchInput + |> Maybe.withDefault (value "") + , class (String.replace "rounded" "" S.textInput) + , class "py-1 text-sm border-r-0 rounded-l" + ] + [] + , a + [ class S.secondaryBasicButtonPlain + , class "text-sm px-4 py-2 border rounded-r" + , href "#" + , onClick ToggleSearchType + ] + [ i [ class "fa fa-exchange-alt" ] [] + ] + ] + + powerSearchBar = + div + [ class "relative flex flex-grow flex-row" ] + [ input + [ type_ "text" + , placeholder "Search query …" + , onInput SetPowerSearch + , Util.Html.onKeyUpCode KeyUpPowerSearchbarMsg + , Maybe.map value model.powerSearchInput + |> Maybe.withDefault (value "") + , class S.textInput + , class "text-sm " + ] + [] + ] in MB.view { end = @@ -129,35 +176,11 @@ defaultMenuBar flags settings model = ] , start = [ MB.CustomElement <| - div - [ class "relative flex flex-row" ] - [ input - [ type_ "text" - , placeholder - (case model.searchTypeDropdownValue of - ContentOnlySearch -> - "Content search…" + if settings.powerSearchEnabled then + powerSearchBar - BasicSearch -> - "Search in names…" - ) - , onInput SetBasicSearch - , Util.Html.onKeyUpCode KeyUpSearchbarMsg - , Maybe.map value searchInput - |> Maybe.withDefault (value "") - , class (String.replace "rounded" "" S.textInput) - , class "py-1 text-sm border-r-0 rounded-l" - ] - [] - , a - [ class S.secondaryBasicButtonPlain - , class "text-sm px-4 py-2 border rounded-r" - , href "#" - , onClick ToggleSearchType - ] - [ i [ class "fa fa-exchange-alt" ] [] - ] - ] + else + simpleSearchBar , MB.CustomButton { tagger = TogglePreviewFullWidth , label = "" @@ -271,7 +294,7 @@ searchStats _ settings model = itemCardList : Flags -> UiSettings -> Model -> List (Html Msg) -itemCardList flags settings model = +itemCardList _ settings model = let itemViewCfg = case model.viewMode of From a48504debbf888ec16028043929226c1de22b63a Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Tue, 2 Mar 2021 21:09:31 +0100 Subject: [PATCH 19/33] Specificly search for field id vs name --- .../src/main/scala/docspell/query/ItemQuery.scala | 2 ++ .../scala/docspell/query/internal/ExprUtil.scala | 2 ++ .../docspell/query/internal/SimpleExprParser.scala | 10 ++++++++-- .../store/qb/generator/ItemQueryGenerator.scala | 12 +++++++++--- .../scala/docspell/store/queries/QueryWildcard.scala | 7 +++++++ modules/webapp/src/main/elm/Comp/SearchMenu.elm | 2 +- modules/webapp/src/main/elm/Data/ItemQuery.elm | 4 ++++ 7 files changed, 33 insertions(+), 6 deletions(-) diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala index ec824ecb..8160da7d 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -103,6 +103,8 @@ object ItemQuery { final case class CustomFieldMatch(name: String, op: Operator, value: String) extends Expr + final case class CustomFieldIdMatch(id: String, op: Operator, value: String) + extends Expr final case class Fulltext(query: String) extends Expr diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala index 75007d11..62489247 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -56,5 +56,7 @@ object ExprUtil { expr case CustomFieldMatch(_, _, _) => expr + case CustomFieldIdMatch(_, _, _) => + expr } } diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala index f76fe947..55b92188 100644 --- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -2,7 +2,6 @@ package docspell.query.internal import cats.parse.{Parser => P} -import docspell.query.ItemQuery.Expr.CustomFieldMatch import docspell.query.ItemQuery._ object SimpleExprParser { @@ -62,7 +61,13 @@ object SimpleExprParser { val customFieldExpr: P[Expr.CustomFieldMatch] = (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map { case ((name, op), value) => - CustomFieldMatch(name, op, value) + Expr.CustomFieldMatch(name, op, value) + } + + val customFieldIdExpr: P[Expr.CustomFieldIdMatch] = + (P.string("f.id:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map { + case ((name, op), value) => + Expr.CustomFieldIdMatch(name, op, value) } val inboxExpr: P[Expr.InboxExpr] = @@ -81,6 +86,7 @@ object SimpleExprParser { tagIdExpr, tagExpr, catExpr, + customFieldIdExpr, customFieldExpr, inboxExpr, dirExpr diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 7904b62e..1ed9e7df 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -145,7 +145,10 @@ object ItemQueryGenerator { } case Expr.CustomFieldMatch(field, op, value) => - tables.item.id.in(itemsWithCustomField(coll, field, makeOp(op), value)) + tables.item.id.in(itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value)) + + case Expr.CustomFieldIdMatch(field, op, value) => + tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value)) case Expr.Fulltext(_) => // not supported here @@ -229,18 +232,21 @@ object ItemQueryGenerator { QOp.Lte } - def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = { + private def itemsWithCustomField( + sel: RCustomField.Table => Condition + )(coll: Ident, op: QOp, value: String): Select = { val cf = RCustomField.as("cf") val cfv = RCustomFieldValue.as("cfv") val v = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value Select( select(cfv.itemId), from(cfv).innerJoin(cf, cf.id === cfv.field), - cf.cid === coll && (cf.name ==== field || cf.id ==== field) && Condition.CompareVal( + cf.cid === coll && sel(cf) && Condition.CompareVal( cfv.value, op, v ) ) } + } diff --git a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala index 5ab70d20..16a2f1bb 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala @@ -19,4 +19,11 @@ object QueryWildcard { else res } + def atEnd(s: String): String = + if (s.endsWith("*")) s"${s.dropRight(1)}%" + else s + + def addAtEnd(s: String): String = + if (s.endsWith("*")) atEnd(s) + else s"${s}%" } diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 1cc34890..669d71b7 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -293,7 +293,7 @@ getItemQuery model = |> List.head |> Maybe.map (Q.ConcEquipId Q.Eq) , whenNotEmpty (Data.CustomFieldChange.toFieldValues model.customValues) - (List.map (Q.CustomField Q.Like) >> Q.And) + (List.map (Q.CustomFieldId Q.Like) >> Q.And) , Maybe.map (Q.DateMs Q.Gte) model.fromDate , Maybe.map (Q.DateMs Q.Lte) model.untilDate , Maybe.map (Q.DueDateMs Q.Gte) model.fromDueDate diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 4105bac9..d01464b8 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -44,6 +44,7 @@ type ItemQuery | ConcPersId AttrMatch String | ConcEquipId AttrMatch String | CustomField AttrMatch CustomFieldValue + | CustomFieldId AttrMatch CustomFieldValue | DateMs AttrMatch Int | DueDateMs AttrMatch Int | Source AttrMatch String @@ -153,6 +154,9 @@ render q = CustomField m kv -> "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value + CustomFieldId m kv -> + "f.id:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value + DateMs m ms -> "date" ++ attrMatch m ++ "ms" ++ String.fromInt ms From 71985244f19229af5968168c8ff2bd9c064d0f99 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Tue, 2 Mar 2021 22:14:26 +0100 Subject: [PATCH 20/33] Use a better representation for macros --- .../main/scala/docspell/query/ItemQuery.scala | 20 ++++++ .../docspell/query/internal/ExprUtil.scala | 7 ++- .../docspell/query/internal/MacroParser.scala | 62 +++++-------------- .../query/internal/MacroParserTest.scala | 16 ++--- .../qb/generator/ItemQueryGenerator.scala | 9 ++- 5 files changed, 53 insertions(+), 61 deletions(-) diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala index 8160da7d..acf2307a 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala @@ -108,6 +108,26 @@ object ItemQuery { final case class Fulltext(query: String) extends Expr + // things that can be expressed with terms above + sealed trait MacroExpr extends Expr { + def body: Expr + } + case class NamesMacro(searchTerm: String) extends MacroExpr { + val body = + Expr.or( + like(Attr.ItemName, searchTerm), + like(Attr.ItemNotes, searchTerm), + like(Attr.Correspondent.OrgName, searchTerm), + like(Attr.Correspondent.PersonName, searchTerm), + like(Attr.Concerning.PersonName, searchTerm), + like(Attr.Concerning.EquipName, searchTerm) + ) + } + case class DateRangeMacro(attr: DateAttr, left: Date, right: Date) extends MacroExpr { + val body = + and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right)) + } + def or(expr0: Expr, exprs: Expr*): OrExpr = OrExpr(Nel.of(expr0, exprs: _*)) diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala index 62489247..a1fa2045 100644 --- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -5,8 +5,8 @@ import docspell.query.ItemQuery._ object ExprUtil { - /** Does some basic transformation, like unfolding deeply nested and - * trees containing one value etc. + /** Does some basic transformation, like unfolding nested and trees + * containing one value etc. */ def reduce(expr: Expr): Expr = expr match { @@ -30,6 +30,9 @@ object ExprUtil { expr } + case m: MacroExpr => + reduce(m.body) + case DirectionExpr(_) => expr diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala index 2a2c2d41..856a2d96 100644 --- a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala +++ b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala @@ -5,61 +5,29 @@ import cats.parse.{Parser => P} import docspell.query.ItemQuery._ object MacroParser { - private[this] val macroDef: P[String] = - P.char('$') *> BasicParser.identParser <* P.char(':') + private def macroDef(name: String): P[Unit] = + P.char('$').soft.with1 *> P.string(name) <* P.char(':') - def parser[A](macros: Map[String, P[A]]): P[A] = { - val p: P[P[A]] = macroDef.map { name => - macros - .get(name) - .getOrElse(P.failWith(s"Unknown macro: $name")) + private def dateRangeMacroImpl( + name: String, + attr: Attr.DateAttr + ): P[Expr.DateRangeMacro] = + (macroDef(name) *> DateParser.dateRange).map { case (left, right) => + Expr.DateRangeMacro(attr, left, right) } - val px = (p ~ P.index ~ BasicParser.singleString).map { case ((pexpr, index), str) => - pexpr - .parseAll(str) - .left - .map(err => err.copy(failedAtOffset = err.failedAtOffset + index)) - } + val namesMacro: P[Expr.NamesMacro] = + (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply) - P.select(px)(P.Fail) - } + val dateRangeMacro: P[Expr.DateRangeMacro] = + dateRangeMacroImpl("datein", Attr.Date) - // --- definitions of available macros - - /** Expands in an OR expression that matches name fields of item and - * correspondent/concerning metadata. - */ - val names: P[Expr] = - P.string(P.anyChar.rep.void).map { input => - Expr.or( - Expr.like(Attr.ItemName, input), - Expr.like(Attr.ItemNotes, input), - Expr.like(Attr.Correspondent.OrgName, input), - Expr.like(Attr.Correspondent.PersonName, input), - Expr.like(Attr.Concerning.PersonName, input), - Expr.like(Attr.Concerning.EquipName, input) - ) - } - - def dateRange(attr: Attr.DateAttr): P[Expr] = - DateParser.dateRange.map { case (left, right) => - Expr.and( - Expr.date(Operator.Gte, attr, left), - Expr.date(Operator.Lte, attr, right) - ) - } + val dueDateRangeMacro: P[Expr.DateRangeMacro] = + dateRangeMacroImpl("duein", Attr.DueDate) // --- all macro parser - val allMacros: Map[String, P[Expr]] = - Map( - "names" -> names, - "datein" -> dateRange(Attr.Date), - "duein" -> dateRange(Attr.DueDate) - ) - val all: P[Expr] = - parser(allMacros) + P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro)) } diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala index 045d1120..def772bd 100644 --- a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala +++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala @@ -1,19 +1,15 @@ package docspell.query.internal import munit._ -import cats.parse.{Parser => P} +//import cats.parse.{Parser => P} +import docspell.query.ItemQuery.Expr class MacroParserTest extends FunSuite { - test("fail with unkown macro names") { - val p = MacroParser.parser(Map.empty) - assert(p.parseAll("$bla:blup").isLeft) // TODO check error message + test("start with $") { + val p = MacroParser.namesMacro + assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test"))) + assert(p.parseAll("names:test").isLeft) } - test("select correct parser") { - val p = - MacroParser.parser[Int](Map("one" -> P.anyChar.as(1), "two" -> P.anyChar.as(2))) - assertEquals(p.parseAll("$one:y"), Right(1)) - assertEquals(p.parseAll("$two:y"), Right(2)) - } } diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 1ed9e7df..18cdf909 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -145,7 +145,9 @@ object ItemQueryGenerator { } case Expr.CustomFieldMatch(field, op, value) => - tables.item.id.in(itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value)) + tables.item.id.in( + itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value) + ) case Expr.CustomFieldIdMatch(field, op, value) => tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value)) @@ -153,6 +155,9 @@ object ItemQueryGenerator { case Expr.Fulltext(_) => // not supported here Condition.unit + + case _: Expr.MacroExpr => + Condition.unit } private def dateToTimestamp(today: LocalDate)(date: Date): Timestamp = @@ -233,7 +238,7 @@ object ItemQueryGenerator { } private def itemsWithCustomField( - sel: RCustomField.Table => Condition + sel: RCustomField.Table => Condition )(coll: Ident, op: QOp, value: String): Select = { val cf = RCustomField.as("cf") val cfv = RCustomFieldValue.as("cfv") From d4006461f64f5c5c4657dc5b294b4a6ea0e4063d Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Tue, 2 Mar 2021 00:51:13 +0100 Subject: [PATCH 21/33] Serving scalajs artifacts and provide errors to js --- build.sbt | 17 +++++++++-- .../docspell/query/js/JSItemQueryParser.scala | 29 +++++++++++++++++++ .../src/main/scala/docspell/query/Date.scala | 0 .../main/scala/docspell/query/ItemQuery.scala | 0 .../docspell/query/ItemQueryParser.scala | 5 ---- .../scala/docspell/query/ParseFailure.scala | 0 .../docspell/query/internal/AttrParser.scala | 0 .../docspell/query/internal/BasicParser.scala | 0 .../docspell/query/internal/DateParser.scala | 0 .../docspell/query/internal/ExprParser.scala | 0 .../docspell/query/internal/ExprUtil.scala | 0 .../docspell/query/internal/MacroParser.scala | 0 .../query/internal/OperatorParser.scala | 0 .../query/internal/SimpleExprParser.scala | 0 .../docspell/query/internal/StringUtil.scala | 0 .../query/internal/AttrParserTest.scala | 0 .../query/internal/BasicParserTest.scala | 0 .../query/internal/DateParserTest.scala | 0 .../query/internal/ExprParserTest.scala | 0 .../query/internal/ItemQueryParserTest.scala | 0 .../query/internal/MacroParserTest.scala | 0 .../query/internal/OperatorParserTest.scala | 0 .../query/internal/SimpleExprParserTest.scala | 0 .../docspell/query/internal/ValueHelper.scala | 0 .../restserver/webapp/TemplateRoutes.scala | 3 +- 25 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala rename modules/query/{ => shared}/src/main/scala/docspell/query/Date.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/ItemQuery.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/ItemQueryParser.scala (86%) rename modules/query/{ => shared}/src/main/scala/docspell/query/ParseFailure.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/AttrParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/BasicParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/DateParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/ExprParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/ExprUtil.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/MacroParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/OperatorParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/SimpleExprParser.scala (100%) rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/StringUtil.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/AttrParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/BasicParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/DateParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ExprParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/MacroParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/OperatorParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala (100%) rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ValueHelper.scala (100%) diff --git a/build.sbt b/build.sbt index 1c4c170b..3a1ca00a 100644 --- a/build.sbt +++ b/build.sbt @@ -86,7 +86,7 @@ val stylesSettings = Seq( Compile / resourceGenerators += stylesBuild.taskValue ) -val webjarSettings = Seq( +def webjarSettings(queryJS: Project) = Seq( Compile / resourceGenerators += Def.task { copyWebjarResources( Seq((sourceDirectory in Compile).value / "webjar"), @@ -96,6 +96,18 @@ val webjarSettings = Seq( streams.value.log ) }.taskValue, + Compile / resourceGenerators += Def.task { + val logger = streams.value.log + val out = (queryJS/Compile/fullOptJS).value + logger.info(s"Produced query js file: ${out.data}") + copyWebjarResources( + Seq(out.data), + (Compile/resourceManaged).value, + name.value, + version.value, + logger + ) + }.taskValue, watchSources += Watched.WatchSource( (Compile / sourceDirectory).value / "webjar", FileFilter.globFilter("*.js") || FileFilter.globFilter("*.css"), @@ -273,7 +285,6 @@ ${lines.map(_._1).mkString(",\n")} val query = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) - .crossType(CrossType.Pure) .in(file("modules/query")) .disablePlugins(RevolverPlugin) .settings(sharedSettings) @@ -446,7 +457,7 @@ val webapp = project .settings(sharedSettings) .settings(elmSettings) .settings(stylesSettings) - .settings(webjarSettings) + .settings(webjarSettings(query.js)) .settings( name := "docspell-webapp", openapiTargetLanguage := Language.Elm, diff --git a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala new file mode 100644 index 00000000..68f7cd5e --- /dev/null +++ b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala @@ -0,0 +1,29 @@ +package docspell.query.js + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +import docspell.query.ItemQueryParser + +@JSExportTopLevel("DsItemQueryParser") +object JSItemQueryParser { + + @JSExport + def parseToFailure(input: String): Failure = + ItemQueryParser + .parse(input) + .swap + .toOption + .map(fr => + new Failure( + fr.input, + fr.failedAt, + js.Array(fr.messages.toList.toSeq.map(_.msg): _*) + ) + ) + .orNull + + @JSExportAll + case class Failure(input: String, failedAt: Int, messages: js.Array[String]) + +} diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/shared/src/main/scala/docspell/query/Date.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/Date.scala rename to modules/query/shared/src/main/scala/docspell/query/Date.scala diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/ItemQuery.scala rename to modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala similarity index 86% rename from modules/query/src/main/scala/docspell/query/ItemQueryParser.scala rename to modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala index cf9b491c..2c178140 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala @@ -1,14 +1,10 @@ package docspell.query -import scala.scalajs.js.annotation._ - import docspell.query.internal.ExprParser import docspell.query.internal.ExprUtil -@JSExportTopLevel("DsItemQueryParser") object ItemQueryParser { - @JSExport def parse(input: String): Either[ParseFailure, ItemQuery] = if (input.isEmpty) Right(ItemQuery.all) else { @@ -22,5 +18,4 @@ object ItemQueryParser { def parseUnsafe(input: String): ItemQuery = parse(input).fold(m => sys.error(m.render), identity) - } diff --git a/modules/query/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/ParseFailure.scala rename to modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/AttrParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/BasicParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/DateParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/ExprParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/MacroParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/StringUtil.scala similarity index 100% rename from modules/query/src/main/scala/docspell/query/internal/StringUtil.scala rename to modules/query/shared/src/main/scala/docspell/query/internal/StringUtil.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/BasicParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/BasicParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/DateParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/DateParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ExprParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/ExprParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/OperatorParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/OperatorParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala diff --git a/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ValueHelper.scala similarity index 100% rename from modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala rename to modules/query/shared/src/test/scala/docspell/query/internal/ValueHelper.scala diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala index b920168a..9d59c201 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala @@ -170,7 +170,8 @@ object TemplateRoutes { chooseUi(uiVersion), Seq( "/app/assets" + Webjars.clipboardjs + "/clipboard.min.js", - s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js" + s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js", + s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-query-opt.js" ), s"/app/assets/docspell-webapp/${BuildInfo.version}/favicon", s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js", From 1c834cbb771210b4a702ffe3babe84bac0bb6feb Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Wed, 3 Mar 2021 01:58:24 +0100 Subject: [PATCH 22/33] Correctly compare numeric field values --- .../migration/h2/V1.21.0__cast_function.sql | 12 +++++++ .../mariadb/V1.21.0__cast_function.sql | 5 +++ .../postgresql/V1.21.0__cast_function.sql | 9 +++++ .../scala/docspell/store/qb/DBFunction.scala | 2 ++ .../main/scala/docspell/store/qb/DSL.scala | 3 ++ .../qb/generator/ItemQueryGenerator.scala | 35 ++++++++++++++----- .../store/qb/impl/DBFunctionBuilder.scala | 3 ++ 7 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql diff --git a/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql new file mode 100644 index 00000000..146bf3d8 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql @@ -0,0 +1,12 @@ +DROP ALIAS IF EXISTS CAST_TO_NUMERIC; +CREATE ALIAS CAST_TO_NUMERIC AS ' +import java.text.*; +import java.math.*; +@CODE +BigDecimal castToNumeric(String s) throws Exception { + try { return new BigDecimal(s); } + catch (Exception e) { + return null; + } +} +' diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql new file mode 100644 index 00000000..24ae76de --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql @@ -0,0 +1,5 @@ +-- Create a function to cast to a numeric, if an error occurs return null +-- Could not get it working with decimal type, so using double +create or replace function CAST_TO_NUMERIC (s char(255)) +returns double deterministic +return cast(s as double); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql new file mode 100644 index 00000000..b603275c --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql @@ -0,0 +1,9 @@ +-- Create a function to cast to a numeric, if an error occurs return null +create or replace function CAST_TO_NUMERIC(text) returns numeric as $$ +begin + return cast($1 as numeric); +exception + when invalid_text_representation then + return null; +end; +$$ language plpgsql immutable; diff --git a/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala index 58cca850..40db91b2 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala @@ -29,6 +29,8 @@ object DBFunction { case class Cast(expr: SelectExpr, newType: String) extends DBFunction + case class CastNumeric(expr: SelectExpr) extends DBFunction + case class Avg(expr: SelectExpr) extends DBFunction case class Sum(expr: SelectExpr) extends DBFunction diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala index fba05543..63a0afc4 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala @@ -89,6 +89,9 @@ trait DSL extends DoobieMeta { def cast(expr: SelectExpr, targetType: String): DBFunction = DBFunction.Cast(expr, targetType) + def castNumeric(expr: SelectExpr): DBFunction = + DBFunction.CastNumeric(expr) + def coalesce(expr: SelectExpr, more: SelectExpr*): DBFunction.Coalesce = DBFunction.Coalesce(expr, more.toVector) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 18cdf909..71760463 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -242,16 +242,33 @@ object ItemQueryGenerator { )(coll: Ident, op: QOp, value: String): Select = { val cf = RCustomField.as("cf") val cfv = RCustomFieldValue.as("cfv") - val v = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value - Select( - select(cfv.itemId), - from(cfv).innerJoin(cf, cf.id === cfv.field), - cf.cid === coll && sel(cf) && Condition.CompareVal( - cfv.value, - op, - v + + val baseSelect = + Select( + select(cfv.itemId), + from(cfv).innerJoin(cf, sel(cf) && cf.cid === coll && cf.id === cfv.field) ) - ) + + if (op == QOp.LowerLike) { + val v = QueryWildcard.lower(value) + baseSelect.where(Condition.CompareVal(cfv.value, op, v)) + } else { + val stringCmp = + Condition.CompareVal(cfv.value, op, value) + + value.toDoubleOption + .map { n => + val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s), op, n) + val fieldIsNumeric = + cf.ftype === CustomFieldType.Numeric || cf.ftype === CustomFieldType.Money + val fieldNotNumeric = + cf.ftype <> CustomFieldType.Numeric && cf.ftype <> CustomFieldType.Money + baseSelect.where( + (fieldIsNumeric && numericCmp) || (fieldNotNumeric && stringCmp) + ) + } + .getOrElse(baseSelect.where(stringCmp)) + } } } diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala index 3a75569a..c57a9ac3 100644 --- a/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala +++ b/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala @@ -46,6 +46,9 @@ object DBFunctionBuilder extends CommonBuilder { fr" AS" ++ Fragment.const(newType) ++ sql")" + case DBFunction.CastNumeric(f) => + sql"CAST_TO_NUMERIC(" ++ SelectExprBuilder.build(f) ++ sql")" + case DBFunction.Avg(expr) => sql"AVG(" ++ SelectExprBuilder.build(expr) ++ fr")" From 63d146c2de048d403ae51d6433b0ee28714f2036 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 7 Mar 2021 09:38:39 +0100 Subject: [PATCH 23/33] Resolve fulltext search queries the same way as before For now, fulltext search is only possible when being the only term or inside the root AND expression. --- .../docspell/backend/ops/OSimpleSearch.scala | 70 +++++++++-- .../docspell/query/FulltextExtract.scala | 71 +++++++++++ .../main/scala/docspell/query/ItemQuery.scala | 5 +- .../docspell/query/FulltextExtractTest.scala | 57 +++++++++ .../restserver/routes/ItemRoutes.scala | 114 +++++++++++------- 5 files changed, 257 insertions(+), 60 deletions(-) create mode 100644 modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala create mode 100644 modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index 8eb91c7f..e468eaaf 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -5,7 +5,7 @@ import cats.implicits._ import docspell.backend.ops.OSimpleSearch._ import docspell.common._ -import docspell.query.{ItemQueryParser, ParseFailure} +import docspell.query._ import docspell.store.qb.Batch import docspell.store.queries.Query import docspell.store.queries.SearchSummary @@ -20,15 +20,29 @@ trait OSimpleSearch[F[_]] { def searchByString( settings: Settings - )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] + )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[Items]] def searchSummaryByString( useFTS: Boolean - )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] + )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[SearchSummary]] } object OSimpleSearch { + sealed trait StringSearchResult[+A] + object StringSearchResult { + case class ParseFailed(error: ParseFailure) extends StringSearchResult[Nothing] + def parseFailed[A](error: ParseFailure): StringSearchResult[A] = + ParseFailed(error) + + case class FulltextMismatch(error: FulltextExtract.FailureResult) + extends StringSearchResult[Nothing] + def fulltextMismatch[A](error: FulltextExtract.FailureResult): StringSearchResult[A] = + FulltextMismatch(error) + + case class Success[A](value: A) extends StringSearchResult[A] + } + final case class Settings( batch: Batch, useFTS: Boolean, @@ -104,19 +118,49 @@ object OSimpleSearch { extends OSimpleSearch[F] { def searchByString( settings: Settings - )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] = - ItemQueryParser - .parse(q.query) - .map(iq => Query(fix, Query.QueryExpr(iq))) - .map(search(settings)(_, None)) //TODO resolve content:xyz expressions + )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[Items]] = { + val parsed: Either[StringSearchResult[Items], ItemQuery] = + ItemQueryParser.parse(q.query).leftMap(StringSearchResult.parseFailed) + + def makeQuery(iq: ItemQuery): F[StringSearchResult[Items]] = + iq.findFulltext match { + case FulltextExtract.Result.Success(expr, ftq) => + search(settings)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq) + .map(StringSearchResult.Success.apply) + case other: FulltextExtract.FailureResult => + StringSearchResult.fulltextMismatch[Items](other).pure[F] + } + + parsed match { + case Right(iq) => + makeQuery(iq) + case Left(err) => + err.pure[F] + } + } def searchSummaryByString( useFTS: Boolean - )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] = - ItemQueryParser - .parse(q.query) - .map(iq => Query(fix, Query.QueryExpr(iq))) - .map(searchSummary(useFTS)(_, None)) //TODO resolve content:xyz expressions + )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[SearchSummary]] = { + val parsed: Either[StringSearchResult[SearchSummary], ItemQuery] = + ItemQueryParser.parse(q.query).leftMap(StringSearchResult.parseFailed) + + def makeQuery(iq: ItemQuery): F[StringSearchResult[SearchSummary]] = + iq.findFulltext match { + case FulltextExtract.Result.Success(expr, ftq) => + searchSummary(useFTS)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq) + .map(StringSearchResult.Success.apply) + case other: FulltextExtract.FailureResult => + StringSearchResult.fulltextMismatch[SearchSummary](other).pure[F] + } + + parsed match { + case Right(iq) => + makeQuery(iq) + case Left(err) => + err.pure[F] + } + } def searchSummary( useFTS: Boolean diff --git a/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala b/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala new file mode 100644 index 00000000..769df244 --- /dev/null +++ b/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala @@ -0,0 +1,71 @@ +package docspell.query + +import cats._ +import cats.implicits._ + +import docspell.query.ItemQuery.Expr.AndExpr +import docspell.query.ItemQuery.Expr.NotExpr +import docspell.query.ItemQuery.Expr.OrExpr +import docspell.query.ItemQuery._ + +/** Currently, fulltext in a query is only supported when in "root + * AND" position + */ +object FulltextExtract { + + sealed trait Result + sealed trait SuccessResult extends Result + sealed trait FailureResult extends Result + object Result { + case class Success(query: Expr, fts: Option[String]) extends SuccessResult + case object TooMany extends FailureResult + case object UnsupportedPosition extends FailureResult + } + + def findFulltext(expr: Expr): Result = + lookForFulltext(expr) + + private def lookForFulltext(expr: Expr): Result = + expr match { + case Expr.Fulltext(ftq) => + Result.Success(ItemQuery.all.expr, ftq.some) + case Expr.AndExpr(inner) => + inner.collect({ case Expr.Fulltext(fq) => fq }) match { + case Nil => + checkPosition(expr, 0) + case e :: Nil => + val c = foldMap(isFulltextExpr)(expr) + if (c > 1) Result.TooMany + else Result.Success(expr, e.some) + case _ => + Result.TooMany + } + case _ => + checkPosition(expr, 0) + } + + private def checkPosition(expr: Expr, max: Int): Result = { + val c = foldMap(isFulltextExpr)(expr) + if (c > max) Result.UnsupportedPosition + else Result.Success(expr, None) + } + + private def foldMap[B: Monoid](f: Expr => B)(expr: Expr): B = + expr match { + case OrExpr(inner) => + inner.map(foldMap(f)).fold + case AndExpr(inner) => + inner.map(foldMap(f)).fold + case NotExpr(e) => + f(e) + case _ => + f(expr) + } + + private def isFulltextExpr(expr: Expr): Int = + expr match { + case Expr.Fulltext(_) => 1 + case _ => 0 + } + +} diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index acf2307a..eb927a87 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -11,7 +11,10 @@ import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr} * against a specific field of an item using some operator or a * combination thereof. */ -final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String]) +final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String]) { + def findFulltext: FulltextExtract.Result = + FulltextExtract.findFulltext(expr) +} object ItemQuery { val all = ItemQuery(Expr.Exists(Attr.ItemId), Some("")) diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala new file mode 100644 index 00000000..0c3b555a --- /dev/null +++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala @@ -0,0 +1,57 @@ +package docspell.query + +import cats.implicits._ +import munit._ +import docspell.query.FulltextExtract.Result + +class FulltextExtractTest extends FunSuite { + + def findFts(q: String): Result = { + val p = ItemQueryParser.parseUnsafe(q) + FulltextExtract.findFulltext(p.expr) + } + + def assertFts(qstr: String, expect: Result) = + assertEquals(findFts(qstr), expect) + + def assertFtsSuccess(qstr: String, expect: Option[String]) = { + val q = ItemQueryParser.parseUnsafe(qstr) + assertEquals(findFts(qstr), Result.Success(q.expr, expect)) + } + + test("find fulltext as root") { + assertEquals(findFts("content:what"), Result.Success(ItemQuery.all.expr, "what".some)) + assertEquals( + findFts("content:\"what hello\""), + Result.Success(ItemQuery.all.expr, "what hello".some) + ) + assertEquals( + findFts("content:\"what OR hello\""), + Result.Success(ItemQuery.all.expr, "what OR hello".some) + ) + } + + test("find no fulltext") { + assertFtsSuccess("name:test", None) + } + + test("find fulltext within and") { + assertFtsSuccess("content:what name:test", "what".some) + assertFtsSuccess("$names:marc* content:what name:test", "what".some) + assertFtsSuccess( + "$names:marc* date:2021-02 content:\"what else\" name:test", + "what else".some + ) + } + + test("too many fulltext searches") { + assertFts("content:yes content:no", Result.TooMany) + assertFts("content:yes (| name:test content:no)", Result.TooMany) + assertFts("content:yes (| name:test (& date:2021-02 content:no))", Result.TooMany) + } + + test("wrong fulltext search position") { + assertFts("name:test (| date:2021-02 content:yes)", Result.UnsupportedPosition) + assertFts("name:test (& date:2021-02 content:yes)", Result.UnsupportedPosition) //TODO + } +} 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 9a0fd9cc..9422fdb0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -11,8 +11,11 @@ import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.backend.ops.OFulltext import docspell.backend.ops.OItemSearch.{Batch, Query} import docspell.backend.ops.OSimpleSearch +import docspell.backend.ops.OSimpleSearch.StringSearchResult import docspell.common._ import docspell.common.syntax.all._ +import docspell.query.FulltextExtract.Result.TooMany +import docspell.query.FulltextExtract.Result.UnsupportedPosition import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.conv.Conversions @@ -60,31 +63,12 @@ object ItemRoutes { cfg.maxNoteLength ) val fixQuery = Query.Fix(user.account, None, None) - backend.simpleSearch.searchByString(settings)(fixQuery, itemQuery) match { - case Right(results) => - val items = results.map( - _.fold( - Conversions.mkItemListFts, - Conversions.mkItemListWithTagsFts, - Conversions.mkItemList, - Conversions.mkItemListWithTags - ) - ) - Ok(items) - case Left(fail) => - BadRequest(BasicResult(false, fail.render)) - } + searchItems(backend, dsl)(settings, fixQuery, itemQuery) case GET -> Root / "searchStats" :? QP.Query(q) => val itemQuery = ItemQueryString(q) val fixQuery = Query.Fix(user.account, None, None) - backend.simpleSearch - .searchSummaryByString(cfg.fullTextSearch.enabled)(fixQuery, itemQuery) match { - case Right(summary) => - summary.flatMap(s => Ok(Conversions.mkSearchStats(s))) - case Left(fail) => - BadRequest(BasicResult(false, fail.render)) - } + searchItemStats(backend, dsl)(cfg.fullTextSearch.enabled, fixQuery, itemQuery) case req @ POST -> Root / "search" => for { @@ -103,21 +87,7 @@ object ItemRoutes { cfg.maxNoteLength ) fixQuery = Query.Fix(user.account, None, None) - resp <- backend.simpleSearch - .searchByString(settings)(fixQuery, itemQuery) match { - case Right(results) => - val items = results.map( - _.fold( - Conversions.mkItemListFts, - Conversions.mkItemListWithTagsFts, - Conversions.mkItemList, - Conversions.mkItemListWithTags - ) - ) - Ok(items) - case Left(fail) => - BadRequest(BasicResult(false, fail.render)) - } + resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery) } yield resp case req @ POST -> Root / "searchStats" => @@ -125,16 +95,11 @@ object ItemRoutes { userQuery <- req.as[ItemQuery] itemQuery = ItemQueryString(userQuery.query) fixQuery = Query.Fix(user.account, None, None) - resp <- backend.simpleSearch - .searchSummaryByString(cfg.fullTextSearch.enabled)( - fixQuery, - itemQuery - ) match { - case Right(summary) => - summary.flatMap(s => Ok(Conversions.mkSearchStats(s))) - case Left(fail) => - BadRequest(BasicResult(false, fail.render)) - } + resp <- searchItemStats(backend, dsl)( + cfg.fullTextSearch.enabled, + fixQuery, + itemQuery + ) } yield resp //DEPRECATED @@ -526,6 +491,63 @@ object ItemRoutes { } } + def searchItems[F[_]: Sync]( + backend: BackendApp[F], + dsl: Http4sDsl[F] + )(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { + import dsl._ + + backend.simpleSearch + .searchByString(settings)(fixQuery, itemQuery) + .flatMap { + case StringSearchResult.Success(items) => + Ok( + items.fold( + Conversions.mkItemListFts, + Conversions.mkItemListWithTagsFts, + Conversions.mkItemList, + Conversions.mkItemListWithTags + ) + ) + case StringSearchResult.FulltextMismatch(TooMany) => + BadRequest(BasicResult(false, "Only one fulltext search term is allowed.")) + case StringSearchResult.FulltextMismatch(UnsupportedPosition) => + BadRequest( + BasicResult( + false, + "Fulltext search must be in root position or inside the first AND." + ) + ) + case StringSearchResult.ParseFailed(pf) => + BadRequest(BasicResult(false, s"Error reading query: ${pf.render}")) + } + } + + def searchItemStats[F[_]: Sync]( + backend: BackendApp[F], + dsl: Http4sDsl[F] + )(ftsEnabled: Boolean, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { + import dsl._ + backend.simpleSearch + .searchSummaryByString(ftsEnabled)(fixQuery, itemQuery) + .flatMap { + case StringSearchResult.Success(summary) => + Ok(Conversions.mkSearchStats(summary)) + case StringSearchResult.FulltextMismatch(TooMany) => + BadRequest(BasicResult(false, "Only one fulltext search term is allowed.")) + case StringSearchResult.FulltextMismatch(UnsupportedPosition) => + BadRequest( + BasicResult( + false, + "Fulltext search must be in root position or inside the first AND." + ) + ) + case StringSearchResult.ParseFailed(pf) => + BadRequest(BasicResult(false, s"Error reading query: ${pf.render}")) + } + + } + implicit final class OptionString(opt: Option[String]) { def notEmpty: Option[String] = opt.map(_.trim).filter(_.nonEmpty) From 7638dc511151cfac0095606a8af677c64b072246 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 7 Mar 2021 12:36:13 +0100 Subject: [PATCH 24/33] Flatten nested and/or queries --- .../docspell/query/internal/ExprUtil.scala | 27 ++++++++++++++++--- .../docspell/query/FulltextExtractTest.scala | 2 +- .../query/internal/ItemQueryParserTest.scala | 18 +++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index a1fa2045..3a82dd1f 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -1,5 +1,7 @@ package docspell.query.internal +import cats.data.{NonEmptyList => Nel} + import docspell.query.ItemQuery.Expr._ import docspell.query.ItemQuery._ @@ -11,12 +13,14 @@ object ExprUtil { def reduce(expr: Expr): Expr = expr match { case AndExpr(inner) => - if (inner.tail.isEmpty) reduce(inner.head) - else AndExpr(inner.map(reduce)) + val nodes = spliceAnd(inner) + if (nodes.tail.isEmpty) reduce(nodes.head) + else AndExpr(nodes.map(reduce)) case OrExpr(inner) => - if (inner.tail.isEmpty) reduce(inner.head) - else OrExpr(inner.map(reduce)) + val nodes = spliceOr(inner) + if (nodes.tail.isEmpty) reduce(nodes.head) + else OrExpr(nodes.map(reduce)) case NotExpr(inner) => inner match { @@ -62,4 +66,19 @@ object ExprUtil { case CustomFieldIdMatch(_, _, _) => expr } + + private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] = + nodes.flatMap { + case Expr.AndExpr(inner) => + spliceAnd(inner) + case node => + Nel.of(node) + } + private def spliceOr(nodes: Nel[Expr]): Nel[Expr] = + nodes.flatMap { + case Expr.OrExpr(inner) => + spliceOr(inner) + case node => + Nel.of(node) + } } diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala index 0c3b555a..8b1fe312 100644 --- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala @@ -52,6 +52,6 @@ class FulltextExtractTest extends FunSuite { test("wrong fulltext search position") { assertFts("name:test (| date:2021-02 content:yes)", Result.UnsupportedPosition) - assertFts("name:test (& date:2021-02 content:yes)", Result.UnsupportedPosition) //TODO + assertFtsSuccess("name:test (& date:2021-02 content:yes)", "yes".some) } } diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala index 61dfdf86..52e35fc4 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala @@ -1,5 +1,7 @@ package docspell.query.internal +import cats.implicits._ + import munit._ import docspell.query.ItemQueryParser import docspell.query.ItemQuery @@ -40,4 +42,20 @@ class ItemQueryParserTest extends FunSuite { val q = ItemQueryParser.parseUnsafe("") assertEquals(ItemQuery.all, q) } + + test("splice inner and nodes") { + val raw = "(& name:hello (& date:2021-02 name:world) (& name:hello) )" + val q = ItemQueryParser.parseUnsafe(raw) + val expect = + ItemQueryParser.parseUnsafe("name:hello date:2021-02 name:world name:hello") + assertEquals(expect.copy(raw = raw.some), q) + } + + test("splice inner or nodes") { + val raw = "(| name:hello (| date:2021-02 name:world) (| name:hello) )" + val q = ItemQueryParser.parseUnsafe(raw) + val expect = + ItemQueryParser.parseUnsafe("(| name:hello date:2021-02 name:world name:hello )") + assertEquals(expect.copy(raw = raw.some), q) + } } From 31e2e99c36fed832c272100e6f9f0dbe4b32a378 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 7 Mar 2021 13:34:35 +0100 Subject: [PATCH 25/33] Add a `$year` shortcut for selecting items within some year --- .../shared/src/main/scala/docspell/query/ItemQuery.scala | 8 ++++++++ .../main/scala/docspell/query/internal/DateParser.scala | 3 +++ .../main/scala/docspell/query/internal/MacroParser.scala | 8 +++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index eb927a87..61bec94d 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -131,6 +131,14 @@ object ItemQuery { and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right)) } + case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr { + val body = + DateRangeMacro(attr, date(year), date(year + 1)) + + private def date(y: Int): Date = + Date(y, 1, 1).fold(throw _, identity) + } + def or(expr0: Expr, exprs: Expr*): OrExpr = OrExpr(Nel.of(expr0, exprs: _*)) diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala index 33e0d556..02d99926 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala @@ -43,6 +43,9 @@ object DateParser { private val dateFromToday: P[Date.DateLiteral] = P.string("today").as(Date.Today) + val yearOnly: P[Int] = + digits4 + val dateLiteral: P[Date.DateLiteral] = P.oneOf(List(dateFromString, dateFromToday, dateFromMillis)) diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala index 856a2d96..0cfdda0f 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala @@ -16,6 +16,9 @@ object MacroParser { Expr.DateRangeMacro(attr, left, right) } + private def yearMacroImpl(name: String, attr: Attr.DateAttr): P[Expr.YearMacro] = + (macroDef(name) *> DateParser.yearOnly).map(year => Expr.YearMacro(attr, year)) + val namesMacro: P[Expr.NamesMacro] = (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply) @@ -25,9 +28,12 @@ object MacroParser { val dueDateRangeMacro: P[Expr.DateRangeMacro] = dateRangeMacroImpl("duein", Attr.DueDate) + val yearDateMacro: P[Expr.YearMacro] = + yearMacroImpl("year", Attr.Date) + // --- all macro parser val all: P[Expr] = - P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro)) + P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro, yearDateMacro)) } From c6032ff27965d2fa310436d750ca1efd127aafde Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Sun, 7 Mar 2021 23:46:31 +0100 Subject: [PATCH 26/33] Check query in client --- .../src/main/elm/Comp/PowerSearchInput.elm | 177 ++++++++++++++++++ .../webapp/src/main/elm/Data/ItemQuery.elm | 4 +- .../src/main/elm/Data/QueryParseResult.elm | 14 ++ .../webapp/src/main/elm/Page/Home/Data.elm | 9 +- .../webapp/src/main/elm/Page/Home/Update.elm | 24 ++- .../webapp/src/main/elm/Page/Home/View2.elm | 18 +- modules/webapp/src/main/elm/Ports.elm | 12 +- modules/webapp/src/main/elm/Styles.elm | 7 +- modules/webapp/src/main/webjar/docspell.js | 26 +++ 9 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/PowerSearchInput.elm create mode 100644 modules/webapp/src/main/elm/Data/QueryParseResult.elm diff --git a/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm new file mode 100644 index 00000000..2a868fa0 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm @@ -0,0 +1,177 @@ +module Comp.PowerSearchInput exposing + ( Action(..) + , Model + , Msg + , init + , update + , viewInput + , viewResult + ) + +import Data.DropdownStyle +import Data.QueryParseResult exposing (QueryParseResult) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Ports +import Styles as S +import Throttle exposing (Throttle) +import Time +import Util.Html exposing (KeyCode(..)) +import Util.Maybe + + +type alias Model = + { input : Maybe String + , result : QueryParseResult + , parseThrottle : Throttle Msg + } + + +init : Model +init = + { input = Nothing + , result = Data.QueryParseResult.success + , parseThrottle = Throttle.create 1 + } + + +type Msg + = SetSearch String + | KeyUpMsg (Maybe KeyCode) + | ParseResultMsg QueryParseResult + | UpdateThrottle + + +type Action + = NoAction + | SubmitSearch + + +type alias Result = + { model : Model + , cmd : Cmd Msg + , action : Action + , subs : Sub Msg + } + + + +--- Update + + +update : Msg -> Model -> Result +update msg model = + case msg of + SetSearch str -> + let + parseCmd = + Ports.checkSearchQueryString str + + parseSub = + Ports.receiveCheckQueryResult ParseResultMsg + + ( newThrottle, cmd ) = + Throttle.try parseCmd model.parseThrottle + + model_ = + { model + | input = Util.Maybe.fromString str + , parseThrottle = newThrottle + , result = + if str == "" then + Data.QueryParseResult.success + + else + model.result + } + in + { model = model_ + , cmd = cmd + , action = NoAction + , subs = Sub.batch [ throttleUpdate model_, parseSub ] + } + + KeyUpMsg (Just Enter) -> + Result model Cmd.none SubmitSearch Sub.none + + KeyUpMsg _ -> + let + parseSub = + Ports.receiveCheckQueryResult ParseResultMsg + in + Result model Cmd.none NoAction (Sub.batch [ throttleUpdate model, parseSub ]) + + ParseResultMsg lm -> + Result { model | result = lm } Cmd.none NoAction Sub.none + + UpdateThrottle -> + let + parseSub = + Ports.receiveCheckQueryResult ParseResultMsg + + ( newThrottle, cmd ) = + Throttle.update model.parseThrottle + + model_ = + { model | parseThrottle = newThrottle } + in + { model = model_ + , cmd = cmd + , action = NoAction + , subs = Sub.batch [ throttleUpdate model_, parseSub ] + } + + +throttleUpdate : Model -> Sub Msg +throttleUpdate model = + Throttle.ifNeeded + (Time.every 100 (\_ -> UpdateThrottle)) + model.parseThrottle + + + +--- View + + +viewInput : List (Attribute Msg) -> Model -> Html Msg +viewInput attrs model = + input + (attrs + ++ [ type_ "text" + , placeholder "Search query …" + , onInput SetSearch + , Util.Html.onKeyUpCode KeyUpMsg + , Maybe.map value model.input + |> Maybe.withDefault (value "") + , class S.textInput + , class "text-sm " + ] + ) + [] + + +viewResult : List ( String, Bool ) -> Model -> Html Msg +viewResult classes model = + div + [ classList [ ( "hidden", model.result.success ) ] + , classList classes + , class resultStyle + ] + [ p [ class "font-mono text-sm" ] + [ text model.result.input + ] + , pre [ class "font-mono text-sm" ] + [ List.repeat model.result.index " " + |> String.join "" + |> text + , text "^" + ] + , ul [] + (List.map (\line -> li [] [ text line ]) model.result.messages) + ] + + +resultStyle : String +resultStyle = + S.warnMessageColors ++ " absolute left-0 max-h-44 w-full overflow-y-auto z-50 shadow-lg transition duration-200 top-9 border-0 border-b border-l border-r rounded-b px-2 py-2" diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index d01464b8..54a54e8e 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -106,8 +106,8 @@ render q = "=" quoteStr = - --TODO escape quotes - surround "\"" + String.replace "\"" "\\\"" + >> surround "\"" in case q of And inner -> diff --git a/modules/webapp/src/main/elm/Data/QueryParseResult.elm b/modules/webapp/src/main/elm/Data/QueryParseResult.elm new file mode 100644 index 00000000..bb0046ba --- /dev/null +++ b/modules/webapp/src/main/elm/Data/QueryParseResult.elm @@ -0,0 +1,14 @@ +module Data.QueryParseResult exposing (QueryParseResult, success) + + +type alias QueryParseResult = + { success : Bool + , input : String + , index : Int + , messages : List String + } + + +success : QueryParseResult +success = + QueryParseResult True "" 0 [] diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 809d2526..7e6246cb 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -27,6 +27,7 @@ import Comp.ItemCardList import Comp.ItemDetail.FormChange exposing (FormChange) import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) import Comp.LinkTarget exposing (LinkTarget) +import Comp.PowerSearchInput import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) @@ -56,7 +57,7 @@ type alias Model = , dragDropData : DD.DragDropData , scrollToCard : Maybe String , searchStats : SearchStats - , powerSearchInput : Maybe String + , powerSearchInput : Comp.PowerSearchInput.Model } @@ -122,7 +123,7 @@ init flags viewMode = , scrollToCard = Nothing , viewMode = viewMode , searchStats = Api.Model.SearchStats.empty - , powerSearchInput = Nothing + , powerSearchInput = Comp.PowerSearchInput.init } @@ -196,7 +197,7 @@ type Msg | SetLinkTarget LinkTarget | SearchStatsResp (Result Http.Error SearchStats) | TogglePreviewFullWidth - | SetPowerSearch String + | PowerSearchMsg Comp.PowerSearchInput.Msg | KeyUpPowerSearchbarMsg (Maybe KeyCode) @@ -247,7 +248,7 @@ doSearchDefaultCmd param model = Q.request <| Q.and [ Comp.SearchMenu.getItemQuery model.searchMenuModel - , Maybe.map Q.Fragment model.powerSearchInput + , Maybe.map Q.Fragment model.powerSearchInput.input ] mask = diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index d161d58c..9d8efee9 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -1,15 +1,14 @@ module Page.Home.Update exposing (update) import Api -import Api.Model.IdList exposing (IdList) import Api.Model.ItemLightList exposing (ItemLightList) -import Api.Model.ItemQuery import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList import Comp.ItemDetail.FormChange exposing (FormChange(..)) import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) import Comp.LinkTarget exposing (LinkTarget) +import Comp.PowerSearchInput import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) @@ -54,7 +53,7 @@ update mId key flags settings msg model = ResetSearch -> let nm = - { model | searchOffset = 0, powerSearchInput = Nothing } + { model | searchOffset = 0, powerSearchInput = Comp.PowerSearchInput.init } in update mId key flags settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm @@ -580,8 +579,23 @@ update mId key flags settings msg model = in noSub ( model, cmd ) - SetPowerSearch str -> - noSub ( { model | powerSearchInput = Util.Maybe.fromString str }, Cmd.none ) + PowerSearchMsg lm -> + let + result = + Comp.PowerSearchInput.update lm model.powerSearchInput + + cmd_ = + Cmd.map PowerSearchMsg result.cmd + + model_ = + { model | powerSearchInput = result.model } + in + case result.action of + Comp.PowerSearchInput.NoAction -> + ( model_, cmd_, Sub.map PowerSearchMsg result.subs ) + + Comp.PowerSearchInput.SubmitSearch -> + update mId key flags settings (DoSearch model_.searchTypeDropdownValue) model_ KeyUpPowerSearchbarMsg (Just Enter) -> update mId key flags settings (DoSearch model.searchTypeDropdownValue) model diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm index 82db65c7..4b6d6f8e 100644 --- a/modules/webapp/src/main/elm/Page/Home/View2.elm +++ b/modules/webapp/src/main/elm/Page/Home/View2.elm @@ -3,6 +3,7 @@ module Page.Home.View2 exposing (viewContent, viewSidebar) import Comp.Basic as B import Comp.ItemCardList import Comp.MenuBar as MB +import Comp.PowerSearchInput import Comp.SearchMenu import Comp.SearchStatsView import Comp.YesNoDimmer @@ -135,17 +136,12 @@ defaultMenuBar _ settings model = powerSearchBar = div [ class "relative flex flex-grow flex-row" ] - [ input - [ type_ "text" - , placeholder "Search query …" - , onInput SetPowerSearch - , Util.Html.onKeyUpCode KeyUpPowerSearchbarMsg - , Maybe.map value model.powerSearchInput - |> Maybe.withDefault (value "") - , class S.textInput - , class "text-sm " - ] - [] + [ Html.map PowerSearchMsg + (Comp.PowerSearchInput.viewInput [] + model.powerSearchInput + ) + , Html.map PowerSearchMsg + (Comp.PowerSearchInput.viewResult [] model.powerSearchInput) ] in MB.view diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm index a8874539..9f630a81 100644 --- a/modules/webapp/src/main/elm/Ports.elm +++ b/modules/webapp/src/main/elm/Ports.elm @@ -1,8 +1,10 @@ port module Ports exposing - ( getUiSettings + ( checkSearchQueryString + , getUiSettings , initClipboard , loadUiSettings , onUiSettingsSaved + , receiveCheckQueryResult , removeAccount , setAccount , setUiTheme @@ -10,7 +12,9 @@ port module Ports exposing ) import Api.Model.AuthResult exposing (AuthResult) +import Api.Model.BasicResult exposing (BasicResult) import Data.Flags exposing (Flags) +import Data.QueryParseResult exposing (QueryParseResult) import Data.UiSettings exposing (StoredUiSettings, UiSettings) import Data.UiTheme exposing (UiTheme) @@ -38,6 +42,12 @@ port uiSettingsSaved : (() -> msg) -> Sub msg port internalSetUiTheme : String -> Cmd msg +port checkSearchQueryString : String -> Cmd msg + + +port receiveCheckQueryResult : (QueryParseResult -> msg) -> Sub msg + + setUiTheme : UiTheme -> Cmd msg setUiTheme theme = internalSetUiTheme (Data.UiTheme.toString theme) diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index 56a727bc..2d917c49 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -43,7 +43,12 @@ errorMessage = warnMessage : String warnMessage = - " border border-yellow-800 bg-yellow-50 text-yellow-800 dark:border-amber-200 dark:bg-amber-800 dark:text-amber-200 dark:bg-opacity-25 px-2 py-2 rounded " + warnMessageColors ++ " border dark:bg-opacity-25 px-2 py-2 rounded " + + +warnMessageColors : String +warnMessageColors = + " border-yellow-800 bg-yellow-50 text-yellow-800 dark:border-amber-200 dark:bg-amber-800 dark:text-amber-200 " infoMessage : String diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js index 17b1901f..d6fcc4c6 100644 --- a/modules/webapp/src/main/webjar/docspell.js +++ b/modules/webapp/src/main/webjar/docspell.js @@ -97,3 +97,29 @@ elmApp.ports.initClipboard.subscribe(function(args) { docspell_clipboards[page] = new ClipboardJS(sel); } }); + +elmApp.ports.checkSearchQueryString.subscribe(function(args) { + var qStr = args; + if (qStr && DsItemQueryParser && DsItemQueryParser['parseToFailure']) { + var result = DsItemQueryParser.parseToFailure(qStr); + var answer; + if (result) { + answer = + { success: false, + input: result.input, + index: result.failedAt, + messages: result.messages + }; + + } else { + answer = + { success: true, + input: qStr, + index: 0, + messages: [] + }; + } + console.log("Sending: " + answer.success); + elmApp.ports.receiveCheckQueryResult.send(answer); + } +}); From 7b1ec97c97bcdaecc413266ec71e4b5f13e1a75c Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 00:46:36 +0100 Subject: [PATCH 27/33] Fix sort when using fulltext only --- .../docspell/backend/ops/OSimpleSearch.scala | 46 ++++++++++--------- .../docspell/query/FulltextExtractTest.scala | 5 ++ .../restserver/src/main/resources/logback.xml | 1 - .../restserver/conv/Conversions.scala | 4 ++ .../restserver/routes/ItemRoutes.scala | 12 ++++- 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index e468eaaf..1c5e54df 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -52,37 +52,41 @@ object OSimpleSearch { sealed trait Items { def fold[A]( - f1: Vector[OFulltext.FtsItem] => A, - f2: Vector[OFulltext.FtsItemWithTags] => A, + f1: Items.FtsItems => A, + f2: Items.FtsItemsFull => A, f3: Vector[OItemSearch.ListItem] => A, f4: Vector[OItemSearch.ListItemWithTags] => A ): A } object Items { - def ftsItems(items: Vector[OFulltext.FtsItem]): Items = - FtsItems(items) + def ftsItems(indexOnly: Boolean)(items: Vector[OFulltext.FtsItem]): Items = + FtsItems(items, indexOnly) - case class FtsItems(items: Vector[OFulltext.FtsItem]) extends Items { + case class FtsItems(items: Vector[OFulltext.FtsItem], indexOnly: Boolean) + extends Items { def fold[A]( - f1: Vector[OFulltext.FtsItem] => A, - f2: Vector[OFulltext.FtsItemWithTags] => A, + f1: FtsItems => A, + f2: FtsItemsFull => A, f3: Vector[OItemSearch.ListItem] => A, f4: Vector[OItemSearch.ListItemWithTags] => A - ): A = f1(items) + ): A = f1(this) } - def ftsItemsFull(items: Vector[OFulltext.FtsItemWithTags]): Items = - FtsItemsFull(items) + def ftsItemsFull(indexOnly: Boolean)( + items: Vector[OFulltext.FtsItemWithTags] + ): Items = + FtsItemsFull(items, indexOnly) - case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags]) extends Items { + case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags], indexOnly: Boolean) + extends Items { def fold[A]( - f1: Vector[OFulltext.FtsItem] => A, - f2: Vector[OFulltext.FtsItemWithTags] => A, + f1: FtsItems => A, + f2: FtsItemsFull => A, f3: Vector[OItemSearch.ListItem] => A, f4: Vector[OItemSearch.ListItemWithTags] => A - ): A = f2(items) + ): A = f2(this) } def itemsPlain(items: Vector[OItemSearch.ListItem]): Items = @@ -90,8 +94,8 @@ object OSimpleSearch { case class ItemsPlain(items: Vector[OItemSearch.ListItem]) extends Items { def fold[A]( - f1: Vector[OFulltext.FtsItem] => A, - f2: Vector[OFulltext.FtsItemWithTags] => A, + f1: FtsItems => A, + f2: FtsItemsFull => A, f3: Vector[OItemSearch.ListItem] => A, f4: Vector[OItemSearch.ListItemWithTags] => A ): A = f3(items) @@ -102,8 +106,8 @@ object OSimpleSearch { case class ItemsFull(items: Vector[OItemSearch.ListItemWithTags]) extends Items { def fold[A]( - f1: Vector[OFulltext.FtsItem] => A, - f2: Vector[OFulltext.FtsItemWithTags] => A, + f1: FtsItems => A, + f2: FtsItemsFull => A, f3: Vector[OItemSearch.ListItem] => A, f4: Vector[OItemSearch.ListItemWithTags] => A ): A = f4(items) @@ -190,7 +194,7 @@ object OSimpleSearch { q.fix.account, settings.batch ) - .map(Items.ftsItemsFull) + .map(Items.ftsItemsFull(true)) else if (settings.resolveDetails) fts .findItemsWithTags(settings.maxNoteLen)( @@ -198,11 +202,11 @@ object OSimpleSearch { OFulltext.FtsInput(ftq), settings.batch ) - .map(Items.ftsItemsFull) + .map(Items.ftsItemsFull(false)) else fts .findItems(settings.maxNoteLen)(q, OFulltext.FtsInput(ftq), settings.batch) - .map(Items.ftsItems) + .map(Items.ftsItems(false)) case _ => if (settings.resolveDetails) diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala index 8b1fe312..9b6ea799 100644 --- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala @@ -29,6 +29,11 @@ class FulltextExtractTest extends FunSuite { findFts("content:\"what OR hello\""), Result.Success(ItemQuery.all.expr, "what OR hello".some) ) + + assertEquals( + findFts("(& content:\"what OR hello\" )"), + Result.Success(ItemQuery.all.expr, "what OR hello".some) + ) } test("find no fulltext") { diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml index d972abcb..f9b2d921 100644 --- a/modules/restserver/src/main/resources/logback.xml +++ b/modules/restserver/src/main/resources/logback.xml @@ -9,7 +9,6 @@ <logger name="docspell" level="debug" /> <logger name="emil" level="debug"/> - <logger name="docspell.store.queries.QItem" level="trace"/> <root level="INFO"> <appender-ref ref="STDOUT" /> 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 a6516bc0..cede3845 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -224,6 +224,10 @@ trait Conversions { if (v.isEmpty) ItemLightList(Nil) else ItemLightList(List(ItemLightGroup("Results", v.map(mkItemLightWithTags).toList))) + def mkItemListFtsPlain(v: Vector[OFulltext.FtsItem]): ItemLightList = + if (v.isEmpty) ItemLightList(Nil) + else ItemLightList(List(ItemLightGroup("Results", v.map(mkItemLight).toList))) + def mkItemLight(i: OItemSearch.ListItem): ItemLight = ItemLight( i.id, 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 9422fdb0..2e7fbbb5 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -497,14 +497,22 @@ object ItemRoutes { )(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { import dsl._ + def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList = + if (res.indexOnly) Conversions.mkItemListFtsPlain(res.items) + else Conversions.mkItemListFts(res.items) + + def convertFtsFull(res: OSimpleSearch.Items.FtsItemsFull): ItemLightList = + if (res.indexOnly) Conversions.mkItemListWithTagsFtsPlain(res.items) + else Conversions.mkItemListWithTagsFts(res.items) + backend.simpleSearch .searchByString(settings)(fixQuery, itemQuery) .flatMap { case StringSearchResult.Success(items) => Ok( items.fold( - Conversions.mkItemListFts, - Conversions.mkItemListWithTagsFts, + convertFts, + convertFtsFull, Conversions.mkItemList, Conversions.mkItemListWithTags ) From 2b2f913e8550f21e08110be05d4b1da7ee084cef Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 01:53:21 +0100 Subject: [PATCH 28/33] Add checksum query expr --- .../main/scala/docspell/query/ItemQuery.scala | 3 +- .../docspell/query/internal/ExprUtil.scala | 2 + .../query/internal/SimpleExprParser.scala | 6 ++- .../qb/generator/ItemQueryGenerator.scala | 13 +++-- .../scala/docspell/store/queries/QItem.scala | 47 +++++++++++-------- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 61bec94d..4fce13d2 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -109,7 +109,8 @@ object ItemQuery { final case class CustomFieldIdMatch(id: String, op: Operator, value: String) extends Expr - final case class Fulltext(query: String) extends Expr + final case class Fulltext(query: String) extends Expr + final case class ChecksumMatch(checksum: String) extends Expr // things that can be expressed with terms above sealed trait MacroExpr extends Expr { diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index 3a82dd1f..f4f8193c 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -65,6 +65,8 @@ object ExprUtil { expr case CustomFieldIdMatch(_, _, _) => expr + case ChecksumMatch(_) => + expr } private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] = diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala index 55b92188..00bf5aa0 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -76,6 +76,9 @@ object SimpleExprParser { val dirExpr: P[Expr.DirectionExpr] = (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply) + val checksumExpr: P[Expr.ChecksumMatch] = + (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply) + val simpleExpr: P[Expr] = P.oneOf( List( @@ -89,7 +92,8 @@ object SimpleExprParser { customFieldIdExpr, customFieldExpr, inboxExpr, - dirExpr + dirExpr, + checksumExpr ) ) } diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 71760463..c92367fe 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -3,15 +3,16 @@ package docspell.store.qb.generator import java.time.Instant import java.time.LocalDate -import cats.data.NonEmptyList +import cats.data.{NonEmptyList => Nel} import docspell.common._ import docspell.query.ItemQuery._ import docspell.query.{Date, ItemQuery} import docspell.store.qb.DSL._ import docspell.store.qb.{Operator => QOp, _} +import docspell.store.queries.QItem import docspell.store.queries.QueryWildcard -import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} +import docspell.store.records._ import doobie.util.Put @@ -39,7 +40,7 @@ object ItemQueryGenerator { case Expr.TagIdsMatch(op, tags) => val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) - NonEmptyList + Nel .fromList(ids) .map { nel => op match { @@ -114,7 +115,7 @@ object ItemQueryGenerator { case Expr.TagIdsMatch(op, tags) => val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) - NonEmptyList + Nel .fromList(ids) .map { nel => op match { @@ -152,6 +153,10 @@ object ItemQueryGenerator { case Expr.CustomFieldIdMatch(field, op, value) => tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value)) + case Expr.ChecksumMatch(checksum) => + val select = QItem.findByChecksumQuery(checksum, coll, Set.empty) + tables.item.id.in(select.withSelect(Nel.of(RItem.as("i").id.s))) + case Expr.Fulltext(_) => // not supported here Condition.unit 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 a79db262..2e1e1296 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -509,6 +509,16 @@ object QItem { collective: Ident, excludeFileMeta: Set[Ident] ): ConnectionIO[Vector[RItem]] = { + val qq = findByChecksumQuery(checksum, collective, excludeFileMeta).build + logger.debug(s"FindByChecksum: $qq") + qq.query[RItem].to[Vector] + } + + def findByChecksumQuery( + checksum: String, + collective: Ident, + excludeFileMeta: Set[Ident] + ): Select = { val m1 = RFileMeta.as("m1") val m2 = RFileMeta.as("m2") val m3 = RFileMeta.as("m3") @@ -517,26 +527,23 @@ object QItem { val s = RAttachmentSource.as("s") val r = RAttachmentArchive.as("r") val fms = Nel.of(m1, m2, m3) - val qq = - Select( - select(i.all), - from(i) - .innerJoin(a, a.itemId === i.id) - .innerJoin(s, s.id === a.id) - .innerJoin(m1, m1.id === a.fileId) - .innerJoin(m2, m2.id === s.fileId) - .leftJoin(r, r.id === a.id) - .leftJoin(m3, m3.id === r.fileId), - where( - i.cid === collective && - Condition.Or(fms.map(m => m.checksum === checksum)) &&? - Nel - .fromList(excludeFileMeta.toList) - .map(excl => Condition.And(fms.map(m => m.id.isNull || m.id.notIn(excl)))) - ) - ).distinct.build - logger.debug(s"FindByChecksum: $qq") - qq.query[RItem].to[Vector] + Select( + select(i.all), + from(i) + .innerJoin(a, a.itemId === i.id) + .innerJoin(s, s.id === a.id) + .innerJoin(m1, m1.id === a.fileId) + .innerJoin(m2, m2.id === s.fileId) + .leftJoin(r, r.id === a.id) + .leftJoin(m3, m3.id === r.fileId), + where( + i.cid === collective && + Condition.Or(fms.map(m => m.checksum === checksum)) &&? + Nel + .fromList(excludeFileMeta.toList) + .map(excl => Condition.And(fms.map(m => m.id.isNull || m.id.notIn(excl)))) + ) + ).distinct } final case class NameAndNotes( From 30c901ddf1824a0b5f084a33dec6eb50073377c0 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 09:30:47 +0100 Subject: [PATCH 29/33] Add more ways to query for attachments - find items with a specified attachment count - find items by attachment id --- .../main/scala/docspell/query/ItemQuery.scala | 21 ++++++++++++------- .../docspell/query/internal/AttrParser.scala | 10 ++++++++- .../docspell/query/internal/ExprUtil.scala | 2 ++ .../query/internal/SimpleExprParser.scala | 16 +++++++++++++- .../qb/generator/ItemQueryGenerator.scala | 20 ++++++++++++++++++ .../docspell/store/qb/generator/Tables.scala | 4 +++- .../store/queries/AttachCountTable.scala | 16 ++++++++++++++ .../scala/docspell/store/queries/QItem.scala | 21 +++++++------------ .../generator/ItemQueryGeneratorTest.scala | 4 +++- 9 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 4fce13d2..94f3868f 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -2,7 +2,7 @@ package docspell.query import cats.data.{NonEmptyList => Nel} -import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr} +import docspell.query.ItemQuery.Attr.{DateAttr, IntAttr, StringAttr} /** A query evaluates to `true` or `false` given enough details about * an item. @@ -40,13 +40,15 @@ object ItemQuery { object Attr { sealed trait StringAttr extends Attr sealed trait DateAttr extends Attr + sealed trait IntAttr extends Attr - case object ItemName extends StringAttr - case object ItemSource extends StringAttr - case object ItemNotes extends StringAttr - case object ItemId extends StringAttr - case object Date extends DateAttr - case object DueDate extends DateAttr + case object ItemName extends StringAttr + case object ItemSource extends StringAttr + case object ItemNotes extends StringAttr + case object ItemId extends StringAttr + case object Date extends DateAttr + case object DueDate extends DateAttr + case object AttachCount extends IntAttr object Correspondent { case object OrgId extends StringAttr @@ -72,12 +74,16 @@ object ItemQuery { object Property { final case class StringProperty(attr: StringAttr, value: String) extends Property final case class DateProperty(attr: DateAttr, value: Date) extends Property + final case class IntProperty(attr: IntAttr, value: Int) extends Property def apply(sa: StringAttr, value: String): Property = StringProperty(sa, value) def apply(da: DateAttr, value: Date): Property = DateProperty(da, value) + + def apply(na: IntAttr, value: Int): Property = + IntProperty(na, value) } sealed trait Expr { @@ -111,6 +117,7 @@ object ItemQuery { final case class Fulltext(query: String) extends Expr final case class ChecksumMatch(checksum: String) extends Expr + final case class AttachId(id: String) extends Expr // things that can be expressed with terms above sealed trait MacroExpr extends Expr { diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala index d5289c67..d7aab3ed 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala @@ -62,6 +62,14 @@ object AttrParser { val folderName: P[Attr.StringAttr] = P.ignoreCase("folder").as(Attr.Folder.FolderName) + val attachCountAttr: P[Attr.IntAttr] = + P.ignoreCase("attach.count").as(Attr.AttachCount) + + // combining grouped by type + + val intAttr: P[Attr.IntAttr] = + attachCountAttr + val dateAttr: P[Attr.DateAttr] = P.oneOf(List(date, dueDate)) @@ -86,5 +94,5 @@ object AttrParser { ) val anyAttr: P[Attr] = - P.oneOf(List(dateAttr, stringAttr)) + P.oneOf(List(dateAttr, stringAttr, intAttr)) } diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index f4f8193c..df81983f 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -67,6 +67,8 @@ object ExprUtil { expr case ChecksumMatch(_) => expr + case AttachId(_) => + expr } private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] = diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala index 00bf5aa0..56a0077a 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -1,5 +1,6 @@ package docspell.query.internal +import cats.parse.Numbers import cats.parse.{Parser => P} import docspell.query.ItemQuery._ @@ -18,6 +19,9 @@ object SimpleExprParser { private[this] val inOrOpDate = P.eitherOr(op ~ DateParser.date, inOp *> DateParser.dateOrMore) + private[this] val opInt = + op ~ Numbers.digits.map(_.toInt) + val stringExpr: P[Expr] = (AttrParser.stringAttr ~ inOrOpStr).map { case (attr, Right((op, value))) => @@ -34,6 +38,11 @@ object SimpleExprParser { Expr.InDateExpr(attr, values) } + val intExpr: P[Expr] = + (AttrParser.intAttr ~ opInt).map { case (attr, (op, value)) => + Expr.SimpleExpr(op, Property(attr, value)) + } + val existsExpr: P[Expr.Exists] = (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr)) @@ -79,11 +88,15 @@ object SimpleExprParser { val checksumExpr: P[Expr.ChecksumMatch] = (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply) + val attachIdExpr: P[Expr.AttachId] = + (P.ignoreCase("attach.id:") *> BasicParser.singleString).map(Expr.AttachId.apply) + val simpleExpr: P[Expr] = P.oneOf( List( dateExpr, stringExpr, + intExpr, existsExpr, fulltextExpr, tagIdExpr, @@ -93,7 +106,8 @@ object SimpleExprParser { customFieldExpr, inboxExpr, dirExpr, - checksumExpr + checksumExpr, + attachIdExpr ) ) } diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index c92367fe..a99516ff 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -94,6 +94,10 @@ object ItemQueryGenerator { val noLikeOp = if (op == Operator.Like) Operator.Eq else op Condition.CompareVal(col, makeOp(noLikeOp), dt) + case Expr.SimpleExpr(op, Property.IntProperty(attr, value)) => + val col = intColumn(tables)(attr) + Condition.CompareVal(col, makeOp(op), value) + case Expr.InExpr(attr, values) => val col = stringColumn(tables)(attr) if (values.tail.isEmpty) col === values.head @@ -157,6 +161,15 @@ object ItemQueryGenerator { val select = QItem.findByChecksumQuery(checksum, coll, Set.empty) tables.item.id.in(select.withSelect(Nel.of(RItem.as("i").id.s))) + case Expr.AttachId(id) => + tables.item.id.in( + Select( + select(RAttachment.T.itemId), + from(RAttachment.T), + RAttachment.T.id.cast[String] === id + ).distinct + ) + case Expr.Fulltext(_) => // not supported here Condition.unit @@ -196,6 +209,8 @@ object ItemQueryGenerator { stringColumn(tables)(s) case t: Attr.DateAttr => timestampColumn(tables)(t) + case n: Attr.IntAttr => + intColumn(tables)(n) } private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) = @@ -224,6 +239,11 @@ object ItemQueryGenerator { case Attr.Folder.FolderName => tables.folder.name } + private def intColumn(tables: Tables)(attr: Attr.IntAttr): Column[Int] = + attr match { + case Attr.AttachCount => tables.attachCount.num + } + private def makeOp(operator: Operator): QOp = operator match { case Operator.Eq => diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala index 966b129d..0d30c99c 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala @@ -1,5 +1,6 @@ package docspell.store.qb.generator +import docspell.store.queries.AttachCountTable import docspell.store.records._ final case class Tables( @@ -10,5 +11,6 @@ final case class Tables( concEquip: REquipment.Table, folder: RFolder.Table, attach: RAttachment.Table, - meta: RAttachmentMeta.Table + meta: RAttachmentMeta.Table, + attachCount: AttachCountTable ) diff --git a/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala b/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala new file mode 100644 index 00000000..2ed6fc3c --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala @@ -0,0 +1,16 @@ +package docspell.store.queries + +import docspell.common.Ident +import docspell.store.qb.Column +import docspell.store.qb.TableDef + +final case class AttachCountTable(aliasName: String) extends TableDef { + val tableName = "attachs" + val alias: Option[String] = Some(aliasName) + + val num = Column[Int]("num", this) + val itemId = Column[Ident]("item_id", this) + + def as(alias: String): AttachCountTable = + copy(aliasName = alias) +} 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 2e1e1296..c1ee5f2c 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -122,15 +122,8 @@ object QItem { } private def findItemsBase(q: Query.Fix, noteMaxLen: Int): Select = { - object Attachs extends TableDef { - val tableName = "attachs" - val aliasName = "cta" - val alias = Some(aliasName) - val num = Column[Int]("num", this) - val itemId = Column[Ident]("item_id", this) - } - - val coll = q.account.collective + val attachs = AttachCountTable("cta") + val coll = q.account.collective Select( select( @@ -142,7 +135,7 @@ object QItem { i.source.s, i.incoming.s, i.created.s, - coalesce(Attachs.num.s, const(0)).s, + coalesce(attachs.num.s, const(0)).s, org.oid.s, org.name.s, pers0.pid.s, @@ -162,14 +155,14 @@ object QItem { .leftJoin(f, f.id === i.folder && f.collective === coll) .leftJoin( Select( - select(countAll.as(Attachs.num), a.itemId.as(Attachs.itemId)), + select(countAll.as(attachs.num), a.itemId.as(attachs.itemId)), from(a) .innerJoin(i, i.id === a.itemId), i.cid === q.account.collective, GroupBy(a.itemId) ), - Attachs.aliasName, - Attachs.itemId === i.id + attachs.aliasName, + attachs.itemId === i.id ) .leftJoin(pers0, pers0.pid === i.corrPerson && pers0.cid === coll) .leftJoin(org, org.oid === i.corrOrg && org.cid === coll) @@ -229,7 +222,7 @@ object QItem { .map(itemIds => i.id.in(itemIds)) def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery): Condition = { - val tables = Tables(i, org, pers0, pers1, equip, f, a, m) + val tables = Tables(i, org, pers0, pers1, equip, f, a, m, AttachCountTable("cta")) ItemQueryGenerator.fromExpr(today, tables, coll)(q.expr) } diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala index 3d9e5b2e..7d511026 100644 --- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -6,6 +6,7 @@ import docspell.store.records._ import minitest._ import docspell.common._ import docspell.query.ItemQueryParser +import docspell.store.queries.AttachCountTable import docspell.store.qb.DSL._ import docspell.store.qb.generator.{ItemQueryGenerator, Tables} @@ -20,7 +21,8 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { REquipment.as("ne"), RFolder.as("f"), RAttachment.as("a"), - RAttachmentMeta.as("m") + RAttachmentMeta.as("m"), + AttachCountTable("cta") ) val now: LocalDate = LocalDate.of(2021, 2, 25) From b514b85f39c2462c86ac19e1aced1ba27a0620a6 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 10:26:39 +0100 Subject: [PATCH 30/33] Improve parser error messages a bit --- .../docspell/query/js/JSItemQueryParser.scala | 2 +- .../scala/docspell/query/ParseFailure.scala | 57 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala index 68f7cd5e..a58371cd 100644 --- a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala +++ b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala @@ -18,7 +18,7 @@ object JSItemQueryParser { new Failure( fr.input, fr.failedAt, - js.Array(fr.messages.toList.toSeq.map(_.msg): _*) + js.Array(fr.messages.toList.toSeq.map(_.render): _*) ) ) .orNull diff --git a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala index 05235c03..4562d68c 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala @@ -18,48 +18,79 @@ final case class ParseFailure( ) { def render: String = { - val items = messages.map(_.msg).toList.mkString(", ") + val items = messages.map(_.render).toList.mkString(", ") s"Failed to read input near $failedAt: $input\nDetails: $items" } } object ParseFailure { - final case class Message(offset: Int, msg: String) + sealed trait Message { + def offset: Int + def render: String + } + final case class SimpleMessage(offset: Int, msg: String) extends Message { + def render: String = + s"Failed at $offset: $msg" + } + final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean) extends Message { + def render: String = { + val opts = expected.mkString(", ") + val dots = if (exhaustive) "" else "…" + s"Expected: ${opts}${dots}" + } + } private[query] def fromError(input: String)(pe: Parser.Error): ParseFailure = ParseFailure( input, pe.failedAtOffset, - Parser.Expectation.unify(pe.expected).map(expectationToMsg) + packMsg(Parser.Expectation.unify(pe.expected).map(expectationToMsg)) ) + private[query] def packMsg(msg: Nel[Message]): Nel[Message] = { + val expectMsg = combineExpected(msg.collect({ case em: ExpectMessage => em })) + .sortBy(_.offset).headOption + + val simpleMsg = msg.collect({ case sm: SimpleMessage => sm }) + + Nel.fromListUnsafe((simpleMsg ++ expectMsg).sortBy(_.offset)) + } + + private[query] def combineExpected(msg: List[ExpectMessage]): List[ExpectMessage] = + msg.groupBy(_.offset).map({ case (offset, es) => + ExpectMessage(offset, es.flatMap(_.expected).distinct.sorted, es.forall(_.exhaustive)) + }).toList + private[query] def expectationToMsg(e: Parser.Expectation): Message = e match { case StartOfString(offset) => - Message(offset, "Expected start of string") + SimpleMessage(offset, "Expected start of string") case FailWith(offset, message) => - Message(offset, message) + SimpleMessage(offset, message) case InRange(offset, lower, upper) => - if (lower == upper) Message(offset, s"Expected character: $lower") - else Message(offset, s"Expected character from range: [$lower .. $upper]") + if (lower == upper) ExpectMessage(offset, List(lower.toString), true) + else { + val expect = s"${lower}-${upper}" + ExpectMessage(offset, List(expect), true) + } case Length(offset, expected, actual) => - Message(offset, s"Expected input of length $expected, but got $actual") + SimpleMessage(offset, s"Expected input of length $expected, but got $actual") case ExpectedFailureAt(offset, matched) => - Message(offset, s"Expected failing, but matched '$matched'") + SimpleMessage(offset, s"Expected failing, but matched '$matched'") case EndOfString(offset, length) => - Message(offset, s"Expected end of string at length: $length") + SimpleMessage(offset, s"Expected end of string at length: $length") case Fail(offset) => - Message(offset, s"Failed to parse near $offset") + SimpleMessage(offset, s"Failed to parse near $offset") case OneOfStr(offset, strs) => - val options = strs.mkString(", ") - Message(offset, s"Expected one of the following strings: $options") + val options = strs.take(8) + ExpectMessage(offset, options.take(7), options.size < 8) } } From e681ffa96f894015c37fcfac0a71de18d5e43d61 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 20:46:42 +0100 Subject: [PATCH 31/33] Extend query builder allowing more conditions Before only a column or a dbfunction could be used in a condition. It is now allowed for all `SelectExpr`. --- .../scala/docspell/store/qb/Condition.scala | 8 +-- .../main/scala/docspell/store/qb/DSL.scala | 57 ++++++++++++++----- .../qb/generator/ItemQueryGenerator.scala | 16 +++--- .../store/qb/impl/ConditionBuilder.scala | 11 ++-- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/modules/store/src/main/scala/docspell/store/qb/Condition.scala b/modules/store/src/main/scala/docspell/store/qb/Condition.scala index 0b0c0692..9a329033 100644 --- a/modules/store/src/main/scala/docspell/store/qb/Condition.scala +++ b/modules/store/src/main/scala/docspell/store/qb/Condition.scala @@ -15,7 +15,7 @@ object Condition { val P: Put[A] ) extends Condition - case class CompareFVal[A](dbf: DBFunction, op: Operator, value: A)(implicit + case class CompareFVal[A](sel: SelectExpr, op: Operator, value: A)(implicit val P: Put[A] ) extends Condition @@ -23,11 +23,11 @@ object Condition { extends Condition case class InSubSelect[A](col: Column[A], subSelect: Select) extends Condition - case class InValues[A](col: Column[A], values: NonEmptyList[A], lower: Boolean)(implicit - val P: Put[A] + case class InValues[A](sel: SelectExpr, values: NonEmptyList[A], lower: Boolean)( + implicit val P: Put[A] ) extends Condition - case class IsNull(col: Column[_]) extends Condition + case class IsNull(sel: SelectExpr) extends Condition case class And(inner: NonEmptyList[Condition]) extends Condition { def append(other: Condition): And = diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala index 63a0afc4..d2e78be1 100644 --- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala +++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala @@ -207,22 +207,22 @@ trait DSL extends DoobieMeta { in(subsel).negate def in(values: Nel[A])(implicit P: Put[A]): Condition = - Condition.InValues(col, values, false) + Condition.InValues(col.s, values, false) def notIn(values: Nel[A])(implicit P: Put[A]): Condition = in(values).negate def inLower(values: Nel[A])(implicit P: Put[A]): Condition = - Condition.InValues(col, values, true) + Condition.InValues(col.s, values, true) def notInLower(values: Nel[A])(implicit P: Put[A]): Condition = - Condition.InValues(col, values, true).negate + Condition.InValues(col.s, values, true).negate def isNull: Condition = - Condition.IsNull(col) + Condition.IsNull(col.s) def isNotNull: Condition = - Condition.IsNull(col).negate + Condition.IsNull(col.s).negate def ===(other: Column[A]): Condition = Condition.CompareCol(col, Operator.Eq, other) @@ -267,31 +267,31 @@ trait DSL extends DoobieMeta { SelectExpr.SelectFun(dbf, Some(otherCol.name)) def ===[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Eq, value) + Condition.CompareFVal(dbf.s, Operator.Eq, value) def ====(value: String): Condition = - Condition.CompareFVal(dbf, Operator.Eq, value) + Condition.CompareFVal(dbf.s, Operator.Eq, value) def like[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.LowerLike, value) + Condition.CompareFVal(dbf.s, Operator.LowerLike, value) def likes(value: String): Condition = - Condition.CompareFVal(dbf, Operator.LowerLike, value) + Condition.CompareFVal(dbf.s, Operator.LowerLike, value) def <=[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Lte, value) + Condition.CompareFVal(dbf.s, Operator.Lte, value) def >=[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Gte, value) + Condition.CompareFVal(dbf.s, Operator.Gte, value) def >[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Gt, value) + Condition.CompareFVal(dbf.s, Operator.Gt, value) def <[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Lt, value) + Condition.CompareFVal(dbf.s, Operator.Lt, value) def <>[A](value: A)(implicit P: Put[A]): Condition = - Condition.CompareFVal(dbf, Operator.Neq, value) + Condition.CompareFVal(dbf.s, Operator.Neq, value) def -[A](value: A)(implicit P: Put[A]): DBFunction = DBFunction.Calc( @@ -300,6 +300,35 @@ trait DSL extends DoobieMeta { SelectExpr.SelectConstant(value, None) ) } + + implicit final class SelectExprOps(sel: SelectExpr) { + def isNull: Condition = + Condition.IsNull(sel) + + def isNotNull: Condition = + Condition.IsNull(sel).negate + + def ===[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Eq, value) + + def <=[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Lte, value) + + def >=[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Gte, value) + + def >[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Gt, value) + + def <[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Lt, value) + + def <>[A](value: A)(implicit P: Put[A]): Condition = + Condition.CompareFVal(sel, Operator.Neq, value) + + def in[A](values: Nel[A])(implicit P: Put[A]): Condition = + Condition.InValues(sel, values, false) + } } object DSL extends DSL { diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index a99516ff..6a721270 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -92,7 +92,7 @@ object ItemQueryGenerator { val dt = dateToTimestamp(today)(value) val col = timestampColumn(tables)(attr) val noLikeOp = if (op == Operator.Like) Operator.Eq else op - Condition.CompareVal(col, makeOp(noLikeOp), dt) + Condition.CompareFVal(col, makeOp(noLikeOp), dt) case Expr.SimpleExpr(op, Property.IntProperty(attr, value)) => val col = intColumn(tables)(attr) @@ -203,22 +203,22 @@ object ItemQueryGenerator { today } - private def anyColumn(tables: Tables)(attr: Attr): Column[_] = + private def anyColumn(tables: Tables)(attr: Attr): SelectExpr = attr match { case s: Attr.StringAttr => - stringColumn(tables)(s) + stringColumn(tables)(s).s case t: Attr.DateAttr => timestampColumn(tables)(t) case n: Attr.IntAttr => - intColumn(tables)(n) + intColumn(tables)(n).s } - private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) = + private def timestampColumn(tables: Tables)(attr: Attr.DateAttr): SelectExpr = attr match { case Attr.Date => - tables.item.itemDate + coalesce(tables.item.itemDate.s, tables.item.created.s).s case Attr.DueDate => - tables.item.dueDate + tables.item.dueDate.s } private def stringColumn(tables: Tables)(attr: Attr.StringAttr): Column[String] = @@ -283,7 +283,7 @@ object ItemQueryGenerator { value.toDoubleOption .map { n => - val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s), op, n) + val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s).s, op, n) val fieldIsNumeric = cf.ftype === CustomFieldType.Numeric || cf.ftype === CustomFieldType.Money val fieldNotNumeric = diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala index f110bb92..c9e50575 100644 --- a/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala +++ b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala @@ -85,7 +85,7 @@ object ConditionBuilder { case Operator.LowerEq => lower(dbf) case _ => - DBFunctionBuilder.build(dbf) + SelectExprBuilder.build(dbf) } dbfFrag ++ opFrag ++ valFrag @@ -105,13 +105,13 @@ object ConditionBuilder { SelectExprBuilder.column(col) ++ sql" IN (" ++ sub ++ parenClose case c @ Condition.InValues(col, values, toLower) => - val cfrag = if (toLower) lower(col) else SelectExprBuilder.column(col) + val cfrag = if (toLower) lower(col) else SelectExprBuilder.build(col) cfrag ++ sql" IN (" ++ values.toList .map(a => buildValue(a)(c.P)) .reduce(_ ++ comma ++ _) ++ parenClose case Condition.IsNull(col) => - SelectExprBuilder.column(col) ++ fr" is null" + SelectExprBuilder.build(col) ++ fr" is null" case Condition.And(ands) => val inner = ands.map(build).reduceLeft(_ ++ and ++ _) @@ -124,7 +124,7 @@ object ConditionBuilder { else parenOpen ++ inner ++ parenClose case Condition.Not(Condition.IsNull(col)) => - SelectExprBuilder.column(col) ++ fr" is not null" + SelectExprBuilder.build(col) ++ fr" is not null" case Condition.Not(c) => fr"NOT" ++ build(c) @@ -159,6 +159,9 @@ object ConditionBuilder { def buildOptValue[A: Put](v: Option[A]): Fragment = fr"$v" + def lower(sel: SelectExpr): Fragment = + Fragment.const0("LOWER(") ++ SelectExprBuilder.build(sel) ++ parenClose + def lower(col: Column[_]): Fragment = Fragment.const0("LOWER(") ++ SelectExprBuilder.column(col) ++ parenClose From 77a87782b7bcb5ebf51119f8a8ef77e34bfcdfb3 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 22:46:40 +0100 Subject: [PATCH 32/33] Refactoring parser - put all used strings in one place to have it easier to track - don't use `$` for shortcuts, it's a detail not interesting to a user; now names must not clash (which is a good idea anyways) - Added two more shortcuts `conc` and `corr` --- .../main/scala/docspell/query/ItemQuery.scala | 27 ++++++++-- .../scala/docspell/query/ParseFailure.scala | 21 +++++--- .../docspell/query/internal/AttrParser.scala | 35 ++++++------- .../docspell/query/internal/Constants.scala | 50 +++++++++++++++++++ .../docspell/query/internal/ExprParser.scala | 5 +- .../docspell/query/internal/MacroParser.scala | 28 ++++++++--- .../query/internal/OperatorParser.scala | 19 +++---- .../query/internal/SimpleExprParser.scala | 44 ++++++++++------ .../docspell/query/FulltextExtractTest.scala | 4 +- .../query/internal/AttrParserTest.scala | 4 -- .../query/internal/MacroParserTest.scala | 6 +-- .../query/internal/SimpleExprParserTest.scala | 8 +-- .../generator/ItemQueryGeneratorTest.scala | 3 +- .../webapp/src/main/elm/Data/ItemQuery.elm | 8 +-- 14 files changed, 183 insertions(+), 79 deletions(-) create mode 100644 modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 94f3868f..e2b7ef06 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -123,23 +123,40 @@ object ItemQuery { sealed trait MacroExpr extends Expr { def body: Expr } - case class NamesMacro(searchTerm: String) extends MacroExpr { + final case class NamesMacro(searchTerm: String) extends MacroExpr { val body = Expr.or( like(Attr.ItemName, searchTerm), - like(Attr.ItemNotes, searchTerm), like(Attr.Correspondent.OrgName, searchTerm), like(Attr.Correspondent.PersonName, searchTerm), like(Attr.Concerning.PersonName, searchTerm), like(Attr.Concerning.EquipName, searchTerm) ) } - case class DateRangeMacro(attr: DateAttr, left: Date, right: Date) extends MacroExpr { + + final case class CorrMacro(term: String) extends MacroExpr { val body = - and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right)) + Expr.or( + like(Attr.Correspondent.OrgName, term), + like(Attr.Correspondent.PersonName, term) + ) } - case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr { + final case class ConcMacro(term: String) extends MacroExpr { + val body = + Expr.or( + like(Attr.Concerning.PersonName, term), + like(Attr.Concerning.EquipName, term) + ) + } + + final case class DateRangeMacro(attr: DateAttr, left: Date, right: Date) + extends MacroExpr { + val body = + and(date(Operator.Gte, attr, left), date(Operator.Lt, attr, right)) + } + + final case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr { val body = DateRangeMacro(attr, date(year), date(year + 1)) diff --git a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala index 4562d68c..128a08c4 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala @@ -31,9 +31,10 @@ object ParseFailure { } final case class SimpleMessage(offset: Int, msg: String) extends Message { def render: String = - s"Failed at $offset: $msg" + s"Failed at $offset: $msg" } - final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean) extends Message { + final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean) + extends Message { def render: String = { val opts = expected.mkString(", ") val dots = if (exhaustive) "" else "…" @@ -50,7 +51,8 @@ object ParseFailure { private[query] def packMsg(msg: Nel[Message]): Nel[Message] = { val expectMsg = combineExpected(msg.collect({ case em: ExpectMessage => em })) - .sortBy(_.offset).headOption + .sortBy(_.offset) + .headOption val simpleMsg = msg.collect({ case sm: SimpleMessage => sm }) @@ -58,9 +60,16 @@ object ParseFailure { } private[query] def combineExpected(msg: List[ExpectMessage]): List[ExpectMessage] = - msg.groupBy(_.offset).map({ case (offset, es) => - ExpectMessage(offset, es.flatMap(_.expected).distinct.sorted, es.forall(_.exhaustive)) - }).toList + msg + .groupBy(_.offset) + .map({ case (offset, es) => + ExpectMessage( + offset, + es.flatMap(_.expected).distinct.sorted, + es.forall(_.exhaustive) + ) + }) + .toList private[query] def expectationToMsg(e: Parser.Expectation): Message = e match { diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala index d7aab3ed..6d74ea59 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala @@ -3,67 +3,68 @@ package docspell.query.internal import cats.parse.{Parser => P} import docspell.query.ItemQuery.Attr +import docspell.query.internal.{Constants => C} object AttrParser { val name: P[Attr.StringAttr] = - P.ignoreCase("name").as(Attr.ItemName) + P.ignoreCase(C.name).as(Attr.ItemName) val source: P[Attr.StringAttr] = - P.ignoreCase("source").as(Attr.ItemSource) + P.ignoreCase(C.source).as(Attr.ItemSource) val id: P[Attr.StringAttr] = - P.ignoreCase("id").as(Attr.ItemId) + P.ignoreCase(C.id).as(Attr.ItemId) val date: P[Attr.DateAttr] = - P.ignoreCase("date").as(Attr.Date) + P.ignoreCase(C.date).as(Attr.Date) val notes: P[Attr.StringAttr] = - P.ignoreCase("notes").as(Attr.ItemNotes) + P.ignoreCase(C.notes).as(Attr.ItemNotes) val dueDate: P[Attr.DateAttr] = - P.stringIn(List("dueDate", "due", "due-date")).as(Attr.DueDate) + P.ignoreCase(C.due).as(Attr.DueDate) val corrOrgId: P[Attr.StringAttr] = - P.stringIn(List("correspondent.org.id", "corr.org.id")) + P.ignoreCase(C.corrOrgId) .as(Attr.Correspondent.OrgId) val corrOrgName: P[Attr.StringAttr] = - P.stringIn(List("correspondent.org.name", "corr.org.name")) + P.ignoreCase(C.corrOrgName) .as(Attr.Correspondent.OrgName) val corrPersId: P[Attr.StringAttr] = - P.stringIn(List("correspondent.person.id", "corr.pers.id")) + P.ignoreCase(C.corrPersId) .as(Attr.Correspondent.PersonId) val corrPersName: P[Attr.StringAttr] = - P.stringIn(List("correspondent.person.name", "corr.pers.name")) + P.ignoreCase(C.corrPersName) .as(Attr.Correspondent.PersonName) val concPersId: P[Attr.StringAttr] = - P.stringIn(List("concerning.person.id", "conc.pers.id")) + P.ignoreCase(C.concPersId) .as(Attr.Concerning.PersonId) val concPersName: P[Attr.StringAttr] = - P.stringIn(List("concerning.person.name", "conc.pers.name")) + P.ignoreCase(C.concPersName) .as(Attr.Concerning.PersonName) val concEquipId: P[Attr.StringAttr] = - P.stringIn(List("concerning.equip.id", "conc.equip.id")) + P.ignoreCase(C.concEquipId) .as(Attr.Concerning.EquipId) val concEquipName: P[Attr.StringAttr] = - P.stringIn(List("concerning.equip.name", "conc.equip.name")) + P.ignoreCase(C.concEquipName) .as(Attr.Concerning.EquipName) val folderId: P[Attr.StringAttr] = - P.ignoreCase("folder.id").as(Attr.Folder.FolderId) + P.ignoreCase(C.folderId).as(Attr.Folder.FolderId) val folderName: P[Attr.StringAttr] = - P.ignoreCase("folder").as(Attr.Folder.FolderName) + P.ignoreCase(C.folder).as(Attr.Folder.FolderName) val attachCountAttr: P[Attr.IntAttr] = - P.ignoreCase("attach.count").as(Attr.AttachCount) + P.ignoreCase(C.attachCount).as(Attr.AttachCount) // combining grouped by type diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala b/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala new file mode 100644 index 00000000..232d6940 --- /dev/null +++ b/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala @@ -0,0 +1,50 @@ +package docspell.query.internal + +object Constants { + + val attachCount = "attach.count" + val attachId = "attach.id" + val cat = "cat" + val checksum = "checksum" + val conc = "conc" + val concEquipId = "conc.equip.id" + val concEquipName = "conc.equip.name" + val concPersId = "conc.pers.id" + val concPersName = "conc.pers.name" + val content = "content" + val corr = "corr" + val corrOrgId = "corr.org.id" + val corrOrgName = "corr.org.name" + val corrPersId = "corr.pers.id" + val corrPersName = "corr.pers.name" + val customField = "f" + val customFieldId = "f.id" + val date = "date" + val dateIn = "dateIn" + val due = "due" + val dueIn = "dueIn" + val exist = "exist" + val folder = "folder" + val folderId = "folder.id" + val id = "id" + val inbox = "inbox" + val incoming = "incoming" + val name = "name" + val names = "names" + val notPrefix = '!' + val notes = "notes" + val source = "source" + val tag = "tag" + val tagId = "tag.id" + val year = "year" + + // operators + val eqs = '=' + val gt = '>' + val gte = ">=" + val in = "~=" + val like = ':' + val lt = '<' + val lte = "<=" + val neq = "!=" +} diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala index 329ec030..de6e9165 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala @@ -4,6 +4,7 @@ import cats.parse.{Parser => P} import docspell.query.ItemQuery import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} object ExprParser { @@ -20,7 +21,7 @@ object ExprParser { .map(Expr.OrExpr.apply) def not(inner: P[Expr]): P[Expr] = - (P.char('!') *> inner).map(_.negate) + (P.char(C.notPrefix) *> inner).map(_.negate) val exprParser: P[Expr] = P.recursive[Expr] { recurse => @@ -28,7 +29,7 @@ object ExprParser { val orP = or(recurse) val notP = not(recurse) val macros = MacroParser.all - P.oneOf(SimpleExprParser.simpleExpr :: macros :: andP :: orP :: notP :: Nil) + P.oneOf(macros :: SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil) } def parseQuery(input: String): Either[P.Error, ItemQuery] = { diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala index 0cfdda0f..44b2d93f 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala @@ -3,10 +3,11 @@ package docspell.query.internal import cats.parse.{Parser => P} import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} object MacroParser { private def macroDef(name: String): P[Unit] = - P.char('$').soft.with1 *> P.string(name) <* P.char(':') + P.ignoreCase(name).soft.with1 <* P.char(':') private def dateRangeMacroImpl( name: String, @@ -20,20 +21,35 @@ object MacroParser { (macroDef(name) *> DateParser.yearOnly).map(year => Expr.YearMacro(attr, year)) val namesMacro: P[Expr.NamesMacro] = - (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply) + (macroDef(C.names) *> BasicParser.singleString).map(Expr.NamesMacro.apply) val dateRangeMacro: P[Expr.DateRangeMacro] = - dateRangeMacroImpl("datein", Attr.Date) + dateRangeMacroImpl(C.dateIn, Attr.Date) val dueDateRangeMacro: P[Expr.DateRangeMacro] = - dateRangeMacroImpl("duein", Attr.DueDate) + dateRangeMacroImpl(C.dueIn, Attr.DueDate) val yearDateMacro: P[Expr.YearMacro] = - yearMacroImpl("year", Attr.Date) + yearMacroImpl(C.year, Attr.Date) + + val corrMacro: P[Expr.CorrMacro] = + (macroDef(C.corr) *> BasicParser.singleString).map(Expr.CorrMacro.apply) + + val concMacro: P[Expr.ConcMacro] = + (macroDef(C.conc) *> BasicParser.singleString).map(Expr.ConcMacro.apply) // --- all macro parser val all: P[Expr] = - P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro, yearDateMacro)) + P.oneOf( + List( + namesMacro, + dateRangeMacro, + dueDateRangeMacro, + yearDateMacro, + corrMacro, + concMacro + ) + ) } diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala index de93fb4f..2b5956a4 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala @@ -3,37 +3,38 @@ package docspell.query.internal import cats.parse.{Parser => P} import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} object OperatorParser { private[this] val Eq: P[Operator] = - P.char('=').as(Operator.Eq) + P.char(C.eqs).as(Operator.Eq) private[this] val Neq: P[Operator] = - P.string("!=").as(Operator.Neq) + P.string(C.neq).as(Operator.Neq) private[this] val Like: P[Operator] = - P.char(':').as(Operator.Like) + P.char(C.like).as(Operator.Like) private[this] val Gt: P[Operator] = - P.char('>').as(Operator.Gt) + P.char(C.gt).as(Operator.Gt) private[this] val Lt: P[Operator] = - P.char('<').as(Operator.Lt) + P.char(C.lt).as(Operator.Lt) private[this] val Gte: P[Operator] = - P.string(">=").as(Operator.Gte) + P.string(C.gte).as(Operator.Gte) private[this] val Lte: P[Operator] = - P.string("<=").as(Operator.Lte) + P.string(C.lte).as(Operator.Lte) val op: P[Operator] = P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt)) private[this] val anyOp: P[TagOperator] = - P.char(':').as(TagOperator.AnyMatch) + P.char(C.like).as(TagOperator.AnyMatch) private[this] val allOp: P[TagOperator] = - P.char('=').as(TagOperator.AllMatch) + P.char(C.eqs).as(TagOperator.AllMatch) val tagOp: P[TagOperator] = P.oneOf(List(anyOp, allOp)) diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala index 56a0077a..950df0cb 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala @@ -4,6 +4,7 @@ import cats.parse.Numbers import cats.parse.{Parser => P} import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} object SimpleExprParser { @@ -11,7 +12,7 @@ object SimpleExprParser { OperatorParser.op.surroundedBy(BasicParser.ws0) private[this] val inOp: P[Unit] = - P.string("~=").surroundedBy(BasicParser.ws0) + P.string(C.in).surroundedBy(BasicParser.ws0) private[this] val inOrOpStr = P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore) @@ -44,52 +45,63 @@ object SimpleExprParser { } val existsExpr: P[Expr.Exists] = - (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr)) + (P.ignoreCase(C.exist) *> P.char(C.like) *> AttrParser.anyAttr).map(attr => + Expr.Exists(attr) + ) val fulltextExpr: P[Expr.Fulltext] = - (P.ignoreCase("content:") *> BasicParser.singleString).map(q => Expr.Fulltext(q)) + (P.ignoreCase(C.content) *> P.char(C.like) *> BasicParser.singleString).map(q => + Expr.Fulltext(q) + ) val tagIdExpr: P[Expr.TagIdsMatch] = - (P.ignoreCase("tag.id") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + (P.ignoreCase(C.tagId) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { case (op, values) => Expr.TagIdsMatch(op, values) } val tagExpr: P[Expr.TagsMatch] = - (P.ignoreCase("tag") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + (P.ignoreCase(C.tag) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { case (op, values) => Expr.TagsMatch(op, values) } val catExpr: P[Expr.TagCategoryMatch] = - (P.ignoreCase("cat") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { + (P.ignoreCase(C.cat) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map { case (op, values) => Expr.TagCategoryMatch(op, values) } val customFieldExpr: P[Expr.CustomFieldMatch] = - (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map { - case ((name, op), value) => + (P.string(C.customField) *> P.char( + C.like + ) *> BasicParser.identParser ~ op ~ BasicParser.singleString) + .map { case ((name, op), value) => Expr.CustomFieldMatch(name, op, value) - } + } val customFieldIdExpr: P[Expr.CustomFieldIdMatch] = - (P.string("f.id:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map { - case ((name, op), value) => + (P.string(C.customFieldId) *> P.char( + C.like + ) *> BasicParser.identParser ~ op ~ BasicParser.singleString) + .map { case ((name, op), value) => Expr.CustomFieldIdMatch(name, op, value) - } + } val inboxExpr: P[Expr.InboxExpr] = - (P.string("inbox:") *> BasicParser.bool).map(Expr.InboxExpr.apply) + (P.string(C.inbox) *> P.char(C.like) *> BasicParser.bool).map(Expr.InboxExpr.apply) val dirExpr: P[Expr.DirectionExpr] = - (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply) + (P.string(C.incoming) *> P.char(C.like) *> BasicParser.bool) + .map(Expr.DirectionExpr.apply) val checksumExpr: P[Expr.ChecksumMatch] = - (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply) + (P.string(C.checksum) *> P.char(C.like) *> BasicParser.singleString) + .map(Expr.ChecksumMatch.apply) val attachIdExpr: P[Expr.AttachId] = - (P.ignoreCase("attach.id:") *> BasicParser.singleString).map(Expr.AttachId.apply) + (P.ignoreCase(C.attachId) *> P.char(C.eqs) *> BasicParser.singleString) + .map(Expr.AttachId.apply) val simpleExpr: P[Expr] = P.oneOf( diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala index 9b6ea799..755ea121 100644 --- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala @@ -42,9 +42,9 @@ class FulltextExtractTest extends FunSuite { test("find fulltext within and") { assertFtsSuccess("content:what name:test", "what".some) - assertFtsSuccess("$names:marc* content:what name:test", "what".some) + assertFtsSuccess("names:marc* content:what name:test", "what".some) assertFtsSuccess( - "$names:marc* date:2021-02 content:\"what else\" name:test", + "names:marc* date:2021-02 content:\"what else\" name:test", "what else".some ) } diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala index 4c6dce3c..34c2270f 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala @@ -13,10 +13,8 @@ class AttrParserTest extends FunSuite { assertEquals(p.parseAll("id"), Right(Attr.ItemId)) assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId)) assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName)) - assertEquals(p.parseAll("correspondent.org.name"), Right(Attr.Correspondent.OrgName)) assertEquals(p.parseAll("conc.pers.id"), Right(Attr.Concerning.PersonId)) assertEquals(p.parseAll("conc.pers.name"), Right(Attr.Concerning.PersonName)) - assertEquals(p.parseAll("concerning.person.name"), Right(Attr.Concerning.PersonName)) assertEquals(p.parseAll("folder"), Right(Attr.Folder.FolderName)) assertEquals(p.parseAll("folder.id"), Right(Attr.Folder.FolderId)) } @@ -24,14 +22,12 @@ class AttrParserTest extends FunSuite { test("date attributes") { val p = AttrParser.dateAttr assertEquals(p.parseAll("date"), Right(Attr.Date)) - assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate)) assertEquals(p.parseAll("due"), Right(Attr.DueDate)) } test("all attributes parser") { val p = AttrParser.anyAttr assertEquals(p.parseAll("date"), Right(Attr.Date)) - assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate)) assertEquals(p.parseAll("name"), Right(Attr.ItemName)) assertEquals(p.parseAll("source"), Right(Attr.ItemSource)) assertEquals(p.parseAll("id"), Right(Attr.ItemId)) diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala index def772bd..15855916 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala @@ -6,10 +6,10 @@ import docspell.query.ItemQuery.Expr class MacroParserTest extends FunSuite { - test("start with $") { + test("recognize names shortcut") { val p = MacroParser.namesMacro - assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test"))) - assert(p.parseAll("names:test").isLeft) + assertEquals(p.parseAll("names:test"), Right(Expr.NamesMacro("test"))) + assert(p.parseAll("$names:test").isLeft) } } diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala index 7a107ad7..35660dfa 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala @@ -77,10 +77,10 @@ class SimpleExprParserTest extends FunSuite with ValueHelper { test("exists expr") { val p = SimpleExprParser.existsExpr - assertEquals(p.parseAll("exists:name"), Right(Expr.Exists(Attr.ItemName))) - assert(p.parseAll("exists:blabla").isLeft) + assertEquals(p.parseAll("exist:name"), Right(Expr.Exists(Attr.ItemName))) + assert(p.parseAll("exist:blabla").isLeft) assertEquals( - p.parseAll("exists:conc.pers.id"), + p.parseAll("exist:conc.pers.id"), Right(Expr.Exists(Attr.Concerning.PersonId)) ) } @@ -153,7 +153,7 @@ class SimpleExprParserTest extends FunSuite with ValueHelper { Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14))) ) assertEquals( - p.parseAll("exists:conc.pers.id"), + p.parseAll("exist:conc.pers.id"), Right(Expr.Exists(Attr.Concerning.PersonId)) ) assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test"))) diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala index 7d511026..ebc8fd33 100644 --- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala +++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala @@ -35,7 +35,8 @@ object ItemQueryGeneratorTest extends SimpleTestSuite { val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q) val expect = tables.item.name.like("hello") && - tables.item.itemDate >= mkTimestamp(2020, 2, 1) && + coalesce(tables.item.itemDate.s, tables.item.created.s) >= + mkTimestamp(2020, 2, 1) && (tables.item.source.like("expense%") || tables.folder.name === "test") assertEquals(cond, expect) diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 54a54e8e..f301abe6 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -140,16 +140,16 @@ render q = "folder.id" ++ attrMatch m ++ quoteStr id CorrOrgId m id -> - "correspondent.org.id" ++ attrMatch m ++ quoteStr id + "corr.org.id" ++ attrMatch m ++ quoteStr id CorrPersId m id -> - "correspondent.person.id" ++ attrMatch m ++ quoteStr id + "corr.pers.id" ++ attrMatch m ++ quoteStr id ConcPersId m id -> - "concerning.person.id" ++ attrMatch m ++ quoteStr id + "conc.pers.id" ++ attrMatch m ++ quoteStr id ConcEquipId m id -> - "concerning.equip.id" ++ attrMatch m ++ quoteStr id + "conc.equip.id" ++ attrMatch m ++ quoteStr id CustomField m kv -> "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value From ba898e4af405e8bf8cee238ab1dd9ad07464c249 Mon Sep 17 00:00:00 2001 From: Eike Kettner <eike.kettner@posteo.de> Date: Mon, 8 Mar 2021 22:59:41 +0100 Subject: [PATCH 33/33] Add some docs for using queries --- website/site/content/docs/api/intro.md | 71 +++ website/site/content/docs/features/_index.md | 2 + website/site/content/docs/query/_index.md | 527 ++++++++++++++++++ .../content/docs/query/enable-powersearch.png | Bin 0 -> 91680 bytes 4 files changed, 600 insertions(+) create mode 100644 website/site/content/docs/query/_index.md create mode 100644 website/site/content/docs/query/enable-powersearch.png diff --git a/website/site/content/docs/api/intro.md b/website/site/content/docs/api/intro.md index 6d9a7818..c6359ab0 100644 --- a/website/site/content/docs/api/intro.md +++ b/website/site/content/docs/api/intro.md @@ -116,3 +116,74 @@ $ curl -H 'X-Docspell-Auth: 1568142446077-ZWlrZS9laWtl-$2a$10$3B0teJ9rMpsBJPzHfZ ,"tagCloud":{"items":[]} } ``` + +### Search for items + +``` bash +$ curl -i -H 'X-Docspell-Auth: 1615240493…kYtFynj4' \ + 'http://localhost:7880/api/v1/sec/item/search?q=tag=todo,invoice%20year:2021' +{ + "groups": [ + { + "name": "2021-02", + "items": [ + { + "id": "41J962DjS7T-sjP9idxJ6o9-hJrmBk34YJN-mQqysHwcFD6", + "name": "something.txt", + "state": "confirmed", + "date": 1613598750202, + "dueDate": 1617883200000, + "source": "webapp", + "direction": "outgoing", + "corrOrg": { + "id": "J58tYifCh4X-cze5R8eSJcc-YAFr6qt1VKL-1ZmhRwiTXoH", + "name": "EasyCare AG" + }, + "corrPerson": null, + "concPerson": null, + "concEquipment": null, + "folder": { + "id": "GKwSvYVdvfb-QeAwzzT7pBM-Gbji2hQc2bL-uCyrMCAg3wo", + "name": "test" + }, + "attachments": [], + "tags": [], + "customfields": [], + "notes": null, + "highlighting": [] + } + ] + }, + { + "name": "2021-01", + "items": [ + { + "id": "ANqtuDynXWU-PrhzUxzQVmH-PDuJfeJ6dYB-Ut3g1jrcFhw", + "name": "letter-de.pdf", + "state": "confirmed", + "date": 1611144000000, + "dueDate": null, + "source": "webapp", + "direction": "incoming", + "corrOrg": { + "id": "J58tYifCh4X-cze5R8eSJcc-YAFr6qt1VKL-1ZmhRwiTXoH", + "name": "EasyCare AG" + }, + "corrPerson": null, + "concPerson": { + "id": "AA5sV1nH9ve-mDCn4DxDRvu-tWkUquiW4fZ-fVJimW4Vq79", + "name": "Max Mustermann" + }, + "concEquipment": null, + "folder": null, + "attachments": [], + "tags": [], + "customfields": [], + "notes": null, + "highlighting": [] + } + ] + } + ] +} +``` diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md index 2160da95..cf213621 100644 --- a/website/site/content/docs/features/_index.md +++ b/website/site/content/docs/features/_index.md @@ -17,6 +17,8 @@ description = "A list of features and limitations." - Conversion to PDF: all files are converted into a PDF file. PDFs with only images (as often returned from scanners) are converted into searchable PDF/A pdfs. +- A powerful [query language](@/docs/query/_index.md) to find + documents - Non-destructive: all your uploaded files are never modified and can always be downloaded untouched - Organize files using tags, folders, [Custom diff --git a/website/site/content/docs/query/_index.md b/website/site/content/docs/query/_index.md new file mode 100644 index 00000000..dad3d732 --- /dev/null +++ b/website/site/content/docs/query/_index.md @@ -0,0 +1,527 @@ ++++ +title = "Query Language" +weight = 55 +description = "The query language is a powerful way to search for documents." +insert_anchor_links = "right" +[extra] +mktoc = true ++++ + + +Docspell uses a query language to provide a powerful way to search for +your documents. It is targeted at advanced users and it needs to be +enabled explicitely in your user settings. + +<div class="colums"> +{{ figure(file="enable-powersearch.png") }} +</div> + +This changes the search bar on the items list page to expect a query +as described below. + +The search menu works as before, the query coming from the search menu +is combined with a query from the search bar. + +For taking a quick look, head over to the [examples](#examples). + +# Structure + +The overall query is an expression that evaluates to `true` or `false` +when applied to an item and so selects whether to include it in the +results or not. It consists of smaller expressions that can be +combined via the common ways: `and`, `or` and `not`. + +Simple expressions check some property of an item. The form is: + +``` +<field><operator><value> +``` + +For example: `tag=invoice` – where `tag` is the field, `=` the +operator and `invoice` the value. It would evaluate to `true` if the +item has a tag with name `invoice` and to `false` if the item doesn't +have a tag with name `invoice`. + +Multiple expressions are separated by whitespace and are combined via +`AND` by default. To explicitely combine them, wrap a list of +expressions into one of these: + +- `(& … )` to combine them via `AND` +- `(| … )` to combine them via `OR` + +It is also possible to negate an expression, by prefixing it with a +`!`; for example `!tag=invoice`. + +# The Parts + +## Operators + +There are 7 operators: + +- `=` for equals +- `>` for greater-than +- `>=` for greater-equals +- `~=` for "in" (a shorter way to say "a or b or c or d") +- `:` for "like" +- `<` for lower than +- `<=` for lower-equal +- `!=` for not-equals + +Not all operators work with every field. + +## Fields + +Fields are used to identify a property of an item. They also define +what operators are allowed. There are fields where an item can have at +most one value (like `name` or `notes`) and there are fields where an +item can have multiple values (like `tag`). At last there are special +fields that are either implemented directly using custom sql or that +are shortcuts to a longer form. + +Here is the list of all available fields. + +These fields map to at most one value: + +- `name` the item name +- `source` the source used for uploading +- `notes` the item notes +- `id` the item id +- `date` the item date +- `due` the due date of the item +- `attach.count` the number of attachments of the item +- `corr.org.id` the id of the correspondent organization +- `corr.org.name` the name of the correspondent organization +- `corr.pers.name` name of correspondent person +- `corr.pers.id` id of correspondent person +- `conc.pers.name` name of concerning person +- `conc.pers.id` id of concerning person +- `conc.equip.name` name of equipment +- `conc.equip.id` id of equipment +- `folder.id` id of a folder +- `folder` name of a folder +- `inbox` whether to return "new" items (boolean) +- `incoming` whether to return incoming items (boolean), `true` to + show only incoming, `false` to show only outgoing. + +These fields support all operators, except `incoming` and `inbox` +which expect boolean values and there these operators don't make much +sense. + +Fields that map to more than one value: + +- `tag` the tag name +- `tag.id` the tag id +- `cat` name of the tag category + +The tag and category fields use two operators: `:` and `=`. + +Other special fields: + +- `attach.id` +- `checksum` +- `content` +- `f` for referencing custom fields by name +- `f.id` for referencing custom fields by their id +- `dateIn` a shortcut for a range search +- `dueIn` a shortcut for a range search +- `exist` check if some porperty exists +- `names` +- `year` +- `conc` +- `corr` + +These fields are often using the `:` operator to simply separate field +and value. They are often backed by a custom implementation, or they +are shortcuts for a longer query. + +## Values + +Values are the data you want to search for. There are different kinds +of that, too: there are text-based values, numbers, boolean and dates. +When multiple values are allowed, they must be separated by comma `,`. + +### Text Values + +Text values need to be put in quotes (`"`) if they contain one of +these characters: +- whitespace ` ` +- quotes `"` +- backslash `\` +- comma `,` +- brackets `[]` +- parens `()` + +Any quotes inside a quoted string must be escaped with a backslash. +Examples: `scan_123`, `a-b-c`, `x.y.z`, `"scan from today"`, `"a \"strange\" +name.pdf"` + +### Numeric and Boolean Values + +Numeric values can be entered literally; an optional fraction part is +separetd by a dot. Examples: `1`, `2.15`. + +A boolean value can be specfied by `yes` or `true` and `no` or +`false`, respectively. Example: `inbox:yes` + +### Dates + +Dates are always treated as local dates and can be entered in multiple +ways. + +#### Date Pattern + +They can be in the following form: `YYYY-MM-DD` or `YYYY/MM/DD`. +The month and day part are optional; if they are missing they are +filled automatically with a `1`. So `2020-01` would be the same as +`2020-01-01`. + +A special pattern is `today` which marks the current day. + +#### Unix Epoch + +Dates can be given in milliseconds from unix epoch. Then it must be +prefixed by `ms`. The time part is ignored. Examples: +`ms1615209591627`. + +#### Calculation + +Dates can be defined by providing a base date and a period to add or +substract. This is especially useful with the `today` pattern. The +period must be separated from the date by a semi-colon `;`. Then write +a `+` or a `-` to add or substract and at last the number of days +(suffix `d`) or months (suffix `m`). + +Examples: `today;-14d`, `2020-02;+1m` + +# Simple Expressions + +Simple expressions are made up of a field with at most one value, an +operator and one or more values. These fields support all operators, +except for boolean fields. + +The like operator `:` can be used with all values, but makes only +sense for text values. It allows to do a substring search for a field. +For example, to look for an item with a name of exactly 'invoice_22': + +``` +name=invoice_22 +``` + +Using `:` it is possible to look for items that have 'invoice' in +their name: + +``` +name:*invoice* +``` + +The asterisk `*` can be added at the beginning and/or end of the +value. Furthermore, the like operator is case-insensitive, whereas `=` +is not. This applies to all fields with a text value; this is another +example looking for a correspondent person of with 'marcus' in the +name: +``` +corr.pers.name:*marcus* +``` + + +---- + +Comparisons via `<`/`>` are done alphanumerically for text based +values and numerically for numeric values. For booleans these +operators don't make sense and therefore don't work there. + +---- + +All these fields (except boolean fields) allow to use the in-operator, +`~=`. This is a more efficient form to specify a list of alternatives +and is logically the same as combining multiple expressions with +`OR`. For example: + +``` +source~=webapp,mailbox +``` + +is the same as +``` +(| source=webapp source=mailbox ) +``` + +The `~=` version is nicer to read, safes some key strokes and also +runs more efficient when the list grows. It is *not* possible to use a +wildcard `*` here. If a wildcard is required, you need to write the +longer form. + +If one value contains whitespace or other characters that require +quoting, each value must be quoted, not the whole list. So this is +correct: +``` +source~="web app","mail box" +``` + +This is not correct: `source~="web app,mail box"` – it would be treated +as one single value and is then essentially the same as using `=`. + +---- + +The two fields `incoming` and `inbox` expect a boolean value: one of +`true` or `false`. The synonyms `yes` and `no` can also be used to +make it better readable. + +This finds all items that have not been confirmed: +``` +inbox:yes +``` + +The `incoming` can be used to show only incoming or only outgoing +documents: + +``` +incoming:yes +``` + +For outgoing, you need to say: +``` +incoming:no +``` + + +# Tags + +Tags have their own syntax, because they are an important tool for +organizing items. Tags only allow for two operators: `=` and `:`. +Combined with negation (the `!` operator), this is quite flexible. + +For tags, `=` means that items must have *all* specified tags (or +more), while `:` means that items must have at least *one* of the +specified tags. Tags can be identified by their name or id and are +given as a comma separated list (just like when using the +in-operator). + +Some examples: Find all invoices that are todo: +``` +tag=invoice,todo +``` + +This returns all items that have tags `invoice` and `todo` – and +possible some other tags. Negating this: +``` +!tag=invoice,todo +``` + +… results in an expression that returns all items that don't have +*both* tags. It might return items with tag `invoice` and also items +with tag `todo`, but no items that have both of them. + +Using `:` is just analog to `=`. This finds all items that are either +`waiting` or `todo` (or both): + +``` +tag:waiting,todo +``` + +When negating this: +``` +!tag:waiting,todo +``` + +it finds all items that have *none* of the tags. + +Tag names are always compared case-insensitive. Tags can also be +selected using their id, then the field name `tag.id` must be used +instead of `tag`. + +The field `cat` can be used the same way to search for tag categories. + +# Custom Fields + +Custom fields are implemented via the following syntax: + +``` +f:<field-name><operator><value> +``` + +They look almost like a simple expression, only prefixed with a `f:` +to indicate that the following is the name of a custom field. + +The type of a custom field is honored. So if you have a money or +numeric type, comparsions are done numerically. Otherwise a +alphnumeric comparison is performed. Custom fields do not support the +in-operator (`~=`). + +For example: assuming there is a custom field of type *money* and name +*usd*, the following selects all items with an amount between 10 and +150: + +``` +f:usd>10 f:usd<150 +``` + +The like-operator can be used, too. For example, to find all items +that have a custom field `asn` (often used for a serial number printed +on the document): + +``` +f:asn:* +``` + +If the like operator is used on numeric fields, it falls back to +text-comparison. + +Instead of using the name, the field-id can be used to select a field. +Then the prefix is `f.id`: + +``` +f.id:J2ES1Z4Ni9W-xw1VdFbt3KA-rL725kuyVzh-7La95Yw7Ax2:15.00 +``` + + +# Fulltext Search + +The special field `content` allows to add a fulltext search. Using +this is currently restricted: it must occur in the root query and +cannot be nested in other complex expressions. + +The form is: + +``` +content:<your search query> +``` + +The search query is interpreted by the fulltext index (currently it is +SOLR). This is usually very powerful and in many cases this value must +be quoted. + +For example, do a fulltext search for 'red needle': +``` +content:"red needle" +``` + +It can be combined in an AND expression (but not deeper): +``` +content:"red needle" tag:todo +``` + + +# File Checksums + +The `checksum` field can be used to look for items that have a certain +file attached. It expects a SHA256 string. + +For example, this is the sha256 checksum of some file on the hard +disk: +`40675c22ab035b8a4ffe760732b65e5f1d452c59b44d3d0a2a08a95d28853497`. + +To find all items that have (exactly) this file attached: +``` +checksum:40675c22ab035b8a4ffe760732b65e5f1d452c59b44d3d0a2a08a95d28853497 +``` + +# Exist + +The `exist` field can be used with another field, to check whether an +item has some value for a given field. It only works for fields that +have at most one value. + +For example, it could be used to find fields that are in any folder: + +``` +exist:folder +``` + +When negating, it finds all items that are not in a folder: +``` +!exist:folder +``` + + +# Attach-Id + +The `attach.id` field is a special field to find items by providing +the id of an attachment. This can be helpful in certain situations +when you only have the id of an attachment. It always uses equality, +so all other operators are not supported. + +``` +attach.id=5YjdnuTAdKJ-V6ofWTYsqKV-mAwB5aXTNWE-FAbeRU58qLb +``` + +# Shortcuts + +Shortcuts are only a short form of a longer query and are provided for +convenience. The following exist: + +- `dateIn` and `dueIn` +- `year` +- `names` +- `conc` +- `corr` + + +### Date Ranges + +The first three are all short forms to specify a range search. With +`dateIn` and `dueIn` have three forms that are translated into a range +search: + +- `dateIn:2020-01;+15d` → `date>=2020-01 date<2020-01;+15d` +- `dateIn:2020-01;-15d` → `date>=2020-01;-15d date<2020-01` +- `dateIn:2020-01;/15d` → `date>=2020-01;-15d date<2020-01;+15d` + +The syntax is the same as defining a date by adding a period to some +base date. These two dates are used to expand the form into a range +search. There is an additional `/` character to allow to subtract and +add the period. + +The `year` is almost the same thing, only a lot shorter to write. It +expands into a range search (only for the item date!) that selects all +items with a date in the specified year: + +- `year:2020` → `date>=2020-01-01 date<2021-01-01` + +The last shortcut is `names`. It allows to search in many "names" of +related entities at once: + +### Names + +- `names:tim` → `(| name:tim corr.org.name:tim corr.pers.name:tim conc.pers.name:tim conc.equip.name:tim )` + +The `names` field uses the like-operator. + +The fields `conc` and `corr` are analog to `names`, only that they +look into correspondent names and concerning names. + +- `conc:marc*` → `(| conc.pers.name:marc* conc.equip.name:marc* )` +- `corr:marc*` → `(| corr.org.name:marc* corr.pers.name:marc* )` + + +# Examples + +Find items with 2 or more attachments: +``` +attach.count>2 +``` + +Find items with at least one tag invoice or todo: +``` +tag:invoice,todo +``` + +Find items with at least both tags invoice and todo: +``` +tag=invoice,todo +``` + +Find items with a concerning person of name starting with "Marcus": +``` +conc.pers.name:marcus* +``` + +Find items with at least a tag "todo" in year 2020: +``` +tag:todo year:2020 +``` + +Find items within the last 30 days: +``` +date>today;-30d +``` diff --git a/website/site/content/docs/query/enable-powersearch.png b/website/site/content/docs/query/enable-powersearch.png new file mode 100644 index 0000000000000000000000000000000000000000..ec5dbcc47319682ae76276a5818d41cde6e7f187 GIT binary patch literal 91680 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 OOXtQNjpA#V$o~LFO7=bg literal 0 HcmV?d00001