From de1baf725f8e6b1e355b9319326b17dc85b96d00 Mon Sep 17 00:00:00 2001 From: eikek Date: Fri, 1 Oct 2021 01:42:20 +0200 Subject: [PATCH] Generate a query string given an expression Initialize share record and improve tests. --- .../main/scala/docspell/query/ItemQuery.scala | 8 +- .../docspell/query/ItemQueryParser.scala | 24 +- .../docspell/query/internal/BasicParser.scala | 2 +- .../docspell/query/internal/ExprString.scala | 244 +++++++++++++++ .../docspell/query/internal/ExprUtil.scala | 23 +- .../scala/docspell/query/ItemQueryGen.scala | 287 ++++++++++++++++++ .../query/internal/ExprStringTest.scala | 69 +++++ .../query/internal/ItemQueryParserTest.scala | 12 +- .../docspell/store/impl/DoobieMeta.scala | 6 + .../scala/docspell/store/records/RShare.scala | 59 ++++ 10 files changed, 718 insertions(+), 16 deletions(-) create mode 100644 modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala create mode 100644 modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala create mode 100644 modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala create mode 100644 modules/store/src/main/scala/docspell/store/records/RShare.scala diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 0fc73acb..be0e5135 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -123,9 +123,11 @@ object ItemQuery { final case class ChecksumMatch(checksum: String) extends Expr final case class AttachId(id: String) extends Expr - final case object ValidItemStates extends Expr - final case object Trashed extends Expr - final case object ValidItemsOrTrashed extends Expr + /** A "private" expression is only visible in code, but cannot be parsed. */ + sealed trait PrivateExpr extends Expr + final case object ValidItemStates extends PrivateExpr + final case object Trashed extends PrivateExpr + final case object ValidItemsOrTrashed extends PrivateExpr // things that can be expressed with terms above sealed trait MacroExpr extends Expr { diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala index c4c17801..d571cf63 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala @@ -8,12 +8,23 @@ package docspell.query import cats.data.NonEmptyList -import docspell.query.internal.ExprParser -import docspell.query.internal.ExprUtil +import docspell.query.internal.{ExprParser, ExprString, ExprUtil} object ItemQueryParser { + val PrivateExprError = ExprString.PrivateExprError + type PrivateExprError = ExprString.PrivateExprError + def parse(input: String): Either[ParseFailure, ItemQuery] = + parse0(input, expandMacros = true) + + def parseKeepMacros(input: String): Either[ParseFailure, ItemQuery] = + parse0(input, expandMacros = false) + + private def parse0( + input: String, + expandMacros: Boolean + ): Either[ParseFailure, ItemQuery] = if (input.isEmpty) Left( ParseFailure("", 0, NonEmptyList.of(ParseFailure.SimpleMessage(0, "No input."))) @@ -24,9 +35,16 @@ object ItemQueryParser { .parseQuery(in) .left .map(ParseFailure.fromError(in)) - .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) + .map(q => q.copy(expr = ExprUtil.reduce(expandMacros)(q.expr))) } def parseUnsafe(input: String): ItemQuery = parse(input).fold(m => sys.error(m.render), identity) + + def asString(q: ItemQuery.Expr): Either[PrivateExprError, String] = + ExprString(q) + + def unsafeAsString(q: ItemQuery.Expr): String = + asString(q).fold(f => sys.error(s"Cannot expose private query part: $f"), identity) + } diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala index fd2d9207..48bd98df 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala @@ -24,7 +24,7 @@ object BasicParser { ) private[this] val identChars: Set[Char] = - (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet + (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet val parenAnd: P[Unit] = P.stringIn(List("(&", "(and")).void <* ws0 diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala new file mode 100644 index 00000000..d3c5def4 --- /dev/null +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query.internal + +import java.time.Period + +import docspell.query.Date +import docspell.query.Date.DateLiteral +import docspell.query.ItemQuery.Attr._ +import docspell.query.ItemQuery.Expr._ +import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} + +/** Creates the string representation for a given expression. The returned string can be + * parsed back to the expression using `ExprParser`. Note that expressions obtained from + * the `ItemQueryParser` have macros already expanded. + * + * It may fail when the expression contains non-public parts. Every expression that has + * been created by parsing a string, can be transformed back to a string. But an + * expression created via code may contain parts that cannot be transformed to a string. + */ +object ExprString { + + final case class PrivateExprError(expr: Expr.PrivateExpr) + type Result = Either[PrivateExprError, String] + + def apply(expr: Expr): Result = + expr match { + case AndExpr(inner) => + val es = inner.traverse(ExprString.apply) + es.map(_.toList.mkString(" ")).map(els => s"(& $els )") + + case OrExpr(inner) => + val es = inner.traverse(ExprString.apply) + es.map(_.toList.mkString(" ")).map(els => s"(| $els )") + + case NotExpr(inner) => + inner match { + case NotExpr(inner2) => + apply(inner2) + case _ => + apply(inner).map(n => s"!$n") + } + + case m: MacroExpr => + Right(macroStr(m)) + + case DirectionExpr(v) => + Right(s"${C.incoming}${C.like}${v}") + + case InboxExpr(v) => + Right(s"${C.inbox}${C.like}${v}") + + case InExpr(attr, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${attrStr(attr)}${C.in}$els") + + case InDateExpr(attr, values) => + val els = values.map(dateStr).toList.mkString(",") + Right(s"${attrStr(attr)}${C.in}$els") + + case TagsMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.tag}${tagOpStr(op)}$els") + + case TagIdsMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.tagId}${tagOpStr(op)}$els") + + case Exists(field) => + Right(s"${C.exist}${C.like}${attrStr(field)}") + + case Fulltext(v) => + Right(s"${C.content}${C.like}${quote(v)}") + + case SimpleExpr(op, prop) => + prop match { + case Property.StringProperty(attr, value) => + Right(s"${stringAttr(attr)}${opStr(op)}${quote(value)}") + case Property.DateProperty(attr, value) => + Right(s"${dateAttr(attr)}${opStr(op)}${dateStr(value)}") + case Property.IntProperty(attr, value) => + Right(s"${attrStr(attr)}${opStr(op)}$value") + } + + case TagCategoryMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.cat}${tagOpStr(op)}$els") + + case CustomFieldMatch(name, op, value) => + Right(s"${C.customField}:$name${opStr(op)}${quote(value)}") + + case CustomFieldIdMatch(id, op, value) => + Right(s"${C.customFieldId}:$id${opStr(op)}${quote(value)}") + + case ChecksumMatch(cs) => + Right(s"${C.checksum}${C.like}$cs") + + case AttachId(aid) => + Right(s"${C.attachId}${C.eqs}$aid") + + case pe: PrivateExpr => + // There is no parser equivalent for this + Left(PrivateExprError(pe)) + } + + private[internal] def macroStr(expr: Expr.MacroExpr): String = + expr match { + case Expr.NamesMacro(name) => + s"${C.names}:${quote(name)}" + case Expr.YearMacro(_, year) => + s"${C.year}:$year" //currently, only for Attr.Date + case Expr.ConcMacro(term) => + s"${C.conc}:${quote(term)}" + case Expr.CorrMacro(term) => + s"${C.corr}:${quote(term)}" + case Expr.DateRangeMacro(attr, left, right) => + val name = attr match { + case Attr.CreatedDate => + C.createdIn + case Attr.Date => + C.dateIn + case Attr.DueDate => + C.dueIn + } + (left, right) match { + case (_: Date.DateLiteral, Date.Calc(date, calc, period)) => + s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}" + + case (Date.Calc(date, calc, period), _: DateLiteral) => + s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}" + + case (Date.Calc(d1, _, p1), Date.Calc(_, _, _)) => + s"$name:${dateStr(d1)};/${periodStr(p1)}" + + case (_: DateLiteral, _: DateLiteral) => + sys.error("Invalid date range") + } + } + + private[internal] def dateStr(date: Date): String = + date match { + case Date.Today => + "today" + case Date.Local(ld) => + f"${ld.getYear}-${ld.getMonthValue}%02d-${ld.getDayOfMonth}%02d" + + case Date.Millis(ms) => + s"ms$ms" + + case Date.Calc(date, calc, period) => + val ds = dateStr(date) + s"$ds;${calcStr(calc)}${periodStr(period)}" + } + + private[internal] def calcStr(c: Date.CalcDirection): String = + c match { + case Date.CalcDirection.Plus => "+" + case Date.CalcDirection.Minus => "-" + } + + private[internal] def periodStr(p: Period): String = + if (p.toTotalMonths == 0) s"${p.getDays}d" + else s"${p.toTotalMonths}m" + + private[internal] def attrStr(attr: Attr): String = + attr match { + case a: StringAttr => stringAttr(a) + case a: DateAttr => dateAttr(a) + case a: IntAttr => intAttr(a) + } + + private[internal] def intAttr(attr: IntAttr): String = + attr match { + case AttachCount => + Constants.attachCount + } + + private[internal] def dateAttr(attr: DateAttr): String = + attr match { + case Attr.Date => + Constants.date + case DueDate => + Constants.due + case CreatedDate => + Constants.created + } + + private[internal] def stringAttr(attr: StringAttr): String = + attr match { + case Attr.ItemName => + Constants.name + case Attr.ItemId => + Constants.id + case Attr.ItemSource => + Constants.source + case Attr.ItemNotes => + Constants.notes + case Correspondent.OrgId => + Constants.corrOrgId + case Correspondent.OrgName => + Constants.corrOrgName + case Correspondent.PersonId => + Constants.corrPersId + case Correspondent.PersonName => + Constants.corrPersName + case Concerning.EquipId => + Constants.concEquipId + case Concerning.EquipName => + Constants.concEquipName + case Concerning.PersonId => + Constants.concPersId + case Concerning.PersonName => + Constants.concPersName + case Folder.FolderName => + Constants.folder + case Folder.FolderId => + Constants.folderId + } + + private[internal] def opStr(op: Operator): String = + op match { + case Operator.Like => Constants.like.toString + case Operator.Gte => Constants.gte + case Operator.Lte => Constants.lte + case Operator.Eq => Constants.eqs.toString + case Operator.Lt => Constants.lt.toString + case Operator.Gt => Constants.gt.toString + case Operator.Neq => Constants.neq + } + + private[internal] def tagOpStr(op: TagOperator): String = + op match { + case TagOperator.AllMatch => C.eqs.toString + case TagOperator.AnyMatch => C.like.toString + } + + private def quote(s: String): String = + s"\"$s\"" +} diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index 4f985be5..6b22ef96 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -13,35 +13,42 @@ import docspell.query.ItemQuery._ object ExprUtil { + def reduce(expr: Expr): Expr = + reduce(expandMacros = true)(expr) + /** Does some basic transformation, like unfolding nested and trees containing one value * etc. */ - def reduce(expr: Expr): Expr = + def reduce(expandMacros: Boolean)(expr: Expr): Expr = expr match { case AndExpr(inner) => val nodes = spliceAnd(inner) - if (nodes.tail.isEmpty) reduce(nodes.head) - else AndExpr(nodes.map(reduce)) + if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head) + else AndExpr(nodes.map(reduce(expandMacros))) case OrExpr(inner) => val nodes = spliceOr(inner) - if (nodes.tail.isEmpty) reduce(nodes.head) - else OrExpr(nodes.map(reduce)) + if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head) + else OrExpr(nodes.map(reduce(expandMacros))) case NotExpr(inner) => inner match { case NotExpr(inner2) => - reduce(inner2) + reduce(expandMacros)(inner2) case InboxExpr(flag) => InboxExpr(!flag) case DirectionExpr(flag) => DirectionExpr(!flag) case _ => - NotExpr(reduce(inner)) + NotExpr(reduce(expandMacros)(inner)) } case m: MacroExpr => - reduce(m.body) + if (expandMacros) { + reduce(expandMacros)(m.body) + } else { + m + } case DirectionExpr(_) => expr diff --git a/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala b/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala new file mode 100644 index 00000000..4f6a6982 --- /dev/null +++ b/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala @@ -0,0 +1,287 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query + +import java.time.{Instant, Period, ZoneOffset} + +import cats.data.NonEmptyList + +import docspell.query.ItemQuery.Expr.TagIdsMatch +import docspell.query.ItemQuery._ + +import org.scalacheck.Gen + +/** Generator for syntactically valid queries. */ +object ItemQueryGen { + + def exprGen: Gen[Expr] = + Gen.oneOf( + simpleExprGen, + existsExprGen, + inExprGen, + inDateExprGen, + inboxExprGen, + directionExprGen, + tagIdsMatchExprGen, + tagMatchExprGen, + tagCatMatchExpr, + customFieldMatchExprGen, + customFieldIdMatchExprGen, + fulltextExprGen, + checksumMatchExprGen, + attachIdExprGen, + namesMacroGen, + corrMacroGen, + concMacroGen, + yearMacroGen, + dateRangeMacro, + Gen.lzy(andExprGen(exprGen)), + Gen.lzy(orExprGen(exprGen)), + Gen.lzy(notExprGen(exprGen)) + ) + + def andExprGen(g: Gen[Expr]): Gen[Expr.AndExpr] = + nelGen(g).map(Expr.AndExpr) + + def orExprGen(g: Gen[Expr]): Gen[Expr.OrExpr] = + nelGen(g).map(Expr.OrExpr) + + // avoid generating nested not expressions, they are already flattened by the parser + // and only occur artificially + def notExprGen(g: Gen[Expr]): Gen[Expr] = + g.map { + case Expr.NotExpr(inner) => inner + case e => Expr.NotExpr(e) + } + + val opGen: Gen[Operator] = + Gen.oneOf( + Operator.Like, + Operator.Gte, + Operator.Lt, + Operator.Gt, + Operator.Lte, + Operator.Eq, + Operator.Neq + ) + + val tagOpGen: Gen[TagOperator] = + Gen.oneOf(TagOperator.AllMatch, TagOperator.AnyMatch) + + val stringAttrGen: Gen[Attr.StringAttr] = + Gen.oneOf( + Attr.Concerning.EquipName, + Attr.Concerning.EquipId, + Attr.Concerning.PersonName, + Attr.Concerning.PersonId, + Attr.Correspondent.OrgName, + Attr.Correspondent.OrgId, + Attr.Correspondent.PersonName, + Attr.Correspondent.PersonId, + Attr.ItemId, + Attr.ItemName, + Attr.ItemSource, + Attr.ItemNotes, + Attr.Folder.FolderId, + Attr.Folder.FolderName + ) + + val dateAttrGen: Gen[Attr.DateAttr] = + Gen.oneOf(Attr.Date, Attr.DueDate, Attr.CreatedDate) + + val intAttrGen: Gen[Attr.IntAttr] = + Gen.const(Attr.AttachCount) + + val attrGen: Gen[Attr] = + Gen.oneOf(stringAttrGen, dateAttrGen, intAttrGen) + + private val valueChars = + Gen.oneOf(Gen.alphaNumChar, Gen.oneOf(" /{}*?-:@#$~+%…_[]^!ß")) + + private val stringValueGen: Gen[String] = + Gen.choose(1, 20).flatMap(n => Gen.stringOfN(n, valueChars)) + + private val intValueGen: Gen[Int] = + Gen.choose(1900, 9999) + + private val identGen: Gen[String] = + Gen + .choose(3, 12) + .flatMap(n => + Gen.stringOfN( + n, + Gen.oneOf((('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet) + ) + ) + + private def nelGen[T](gen: Gen[T]): Gen[NonEmptyList[T]] = + for { + head <- gen + tail <- Gen.choose(0, 9).flatMap(n => Gen.listOfN(n, gen)) + } yield NonEmptyList(head, tail) + + private val dateMillisGen: Gen[Long] = + Gen.choose(0, Instant.parse("2100-12-24T20:00:00Z").toEpochMilli) + + val localDateGen: Gen[Date.Local] = + dateMillisGen + .map(ms => Instant.ofEpochMilli(ms).atOffset(ZoneOffset.UTC).toLocalDate) + .map(Date.Local) + + val millisDateGen: Gen[Date.Millis] = + dateMillisGen.map(Date.Millis) + + val dateLiteralGen: Gen[Date.DateLiteral] = + Gen.oneOf( + localDateGen, + millisDateGen, + Gen.const(Date.Today) + ) + + val periodGen: Gen[Period] = + for { + mOrD <- Gen.oneOf(a => Period.ofDays(a), a => Period.ofMonths(a)) + num <- Gen.choose(1, 30) + } yield mOrD(num) + + val calcGen: Gen[Date.CalcDirection] = + Gen.oneOf(Date.CalcDirection.Plus, Date.CalcDirection.Minus) + + val dateCalcGen: Gen[Date.Calc] = + for { + dl <- dateLiteralGen + calc <- calcGen + period <- periodGen + } yield Date.Calc(dl, calc, period) + + val dateValueGen: Gen[Date] = + Gen.oneOf(dateLiteralGen, dateCalcGen) + + val stringPropGen: Gen[Property.StringProperty] = + for { + attr <- stringAttrGen + sval <- stringValueGen + } yield Property.StringProperty(attr, sval) + + val intPropGen: Gen[Property.IntProperty] = + for { + attr <- intAttrGen + ival <- intValueGen + } yield Property.IntProperty(attr, ival) + + val datePropGen: Gen[Property.DateProperty] = + for { + attr <- dateAttrGen + dv <- dateValueGen + } yield Property.DateProperty(attr, dv) + + val propertyGen: Gen[Property] = + Gen.oneOf(stringPropGen, datePropGen, intPropGen) + + val simpleExprGen: Gen[Expr.SimpleExpr] = + for { + op <- opGen + prop <- propertyGen + } yield Expr.SimpleExpr(op, prop) + + val existsExprGen: Gen[Expr.Exists] = + attrGen.map(Expr.Exists) + + val inExprGen: Gen[Expr.InExpr] = + for { + attr <- stringAttrGen + vals <- nelGen(stringValueGen) + } yield Expr.InExpr(attr, vals) + + val inDateExprGen: Gen[Expr.InDateExpr] = + for { + attr <- dateAttrGen + vals <- nelGen(dateValueGen) + } yield Expr.InDateExpr(attr, vals) + + val inboxExprGen: Gen[Expr.InboxExpr] = + Gen.oneOf(true, false).map(Expr.InboxExpr) + + val directionExprGen: Gen[Expr.DirectionExpr] = + Gen.oneOf(true, false).map(Expr.DirectionExpr) + + val tagIdsMatchExprGen: Gen[Expr.TagIdsMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield TagIdsMatch(op, vals) + + val tagMatchExprGen: Gen[Expr.TagsMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield Expr.TagsMatch(op, vals) + + val tagCatMatchExpr: Gen[Expr.TagCategoryMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield Expr.TagCategoryMatch(op, vals) + + val customFieldMatchExprGen: Gen[Expr.CustomFieldMatch] = + for { + name <- identGen + op <- opGen + value <- stringValueGen + } yield Expr.CustomFieldMatch(name, op, value) + + val customFieldIdMatchExprGen: Gen[Expr.CustomFieldIdMatch] = + for { + name <- identGen + op <- opGen + value <- identGen + } yield Expr.CustomFieldIdMatch(name, op, value) + + val fulltextExprGen: Gen[Expr.Fulltext] = + Gen + .choose(3, 20) + .flatMap(n => Gen.stringOfN(n, valueChars)) + .map(Expr.Fulltext) + + val checksumMatchExprGen: Gen[Expr.ChecksumMatch] = + Gen.stringOfN(64, Gen.hexChar).map(Expr.ChecksumMatch) + + val attachIdExprGen: Gen[Expr.AttachId] = + identGen.map(Expr.AttachId) + + val namesMacroGen: Gen[Expr.NamesMacro] = + stringValueGen.map(Expr.NamesMacro) + + val concMacroGen: Gen[Expr.ConcMacro] = + stringValueGen.map(Expr.ConcMacro) + + val corrMacroGen: Gen[Expr.CorrMacro] = + stringValueGen.map(Expr.CorrMacro) + + val yearMacroGen: Gen[Expr.YearMacro] = + Gen.choose(1900, 9999).map(Expr.YearMacro(Attr.Date, _)) + + val dateRangeMacro: Gen[Expr.DateRangeMacro] = + for { + attr <- dateAttrGen + dl <- dateLiteralGen + p <- periodGen + calc <- Gen.option(calcGen) + range = calc match { + case Some(c @ Date.CalcDirection.Plus) => + Expr.DateRangeMacro(attr, dl, Date.Calc(dl, c, p)) + case Some(c @ Date.CalcDirection.Minus) => + Expr.DateRangeMacro(attr, Date.Calc(dl, c, p), dl) + case None => + Expr.DateRangeMacro( + attr, + Date.Calc(dl, Date.CalcDirection.Minus, p), + Date.Calc(dl, Date.CalcDirection.Plus, p) + ) + } + } yield range +} diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala new file mode 100644 index 00000000..99fe673f --- /dev/null +++ b/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query.internal + +import java.time.{LocalDate, Period} + +import docspell.query.ItemQuery._ +import docspell.query.{Date, ItemQueryGen, ParseFailure} + +import munit.{FunSuite, ScalaCheckSuite} +import org.scalacheck.Prop.forAll + +class ExprStringTest extends FunSuite with ScalaCheckSuite { + + // parses the query without reducing and expanding macros + def singleParse(s: String): Expr = + ExprParser + .parseQuery(s) + .left + .map(ParseFailure.fromError(s)) + .fold(f => sys.error(f.render), _.expr) + + def exprString(expr: Expr): String = + ExprString(expr).fold(f => sys.error(f.toString), identity) + + test("macro: name") { + val str = exprString(Expr.NamesMacro("test")) + val q = singleParse(str) + assertEquals(str, "names:\"test\"") + assertEquals(q, Expr.NamesMacro("test")) + } + + test("macro: year") { + val str = exprString(Expr.YearMacro(Attr.Date, 1990)) + val q = singleParse(str) + assertEquals(str, "year:1990") + assertEquals(q, Expr.YearMacro(Attr.Date, 1990)) + } + + test("macro: daterange") { + val range = Expr.DateRangeMacro( + attr = Attr.Date, + left = Date.Calc( + date = Date.Local( + date = LocalDate.of(2076, 12, 9) + ), + calc = Date.CalcDirection.Minus, + period = Period.ofMonths(27) + ), + right = Date.Local(LocalDate.of(2076, 12, 9)) + ) + val str = exprString(range) + val q = singleParse(str) + assertEquals(str, "dateIn:2076-12-09;-27m") + assertEquals(q, range) + } + + property("generate expr and parse it") { + forAll(ItemQueryGen.exprGen) { expr => + val str = exprString(expr) + val q = singleParse(str) + assertEquals(q, expr) + } + } +} diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala index 37de9db0..3326a764 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala @@ -8,7 +8,7 @@ package docspell.query.internal import cats.implicits._ -import docspell.query.ItemQueryParser +import docspell.query.{ItemQuery, ItemQueryParser} import munit._ @@ -64,4 +64,14 @@ class ItemQueryParserTest extends FunSuite { ItemQueryParser.parseUnsafe("(| name:hello date:2021-02 name:world name:hello )") assertEquals(expect.copy(raw = raw.some), q) } + + test("f.id:name=value") { + val raw = "f.id:QsuGW@=\"dAHBstXJd0\"" + val q = ItemQueryParser.parseUnsafe(raw) + val expect = + ItemQuery.Expr.CustomFieldIdMatch("QsuGW@", ItemQuery.Operator.Eq, "dAHBstXJd0") + + assertEquals(q.expr, expect) + + } } diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 1d69da70..4c94b1c2 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -11,6 +11,7 @@ import java.time.{Instant, LocalDate} import docspell.common._ import docspell.common.syntax.all._ +import docspell.query.{ItemQuery, ItemQueryParser} import docspell.totp.Key import com.github.eikek.calev.CalEvent @@ -142,6 +143,11 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaByteSize: Meta[ByteSize] = Meta[Long].timap(ByteSize.apply)(_.bytes) + + implicit val metaItemQuery: Meta[ItemQuery] = + Meta[String].timap(s => ItemQueryParser.parseUnsafe(s))(q => + q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr)) + ) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/records/RShare.scala b/modules/store/src/main/scala/docspell/store/records/RShare.scala new file mode 100644 index 00000000..72f67de7 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RShare.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList + +import docspell.common._ +import docspell.query.ItemQuery +import docspell.store.qb._ + +final case class RShare( + id: Ident, + cid: Ident, + query: ItemQuery, + enabled: Boolean, + password: Option[Password], + publishedAt: Timestamp, + publishedUntil: Timestamp, + views: Int, + lastAccess: Option[Timestamp] +) {} + +object RShare { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "item_share"; + + val id = Column[Ident]("id", this) + val cid = Column[Ident]("cid", this) + val query = Column[ItemQuery]("query", this) + val enabled = Column[Boolean]("enabled", this) + val password = Column[Password]("password", this) + val publishedAt = Column[Timestamp]("published_at", this) + val publishedUntil = Column[Timestamp]("published_until", this) + val views = Column[Int]("views", this) + val lastAccess = Column[Timestamp]("last_access", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of( + id, + cid, + query, + enabled, + password, + publishedAt, + publishedUntil, + views, + lastAccess + ) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + +}