From 6eabb8f5ffc8b47260c9593f70ce412b8a80010b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 18 Nov 2025 12:51:37 +0100 Subject: [PATCH 1/3] fix nested aggregations --- .../sql/bridge/ElasticAggregation.scala | 257 ++++++++++++++++-- .../elastic/sql/bridge/package.scala | 192 +++++++++---- .../elastic/sql/SQLQuerySpec.scala | 11 +- build.sbt | 1 + .../sql/bridge/ElasticAggregation.scala | 257 ++++++++++++++++-- .../elastic/sql/bridge/package.scala | 196 +++++++++---- .../elastic/sql/SQLQuerySpec.scala | 9 +- .../elastic/sql/function/package.scala | 9 + .../app/softnetwork/elastic/sql/package.scala | 81 +++--- .../softnetwork/elastic/sql/query/From.scala | 31 ++- .../elastic/sql/query/GroupBy.scala | 58 ++-- .../elastic/sql/query/SQLSearchRequest.scala | 18 +- .../elastic/sql/query/Select.scala | 22 +- .../softnetwork/elastic/sql/query/Where.scala | 22 +- 14 files changed, 901 insertions(+), 263 deletions(-) diff --git a/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala b/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala index 847ee1a3..51b3766b 100644 --- a/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala +++ b/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala @@ -46,8 +46,11 @@ import com.sksamuel.elastic4s.ElasticApi.{ import com.sksamuel.elastic4s.requests.script.Script import com.sksamuel.elastic4s.requests.searches.aggs.{ Aggregation, + CardinalityAggregation, + ExtendedStatsAggregation, FilterAggregation, NestedAggregation, + StatsAggregation, TermsAggregation, TermsOrder } @@ -71,6 +74,14 @@ case class ElasticAggregation( ) { val nested: Boolean = nestedElement.nonEmpty val filtered: Boolean = filteredAgg.nonEmpty + + // CHECK if it is a "global" metric (cardinality, etc.) or a bucket metric (avg, sum, etc.) + val isGlobalMetric: Boolean = agg match { + case _: CardinalityAggregation => true + case _: StatsAggregation => true + case _: ExtendedStatsAggregation => true + case _ => false + } } object ElasticAggregation { @@ -186,17 +197,6 @@ object ElasticAggregation { topHits } - val filteredAggName = "filtered_agg" - - def filtered(): Unit = - having match { - case Some(_) => - aggPath ++= Seq(filteredAggName) - aggPath ++= Seq(aggName) - case _ => - aggPath ++= Seq(aggName) - } - val nestedElement = identifier.nestedElement val nestedElements: Seq[NestedElement] = @@ -205,6 +205,7 @@ object ElasticAggregation { val nestedAgg = nestedElements match { case Nil => + aggPath ++= Seq(aggName) None case nestedElements => def buildNested(n: NestedElement): NestedAggregation = { @@ -231,11 +232,16 @@ object ElasticAggregation { } } - Some(buildNested(nestedElements.head)) + val root = nestedElements.head + val nestedAgg = buildNested(root) subaggs Seq(_agg) + having match { + case Some(_) => aggPath ++= Seq("filtered_agg") + case _ => + } + aggPath ++= Seq(aggName) + Some(nestedAgg) } - filtered() - ElasticAggregation( aggPath.mkString("."), field, @@ -254,19 +260,24 @@ object ElasticAggregation { bucketsDirection: Map[String, SortOrder], aggregations: Seq[Aggregation], aggregationsDirection: Map[String, SortOrder], - having: Option[Criteria] + having: Option[Criteria], + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] ): Option[TermsAggregation] = { buckets.reverse.foldLeft(Option.empty[TermsAggregation]) { (current, bucket) => + // Determine the bucketPath of the current bucket + val currentBucketPath = bucket.identifier.path + var agg = { bucketsDirection.get(bucket.identifier.identifierName) match { case Some(direction) => - termsAgg(bucket.name, s"${bucket.identifier.path}.keyword") + termsAgg(bucket.name, s"$currentBucketPath.keyword") .order(Seq(direction match { case Asc => TermsOrder("_key", asc = true) case _ => TermsOrder("_key", asc = false) })) case None => - termsAgg(bucket.name, s"${bucket.identifier.path}.keyword") + termsAgg(bucket.name, s"$currentBucketPath.keyword") } } bucket.size.foreach(s => agg = agg.size(s)) @@ -304,22 +315,212 @@ object ElasticAggregation { agg val withHaving = having match { case Some(criteria) => - val script = MetricSelectorScript.metricSelector(criteria) - val bucketsPath = criteria.extractMetricsPath - - val bucketSelector = - bucketSelectorAggregation( - "having_filter", - Script(script.replaceAll("1 == 1 &&", "").replaceAll("&& 1 == 1", "").trim), - bucketsPath - ) - - withAggregationOrders.copy(subaggs = aggregations :+ bucketSelector) + val script = metricSelectorForBucket( + criteria, + nested, + allElasticAggregations + ) + if (script.nonEmpty) { + val bucketSelector = + bucketSelectorAggregation( + "having_filter", + Script(script), + extractMetricsPathForBucket( + criteria, + nested, + allElasticAggregations + ) + ) + withAggregationOrders.copy(subaggs = aggregations :+ bucketSelector) + } else { + withAggregationOrders.copy(subaggs = aggregations) + } case None => withAggregationOrders.copy(subaggs = aggregations) } Some(withHaving) } } } + + /** Generates the bucket_selector script for a given bucket + */ + def metricSelectorForBucket( + criteria: Criteria, + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] + ): String = { + + val currentBucketPath = nested.map(_.bucketPath).getOrElse("") + + // No filtering + val fullScript = MetricSelectorScript + .metricSelector(criteria) + .replaceAll("1 == 1 &&", "") + .replaceAll("&& 1 == 1", "") + .replaceAll("1 == 1", "") + .trim + + // println(s"[DEBUG] currentBucketPath = $currentBucketPath") + // println(s"[DEBUG] fullScript (complete) = $fullScript") + + if (fullScript.isEmpty) { + return "" + } + + // Parse the script to extract the conditions + val conditions = parseConditions(fullScript) + // println(s"[DEBUG] conditions = $conditions") + + // Filter based on availability in buckets_path + val relevantConditions = conditions.filter { condition => + val metricNames = extractMetricNames(condition) + // println(s"[DEBUG] condition = $condition, metricNames = $metricNames") + + metricNames.forall { metricName => + allElasticAggregations.find(agg => + agg.aggName == metricName || agg.field == metricName + ) match { + case Some(elasticAgg) => + val metricBucketPath = elasticAgg.nestedElement + .map(_.bucketPath) + .getOrElse("") + + // println( + // s"[DEBUG] metricName = $metricName, metricBucketPath = $metricBucketPath, aggType = ${elasticAgg.agg.getClass.getSimpleName}" + // ) + + val belongsToLevel = metricBucketPath == currentBucketPath + + val isDirectChildAndAccessible = + if (isDirectChild(metricBucketPath, currentBucketPath)) { + // Check if it's a "global" metric (cardinality, etc.) + elasticAgg.isGlobalMetric + } else { + false + } + + val result = belongsToLevel || isDirectChildAndAccessible + + // println( + // s"[DEBUG] belongsToLevel = $belongsToLevel, isDirectChildAndAccessible = $isDirectChildAndAccessible, result = $result" + // ) + result + + case None => + // println(s"[DEBUG] metricName = $metricName NOT FOUND") + currentBucketPath.isEmpty + } + } + } + + // println(s"[DEBUG] relevantConditions = $relevantConditions") + + if (relevantConditions.isEmpty) { + "" + } else { + relevantConditions.mkString(" && ") + } + } + + /** HELPER: Parse the conditions of a script (separated by &&) + */ + private def parseConditions(script: String): Seq[String] = { + // Simple parsing : split by " && " + // ⚠️ This simple implementation does not handle parentheses. + script.split(" && ").map(_.trim).toSeq + } + + /** HELPER: Extracts the metric names from a condition Example: "params.ingredient_count >= 3" => + * Seq("ingredient_count") + */ + private def extractMetricNames(condition: String): Seq[String] = { + // Pattern to extract "params.XXX" + val pattern = "params\\.([a-zA-Z_][a-zA-Z0-9_]*)".r + pattern.findAllMatchIn(condition).map(_.group(1)).toSeq + } + + // HELPER: Check if a path is a direct child + private def isDirectChild(childPath: String, parentPath: String): Boolean = { + if (parentPath.isEmpty) { + childPath.nonEmpty && !childPath.contains(">") + } else { + childPath.startsWith(parentPath + ">") && + childPath.count(_ == '>') == parentPath.count(_ == '>') + 1 + } + } + + /** Extracts the buckets_path for a given bucket + */ + def extractMetricsPathForBucket( + criteria: Criteria, + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] + ): Map[String, String] = { + + val currentBucketPath = nested.map(_.bucketPath).getOrElse("") + + // Extract ALL metrics paths + val allMetricsPaths = criteria.extractAllMetricsPath + + // println(s"[DEBUG extractMetricsPath] currentBucketPath = $currentBucketPath") + // println(s"[DEBUG extractMetricsPath] allMetricsPaths = $allMetricsPaths") + + // Filter and adapt the paths for this bucket + val result = allMetricsPaths.flatMap { case (metricName, metricPath) => + allElasticAggregations.find(agg => + agg.aggName == metricName || agg.field == metricName + ) match { + case Some(elasticAgg) => + val metricBucketPath = elasticAgg.nestedElement + .map(_.bucketPath) + .getOrElse("") + + // println( + // s"[DEBUG extractMetricsPath] metricName = $metricName, metricBucketPath = $metricBucketPath, aggType = ${elasticAgg.agg.getClass.getSimpleName}" + // ) + + if (metricBucketPath == currentBucketPath) { + // Metric of the same level + // println(s"[DEBUG extractMetricsPath] Same level: $metricName -> $metricName") + Some(metricName -> metricName) + + } else if (isDirectChild(metricBucketPath, currentBucketPath)) { + // Metric of a direct child + + // CHECK if it is a "global" metric (cardinality, etc.) or a bucket metric (avg, sum, etc.) + val isGlobalMetric = elasticAgg.isGlobalMetric + + if (isGlobalMetric) { + // Global metric: can be referenced from the parent + val childNestedName = elasticAgg.nestedElement + .map(_.innerHitsName) + .getOrElse("") + // println( + // s"[DEBUG extractMetricsPath] Direct child (global metric): $metricName -> $childNestedName>$metricName" + // ) + Some(metricName -> s"$childNestedName>$metricName") + } else { + // Bucket metric: cannot be referenced from the parent + // println( + // s"[DEBUG extractMetricsPath] Direct child (bucket metric): $metricName -> SKIP (bucket-level metric)" + // ) + None + } + + } else { + // A different level of metric + // println(s"[DEBUG extractMetricsPath] Other level: $metricName -> SKIP") + None + } + + case None => + // println(s"[DEBUG extractMetricsPath] Not found: $metricName -> SKIP") + None + } + } + + // println(s"[DEBUG extractMetricsPath] result = $result") + result + } } diff --git a/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala b/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala index 7dd7ec8e..b29f1ce2 100644 --- a/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala +++ b/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala @@ -21,7 +21,6 @@ import app.softnetwork.elastic.sql.function.aggregate.COUNT import app.softnetwork.elastic.sql.function.geo.{Distance, Meters} import app.softnetwork.elastic.sql.operator._ import app.softnetwork.elastic.sql.query._ - import com.sksamuel.elastic4s.ElasticApi import com.sksamuel.elastic4s.ElasticApi._ import com.sksamuel.elastic4s.requests.common.FetchSourceContext @@ -30,8 +29,10 @@ import com.sksamuel.elastic4s.requests.script.ScriptType.Source import com.sksamuel.elastic4s.requests.searches.aggs.{ Aggregation, FilterAggregation, - NestedAggregation + NestedAggregation, + TermsAggregation } +import com.sksamuel.elastic4s.requests.searches.queries.compound.BoolQuery import com.sksamuel.elastic4s.requests.searches.queries.{InnerHit, Query} import com.sksamuel.elastic4s.requests.searches.sort.FieldSort import com.sksamuel.elastic4s.requests.searches.{ @@ -56,12 +57,17 @@ package object bridge { case cs => val boolQuery = ElasticBoolQuery(group = true) cs.map(c => boolQuery.filter(c.asFilter(Option(boolQuery)))) - Some( - boolQuery.query( - request.aggregates.flatMap(_.identifier.innerHitsName).toSet, - Option(boolQuery) - ) - ) + boolQuery.query( + request.aggregates.flatMap(_.identifier.innerHitsName).toSet, + Option(boolQuery) + ) match { + case q: BoolQuery + if q.filters.forall(p => + p == matchAllQuery() + ) && q.must.isEmpty && q.should.isEmpty && q.not.isEmpty => + None + case other => Some(other) + } } case _ => None @@ -74,12 +80,17 @@ package object bridge { case cs => val boolQuery = ElasticBoolQuery(group = true) cs.map(c => boolQuery.filter(c.asFilter(Option(boolQuery)))) - Some( - boolQuery.query( - request.aggregates.flatMap(_.identifier.innerHitsName).toSet, - Option(boolQuery) - ) - ) + boolQuery.query( + request.aggregates.flatMap(_.identifier.innerHitsName).toSet, + Option(boolQuery) + ) match { + case q: BoolQuery + if q.filters.forall(p => + p == matchAllQuery() + ) && q.must.isEmpty && q.should.isEmpty && q.not.isEmpty => + None + case other => Some(other) + } } case _ => None @@ -129,28 +140,44 @@ package object bridge { } implicit def requestToRootAggregations( - request: SQLSearchRequest + request: SQLSearchRequest, + aggregations: Seq[ElasticAggregation] ): Seq[Aggregation] = { - val aggregations = request.aggregates.map( - ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) - ) - val notNestedAggregations = aggregations.filterNot(_.nested) + val notNestedBuckets = request.buckets.filterNot(_.nested) + val rootAggregations = notNestedAggregations match { - case Nil => Nil + case Nil => + val buckets = ElasticAggregation.buildBuckets( + notNestedBuckets, + request.sorts, + Seq.empty, + Map.empty, + request.having.flatMap(_.criteria), + None, + aggregations + ) match { + case Some(b) => Seq(b) + case _ => Seq.empty + } + buckets case aggs => val directions: Map[String, SortOrder] = aggs .filter(_.direction.isDefined) .map(agg => agg.agg.name -> agg.direction.get) .toMap + val aggregations = aggs.map(_.agg) + val buckets = ElasticAggregation.buildBuckets( - request.buckets.filterNot(_.nested), + notNestedBuckets, request.sorts -- directions.keys, aggregations, directions, - request.having.flatMap(_.criteria) + request.having.flatMap(_.criteria), + None, + aggs ) match { case Some(b) => Seq(b) case _ => aggregations @@ -161,12 +188,10 @@ package object bridge { } implicit def requestToScopedAggregations( - request: SQLSearchRequest + request: SQLSearchRequest, + aggregations: Seq[ElasticAggregation] ): Seq[NestedAggregation] = { - val aggregations = request.aggregates.map( - ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) - ) - + // Group nested aggregations by their nested path val nestedAggregations: Map[String, Seq[ElasticAggregation]] = aggregations .filter(_.nested) .groupBy( @@ -177,6 +202,7 @@ package object bridge { ) ) + // Group nested buckets by their nested path val nestedGroupedBuckets = request.buckets .filter(_.nested) @@ -188,16 +214,36 @@ package object bridge { ) ) + // Having criteria or none val havingCriteria = request.having.flatMap(_.criteria) + // Build nested aggregations val scopedAggregations = NestedElements .buildNestedTrees( nestedAggregations.values.flatMap(_.flatMap(_.nestedElement)).toSeq.distinct ) - .map { tree => + .map { tree => // For each nested tree, build the nested aggregation def buildNestedAgg(n: NestedElement): NestedAggregation = { + // Get the aggregations for this nested path val elasticAggregations = nestedAggregations.getOrElse(n.path, Seq.empty) - val aggregations = elasticAggregations.map(_.agg) + + // Get the buckets for this nested element + val nestedBuckets = + nestedGroupedBuckets.getOrElse(n.innerHitsName, Seq.empty) + + val notRelatedAggregationsToBuckets = elasticAggregations + .filterNot { ea => + nestedBuckets.exists(nb => nb.identifier.path == ea.sourceField) + } + .map(_.agg) + + val relatedAggregationsToBuckets = elasticAggregations + .filter { ea => + nestedBuckets.exists(nb => nb.identifier.path == ea.sourceField) + } + .map(_.agg) + + // Get the directions for this nested aggregation val directions: Map[String, SortOrder] = elasticAggregations .filter(_.direction.isDefined) @@ -205,20 +251,26 @@ package object bridge { elasticAggregation.agg.name -> elasticAggregation.direction.getOrElse(Asc) ) .toMap + + // Build filter aggregation for this nested aggregation + val nestedFilteredAgg: Option[FilterAggregation] = + requestToNestedFilterAggregation(request, n.innerHitsName) + + // Build buckets for this nested aggregation val buckets: Seq[Aggregation] = ElasticAggregation.buildBuckets( - nestedGroupedBuckets - .getOrElse(n.innerHitsName, Seq.empty), + nestedBuckets, request.sorts -- directions.keys, - aggregations, + notRelatedAggregationsToBuckets, directions, - havingCriteria + havingCriteria, + Some(n), + aggregations ) match { case Some(b) => Seq(b) - case _ => aggregations + case _ => notRelatedAggregationsToBuckets } - val nestedFilteredAgg: Option[FilterAggregation] = - requestToNestedFilterAggregation(request, n.innerHitsName) + val children = n.children if (children.nonEmpty) { val innerAggs = children.map(buildNestedAgg) @@ -229,13 +281,23 @@ package object bridge { agg1.copy(subaggs = agg1.subaggs ++ Seq(agg2)) } } + val combinedBuckets = + buckets match { + case Nil => Seq(combinedAgg) + case b if b.size == 1 => + addNestedAggregationsToTermsAggregation(b.head, Seq(combinedAgg)) match { + case Some(updated) => Seq(updated) + case _ => b ++ Seq(combinedAgg) + } + case _ => buckets ++ Seq(combinedAgg) + } nestedAggregation( n.innerHitsName, n.path ) subaggs (nestedFilteredAgg match { case Some(filteredAgg) => - Seq(filteredAgg subaggs buckets ++ Seq(combinedAgg)) - case _ => buckets ++ Seq(combinedAgg) + Seq(filteredAgg subaggs relatedAggregationsToBuckets ++ combinedBuckets) + case _ => relatedAggregationsToBuckets ++ combinedBuckets }) } else { nestedAggregation( @@ -243,8 +305,8 @@ package object bridge { n.path ) subaggs (nestedFilteredAgg match { case Some(filteredAgg) => - Seq(filteredAgg subaggs buckets) - case _ => buckets + Seq(filteredAgg subaggs relatedAggregationsToBuckets ++ buckets) + case _ => relatedAggregationsToBuckets ++ buckets }) } } @@ -310,6 +372,29 @@ package object bridge { } } + private def addNestedAggregationsToTermsAggregation( + agg: Aggregation, + nested: Seq[NestedAggregation] + ): Option[TermsAggregation] = { + agg match { + case termsAgg: TermsAggregation => + termsAgg.subaggs.find(subAgg => + subAgg match { + case _: TermsAggregation => true + case _ => false + } + ) match { + case Some(t: TermsAggregation) => + val ret = addNestedAggregationsToTermsAggregation(t, nested) + val updated = + termsAgg.copy(subaggs = termsAgg.subaggs.filterNot(_ == t) ++ ret.toList) + Some(updated) + case _ => Some(termsAgg subaggs termsAgg.subaggs ++ nested) + } + case _ => None + } + } + implicit def requestToElasticSearchRequest(request: SQLSearchRequest): ElasticSearchRequest = ElasticSearchRequest( request.select.fields, @@ -328,11 +413,26 @@ package object bridge { implicit def requestToSearchRequest(request: SQLSearchRequest): SearchRequest = { import request._ - val rootAggregations = requestToRootAggregations(request) + val aggregations = request.aggregates.map( + ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) + ) + + val rootAggregations = requestToRootAggregations(request, aggregations) - val scopedAggregations = requestToScopedAggregations(request) + val scopedAggregations = requestToScopedAggregations(request, aggregations) - val aggregations = rootAggregations ++ scopedAggregations + val allAggregations = { + rootAggregations match { + case Nil => scopedAggregations + case r if r.size == 1 => + addNestedAggregationsToTermsAggregation(r.head, scopedAggregations) match { + case Some(agg) => + Seq(agg) + case _ => r ++ scopedAggregations + } + case _ => rootAggregations ++ scopedAggregations + } + } val nestedWithoutCriteriaQuery: Option[Query] = requestToNestedWithoutCriteriaQuery(request) @@ -349,9 +449,9 @@ package object bridge { } } sourceFiltering (fields, excludes) - _search = if (aggregations.nonEmpty) { + _search = if (allAggregations.nonEmpty) { _search aggregations { - aggregations + allAggregations } } else { _search @@ -387,7 +487,7 @@ package object bridge { case _ => _search } - if (aggregations.nonEmpty && fields.isEmpty) { + if (allAggregations.nonEmpty && fields.isEmpty) { _search size 0 } else { limit match { diff --git a/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala b/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala index b352e0d8..3f4c5d37 100644 --- a/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala +++ b/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala @@ -6,8 +6,6 @@ import app.softnetwork.elastic.sql.query._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers -import scala.jdk.CollectionConverters._ - /** Created by smanciot on 13/04/17. */ class SQLQuerySpec extends AnyFlatSpec with Matchers { @@ -214,7 +212,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { val result = results.head result.nested shouldBe true result.distinct shouldBe false - result.aggName shouldBe "inner_emails.filtered_agg.count_emails" + result.aggName shouldBe "inner_emails.filtered_inner_emails.count_emails" result.field shouldBe "count_emails" result.sources shouldBe Seq[String]("index") val query = result.query.getOrElse("") @@ -795,7 +793,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { | "aggs": { | "cat": { | "terms": { - | "field": "products.category.keyword" + | "field": "products.category.keyword", + | "size": 10 | }, | "aggs": { | "min_price": { @@ -811,8 +810,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { | "having_filter": { | "bucket_selector": { | "buckets_path": { - | "min_price": "inner_products>min_price", - | "max_price": "inner_products>max_price" + | "min_price": "min_price", + | "max_price": "max_price" | }, | "script": { | "source": "params.min_price > 5.0 && params.max_price < 50.0" diff --git a/build.sbt b/build.sbt index 562c0eee..92b7ff14 100644 --- a/build.sbt +++ b/build.sbt @@ -238,6 +238,7 @@ def copyBridge(esVersion: String): Def.Initialize[Task[Unit]] = Def.task { streams.value.log.info( s"Copying bridge template sources for ES ${elasticSearchMajorVersion(esVersion)}..." ) + IO.delete(target / "src") IO.copyDirectory(src / "src", target / "src") } diff --git a/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala b/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala index 2e427256..be7d57ed 100644 --- a/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala +++ b/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/ElasticAggregation.scala @@ -46,8 +46,11 @@ import com.sksamuel.elastic4s.ElasticApi.{ import com.sksamuel.elastic4s.script.Script import com.sksamuel.elastic4s.searches.aggs.{ Aggregation, + CardinalityAggregation, + ExtendedStatsAggregation, FilterAggregation, NestedAggregation, + StatsAggregation, TermsAggregation, TermsOrder } @@ -71,6 +74,14 @@ case class ElasticAggregation( ) { val nested: Boolean = nestedElement.nonEmpty val filtered: Boolean = filteredAgg.nonEmpty + + // CHECK if it is a "global" metric (cardinality, etc.) or a bucket metric (avg, sum, etc.) + val isGlobalMetric: Boolean = agg match { + case _: CardinalityAggregation => true + case _: StatsAggregation => true + case _: ExtendedStatsAggregation => true + case _ => false + } } object ElasticAggregation { @@ -183,17 +194,6 @@ object ElasticAggregation { topHits } - val filteredAggName = "filtered_agg" - - def filtered(): Unit = - having match { - case Some(_) => - aggPath ++= Seq(filteredAggName) - aggPath ++= Seq(aggName) - case _ => - aggPath ++= Seq(aggName) - } - val nestedElement = identifier.nestedElement val nestedElements: Seq[NestedElement] = @@ -202,6 +202,7 @@ object ElasticAggregation { val nestedAgg = nestedElements match { case Nil => + aggPath ++= Seq(aggName) None case nestedElements => def buildNested(n: NestedElement): NestedAggregation = { @@ -228,11 +229,16 @@ object ElasticAggregation { } } - Some(buildNested(nestedElements.head)) + val root = nestedElements.head + val nestedAgg = buildNested(root) subaggs Seq(_agg) + having match { + case Some(_) => aggPath ++= Seq("filtered_agg") + case _ => + } + aggPath ++= Seq(aggName) + Some(nestedAgg) } - filtered() - ElasticAggregation( aggPath.mkString("."), field, @@ -251,19 +257,24 @@ object ElasticAggregation { bucketsDirection: Map[String, SortOrder], aggregations: Seq[Aggregation], aggregationsDirection: Map[String, SortOrder], - having: Option[Criteria] + having: Option[Criteria], + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] ): Option[TermsAggregation] = { buckets.reverse.foldLeft(Option.empty[TermsAggregation]) { (current, bucket) => + // Determine the bucketPath of the current bucket + val currentBucketPath = bucket.identifier.path + var agg = { bucketsDirection.get(bucket.identifier.identifierName) match { case Some(direction) => - termsAgg(bucket.name, s"${bucket.identifier.path}.keyword") + termsAgg(bucket.name, s"$currentBucketPath.keyword") .order(Seq(direction match { case Asc => TermsOrder("_key", asc = true) case _ => TermsOrder("_key", asc = false) })) case None => - termsAgg(bucket.name, s"${bucket.identifier.path}.keyword") + termsAgg(bucket.name, s"$currentBucketPath.keyword") } } bucket.size.foreach(s => agg = agg.size(s)) @@ -301,22 +312,212 @@ object ElasticAggregation { agg val withHaving = having match { case Some(criteria) => - val script = MetricSelectorScript.metricSelector(criteria) - val bucketsPath = criteria.extractMetricsPath - - val bucketSelector = - bucketSelectorAggregation( - "having_filter", - Script(script.replaceAll("1 == 1 &&", "").replaceAll("&& 1 == 1", "").trim), - bucketsPath - ) - - withAggregationOrders.copy(subaggs = aggregations :+ bucketSelector) + val script = metricSelectorForBucket( + criteria, + nested, + allElasticAggregations + ) + if (script.nonEmpty) { + val bucketSelector = + bucketSelectorAggregation( + "having_filter", + Script(script), + extractMetricsPathForBucket( + criteria, + nested, + allElasticAggregations + ) + ) + withAggregationOrders.copy(subaggs = aggregations :+ bucketSelector) + } else { + withAggregationOrders.copy(subaggs = aggregations) + } case None => withAggregationOrders.copy(subaggs = aggregations) } Some(withHaving) } } } + + /** Generates the bucket_selector script for a given bucket + */ + def metricSelectorForBucket( + criteria: Criteria, + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] + ): String = { + + val currentBucketPath = nested.map(_.bucketPath).getOrElse("") + + // No filtering + val fullScript = MetricSelectorScript + .metricSelector(criteria) + .replaceAll("1 == 1 &&", "") + .replaceAll("&& 1 == 1", "") + .replaceAll("1 == 1", "") + .trim + + // println(s"[DEBUG] currentBucketPath = $currentBucketPath") + // println(s"[DEBUG] fullScript (complete) = $fullScript") + + if (fullScript.isEmpty) { + return "" + } + + // Parse the script to extract the conditions + val conditions = parseConditions(fullScript) + // println(s"[DEBUG] conditions = $conditions") + + // Filter based on availability in buckets_path + val relevantConditions = conditions.filter { condition => + val metricNames = extractMetricNames(condition) + // println(s"[DEBUG] condition = $condition, metricNames = $metricNames") + + metricNames.forall { metricName => + allElasticAggregations.find(agg => + agg.aggName == metricName || agg.field == metricName + ) match { + case Some(elasticAgg) => + val metricBucketPath = elasticAgg.nestedElement + .map(_.bucketPath) + .getOrElse("") + + // println( + // s"[DEBUG] metricName = $metricName, metricBucketPath = $metricBucketPath, aggType = ${elasticAgg.agg.getClass.getSimpleName}" + // ) + + val belongsToLevel = metricBucketPath == currentBucketPath + + val isDirectChildAndAccessible = + if (isDirectChild(metricBucketPath, currentBucketPath)) { + // Check if it's a "global" metric (cardinality, etc.) + elasticAgg.isGlobalMetric + } else { + false + } + + val result = belongsToLevel || isDirectChildAndAccessible + + // println( + // s"[DEBUG] belongsToLevel = $belongsToLevel, isDirectChildAndAccessible = $isDirectChildAndAccessible, result = $result" + // ) + result + + case None => + // println(s"[DEBUG] metricName = $metricName NOT FOUND") + currentBucketPath.isEmpty + } + } + } + + // println(s"[DEBUG] relevantConditions = $relevantConditions") + + if (relevantConditions.isEmpty) { + "" + } else { + relevantConditions.mkString(" && ") + } + } + + /** HELPER: Parse the conditions of a script (separated by &&) + */ + private def parseConditions(script: String): Seq[String] = { + // Simple parsing : split by " && " + // ⚠️ This simple implementation does not handle parentheses. + script.split(" && ").map(_.trim).toSeq + } + + /** HELPER: Extracts the metric names from a condition Example: "params.ingredient_count >= 3" => + * Seq("ingredient_count") + */ + private def extractMetricNames(condition: String): Seq[String] = { + // Pattern to extract "params.XXX" + val pattern = "params\\.([a-zA-Z_][a-zA-Z0-9_]*)".r + pattern.findAllMatchIn(condition).map(_.group(1)).toSeq + } + + // HELPER: Check if a path is a direct child + private def isDirectChild(childPath: String, parentPath: String): Boolean = { + if (parentPath.isEmpty) { + childPath.nonEmpty && !childPath.contains(">") + } else { + childPath.startsWith(parentPath + ">") && + childPath.count(_ == '>') == parentPath.count(_ == '>') + 1 + } + } + + /** Extracts the buckets_path for a given bucket + */ + def extractMetricsPathForBucket( + criteria: Criteria, + nested: Option[NestedElement], + allElasticAggregations: Seq[ElasticAggregation] + ): Map[String, String] = { + + val currentBucketPath = nested.map(_.bucketPath).getOrElse("") + + // Extract ALL metrics paths + val allMetricsPaths = criteria.extractAllMetricsPath + + // println(s"[DEBUG extractMetricsPath] currentBucketPath = $currentBucketPath") + // println(s"[DEBUG extractMetricsPath] allMetricsPaths = $allMetricsPaths") + + // Filter and adapt the paths for this bucket + val result = allMetricsPaths.flatMap { case (metricName, metricPath) => + allElasticAggregations.find(agg => + agg.aggName == metricName || agg.field == metricName + ) match { + case Some(elasticAgg) => + val metricBucketPath = elasticAgg.nestedElement + .map(_.bucketPath) + .getOrElse("") + + // println( + // s"[DEBUG extractMetricsPath] metricName = $metricName, metricBucketPath = $metricBucketPath, aggType = ${elasticAgg.agg.getClass.getSimpleName}" + // ) + + if (metricBucketPath == currentBucketPath) { + // Metric of the same level + // println(s"[DEBUG extractMetricsPath] Same level: $metricName -> $metricName") + Some(metricName -> metricName) + + } else if (isDirectChild(metricBucketPath, currentBucketPath)) { + // Metric of a direct child + + // CHECK if it is a "global" metric (cardinality, etc.) or a bucket metric (avg, sum, etc.) + val isGlobalMetric = elasticAgg.isGlobalMetric + + if (isGlobalMetric) { + // Global metric: can be referenced from the parent + val childNestedName = elasticAgg.nestedElement + .map(_.innerHitsName) + .getOrElse("") + // println( + // s"[DEBUG extractMetricsPath] Direct child (global metric): $metricName -> $childNestedName>$metricName" + // ) + Some(metricName -> s"$childNestedName>$metricName") + } else { + // Bucket metric: cannot be referenced from the parent + // println( + // s"[DEBUG extractMetricsPath] Direct child (bucket metric): $metricName -> SKIP (bucket-level metric)" + // ) + None + } + + } else { + // A different level of metric + // println(s"[DEBUG extractMetricsPath] Other level: $metricName -> SKIP") + None + } + + case None => + // println(s"[DEBUG extractMetricsPath] Not found: $metricName -> SKIP") + None + } + } + + // println(s"[DEBUG extractMetricsPath] result = $result") + result + } } diff --git a/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala b/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala index a08d5068..ba3d7dae 100644 --- a/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala +++ b/es6/bridge/src/main/scala/app/softnetwork/elastic/sql/bridge/package.scala @@ -27,8 +27,13 @@ import com.sksamuel.elastic4s.http.ElasticDsl.BuildableTermsNoOp import com.sksamuel.elastic4s.http.search.SearchBodyBuilderFn import com.sksamuel.elastic4s.script.Script import com.sksamuel.elastic4s.script.ScriptType.Source -import com.sksamuel.elastic4s.searches.aggs.{Aggregation, FilterAggregation, NestedAggregation} -import com.sksamuel.elastic4s.searches.queries.{InnerHit, Query} +import com.sksamuel.elastic4s.searches.aggs.{ + Aggregation, + FilterAggregation, + NestedAggregation, + TermsAggregation +} +import com.sksamuel.elastic4s.searches.queries.{BoolQuery, InnerHit, Query} import com.sksamuel.elastic4s.searches.{MultiSearchRequest, SearchRequest} import com.sksamuel.elastic4s.searches.sort.FieldSort @@ -48,12 +53,17 @@ package object bridge { case cs => val boolQuery = ElasticBoolQuery(group = true) cs.map(c => boolQuery.filter(c.asFilter(Option(boolQuery)))) - Some( - boolQuery.query( - request.aggregates.flatMap(_.identifier.innerHitsName).toSet, - Option(boolQuery) - ) - ) + boolQuery.query( + request.aggregates.flatMap(_.identifier.innerHitsName).toSet, + Option(boolQuery) + ) match { + case q: BoolQuery + if q.filters.forall(p => + p == matchAllQuery() + ) && q.must.isEmpty && q.should.isEmpty && q.not.isEmpty => + None + case other => Some(other) + } } case _ => None @@ -66,12 +76,17 @@ package object bridge { case cs => val boolQuery = ElasticBoolQuery(group = true) cs.map(c => boolQuery.filter(c.asFilter(Option(boolQuery)))) - Some( - boolQuery.query( - request.aggregates.flatMap(_.identifier.innerHitsName).toSet, - Option(boolQuery) - ) - ) + boolQuery.query( + request.aggregates.flatMap(_.identifier.innerHitsName).toSet, + Option(boolQuery) + ) match { + case q: BoolQuery + if q.filters.forall(p => + p == matchAllQuery() + ) && q.must.isEmpty && q.should.isEmpty && q.not.isEmpty => + None + case other => Some(other) + } } case _ => None @@ -121,28 +136,44 @@ package object bridge { } implicit def requestToRootAggregations( - request: SQLSearchRequest + request: SQLSearchRequest, + aggregations: Seq[ElasticAggregation] ): Seq[Aggregation] = { - val aggregations = request.aggregates.map( - ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) - ) - val notNestedAggregations = aggregations.filterNot(_.nested) + val notNestedBuckets = request.buckets.filterNot(_.nested) + val rootAggregations = notNestedAggregations match { - case Nil => Nil + case Nil => + val buckets = ElasticAggregation.buildBuckets( + notNestedBuckets, + request.sorts, + Seq.empty, + Map.empty, + request.having.flatMap(_.criteria), + None, + aggregations + ) match { + case Some(b) => Seq(b) + case _ => Seq.empty + } + buckets case aggs => val directions: Map[String, SortOrder] = aggs .filter(_.direction.isDefined) .map(agg => agg.agg.name -> agg.direction.get) .toMap + val aggregations = aggs.map(_.agg) + val buckets = ElasticAggregation.buildBuckets( - request.buckets.filterNot(_.nested), + notNestedBuckets, request.sorts -- directions.keys, aggregations, directions, - request.having.flatMap(_.criteria) + request.having.flatMap(_.criteria), + None, + aggs ) match { case Some(b) => Seq(b) case _ => aggregations @@ -153,12 +184,10 @@ package object bridge { } implicit def requestToScopedAggregations( - request: SQLSearchRequest + request: SQLSearchRequest, + aggregations: Seq[ElasticAggregation] ): Seq[NestedAggregation] = { - val aggregations = request.aggregates.map( - ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) - ) - + // Group nested aggregations by their nested path val nestedAggregations: Map[String, Seq[ElasticAggregation]] = aggregations .filter(_.nested) .groupBy( @@ -169,6 +198,7 @@ package object bridge { ) ) + // Group nested buckets by their nested path val nestedGroupedBuckets = request.buckets .filter(_.nested) @@ -180,16 +210,36 @@ package object bridge { ) ) + // Having criteria or none val havingCriteria = request.having.flatMap(_.criteria) + // Build nested aggregations val scopedAggregations = NestedElements .buildNestedTrees( nestedAggregations.values.flatMap(_.flatMap(_.nestedElement)).toSeq.distinct ) - .map { tree => + .map { tree => // For each nested tree, build the nested aggregation def buildNestedAgg(n: NestedElement): NestedAggregation = { + // Get the aggregations for this nested path val elasticAggregations = nestedAggregations.getOrElse(n.path, Seq.empty) - val aggregations = elasticAggregations.map(_.agg) + + // Get the buckets for this nested element + val nestedBuckets = + nestedGroupedBuckets.getOrElse(n.innerHitsName, Seq.empty) + + val notRelatedAggregationsToBuckets = elasticAggregations + .filterNot { ea => + nestedBuckets.exists(nb => nb.identifier.path == ea.sourceField) + } + .map(_.agg) + + val relatedAggregationsToBuckets = elasticAggregations + .filter { ea => + nestedBuckets.exists(nb => nb.identifier.path == ea.sourceField) + } + .map(_.agg) + + // Get the directions for this nested aggregation val directions: Map[String, SortOrder] = elasticAggregations .filter(_.direction.isDefined) @@ -197,20 +247,26 @@ package object bridge { elasticAggregation.agg.name -> elasticAggregation.direction.getOrElse(Asc) ) .toMap + + // Build filter aggregation for this nested aggregation + val nestedFilteredAgg: Option[FilterAggregation] = + requestToNestedFilterAggregation(request, n.innerHitsName) + + // Build buckets for this nested aggregation val buckets: Seq[Aggregation] = ElasticAggregation.buildBuckets( - nestedGroupedBuckets - .getOrElse(n.innerHitsName, Seq.empty), + nestedBuckets, request.sorts -- directions.keys, - aggregations, + notRelatedAggregationsToBuckets, directions, - havingCriteria + havingCriteria, + Some(n), + aggregations ) match { case Some(b) => Seq(b) - case _ => aggregations + case _ => notRelatedAggregationsToBuckets } - val nestedFilteredAgg: Option[FilterAggregation] = - requestToNestedFilterAggregation(request, n.innerHitsName) + val children = n.children if (children.nonEmpty) { val innerAggs = children.map(buildNestedAgg) @@ -221,13 +277,23 @@ package object bridge { agg1.copy(subaggs = agg1.subaggs ++ Seq(agg2)) } } + val combinedBuckets = + buckets match { + case Nil => Seq(combinedAgg) + case b if b.size == 1 => + addNestedAggregationsToTermsAggregation(b.head, Seq(combinedAgg)) match { + case Some(updated) => Seq(updated) + case _ => b ++ Seq(combinedAgg) + } + case _ => buckets ++ Seq(combinedAgg) + } nestedAggregation( n.innerHitsName, n.path ) subaggs (nestedFilteredAgg match { case Some(filteredAgg) => - Seq(filteredAgg subaggs buckets ++ Seq(combinedAgg)) - case _ => buckets ++ Seq(combinedAgg) + Seq(filteredAgg subaggs relatedAggregationsToBuckets ++ combinedBuckets) + case _ => relatedAggregationsToBuckets ++ combinedBuckets }) } else { nestedAggregation( @@ -235,8 +301,8 @@ package object bridge { n.path ) subaggs (nestedFilteredAgg match { case Some(filteredAgg) => - Seq(filteredAgg subaggs buckets) - case _ => buckets + Seq(filteredAgg subaggs relatedAggregationsToBuckets ++ buckets) + case _ => relatedAggregationsToBuckets ++ buckets }) } } @@ -302,6 +368,29 @@ package object bridge { } } + private def addNestedAggregationsToTermsAggregation( + agg: Aggregation, + nested: Seq[NestedAggregation] + ): Option[TermsAggregation] = { + agg match { + case termsAgg: TermsAggregation => + termsAgg.subaggs.find(subAgg => + subAgg match { + case _: TermsAggregation => true + case _ => false + } + ) match { + case Some(t: TermsAggregation) => + val ret = addNestedAggregationsToTermsAggregation(t, nested) + val updated = + termsAgg.copy(subaggs = termsAgg.subaggs.filterNot(_ == t) ++ ret.toList) + Some(updated) + case _ => Some(termsAgg subaggs termsAgg.subaggs ++ nested) + } + case _ => None + } + } + implicit def requestToElasticSearchRequest(request: SQLSearchRequest): ElasticSearchRequest = ElasticSearchRequest( request.select.fields, @@ -320,11 +409,26 @@ package object bridge { implicit def requestToSearchRequest(request: SQLSearchRequest): SearchRequest = { import request._ - val rootAggregations = requestToRootAggregations(request) + val aggregations = request.aggregates.map( + ElasticAggregation(_, request.having.flatMap(_.criteria), request.sorts) + ) + + val rootAggregations = requestToRootAggregations(request, aggregations) - val scopedAggregations = requestToScopedAggregations(request) + val scopedAggregations = requestToScopedAggregations(request, aggregations) - val aggregations = rootAggregations ++ scopedAggregations + val allAggregations = { + rootAggregations match { + case Nil => scopedAggregations + case r if r.size == 1 => + addNestedAggregationsToTermsAggregation(r.head, scopedAggregations) match { + case Some(agg) => + Seq(agg) + case _ => r ++ scopedAggregations + } + case _ => rootAggregations ++ scopedAggregations + } + } val nestedWithoutCriteriaQuery: Option[Query] = requestToNestedWithoutCriteriaQuery(request) @@ -341,9 +445,9 @@ package object bridge { } } sourceFiltering (fields, excludes) - _search = if (aggregations.nonEmpty) { + _search = if (allAggregations.nonEmpty) { _search aggregations { - aggregations + allAggregations } } else { _search @@ -379,7 +483,7 @@ package object bridge { case _ => _search } - if (aggregations.nonEmpty || buckets.nonEmpty) { + if (allAggregations.nonEmpty || buckets.nonEmpty) { _search size 0 } else { limit match { diff --git a/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala b/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala index 88c3501e..112aec2c 100644 --- a/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala +++ b/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala @@ -6,8 +6,6 @@ import app.softnetwork.elastic.sql.query.SQLQuery import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers -import scala.jdk.CollectionConverters._ - /** Created by smanciot on 13/04/17. */ class SQLQuerySpec extends AnyFlatSpec with Matchers { @@ -795,7 +793,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { | "aggs": { | "cat": { | "terms": { - | "field": "products.category.keyword" + | "field": "products.category.keyword", + | "size": 10 | }, | "aggs": { | "min_price": { @@ -811,8 +810,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { | "having_filter": { | "bucket_selector": { | "buckets_path": { - | "min_price": "inner_products>min_price", - | "max_price": "inner_products>max_price" + | "min_price": "min_price", + | "max_price": "max_price" | }, | "script": { | "source": "params.min_price > 5.0 && params.max_price < 50.0" diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/function/package.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/function/package.scala index 972b9989..0daf4517 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/function/package.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/function/package.scala @@ -20,6 +20,7 @@ import app.softnetwork.elastic.sql.`type`.{SQLType, SQLTypeUtils, SQLTypes} import app.softnetwork.elastic.sql.function.aggregate.AggregateFunction import app.softnetwork.elastic.sql.operator.math.ArithmeticExpression import app.softnetwork.elastic.sql.parser.Validator +import app.softnetwork.elastic.sql.query.SQLSearchRequest package object function { @@ -128,6 +129,14 @@ package object function { def indexOf(function: Function): Int = { functions.indexOf(function) } + + def updateFunctions(request: SQLSearchRequest): List[Function] = { + functions.map { + case f: Updateable => + f.update(request).asInstanceOf[Function] + case f => f + } + } } trait FunctionN[In <: SQLType, Out <: SQLType] extends Function with PainlessScript { diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/package.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/package.scala index e7ac14f1..bac4accb 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/package.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/package.scala @@ -621,13 +621,11 @@ package object sql { def fieldAlias: Option[String] def bucket: Option[Bucket] def hasBucket: Boolean = bucket.isDefined - def metricsPath: Map[String, String] = { // TODO add bucket context ? + + def allMetricsPath: Map[String, String] = { if (aggregation) { val metricName = aliasOrName - nestedElement match { - case Some(ne) => Map(metricName -> s"${ne.bucketPath}>$metricName") - case _ => Map(metricName -> metricName) - } + Map(metricName -> metricName) } else { Map.empty } @@ -820,50 +818,59 @@ package object sql { def update(request: SQLSearchRequest): Identifier = { val parts: Seq[String] = name.split("\\.").toSeq - if (request.tableAliases.values.toSeq.contains(parts.head)) { - request.unnestAliases.find(_._1 == parts.head) match { + val tableAlias = parts.head + if (request.tableAliases.values.toSeq.contains(tableAlias)) { + request.unnestAliases.find(_._1 == tableAlias) match { case Some(tuple) if !nested => - this.copy( - tableAlias = Some(parts.head), - name = s"${tuple._2._1}.${parts.tail.mkString(".")}", - nested = true, - limit = tuple._2._2, - fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), - bucket = request.bucketNames.get(identifierName).orElse(bucket), - nestedElement = { - request.unnests.get(parts.head) match { - case Some(unnest) => Some(request.toNestedElement(unnest)) - case None => None - } + val nestedElement = + request.unnests.get(tableAlias) match { + case Some(unnest) => Some(request.toNestedElement(unnest)) + case None => None } - ) + this + .copy( + tableAlias = Some(tableAlias), + name = s"${tuple._2._1}.${parts.tail.mkString(".")}", + nested = true, + limit = tuple._2._2, + fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), + bucket = request.bucketNames.get(identifierName).orElse(bucket), + nestedElement = nestedElement + ) + .withFunctions(this.updateFunctions(request)) case Some(tuple) if nested => - this.copy( - tableAlias = Some(parts.head), - name = s"${tuple._2._1}.${parts.tail.mkString(".")}", - limit = tuple._2._2, - fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), - bucket = request.bucketNames.get(identifierName).orElse(bucket) - ) + this + .copy( + tableAlias = Some(tableAlias), + name = s"${tuple._2._1}.${parts.tail.mkString(".")}", + limit = tuple._2._2, + fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), + bucket = request.bucketNames.get(identifierName).orElse(bucket) + ) + .withFunctions(this.updateFunctions(request)) case None if nested => - this.copy( - tableAlias = Some(parts.head), - fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), - bucket = request.bucketNames.get(identifierName).orElse(bucket) - ) + this + .copy( + tableAlias = Some(tableAlias), + fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), + bucket = request.bucketNames.get(identifierName).orElse(bucket) + ) + .withFunctions(this.updateFunctions(request)) case _ => this.copy( - tableAlias = Some(parts.head), + tableAlias = Some(tableAlias), name = parts.tail.mkString("."), fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), bucket = request.bucketNames.get(identifierName).orElse(bucket) ) } } else { - this.copy( - fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), - bucket = request.bucketNames.get(identifierName).orElse(bucket) - ) + this + .copy( + fieldAlias = request.fieldAliases.get(identifierName).orElse(fieldAlias), + bucket = request.bucketNames.get(identifierName).orElse(bucket) + ) + .withFunctions(this.updateFunctions(request)) } } } diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala index eb0db185..e4b1763f 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala @@ -98,8 +98,10 @@ case class Unnest( updated.identifier.tableAlias match { case Some(alias) if updated.identifier.nested => request.unnests.get(alias) match { - case Some(parent) if parent.path != updated.path => - return updated.copy(parent = Some(parent)) + case Some(parent) /*if parent.path != updated.path*/ => + val unnest = updated.copy(parent = Some(parent)) + request.unnests += unnest.alias.map(_.alias).getOrElse(unnest.name) -> unnest + return unnest case _ => } case _ => @@ -233,11 +235,17 @@ object NestedElements { val distinctNestedElements = nestedElements.groupBy(_.path).map(_._2.head).toList + val distinctNestedElementsByRoot = + distinctNestedElements + .groupBy(_.root.path) + .map(tree => tree._1 -> tree._2.sortBy(_.level).reverse) + .toMap + @tailrec def getNestedParents( n: NestedElement, parents: Seq[NestedElement] - ): Seq[NestedElement] = { + ): NestedElement = { n.parent match { case Some(p) => if (!nestedParentsPath.contains(p.path)) { @@ -246,28 +254,31 @@ object NestedElements { getNestedParents(p, p +: parents) } else { nestedParentsPath += p.path -> (p, nestedParentsPath(p.path)._2 :+ n) - parents + p } - case _ => parents + case _ => n } } - val deepestNestedElement = - distinctNestedElements.maxBy(_.level) // FIXME we may have multiple deepest elements - val nestedParents = getNestedParents(deepestNestedElement, Seq.empty) + val nestedParents = + distinctNestedElementsByRoot.values.flatten + .map(de => getNestedParents(de, Seq.empty)) + .toSeq + .distinct def innerBuildNestedTree(n: NestedElement): NestedElement = { val children = nestedParentsPath.get(n.path).map(_._2).getOrElse(Seq.empty) if (children.nonEmpty) { val updatedChildren = children.map(innerBuildNestedTree) - n.copy(children = updatedChildren) + n.copy(children = updatedChildren.groupBy(_.path).map(_._2.head).toSeq) } else { n } } if (nestedParents.nonEmpty) { - nestedParents.map(innerBuildNestedTree) + val trees = nestedParents.map(innerBuildNestedTree) + trees } else { distinctNestedElements } diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/GroupBy.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/GroupBy.scala index e619614e..9544f8a3 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/GroupBy.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/GroupBy.scala @@ -60,7 +60,8 @@ case class Bucket( val field = request.select.fields(func.value.toInt - 1) this.copy(identifier = field.identifier, size = request.limit.map(_.limit)) } - case _ => this.copy(identifier = identifier.update(request)) + case _ => + this.copy(identifier = identifier.update(request), size = request.limit.map(_.limit)) } } @@ -78,47 +79,51 @@ case class Bucket( identifier.nestedElement.map(_.innerHitsName) lazy val name: String = identifier.fieldAlias.getOrElse(sourceBucket.replace(".", "_")) + + lazy val bucketPath: String = { + identifier.nestedElement match { + case Some(ne) => ne.bucketPath + case None => "" // Root level + } + } } object MetricSelectorScript { - def extractMetricsPath(criteria: Criteria): Map[String, String] = criteria match { - case Predicate(left, _, right, _, _) => - extractMetricsPath(left) ++ extractMetricsPath(right) - case relation: ElasticRelation => extractMetricsPath(relation.criteria) - case _: MultiMatchCriteria => Map.empty //MATCH is not supported in bucket_selector - case e: Expression if e.aggregation => - import e._ - maybeValue match { - case Some(v: Identifier) => identifier.metricsPath ++ v.metricsPath - case _ => identifier.metricsPath - } - case _ => Map.empty - } - def metricSelector(expr: Criteria): String = expr match { case Predicate(left, op, right, maybeNot, group) => val leftStr = metricSelector(left) val rightStr = metricSelector(right) - val opStr = op match { - case AND | OR => op.painless(None) - case _ => throw new IllegalArgumentException(s"Unsupported logical operator: $op") + + // Filtering all "1 == 1" + if (leftStr == "1 == 1" && rightStr == "1 == 1") { + "1 == 1" + } else if (leftStr == "1 == 1") { + rightStr + } else if (rightStr == "1 == 1") { + leftStr + } else { + val opStr = op match { + case AND | OR => op.painless(None) + case _ => throw new IllegalArgumentException(s"Unsupported logical operator: $op") + } + val not = maybeNot.nonEmpty + if (group || not) + s"${maybeNot.map(_ => "!").getOrElse("")}($leftStr) $opStr ($rightStr)" + else + s"$leftStr $opStr $rightStr" } - val not = maybeNot.nonEmpty - if (group || not) - s"${maybeNot.map(_ => "!").getOrElse("")}($leftStr) $opStr ($rightStr)" - else - s"$leftStr $opStr $rightStr" case relation: ElasticRelation => metricSelector(relation.criteria) - case _: MultiMatchCriteria => "1 == 1" //MATCH is not supported in bucket_selector + case _: MultiMatchCriteria => "1 == 1" case e: Expression if e.aggregation => + // NO FILTERING: the script is generated for all metrics val painless = e.painless(None) e.maybeValue match { case Some(value) if e.operator.isInstanceOf[ComparisonOperator] => - value.out match { // compare epoch millis + value.out match { case SQLTypes.Date => s"$painless.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli()" case SQLTypes.Time if e.operator.isInstanceOf[ComparisonOperator] => @@ -129,8 +134,7 @@ object MetricSelectorScript { } case _ => painless } - - case _ => "1 == 1" //throw new IllegalArgumentException(s"Unsupported SQLCriteria type: $expr") + case _ => "1 == 1" } } diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/SQLSearchRequest.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/SQLSearchRequest.scala index 4d52aca3..15c174b4 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/SQLSearchRequest.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/SQLSearchRequest.scala @@ -48,8 +48,12 @@ case class SQLSearchRequest( case _ => Map(name -> b) } }.toMap - lazy val unnests: Map[String, Unnest] = - from.unnests.map(u => u.alias.map(_.alias).getOrElse(u.name) -> u).toMap + + var unnests: scala.collection.mutable.Map[String, Unnest] = { + val map = from.unnests.map(u => u.alias.map(_.alias).getOrElse(u.name) -> u).toMap + scala.collection.mutable.Map(map.toSeq: _*) + } + lazy val nestedFields: Map[String, Seq[Field]] = select.fields .filterNot(_.aggregation) @@ -75,16 +79,18 @@ case class SQLSearchRequest( nested.filter(n => nestedFieldsWithoutCriteria.keys.toSeq.contains(n.innerHitsName)) def toNestedElement(u: Unnest): NestedElement = { + val updated = unnests.getOrElse(u.alias.map(_.alias).getOrElse(u.name), u) + val parent = updated.parent.map(toNestedElement) NestedElement( - path = u.path, - innerHitsName = u.innerHitsName, + path = updated.path, + innerHitsName = updated.innerHitsName, size = limit.map(_.limit), children = Nil, sources = nestedFields - .get(u.innerHitsName) + .get(updated.innerHitsName) .map(_.map(_.identifier.name.split('.').tail.mkString("."))) .getOrElse(Nil), - parent = u.parent.map(toNestedElement) + parent = parent ) } diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/Select.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/Select.scala index 15d7df81..6499405a 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/Select.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/Select.scala @@ -177,17 +177,6 @@ object SQLAggregation { require(aggFuncs.size == 1, s"Multiple aggregate functions not supported: $aggFuncs") - val filteredAggName = "filtered_agg" - - def filtered(): Unit = - request.having match { - case Some(_) => - aggPath ++= Seq(filteredAggName) - aggPath ++= Seq(aggName) - case _ => - aggPath ++= Seq(aggName) - } - val nestedElement = identifier.nestedElement val nestedElements: Seq[NestedElement] = @@ -195,6 +184,7 @@ object SQLAggregation { nestedElements match { case Nil => + aggPath ++= Seq(aggName) case nestedElements => def buildNested(n: NestedElement): Unit = { aggPath ++= Seq(n.innerHitsName) @@ -203,11 +193,15 @@ object SQLAggregation { children.map(buildNested) } } - buildNested(nestedElements.head) + val root = nestedElements.head + buildNested(root) + request.having match { + case Some(_) => aggPath ++= Seq("filtered_agg") + case _ => + } + aggPath ++= Seq(aggName) } - filtered() - SQLAggregation( aggPath.mkString("."), _field, diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/Where.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/Where.scala index 3e11657b..939ec421 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/Where.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/Where.scala @@ -63,13 +63,13 @@ sealed trait Criteria extends Updateable with PainlessScript { } } - def extractMetricsPath: Map[String, String] = - this match { // used for bucket_selector + def extractAllMetricsPath: Map[String, String] = + this match { case Predicate(left, _, right, _, _) => - left.extractMetricsPath ++ right.extractMetricsPath - case relation: ElasticRelation => relation.criteria.extractMetricsPath - case _: MultiMatchCriteria => Map.empty //MATCH is not supported in bucket_selector - case e: Expression => e.extractMetricsPath + left.extractAllMetricsPath ++ right.extractAllMetricsPath + case relation: ElasticRelation => relation.criteria.extractAllMetricsPath + case _: MultiMatchCriteria => Map.empty + case e: Expression => e.extractAllMetricsPath case _ => Map.empty } @@ -291,10 +291,12 @@ sealed trait Expression extends FunctionChain with ElasticFilter with Criteria { case _ => Seq(identifier) } - override def extractMetricsPath: Map[String, String] = maybeValue match { - case Some(v: Identifier) => identifier.metricsPath ++ v.metricsPath - case _ => identifier.metricsPath - } + override def extractAllMetricsPath: Map[String, String] = + maybeValue match { + case Some(v: Identifier) => + identifier.allMetricsPath ++ v.allMetricsPath + case _ => identifier.allMetricsPath + } override def includes( bucket: Bucket, From e05045c251d630b8d8528a9dd626fe370e380b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 18 Nov 2025 12:56:34 +0100 Subject: [PATCH 2/3] to fix warnings --- .../test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala | 4 ++-- .../test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala | 2 +- .../main/scala/app/softnetwork/elastic/sql/query/From.scala | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala b/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala index 3f4c5d37..8a41ae9d 100644 --- a/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala +++ b/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala @@ -212,7 +212,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { val result = results.head result.nested shouldBe true result.distinct shouldBe false - result.aggName shouldBe "inner_emails.filtered_inner_emails.count_emails" + result.aggName shouldBe "inner_emails.filtered_agg.count_emails" result.field shouldBe "count_emails" result.sources shouldBe Seq[String]("index") val query = result.query.getOrElse("") @@ -2075,7 +2075,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { .replaceAll(",LocalDate", ", LocalDate") .replaceAll("=DateTimeFormatter", " = DateTimeFormatter") .replaceAll("try\\{", "try {") - .replaceAll("\\}catch", "} catch ") + .replaceAll("}catch", "} catch ") .replaceAll("Exceptione\\)", "Exception e) ") .replaceAll(",DateTimeFormatter", ", DateTimeFormatter") .replaceAll("(\\d)=p", "$1 = p") diff --git a/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala b/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala index 112aec2c..d28b409f 100644 --- a/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala +++ b/es6/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala @@ -2071,7 +2071,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers { .replaceAll(",LocalDate", ", LocalDate") .replaceAll("=DateTimeFormatter", " = DateTimeFormatter") .replaceAll("try\\{", "try {") - .replaceAll("\\}catch", "} catch ") + .replaceAll("}catch", "} catch ") .replaceAll("Exceptione\\)", "Exception e) ") .replaceAll(",DateTimeFormatter", ", DateTimeFormatter") .replaceAll("(\\d)=p", "$1 = p") diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala index e4b1763f..25df23ac 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/From.scala @@ -239,7 +239,6 @@ object NestedElements { distinctNestedElements .groupBy(_.root.path) .map(tree => tree._1 -> tree._2.sortBy(_.level).reverse) - .toMap @tailrec def getNestedParents( From 631cec2ae0370b0dd03f833409cc6d109f04e937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 18 Nov 2025 13:48:55 +0100 Subject: [PATCH 3/3] update README --- README.md | 353 +++++++++++++++++++++--------------------------------- 1 file changed, 136 insertions(+), 217 deletions(-) diff --git a/README.md b/README.md index 34ba0bcc..db696f36 100644 --- a/README.md +++ b/README.md @@ -216,39 +216,29 @@ SoftClient4ES includes a powerful SQL parser that translates standard SQL `SELEC ```scala val sqlQuery = """ SELECT - min(inner_products.price) as min_price, - max(inner_products.price) as max_price - FROM - stores store - JOIN UNNEST(store.products) as inner_products - WHERE - ( - firstName is not null AND - lastName is not null AND - description is not null AND - preparationTime <= 120 AND - store.deliveryPeriods.dayOfWeek=6 AND - blockedCustomers not like "%uuid%" AND - NOT receiptOfOrdersDisabled=true AND - ( - distance(pickup.location, POINT(0.0, 0.0)) <= 7000 m OR - distance(withdrawals.location, POINT(0.0, 0.0)) <= 7000 m - ) - ) - GROUP BY - inner_products.category - HAVING inner_products.deleted=false AND - inner_products.upForSale=true AND - inner_products.stock > 0 AND - match ( - inner_products.name, - inner_products.description, - inner_products.ingredients - ) against ("lasagnes") AND - min(inner_products.price) > 5.0 AND - max(inner_products.price) < 50.0 AND - inner_products.category <> "coffee" - LIMIT 10 OFFSET 0 + restaurant_name, + restaurant_city, + menu.category AS menu_category, + dish.name AS dish_name, + ingredient.name AS ingredient_name, + COUNT(distinct ingredient.name) AS ingredient_count, + AVG(ingredient.cost) AS avg_ingredient_cost, + SUM(ingredient.calories) AS total_calories, + AVG(dish.price) AS avg_dish_price + FROM restaurants + JOIN UNNEST(restaurants.menus) AS menu + JOIN UNNEST (menu.dishes) AS dish + JOIN UNNEST(dish.ingredients) AS ingredient + WHERE + restaurant_status = 'open' + GROUP BY restaurant_name, restaurant_city, menu.category, dish.name, ingredient.name + HAVING + menu.is_available = true + AND COUNT(distinct ingredient.name) >= 3 + AND AVG(ingredient.cost) <= 5 + AND SUM(ingredient.calories) <= 800 + AND AVG(dish.price) >= 10 + LIMIT 1000 """ val results = client.search(SQLQuery(sqlQuery)) @@ -260,200 +250,130 @@ val results = client.search(SQLQuery(sqlQuery)) "bool": { "filter": [ { - "bool": { - "filter": [ - { - "exists": { - "field": "firstName" - } - }, - { - "exists": { - "field": "lastName" - } - }, - { - "exists": { - "field": "description" - } - }, - { - "range": { - "preparationTime": { - "lte": 120 - } - } - }, - { - "term": { - "deliveryPeriods.dayOfWeek": { - "value": 6 - } - } - }, - { - "bool": { - "must_not": [ - { - "regexp": { - "blockedCustomers": { - "value": ".*uuid.*" - } - } - } - ] - } - }, - { - "bool": { - "must_not": [ - { - "term": { - "receiptOfOrdersDisabled": { - "value": true - } - } - } - ] - } - }, - { - "bool": { - "should": [ - { - "geo_distance": { - "distance": "7000m", - "pickup.location": [ - 0.0, - 0.0 - ] - } - }, - { - "geo_distance": { - "distance": "7000m", - "withdrawals.location": [ - 0.0, - 0.0 - ] - } - } - ] - } - } - ] + "term": { + "restaurant_status": { + "value": "open" + } } } ] } }, "size": 0, - "min_score": 1.0, "_source": true, "aggs": { - "inner_products": { - "nested": { - "path": "products" + "restaurant_name": { + "terms": { + "field": "restaurant_name.keyword", + "size": 1000 }, "aggs": { - "filtered_inner_products": { - "filter": { - "bool": { - "filter": [ - { - "bool": { - "must_not": [ - { - "term": { - "products.category": { - "value": "coffee" - } - } - } - ] - } - }, - { - "match_all": {} - }, - { - "match_all": {} - }, - { - "bool": { - "should": [ - { - "match": { - "products.name": { - "query": "lasagnes" - } - } - }, - { - "match": { - "products.description": { - "query": "lasagnes" + "restaurant_city": { + "terms": { + "field": "restaurant_city.keyword", + "size": 1000 + }, + "aggs": { + "menu": { + "nested": { + "path": "menus" + }, + "aggs": { + "filtered_menu": { + "filter": { + "bool": { + "filter": [ + { + "term": { + "menus.is_available": { + "value": true + } } } + ] + } + }, + "aggs": { + "menu_category": { + "terms": { + "field": "menus.category.keyword", + "size": 1000 }, - { - "match": { - "products.ingredients": { - "query": "lasagnes" + "aggs": { + "dish": { + "nested": { + "path": "menus.dishes" + }, + "aggs": { + "dish_name": { + "terms": { + "field": "menus.dishes.name.keyword", + "size": 1000 + }, + "aggs": { + "avg_dish_price": { + "avg": { + "field": "menus.dishes.price" + } + }, + "having_filter": { + "bucket_selector": { + "buckets_path": { + "ingredient_count": "ingredient>ingredient_count", + "avg_dish_price": "avg_dish_price" + }, + "script": { + "source": "params.ingredient_count >= 3 && params.avg_dish_price >= 10" + } + } + }, + "ingredient": { + "nested": { + "path": "menus.dishes.ingredients" + }, + "aggs": { + "ingredient_count": { + "cardinality": { + "field": "menus.dishes.ingredients.name" + } + }, + "ingredient_name": { + "terms": { + "field": "menus.dishes.ingredients.name.keyword", + "size": 1000 + }, + "aggs": { + "avg_ingredient_cost": { + "avg": { + "field": "menus.dishes.ingredients.cost" + } + }, + "total_calories": { + "sum": { + "field": "menus.dishes.ingredients.calories" + } + }, + "having_filter": { + "bucket_selector": { + "buckets_path": { + "ingredient_count": "ingredient_count", + "avg_ingredient_cost": "avg_ingredient_cost", + "total_calories": "total_calories" + }, + "script": { + "source": "params.ingredient_count >= 3 && params.avg_ingredient_cost <= 5 && params.total_calories <= 800" + } + } + } + } + } + } + } + } + } } } } - ] - } - }, - { - "range": { - "products.stock": { - "gt": 0 - } - } - }, - { - "term": { - "products.upForSale": { - "value": true - } - } - }, - { - "term": { - "products.deleted": { - "value": false - } - } - } - ] - } - }, - "aggs": { - "cat": { - "terms": { - "field": "products.category.keyword" - }, - "aggs": { - "min_price": { - "min": { - "field": "products.price" - } - }, - "max_price": { - "max": { - "field": "products.price" - } - }, - "having_filter": { - "bucket_selector": { - "buckets_path": { - "min_price": "inner_products>min_price", - "max_price": "inner_products>max_price" - }, - "script": { - "source": "params.min_price > 5.0 && params.max_price < 50.0" } } } @@ -867,18 +787,18 @@ ThisBuild / resolvers ++= Seq( // For Elasticsearch 6 // Using Jest client -libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es6-jest-client" % 0.12.0 +libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es6-jest-client" % 0.13.0 // Or using Rest High Level client -libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es6-rest-client" % 0.12.0 +libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es6-rest-client" % 0.13.0 // For Elasticsearch 7 -libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es7-rest-client" % 0.12.0 +libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es7-rest-client" % 0.13.0 // For Elasticsearch 8 -libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es8-java-client" % 0.12.0 +libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es8-java-client" % 0.13.0 // For Elasticsearch 9 -libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es9-java-client" % 0.12.0 +libraryDependencies += "app.softnetwork.elastic" %% s"softclient4es9-java-client" % 0.13.0 ``` ### **Quick Example** @@ -923,7 +843,6 @@ client.createIndex("users", mapping) match { ### **Long-term** - [ ] Full **JDBC connector for Elasticsearch** -- [ ] GraphQL query support - [ ] Advanced monitoring and metrics ---