diff --git a/.gitattributes b/.gitattributes index 476390ebf..a45fa8234 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf sbt linguist-vendored diff --git a/docs/overview/queries/elastic_query_interval.md b/docs/overview/queries/elastic_query_interval.md new file mode 100644 index 000000000..c5db98dd0 --- /dev/null +++ b/docs/overview/queries/elastic_query_interval.md @@ -0,0 +1,61 @@ +--- +id: elastic_interval_query +title: "Overview" +--- + +The `Intervals` query allows for advanced search queries based on intervals between words in specific fields. +This query provides flexibility for conditions. + +To use the `Intervals` query, import the following: +```scala +import zio.elasticsearch.query.IntervalQuery +import zio.elasticsearch.ElasticQuery._ +``` + +You can create a basic `Intervals` query` using the `intervals` method: +```scala +val query: IntervalQuery[Any] = intervals(field = "content", rule = intervalMatch("targetWord")) +``` + +To define `field` in a type-safe manner, use the overloaded `useField` method with field definitions from your document: +```scala +val queryWithSafeField: IntervalQuery[Document] = + intervals(field = Document.stringField, rule = intervalMatch("targetWord")) +``` + +If you want to specify which fields should be searched, you can use the `useField` method: +```scala +val queryWithField: IntervalQuery[Document] = + intervals(field = "content", rule = intervalMatch("targetWord").useField(Document.stringField)) +``` + +If you want to define the `maxGaps` parameter, use the `maxGaps` method: +```scala +val queryWithMaxGaps: IntervalQuery[Document] = + intervals(field = "content", rule = intervalMatch("targetWord").maxGaps(2)) +``` + +If you want to specify the word order requirement, use the `orderedOn` method: +```scala +val queryWithOrder: IntervalQuery[Document] = + intervals(field = "content", rule = intervalMatch("targetWord").orderedOn) +``` + +You can also apply additional filters to the query: +```scala +val queryWithFilter: IntervalQuery[Document] = + intervals(field = "content", rule = intervalMatch("targetWord").filter(IntervalFilter.someFilter)) +``` + +Alternatively, you can construct the query manually with all parameters: +```scala +val queryManually: IntervalQuery[Document] = + IntervalQuery( + field = "content", + rule = intervalMatch("targetWord") + .maxGaps(2) + .orderedOn + .filter(IntervalFilter.someFilter) + .analyzer("standard") + ) +``` diff --git a/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala b/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala index 24efb9e44..939be62a6 100644 --- a/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala +++ b/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala @@ -19,7 +19,8 @@ package zio.elasticsearch import zio.Chunk import zio.elasticsearch.ElasticAggregation._ import zio.elasticsearch.ElasticHighlight.highlight -import zio.elasticsearch.ElasticQuery.{script => _, _} +import zio.elasticsearch.ElasticIntervalRule.intervalMatch +import zio.elasticsearch.ElasticQuery.{contains => _, _} import zio.elasticsearch.ElasticSort.sortBy import zio.elasticsearch.aggregation.AggregationOrder import zio.elasticsearch.data.GeoPoint @@ -2771,6 +2772,77 @@ object HttpExecutorSpec extends IntegrationSpec { } } @@ after(Executor.execute(ElasticRequest.deleteIndex(geoPolygonIndex)).orDie) ), + suite("intervals query")( + test("intervalMatch query returns only matching document") { + checkOnce(genDocumentId, genTestDocument, Gen.alphaNumericString.filter(_.nonEmpty)) { + (idMatch, docMatch, targetWord) => + val docShouldMatch = docMatch.copy(stringField = s"prefix $targetWord suffix") + + for { + _ <- Executor.execute(ElasticRequest.deleteByQuery(firstSearchIndex, matchAll)) + _ <- Executor.execute(ElasticRequest.upsert(firstSearchIndex, idMatch, docShouldMatch).refreshTrue) + res <- Executor + .execute( + ElasticRequest.search( + firstSearchIndex, + intervals(TestDocument.stringField, intervalMatch(targetWord)) + ) + ) + .documentAs[TestDocument] + } yield assert(res)(Assertion.hasSameElements(Chunk(docShouldMatch))) + } + } @@ around( + Executor.execute(ElasticRequest.createIndex(firstSearchIndex)), + Executor.execute(ElasticRequest.deleteIndex(firstSearchIndex)).orDie + ), + test("intervalMatch query finds document with exact matching term") { + checkOnce(genDocumentId, genTestDocument) { (docId, doc) => + val term = "apple" + val docWithTerm = doc.copy(stringField = s"$term banana orange") + + for { + _ <- Executor.execute(ElasticRequest.upsert(firstSearchIndex, docId, docWithTerm)) + _ <- Executor.execute(ElasticRequest.refresh(firstSearchIndex)) + res <- Executor + .execute( + ElasticRequest.search( + firstSearchIndex, + intervals( + TestDocument.stringField, + intervalMatch(term) + ) + ) + ) + .documentAs[TestDocument] + } yield assert(res)(Assertion.contains(docWithTerm)) + } + } @@ around( + Executor.execute(ElasticRequest.createIndex(firstSearchIndex)), + Executor.execute(ElasticRequest.deleteIndex(firstSearchIndex)).orDie + ), + test("intervalMatch query does not find document if term is absent") { + checkOnce(genDocumentId, genTestDocument) { (docId, doc) => + for { + _ <- Executor.execute(ElasticRequest.upsert(firstSearchIndex, docId, doc)) + _ <- Executor.execute(ElasticRequest.refresh(firstSearchIndex)) + res <- Executor + .execute( + ElasticRequest.search( + firstSearchIndex, + intervals( + TestDocument.stringField, + intervalMatch("nonexistentterm") + ) + ) + ) + .documentAs[TestDocument] + } yield assert(res)(Assertion.isEmpty) + } + } @@ around( + Executor.execute(ElasticRequest.createIndex(firstSearchIndex)), + Executor.execute(ElasticRequest.deleteIndex(firstSearchIndex)).orDie + ) + ), suite("search for documents using FunctionScore query")( test("using randomScore function") { checkOnce(genTestDocument, genTestDocument) { (firstDocument, secondDocument) => diff --git a/modules/library/src/main/scala/zio/elasticsearch/ElasticIntervalRule.scala b/modules/library/src/main/scala/zio/elasticsearch/ElasticIntervalRule.scala new file mode 100644 index 000000000..cac26b2d1 --- /dev/null +++ b/modules/library/src/main/scala/zio/elasticsearch/ElasticIntervalRule.scala @@ -0,0 +1,111 @@ +/* + * Copyright 2022 LambdaWorks + * + * 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 zio.elasticsearch + +import zio.NonEmptyChunk +import zio.elasticsearch.query.{ + IntervalAllOf, + IntervalAnyOf, + IntervalFilter, + IntervalFuzzy, + IntervalMatch, + IntervalPrefix, + IntervalRange, + IntervalRegexp, + IntervalRule, + IntervalWildcard, + Regexp +} +import zio.json.ast.Json + +object ElasticIntervalRule { + + def intervalAllOf[S](intervals: NonEmptyChunk[IntervalRule]): IntervalAllOf[S] = + IntervalAllOf(intervals = intervals, maxGaps = None, ordered = None, filter = None) + + def intervalAnyOf[S](intervals: NonEmptyChunk[IntervalRule]): IntervalAnyOf[S] = + IntervalAnyOf(intervals = intervals, filter = None) + + def intervalContains[S](pattern: String): IntervalWildcard[S] = + IntervalWildcard(s"*$pattern*", analyzer = None, useField = None) + + def intervalEndsWith[S](pattern: String): IntervalWildcard[S] = + IntervalWildcard(s"*$pattern", analyzer = None, useField = None) + + def intervalFilter[S]( + after: Option[IntervalRule] = None, + before: Option[IntervalRule] = None, + containedBy: Option[IntervalRule] = None, + containing: Option[IntervalRule] = None, + notContainedBy: Option[IntervalRule] = None, + notContaining: Option[IntervalRule] = None, + notOverlapping: Option[IntervalRule] = None, + overlapping: Option[IntervalRule] = None, + script: Option[Json] = None + ): Option[IntervalFilter[S]] = { + + val filter: IntervalFilter[S] = IntervalFilter( + after = after, + before = before, + containedBy = containedBy, + containing = containing, + notContainedBy = notContainedBy, + notContaining = notContaining, + notOverlapping = notOverlapping, + overlapping = overlapping, + script = script + ) + + Some(filter).filterNot(_ => + List(after, before, containedBy, containing, notContainedBy, notContaining, notOverlapping, overlapping, script) + .forall(_.isEmpty) + ) + } + + def intervalFuzzy[S](term: String): IntervalFuzzy[S] = + IntervalFuzzy( + term = term, + prefixLength = None, + transpositions = None, + fuzziness = None, + analyzer = None, + useField = None + ) + + def intervalMatch[S](query: String): IntervalMatch[S] = + IntervalMatch(query = query, analyzer = None, useField = None, maxGaps = None, ordered = None, filter = None) + + def intervalPrefix[S](prefix: String): IntervalPrefix[S] = + IntervalPrefix(prefix = prefix, analyzer = None, useField = None) + + def intervalRange[S]( + lower: Option[IntervalRule] = None, + upper: Option[IntervalRule] = None, + analyzer: Option[String] = None, + useField: Option[String] = None + ): IntervalRange[S] = + IntervalRange(lower = lower, upper = upper, analyzer = analyzer, useField = useField) + + def intervalRegexp[S](pattern: Regexp[S]): IntervalRegexp[S] = + IntervalRegexp(pattern = pattern, analyzer = None, useField = None) + + def intervalStartsWith[S](pattern: String): IntervalWildcard[S] = + IntervalWildcard(s"$pattern*", analyzer = None, useField = None) + + def intervalWildcard[S](pattern: String): IntervalWildcard[S] = + IntervalWildcard(pattern = pattern, analyzer = None, useField = None) +} diff --git a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala index 7b1472988..897dba8b8 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala @@ -548,6 +548,37 @@ object ElasticQuery { final def ids(value: String, values: String*): IdsQuery[Any] = Ids(values = Chunk.fromIterable(value +: values)) + /** + * Constructs a type-safe intervals query by combining a field and an interval rule. + * + * The resulting query wraps the specified interval query under the given type-safe field in the intervals query + * structure. + * + * @param field + * the type-safe field on which the query is executed + * @param rule + * an instance of [[zio.elasticsearch.query.IntervalRule]] representing the interval query rule + * @tparam S + * the document type for which the query is defined + * @return + * an [[zio.elasticsearch.ElasticQuery]] instance representing the intervals query. + */ + final def intervals[S](field: Field[S, _], rule: IntervalRule): ElasticQuery[S] = Intervals(field.toString, rule) + + /** + * Constructs an intervals query by combining a field and an interval query. + * + * The resulting query wraps the specified interval query under the given field in the intervals query structure. + * + * @param field + * the name of the field to be queried + * @param rule + * an instance of [[zio.elasticsearch.query.IntervalRule]] representing the interval query rule + * @return + * an [[zio.elasticsearch.ElasticQuery]] instance representing the intervals query. + */ + final def intervals(field: String, rule: IntervalRule): ElasticQuery[Any] = Intervals(field = field, rule = rule) + /** * Constructs a type-safe instance of [[zio.elasticsearch.query.KNNQuery]] using the specified parameters. * [[zio.elasticsearch.query.KNNQuery]] is used to perform a k-nearest neighbor (kNN) search and returns the matching diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/ElasticIntervalRule.scala b/modules/library/src/main/scala/zio/elasticsearch/query/ElasticIntervalRule.scala new file mode 100644 index 000000000..a7ae616a4 --- /dev/null +++ b/modules/library/src/main/scala/zio/elasticsearch/query/ElasticIntervalRule.scala @@ -0,0 +1,318 @@ +/* + * Copyright 2022 LambdaWorks + * + * 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 zio.elasticsearch.query + +import zio.Chunk +import zio.elasticsearch.ElasticPrimitive.ElasticPrimitiveOps +import zio.elasticsearch.Field +import zio.elasticsearch.query.options.{HasAnalyzer, HasUseField} +import zio.json.ast.Json +import zio.json.ast.Json.{Arr, Obj, Str} + +private[elasticsearch] sealed trait BoundType +private[elasticsearch] sealed trait Inclusive extends BoundType +private[elasticsearch] sealed trait Exclusive extends BoundType + +private[elasticsearch] case object InclusiveBound extends Inclusive +private[elasticsearch] case object ExclusiveBound extends Exclusive + +private[elasticsearch] final case class Bound[B <: BoundType](value: String, boundType: B) + +sealed trait IntervalRule { + private[elasticsearch] def toJson: Json +} + +private[elasticsearch] final case class GreaterThanInterval(value: String) extends IntervalRule { + private[elasticsearch] def toJson: Json = Str(value) +} + +private[elasticsearch] final case class GreaterThanOrEqualToInterval(value: String) extends IntervalRule { + private[elasticsearch] def toJson: Json = Str(value) +} + +private[elasticsearch] final case class LessThanInterval(value: String) extends IntervalRule { + private[elasticsearch] def toJson: Json = Str(value) +} + +private[elasticsearch] final case class LessThanOrEqualToInterval(value: String) extends IntervalRule { + private[elasticsearch] def toJson: Json = Str(value) +} + +private[elasticsearch] final case class IntervalAllOf[S]( + intervals: Chunk[IntervalRule], + maxGaps: Option[Int], + ordered: Option[Boolean], + filter: Option[IntervalFilter[S]] +) extends IntervalRule { + + def filter(f: IntervalFilter[S]): IntervalAllOf[S] = copy(filter = Some(f)) + + def maxGaps(g: Int): IntervalAllOf[S] = copy(maxGaps = Some(g)) + + def orderedOn: IntervalAllOf[S] = copy(ordered = Some(true)) + + private[elasticsearch] def toJson: Json = + Obj( + "all_of" -> Obj( + Chunk( + Some("intervals" -> Arr(intervals.map(_.toJson): _*)), + maxGaps.map("max_gaps" -> _.toJson), + ordered.map("ordered" -> _.toJson), + filter.map("filter" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalAnyOf[S]( + intervals: Chunk[IntervalRule], + filter: Option[IntervalFilter[S]] +) extends IntervalRule { self => + + def filter(f: IntervalFilter[S]): IntervalAnyOf[S] = copy(filter = Some(f)) + + private[elasticsearch] def toJson: Json = + Obj( + "any_of" -> Obj( + Chunk( + Some("intervals" -> Arr(intervals.map(_.toJson): _*)), + filter.map("filter" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalFilter[S]( + after: Option[IntervalRule] = None, + before: Option[IntervalRule] = None, + containedBy: Option[IntervalRule] = None, + containing: Option[IntervalRule] = None, + notContainedBy: Option[IntervalRule] = None, + notContaining: Option[IntervalRule] = None, + notOverlapping: Option[IntervalRule] = None, + overlapping: Option[IntervalRule] = None, + script: Option[Json] = None +) { + private[elasticsearch] def toJson: Json = + Obj( + Chunk( + after.map("after" -> _.toJson), + before.map("before" -> _.toJson), + containedBy.map("contained_by" -> _.toJson), + containing.map("containing" -> _.toJson), + notContainedBy.map("not_contained_by" -> _.toJson), + notContaining.map("not_containing" -> _.toJson), + notOverlapping.map("not_overlapping" -> _.toJson), + overlapping.map("overlapping" -> _.toJson), + script.map("script" -> _) + ).flatten: _* + ) +} + +private[elasticsearch] final case class IntervalFuzzy[S]( + term: String, + prefixLength: Option[Int], + transpositions: Option[Boolean], + fuzziness: Option[String], + analyzer: Option[String], + useField: Option[String] +) extends IntervalRule + with HasAnalyzer[IntervalFuzzy[S]] + with HasUseField[IntervalFuzzy[S]] { + + def analyzer(value: String): IntervalFuzzy[S] = copy(analyzer = Some(value)) + + def prefixLength(length: Int): IntervalFuzzy[S] = copy(prefixLength = Some(length)) + + def transpositionsDisabled: IntervalFuzzy[S] = copy(transpositions = Some(false)) + + def transpositionsEnabled: IntervalFuzzy[S] = copy(transpositions = Some(true)) + + def useField(field: Field[_, _]): IntervalFuzzy[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalFuzzy[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "fuzzy" -> Obj( + Chunk( + Some("term" -> term.toJson), + prefixLength.map("prefix_length" -> _.toJson), + transpositions.map("transpositions" -> _.toJson), + fuzziness.map("fuzziness" -> _.toJson), + analyzer.map("analyzer" -> _.toJson), + useField.map("use_field" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalMatch[S]( + query: String, + analyzer: Option[String], + useField: Option[String], + maxGaps: Option[Int], + ordered: Option[Boolean], + filter: Option[IntervalFilter[S]] +) extends IntervalRule + with HasAnalyzer[IntervalMatch[S]] + with HasUseField[IntervalMatch[S]] { self => + + def analyzer(value: String): IntervalMatch[S] = copy(analyzer = Some(value)) + + def filter(f: IntervalFilter[S]): IntervalMatch[S] = copy(filter = Some(f)) + + def maxGaps(g: Int): IntervalMatch[S] = copy(maxGaps = Some(g)) + + def orderedOn: IntervalMatch[S] = copy(ordered = Some(true)) + + def useField(field: Field[_, _]): IntervalMatch[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalMatch[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "match" -> Obj( + Chunk( + Some("query" -> Str(query)), + analyzer.map("analyzer" -> _.toJson), + useField.map("use_field" -> _.toJson), + maxGaps.map("max_gaps" -> _.toJson), + ordered.map("ordered" -> _.toJson), + filter.map("filter" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalPrefix[S]( + prefix: String, + analyzer: Option[String], + useField: Option[String] +) extends IntervalRule + with HasAnalyzer[IntervalPrefix[S]] + with HasUseField[IntervalPrefix[S]] { + + def analyzer(value: String): IntervalPrefix[S] = copy(analyzer = Some(value)) + + def useField(field: Field[_, _]): IntervalPrefix[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalPrefix[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "prefix" -> Obj( + Chunk( + Some("prefix" -> Str(prefix)), + analyzer.map(a => "analyzer" -> Str(a)), + useField.map("use_field" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalRange[S]( + lower: Option[IntervalRule] = None, + upper: Option[IntervalRule] = None, + analyzer: Option[String] = None, + useField: Option[String] = None +) extends IntervalRule + with HasAnalyzer[IntervalRange[S]] + with HasUseField[IntervalRange[S]] { + + def analyzer(value: String): IntervalRange[S] = copy(analyzer = Some(value)) + + def gt[B <: BoundType](value: String): IntervalRange[S] = copy(lower = Some(GreaterThanInterval(value))) + + def gte[B <: BoundType](value: String): IntervalRange[S] = copy(lower = Some(GreaterThanOrEqualToInterval(value))) + + def lower[B <: BoundType](b: Bound[B]): IntervalRange[S] = copy(lower = Some(GreaterThanInterval(b.value))) + + def lt[B <: BoundType](value: String): IntervalRange[S] = copy(upper = Some(LessThanInterval(value))) + + def lte[B <: BoundType](value: String): IntervalRange[S] = copy(upper = Some(LessThanOrEqualToInterval(value))) + + def upper[B <: BoundType](b: Bound[B]): IntervalRange[S] = copy(upper = Some(LessThanInterval(b.value))) + + def useField(field: Field[_, _]): IntervalRange[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalRange[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "range" -> Obj( + Chunk( + lower.map("gte" -> _.toJson), + upper.map("lte" -> _.toJson), + analyzer.map("analyzer" -> Str(_)), + useField.map("use_field" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalRegexp[S]( + pattern: Regexp[S], + analyzer: Option[String], + useField: Option[String] +) extends IntervalRule + with HasAnalyzer[IntervalRegexp[S]] + with HasUseField[IntervalRegexp[S]] { + + def analyzer(value: String): IntervalRegexp[S] = copy(analyzer = Some(value)) + + def useField(field: Field[_, _]): IntervalRegexp[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalRegexp[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "regexp" -> Obj( + Chunk( + Some("pattern" -> pattern.toJson(None)), + analyzer.map("analyzer" -> _.toJson), + useField.map("use_field" -> _.toJson) + ).flatten: _* + ) + ) +} + +private[elasticsearch] final case class IntervalWildcard[S]( + pattern: String, + analyzer: Option[String], + useField: Option[String] +) extends IntervalRule + with HasAnalyzer[IntervalWildcard[S]] + with HasUseField[IntervalWildcard[S]] { + + def analyzer(value: String): IntervalWildcard[S] = copy(analyzer = Some(value)) + + def useField(field: Field[_, _]): IntervalWildcard[S] = copy(useField = Some(field.name)) + + def useField(field: String): IntervalWildcard[S] = copy(useField = Some(field)) + + private[elasticsearch] def toJson: Json = + Obj( + "wildcard" -> Obj( + Chunk( + Some("pattern" -> pattern.toJson), + analyzer.map("analyzer" -> _.toJson), + useField.map("use_field" -> _.toJson) + ).flatten: _* + ) + ) +} diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala index ca62ebdb2..b74c4a5f7 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala @@ -528,7 +528,7 @@ sealed trait GeoDistanceQuery[S] extends ElasticQuery[S] { * incorrect coordinates. * * @param value - * defines how to handle invalid latitude nad longitude: + * defines how to handle invalid latitude and longitude: * - [[zio.elasticsearch.query.ValidationMethod.Strict]]: Default method * - [[zio.elasticsearch.query.ValidationMethod.IgnoreMalformed]]: Accepts geo points with invalid latitude or * longitude @@ -569,7 +569,6 @@ private[elasticsearch] final case class GeoDistance[S]( ).flatten: _* ) ) - } sealed trait GeoPolygonQuery[S] extends ElasticQuery[S] { @@ -590,7 +589,7 @@ sealed trait GeoPolygonQuery[S] extends ElasticQuery[S] { * incorrect coordinates. * * @param value - * defines how to handle invalid latitude nad longitude: + * defines how to handle invalid latitude and longitude: * - [[zio.elasticsearch.query.ValidationMethod.Strict]]: Default method * - [[zio.elasticsearch.query.ValidationMethod.IgnoreMalformed]]: Accepts geo points with invalid latitude or * longitude @@ -624,7 +623,6 @@ private[elasticsearch] final case class GeoPolygon[S]( ).flatten: _* ) ) - } sealed trait HasChildQuery[S] @@ -782,6 +780,21 @@ private[elasticsearch] final case class Ids[S](values: Chunk[String]) extends Id Obj("ids" -> Obj("values" -> Arr(values.map(_.toJson)))) } +sealed trait IntervalsQuery[S] extends ElasticQuery[S] + +private[elasticsearch] final case class Intervals[S]( + field: String, + rule: IntervalRule +) extends IntervalsQuery[S] { self => + + private[elasticsearch] def toJson(fieldPath: Option[String]): Json = + Obj( + "intervals" -> Obj( + fieldPath.fold(field)(_ + "." + field) -> rule.toJson + ) + ) +} + sealed trait MatchQuery[S] extends ElasticQuery[S] private[elasticsearch] final case class Match[S, A: ElasticPrimitive](field: String, value: A) extends MatchQuery[S] { diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/options/HasAnalyzer.scala b/modules/library/src/main/scala/zio/elasticsearch/query/options/HasAnalyzer.scala new file mode 100644 index 000000000..1c393c043 --- /dev/null +++ b/modules/library/src/main/scala/zio/elasticsearch/query/options/HasAnalyzer.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2022 LambdaWorks + * + * 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 zio.elasticsearch.query.options + +private[elasticsearch] trait HasAnalyzer[Q <: HasAnalyzer[Q]] { + + /** + * Sets the `analyzer` parameter for this [[zio.elasticsearch.query.ElasticIntervalQuery]] query. + * + * @param value + * the name of the analyzer to use + * @return + * a new instance of the query with the `analyzer` value set. + */ + def analyzer(value: String): Q +} diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/options/HasUseField.scala b/modules/library/src/main/scala/zio/elasticsearch/query/options/HasUseField.scala new file mode 100644 index 000000000..ff10f5081 --- /dev/null +++ b/modules/library/src/main/scala/zio/elasticsearch/query/options/HasUseField.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2022 LambdaWorks + * + * 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 zio.elasticsearch.query.options + +import zio.elasticsearch.Field + +private[elasticsearch] trait HasUseField[Q <: HasUseField[Q]] { + + /** + * Sets the `use_field` parameter for this [[zio.elasticsearch.query.ElasticIntervalQuery]] query. + * + * @param field + * the type-safe field to use from the document definition + * @return + * a new instance of the query with the `use_field` value set. + */ + def useField(field: Field[_, _]): Q + + /** + * Sets the `use_field` parameter using a plain string. + * + * @param field + * the name of the field as a string + * @return + * a new instance of the query with the `use_field` value set. + */ + def useField(field: String): Q +} diff --git a/modules/library/src/test/scala/zio/elasticsearch/ElasticIntervalRuleSpec.scala b/modules/library/src/test/scala/zio/elasticsearch/ElasticIntervalRuleSpec.scala new file mode 100644 index 000000000..2241c2b3d --- /dev/null +++ b/modules/library/src/test/scala/zio/elasticsearch/ElasticIntervalRuleSpec.scala @@ -0,0 +1,318 @@ +/* + * Copyright 2022 LambdaWorks + * + * 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 zio.elasticsearch + +import zio.elasticsearch.ElasticIntervalRule.{ + intervalContains, + intervalEndsWith, + intervalMatch, + intervalRange, + intervalStartsWith, + intervalWildcard +} +import zio.elasticsearch.ElasticQuery.intervals +import zio.elasticsearch.domain.TestDocument +import zio.elasticsearch.query._ +import zio.elasticsearch.utils._ +import zio.test.Assertion.equalTo +import zio.test._ + +object ElasticIntervalRuleSpec extends ZIOSpecDefault { + def spec: Spec[TestEnvironment, Any] = { + suite("ElasticIntervalRuleSpec")( + test("intervalMatch") { + val intervalNoOptions: IntervalMatch[String] = intervalMatch("lambda works") + + val intervalWithOptions: IntervalMatch[String] = intervalMatch("lambda works").orderedOn + .maxGaps(2) + .analyzer("standard") + + val filter = IntervalFilter[String]( + before = Some(intervalMatch("before_term")), + after = Some(intervalMatch("after_term")) + ) + + val queryWithStringField = intervals("stringField", intervalWithOptions) + val queryWithTypedField = intervals(TestDocument.stringField, intervalWithOptions) + + val expectedNoOptions = + """ + |{ + | "intervals": { + | "stringField": { + | "match": { + | "query": "lambda works" + | } + | } + | } + |} + |""".stripMargin + + val expectedWithOptions = + """ + |{ + | "intervals": { + | "stringField": { + | "match": { + | "query": "lambda works", + | "analyzer": "standard", + | "max_gaps": 2, + | "ordered": true + | } + | } + | } + |} + |""".stripMargin + + assert(intervals("stringField", intervalNoOptions).toJson(None))( + equalTo(expectedNoOptions.toJson) + ) && + assert(queryWithStringField.toJson(None))( + equalTo(expectedWithOptions.toJson) + ) && + assert(queryWithTypedField.toJson(None))( + equalTo(expectedWithOptions.toJson) + ) + }, + test("intervalRange") { + val intervalWithBounds = intervalRange[Any]( + lower = Some(GreaterThanInterval("10")), + upper = Some(LessThanInterval("20")), + analyzer = Some("standard"), + useField = Some("stringField") + ) + + val intervalWithOnlyLower = intervalRange[Any]( + lower = Some(GreaterThanInterval("10")), + upper = None, + analyzer = Some("standard"), + useField = Some("stringField") + ) + + val intervalWithOnlyUpper = intervalRange[Any]( + lower = None, + upper = Some(LessThanInterval("20")), + analyzer = Some("standard"), + useField = Some("stringField") + ) + + val queryWithBounds = intervals(TestDocument.stringField, intervalWithBounds) + val queryWithLower = intervals(TestDocument.stringField, intervalWithOnlyLower) + val queryWithUpper = intervals(TestDocument.stringField, intervalWithOnlyUpper) + + val expectedWithBounds = + """ + |{ + | "intervals": { + | "stringField": { + | "range": { + | "gte": "10", + | "lte": "20", + | "analyzer": "standard", + | "use_field": "stringField" + | } + | } + | } + |} + |""".stripMargin + + val expectedWithLower = + """ + |{ + | "intervals": { + | "stringField": { + | "range": { + | "gte": "10", + | "analyzer": "standard", + | "use_field": "stringField" + | } + | } + | } + |} + |""".stripMargin + + val expectedWithUpper = + """ + |{ + | "intervals": { + | "stringField": { + | "range": { + | "lte": "20", + | "analyzer": "standard", + | "use_field": "stringField" + | } + | } + | } + |} + |""".stripMargin + + assert(queryWithBounds.toJson(None))( + equalTo(expectedWithBounds.toJson) + ) && + assert(queryWithLower.toJson(None))( + equalTo(expectedWithLower.toJson) + ) && + assert(queryWithUpper.toJson(None))( + equalTo(expectedWithUpper.toJson) + ) + }, + test("intervalWildcard") { + val wildcardExact: IntervalWildcard[String] = + intervalWildcard("la*mb?da") + + val wildcardContains: IntervalWildcard[String] = + intervalContains("lambda") + + val wildcardStartsWith: IntervalWildcard[String] = + intervalStartsWith("lambda") + + val wildcardEndsWith: IntervalWildcard[String] = + intervalEndsWith("lambda") + + val queryExact: Intervals[String] = + Intervals("stringField", wildcardExact) + + val queryContains: Intervals[String] = + Intervals("stringField", wildcardContains) + + val queryStartsWith: Intervals[String] = + Intervals("stringField", wildcardStartsWith) + + val queryEndsWith: Intervals[String] = + Intervals("stringField", wildcardEndsWith) + + val expectedExact = + """ + |{ + | "intervals": { + | "stringField": { + | "wildcard": { + | "pattern": "la*mb?da" + | } + | } + | } + |} + |""".stripMargin + + val expectedContains = + """ + |{ + | "intervals": { + | "stringField": { + | "wildcard": { + | "pattern": "*lambda*" + | } + | } + | } + |} + |""".stripMargin + + val expectedStartsWith = + """ + |{ + | "intervals": { + | "stringField": { + | "wildcard": { + | "pattern": "lambda*" + | } + | } + | } + |} + |""".stripMargin + + val expectedEndsWith = + """ + |{ + | "intervals": { + | "stringField": { + | "wildcard": { + | "pattern": "*lambda" + | } + | } + | } + |} + |""".stripMargin + + assert(queryExact.toJson(None))(equalTo(expectedExact.toJson)) && + assert(queryContains.toJson(None))(equalTo(expectedContains.toJson)) && + assert(queryStartsWith.toJson(None))(equalTo(expectedStartsWith.toJson)) && + assert(queryEndsWith.toJson(None))(equalTo(expectedEndsWith.toJson)) + }, + test("interval query") { + val query1 = intervals(TestDocument.stringField, intervalMatch("test query")) + + val query2 = intervals( + TestDocument.stringField, + intervalMatch("another test") + .maxGaps(3) + .orderedOn + ) + + val query3 = intervals( + TestDocument.stringField, + intervalMatch("sample text") + .analyzer("standard") + ) + val expectedJson1 = + """ + |{ + | "intervals": { + | "stringField": { + | "match": { + | "query": "test query" + | } + | } + | } + |} + |""".stripMargin + + val expectedJson2 = + """ + |{ + | "intervals": { + | "stringField": { + | "match": { + | "query": "another test", + | "max_gaps": 3, + | "ordered": true + | } + | } + | } + |} + |""".stripMargin + + val expectedJson3 = + """ + |{ + | "intervals": { + | "stringField": { + | "match": { + | "query": "sample text", + | "analyzer": "standard" + | } + | } + | } + |} + |""".stripMargin + + assert(query1.toJson(None))(equalTo(expectedJson1.toJson)) && + assert(query2.toJson(None))(equalTo(expectedJson2.toJson)) && + assert(query3.toJson(None))(equalTo(expectedJson3.toJson)) + } + ) + } +} diff --git a/website/sidebars.js b/website/sidebars.js index 8c11fe381..ce8b26f1b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -25,6 +25,7 @@ module.exports = { 'overview/queries/elastic_query_geo_polygon', 'overview/queries/elastic_query_has_child', 'overview/queries/elastic_query_has_parent', + 'overview/queries/elastic_query_interval', 'overview/queries/elastic_query_match', 'overview/queries/elastic_query_match_all', 'overview/queries/elastic_query_match_boolean_prefix',