Serving scalajs artifacts and provide errors to js

This commit is contained in:
Eike Kettner
2021-03-02 00:51:13 +01:00
parent 71985244f1
commit d4006461f6
25 changed files with 45 additions and 9 deletions

View File

@ -0,0 +1,32 @@
package docspell.query
import java.time.LocalDate
import java.time.Period
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)
sealed trait DateLiteral 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
}

View File

@ -0,0 +1,147 @@
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 {
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
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 ItemNotes 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
def apply(sa: StringAttr, value: String): Property =
StringProperty(sa, value)
def apply(da: DateAttr, value: Date): Property =
DateProperty(da, value)
}
sealed trait Expr {
def negate: Expr =
Expr.NotExpr(this)
}
object 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
}
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
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
final case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr
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
// 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: _*))
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))
}
}

View File

@ -0,0 +1,21 @@
package docspell.query
import docspell.query.internal.ExprParser
import docspell.query.internal.ExprUtil
object ItemQueryParser {
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(ParseFailure.fromError(in))
.map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
}
def parseUnsafe(input: String): ItemQuery =
parse(input).fold(m => sys.error(m.render), identity)
}

View File

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

View File

@ -0,0 +1,90 @@
package docspell.query.internal
import cats.parse.{Parser => P}
import docspell.query.ItemQuery.Attr
object AttrParser {
val name: P[Attr.StringAttr] =
P.ignoreCase("name").as(Attr.ItemName)
val source: P[Attr.StringAttr] =
P.ignoreCase("source").as(Attr.ItemSource)
val id: P[Attr.StringAttr] =
P.ignoreCase("id").as(Attr.ItemId)
val date: P[Attr.DateAttr] =
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")).as(Attr.DueDate)
val corrOrgId: P[Attr.StringAttr] =
P.stringIn(List("correspondent.org.id", "corr.org.id"))
.as(Attr.Correspondent.OrgId)
val corrOrgName: P[Attr.StringAttr] =
P.stringIn(List("correspondent.org.name", "corr.org.name"))
.as(Attr.Correspondent.OrgName)
val corrPersId: P[Attr.StringAttr] =
P.stringIn(List("correspondent.person.id", "corr.pers.id"))
.as(Attr.Correspondent.PersonId)
val corrPersName: P[Attr.StringAttr] =
P.stringIn(List("correspondent.person.name", "corr.pers.name"))
.as(Attr.Correspondent.PersonName)
val concPersId: P[Attr.StringAttr] =
P.stringIn(List("concerning.person.id", "conc.pers.id"))
.as(Attr.Concerning.PersonId)
val concPersName: P[Attr.StringAttr] =
P.stringIn(List("concerning.person.name", "conc.pers.name"))
.as(Attr.Concerning.PersonName)
val concEquipId: P[Attr.StringAttr] =
P.stringIn(List("concerning.equip.id", "conc.equip.id"))
.as(Attr.Concerning.EquipId)
val concEquipName: P[Attr.StringAttr] =
P.stringIn(List("concerning.equip.name", "conc.equip.name"))
.as(Attr.Concerning.EquipName)
val folderId: P[Attr.StringAttr] =
P.ignoreCase("folder.id").as(Attr.Folder.FolderId)
val folderName: P[Attr.StringAttr] =
P.ignoreCase("folder").as(Attr.Folder.FolderName)
val dateAttr: P[Attr.DateAttr] =
P.oneOf(List(date, dueDate))
val stringAttr: P[Attr.StringAttr] =
P.oneOf(
List(
name,
source,
id,
notes,
corrOrgId,
corrOrgName,
corrPersId,
corrPersName,
concPersId,
concPersName,
concEquipId,
concEquipName,
folderId,
folderName
)
)
val anyAttr: P[Attr] =
P.oneOf(List(dateAttr, stringAttr))
}

View File

@ -0,0 +1,47 @@
package docspell.query.internal
import cats.data.{NonEmptyList => Nel}
import cats.parse.{Parser => P, Parser0}
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.void
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 != ')'
)
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 <* ws0
val parenClose: P[Unit] =
ws0.soft.with1 *> P.char(')')
val parenOr: P[Unit] =
P.stringIn(List("(|", "(or")).void <* ws0
val identParser: P[String] =
P.charsWhile(identChars.contains)
val singleString: P[String] =
basicString.backtrack.orElse(StringUtil.quoted('"'))
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
}
}

View File

@ -0,0 +1,105 @@
package docspell.query.internal
import java.time.Period
import cats.data.{NonEmptyList => Nel}
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.charIn('-', '/').void
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
}
private[internal] val dateFromMillis: P[Date.DateLiteral] =
P.string("ms") *> longParser.map(Date.apply)
private val dateFromToday: P[Date.DateLiteral] =
P.string("today").as(Date.Today)
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)
}

View File

@ -0,0 +1,38 @@
package docspell.query.internal
import cats.parse.{Parser => P}
import docspell.query.ItemQuery
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] =
(P.char('!') *> inner).map(_.negate)
val exprParser: P[Expr] =
P.recursive[Expr] { recurse =>
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] = {
val p = BasicParser.ws0 *> exprParser <* (BasicParser.ws0 ~ P.end)
p.parseAll(input).map(expr => ItemQuery(expr, Some(input)))
}
}

View File

@ -0,0 +1,65 @@
package docspell.query.internal
import docspell.query.ItemQuery.Expr._
import docspell.query.ItemQuery._
object ExprUtil {
/** Does some basic transformation, like unfolding 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 InboxExpr(flag) =>
InboxExpr(!flag)
case DirectionExpr(flag) =>
DirectionExpr(!flag)
case _ =>
expr
}
case m: MacroExpr =>
reduce(m.body)
case DirectionExpr(_) =>
expr
case InboxExpr(_) =>
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
case CustomFieldIdMatch(_, _, _) =>
expr
}
}

View File

@ -0,0 +1,33 @@
package docspell.query.internal
import cats.parse.{Parser => P}
import docspell.query.ItemQuery._
object MacroParser {
private def macroDef(name: String): P[Unit] =
P.char('$').soft.with1 *> P.string(name) <* P.char(':')
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 namesMacro: P[Expr.NamesMacro] =
(macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply)
val dateRangeMacro: P[Expr.DateRangeMacro] =
dateRangeMacroImpl("datein", Attr.Date)
val dueDateRangeMacro: P[Expr.DateRangeMacro] =
dateRangeMacroImpl("duein", Attr.DueDate)
// --- all macro parser
val all: P[Expr] =
P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro))
}

View File

@ -0,0 +1,40 @@
package docspell.query.internal
import cats.parse.{Parser => P}
import docspell.query.ItemQuery._
object OperatorParser {
private[this] val Eq: P[Operator] =
P.char('=').as(Operator.Eq)
private[this] val Neq: P[Operator] =
P.string("!=").as(Operator.Neq)
private[this] val Like: P[Operator] =
P.char(':').as(Operator.Like)
private[this] val Gt: P[Operator] =
P.char('>').as(Operator.Gt)
private[this] val Lt: P[Operator] =
P.char('<').as(Operator.Lt)
private[this] val Gte: P[Operator] =
P.string(">=").as(Operator.Gte)
private[this] val Lte: P[Operator] =
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(':').as(TagOperator.AnyMatch)
private[this] val allOp: P[TagOperator] =
P.char('=').as(TagOperator.AllMatch)
val tagOp: P[TagOperator] =
P.oneOf(List(anyOp, allOp))
}

View File

@ -0,0 +1,95 @@
package docspell.query.internal
import cats.parse.{Parser => P}
import docspell.query.ItemQuery._
object SimpleExprParser {
private[this] val op: P[Operator] =
OperatorParser.op.surroundedBy(BasicParser.ws0)
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.date, inOp *> DateParser.dateOrMore)
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] =
(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] =
(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) =>
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] =
(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(
dateExpr,
stringExpr,
existsExpr,
fulltextExpr,
tagIdExpr,
tagExpr,
catExpr,
customFieldIdExpr,
customFieldExpr,
inboxExpr,
dirExpr
)
)
}

View File

@ -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.{Parser => P, Parser0 => P0}
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)
}
}
}

View File

@ -0,0 +1,41 @@
package docspell.query.internal
import docspell.query.ItemQuery.Attr
import docspell.query.internal.AttrParser
import munit._
class AttrParserTest extends FunSuite {
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))
}
}

View File

@ -0,0 +1,30 @@
package docspell.query.internal
import munit._
import cats.data.{NonEmptyList => Nel}
import docspell.query.internal.BasicParser
class BasicParserTest extends FunSuite {
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.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)
}
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.parse("a, b, c "), Right((" ", Nel.of("a", "b", "c"))))
}
}

View File

@ -0,0 +1,60 @@
package docspell.query.internal
import munit._
import docspell.query.Date
import java.time.Period
class DateParserTest extends FunSuite with ValueHelper {
test("local date string") {
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").isLeft)
}
test("local date millis") {
val p = DateParser.dateFromMillis
assertEquals(p.parseAll("ms0"), Right(Date(0)))
assertEquals(
p.parseAll("ms1600000065463"),
Right(Date(1600000065463L))
)
}
test("local date") {
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("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))))
}
}

View File

@ -0,0 +1,92 @@
package docspell.query.internal
import docspell.query.ItemQuery._
import munit._
import cats.data.{NonEmptyList => Nel}
class ExprParserTest extends FunSuite with ValueHelper {
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")
)
)
)
)
}
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"))
)
)
)
)
}
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*"))
)
)
)
}
}

View File

@ -0,0 +1,43 @@
package docspell.query.internal
import munit._
import docspell.query.ItemQueryParser
import docspell.query.ItemQuery
class ItemQueryParserTest extends FunSuite {
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)
}
}

View File

@ -0,0 +1,15 @@
package docspell.query.internal
import munit._
//import cats.parse.{Parser => P}
import docspell.query.ItemQuery.Expr
class MacroParserTest extends FunSuite {
test("start with $") {
val p = MacroParser.namesMacro
assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test")))
assert(p.parseAll("names:test").isLeft)
}
}

View File

@ -0,0 +1,24 @@
package docspell.query.internal
import munit._
import docspell.query.ItemQuery.{Operator, TagOperator}
import docspell.query.internal.OperatorParser
class OperatorParserTest extends FunSuite {
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))
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))
}
}

View File

@ -0,0 +1,182 @@
package docspell.query.internal
import cats.data.{NonEmptyList => Nel}
import docspell.query.ItemQuery._
import munit._
import docspell.query.Date
import java.time.Period
class SimpleExprParserTest extends FunSuite with ValueHelper {
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"))
)
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") {
val p = SimpleExprParser.dateExpr
assertEquals(
p.parseAll("date:2021-03-14"),
Right(dateExpr(Operator.Like, Attr.Date, ld(2021, 3, 14)))
)
assertEquals(
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))))
)
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") {
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"))
)
assertEquals(
p.parseAll("f:usd=\"26.66\""),
Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
)
}
}

View File

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