Add more convenient date parsers and some basic macros

This commit is contained in:
Eike Kettner 2021-02-28 16:11:25 +01:00
parent af73b59ec2
commit 9013d9264e
23 changed files with 445 additions and 142 deletions

View File

@ -275,7 +275,9 @@ val query =
.settings( .settings(
name := "docspell-query", name := "docspell-query",
libraryDependencies += libraryDependencies +=
Dependencies.catsParseJS.value Dependencies.catsParseJS.value,
libraryDependencies +=
Dependencies.scalaJavaTime.value
) )
.jsSettings( .jsSettings(
Test / fork := false Test / fork := false

View File

@ -159,13 +159,14 @@ object OFulltext {
for { for {
folder <- store.transact(QFolder.getMemberFolders(account)) folder <- store.transact(QFolder.getMemberFolders(account))
now <- Timestamp.current[F]
itemIds <- fts itemIds <- fts
.searchAll(fq.withFolders(folder)) .searchAll(fq.withFolders(folder))
.flatMap(r => Stream.emits(r.results.map(_.itemId))) .flatMap(r => Stream.emits(r.results.map(_.itemId)))
.compile .compile
.to(Set) .to(Set)
q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some)) 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 } yield res
} }
@ -221,7 +222,8 @@ object OFulltext {
.compile .compile
.to(Set) .to(Set)
qnext = q.withFix(_.copy(itemIds = items.some)) 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 } yield res
// Helper // Helper

View File

@ -127,27 +127,39 @@ object OItemSearch {
.map(opt => opt.flatMap(_.filterCollective(collective))) .map(opt => opt.flatMap(_.filterCollective(collective)))
def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] = def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] =
store Timestamp
.transact(QItem.findItems(q, maxNoteLen, batch).take(batch.limit.toLong)) .current[F]
.compile .map(_.toUtcDate)
.toVector .flatMap { today =>
store
.transact(
QItem.findItems(q, today, maxNoteLen, batch).take(batch.limit.toLong)
)
.compile
.toVector
}
def findItemsWithTags( def findItemsWithTags(
maxNoteLen: Int maxNoteLen: Int
)(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = { )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] =
val search = QItem.findItems(q, maxNoteLen: Int, batch) for {
store now <- Timestamp.current[F]
.transact( search = QItem.findItems(q, now.toUtcDate, maxNoteLen: Int, batch)
QItem res <- store
.findItemsWithTags(q.fix.account.collective, search) .transact(
.take(batch.limit.toLong) QItem
) .findItemsWithTags(q.fix.account.collective, search)
.compile .take(batch.limit.toLong)
.toVector )
} .compile
.toVector
} yield res
def findItemsSummary(q: Query): F[SearchSummary] = 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]]] = def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] =
store store

View File

@ -85,7 +85,11 @@ object NotifyDueItemsTask {
) )
res <- res <-
ctx.store 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 .compile
.toVector .toVector
} yield res } yield res

View File

@ -1,14 +1,32 @@
package docspell.query package docspell.query
sealed trait Date import java.time.LocalDate
object Date { import java.time.Period
def apply(y: Int, m: Int, d: Int): Date =
Local(y, m, d)
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) 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
} }

View File

@ -40,6 +40,7 @@ object ItemQuery {
case object ItemName extends StringAttr case object ItemName extends StringAttr
case object ItemSource extends StringAttr case object ItemSource extends StringAttr
case object ItemNotes extends StringAttr
case object ItemId extends StringAttr case object ItemId extends StringAttr
case object Date extends DateAttr case object Date extends DateAttr
case object DueDate 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 StringProperty(attr: StringAttr, value: String) extends Property
final case class DateProperty(attr: DateAttr, value: Date) 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 { sealed trait Expr {
@ -88,6 +94,8 @@ object ItemQuery {
final case class Exists(field: Attr) extends Expr final case class Exists(field: Attr) extends Expr
final case class InExpr(attr: StringAttr, values: Nel[String]) 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 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 TagIdsMatch(op: TagOperator, tags: Nel[String]) extends Expr
final case class TagsMatch(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 extends Expr
final case class Fulltext(query: String) 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))
} }
} }

View File

@ -16,7 +16,7 @@ object ItemQueryParser {
ExprParser ExprParser
.parseQuery(in) .parseQuery(in)
.left .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))) .map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
} }

View File

@ -7,57 +7,60 @@ import docspell.query.ItemQuery.Attr
object AttrParser { object AttrParser {
val name: P[Attr.StringAttr] = val name: P[Attr.StringAttr] =
P.ignoreCase("name").map(_ => Attr.ItemName) P.ignoreCase("name").as(Attr.ItemName)
val source: P[Attr.StringAttr] = val source: P[Attr.StringAttr] =
P.ignoreCase("source").map(_ => Attr.ItemSource) P.ignoreCase("source").as(Attr.ItemSource)
val id: P[Attr.StringAttr] = val id: P[Attr.StringAttr] =
P.ignoreCase("id").map(_ => Attr.ItemId) P.ignoreCase("id").as(Attr.ItemId)
val date: P[Attr.DateAttr] = 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] = 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] = val corrOrgId: P[Attr.StringAttr] =
P.stringIn(List("correspondent.org.id", "corr.org.id")) P.stringIn(List("correspondent.org.id", "corr.org.id"))
.map(_ => Attr.Correspondent.OrgId) .as(Attr.Correspondent.OrgId)
val corrOrgName: P[Attr.StringAttr] = val corrOrgName: P[Attr.StringAttr] =
P.stringIn(List("correspondent.org.name", "corr.org.name")) P.stringIn(List("correspondent.org.name", "corr.org.name"))
.map(_ => Attr.Correspondent.OrgName) .as(Attr.Correspondent.OrgName)
val corrPersId: P[Attr.StringAttr] = val corrPersId: P[Attr.StringAttr] =
P.stringIn(List("correspondent.person.id", "corr.pers.id")) P.stringIn(List("correspondent.person.id", "corr.pers.id"))
.map(_ => Attr.Correspondent.PersonId) .as(Attr.Correspondent.PersonId)
val corrPersName: P[Attr.StringAttr] = val corrPersName: P[Attr.StringAttr] =
P.stringIn(List("correspondent.person.name", "corr.pers.name")) P.stringIn(List("correspondent.person.name", "corr.pers.name"))
.map(_ => Attr.Correspondent.PersonName) .as(Attr.Correspondent.PersonName)
val concPersId: P[Attr.StringAttr] = val concPersId: P[Attr.StringAttr] =
P.stringIn(List("concerning.person.id", "conc.pers.id")) P.stringIn(List("concerning.person.id", "conc.pers.id"))
.map(_ => Attr.Concerning.PersonId) .as(Attr.Concerning.PersonId)
val concPersName: P[Attr.StringAttr] = val concPersName: P[Attr.StringAttr] =
P.stringIn(List("concerning.person.name", "conc.pers.name")) P.stringIn(List("concerning.person.name", "conc.pers.name"))
.map(_ => Attr.Concerning.PersonName) .as(Attr.Concerning.PersonName)
val concEquipId: P[Attr.StringAttr] = val concEquipId: P[Attr.StringAttr] =
P.stringIn(List("concerning.equip.id", "conc.equip.id")) P.stringIn(List("concerning.equip.id", "conc.equip.id"))
.map(_ => Attr.Concerning.EquipId) .as(Attr.Concerning.EquipId)
val concEquipName: P[Attr.StringAttr] = val concEquipName: P[Attr.StringAttr] =
P.stringIn(List("concerning.equip.name", "conc.equip.name")) P.stringIn(List("concerning.equip.name", "conc.equip.name"))
.map(_ => Attr.Concerning.EquipName) .as(Attr.Concerning.EquipName)
val folderId: P[Attr.StringAttr] = 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] = val folderName: P[Attr.StringAttr] =
P.ignoreCase("folder").map(_ => Attr.Folder.FolderName) P.ignoreCase("folder").as(Attr.Folder.FolderName)
val dateAttr: P[Attr.DateAttr] = val dateAttr: P[Attr.DateAttr] =
P.oneOf(List(date, dueDate)) P.oneOf(List(date, dueDate))
@ -68,6 +71,7 @@ object AttrParser {
name, name,
source, source,
id, id,
notes,
corrOrgId, corrOrgId,
corrOrgName, corrOrgName,
corrPersId, corrPersId,

View File

@ -38,4 +38,10 @@ object BasicParser {
val stringOrMore: P[Nel[String]] = val stringOrMore: P[Nel[String]] =
singleString.repSep(stringListSep) 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

@ -1,7 +1,8 @@
package docspell.query.internal package docspell.query.internal
import cats.data.NonEmptyList import java.time.Period
import cats.implicits._
import cats.data.{NonEmptyList => Nel}
import cats.parse.{Numbers, Parser => P} import cats.parse.{Numbers, Parser => P}
import docspell.query.Date import docspell.query.Date
@ -26,21 +27,79 @@ object DateParser {
digits2.filter(n => n >= 1 && n <= 31) digits2.filter(n => n >= 1 && n <= 31)
private val dateSep: P[Unit] = private val dateSep: P[Unit] =
P.anyChar.void P.charIn('-', '/').void
val localDateFromString: P[Date] = private val dateString: P[((Int, Option[Int]), Option[Int])] =
((digits4 <* dateSep) ~ (month <* dateSep) ~ day).mapFilter { digits4 ~ (dateSep *> month).? ~ (dateSep *> day).?
case ((year, month), day) =>
Either.catchNonFatal(Date(year, month, day)).toOption 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] = private[internal] val dateFromMillis: P[Date.DateLiteral] =
longParser.map(Date.apply) P.string("ms") *> longParser.map(Date.apply)
val localDate: P[Date] = private val dateFromToday: P[Date.DateLiteral] =
localDateFromString.backtrack.orElse(dateFromMillis) P.string("today").as(Date.Today)
val localDateOrMore: P[NonEmptyList[Date]] = val dateLiteral: P[Date.DateLiteral] =
localDate.repSep(BasicParser.stringListSep) 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

@ -24,10 +24,11 @@ object ExprParser {
val exprParser: P[Expr] = val exprParser: P[Expr] =
P.recursive[Expr] { recurse => P.recursive[Expr] { recurse =>
val andP = and(recurse) val andP = and(recurse)
val orP = or(recurse) val orP = or(recurse)
val notP = not(recurse) val notP = not(recurse)
P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil) val macros = MacroParser.all
P.oneOf(SimpleExprParser.simpleExpr :: macros :: andP :: orP :: notP :: Nil)
} }
def parseQuery(input: String): Either[P.Error, ItemQuery] = { def parseQuery(input: String): Either[P.Error, ItemQuery] = {

View File

@ -22,10 +22,20 @@ object ExprUtil {
inner match { inner match {
case NotExpr(inner2) => case NotExpr(inner2) =>
reduce(inner2) reduce(inner2)
case InboxExpr(flag) =>
InboxExpr(!flag)
case DirectionExpr(flag) =>
DirectionExpr(!flag)
case _ => case _ =>
expr expr
} }
case DirectionExpr(_) =>
expr
case InboxExpr(_) =>
expr
case InExpr(_, _) => case InExpr(_, _) =>
expr expr

View File

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

View File

@ -6,34 +6,34 @@ import docspell.query.ItemQuery._
object OperatorParser { object OperatorParser {
private[this] val Eq: P[Operator] = private[this] val Eq: P[Operator] =
P.char('=').void.map(_ => Operator.Eq) P.char('=').as(Operator.Eq)
private[this] val Neq: P[Operator] = private[this] val Neq: P[Operator] =
P.string("!=").void.map(_ => Operator.Neq) P.string("!=").as(Operator.Neq)
private[this] val Like: P[Operator] = private[this] val Like: P[Operator] =
P.char(':').void.map(_ => Operator.Like) P.char(':').as(Operator.Like)
private[this] val Gt: P[Operator] = private[this] val Gt: P[Operator] =
P.char('>').void.map(_ => Operator.Gt) P.char('>').as(Operator.Gt)
private[this] val Lt: P[Operator] = private[this] val Lt: P[Operator] =
P.char('<').void.map(_ => Operator.Lt) P.char('<').as(Operator.Lt)
private[this] val Gte: P[Operator] = private[this] val Gte: P[Operator] =
P.string(">=").map(_ => Operator.Gte) P.string(">=").as(Operator.Gte)
private[this] val Lte: P[Operator] = private[this] val Lte: P[Operator] =
P.string("<=").map(_ => Operator.Lte) P.string("<=").as(Operator.Lte)
val op: P[Operator] = val op: P[Operator] =
P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt)) P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt))
private[this] val anyOp: P[TagOperator] = private[this] val anyOp: P[TagOperator] =
P.char(':').map(_ => TagOperator.AnyMatch) P.char(':').as(TagOperator.AnyMatch)
private[this] val allOp: P[TagOperator] = private[this] val allOp: P[TagOperator] =
P.char('=').map(_ => TagOperator.AllMatch) P.char('=').as(TagOperator.AllMatch)
val tagOp: P[TagOperator] = val tagOp: P[TagOperator] =
P.oneOf(List(anyOp, allOp)) P.oneOf(List(anyOp, allOp))

View File

@ -17,7 +17,7 @@ object SimpleExprParser {
P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore) P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore)
private[this] val inOrOpDate = private[this] val inOrOpDate =
P.eitherOr(op ~ DateParser.localDate, inOp *> DateParser.localDateOrMore) P.eitherOr(op ~ DateParser.date, inOp *> DateParser.dateOrMore)
val stringExpr: P[Expr] = val stringExpr: P[Expr] =
(AttrParser.stringAttr ~ inOrOpStr).map { (AttrParser.stringAttr ~ inOrOpStr).map {
@ -65,6 +65,12 @@ object SimpleExprParser {
CustomFieldMatch(name, op, value) 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] = val simpleExpr: P[Expr] =
P.oneOf( P.oneOf(
List( List(
@ -75,7 +81,9 @@ object SimpleExprParser {
tagIdExpr, tagIdExpr,
tagExpr, tagExpr,
catExpr, catExpr,
customFieldExpr customFieldExpr,
inboxExpr,
dirExpr
) )
) )
} }

View File

@ -2,35 +2,68 @@ package docspell.query.internal
import minitest._ import minitest._
import docspell.query.Date import docspell.query.Date
import java.time.Period
object DateParserTest extends SimpleTestSuite { object DateParserTest extends SimpleTestSuite {
def ld(year: Int, m: Int, d: Int): Date = def ld(year: Int, m: Int, d: Int): Date.DateLiteral =
Date(year, m, d) 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") { 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("2021-02-22"), Right(ld(2021, 2, 22)))
assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21))) assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21)))
assert(p.parseAll("0-0-0").isLeft) 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") { test("local date millis") {
val p = DateParser.dateFromMillis val p = DateParser.dateFromMillis
assertEquals(p.parseAll("0"), Right(Date(0))) assertEquals(p.parseAll("ms0"), Right(Date(0)))
assertEquals( assertEquals(
p.parseAll("1600000065463"), p.parseAll("ms1600000065463"),
Right(Date(1600000065463L)) Right(Date(1600000065463L))
) )
} }
test("local date") { 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("2021-02-22"), Right(ld(2021, 2, 22)))
assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11))) assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
assertEquals(p.parseAll("0"), Right(Date(0))) assertEquals(p.parseAll("ms0"), Right(Date(0)))
assertEquals(p.parseAll("1600000065463"), Right(Date(1600000065463L))) 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,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))
}
}

View File

@ -4,6 +4,7 @@ import cats.data.{NonEmptyList => Nel}
import docspell.query.ItemQuery._ import docspell.query.ItemQuery._
import minitest._ import minitest._
import docspell.query.Date import docspell.query.Date
import java.time.Period
object SimpleExprParserTest extends SimpleTestSuite { object SimpleExprParserTest extends SimpleTestSuite {
@ -39,8 +40,8 @@ object SimpleExprParserTest extends SimpleTestSuite {
test("date expr") { test("date expr") {
val p = SimpleExprParser.dateExpr val p = SimpleExprParser.dateExpr
assertEquals( assertEquals(
p.parseAll("due:2021-03-14"), p.parseAll("date:2021-03-14"),
Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14))) Right(dateExpr(Operator.Like, Attr.Date, ld(2021, 3, 14)))
) )
assertEquals( assertEquals(
p.parseAll("due<2021-03-14"), p.parseAll("due<2021-03-14"),
@ -50,6 +51,28 @@ object SimpleExprParserTest extends SimpleTestSuite {
p.parseAll("due~=2021-03-14,2021-03-13"), p.parseAll("due~=2021-03-14,2021-03-13"),
Right(Expr.InDateExpr(Attr.DueDate, Nel.of(ld(2021, 3, 14), ld(2021, 3, 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") { test("exists expr") {

View File

@ -9,6 +9,7 @@
<logger name="docspell" level="debug" /> <logger name="docspell" level="debug" />
<logger name="emil" level="debug"/> <logger name="emil" level="debug"/>
<logger name="docspell.store.queries.QItem" level="trace"/>
<root level="INFO"> <root level="INFO">
<appender-ref ref="STDOUT" /> <appender-ref ref="STDOUT" />

View File

@ -1,6 +1,7 @@
package docspell.store.qb.generator package docspell.store.qb.generator
import java.time.{Instant, LocalDate} import java.time.Instant
import java.time.LocalDate
import cats.data.NonEmptyList import cats.data.NonEmptyList
@ -9,27 +10,27 @@ import docspell.query.ItemQuery._
import docspell.query.{Date, ItemQuery} import docspell.query.{Date, ItemQuery}
import docspell.store.qb.DSL._ import docspell.store.qb.DSL._
import docspell.store.qb.{Operator => QOp, _} import docspell.store.qb.{Operator => QOp, _}
import docspell.store.queries.QueryWildcard
import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName} import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
import doobie.util.Put import doobie.util.Put
import docspell.store.queries.QueryWildcard
object ItemQueryGenerator { 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] PT: Put[Timestamp]
): Condition = ): 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 expr: Expr
)(implicit PT: Put[Timestamp]): Condition = )(implicit PT: Put[Timestamp]): Condition =
expr match { expr match {
case Expr.AndExpr(inner) => case Expr.AndExpr(inner) =>
Condition.And(inner.map(fromExpr(tables, coll))) Condition.And(inner.map(fromExpr(today, tables, coll)))
case Expr.OrExpr(inner) => case Expr.OrExpr(inner) =>
Condition.Or(inner.map(fromExpr(tables, coll))) Condition.Or(inner.map(fromExpr(today, tables, coll)))
case Expr.NotExpr(inner) => case Expr.NotExpr(inner) =>
inner match { inner match {
@ -71,7 +72,7 @@ object ItemQueryGenerator {
Condition.unit Condition.unit
case _ => case _ =>
Condition.Not(fromExpr(tables, coll)(inner)) Condition.Not(fromExpr(today, tables, coll)(inner))
} }
case Expr.Exists(field) => case Expr.Exists(field) =>
@ -87,9 +88,10 @@ object ItemQueryGenerator {
} }
case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) => case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) =>
val dt = dateToTimestamp(value) val dt = dateToTimestamp(today)(value)
val col = timestampColumn(tables)(attr) val col = timestampColumn(tables)(attr)
Condition.CompareVal(col, makeOp(op), dt) val noLikeOp = if (op == Operator.Like) Operator.Eq else op
Condition.CompareVal(col, makeOp(noLikeOp), dt)
case Expr.InExpr(attr, values) => case Expr.InExpr(attr, values) =>
val col = stringColumn(tables)(attr) val col = stringColumn(tables)(attr)
@ -98,10 +100,18 @@ object ItemQueryGenerator {
case Expr.InDateExpr(attr, values) => case Expr.InDateExpr(attr, values) =>
val col = timestampColumn(tables)(attr) val col = timestampColumn(tables)(attr)
val dts = values.map(dateToTimestamp) val dts = values.map(dateToTimestamp(today))
if (values.tail.isEmpty) col === dts.head if (values.tail.isEmpty) col === dts.head
else col.in(dts) 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) => case Expr.TagIdsMatch(op, tags) =>
val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
NonEmptyList NonEmptyList
@ -142,12 +152,31 @@ object ItemQueryGenerator {
Condition.unit Condition.unit
} }
private def dateToTimestamp(date: Date): Timestamp = private def dateToTimestamp(today: LocalDate)(date: Date): Timestamp =
date match { date match {
case Date.Local(year, month, day) => case d: Date.DateLiteral =>
Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay()) 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) => 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[_] = private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
@ -171,6 +200,7 @@ object ItemQueryGenerator {
case Attr.ItemId => tables.item.id.cast[String] case Attr.ItemId => tables.item.id.cast[String]
case Attr.ItemName => tables.item.name case Attr.ItemName => tables.item.name
case Attr.ItemSource => tables.item.source case Attr.ItemSource => tables.item.source
case Attr.ItemNotes => tables.item.notes
case Attr.Correspondent.OrgId => tables.corrOrg.oid.cast[String] case Attr.Correspondent.OrgId => tables.corrOrg.oid.cast[String]
case Attr.Correspondent.OrgName => tables.corrOrg.name case Attr.Correspondent.OrgName => tables.corrOrg.name
case Attr.Correspondent.PersonId => tables.corrPers.pid.cast[String] case Attr.Correspondent.PersonId => tables.corrPers.pid.cast[String]

View File

@ -1,5 +1,7 @@
package docspell.store.queries package docspell.store.queries
import java.time.LocalDate
import cats.data.{NonEmptyList => Nel} import cats.data.{NonEmptyList => Nel}
import cats.effect.Sync import cats.effect.Sync
import cats.effect.concurrent.Ref import cats.effect.concurrent.Ref
@ -226,41 +228,42 @@ object QItem {
findCustomFieldValuesForColl(coll, q.customValues) findCustomFieldValuesForColl(coll, q.customValues)
.map(itemIds => i.id.in(itemIds)) .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) 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 { cond match {
case fm: Query.QueryForm => case fm: Query.QueryForm =>
queryCondFromForm(coll, fm) queryCondFromForm(coll, fm)
case expr: Query.QueryExpr => case expr: Query.QueryExpr =>
queryCondFromExpr(coll, expr.q) queryCondFromExpr(today, coll, expr.q)
} }
def findItems( def findItems(
q: Query, q: Query,
today: LocalDate,
maxNoteLen: Int, maxNoteLen: Int,
batch: Batch batch: Batch
): Stream[ConnectionIO, ListItem] = { ): Stream[ConnectionIO, ListItem] = {
val sql = findItemsBase(q.fix, maxNoteLen) 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) .limit(batch)
.build .build
logger.trace(s"List $batch items: $sql") logger.trace(s"List $batch items: $sql")
sql.query[ListItem].stream sql.query[ListItem].stream
} }
def searchStats(q: Query): ConnectionIO[SearchSummary] = def searchStats(today: LocalDate)(q: Query): ConnectionIO[SearchSummary] =
for { for {
count <- searchCountSummary(q) count <- searchCountSummary(today)(q)
tags <- searchTagSummary(q) tags <- searchTagSummary(today)(q)
fields <- searchFieldSummary(q) fields <- searchFieldSummary(today)(q)
folders <- searchFolderSummary(q) folders <- searchFolderSummary(today)(q)
} yield SearchSummary(count, tags, fields, folders) } yield SearchSummary(count, tags, fields, folders)
def searchTagSummary(q: Query): ConnectionIO[List[TagCount]] = { def searchTagSummary(today: LocalDate)(q: Query): ConnectionIO[List[TagCount]] = {
val tagFrom = val tagFrom =
from(ti) from(ti)
.innerJoin(tag, tag.tid === ti.tagId) .innerJoin(tag, tag.tid === ti.tagId)
@ -270,7 +273,7 @@ object QItem {
findItemsBase(q.fix, 0).unwrap findItemsBase(q.fix, 0).unwrap
.withSelect(select(tag.all).append(count(i.id).as("num"))) .withSelect(select(tag.all).append(count(i.id).as("num")))
.changeFrom(_.prepend(tagFrom)) .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) .groupBy(tag.tid)
.build .build
.query[TagCount] .query[TagCount]
@ -284,27 +287,27 @@ object QItem {
} yield existing ++ other.map(TagCount(_, 0)) } 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 findItemsBase(q.fix, 0).unwrap
.withSelect(Nel.of(count(i.id).as("num"))) .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 .build
.query[Int] .query[Int]
.unique .unique
def searchFolderSummary(q: Query): ConnectionIO[List[FolderCount]] = { def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = {
val fu = RUser.as("fu") val fu = RUser.as("fu")
findItemsBase(q.fix, 0).unwrap findItemsBase(q.fix, 0).unwrap
.withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num"))) .withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num")))
.changeFrom(_.innerJoin(fu, fu.uid === f.owner)) .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) .groupBy(f.id, f.name, f.owner, fu.login)
.build .build
.query[FolderCount] .query[FolderCount]
.to[List] .to[List]
} }
def searchFieldSummary(q: Query): ConnectionIO[List[FieldStats]] = { def searchFieldSummary(today: LocalDate)(q: Query): ConnectionIO[List[FieldStats]] = {
val fieldJoin = val fieldJoin =
from(cv) from(cv)
.innerJoin(cf, cf.id === cv.field) .innerJoin(cf, cf.id === cv.field)
@ -313,7 +316,7 @@ object QItem {
val base = val base =
findItemsBase(q.fix, 0).unwrap findItemsBase(q.fix, 0).unwrap
.changeFrom(_.prepend(fieldJoin)) .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)) .groupBy(GroupBy(cf.all))
val basicFields = Nel.of( val basicFields = Nel.of(

View File

@ -22,11 +22,12 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
RAttachment.as("a"), RAttachment.as("a"),
RAttachmentMeta.as("m") RAttachmentMeta.as("m")
) )
val now: LocalDate = LocalDate.of(2021, 2, 25)
test("basic test") { test("basic test") {
val q = ItemQueryParser 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(tables, Ident.unsafe("coll"))(q) val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q)
val expect = val expect =
tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc( tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc(
LocalDate.of(2020, 2, 1).atStartOfDay() LocalDate.of(2020, 2, 1).atStartOfDay()
@ -35,29 +36,4 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
assertEquals(cond, expect) 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 ()
// }
// }
} }

View File

@ -33,6 +33,7 @@ object Dependencies {
val PoiVersion = "4.1.2" val PoiVersion = "4.1.2"
val PostgresVersion = "42.2.19" val PostgresVersion = "42.2.19"
val PureConfigVersion = "0.14.1" val PureConfigVersion = "0.14.1"
val ScalaJavaTimeVersion = "2.2.0"
val Slf4jVersion = "1.7.30" val Slf4jVersion = "1.7.30"
val StanfordNlpVersion = "4.2.0" val StanfordNlpVersion = "4.2.0"
val TikaVersion = "1.25" val TikaVersion = "1.25"
@ -54,6 +55,9 @@ object Dependencies {
val catsJS = Def.setting("org.typelevel" %%% "cats-core" % "2.4.2") 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( val kittens = Seq(
"org.typelevel" %% "kittens" % KittensVersion "org.typelevel" %% "kittens" % KittensVersion
) )