Skip to content

Commit

Permalink
feat: Parse search query from single string and json (#21)
Browse files Browse the repository at this point in the history
To allow more flexible searching, a query string following renku terminology can be given. It can be parsed from a single string or a json structure.
  • Loading branch information
eikek authored Feb 14, 2024
1 parent 72ecad4 commit b33e792
Show file tree
Hide file tree
Showing 25 changed files with 1,484 additions and 6 deletions.
20 changes: 18 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ lazy val root = project
events,
redisClient,
solrClient,
searchQuery,
searchSolrClient,
searchProvision,
searchApi
Expand Down Expand Up @@ -233,6 +234,20 @@ lazy val configValues = project
searchSolrClient % "compile->compile;test->test"
)

lazy val searchQuery = project
.in(file("modules/search-query"))
.withId("search-query")
.settings(commonSettings)
.settings(
name := "search-query",
libraryDependencies ++= Dependencies.catsParse ++
Dependencies.borer
)
.dependsOn(
commons % "compile->compile;test->test"
)
.enablePlugins(AutomateHeaderPlugin)

lazy val searchProvision = project
.in(file("modules/search-provision"))
.withId("search-provision")
Expand Down Expand Up @@ -284,7 +299,7 @@ lazy val commonSettings = Seq(
"-language:postfixOps", // enabling postfixes
"-deprecation", // Emit warning and location for usages of deprecated APIs.
"-encoding", "utf-8", // Specify character encoding used by source files.
"-explaintypes", // Explain type errors in more detail.
//"-explaintypes", // Explain type errors in more detail.
"-feature", // Emit warning and location for usages of features that should be imported explicitly.
"-unchecked", // Enable additional warnings where generated code depends on assumptions.
"-language:higherKinds", // Allow higher-kinded types
Expand All @@ -301,7 +316,8 @@ lazy val commonSettings = Seq(
Dependencies.scribe,
libraryDependencies ++= (
Dependencies.catsEffectMunit ++
Dependencies.scalacheckEffectMunit
Dependencies.scalacheckEffectMunit ++
Dependencies.catsScalaCheck
).map(_ % Test),
// Format: on
organizationName := "Swiss Data Science Center (SDSC)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package io.renku.search.model

import cats.kernel.Order
import io.bullet.borer.derivation.MapBasedCodecs.*
import io.bullet.borer.{Codec, Decoder, Encoder}
import io.renku.search.borer.codecs.all.given
Expand Down Expand Up @@ -70,9 +71,13 @@ object projects:
given Codec[CreationDate] = Codec.of[Instant]

enum Visibility derives Codec:
lazy val name: String = productPrefix
lazy val name: String = productPrefix.toLowerCase
case Public, Private

object Visibility:
def fromCaseInsensitive(v: String): Visibility =
given Order[Visibility] = Order.by(_.ordinal)
given Decoder[Visibility] = Decoder.forString.map(Visibility.unsafeFromString)
given Encoder[Visibility] = Encoder.forString.contramap(_.name)

def unsafeFromString(v: String): Visibility =
valueOf(v.toLowerCase.capitalize)
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ private class SearchProvisionerImpl[F[_]: Async](
projects.Name(pc.name),
projects.Slug(pc.slug),
pc.repositories.map(projects.Repository(_)),
projects.Visibility.fromCaseInsensitive(pc.visibility.name()),
projects.Visibility.unsafeFromString(pc.visibility.name()),
pc.description.map(projects.Description(_)),
toUser(pc.createdBy),
projects.CreationDate(pc.creationDate),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class SearchProvisionerSpec extends CatsEffectSuite with RedisSpec with SearchSo
projects.Name(created.name),
projects.Slug(created.slug),
created.repositories.map(projects.Repository(_)),
projects.Visibility.fromCaseInsensitive(created.visibility.name()),
projects.Visibility.unsafeFromString(created.visibility.name()),
created.description.map(projects.Description(_)),
toUser(created.createdBy),
projects.CreationDate(created.creationDate),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.query

import io.bullet.borer.{Decoder, Encoder}

enum Comparison:
case Is
case LowerThan
case GreaterThan

private[query] def asString = this match
case Is => ":"
case LowerThan => "<"
case GreaterThan => ">"

object Comparison:
given Encoder[Comparison] = Encoder.forString.contramap(_.asString)
given Decoder[Comparison] = Decoder.forString.mapEither(fromString)

private[query] def fromString(str: String): Either[String, Comparison] =
Comparison.values.find(_.asString == str).toRight(s"Invalid comparison: $str")

private[query] def unsafeFromString(str: String): Comparison =
fromString(str).fold(sys.error, identity)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.query

import java.time.Period

final case class DateTimeCalc(
ref: PartialDateTime | RelativeDate,
amount: Period,
range: Boolean
):
def asString: String =
val period = amount.getDays.abs
val sep =
if (range) DateTimeCalc.range
else if (amount.isNegative) DateTimeCalc.sub
else DateTimeCalc.add
ref match
case d: PartialDateTime =>
s"${d.asString}$sep${period}d"

case d: RelativeDate =>
s"${d.name}$sep${period}d"

object DateTimeCalc:
private[query] val add: String = "+"
private[query] val sub: String = "-"
private[query] val range: String = "/"
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.query

import cats.syntax.all.*
import io.bullet.borer.{Decoder, Encoder}
import io.renku.search.query.parse.DateTimeParser

enum DateTimeRef:
case Literal(ref: PartialDateTime)
case Relative(ref: RelativeDate)
case Calc(ref: DateTimeCalc)

val asString: String = this match
case Literal(ref) => ref.asString
case Relative(ref) => ref.name
case Calc(ref) => ref.asString

object DateTimeRef:
given Encoder[DateTimeRef] = Encoder.forString.contramap(_.asString)
given Decoder[DateTimeRef] = Decoder.forString.mapEither { str =>
DateTimeParser.dateTimeRef.parseAll(str).leftMap(_.show)
}

def apply(ref: PartialDateTime): DateTimeRef = Literal(ref)
def apply(ref: RelativeDate): DateTimeRef = Relative(ref)
def apply(ref: DateTimeCalc): DateTimeRef = Calc(ref)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.query

import io.bullet.borer.{Decoder, Encoder}

enum Field:
case ProjectId
case Name
case Slug
case Visibility
case Created
case CreatedBy

val name: String = Strings.lowerFirst(productPrefix)

object Field:
given Encoder[Field] = Encoder.forString.contramap(_.name)
given Decoder[Field] = Decoder.forString.mapEither(fromString)

private[this] val allNames: String = Field.values.mkString(", ")

def fromString(str: String): Either[String, Field] =
Field.values
.find(_.name.equalsIgnoreCase(str))
.toRight(s"Invalid field: $str. Allowed are: $allNames")

def unsafeFromString(str: String): Field =
fromString(str).fold(sys.error, identity)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.query

import cats.data.NonEmptyList
import io.renku.search.model.projects.Visibility

enum FieldTerm(val field: Field, val cmp: Comparison):
case ProjectIdIs(values: NonEmptyList[String])
extends FieldTerm(Field.ProjectId, Comparison.Is)
case NameIs(values: NonEmptyList[String]) extends FieldTerm(Field.Name, Comparison.Is)
case SlugIs(values: NonEmptyList[String]) extends FieldTerm(Field.Slug, Comparison.Is)
case VisibilityIs(values: NonEmptyList[Visibility])
extends FieldTerm(Field.Visibility, Comparison.Is)
case Created(override val cmp: Comparison, values: NonEmptyList[DateTimeRef])
extends FieldTerm(Field.Created, cmp)
case CreatedByIs(values: NonEmptyList[String])
extends FieldTerm(Field.CreatedBy, Comparison.Is)

private[query] def asString =
val value = this match
case ProjectIdIs(values) => FieldTerm.nelToString(values)
case NameIs(values) => FieldTerm.nelToString(values)
case SlugIs(values) => FieldTerm.nelToString(values)
case VisibilityIs(values) =>
val vis = values.toList.distinct.map(_.name)
vis.mkString(",")
case Created(_, values) => FieldTerm.nelToString(values.map(_.asString))
case CreatedByIs(values) => FieldTerm.nelToString(values)

s"${field.name}${cmp.asString}${value}"

object FieldTerm:
private def quote(s: String): String =
if (s.exists(c => c.isWhitespace || c == ',' || c == '"'))
s"\"${s.replace("\"", "\\\"")}\""
else s

private def nelToString(nel: NonEmptyList[String]): String =
nel.map(quote).toList.mkString(",")
Loading

0 comments on commit b33e792

Please sign in to comment.