First draft of ast and parser

This commit is contained in:
Eike Kettner 2021-02-23 01:24:24 +01:00
parent 74a79a79d9
commit be5c7ffb88
25 changed files with 1190 additions and 24 deletions

@ -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

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

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

@ -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)))
}

@ -1,3 +0,0 @@
package docspell.query
case class Query(raw: String)

@ -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))
}
}

@ -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))
}

@ -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))
)
}

@ -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)
}

@ -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)
}
}

@ -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))
}

@ -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
)
)
}

@ -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)
}
}
}

@ -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))
}
}

@ -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")))
}
}

@ -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)))
}
}

@ -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")
)
)
)
)
}
}

@ -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))
}
}

@ -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))
}

@ -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 {}

@ -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)

@ -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)
)
}
}

@ -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
)

@ -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]

@ -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
)