From 502f0ecc9ad5d4b89912ebe310d82366a9727105 Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Wed, 6 Nov 2024 08:35:26 +0800 Subject: [PATCH 01/26] [followup] Refactor JSON function and add TO_JSON_STRING, ARRAY_LENGHT functions (#870) Signed-off-by: Lantao Jin --- docs/ppl-lang/functions/ppl-json.md | 73 +++++++++++++++---- .../FlintSparkPPLJsonFunctionITSuite.scala | 38 +++++----- .../src/main/antlr4/OpenSearchPPLLexer.g4 | 2 + .../src/main/antlr4/OpenSearchPPLParser.g4 | 2 + .../function/BuiltinFunctionName.java | 2 + .../ppl/utils/BuiltinFunctionTransformer.java | 22 ++---- ...PlanJsonFunctionsTranslatorTestSuite.scala | 20 +++-- 7 files changed, 100 insertions(+), 59 deletions(-) diff --git a/docs/ppl-lang/functions/ppl-json.md b/docs/ppl-lang/functions/ppl-json.md index 1953e8c70..5b26ee427 100644 --- a/docs/ppl-lang/functions/ppl-json.md +++ b/docs/ppl-lang/functions/ppl-json.md @@ -4,11 +4,11 @@ **Description** -`json(value)` Evaluates whether a value can be parsed as JSON. Returns the json string if valid, null otherwise. +`json(value)` Evaluates whether a string can be parsed as JSON format. Returns the string value if valid, null otherwise. -**Argument type:** STRING/JSON_ARRAY/JSON_OBJECT +**Argument type:** STRING -**Return type:** STRING +**Return type:** STRING/NULL A STRING expression of a valid JSON object format. @@ -47,7 +47,7 @@ A StructType expression of a valid JSON object. Example: - os> source=people | eval result = json(json_object('key', 123.45)) | fields result + os> source=people | eval result = json_object('key', 123.45) | fields result fetched rows / total rows = 1/1 +------------------+ | result | @@ -55,7 +55,7 @@ Example: | {"key":123.45} | +------------------+ - os> source=people | eval result = json(json_object('outer', json_object('inner', 123.45))) | fields result + os> source=people | eval result = json_object('outer', json_object('inner', 123.45)) | fields result fetched rows / total rows = 1/1 +------------------------------+ | result | @@ -81,13 +81,13 @@ Example: os> source=people | eval `json_array` = json_array(1, 2, 0, -1, 1.1, -0.11) fetched rows / total rows = 1/1 - +----------------------------+ - | json_array | - +----------------------------+ - | 1.0,2.0,0.0,-1.0,1.1,-0.11 | - +----------------------------+ + +------------------------------+ + | json_array | + +------------------------------+ + | [1.0,2.0,0.0,-1.0,1.1,-0.11] | + +------------------------------+ - os> source=people | eval `json_array_object` = json(json_object("array", json_array(1, 2, 0, -1, 1.1, -0.11))) + os> source=people | eval `json_array_object` = json_object("array", json_array(1, 2, 0, -1, 1.1, -0.11)) fetched rows / total rows = 1/1 +----------------------------------------+ | json_array_object | @@ -95,15 +95,44 @@ Example: | {"array":[1.0,2.0,0.0,-1.0,1.1,-0.11]} | +----------------------------------------+ +### `TO_JSON_STRING` + +**Description** + +`to_json_string(jsonObject)` Returns a JSON string with a given json object value. + +**Argument type:** JSON_OBJECT (Spark StructType/ArrayType) + +**Return type:** STRING + +Example: + + os> source=people | eval `json_string` = to_json_string(json_array(1, 2, 0, -1, 1.1, -0.11)) | fields json_string + fetched rows / total rows = 1/1 + +--------------------------------+ + | json_string | + +--------------------------------+ + | [1.0,2.0,0.0,-1.0,1.1,-0.11] | + +--------------------------------+ + + os> source=people | eval `json_string` = to_json_string(json_object('key', 123.45)) | fields json_string + fetched rows / total rows = 1/1 + +-----------------+ + | json_string | + +-----------------+ + | {'key', 123.45} | + +-----------------+ + + ### `JSON_ARRAY_LENGTH` **Description** -`json_array_length(jsonArray)` Returns the number of elements in the outermost JSON array. +`json_array_length(jsonArrayString)` Returns the number of elements in the outermost JSON array string. -**Argument type:** STRING/JSON_ARRAY +**Argument type:** STRING -A STRING expression of a valid JSON array format, or JSON_ARRAY object. +A STRING expression of a valid JSON array format. **Return type:** INTEGER @@ -119,6 +148,21 @@ Example: | 4 | 5 | null | +-----------+-----------+-------------+ + +### `ARRAY_LENGTH` + +**Description** + +`array_length(jsonArray)` Returns the number of elements in the outermost array. + +**Argument type:** ARRAY + +ARRAY or JSON_ARRAY object. + +**Return type:** INTEGER + +Example: + os> source=people | eval `json_array` = json_array_length(json_array(1,2,3,4)), `empty_array` = json_array_length(json_array()) fetched rows / total rows = 1/1 +--------------+---------------+ @@ -127,6 +171,7 @@ Example: | 4 | 0 | +--------------+---------------+ + ### `JSON_EXTRACT` **Description** diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJsonFunctionITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJsonFunctionITSuite.scala index 7cc0a221d..fca758101 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJsonFunctionITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJsonFunctionITSuite.scala @@ -163,30 +163,32 @@ class FlintSparkPPLJsonFunctionITSuite assert(ex.getMessage().contains("should all be the same type")) } - test("test json_array() with json()") { + test("test json_array() with to_json_tring()") { val frame = sql(s""" - | source = $testTable | eval result = json(json_array(1,2,0,-1,1.1,-0.11)) | head 1 | fields result + | source = $testTable | eval result = to_json_string(json_array(1,2,0,-1,1.1,-0.11)) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""[1.0,2.0,0.0,-1.0,1.1,-0.11]""")), frame) } - test("test json_array_length()") { + test("test array_length()") { var frame = sql(s""" - | source = $testTable | eval result = json_array_length(json_array('this', 'is', 'a', 'string', 'array')) | head 1 | fields result - | """.stripMargin) + | source = $testTable| eval result = array_length(json_array('this', 'is', 'a', 'string', 'array')) | head 1 | fields result + | """.stripMargin) assertSameRows(Seq(Row(5)), frame) frame = sql(s""" - | source = $testTable | eval result = json_array_length(json_array(1, 2, 0, -1, 1.1, -0.11)) | head 1 | fields result - | """.stripMargin) + | source = $testTable| eval result = array_length(json_array(1, 2, 0, -1, 1.1, -0.11)) | head 1 | fields result + | """.stripMargin) assertSameRows(Seq(Row(6)), frame) frame = sql(s""" - | source = $testTable | eval result = json_array_length(json_array()) | head 1 | fields result - | """.stripMargin) + | source = $testTable| eval result = array_length(json_array()) | head 1 | fields result + | """.stripMargin) assertSameRows(Seq(Row(0)), frame) + } - frame = sql(s""" + test("test json_array_length()") { + var frame = sql(s""" | source = $testTable | eval result = json_array_length('[]') | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row(0)), frame) @@ -211,24 +213,24 @@ class FlintSparkPPLJsonFunctionITSuite test("test json_object()") { // test value is a string var frame = sql(s""" - | source = $testTable| eval result = json(json_object('key', 'string_value')) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object('key', 'string_value')) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"key":"string_value"}""")), frame) // test value is a number frame = sql(s""" - | source = $testTable| eval result = json(json_object('key', 123.45)) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object('key', 123.45)) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"key":123.45}""")), frame) // test value is a boolean frame = sql(s""" - | source = $testTable| eval result = json(json_object('key', true)) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object('key', true)) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"key":true}""")), frame) frame = sql(s""" - | source = $testTable| eval result = json(json_object("a", 1, "b", 2, "c", 3)) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object("a", 1, "b", 2, "c", 3)) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"a":1,"b":2,"c":3}""")), frame) } @@ -236,13 +238,13 @@ class FlintSparkPPLJsonFunctionITSuite test("test json_object() and json_array()") { // test value is an empty array var frame = sql(s""" - | source = $testTable| eval result = json(json_object('key', array())) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object('key', array())) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"key":[]}""")), frame) // test value is an array frame = sql(s""" - | source = $testTable| eval result = json(json_object('key', array(1, 2, 3))) | head 1 | fields result + | source = $testTable| eval result = to_json_string(json_object('key', array(1, 2, 3))) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"key":[1,2,3]}""")), frame) @@ -272,14 +274,14 @@ class FlintSparkPPLJsonFunctionITSuite test("test json_object() nested") { val frame = sql(s""" - | source = $testTable | eval result = json(json_object('outer', json_object('inner', 123.45))) | head 1 | fields result + | source = $testTable | eval result = to_json_string(json_object('outer', json_object('inner', 123.45))) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"outer":{"inner":123.45}}""")), frame) } test("test json_object(), json_array() and json()") { val frame = sql(s""" - | source = $testTable | eval result = json(json_object("array", json_array(1,2,0,-1,1.1,-0.11))) | head 1 | fields result + | source = $testTable | eval result = to_json_string(json_object("array", json_array(1,2,0,-1,1.1,-0.11))) | head 1 | fields result | """.stripMargin) assertSameRows(Seq(Row("""{"array":[1.0,2.0,0.0,-1.0,1.1,-0.11]}""")), frame) } diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 index fcec4d13f..93efb2df1 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 @@ -378,6 +378,7 @@ JSON: 'JSON'; JSON_OBJECT: 'JSON_OBJECT'; JSON_ARRAY: 'JSON_ARRAY'; JSON_ARRAY_LENGTH: 'JSON_ARRAY_LENGTH'; +TO_JSON_STRING: 'TO_JSON_STRING'; JSON_EXTRACT: 'JSON_EXTRACT'; JSON_KEYS: 'JSON_KEYS'; JSON_VALID: 'JSON_VALID'; @@ -393,6 +394,7 @@ JSON_VALID: 'JSON_VALID'; // COLLECTION FUNCTIONS ARRAY: 'ARRAY'; +ARRAY_LENGTH: 'ARRAY_LENGTH'; // LAMBDA FUNCTIONS //EXISTS: 'EXISTS'; diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index b7f293a4a..06dffa55c 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -860,6 +860,7 @@ jsonFunctionName | JSON_OBJECT | JSON_ARRAY | JSON_ARRAY_LENGTH + | TO_JSON_STRING | JSON_EXTRACT | JSON_KEYS | JSON_VALID @@ -876,6 +877,7 @@ jsonFunctionName collectionFunctionName : ARRAY + | ARRAY_LENGTH ; lambdaFunctionName diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java index 13b5c20ef..1959d0f6d 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java @@ -213,6 +213,7 @@ public enum BuiltinFunctionName { JSON_OBJECT(FunctionName.of("json_object")), JSON_ARRAY(FunctionName.of("json_array")), JSON_ARRAY_LENGTH(FunctionName.of("json_array_length")), + TO_JSON_STRING(FunctionName.of("to_json_string")), JSON_EXTRACT(FunctionName.of("json_extract")), JSON_KEYS(FunctionName.of("json_keys")), JSON_VALID(FunctionName.of("json_valid")), @@ -228,6 +229,7 @@ public enum BuiltinFunctionName { /** COLLECTION Functions **/ ARRAY(FunctionName.of("array")), + ARRAY_LENGTH(FunctionName.of("array_length")), /** LAMBDA Functions **/ ARRAY_FORALL(FunctionName.of("forall")), diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java index e39c9ab38..0b0fb8314 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java @@ -28,6 +28,7 @@ import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADD; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADDDATE; +import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_LENGTH; import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATEDIFF; import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_ADD; import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_SUB; @@ -58,6 +59,7 @@ import static org.opensearch.sql.expression.function.BuiltinFunctionName.SYSDATE; import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMESTAMPADD; import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMESTAMPDIFF; +import static org.opensearch.sql.expression.function.BuiltinFunctionName.TO_JSON_STRING; import static org.opensearch.sql.expression.function.BuiltinFunctionName.TRIM; import static org.opensearch.sql.expression.function.BuiltinFunctionName.UTC_TIMESTAMP; import static org.opensearch.sql.expression.function.BuiltinFunctionName.WEEK; @@ -102,7 +104,9 @@ public interface BuiltinFunctionTransformer { .put(COALESCE, "coalesce") .put(LENGTH, "length") .put(TRIM, "trim") + .put(ARRAY_LENGTH, "array_size") // json functions + .put(TO_JSON_STRING, "to_json") .put(JSON_KEYS, "json_object_keys") .put(JSON_EXTRACT, "get_json_object") .build(); @@ -126,26 +130,12 @@ public interface BuiltinFunctionTransformer { .put( JSON_ARRAY_LENGTH, args -> { - // Check if the input is an array (from json_array()) or a JSON string - if (args.get(0) instanceof UnresolvedFunction) { - // Input is a JSON array - return UnresolvedFunction$.MODULE$.apply("json_array_length", - seq(UnresolvedFunction$.MODULE$.apply("to_json", seq(args), false)), false); - } else { - // Input is a JSON string - return UnresolvedFunction$.MODULE$.apply("json_array_length", seq(args.get(0)), false); - } + return UnresolvedFunction$.MODULE$.apply("json_array_length", seq(args.get(0)), false); }) .put( JSON, args -> { - // Check if the input is a named_struct (from json_object()) or a JSON string - if (args.get(0) instanceof UnresolvedFunction) { - return UnresolvedFunction$.MODULE$.apply("to_json", seq(args.get(0)), false); - } else { - return UnresolvedFunction$.MODULE$.apply("get_json_object", - seq(args.get(0), Literal$.MODULE$.apply("$")), false); - } + return UnresolvedFunction$.MODULE$.apply("get_json_object", seq(args.get(0), Literal$.MODULE$.apply("$")), false); }) .put( JSON_VALID, diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJsonFunctionsTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJsonFunctionsTranslatorTestSuite.scala index 216c0f232..6193bc43f 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJsonFunctionsTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJsonFunctionsTranslatorTestSuite.scala @@ -48,7 +48,7 @@ class PPLLogicalPlanJsonFunctionsTranslatorTestSuite val context = new CatalystPlanContext val logPlan = planTransformer.visit( - plan(pplParser, """source=t a = json(json_object('key', array(1, 2, 3)))"""), + plan(pplParser, """source=t a = to_json_string(json_object('key', array(1, 2, 3)))"""), context) val table = UnresolvedRelation(Seq("t")) @@ -97,7 +97,9 @@ class PPLLogicalPlanJsonFunctionsTranslatorTestSuite val context = new CatalystPlanContext val logPlan = planTransformer.visit( - plan(pplParser, """source=t a = json(json_object('key', json_array(1, 2, 3)))"""), + plan( + pplParser, + """source=t a = to_json_string(json_object('key', json_array(1, 2, 3)))"""), context) val table = UnresolvedRelation(Seq("t")) @@ -139,25 +141,21 @@ class PPLLogicalPlanJsonFunctionsTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } - test("test json_array_length(json_array())") { + test("test array_length(json_array())") { val context = new CatalystPlanContext val logPlan = planTransformer.visit( - plan(pplParser, """source=t a = json_array_length(json_array(1,2,3))"""), + plan(pplParser, """source=t a = array_length(json_array(1,2,3))"""), context) val table = UnresolvedRelation(Seq("t")) val jsonFunc = UnresolvedFunction( - "json_array_length", + "array_size", Seq( UnresolvedFunction( - "to_json", - Seq( - UnresolvedFunction( - "array", - Seq(Literal(1), Literal(2), Literal(3)), - isDistinct = false)), + "array", + Seq(Literal(1), Literal(2), Literal(3)), isDistinct = false)), isDistinct = false) val filterExpr = EqualTo(UnresolvedAttribute("a"), jsonFunc) From cfd41a36a853f5b413fca7d03584bd1ba95e64bf Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Wed, 6 Nov 2024 09:19:56 +0800 Subject: [PATCH 02/26] Join side aliases should be optional (#862) * Join side aliases should be optional Signed-off-by: Lantao Jin * address comments Signed-off-by: Lantao Jin * typo Signed-off-by: Lantao Jin --------- Signed-off-by: Lantao Jin --- docs/ppl-lang/PPL-Example-Commands.md | 6 +- docs/ppl-lang/ppl-join-command.md | 23 +- .../spark/ppl/FlintSparkPPLBasicITSuite.scala | 64 ++++ .../spark/ppl/FlintSparkPPLJoinITSuite.scala | 269 +++++++++++++++- .../src/main/antlr4/OpenSearchPPLParser.g4 | 2 +- .../sql/ast/tree/DescribeRelation.java | 4 +- .../org/opensearch/sql/ast/tree/Join.java | 6 +- .../org/opensearch/sql/ast/tree/Relation.java | 41 +-- .../sql/ast/tree/SubqueryAlias.java | 10 +- .../sql/ppl/CatalystExpressionVisitor.java | 10 + .../sql/ppl/CatalystPlanContext.java | 60 ++-- .../sql/ppl/CatalystQueryPlanVisitor.java | 3 +- .../opensearch/sql/ppl/parser/AstBuilder.java | 28 +- ...lPlanBasicQueriesTranslatorTestSuite.scala | 40 ++- ...PLLogicalPlanJoinTranslatorTestSuite.scala | 292 +++++++++++++++++- 15 files changed, 755 insertions(+), 103 deletions(-) diff --git a/docs/ppl-lang/PPL-Example-Commands.md b/docs/ppl-lang/PPL-Example-Commands.md index e780f688d..e80f8c906 100644 --- a/docs/ppl-lang/PPL-Example-Commands.md +++ b/docs/ppl-lang/PPL-Example-Commands.md @@ -306,7 +306,11 @@ source = table | where ispresent(a) | - `source = table1 | left semi join left = l right = r on l.a = r.a table2` - `source = table1 | left anti join left = l right = r on l.a = r.a table2` - `source = table1 | join left = l right = r [ source = table2 | where d > 10 | head 5 ]` - +- `source = table1 | inner join on table1.a = table2.a table2 | fields table1.a, table2.a, table1.b, table1.c` (directly refer table name) +- `source = table1 | inner join on a = c table2 | fields a, b, c, d` (ignore side aliases as long as no ambiguous) +- `source = table1 as t1 | join left = l right = r on l.a = r.a table2 as t2 | fields l.a, r.a` (side alias overrides table alias) +- `source = table1 as t1 | join left = l right = r on l.a = r.a table2 as t2 | fields t1.a, t2.a` (error, side alias overrides table alias) +- `source = table1 | join left = l right = r on l.a = r.a [ source = table2 ] as s | fields l.a, s.a` (error, side alias overrides subquery alias) #### **Lookup** [See additional command details](ppl-lookup-command.md) diff --git a/docs/ppl-lang/ppl-join-command.md b/docs/ppl-lang/ppl-join-command.md index 525373f7c..b374bce5f 100644 --- a/docs/ppl-lang/ppl-join-command.md +++ b/docs/ppl-lang/ppl-join-command.md @@ -65,8 +65,8 @@ WHERE t1.serviceName = `order` SEARCH source= | | [joinType] JOIN - leftAlias - rightAlias + [leftAlias] + [rightAlias] [joinHints] ON joinCriteria @@ -79,12 +79,12 @@ SEARCH source= **leftAlias** - Syntax: `left = ` -- Required +- Optional - Description: The subquery alias to use with the left join side, to avoid ambiguous naming. **rightAlias** - Syntax: `right = ` -- Required +- Optional - Description: The subquery alias to use with the right join side, to avoid ambiguous naming. **joinHints** @@ -138,11 +138,11 @@ Rewritten by PPL Join query: ```sql SEARCH source=customer | FIELDS c_custkey -| LEFT OUTER JOIN left = c, right = o - ON c.c_custkey = o.o_custkey AND o_comment NOT LIKE '%unusual%packages%' +| LEFT OUTER JOIN + ON c_custkey = o_custkey AND o_comment NOT LIKE '%unusual%packages%' orders -| STATS count(o_orderkey) AS c_count BY c.c_custkey -| STATS count(1) AS custdist BY c_count +| STATS count(o_orderkey) AS c_count BY c_custkey +| STATS count() AS custdist BY c_count | SORT - custdist, - c_count ``` _- **Limitation: sub-searches is unsupported in join right side**_ @@ -151,14 +151,15 @@ If sub-searches is supported, above ppl query could be rewritten as: ```sql SEARCH source=customer | FIELDS c_custkey -| LEFT OUTER JOIN left = c, right = o ON c.c_custkey = o.o_custkey +| LEFT OUTER JOIN + ON c_custkey = o_custkey [ SEARCH source=orders | WHERE o_comment NOT LIKE '%unusual%packages%' | FIELDS o_orderkey, o_custkey ] -| STATS count(o_orderkey) AS c_count BY c.c_custkey -| STATS count(1) AS custdist BY c_count +| STATS count(o_orderkey) AS c_count BY c_custkey +| STATS count() AS custdist BY c_count | SORT - custdist, - c_count ``` diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala index cbc4308b0..3bd98edf1 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala @@ -597,4 +597,68 @@ class FlintSparkPPLBasicITSuite | """.stripMargin)) assert(ex.getMessage().contains("Invalid table name")) } + + test("Search multiple tables - translated into union call with fields") { + val frame = sql(s""" + | source = $t1, $t2 + | """.stripMargin) + assertSameRows( + Seq( + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4), + Row("Jane", 20, "Quebec", "Canada", 2023, 4), + Row("Jane", 20, "Quebec", "Canada", 2023, 4), + Row("John", 25, "Ontario", "Canada", 2023, 4), + Row("John", 25, "Ontario", "Canada", 2023, 4)), + frame) + + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + + val allFields1 = UnresolvedStar(None) + val allFields2 = UnresolvedStar(None) + + val projectedTable1 = Project(Seq(allFields1), table1) + val projectedTable2 = Project(Seq(allFields2), table2) + + val expectedPlan = + Union(Seq(projectedTable1, projectedTable2), byName = true, allowMissingCol = true) + + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("Search multiple tables - with table alias") { + val frame = sql(s""" + | source = $t1, $t2 as t | where t.country = "USA" + | """.stripMargin) + assertSameRows( + Seq( + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4)), + frame) + + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + + val plan1 = Filter( + EqualTo(UnresolvedAttribute("t.country"), Literal("USA")), + SubqueryAlias("t", table1)) + val plan2 = Filter( + EqualTo(UnresolvedAttribute("t.country"), Literal("USA")), + SubqueryAlias("t", table2)) + + val projectedTable1 = Project(Seq(UnresolvedStar(None)), plan1) + val projectedTable2 = Project(Seq(UnresolvedStar(None)), plan2) + + val expectedPlan = + Union(Seq(projectedTable1, projectedTable2), byName = true, allowMissingCol = true) + + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } } diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJoinITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJoinITSuite.scala index 00e55d50a..3127325c8 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJoinITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLJoinITSuite.scala @@ -5,7 +5,7 @@ package org.opensearch.flint.spark.ppl -import org.apache.spark.sql.{QueryTest, Row} +import org.apache.spark.sql.{AnalysisException, QueryTest, Row} import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} import org.apache.spark.sql.catalyst.expressions.{Alias, And, Ascending, Divide, EqualTo, Floor, GreaterThan, LessThan, Literal, Multiply, Or, SortOrder} import org.apache.spark.sql.catalyst.plans.{Cross, Inner, LeftAnti, LeftOuter, LeftSemi, RightOuter} @@ -924,4 +924,271 @@ class FlintSparkPPLJoinITSuite s }.size == 13) } + + test("test multiple joins without table aliases") { + val frame = sql(s""" + | source = $testTable1 + | | JOIN ON $testTable1.name = $testTable2.name $testTable2 + | | JOIN ON $testTable2.name = $testTable3.name $testTable3 + | | fields $testTable1.name, $testTable2.name, $testTable3.name + | """.stripMargin) + assertSameRows( + Array( + Row("Jake", "Jake", "Jake"), + Row("Hello", "Hello", "Hello"), + Row("John", "John", "John"), + Row("David", "David", "David"), + Row("David", "David", "David"), + Row("Jane", "Jane", "Jane")), + frame) + + val logicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + table1, + table2, + Inner, + Some( + EqualTo( + UnresolvedAttribute(s"$testTable1.name"), + UnresolvedAttribute(s"$testTable2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + table3, + Inner, + Some( + EqualTo( + UnresolvedAttribute(s"$testTable2.name"), + UnresolvedAttribute(s"$testTable3.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute(s"$testTable1.name"), + UnresolvedAttribute(s"$testTable2.name"), + UnresolvedAttribute(s"$testTable3.name")), + joinPlan2) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with part subquery aliases") { + val frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | JOIN right = t3 ON t1.name = t3.name $testTable3 + | | fields t1.name, t2.name, t3.name + | """.stripMargin) + assertSameRows( + Array( + Row("Jake", "Jake", "Jake"), + Row("Hello", "Hello", "Hello"), + Row("John", "John", "John"), + Row("David", "David", "David"), + Row("David", "David", "David"), + Row("Jane", "Jane", "Jane")), + frame) + + val logicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("t1.name"), + UnresolvedAttribute("t2.name"), + UnresolvedAttribute("t3.name")), + joinPlan2) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with self join 1") { + val frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | JOIN right = t3 ON t1.name = t3.name $testTable3 + | | JOIN right = t4 ON t1.name = t4.name $testTable1 + | | fields t1.name, t2.name, t3.name, t4.name + | """.stripMargin) + assertSameRows( + Array( + Row("Jake", "Jake", "Jake", "Jake"), + Row("Hello", "Hello", "Hello", "Hello"), + Row("John", "John", "John", "John"), + Row("David", "David", "David", "David"), + Row("David", "David", "David", "David"), + Row("Jane", "Jane", "Jane", "Jane")), + frame) + + val logicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table1), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t4.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("t1.name"), + UnresolvedAttribute("t2.name"), + UnresolvedAttribute("t3.name"), + UnresolvedAttribute("t4.name")), + joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with self join 2") { + val frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | JOIN right = t3 ON t1.name = t3.name $testTable3 + | | JOIN ON t1.name = t4.name + | [ + | source = $testTable1 + | ] as t4 + | | fields t1.name, t2.name, t3.name, t4.name + | """.stripMargin) + assertSameRows( + Array( + Row("Jake", "Jake", "Jake", "Jake"), + Row("Hello", "Hello", "Hello", "Hello"), + Row("John", "John", "John", "John"), + Row("David", "David", "David", "David"), + Row("David", "David", "David", "David"), + Row("Jane", "Jane", "Jane", "Jane")), + frame) + + val logicalPlan = frame.queryExecution.logical + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table1), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t4.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("t1.name"), + UnresolvedAttribute("t2.name"), + UnresolvedAttribute("t3.name"), + UnresolvedAttribute("t4.name")), + joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("check access the reference by aliases") { + var frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 ON t1.name = t2.name $testTable2 as t2 + | | fields t1.name, t2.name + | """.stripMargin) + assert(frame.collect().length > 0) + + frame = sql(s""" + | source = $testTable1 as t1 + | | JOIN ON t1.name = t2.name $testTable2 as t2 + | | fields t1.name, t2.name + | """.stripMargin) + assert(frame.collect().length > 0) + + frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 ON t1.name = t2.name [ source = $testTable2 ] as t2 + | | fields t1.name, t2.name + | """.stripMargin) + assert(frame.collect().length > 0) + + frame = sql(s""" + | source = $testTable1 + | | JOIN left = t1 ON t1.name = t2.name [ source = $testTable2 as t2 ] + | | fields t1.name, t2.name + | """.stripMargin) + assert(frame.collect().length > 0) + } + + test("access the reference by override aliases should throw exception") { + var ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 as tt + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + + ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 as tt + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + + ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name [ source = $testTable2 as tt ] + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + + ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 + | | JOIN left = t1 ON t1.name = t2.name [ source = $testTable2 as tt ] as t2 + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + + ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name [ source = $testTable2 ] as tt + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + + ex = intercept[AnalysisException](sql(s""" + | source = $testTable1 as tt + | | JOIN left = t1 ON t1.name = t2.name $testTable2 as t2 + | | fields tt.name + | """.stripMargin)) + assert(ex.getMessage.contains("`tt`.`name` cannot be resolved")) + } } diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index 06dffa55c..123d1e15a 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -339,7 +339,7 @@ joinType ; sideAlias - : LEFT EQUAL leftAlias = ident COMMA? RIGHT EQUAL rightAlias = ident + : (LEFT EQUAL leftAlias = ident)? COMMA? (RIGHT EQUAL rightAlias = ident)? ; joinCriteria diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/DescribeRelation.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/DescribeRelation.java index b513d01bf..dd9947329 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/DescribeRelation.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/DescribeRelation.java @@ -8,12 +8,14 @@ import lombok.ToString; import org.opensearch.sql.ast.expression.UnresolvedExpression; +import java.util.Collections; + /** * Extend Relation to describe the table itself */ @ToString public class DescribeRelation extends Relation{ public DescribeRelation(UnresolvedExpression tableName) { - super(tableName); + super(Collections.singletonList(tableName)); } } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Join.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Join.java index 89f787d34..176902911 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Join.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Join.java @@ -25,15 +25,15 @@ public class Join extends UnresolvedPlan { private UnresolvedPlan left; private final UnresolvedPlan right; - private final String leftAlias; - private final String rightAlias; + private final Optional leftAlias; + private final Optional rightAlias; private final JoinType joinType; private final Optional joinCondition; private final JoinHint joinHint; @Override public UnresolvedPlan attach(UnresolvedPlan child) { - this.left = new SubqueryAlias(leftAlias, child); + this.left = leftAlias.isEmpty() ? child : new SubqueryAlias(leftAlias.get(), child); return this; } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Relation.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Relation.java index 1b30a7998..d8ea104a4 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Relation.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Relation.java @@ -6,53 +6,34 @@ package org.opensearch.sql.ast.tree; import com.google.common.collect.ImmutableList; -import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.ToString; import org.opensearch.sql.ast.AbstractNodeVisitor; import org.opensearch.sql.ast.expression.QualifiedName; import org.opensearch.sql.ast.expression.UnresolvedExpression; -import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; /** Logical plan node of Relation, the interface for building the searching sources. */ -@AllArgsConstructor @ToString +@Getter @EqualsAndHashCode(callSuper = false) @RequiredArgsConstructor public class Relation extends UnresolvedPlan { private static final String COMMA = ","; - private final List tableName; - - public Relation(UnresolvedExpression tableName) { - this(tableName, null); - } - - public Relation(UnresolvedExpression tableName, String alias) { - this.tableName = Arrays.asList(tableName); - this.alias = alias; - } - - /** Optional alias name for the relation. */ - @Setter @Getter private String alias; - - /** - * Return table name. - * - * @return table name - */ - public List getTableName() { - return tableName.stream().map(Object::toString).collect(Collectors.toList()); - } + // A relation could contain more than one table/index names, such as + // source=account1, account2 + // source=`account1`,`account2` + // source=`account*` + // They translated into union call with fields. + private final List tableNames; public List getQualifiedNames() { - return tableName.stream().map(t -> (QualifiedName) t).collect(Collectors.toList()); + return tableNames.stream().map(t -> (QualifiedName) t).collect(Collectors.toList()); } /** @@ -63,11 +44,11 @@ public List getQualifiedNames() { * @return TableQualifiedName. */ public QualifiedName getTableQualifiedName() { - if (tableName.size() == 1) { - return (QualifiedName) tableName.get(0); + if (tableNames.size() == 1) { + return (QualifiedName) tableNames.get(0); } else { return new QualifiedName( - tableName.stream() + tableNames.stream() .map(UnresolvedExpression::toString) .collect(Collectors.joining(COMMA))); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/SubqueryAlias.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/SubqueryAlias.java index 29c3d4b90..ba66cca80 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/SubqueryAlias.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/SubqueryAlias.java @@ -6,19 +6,14 @@ package org.opensearch.sql.ast.tree; import com.google.common.collect.ImmutableList; -import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.ToString; import org.opensearch.sql.ast.AbstractNodeVisitor; import java.util.List; -import java.util.Objects; -@AllArgsConstructor @EqualsAndHashCode(callSuper = false) -@RequiredArgsConstructor @ToString public class SubqueryAlias extends UnresolvedPlan { @Getter private final String alias; @@ -32,6 +27,11 @@ public SubqueryAlias(UnresolvedPlan child, String suffix) { this.child = child; } + public SubqueryAlias(String alias, UnresolvedPlan child) { + this.alias = alias; + this.child = child; + } + public List getChild() { return ImmutableList.of(child); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystExpressionVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystExpressionVisitor.java index a0506ceee..4c8d117b3 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystExpressionVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystExpressionVisitor.java @@ -110,6 +110,11 @@ public Expression analyze(UnresolvedExpression unresolved, CatalystPlanContext c return unresolved.accept(this, context); } + /** This method is only for analyze the join condition expression */ + public Expression analyzeJoinCondition(UnresolvedExpression unresolved, CatalystPlanContext context) { + return context.resolveJoinCondition(unresolved, this::analyze); + } + @Override public Expression visitLiteral(Literal node, CatalystPlanContext context) { return context.getNamedParseExpressions().push(new org.apache.spark.sql.catalyst.expressions.Literal( @@ -197,6 +202,11 @@ public Expression visitCompare(Compare node, CatalystPlanContext context) { @Override public Expression visitQualifiedName(QualifiedName node, CatalystPlanContext context) { + // When the qualified name is part of join condition, for example: table1.id = table2.id + // findRelation(context.traversalContext() only returns relation table1 which cause table2.id fail to resolve + if (context.isResolvingJoinCondition()) { + return context.getNamedParseExpressions().push(UnresolvedAttribute$.MODULE$.apply(seq(node.getParts()))); + } List relation = findRelation(context.traversalContext()); if (!relation.isEmpty()) { Optional resolveField = resolveField(relation, node, context.getRelations()); diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java index 61762f616..53dc17576 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java @@ -5,6 +5,7 @@ package org.opensearch.sql.ppl; +import lombok.Getter; import org.apache.spark.sql.catalyst.analysis.UnresolvedRelation; import org.apache.spark.sql.catalyst.expressions.AttributeReference; import org.apache.spark.sql.catalyst.expressions.Expression; @@ -39,19 +40,19 @@ public class CatalystPlanContext { /** * Catalyst relations list **/ - private List projectedFields = new ArrayList<>(); + @Getter private List projectedFields = new ArrayList<>(); /** * Catalyst relations list **/ - private List relations = new ArrayList<>(); + @Getter private List relations = new ArrayList<>(); /** * Catalyst SubqueryAlias list **/ - private List subqueryAlias = new ArrayList<>(); + @Getter private List subqueryAlias = new ArrayList<>(); /** * Catalyst evolving logical plan **/ - private Stack planBranches = new Stack<>(); + @Getter private Stack planBranches = new Stack<>(); /** * The current traversal context the visitor is going threw */ @@ -60,28 +61,12 @@ public class CatalystPlanContext { /** * NamedExpression contextual parameters **/ - private final Stack namedParseExpressions = new Stack<>(); + @Getter private final Stack namedParseExpressions = new Stack<>(); /** * Grouping NamedExpression contextual parameters **/ - private final Stack groupingParseExpressions = new Stack<>(); - - public Stack getPlanBranches() { - return planBranches; - } - - public List getRelations() { - return relations; - } - - public List getSubqueryAlias() { - return subqueryAlias; - } - - public List getProjectedFields() { - return projectedFields; - } + @Getter private final Stack groupingParseExpressions = new Stack<>(); public LogicalPlan getPlan() { if (this.planBranches.isEmpty()) return null; @@ -101,10 +86,6 @@ public Stack traversalContext() { return planTraversalContext; } - public Stack getNamedParseExpressions() { - return namedParseExpressions; - } - public void setNamedParseExpressions(Stack namedParseExpressions) { this.namedParseExpressions.clear(); this.namedParseExpressions.addAll(namedParseExpressions); @@ -114,10 +95,6 @@ public Optional popNamedParseExpressions() { return namedParseExpressions.isEmpty() ? Optional.empty() : Optional.of(namedParseExpressions.pop()); } - public Stack getGroupingParseExpressions() { - return groupingParseExpressions; - } - /** * define new field * @@ -154,13 +131,13 @@ public LogicalPlan withProjectedFields(List projectedField this.projectedFields.addAll(projectedFields); return getPlan(); } - + public LogicalPlan applyBranches(List> plans) { plans.forEach(plan -> with(plan.apply(planBranches.get(0)))); planBranches.remove(0); return getPlan(); - } - + } + /** * append plan with evolving plans branches * @@ -288,4 +265,21 @@ public static Optional findRelation(LogicalPlan plan) { return Optional.empty(); } + @Getter private boolean isResolvingJoinCondition = false; + + /** + * Resolve the join condition with the given function. + * A flag will be set to true ahead expression resolving, then false after resolving. + * @param expr + * @param transformFunction + * @return + */ + public Expression resolveJoinCondition( + UnresolvedExpression expr, + BiFunction transformFunction) { + isResolvingJoinCondition = true; + Expression result = transformFunction.apply(expr, this); + isResolvingJoinCondition = false; + return result; + } } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java index 669459fba..a43378480 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java @@ -273,7 +273,8 @@ public LogicalPlan visitJoin(Join node, CatalystPlanContext context) { node.getChild().get(0).accept(this, context); return context.apply(left -> { LogicalPlan right = node.getRight().accept(this, context); - Optional joinCondition = node.getJoinCondition().map(c -> visitExpression(c, context)); + Optional joinCondition = node.getJoinCondition() + .map(c -> expressionAnalyzer.analyzeJoinCondition(c, context)); context.retainAllNamedParseExpressions(p -> p); context.retainAllPlans(p -> p); return join(left, right, node.getJoinType(), joinCondition, node.getJoinHint()); diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java index 4e6b1f131..36a34cd06 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java @@ -155,14 +155,25 @@ public UnresolvedPlan visitJoinCommand(OpenSearchPPLParser.JoinCommandContext ct joinType = Join.JoinType.CROSS; } Join.JoinHint joinHint = getJoinHint(ctx.joinHintList()); - String leftAlias = ctx.sideAlias().leftAlias.getText(); - String rightAlias = ctx.sideAlias().rightAlias.getText(); + Optional leftAlias = ctx.sideAlias().leftAlias != null ? Optional.of(ctx.sideAlias().leftAlias.getText()) : Optional.empty(); + Optional rightAlias = Optional.empty(); if (ctx.tableOrSubqueryClause().alias != null) { - // left and right aliases are required in join syntax. Setting by 'AS' causes ambiguous - throw new SyntaxCheckException("'AS' is not allowed in right subquery, use right= instead"); + rightAlias = Optional.of(ctx.tableOrSubqueryClause().alias.getText()); } + if (ctx.sideAlias().rightAlias != null) { + rightAlias = Optional.of(ctx.sideAlias().rightAlias.getText()); + } + UnresolvedPlan rightRelation = visit(ctx.tableOrSubqueryClause()); - UnresolvedPlan right = new SubqueryAlias(rightAlias, rightRelation); + // Add a SubqueryAlias to the right plan when the right alias is present and no duplicated alias existing in right. + UnresolvedPlan right; + if (rightAlias.isEmpty() || + (rightRelation instanceof SubqueryAlias && + rightAlias.get().equals(((SubqueryAlias) rightRelation).getAlias()))) { + right = rightRelation; + } else { + right = new SubqueryAlias(rightAlias.get(), rightRelation); + } Optional joinCondition = ctx.joinCriteria() == null ? Optional.empty() : Optional.of(expressionBuilder.visitJoinCriteria(ctx.joinCriteria())); @@ -370,7 +381,7 @@ public UnresolvedPlan visitPatternsCommand(OpenSearchPPLParser.PatternsCommandCo /** Lookup command */ @Override public UnresolvedPlan visitLookupCommand(OpenSearchPPLParser.LookupCommandContext ctx) { - Relation lookupRelation = new Relation(this.internalVisitExpression(ctx.tableSource())); + Relation lookupRelation = new Relation(Collections.singletonList(this.internalVisitExpression(ctx.tableSource()))); Lookup.OutputStrategy strategy = ctx.APPEND() != null ? Lookup.OutputStrategy.APPEND : Lookup.OutputStrategy.REPLACE; java.util.Map lookupMappingList = buildLookupPair(ctx.lookupMappingList().lookupPair()); @@ -509,9 +520,8 @@ public UnresolvedPlan visitTableOrSubqueryClause(OpenSearchPPLParser.TableOrSubq @Override public UnresolvedPlan visitTableSourceClause(OpenSearchPPLParser.TableSourceClauseContext ctx) { - return ctx.alias == null - ? new Relation(ctx.tableSource().stream().map(this::internalVisitExpression).collect(Collectors.toList())) - : new Relation(ctx.tableSource().stream().map(this::internalVisitExpression).collect(Collectors.toList()), ctx.alias.getText()); + Relation relation = new Relation(ctx.tableSource().stream().map(this::internalVisitExpression).collect(Collectors.toList())); + return ctx.alias != null ? new SubqueryAlias(ctx.alias.getText(), relation) : relation; } @Override diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala index 2a569dbdf..50ef985d6 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala @@ -13,7 +13,7 @@ import org.scalatest.matchers.should.Matchers import org.apache.spark.SparkFunSuite import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedRelation, UnresolvedStar} -import org.apache.spark.sql.catalyst.expressions.{Ascending, Descending, GreaterThan, Literal, NamedExpression, SortOrder} +import org.apache.spark.sql.catalyst.expressions.{Ascending, Descending, EqualTo, GreaterThan, Literal, NamedExpression, SortOrder} import org.apache.spark.sql.catalyst.plans.PlanTest import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.execution.command.DescribeTableCommand @@ -292,6 +292,44 @@ class PPLLogicalPlanBasicQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } + test("Search multiple tables - with table alias") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + """ + | source=table1, table2, table3 as t + | | where t.name = 'Molly' + |""".stripMargin), + context) + + val table1 = UnresolvedRelation(Seq("table1")) + val table2 = UnresolvedRelation(Seq("table2")) + val table3 = UnresolvedRelation(Seq("table3")) + val star = UnresolvedStar(None) + val plan1 = Project( + Seq(star), + Filter( + EqualTo(UnresolvedAttribute("t.name"), Literal("Molly")), + SubqueryAlias("t", table1))) + val plan2 = Project( + Seq(star), + Filter( + EqualTo(UnresolvedAttribute("t.name"), Literal("Molly")), + SubqueryAlias("t", table2))) + val plan3 = Project( + Seq(star), + Filter( + EqualTo(UnresolvedAttribute("t.name"), Literal("Molly")), + SubqueryAlias("t", table3))) + + val expectedPlan = + Union(Seq(plan1, plan2, plan3), byName = true, allowMissingCol = true) + + comparePlans(expectedPlan, logPlan, false) + } + test("test fields + field list") { val context = new CatalystPlanContext val logPlan = planTransformer.visit( diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJoinTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJoinTranslatorTestSuite.scala index 3ceff7735..f4ed397e3 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJoinTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanJoinTranslatorTestSuite.scala @@ -271,9 +271,9 @@ class PPLLogicalPlanJoinTranslatorTestSuite pplParser, s""" | source = $testTable1 - | | inner JOIN left = l,right = r ON l.id = r.id $testTable2 - | | left JOIN left = l,right = r ON l.name = r.name $testTable3 - | | cross JOIN left = l,right = r $testTable4 + | | inner JOIN left = l right = r ON l.id = r.id $testTable2 + | | left JOIN left = l right = r ON l.name = r.name $testTable3 + | | cross JOIN left = l right = r $testTable4 | """.stripMargin) val logicalPlan = planTransformer.visit(logPlan, context) val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) @@ -443,17 +443,17 @@ class PPLLogicalPlanJoinTranslatorTestSuite s""" | source = $testTable1 | | head 10 - | | inner JOIN left = l,right = r ON l.id = r.id + | | inner JOIN left = l right = r ON l.id = r.id | [ | source = $testTable2 | | where id > 10 | ] - | | left JOIN left = l,right = r ON l.name = r.name + | | left JOIN left = l right = r ON l.name = r.name | [ | source = $testTable3 | | fields id | ] - | | cross JOIN left = l,right = r + | | cross JOIN left = l right = r | [ | source = $testTable4 | | sort id @@ -565,4 +565,284 @@ class PPLLogicalPlanJoinTranslatorTestSuite val expectedPlan = Project(Seq(UnresolvedStar(None)), sort) comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) } + + test("test multiple joins with table alias") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = table1 as t1 + | | JOIN ON t1.id = t2.id + | [ + | source = table2 as t2 + | ] + | | JOIN ON t2.id = t3.id + | [ + | source = table3 as t3 + | ] + | | JOIN ON t3.id = t4.id + | [ + | source = table4 as t4 + | ] + | """.stripMargin) + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("table1")) + val table2 = UnresolvedRelation(Seq("table2")) + val table3 = UnresolvedRelation(Seq("table3")) + val table4 = UnresolvedRelation(Seq("table4")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.id"), UnresolvedAttribute("t2.id"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t2.id"), UnresolvedAttribute("t3.id"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table4), + Inner, + Some(EqualTo(UnresolvedAttribute("t3.id"), UnresolvedAttribute("t4.id"))), + JoinHint.NONE) + val expectedPlan = Project(Seq(UnresolvedStar(None)), joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with table and subquery alias") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = table1 as t1 + | | JOIN left = l right = r ON t1.id = t2.id + | [ + | source = table2 as t2 + | ] + | | JOIN left = l right = r ON t2.id = t3.id + | [ + | source = table3 as t3 + | ] + | | JOIN left = l right = r ON t3.id = t4.id + | [ + | source = table4 as t4 + | ] + | """.stripMargin) + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("table1")) + val table2 = UnresolvedRelation(Seq("table2")) + val table3 = UnresolvedRelation(Seq("table3")) + val table4 = UnresolvedRelation(Seq("table4")) + val joinPlan1 = Join( + SubqueryAlias("l", SubqueryAlias("t1", table1)), + SubqueryAlias("r", SubqueryAlias("t2", table2)), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.id"), UnresolvedAttribute("t2.id"))), + JoinHint.NONE) + val joinPlan2 = Join( + SubqueryAlias("l", joinPlan1), + SubqueryAlias("r", SubqueryAlias("t3", table3)), + Inner, + Some(EqualTo(UnresolvedAttribute("t2.id"), UnresolvedAttribute("t3.id"))), + JoinHint.NONE) + val joinPlan3 = Join( + SubqueryAlias("l", joinPlan2), + SubqueryAlias("r", SubqueryAlias("t4", table4)), + Inner, + Some(EqualTo(UnresolvedAttribute("t3.id"), UnresolvedAttribute("t4.id"))), + JoinHint.NONE) + val expectedPlan = Project(Seq(UnresolvedStar(None)), joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins without table aliases") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = table1 + | | JOIN ON table1.id = table2.id table2 + | | JOIN ON table1.id = table3.id table3 + | | JOIN ON table2.id = table4.id table4 + | """.stripMargin) + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("table1")) + val table2 = UnresolvedRelation(Seq("table2")) + val table3 = UnresolvedRelation(Seq("table3")) + val table4 = UnresolvedRelation(Seq("table4")) + val joinPlan1 = Join( + table1, + table2, + Inner, + Some(EqualTo(UnresolvedAttribute("table1.id"), UnresolvedAttribute("table2.id"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + table3, + Inner, + Some(EqualTo(UnresolvedAttribute("table1.id"), UnresolvedAttribute("table3.id"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + table4, + Inner, + Some(EqualTo(UnresolvedAttribute("table2.id"), UnresolvedAttribute("table4.id"))), + JoinHint.NONE) + val expectedPlan = Project(Seq(UnresolvedStar(None)), joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with part subquery aliases") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = table1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name table2 + | | JOIN right = t3 ON t1.name = t3.name table3 + | | JOIN right = t4 ON t2.name = t4.name table4 + | """.stripMargin) + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("table1")) + val table2 = UnresolvedRelation(Seq("table2")) + val table3 = UnresolvedRelation(Seq("table3")) + val table4 = UnresolvedRelation(Seq("table4")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table4), + Inner, + Some(EqualTo(UnresolvedAttribute("t2.name"), UnresolvedAttribute("t4.name"))), + JoinHint.NONE) + val expectedPlan = Project(Seq(UnresolvedStar(None)), joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with self join 1") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | JOIN right = t3 ON t1.name = t3.name $testTable3 + | | JOIN right = t4 ON t1.name = t4.name $testTable1 + | | fields t1.name, t2.name, t3.name, t4.name + | """.stripMargin) + + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table1), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t4.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("t1.name"), + UnresolvedAttribute("t2.name"), + UnresolvedAttribute("t3.name"), + UnresolvedAttribute("t4.name")), + joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test multiple joins with self join 2") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name $testTable2 + | | JOIN right = t3 ON t1.name = t3.name $testTable3 + | | JOIN ON t1.name = t4.name + | [ + | source = $testTable1 + | ] as t4 + | | fields t1.name, t2.name, t3.name, t4.name + | """.stripMargin) + + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val table3 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test3")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", table2), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val joinPlan2 = Join( + joinPlan1, + SubqueryAlias("t3", table3), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t3.name"))), + JoinHint.NONE) + val joinPlan3 = Join( + joinPlan2, + SubqueryAlias("t4", table1), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t4.name"))), + JoinHint.NONE) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("t1.name"), + UnresolvedAttribute("t2.name"), + UnresolvedAttribute("t3.name"), + UnresolvedAttribute("t4.name")), + joinPlan3) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("test side alias will override the subquery alias") { + val context = new CatalystPlanContext + val logPlan = plan( + pplParser, + s""" + | source = $testTable1 + | | JOIN left = t1 right = t2 ON t1.name = t2.name [ source = $testTable2 as ttt ] as tt + | | fields t1.name, t2.name + | """.stripMargin) + val logicalPlan = planTransformer.visit(logPlan, context) + val table1 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test1")) + val table2 = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test2")) + val joinPlan1 = Join( + SubqueryAlias("t1", table1), + SubqueryAlias("t2", SubqueryAlias("tt", SubqueryAlias("ttt", table2))), + Inner, + Some(EqualTo(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name"))), + JoinHint.NONE) + val expectedPlan = + Project(Seq(UnresolvedAttribute("t1.name"), UnresolvedAttribute("t2.name")), joinPlan1) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } } From 48be5cc1224ea39a38dc55ba465577d24dc99d7f Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Thu, 7 Nov 2024 10:15:04 +0800 Subject: [PATCH 03/26] Add TPC-H PPL query suite (#830) * Add TPC-H PPL query suite Signed-off-by: Lantao Jin * fix failure of loading resources Signed-off-by: Lantao Jin * fix data_add() Signed-off-by: Lantao Jin * enable q21 and add docs Signed-off-by: Lantao Jin --------- Signed-off-by: Lantao Jin --- build.sbt | 3 +- docs/ppl-lang/README.md | 4 + docs/ppl-lang/ppl-tpch.md | 102 ++++++++++ .../src/integration/resources/tpch/q1.ppl | 35 ++++ .../src/integration/resources/tpch/q10.ppl | 45 +++++ .../src/integration/resources/tpch/q11.ppl | 45 +++++ .../src/integration/resources/tpch/q12.ppl | 42 +++++ .../src/integration/resources/tpch/q13.ppl | 31 +++ .../src/integration/resources/tpch/q14.ppl | 25 +++ .../src/integration/resources/tpch/q15.ppl | 52 +++++ .../src/integration/resources/tpch/q16.ppl | 45 +++++ .../src/integration/resources/tpch/q17.ppl | 34 ++++ .../src/integration/resources/tpch/q18.ppl | 48 +++++ .../src/integration/resources/tpch/q19.ppl | 61 ++++++ .../src/integration/resources/tpch/q2.ppl | 62 ++++++ .../src/integration/resources/tpch/q20.ppl | 62 ++++++ .../src/integration/resources/tpch/q21.ppl | 64 +++++++ .../src/integration/resources/tpch/q22.ppl | 58 ++++++ .../src/integration/resources/tpch/q3.ppl | 33 ++++ .../src/integration/resources/tpch/q4.ppl | 33 ++++ .../src/integration/resources/tpch/q5.ppl | 36 ++++ .../src/integration/resources/tpch/q6.ppl | 18 ++ .../src/integration/resources/tpch/q7.ppl | 56 ++++++ .../src/integration/resources/tpch/q8.ppl | 60 ++++++ .../src/integration/resources/tpch/q9.ppl | 50 +++++ .../flint/spark/ppl/tpch/TPCHQueryBase.scala | 177 ++++++++++++++++++ .../spark/ppl/tpch/TPCHQueryITSuite.scala | 43 +++++ 27 files changed, 1323 insertions(+), 1 deletion(-) create mode 100644 docs/ppl-lang/ppl-tpch.md create mode 100644 integ-test/src/integration/resources/tpch/q1.ppl create mode 100644 integ-test/src/integration/resources/tpch/q10.ppl create mode 100644 integ-test/src/integration/resources/tpch/q11.ppl create mode 100644 integ-test/src/integration/resources/tpch/q12.ppl create mode 100644 integ-test/src/integration/resources/tpch/q13.ppl create mode 100644 integ-test/src/integration/resources/tpch/q14.ppl create mode 100644 integ-test/src/integration/resources/tpch/q15.ppl create mode 100644 integ-test/src/integration/resources/tpch/q16.ppl create mode 100644 integ-test/src/integration/resources/tpch/q17.ppl create mode 100644 integ-test/src/integration/resources/tpch/q18.ppl create mode 100644 integ-test/src/integration/resources/tpch/q19.ppl create mode 100644 integ-test/src/integration/resources/tpch/q2.ppl create mode 100644 integ-test/src/integration/resources/tpch/q20.ppl create mode 100644 integ-test/src/integration/resources/tpch/q21.ppl create mode 100644 integ-test/src/integration/resources/tpch/q22.ppl create mode 100644 integ-test/src/integration/resources/tpch/q3.ppl create mode 100644 integ-test/src/integration/resources/tpch/q4.ppl create mode 100644 integ-test/src/integration/resources/tpch/q5.ppl create mode 100644 integ-test/src/integration/resources/tpch/q6.ppl create mode 100644 integ-test/src/integration/resources/tpch/q7.ppl create mode 100644 integ-test/src/integration/resources/tpch/q8.ppl create mode 100644 integ-test/src/integration/resources/tpch/q9.ppl create mode 100644 integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryBase.scala create mode 100644 integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryITSuite.scala diff --git a/build.sbt b/build.sbt index 66b06d6be..1300f68a0 100644 --- a/build.sbt +++ b/build.sbt @@ -238,7 +238,8 @@ lazy val integtest = (project in file("integ-test")) inConfig(IntegrationTest)(Defaults.testSettings ++ Seq( IntegrationTest / javaSource := baseDirectory.value / "src/integration/java", IntegrationTest / scalaSource := baseDirectory.value / "src/integration/scala", - IntegrationTest / parallelExecution := false, + IntegrationTest / resourceDirectory := baseDirectory.value / "src/integration/resources", + IntegrationTest / parallelExecution := false, IntegrationTest / fork := true, )), inConfig(AwsIntegrationTest)(Defaults.testSettings ++ Seq( diff --git a/docs/ppl-lang/README.md b/docs/ppl-lang/README.md index d78f4c030..ef186e5f2 100644 --- a/docs/ppl-lang/README.md +++ b/docs/ppl-lang/README.md @@ -104,6 +104,10 @@ For additional examples see the next [documentation](PPL-Example-Commands.md). ### Example PPL Queries See samples of [PPL queries](PPL-Example-Commands.md) +--- +### TPC-H PPL Query Rewriting +See samples of [TPC-H PPL query rewriting](ppl-tpch.md) + --- ### Planned PPL Commands diff --git a/docs/ppl-lang/ppl-tpch.md b/docs/ppl-lang/ppl-tpch.md new file mode 100644 index 000000000..ef5846ce0 --- /dev/null +++ b/docs/ppl-lang/ppl-tpch.md @@ -0,0 +1,102 @@ +## TPC-H Benchmark + +TPC-H is a decision support benchmark designed to evaluate the performance of database systems in handling complex business-oriented queries and concurrent data modifications. The benchmark utilizes a dataset that is broadly representative of various industries, making it widely applicable. TPC-H simulates a decision support environment where large volumes of data are analyzed, intricate queries are executed, and critical business questions are answered. + +### Test PPL Queries + +TPC-H 22 test query statements: [TPCH-Query-PPL](https://github.com/opensearch-project/opensearch-spark/blob/main/integ-test/src/integration/resources/tpch) + +### Data Preparation + +#### Option 1 - from PyPi + +``` +# Create the virtual environment +python3 -m venv .venv + +# Activate the virtual environment +. .venv/bin/activate + +pip install tpch-datagen +``` + +#### Option 2 - from source + +``` +git clone https://github.com/gizmodata/tpch-datagen + +cd tpch-datagen + +# Create the virtual environment +python3 -m venv .venv + +# Activate the virtual environment +. .venv/bin/activate + +# Upgrade pip, setuptools, and wheel +pip install --upgrade pip setuptools wheel + +# Install TPC-H Datagen - in editable mode with client and dev dependencies +pip install --editable .[dev] +``` + +#### Usage + +Here are the options for the tpch-datagen command: +``` +tpch-datagen --help +Usage: tpch-datagen [OPTIONS] + +Options: + --version / --no-version Prints the TPC-H Datagen package version and + exits. [required] + --scale-factor INTEGER The TPC-H Scale Factor to use for data + generation. + --data-directory TEXT The target output data directory to put the + files into [default: data; required] + --work-directory TEXT The work directory to use for data + generation. [default: /tmp; required] + --overwrite / --no-overwrite Can we overwrite the target directory if it + already exists... [default: no-overwrite; + required] + --num-chunks INTEGER The number of chunks that will be generated + - more chunks equals smaller memory + requirements, but more files generated. + [default: 10; required] + --num-processes INTEGER The maximum number of processes for the + multi-processing pool to use for data + generation. [default: 10; required] + --duckdb-threads INTEGER The number of DuckDB threads to use for data + generation (within each job process). + [default: 1; required] + --per-thread-output / --no-per-thread-output + Controls whether to write the output to a + single file or multiple files (for each + process). [default: per-thread-output; + required] + --compression-method [none|snappy|gzip|zstd] + The compression method to use for the + parquet files generated. [default: zstd; + required] + --file-size-bytes TEXT The target file size for the parquet files + generated. [default: 100m; required] + --help Show this message and exit. +``` + +### Generate 1 GB data with zstd (by default) compression + +``` +tpch-datagen --scale-factor 1 +``` + +### Generate 10 GB data with snappy compression + +``` +tpch-datagen --scale-factor 10 --compression-method snappy +``` + +### Query Test + +All TPC-H PPL Queries located in `integ-test/src/integration/resources/tpch` folder. + +To test all queries, run `org.opensearch.flint.spark.ppl.tpch.TPCHQueryITSuite`. \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q1.ppl b/integ-test/src/integration/resources/tpch/q1.ppl new file mode 100644 index 000000000..885ce35c6 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q1.ppl @@ -0,0 +1,35 @@ +/* +select + l_returnflag, + l_linestatus, + sum(l_quantity) as sum_qty, + sum(l_extendedprice) as sum_base_price, + sum(l_extendedprice * (1 - l_discount)) as sum_disc_price, + sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)) as sum_charge, + avg(l_quantity) as avg_qty, + avg(l_extendedprice) as avg_price, + avg(l_discount) as avg_disc, + count(*) as count_order +from + lineitem +where + l_shipdate <= date '1998-12-01' - interval '90' day +group by + l_returnflag, + l_linestatus +order by + l_returnflag, + l_linestatus +*/ + +source = lineitem +| where l_shipdate <= subdate(date('1998-12-01'), 90) +| stats sum(l_quantity) as sum_qty, + sum(l_extendedprice) as sum_base_price, + sum(l_extendedprice * (1 - l_discount)) as sum_disc_price, + sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)) as sum_charge, + avg(l_quantity) as avg_qty, avg(l_extendedprice) as avg_price, + avg(l_discount) as avg_disc, + count() as count_order + by l_returnflag, l_linestatus +| sort l_returnflag, l_linestatus \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q10.ppl b/integ-test/src/integration/resources/tpch/q10.ppl new file mode 100644 index 000000000..10a050785 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q10.ppl @@ -0,0 +1,45 @@ +/* +select + c_custkey, + c_name, + sum(l_extendedprice * (1 - l_discount)) as revenue, + c_acctbal, + n_name, + c_address, + c_phone, + c_comment +from + customer, + orders, + lineitem, + nation +where + c_custkey = o_custkey + and l_orderkey = o_orderkey + and o_orderdate >= date '1993-10-01' + and o_orderdate < date '1993-10-01' + interval '3' month + and l_returnflag = 'R' + and c_nationkey = n_nationkey +group by + c_custkey, + c_name, + c_acctbal, + c_phone, + n_name, + c_address, + c_comment +order by + revenue desc +limit 20 +*/ + +source = customer +| join ON c_custkey = o_custkey orders +| join ON l_orderkey = o_orderkey lineitem +| join ON c_nationkey = n_nationkey nation +| where o_orderdate >= date('1993-10-01') + AND o_orderdate < date_add(date('1993-10-01'), interval 3 month) + AND l_returnflag = 'R' +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment +| sort - revenue +| head 20 \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q11.ppl b/integ-test/src/integration/resources/tpch/q11.ppl new file mode 100644 index 000000000..3a55d986e --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q11.ppl @@ -0,0 +1,45 @@ +/* +select + ps_partkey, + sum(ps_supplycost * ps_availqty) as value +from + partsupp, + supplier, + nation +where + ps_suppkey = s_suppkey + and s_nationkey = n_nationkey + and n_name = 'GERMANY' +group by + ps_partkey having + sum(ps_supplycost * ps_availqty) > ( + select + sum(ps_supplycost * ps_availqty) * 0.0001000000 + from + partsupp, + supplier, + nation + where + ps_suppkey = s_suppkey + and s_nationkey = n_nationkey + and n_name = 'GERMANY' + ) +order by + value desc +*/ + +source = partsupp +| join ON ps_suppkey = s_suppkey supplier +| join ON s_nationkey = n_nationkey nation +| where n_name = 'GERMANY' +| stats sum(ps_supplycost * ps_availqty) as value by ps_partkey +| where value > [ + source = partsupp + | join ON ps_suppkey = s_suppkey supplier + | join ON s_nationkey = n_nationkey nation + | where n_name = 'GERMANY' + | stats sum(ps_supplycost * ps_availqty) as check + | eval threshold = check * 0.0001000000 + | fields threshold + ] +| sort - value \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q12.ppl b/integ-test/src/integration/resources/tpch/q12.ppl new file mode 100644 index 000000000..79672d844 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q12.ppl @@ -0,0 +1,42 @@ +/* +select + l_shipmode, + sum(case + when o_orderpriority = '1-URGENT' + or o_orderpriority = '2-HIGH' + then 1 + else 0 + end) as high_line_count, + sum(case + when o_orderpriority <> '1-URGENT' + and o_orderpriority <> '2-HIGH' + then 1 + else 0 + end) as low_line_count +from + orders, + lineitem +where + o_orderkey = l_orderkey + and l_shipmode in ('MAIL', 'SHIP') + and l_commitdate < l_receiptdate + and l_shipdate < l_commitdate + and l_receiptdate >= date '1994-01-01' + and l_receiptdate < date '1994-01-01' + interval '1' year +group by + l_shipmode +order by + l_shipmode +*/ + +source = orders +| join ON o_orderkey = l_orderkey lineitem +| where l_commitdate < l_receiptdate + and l_shipdate < l_commitdate + and l_shipmode in ('MAIL', 'SHIP') + and l_receiptdate >= date('1994-01-01') + and l_receiptdate < date_add(date('1994-01-01'), interval 1 year) +| stats sum(case(o_orderpriority = '1-URGENT' or o_orderpriority = '2-HIGH', 1 else 0)) as high_line_count, + sum(case(o_orderpriority != '1-URGENT' and o_orderpriority != '2-HIGH', 1 else 0)) as low_line_countby + by l_shipmode +| sort l_shipmode \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q13.ppl b/integ-test/src/integration/resources/tpch/q13.ppl new file mode 100644 index 000000000..6e77c9b0a --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q13.ppl @@ -0,0 +1,31 @@ +/* +select + c_count, + count(*) as custdist +from + ( + select + c_custkey, + count(o_orderkey) as c_count + from + customer left outer join orders on + c_custkey = o_custkey + and o_comment not like '%special%requests%' + group by + c_custkey + ) as c_orders +group by + c_count +order by + custdist desc, + c_count desc +*/ + +source = [ + source = customer + | left outer join ON c_custkey = o_custkey AND not like(o_comment, '%special%requests%') + orders + | stats count(o_orderkey) as c_count by c_custkey + ] as c_orders +| stats count() as custdist by c_count +| sort - custdist, - c_count \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q14.ppl b/integ-test/src/integration/resources/tpch/q14.ppl new file mode 100644 index 000000000..553f1e549 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q14.ppl @@ -0,0 +1,25 @@ +/* +select + 100.00 * sum(case + when p_type like 'PROMO%' + then l_extendedprice * (1 - l_discount) + else 0 + end) / sum(l_extendedprice * (1 - l_discount)) as promo_revenue +from + lineitem, + part +where + l_partkey = p_partkey + and l_shipdate >= date '1995-09-01' + and l_shipdate < date '1995-09-01' + interval '1' month +*/ + +source = lineitem +| join ON l_partkey = p_partkey + AND l_shipdate >= date('1995-09-01') + AND l_shipdate < date_add(date('1995-09-01'), interval 1 month) + part +| stats sum(case(like(p_type, 'PROMO%'), l_extendedprice * (1 - l_discount) else 0)) as sum1, + sum(l_extendedprice * (1 - l_discount)) as sum2 +| eval promo_revenue = 100.00 * sum1 / sum2 // Stats and Eval commands can combine when issues/819 resolved +| fields promo_revenue \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q15.ppl b/integ-test/src/integration/resources/tpch/q15.ppl new file mode 100644 index 000000000..96f5ecea2 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q15.ppl @@ -0,0 +1,52 @@ +/* +with revenue0 as + (select + l_suppkey as supplier_no, + sum(l_extendedprice * (1 - l_discount)) as total_revenue + from + lineitem + where + l_shipdate >= date '1996-01-01' + and l_shipdate < date '1996-01-01' + interval '3' month + group by + l_suppkey) +select + s_suppkey, + s_name, + s_address, + s_phone, + total_revenue +from + supplier, + revenue0 +where + s_suppkey = supplier_no + and total_revenue = ( + select + max(total_revenue) + from + revenue0 + ) +order by + s_suppkey +*/ + +// CTE is unsupported in PPL +source = supplier +| join right = revenue0 ON s_suppkey = supplier_no [ + source = lineitem + | where l_shipdate >= date('1996-01-01') AND l_shipdate < date_add(date('1996-01-01'), interval 3 month) + | eval supplier_no = l_suppkey + | stats sum(l_extendedprice * (1 - l_discount)) as total_revenue by supplier_no + ] +| where total_revenue = [ + source = [ + source = lineitem + | where l_shipdate >= date('1996-01-01') AND l_shipdate < date_add(date('1996-01-01'), interval 3 month) + | eval supplier_no = l_suppkey + | stats sum(l_extendedprice * (1 - l_discount)) as total_revenue by supplier_no + ] + | stats max(total_revenue) + ] +| sort s_suppkey +| fields s_suppkey, s_name, s_address, s_phone, total_revenue diff --git a/integ-test/src/integration/resources/tpch/q16.ppl b/integ-test/src/integration/resources/tpch/q16.ppl new file mode 100644 index 000000000..4c5765f04 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q16.ppl @@ -0,0 +1,45 @@ +/* +select + p_brand, + p_type, + p_size, + count(distinct ps_suppkey) as supplier_cnt +from + partsupp, + part +where + p_partkey = ps_partkey + and p_brand <> 'Brand#45' + and p_type not like 'MEDIUM POLISHED%' + and p_size in (49, 14, 23, 45, 19, 3, 36, 9) + and ps_suppkey not in ( + select + s_suppkey + from + supplier + where + s_comment like '%Customer%Complaints%' + ) +group by + p_brand, + p_type, + p_size +order by + supplier_cnt desc, + p_brand, + p_type, + p_size +*/ + +source = partsupp +| join ON p_partkey = ps_partkey part +| where p_brand != 'Brand#45' + and not like(p_type, 'MEDIUM POLISHED%') + and p_size in (49, 14, 23, 45, 19, 3, 36, 9) + and ps_suppkey not in [ + source = supplier + | where like(s_comment, '%Customer%Complaints%') + | fields s_suppkey + ] +| stats distinct_count(ps_suppkey) as supplier_cnt by p_brand, p_type, p_size +| sort - supplier_cnt, p_brand, p_type, p_size \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q17.ppl b/integ-test/src/integration/resources/tpch/q17.ppl new file mode 100644 index 000000000..994b7ee18 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q17.ppl @@ -0,0 +1,34 @@ +/* +select + sum(l_extendedprice) / 7.0 as avg_yearly +from + lineitem, + part +where + p_partkey = l_partkey + and p_brand = 'Brand#23' + and p_container = 'MED BOX' + and l_quantity < ( + select + 0.2 * avg(l_quantity) + from + lineitem + where + l_partkey = p_partkey + ) +*/ + +source = lineitem +| join ON p_partkey = l_partkey part +| where p_brand = 'Brand#23' + and p_container = 'MED BOX' + and l_quantity < [ + source = lineitem + | where l_partkey = p_partkey + | stats avg(l_quantity) as avg + | eval `0.2 * avg` = 0.2 * avg // Stats and Eval commands can combine when issues/819 resolved + | fields `0.2 * avg` + ] +| stats sum(l_extendedprice) as sum +| eval avg_yearly = sum / 7.0 // Stats and Eval commands can combine when issues/819 resolved +| fields avg_yearly \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q18.ppl b/integ-test/src/integration/resources/tpch/q18.ppl new file mode 100644 index 000000000..1dab3d473 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q18.ppl @@ -0,0 +1,48 @@ +/* +select + c_name, + c_custkey, + o_orderkey, + o_orderdate, + o_totalprice, + sum(l_quantity) +from + customer, + orders, + lineitem +where + o_orderkey in ( + select + l_orderkey + from + lineitem + group by + l_orderkey having + sum(l_quantity) > 300 + ) + and c_custkey = o_custkey + and o_orderkey = l_orderkey +group by + c_name, + c_custkey, + o_orderkey, + o_orderdate, + o_totalprice +order by + o_totalprice desc, + o_orderdate +limit 100 +*/ + +source = customer +| join ON c_custkey = o_custkey orders +| join ON o_orderkey = l_orderkey lineitem +| where o_orderkey in [ + source = lineitem + | stats sum(l_quantity) as sum by l_orderkey + | where sum > 300 + | fields l_orderkey + ] +| stats sum(l_quantity) by c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice +| sort - o_totalprice, o_orderdate +| head 100 \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q19.ppl b/integ-test/src/integration/resources/tpch/q19.ppl new file mode 100644 index 000000000..630d63bcc --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q19.ppl @@ -0,0 +1,61 @@ +/* +select + sum(l_extendedprice* (1 - l_discount)) as revenue +from + lineitem, + part +where + ( + p_partkey = l_partkey + and p_brand = 'Brand#12' + and p_container in ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') + and l_quantity >= 1 and l_quantity <= 1 + 10 + and p_size between 1 and 5 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) + or + ( + p_partkey = l_partkey + and p_brand = 'Brand#23' + and p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') + and l_quantity >= 10 and l_quantity <= 10 + 10 + and p_size between 1 and 10 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) + or + ( + p_partkey = l_partkey + and p_brand = 'Brand#34' + and p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') + and l_quantity >= 20 and l_quantity <= 20 + 10 + and p_size between 1 and 15 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) +*/ + +source = lineitem +| join ON p_partkey = l_partkey + and p_brand = 'Brand#12' + and p_container in ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') + and l_quantity >= 1 and l_quantity <= 1 + 10 + and p_size between 1 and 5 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + OR p_partkey = l_partkey + and p_brand = 'Brand#23' + and p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') + and l_quantity >= 10 and l_quantity <= 10 + 10 + and p_size between 1 and 10 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + OR p_partkey = l_partkey + and p_brand = 'Brand#34' + and p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') + and l_quantity >= 20 and l_quantity <= 20 + 10 + and p_size between 1 and 15 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + part \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q2.ppl b/integ-test/src/integration/resources/tpch/q2.ppl new file mode 100644 index 000000000..aa95d9d14 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q2.ppl @@ -0,0 +1,62 @@ +/* +select + s_acctbal, + s_name, + n_name, + p_partkey, + p_mfgr, + s_address, + s_phone, + s_comment +from + part, + supplier, + partsupp, + nation, + region +where + p_partkey = ps_partkey + and s_suppkey = ps_suppkey + and p_size = 15 + and p_type like '%BRASS' + and s_nationkey = n_nationkey + and n_regionkey = r_regionkey + and r_name = 'EUROPE' + and ps_supplycost = ( + select + min(ps_supplycost) + from + partsupp, + supplier, + nation, + region + where + p_partkey = ps_partkey + and s_suppkey = ps_suppkey + and s_nationkey = n_nationkey + and n_regionkey = r_regionkey + and r_name = 'EUROPE' + ) +order by + s_acctbal desc, + n_name, + s_name, + p_partkey +limit 100 +*/ + +source = part +| join ON p_partkey = ps_partkey partsupp +| join ON s_suppkey = ps_suppkey supplier +| join ON s_nationkey = n_nationkey nation +| join ON n_regionkey = r_regionkey region +| where p_size = 15 AND like(p_type, '%BRASS') AND r_name = 'EUROPE' AND ps_supplycost = [ + source = partsupp + | join ON s_suppkey = ps_suppkey supplier + | join ON s_nationkey = n_nationkey nation + | join ON n_regionkey = r_regionkey region + | where r_name = 'EUROPE' + | stats MIN(ps_supplycost) + ] +| sort - s_acctbal, n_name, s_name, p_partkey +| head 100 \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q20.ppl b/integ-test/src/integration/resources/tpch/q20.ppl new file mode 100644 index 000000000..08bd21277 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q20.ppl @@ -0,0 +1,62 @@ +/* +select + s_name, + s_address +from + supplier, + nation +where + s_suppkey in ( + select + ps_suppkey + from + partsupp + where + ps_partkey in ( + select + p_partkey + from + part + where + p_name like 'forest%' + ) + and ps_availqty > ( + select + 0.5 * sum(l_quantity) + from + lineitem + where + l_partkey = ps_partkey + and l_suppkey = ps_suppkey + and l_shipdate >= date '1994-01-01' + and l_shipdate < date '1994-01-01' + interval '1' year + ) + ) + and s_nationkey = n_nationkey + and n_name = 'CANADA' +order by + s_name +*/ + +source = supplier +| join ON s_nationkey = n_nationkey nation +| where n_name = 'CANADA' + and s_suppkey in [ + source = partsupp + | where ps_partkey in [ + source = part + | where like(p_name, 'forest%') + | fields p_partkey + ] + and ps_availqty > [ + source = lineitem + | where l_partkey = ps_partkey + and l_suppkey = ps_suppkey + and l_shipdate >= date('1994-01-01') + and l_shipdate < date_add(date('1994-01-01'), interval 1 year) + | stats sum(l_quantity) as sum_l_quantity + | eval half_sum_l_quantity = 0.5 * sum_l_quantity // Stats and Eval commands can combine when issues/819 resolved + | fields half_sum_l_quantity + ] + | fields ps_suppkey + ] \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q21.ppl b/integ-test/src/integration/resources/tpch/q21.ppl new file mode 100644 index 000000000..0eb7149f6 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q21.ppl @@ -0,0 +1,64 @@ +/* +select + s_name, + count(*) as numwait +from + supplier, + lineitem l1, + orders, + nation +where + s_suppkey = l1.l_suppkey + and o_orderkey = l1.l_orderkey + and o_orderstatus = 'F' + and l1.l_receiptdate > l1.l_commitdate + and exists ( + select + * + from + lineitem l2 + where + l2.l_orderkey = l1.l_orderkey + and l2.l_suppkey <> l1.l_suppkey + ) + and not exists ( + select + * + from + lineitem l3 + where + l3.l_orderkey = l1.l_orderkey + and l3.l_suppkey <> l1.l_suppkey + and l3.l_receiptdate > l3.l_commitdate + ) + and s_nationkey = n_nationkey + and n_name = 'SAUDI ARABIA' +group by + s_name +order by + numwait desc, + s_name +limit 100 +*/ + +source = supplier +| join ON s_suppkey = l1.l_suppkey lineitem as l1 +| join ON o_orderkey = l1.l_orderkey orders +| join ON s_nationkey = n_nationkey nation +| where o_orderstatus = 'F' + and l1.l_receiptdate > l1.l_commitdate + and exists [ + source = lineitem as l2 + | where l2.l_orderkey = l1.l_orderkey + and l2.l_suppkey != l1.l_suppkey + ] + and not exists [ + source = lineitem as l3 + | where l3.l_orderkey = l1.l_orderkey + and l3.l_suppkey != l1.l_suppkey + and l3.l_receiptdate > l3.l_commitdate + ] + and n_name = 'SAUDI ARABIA' +| stats count() as numwait by s_name +| sort - numwait, s_name +| head 100 \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q22.ppl b/integ-test/src/integration/resources/tpch/q22.ppl new file mode 100644 index 000000000..811308cb0 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q22.ppl @@ -0,0 +1,58 @@ +/* +select + cntrycode, + count(*) as numcust, + sum(c_acctbal) as totacctbal +from + ( + select + substring(c_phone, 1, 2) as cntrycode, + c_acctbal + from + customer + where + substring(c_phone, 1, 2) in + ('13', '31', '23', '29', '30', '18', '17') + and c_acctbal > ( + select + avg(c_acctbal) + from + customer + where + c_acctbal > 0.00 + and substring(c_phone, 1, 2) in + ('13', '31', '23', '29', '30', '18', '17') + ) + and not exists ( + select + * + from + orders + where + o_custkey = c_custkey + ) + ) as custsale +group by + cntrycode +order by + cntrycode +*/ + +source = [ + source = customer + | where substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + and c_acctbal > [ + source = customer + | where c_acctbal > 0.00 + and substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + | stats avg(c_acctbal) + ] + and not exists [ + source = orders + | where o_custkey = c_custkey + ] + | eval cntrycode = substring(c_phone, 1, 2) + | fields cntrycode, c_acctbal + ] as custsale +| stats count() as numcust, sum(c_acctbal) as totacctbal by cntrycode +| sort cntrycode \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q3.ppl b/integ-test/src/integration/resources/tpch/q3.ppl new file mode 100644 index 000000000..0ece358ab --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q3.ppl @@ -0,0 +1,33 @@ +/* +select + l_orderkey, + sum(l_extendedprice * (1 - l_discount)) as revenue, + o_orderdate, + o_shippriority +from + customer, + orders, + lineitem +where + c_mktsegment = 'BUILDING' + and c_custkey = o_custkey + and l_orderkey = o_orderkey + and o_orderdate < date '1995-03-15' + and l_shipdate > date '1995-03-15' +group by + l_orderkey, + o_orderdate, + o_shippriority +order by + revenue desc, + o_orderdate +limit 10 +*/ + +source = customer +| join ON c_custkey = o_custkey orders +| join ON l_orderkey = o_orderkey lineitem +| where c_mktsegment = 'BUILDING' AND o_orderdate < date('1995-03-15') AND l_shipdate > date('1995-03-15') +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by l_orderkey, o_orderdate, o_shippriority +| sort - revenue, o_orderdate +| head 10 \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q4.ppl b/integ-test/src/integration/resources/tpch/q4.ppl new file mode 100644 index 000000000..cc01bda7d --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q4.ppl @@ -0,0 +1,33 @@ +/* +select + o_orderpriority, + count(*) as order_count +from + orders +where + o_orderdate >= date '1993-07-01' + and o_orderdate < date '1993-07-01' + interval '3' month + and exists ( + select + * + from + lineitem + where + l_orderkey = o_orderkey + and l_commitdate < l_receiptdate + ) +group by + o_orderpriority +order by + o_orderpriority +*/ + +source = orders +| where o_orderdate >= date('1993-07-01') + and o_orderdate < date_add(date('1993-07-01'), interval 3 month) + and exists [ + source = lineitem + | where l_orderkey = o_orderkey and l_commitdate < l_receiptdate + ] +| stats count() as order_count by o_orderpriority +| sort o_orderpriority \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q5.ppl b/integ-test/src/integration/resources/tpch/q5.ppl new file mode 100644 index 000000000..4761b0365 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q5.ppl @@ -0,0 +1,36 @@ +/* +select + n_name, + sum(l_extendedprice * (1 - l_discount)) as revenue +from + customer, + orders, + lineitem, + supplier, + nation, + region +where + c_custkey = o_custkey + and l_orderkey = o_orderkey + and l_suppkey = s_suppkey + and c_nationkey = s_nationkey + and s_nationkey = n_nationkey + and n_regionkey = r_regionkey + and r_name = 'ASIA' + and o_orderdate >= date '1994-01-01' + and o_orderdate < date '1994-01-01' + interval '1' year +group by + n_name +order by + revenue desc +*/ + +source = customer +| join ON c_custkey = o_custkey orders +| join ON l_orderkey = o_orderkey lineitem +| join ON l_suppkey = s_suppkey AND c_nationkey = s_nationkey supplier +| join ON s_nationkey = n_nationkey nation +| join ON n_regionkey = r_regionkey region +| where r_name = 'ASIA' AND o_orderdate >= date('1994-01-01') AND o_orderdate < date_add(date('1994-01-01'), interval 1 year) +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by n_name +| sort - revenue \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q6.ppl b/integ-test/src/integration/resources/tpch/q6.ppl new file mode 100644 index 000000000..6a77877c3 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q6.ppl @@ -0,0 +1,18 @@ +/* +select + sum(l_extendedprice * l_discount) as revenue +from + lineitem +where + l_shipdate >= date '1994-01-01' + and l_shipdate < date '1994-01-01' + interval '1' year + and l_discount between .06 - 0.01 and .06 + 0.01 + and l_quantity < 24 +*/ + +source = lineitem +| where l_shipdate >= date('1994-01-01') + and l_shipdate < adddate(date('1994-01-01'), 365) + and l_discount between .06 - 0.01 and .06 + 0.01 + and l_quantity < 24 +| stats sum(l_extendedprice * l_discount) as revenue \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q7.ppl b/integ-test/src/integration/resources/tpch/q7.ppl new file mode 100644 index 000000000..ceda602b3 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q7.ppl @@ -0,0 +1,56 @@ +/* +select + supp_nation, + cust_nation, + l_year, + sum(volume) as revenue +from + ( + select + n1.n_name as supp_nation, + n2.n_name as cust_nation, + year(l_shipdate) as l_year, + l_extendedprice * (1 - l_discount) as volume + from + supplier, + lineitem, + orders, + customer, + nation n1, + nation n2 + where + s_suppkey = l_suppkey + and o_orderkey = l_orderkey + and c_custkey = o_custkey + and s_nationkey = n1.n_nationkey + and c_nationkey = n2.n_nationkey + and ( + (n1.n_name = 'FRANCE' and n2.n_name = 'GERMANY') + or (n1.n_name = 'GERMANY' and n2.n_name = 'FRANCE') + ) + and l_shipdate between date '1995-01-01' and date '1996-12-31' + ) as shipping +group by + supp_nation, + cust_nation, + l_year +order by + supp_nation, + cust_nation, + l_year +*/ + +source = [ + source = supplier + | join ON s_suppkey = l_suppkey lineitem + | join ON o_orderkey = l_orderkey orders + | join ON c_custkey = o_custkey customer + | join ON s_nationkey = n1.n_nationkey nation as n1 + | join ON c_nationkey = n2.n_nationkey nation as n2 + | where l_shipdate between date('1995-01-01') and date('1996-12-31') + and n1.n_name = 'FRANCE' and n2.n_name = 'GERMANY' or n1.n_name = 'GERMANY' and n2.n_name = 'FRANCE' + | eval supp_nation = n1.n_name, cust_nation = n2.n_name, l_year = year(l_shipdate), volume = l_extendedprice * (1 - l_discount) + | fields supp_nation, cust_nation, l_year, volume + ] as shipping +| stats sum(volume) as revenue by supp_nation, cust_nation, l_year +| sort supp_nation, cust_nation, l_year \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q8.ppl b/integ-test/src/integration/resources/tpch/q8.ppl new file mode 100644 index 000000000..a73c7f7c3 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q8.ppl @@ -0,0 +1,60 @@ +/* +select + o_year, + sum(case + when nation = 'BRAZIL' then volume + else 0 + end) / sum(volume) as mkt_share +from + ( + select + year(o_orderdate) as o_year, + l_extendedprice * (1 - l_discount) as volume, + n2.n_name as nation + from + part, + supplier, + lineitem, + orders, + customer, + nation n1, + nation n2, + region + where + p_partkey = l_partkey + and s_suppkey = l_suppkey + and l_orderkey = o_orderkey + and o_custkey = c_custkey + and c_nationkey = n1.n_nationkey + and n1.n_regionkey = r_regionkey + and r_name = 'AMERICA' + and s_nationkey = n2.n_nationkey + and o_orderdate between date '1995-01-01' and date '1996-12-31' + and p_type = 'ECONOMY ANODIZED STEEL' + ) as all_nations +group by + o_year +order by + o_year +*/ + +source = [ + source = part + | join ON p_partkey = l_partkey lineitem + | join ON s_suppkey = l_suppkey supplier + | join ON l_orderkey = o_orderkey orders + | join ON o_custkey = c_custkey customer + | join ON c_nationkey = n1.n_nationkey nation as n1 + | join ON s_nationkey = n2.n_nationkey nation as n2 + | join ON n1.n_regionkey = r_regionkey region + | where r_name = 'AMERICA' AND p_type = 'ECONOMY ANODIZED STEEL' + and o_orderdate between date('1995-01-01') and date('1996-12-31') + | eval o_year = year(o_orderdate) + | eval volume = l_extendedprice * (1 - l_discount) + | eval nation = n2.n_name + | fields o_year, volume, nation + ] as all_nations +| stats sum(case(nation = 'BRAZIL', volume else 0)) as sum_case, sum(volume) as sum_volume by o_year +| eval mkt_share = sum_case / sum_volume +| fields mkt_share, o_year +| sort o_year \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q9.ppl b/integ-test/src/integration/resources/tpch/q9.ppl new file mode 100644 index 000000000..7692afd74 --- /dev/null +++ b/integ-test/src/integration/resources/tpch/q9.ppl @@ -0,0 +1,50 @@ +/* +select + nation, + o_year, + sum(amount) as sum_profit +from + ( + select + n_name as nation, + year(o_orderdate) as o_year, + l_extendedprice * (1 - l_discount) - ps_supplycost * l_quantity as amount + from + part, + supplier, + lineitem, + partsupp, + orders, + nation + where + s_suppkey = l_suppkey + and ps_suppkey = l_suppkey + and ps_partkey = l_partkey + and p_partkey = l_partkey + and o_orderkey = l_orderkey + and s_nationkey = n_nationkey + and p_name like '%green%' + ) as profit +group by + nation, + o_year +order by + nation, + o_year desc +*/ + +source = [ + source = part + | join ON p_partkey = l_partkey lineitem + | join ON s_suppkey = l_suppkey supplier + | join ON ps_partkey = l_partkey and ps_suppkey = l_suppkey partsupp + | join ON o_orderkey = l_orderkey orders + | join ON s_nationkey = n_nationkey nation + | where like(p_name, '%green%') + | eval nation = n_name + | eval o_year = year(o_orderdate) + | eval amount = l_extendedprice * (1 - l_discount) - ps_supplycost * l_quantity + | fields nation, o_year, amount + ] as profit +| stats sum(amount) as sum_profit by nation, o_year +| sort nation, - o_year \ No newline at end of file diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryBase.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryBase.scala new file mode 100644 index 000000000..fb14210e9 --- /dev/null +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryBase.scala @@ -0,0 +1,177 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.flint.spark.ppl.tpch + +import org.opensearch.flint.spark.ppl.FlintPPLSuite + +import org.apache.spark.SparkConf +import org.apache.spark.sql.catalyst.TableIdentifier +import org.apache.spark.sql.catalyst.expressions.codegen.{ByteCodeStats, CodeFormatter, CodeGenerator} +import org.apache.spark.sql.catalyst.rules.RuleExecutor +import org.apache.spark.sql.catalyst.util.DateTimeConstants.NANOS_PER_SECOND +import org.apache.spark.sql.execution.{SparkPlan, WholeStageCodegenExec} +import org.apache.spark.sql.internal.SQLConf + +trait TPCHQueryBase extends FlintPPLSuite { + + override protected def sparkConf: SparkConf = { + super.sparkConf.set(SQLConf.MAX_TO_STRING_FIELDS.key, Int.MaxValue.toString) + } + + override def beforeAll(): Unit = { + super.beforeAll() + RuleExecutor.resetMetrics() + CodeGenerator.resetCompileTime() + WholeStageCodegenExec.resetCodeGenTime() + tpchCreateTable.values.foreach { ppl => + sql(ppl) + } + } + + override def afterAll(): Unit = { + try { + tpchCreateTable.keys.foreach { tableName => + spark.sessionState.catalog.dropTable(TableIdentifier(tableName), true, true) + } + // For debugging dump some statistics about how much time was spent in various optimizer rules + // code generation, and compilation. + logWarning(RuleExecutor.dumpTimeSpent()) + val codeGenTime = WholeStageCodegenExec.codeGenTime.toDouble / NANOS_PER_SECOND + val compileTime = CodeGenerator.compileTime.toDouble / NANOS_PER_SECOND + val codegenInfo = + s""" + |=== Metrics of Whole-stage Codegen === + |Total code generation time: $codeGenTime seconds + |Total compile time: $compileTime seconds + """.stripMargin + logWarning(codegenInfo) + spark.sessionState.catalog.reset() + } finally { + super.afterAll() + } + } + + def checkGeneratedCode(plan: SparkPlan, checkMethodCodeSize: Boolean = true): Unit = { + val codegenSubtrees = new collection.mutable.HashSet[WholeStageCodegenExec]() + + def findSubtrees(plan: SparkPlan): Unit = { + plan foreach { + case s: WholeStageCodegenExec => + codegenSubtrees += s + case s => + s.subqueries.foreach(findSubtrees) + } + } + + findSubtrees(plan) + codegenSubtrees.toSeq.foreach { subtree => + val code = subtree.doCodeGen()._2 + val (_, ByteCodeStats(maxMethodCodeSize, _, _)) = + try { + // Just check the generated code can be properly compiled + CodeGenerator.compile(code) + } catch { + case e: Exception => + val msg = + s""" + |failed to compile: + |Subtree: + |$subtree + |Generated code: + |${CodeFormatter.format(code)} + """.stripMargin + throw new Exception(msg, e) + } + + assert( + !checkMethodCodeSize || + maxMethodCodeSize <= CodeGenerator.DEFAULT_JVM_HUGE_METHOD_LIMIT, + s"too long generated codes found in the WholeStageCodegenExec subtree (id=${subtree.id}) " + + s"and JIT optimization might not work:\n${subtree.treeString}") + } + } + + val tpchCreateTable = Map( + "orders" -> + """ + |CREATE TABLE `orders` ( + |`o_orderkey` BIGINT, `o_custkey` BIGINT, `o_orderstatus` STRING, + |`o_totalprice` DECIMAL(10,0), `o_orderdate` DATE, `o_orderpriority` STRING, + |`o_clerk` STRING, `o_shippriority` INT, `o_comment` STRING) + |USING parquet + """.stripMargin, + "nation" -> + """ + |CREATE TABLE `nation` ( + |`n_nationkey` BIGINT, `n_name` STRING, `n_regionkey` BIGINT, `n_comment` STRING) + |USING parquet + """.stripMargin, + "region" -> + """ + |CREATE TABLE `region` ( + |`r_regionkey` BIGINT, `r_name` STRING, `r_comment` STRING) + |USING parquet + """.stripMargin, + "part" -> + """ + |CREATE TABLE `part` (`p_partkey` BIGINT, `p_name` STRING, `p_mfgr` STRING, + |`p_brand` STRING, `p_type` STRING, `p_size` INT, `p_container` STRING, + |`p_retailprice` DECIMAL(10,0), `p_comment` STRING) + |USING parquet + """.stripMargin, + "partsupp" -> + """ + |CREATE TABLE `partsupp` (`ps_partkey` BIGINT, `ps_suppkey` BIGINT, + |`ps_availqty` INT, `ps_supplycost` DECIMAL(10,0), `ps_comment` STRING) + |USING parquet + """.stripMargin, + "customer" -> + """ + |CREATE TABLE `customer` (`c_custkey` BIGINT, `c_name` STRING, `c_address` STRING, + |`c_nationkey` BIGINT, `c_phone` STRING, `c_acctbal` DECIMAL(10,0), + |`c_mktsegment` STRING, `c_comment` STRING) + |USING parquet + """.stripMargin, + "supplier" -> + """ + |CREATE TABLE `supplier` (`s_suppkey` BIGINT, `s_name` STRING, `s_address` STRING, + |`s_nationkey` BIGINT, `s_phone` STRING, `s_acctbal` DECIMAL(10,0), `s_comment` STRING) + |USING parquet + """.stripMargin, + "lineitem" -> + """ + |CREATE TABLE `lineitem` (`l_orderkey` BIGINT, `l_partkey` BIGINT, `l_suppkey` BIGINT, + |`l_linenumber` INT, `l_quantity` DECIMAL(10,0), `l_extendedprice` DECIMAL(10,0), + |`l_discount` DECIMAL(10,0), `l_tax` DECIMAL(10,0), `l_returnflag` STRING, + |`l_linestatus` STRING, `l_shipdate` DATE, `l_commitdate` DATE, `l_receiptdate` DATE, + |`l_shipinstruct` STRING, `l_shipmode` STRING, `l_comment` STRING) + |USING parquet + """.stripMargin) + + val tpchQueries = Seq( + "q1", + "q2", + "q3", + "q4", + "q5", + "q6", + "q7", + "q8", + "q9", + "q10", + "q11", + "q12", + "q13", + "q14", + "q15", + "q16", + "q17", + "q18", + "q19", + "q20", + "q21", + "q22") +} diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryITSuite.scala new file mode 100644 index 000000000..1b9681618 --- /dev/null +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/tpch/TPCHQueryITSuite.scala @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.flint.spark.ppl.tpch + +import org.opensearch.flint.spark.ppl.LogicalPlanTestUtils + +import org.apache.spark.sql.QueryTest +import org.apache.spark.sql.catalyst.util.resourceToString +import org.apache.spark.sql.streaming.StreamTest + +class TPCHQueryITSuite + extends QueryTest + with LogicalPlanTestUtils + with TPCHQueryBase + with StreamTest { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + protected override def afterEach(): Unit = { + super.afterEach() + // Stop all streaming jobs if any + spark.streams.active.foreach { job => + job.stop() + job.awaitTermination() + } + } + + tpchQueries.foreach { name => + val queryString = resourceToString( + s"tpch/$name.ppl", + classLoader = Thread.currentThread().getContextClassLoader) + test(name) { + // check the plans can be properly generated + val plan = sql(queryString).queryExecution.executedPlan + checkGeneratedCode(plan) + } + } +} From 4303057aad2c0edd0ae2c75ef48bee81cd4bb7af Mon Sep 17 00:00:00 2001 From: YANGDB Date: Wed, 6 Nov 2024 18:25:25 -0800 Subject: [PATCH 04/26] Expand ppl command (#868) * add expand command Signed-off-by: YANGDB * add expand command with visitor Signed-off-by: YANGDB * create unit / integration tests Signed-off-by: YANGDB * update expand tests Signed-off-by: YANGDB * add tests Signed-off-by: YANGDB * update doc Signed-off-by: YANGDB * update docs with examples Signed-off-by: YANGDB * update scala style Signed-off-by: YANGDB * update with additional test case remove outer generator Signed-off-by: YANGDB * update with additional test case remove outer generator Signed-off-by: YANGDB * update documentation Signed-off-by: YANGDB --------- Signed-off-by: YANGDB --- docs/ppl-lang/PPL-Example-Commands.md | 37 ++- docs/ppl-lang/README.md | 2 + docs/ppl-lang/ppl-expand-command.md | 45 +++ .../flint/spark/FlintSparkSuite.scala | 22 ++ .../ppl/FlintSparkPPLExpandITSuite.scala | 255 ++++++++++++++++ .../src/main/antlr4/OpenSearchPPLLexer.g4 | 1 + .../src/main/antlr4/OpenSearchPPLParser.g4 | 6 + .../sql/ast/AbstractNodeVisitor.java | 4 + .../org/opensearch/sql/ast/tree/Expand.java | 44 +++ .../org/opensearch/sql/ast/tree/Flatten.java | 4 +- .../sql/ppl/CatalystQueryPlanVisitor.java | 25 +- .../opensearch/sql/ppl/parser/AstBuilder.java | 6 + ...PlanExpandCommandTranslatorTestSuite.scala | 281 ++++++++++++++++++ 13 files changed, 716 insertions(+), 16 deletions(-) create mode 100644 docs/ppl-lang/ppl-expand-command.md create mode 100644 integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLExpandITSuite.scala create mode 100644 ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Expand.java create mode 100644 ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanExpandCommandTranslatorTestSuite.scala diff --git a/docs/ppl-lang/PPL-Example-Commands.md b/docs/ppl-lang/PPL-Example-Commands.md index e80f8c906..4ea564111 100644 --- a/docs/ppl-lang/PPL-Example-Commands.md +++ b/docs/ppl-lang/PPL-Example-Commands.md @@ -441,8 +441,30 @@ Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table in _- **Limitation: another command usage of (relation) subquery is in `appendcols` commands which is unsupported**_ ---- -#### Experimental Commands: + +#### **fillnull** +[See additional command details](ppl-fillnull-command.md) +```sql + - `source=accounts | fillnull fields status_code=101` + - `source=accounts | fillnull fields request_path='/not_found', timestamp='*'` + - `source=accounts | fillnull using field1=101` + - `source=accounts | fillnull using field1=concat(field2, field3), field4=2*pi()*field5` + - `source=accounts | fillnull using field1=concat(field2, field3), field4=2*pi()*field5, field6 = 'N/A'` +``` + +#### **expand** +[See additional command details](ppl-expand-command.md) +```sql + - `source = table | expand field_with_array as array_list` + - `source = table | expand employee | stats max(salary) as max by state, company` + - `source = table | expand employee as worker | stats max(salary) as max by state, company` + - `source = table | expand employee as worker | eval bonus = salary * 3 | fields worker, bonus` + - `source = table | expand employee | parse description '(?.+@.+)' | fields employee, email` + - `source = table | eval array=json_array(1, 2, 3) | expand array as uid | fields name, occupation, uid` + - `source = table | expand multi_valueA as multiA | expand multi_valueB as multiB` +``` + +#### Correlation Commands: [See additional command details](ppl-correlation-command.md) ```sql @@ -454,14 +476,3 @@ _- **Limitation: another command usage of (relation) subquery is in `appendcols` > ppl-correlation-command is an experimental command - it may be removed in future versions --- -### Planned Commands: - -#### **fillnull** -[See additional command details](ppl-fillnull-command.md) -```sql - - `source=accounts | fillnull fields status_code=101` - - `source=accounts | fillnull fields request_path='/not_found', timestamp='*'` - - `source=accounts | fillnull using field1=101` - - `source=accounts | fillnull using field1=concat(field2, field3), field4=2*pi()*field5` - - `source=accounts | fillnull using field1=concat(field2, field3), field4=2*pi()*field5, field6 = 'N/A'` -``` diff --git a/docs/ppl-lang/README.md b/docs/ppl-lang/README.md index ef186e5f2..d72c973be 100644 --- a/docs/ppl-lang/README.md +++ b/docs/ppl-lang/README.md @@ -71,6 +71,8 @@ For additional examples see the next [documentation](PPL-Example-Commands.md). - [`correlation commands`](ppl-correlation-command.md) - [`trendline commands`](ppl-trendline-command.md) + + - [`expand commands`](ppl-expand-command.md) * **Functions** diff --git a/docs/ppl-lang/ppl-expand-command.md b/docs/ppl-lang/ppl-expand-command.md new file mode 100644 index 000000000..144c0aafa --- /dev/null +++ b/docs/ppl-lang/ppl-expand-command.md @@ -0,0 +1,45 @@ +## PPL `expand` command + +### Description +Using `expand` command to flatten a field of type: +- `Array` +- `Map` + + +### Syntax +`expand [As alias]` + +* field: to be expanded (exploded). The field must be of supported type. +* alias: Optional to be expanded as the name to be used instead of the original field name + +### Usage Guidelines +The expand command produces a row for each element in the specified array or map field, where: +- Array elements become individual rows. +- Map key-value pairs are broken into separate rows, with each key-value represented as a row. + +- When an alias is provided, the exploded values are represented under the alias instead of the original field name. +- This can be used in combination with other commands, such as stats, eval, and parse to manipulate or extract data post-expansion. + +### Examples: +- `source = table | expand employee | stats max(salary) as max by state, company` +- `source = table | expand employee as worker | stats max(salary) as max by state, company` +- `source = table | expand employee as worker | eval bonus = salary * 3 | fields worker, bonus` +- `source = table | expand employee | parse description '(?.+@.+)' | fields employee, email` +- `source = table | eval array=json_array(1, 2, 3) | expand array as uid | fields name, occupation, uid` +- `source = table | expand multi_valueA as multiA | expand multi_valueB as multiB` + +- Expand command can be used in combination with other commands such as `eval`, `stats` and more +- Using multiple expand commands will create a cartesian product of all the internal elements within each composite array or map + +### Effective SQL push-down query +The expand command is translated into an equivalent SQL operation using LATERAL VIEW explode, allowing for efficient exploding of arrays or maps at the SQL query level. + +```sql +SELECT customer exploded_productId +FROM table +LATERAL VIEW explode(productId) AS exploded_productId +``` +Where the `explode` command offers the following functionality: +- it is a column operation that returns a new column +- it creates a new row for every element in the exploded column +- internal `null`s are ignored as part of the exploded field (no row is created/exploded for null) diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkSuite.scala index c53eee548..68d370791 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkSuite.scala @@ -559,6 +559,28 @@ trait FlintSparkSuite extends QueryTest with FlintSuite with OpenSearchSuite wit |""".stripMargin) } + protected def createMultiColumnArrayTable(testTable: String): Unit = { + // CSV doesn't support struct field + sql(s""" + | CREATE TABLE $testTable + | ( + | int_col INT, + | multi_valueA Array>, + | multi_valueB Array> + | ) + | USING JSON + |""".stripMargin) + + sql(s""" + | INSERT INTO $testTable + | VALUES + | ( 1, array(STRUCT("1_one", 1), STRUCT(null, 11), STRUCT("1_three", null)), array(STRUCT("2_Monday", 2), null) ), + | ( 2, array(STRUCT("2_Monday", 2), null) , array(STRUCT("3_third", 3), STRUCT("3_4th", 4)) ), + | ( 3, array(STRUCT("3_third", 3), STRUCT("3_4th", 4)) , array(STRUCT("1_one", 1))), + | ( 4, null, array(STRUCT("1_one", 1))) + |""".stripMargin) + } + protected def createTableIssue112(testTable: String): Unit = { sql(s""" | CREATE TABLE $testTable ( diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLExpandITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLExpandITSuite.scala new file mode 100644 index 000000000..f0404bf7b --- /dev/null +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLExpandITSuite.scala @@ -0,0 +1,255 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.flint.spark.ppl + +import java.nio.file.Files + +import scala.collection.mutable + +import org.opensearch.sql.ppl.utils.DataTypeTransformer.seq + +import org.apache.spark.sql.{QueryTest, Row} +import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} +import org.apache.spark.sql.catalyst.expressions.{Alias, EqualTo, Explode, GeneratorOuter, Literal, Or} +import org.apache.spark.sql.catalyst.plans.logical._ +import org.apache.spark.sql.streaming.StreamTest + +class FlintSparkPPLExpandITSuite + extends QueryTest + with LogicalPlanTestUtils + with FlintPPLSuite + with StreamTest { + + private val testTable = "flint_ppl_test" + private val occupationTable = "spark_catalog.default.flint_ppl_flat_table_test" + private val structNestedTable = "spark_catalog.default.flint_ppl_struct_nested_test" + private val structTable = "spark_catalog.default.flint_ppl_struct_test" + private val multiValueTable = "spark_catalog.default.flint_ppl_multi_value_test" + private val multiArraysTable = "spark_catalog.default.flint_ppl_multi_array_test" + private val tempFile = Files.createTempFile("jsonTestData", ".json") + + override def beforeAll(): Unit = { + super.beforeAll() + + // Create test table + createNestedJsonContentTable(tempFile, testTable) + createOccupationTable(occupationTable) + createStructNestedTable(structNestedTable) + createStructTable(structTable) + createMultiValueStructTable(multiValueTable) + createMultiColumnArrayTable(multiArraysTable) + } + + protected override def afterEach(): Unit = { + super.afterEach() + // Stop all streaming jobs if any + spark.streams.active.foreach { job => + job.stop() + job.awaitTermination() + } + } + + override def afterAll(): Unit = { + super.afterAll() + Files.deleteIfExists(tempFile) + } + + test("expand for eval field of an array") { + val frame = sql( + s""" source = $occupationTable | eval array=json_array(1, 2, 3) | expand array as uid | fields name, occupation, uid + """.stripMargin) + + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = Array( + Row("Jake", "Engineer", 1), + Row("Jake", "Engineer", 2), + Row("Jake", "Engineer", 3), + Row("Hello", "Artist", 1), + Row("Hello", "Artist", 2), + Row("Hello", "Artist", 3), + Row("John", "Doctor", 1), + Row("John", "Doctor", 2), + Row("John", "Doctor", 3), + Row("David", "Doctor", 1), + Row("David", "Doctor", 2), + Row("David", "Doctor", 3), + Row("David", "Unemployed", 1), + Row("David", "Unemployed", 2), + Row("David", "Unemployed", 3), + Row("Jane", "Scientist", 1), + Row("Jane", "Scientist", 2), + Row("Jane", "Scientist", 3)) + + // Compare the results + assert(results.toSet == expectedResults.toSet) + + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // expected plan + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_flat_table_test")) + val jsonFunc = + UnresolvedFunction("array", Seq(Literal(1), Literal(2), Literal(3)), isDistinct = false) + val aliasA = Alias(jsonFunc, "array")() + val project = Project(seq(UnresolvedStar(None), aliasA), table) + val generate = Generate( + Explode(UnresolvedAttribute("array")), + seq(), + false, + None, + seq(UnresolvedAttribute("uid")), + project) + val dropSourceColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("array")), generate) + val expectedPlan = Project( + seq( + UnresolvedAttribute("name"), + UnresolvedAttribute("occupation"), + UnresolvedAttribute("uid")), + dropSourceColumn) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + + test("expand for structs") { + val frame = sql( + s""" source = $multiValueTable | expand multi_value AS exploded_multi_value | fields exploded_multi_value + """.stripMargin) + + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = Array( + Row(Row("1_one", 1)), + Row(Row(null, 11)), + Row(Row("1_three", null)), + Row(Row("2_Monday", 2)), + Row(null), + Row(Row("3_third", 3)), + Row(Row("3_4th", 4)), + Row(null)) + // Compare the results + assert(results.toSet == expectedResults.toSet) + + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // expected plan + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_multi_value_test")) + val generate = Generate( + Explode(UnresolvedAttribute("multi_value")), + seq(), + outer = false, + None, + seq(UnresolvedAttribute("exploded_multi_value")), + table) + val dropSourceColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("multi_value")), generate) + val expectedPlan = Project(Seq(UnresolvedAttribute("exploded_multi_value")), dropSourceColumn) + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("expand for array of structs") { + val frame = sql(s""" + | source = $testTable + | | where country = 'England' or country = 'Poland' + | | expand bridges + | | fields bridges + | """.stripMargin) + + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = Array( + Row(mutable.WrappedArray.make(Array(Row(801, "Tower Bridge"), Row(928, "London Bridge")))), + Row(mutable.WrappedArray.make(Array(Row(801, "Tower Bridge"), Row(928, "London Bridge")))) + // Row(null)) -> in case of outerGenerator = GeneratorOuter(Explode(UnresolvedAttribute("bridges"))) it will include the `null` row + ) + + // Compare the results + assert(results.toSet == expectedResults.toSet) + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val table = UnresolvedRelation(Seq("flint_ppl_test")) + val filter = Filter( + Or( + EqualTo(UnresolvedAttribute("country"), Literal("England")), + EqualTo(UnresolvedAttribute("country"), Literal("Poland"))), + table) + val generate = + Generate(Explode(UnresolvedAttribute("bridges")), seq(), outer = false, None, seq(), filter) + val expectedPlan = Project(Seq(UnresolvedAttribute("bridges")), generate) + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("expand for array of structs with alias") { + val frame = sql(s""" + | source = $testTable + | | where country = 'England' + | | expand bridges as britishBridges + | | fields britishBridges + | """.stripMargin) + + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = Array( + Row(Row(801, "Tower Bridge")), + Row(Row(928, "London Bridge")), + Row(Row(801, "Tower Bridge")), + Row(Row(928, "London Bridge"))) + // Compare the results + assert(results.toSet == expectedResults.toSet) + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val table = UnresolvedRelation(Seq("flint_ppl_test")) + val filter = Filter(EqualTo(UnresolvedAttribute("country"), Literal("England")), table) + val generate = Generate( + Explode(UnresolvedAttribute("bridges")), + seq(), + outer = false, + None, + seq(UnresolvedAttribute("britishBridges")), + filter) + val dropColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("bridges")), generate) + val expectedPlan = Project(Seq(UnresolvedAttribute("britishBridges")), dropColumn) + + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("expand multi columns array table") { + val frame = sql(s""" + | source = $multiArraysTable + | | expand multi_valueA as multiA + | | expand multi_valueB as multiB + | """.stripMargin) + + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = Array( + Row(1, Row("1_one", 1), Row("2_Monday", 2)), + Row(1, Row("1_one", 1), null), + Row(1, Row(null, 11), Row("2_Monday", 2)), + Row(1, Row(null, 11), null), + Row(1, Row("1_three", null), Row("2_Monday", 2)), + Row(1, Row("1_three", null), null), + Row(2, Row("2_Monday", 2), Row("3_third", 3)), + Row(2, Row("2_Monday", 2), Row("3_4th", 4)), + Row(2, null, Row("3_third", 3)), + Row(2, null, Row("3_4th", 4)), + Row(3, Row("3_third", 3), Row("1_one", 1)), + Row(3, Row("3_4th", 4), Row("1_one", 1))) + // Compare the results + assert(results.toSet == expectedResults.toSet) + + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_multi_array_test")) + val generatorA = Explode(UnresolvedAttribute("multi_valueA")) + val generateA = + Generate(generatorA, seq(), false, None, seq(UnresolvedAttribute("multiA")), table) + val dropSourceColumnA = + DataFrameDropColumns(Seq(UnresolvedAttribute("multi_valueA")), generateA) + val generatorB = Explode(UnresolvedAttribute("multi_valueB")) + val generateB = Generate( + generatorB, + seq(), + false, + None, + seq(UnresolvedAttribute("multiB")), + dropSourceColumnA) + val dropSourceColumnB = + DataFrameDropColumns(Seq(UnresolvedAttribute("multi_valueB")), generateB) + val expectedPlan = Project(seq(UnresolvedStar(None)), dropSourceColumnB) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + + } +} diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 index 93efb2df1..2c3344b3c 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 @@ -37,6 +37,7 @@ KMEANS: 'KMEANS'; AD: 'AD'; ML: 'ML'; FILLNULL: 'FILLNULL'; +EXPAND: 'EXPAND'; FLATTEN: 'FLATTEN'; TRENDLINE: 'TRENDLINE'; diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index 123d1e15a..1cfd172f7 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -54,6 +54,7 @@ commands | fillnullCommand | fieldsummaryCommand | flattenCommand + | expandCommand | trendlineCommand ; @@ -82,6 +83,7 @@ commandName | PATTERNS | LOOKUP | RENAME + | EXPAND | FILLNULL | FIELDSUMMARY | FLATTEN @@ -250,6 +252,10 @@ fillnullCommand : expression ; +expandCommand + : EXPAND fieldExpression (AS alias = qualifiedName)? + ; + flattenCommand : FLATTEN fieldExpression ; diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java index 189d9084a..54e1205cb 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java @@ -108,6 +108,10 @@ public T visitFilter(Filter node, C context) { return visitChildren(node, context); } + public T visitExpand(Expand node, C context) { + return visitChildren(node, context); + } + public T visitLookup(Lookup node, C context) { return visitChildren(node, context); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Expand.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Expand.java new file mode 100644 index 000000000..0e164ccd7 --- /dev/null +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Expand.java @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.ast.tree; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.opensearch.sql.ast.AbstractNodeVisitor; +import org.opensearch.sql.ast.Node; +import org.opensearch.sql.ast.expression.Field; +import org.opensearch.sql.ast.expression.UnresolvedAttribute; +import org.opensearch.sql.ast.expression.UnresolvedExpression; + +import java.util.List; +import java.util.Optional; + +/** Logical plan node of Expand */ +@RequiredArgsConstructor +public class Expand extends UnresolvedPlan { + private UnresolvedPlan child; + + @Getter + private final Field field; + @Getter + private final Optional alias; + + @Override + public Expand attach(UnresolvedPlan child) { + this.child = child; + return this; + } + + @Override + public List getChild() { + return child == null ? List.of() : List.of(child); + } + + @Override + public T accept(AbstractNodeVisitor nodeVisitor, C context) { + return nodeVisitor.visitExpand(this, context); + } +} diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Flatten.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Flatten.java index e31fbb6e3..9c57d2adf 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Flatten.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Flatten.java @@ -14,7 +14,7 @@ public class Flatten extends UnresolvedPlan { private UnresolvedPlan child; @Getter - private final Field fieldToBeFlattened; + private final Field field; @Override public UnresolvedPlan attach(UnresolvedPlan child) { @@ -26,7 +26,7 @@ public UnresolvedPlan attach(UnresolvedPlan child) { public List getChild() { return child == null ? List.of() : List.of(child); } - + @Override public T accept(AbstractNodeVisitor nodeVisitor, C context) { return nodeVisitor.visitFlatten(this, context); diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java index a43378480..d2ee46ae6 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java @@ -11,6 +11,7 @@ import org.apache.spark.sql.catalyst.analysis.UnresolvedStar$; import org.apache.spark.sql.catalyst.expressions.Ascending$; import org.apache.spark.sql.catalyst.expressions.Descending$; +import org.apache.spark.sql.catalyst.expressions.Explode; import org.apache.spark.sql.catalyst.expressions.Expression; import org.apache.spark.sql.catalyst.expressions.GeneratorOuter; import org.apache.spark.sql.catalyst.expressions.In$; @@ -93,6 +94,7 @@ import static java.util.Collections.emptyList; import static java.util.List.of; +import static org.opensearch.sql.ppl.CatalystPlanContext.findRelation; import static org.opensearch.sql.ppl.utils.DataTypeTransformer.seq; import static org.opensearch.sql.ppl.utils.DedupeTransformer.retainMultipleDuplicateEvents; import static org.opensearch.sql.ppl.utils.DedupeTransformer.retainMultipleDuplicateEventsAndKeepEmpty; @@ -460,13 +462,34 @@ public LogicalPlan visitFlatten(Flatten flatten, CatalystPlanContext context) { // Create an UnresolvedStar for all-fields projection context.getNamedParseExpressions().push(UnresolvedStar$.MODULE$.apply(Option.>empty())); } - Expression field = visitExpression(flatten.getFieldToBeFlattened(), context); + Expression field = visitExpression(flatten.getField(), context); context.retainAllNamedParseExpressions(p -> (NamedExpression) p); FlattenGenerator flattenGenerator = new FlattenGenerator(field); context.apply(p -> new Generate(new GeneratorOuter(flattenGenerator), seq(), true, (Option) None$.MODULE$, seq(), p)); return context.apply(logicalPlan -> DataFrameDropColumns$.MODULE$.apply(seq(field), logicalPlan)); } + @Override + public LogicalPlan visitExpand(org.opensearch.sql.ast.tree.Expand node, CatalystPlanContext context) { + node.getChild().get(0).accept(this, context); + if (context.getNamedParseExpressions().isEmpty()) { + // Create an UnresolvedStar for all-fields projection + context.getNamedParseExpressions().push(UnresolvedStar$.MODULE$.apply(Option.>empty())); + } + Expression field = visitExpression(node.getField(), context); + Optional alias = node.getAlias().map(aliasNode -> visitExpression(aliasNode, context)); + context.retainAllNamedParseExpressions(p -> (NamedExpression) p); + Explode explodeGenerator = new Explode(field); + scala.collection.mutable.Seq outputs = alias.isEmpty() ? seq() : seq(alias.get()); + if(alias.isEmpty()) + return context.apply(p -> new Generate(explodeGenerator, seq(), false, (Option) None$.MODULE$, outputs, p)); + else { + //in case an alias does appear - remove the original field from the returning columns + context.apply(p -> new Generate(explodeGenerator, seq(), false, (Option) None$.MODULE$, outputs, p)); + return context.apply(logicalPlan -> DataFrameDropColumns$.MODULE$.apply(seq(field), logicalPlan)); + } + } + private void visitFieldList(List fieldList, CatalystPlanContext context) { fieldList.forEach(field -> visitExpression(field, context)); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java index 36a34cd06..f6581016f 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java @@ -131,6 +131,12 @@ public UnresolvedPlan visitWhereCommand(OpenSearchPPLParser.WhereCommandContext return new Filter(internalVisitExpression(ctx.logicalExpression())); } + @Override + public UnresolvedPlan visitExpandCommand(OpenSearchPPLParser.ExpandCommandContext ctx) { + return new Expand((Field) internalVisitExpression(ctx.fieldExpression()), + ctx.alias!=null ? Optional.of(internalVisitExpression(ctx.alias)) : Optional.empty()); + } + @Override public UnresolvedPlan visitCorrelateCommand(OpenSearchPPLParser.CorrelateCommandContext ctx) { return new Correlation(ctx.correlationType().getText(), diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanExpandCommandTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanExpandCommandTranslatorTestSuite.scala new file mode 100644 index 000000000..2acaac529 --- /dev/null +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanExpandCommandTranslatorTestSuite.scala @@ -0,0 +1,281 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.flint.spark.ppl + +import org.opensearch.flint.spark.FlattenGenerator +import org.opensearch.flint.spark.ppl.PlaneUtils.plan +import org.opensearch.sql.ppl.{CatalystPlanContext, CatalystQueryPlanVisitor} +import org.opensearch.sql.ppl.utils.DataTypeTransformer.seq +import org.scalatest.matchers.should.Matchers + +import org.apache.spark.SparkFunSuite +import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} +import org.apache.spark.sql.catalyst.expressions.{Alias, Explode, GeneratorOuter, Literal, RegExpExtract} +import org.apache.spark.sql.catalyst.plans.PlanTest +import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, DataFrameDropColumns, Generate, Project} +import org.apache.spark.sql.types.IntegerType + +class PPLLogicalPlanExpandCommandTranslatorTestSuite + extends SparkFunSuite + with PlanTest + with LogicalPlanTestUtils + with Matchers { + + private val planTransformer = new CatalystQueryPlanVisitor() + private val pplParser = new PPLSyntaxParser() + + test("test expand only field") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit(plan(pplParser, "source=relation | expand field_with_array"), context) + + val relation = UnresolvedRelation(Seq("relation")) + val generator = Explode(UnresolvedAttribute("field_with_array")) + val generate = Generate(generator, seq(), false, None, seq(), relation) + val expectedPlan = Project(seq(UnresolvedStar(None)), generate) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("expand multi columns array table") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + s""" + | source = table + | | expand multi_valueA as multiA + | | expand multi_valueB as multiB + | """.stripMargin), + context) + + val relation = UnresolvedRelation(Seq("table")) + val generatorA = Explode(UnresolvedAttribute("multi_valueA")) + val generateA = + Generate(generatorA, seq(), false, None, seq(UnresolvedAttribute("multiA")), relation) + val dropSourceColumnA = + DataFrameDropColumns(Seq(UnresolvedAttribute("multi_valueA")), generateA) + val generatorB = Explode(UnresolvedAttribute("multi_valueB")) + val generateB = Generate( + generatorB, + seq(), + false, + None, + seq(UnresolvedAttribute("multiB")), + dropSourceColumnA) + val dropSourceColumnB = + DataFrameDropColumns(Seq(UnresolvedAttribute("multi_valueB")), generateB) + val expectedPlan = Project(seq(UnresolvedStar(None)), dropSourceColumnB) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand on array field which is eval array=json_array") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "source = table | eval array=json_array(1, 2, 3) | expand array as uid | fields uid"), + context) + + val relation = UnresolvedRelation(Seq("table")) + val jsonFunc = + UnresolvedFunction("array", Seq(Literal(1), Literal(2), Literal(3)), isDistinct = false) + val aliasA = Alias(jsonFunc, "array")() + val project = Project(seq(UnresolvedStar(None), aliasA), relation) + val generate = Generate( + Explode(UnresolvedAttribute("array")), + seq(), + false, + None, + seq(UnresolvedAttribute("uid")), + project) + val dropSourceColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("array")), generate) + val expectedPlan = Project(seq(UnresolvedAttribute("uid")), dropSourceColumn) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand only field with alias") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan(pplParser, "source=relation | expand field_with_array as array_list "), + context) + + val relation = UnresolvedRelation(Seq("relation")) + val generate = Generate( + Explode(UnresolvedAttribute("field_with_array")), + seq(), + false, + None, + seq(UnresolvedAttribute("array_list")), + relation) + val dropSourceColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("field_with_array")), generate) + val expectedPlan = Project(seq(UnresolvedStar(None)), dropSourceColumn) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and stats") { + val context = new CatalystPlanContext + val query = + "source = table | expand employee | stats max(salary) as max by state, company" + val logPlan = + planTransformer.visit(plan(pplParser, query), context) + val table = UnresolvedRelation(Seq("table")) + val generate = + Generate(Explode(UnresolvedAttribute("employee")), seq(), false, None, seq(), table) + val average = Alias( + UnresolvedFunction(seq("MAX"), seq(UnresolvedAttribute("salary")), false, None, false), + "max")() + val state = Alias(UnresolvedAttribute("state"), "state")() + val company = Alias(UnresolvedAttribute("company"), "company")() + val groupingState = Alias(UnresolvedAttribute("state"), "state")() + val groupingCompany = Alias(UnresolvedAttribute("company"), "company")() + val aggregate = + Aggregate(Seq(groupingState, groupingCompany), Seq(average, state, company), generate) + val expectedPlan = Project(Seq(UnresolvedStar(None)), aggregate) + + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and stats with alias") { + val context = new CatalystPlanContext + val query = + "source = table | expand employee as workers | stats max(salary) as max by state, company" + val logPlan = + planTransformer.visit(plan(pplParser, query), context) + val table = UnresolvedRelation(Seq("table")) + val generate = Generate( + Explode(UnresolvedAttribute("employee")), + seq(), + false, + None, + seq(UnresolvedAttribute("workers")), + table) + val dropSourceColumn = DataFrameDropColumns(Seq(UnresolvedAttribute("employee")), generate) + val average = Alias( + UnresolvedFunction(seq("MAX"), seq(UnresolvedAttribute("salary")), false, None, false), + "max")() + val state = Alias(UnresolvedAttribute("state"), "state")() + val company = Alias(UnresolvedAttribute("company"), "company")() + val groupingState = Alias(UnresolvedAttribute("state"), "state")() + val groupingCompany = Alias(UnresolvedAttribute("company"), "company")() + val aggregate = Aggregate( + Seq(groupingState, groupingCompany), + Seq(average, state, company), + dropSourceColumn) + val expectedPlan = Project(Seq(UnresolvedStar(None)), aggregate) + + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and eval") { + val context = new CatalystPlanContext + val query = "source = table | expand employee | eval bonus = salary * 3" + val logPlan = planTransformer.visit(plan(pplParser, query), context) + val table = UnresolvedRelation(Seq("table")) + val generate = + Generate(Explode(UnresolvedAttribute("employee")), seq(), false, None, seq(), table) + val bonusProject = Project( + Seq( + UnresolvedStar(None), + Alias( + UnresolvedFunction( + "*", + Seq(UnresolvedAttribute("salary"), Literal(3, IntegerType)), + isDistinct = false), + "bonus")()), + generate) + val expectedPlan = Project(Seq(UnresolvedStar(None)), bonusProject) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and eval with fields and alias") { + val context = new CatalystPlanContext + val query = + "source = table | expand employee as worker | eval bonus = salary * 3 | fields worker, bonus " + val logPlan = planTransformer.visit(plan(pplParser, query), context) + val table = UnresolvedRelation(Seq("table")) + val generate = Generate( + Explode(UnresolvedAttribute("employee")), + seq(), + false, + None, + seq(UnresolvedAttribute("worker")), + table) + val dropSourceColumn = + DataFrameDropColumns(Seq(UnresolvedAttribute("employee")), generate) + val bonusProject = Project( + Seq( + UnresolvedStar(None), + Alias( + UnresolvedFunction( + "*", + Seq(UnresolvedAttribute("salary"), Literal(3, IntegerType)), + isDistinct = false), + "bonus")()), + dropSourceColumn) + val expectedPlan = + Project(Seq(UnresolvedAttribute("worker"), UnresolvedAttribute("bonus")), bonusProject) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and parse and fields") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "source=table | expand employee | parse description '(?.+@.+)' | fields employee, email"), + context) + val table = UnresolvedRelation(Seq("table")) + val generator = + Generate(Explode(UnresolvedAttribute("employee")), seq(), false, None, seq(), table) + val emailAlias = + Alias( + RegExpExtract(UnresolvedAttribute("description"), Literal("(?.+@.+)"), Literal(1)), + "email")() + val parseProject = Project( + Seq(UnresolvedAttribute("description"), emailAlias, UnresolvedStar(None)), + generator) + val expectedPlan = + Project(Seq(UnresolvedAttribute("employee"), UnresolvedAttribute("email")), parseProject) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test expand and parse and flatten ") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "source=relation | expand employee | parse description '(?.+@.+)' | flatten roles "), + context) + val table = UnresolvedRelation(Seq("relation")) + val generateEmployee = + Generate(Explode(UnresolvedAttribute("employee")), seq(), false, None, seq(), table) + val emailAlias = + Alias( + RegExpExtract(UnresolvedAttribute("description"), Literal("(?.+@.+)"), Literal(1)), + "email")() + val parseProject = Project( + Seq(UnresolvedAttribute("description"), emailAlias, UnresolvedStar(None)), + generateEmployee) + val generateRoles = Generate( + GeneratorOuter(new FlattenGenerator(UnresolvedAttribute("roles"))), + seq(), + true, + None, + seq(), + parseProject) + val dropSourceColumnRoles = + DataFrameDropColumns(Seq(UnresolvedAttribute("roles")), generateRoles) + val expectedPlan = Project(Seq(UnresolvedStar(None)), dropSourceColumnRoles) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + +} From 31d04f18da0ef74d27288b67b5b209f6c4571199 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Thu, 7 Nov 2024 10:38:05 -0800 Subject: [PATCH 05/26] Bump Flint version to 0.7.0 (#867) Signed-off-by: Tomoyuki Morita --- README.md | 5 +++-- build.sbt | 2 +- docs/index.md | 6 +++--- docs/ppl-lang/PPL-on-Spark.md | 4 ++-- .../scala/org/opensearch/flint/common/FlintVersion.scala | 3 ++- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4c470e98b..592b2645d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Version compatibility: | 0.4.0 | 11+ | 3.3.2 | 2.12.14 | 2.13+ | | 0.5.0 | 11+ | 3.5.1 | 2.12.14 | 2.17+ | | 0.6.0 | 11+ | 3.5.1 | 2.12.14 | 2.17+ | +| 0.7.0 | 11+ | 3.5.1 | 2.12.14 | 2.17+ | ## Flint Extension Usage @@ -62,7 +63,7 @@ sbt clean standaloneCosmetic/publishM2 ``` then add org.opensearch:opensearch-spark-standalone_2.12 when run spark application, for example, ``` -bin/spark-shell --packages "org.opensearch:opensearch-spark-standalone_2.12:0.6.0-SNAPSHOT" \ +bin/spark-shell --packages "org.opensearch:opensearch-spark-standalone_2.12:0.7.0-SNAPSHOT" \ --conf "spark.sql.extensions=org.opensearch.flint.spark.FlintSparkExtensions" \ --conf "spark.sql.catalog.dev=org.apache.spark.opensearch.catalog.OpenSearchCatalog" ``` @@ -76,7 +77,7 @@ sbt clean sparkPPLCosmetic/publishM2 ``` then add org.opensearch:opensearch-spark-ppl_2.12 when run spark application, for example, ``` -bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.6.0-SNAPSHOT" \ +bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPSHOT" \ --conf "spark.sql.extensions=org.opensearch.flint.spark.FlintPPLSparkExtensions" \ --conf "spark.sql.catalog.dev=org.apache.spark.opensearch.catalog.OpenSearchCatalog" diff --git a/build.sbt b/build.sbt index 1300f68a0..8752d3bf9 100644 --- a/build.sbt +++ b/build.sbt @@ -21,7 +21,7 @@ val sparkMinorVersion = sparkVersion.split("\\.").take(2).mkString(".") ThisBuild / organization := "org.opensearch" -ThisBuild / version := "0.6.0-SNAPSHOT" +ThisBuild / version := "0.7.0-SNAPSHOT" ThisBuild / scalaVersion := scala212 diff --git a/docs/index.md b/docs/index.md index e76cb387a..82c147de2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,7 +60,7 @@ Currently, Flint metadata is only static configuration without version control a ```json { - "version": "0.6.0", + "version": "0.7.0", "name": "...", "kind": "skipping", "source": "...", @@ -698,7 +698,7 @@ For now, only single or conjunct conditions (conditions connected by AND) in WHE ### AWS EMR Spark Integration - Using execution role Flint use [DefaultAWSCredentialsProviderChain](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html). When running in EMR Spark, Flint use executionRole credentials ``` ---conf spark.jars.packages=org.opensearch:opensearch-spark-standalone_2.12:0.6.0-SNAPSHOT \ +--conf spark.jars.packages=org.opensearch:opensearch-spark-standalone_2.12:0.7.0-SNAPSHOT \ --conf spark.jars.repositories=https://aws.oss.sonatype.org/content/repositories/snapshots \ --conf spark.emr-serverless.driverEnv.JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto.x86_64 \ --conf spark.executorEnv.JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto.x86_64 \ @@ -740,7 +740,7 @@ Flint use [DefaultAWSCredentialsProviderChain](https://docs.aws.amazon.com/AWSJa ``` 3. Set the spark.datasource.flint.customAWSCredentialsProvider property with value as com.amazonaws.emr.AssumeRoleAWSCredentialsProvider. Set the environment variable ASSUME_ROLE_CREDENTIALS_ROLE_ARN with the ARN value of CrossAccountRoleB. ``` ---conf spark.jars.packages=org.opensearch:opensearch-spark-standalone_2.12:0.6.0-SNAPSHOT \ +--conf spark.jars.packages=org.opensearch:opensearch-spark-standalone_2.12:0.7.0-SNAPSHOT \ --conf spark.jars.repositories=https://aws.oss.sonatype.org/content/repositories/snapshots \ --conf spark.emr-serverless.driverEnv.JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto.x86_64 \ --conf spark.executorEnv.JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto.x86_64 \ diff --git a/docs/ppl-lang/PPL-on-Spark.md b/docs/ppl-lang/PPL-on-Spark.md index 3b260bd37..1b057572b 100644 --- a/docs/ppl-lang/PPL-on-Spark.md +++ b/docs/ppl-lang/PPL-on-Spark.md @@ -34,7 +34,7 @@ sbt clean sparkPPLCosmetic/publishM2 ``` then add org.opensearch:opensearch-spark_2.12 when run spark application, for example, ``` -bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.6.0-SNAPSHOT" +bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPSHOT" ``` ### PPL Extension Usage @@ -46,7 +46,7 @@ spark-sql --conf "spark.sql.extensions=org.opensearch.flint.spark.FlintPPLSparkE ``` ### Running With both Flint & PPL Extensions -In order to make use of both flint and ppl extension, one can simply add both jars (`org.opensearch:opensearch-spark-ppl_2.12:0.6.0-SNAPSHOT`,`org.opensearch:opensearch-spark_2.12:0.6.0-SNAPSHOT`) to the cluster's +In order to make use of both flint and ppl extension, one can simply add both jars (`org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPSHOT`,`org.opensearch:opensearch-spark_2.12:0.7.0-SNAPSHOT`) to the cluster's classpath. Next need to configure both extensions : diff --git a/flint-commons/src/main/scala/org/opensearch/flint/common/FlintVersion.scala b/flint-commons/src/main/scala/org/opensearch/flint/common/FlintVersion.scala index 1203ea7ef..53574b770 100644 --- a/flint-commons/src/main/scala/org/opensearch/flint/common/FlintVersion.scala +++ b/flint-commons/src/main/scala/org/opensearch/flint/common/FlintVersion.scala @@ -20,6 +20,7 @@ object FlintVersion { val V_0_4_0: FlintVersion = FlintVersion("0.4.0") val V_0_5_0: FlintVersion = FlintVersion("0.5.0") val V_0_6_0: FlintVersion = FlintVersion("0.6.0") + val V_0_7_0: FlintVersion = FlintVersion("0.7.0") - def current(): FlintVersion = V_0_6_0 + def current(): FlintVersion = V_0_7_0 } From 182689c5f5d3dda78fe1cd25af3ada06c6df545a Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Thu, 7 Nov 2024 10:47:34 -0800 Subject: [PATCH 06/26] Add validation for time column in tumble function (#858) * Validate tumble function argument and add IT Signed-off-by: Chen Dai * Add IT for verifying correctness of subquery workaround Signed-off-by: Chen Dai * Modify error message wording Signed-off-by: Chen Dai --------- Signed-off-by: Chen Dai --- .../spark/mv/FlintSparkMaterializedView.scala | 10 ++- ...FlintSparkMaterializedViewSqlITSuite.scala | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala index aecfc99df..e2a64d183 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala @@ -133,8 +133,14 @@ case class FlintSparkMaterializedView( // Assume first aggregate item must be time column val winFunc = winFuncs.head - val timeCol = winFunc.arguments.head.asInstanceOf[Attribute] - Some(agg, timeCol) + val timeCol = winFunc.arguments.head + timeCol match { + case attr: Attribute => + Some(agg, attr) + case _ => + throw new IllegalArgumentException( + s"Tumble function only supports simple timestamp column, but found: $timeCol") + } } private def isWindowingFunction(func: UnresolvedFunction): Boolean = { diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewSqlITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewSqlITSuite.scala index 9e75078d2..ae2e53090 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewSqlITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewSqlITSuite.scala @@ -448,5 +448,80 @@ class FlintSparkMaterializedViewSqlITSuite extends FlintSparkSuite { } } + test("tumble function should raise error for non-simple time column") { + val httpLogs = s"$catalogName.default.mv_test_tumble" + withTable(httpLogs) { + createTableHttpLog(httpLogs) + + withTempDir { checkpointDir => + val ex = the[IllegalStateException] thrownBy { + sql(s""" + | CREATE MATERIALIZED VIEW `$catalogName`.`default`.`mv_test_metrics` + | AS + | SELECT + | window.start AS startTime, + | COUNT(*) AS count + | FROM $httpLogs + | GROUP BY + | TUMBLE(CAST(timestamp AS TIMESTAMP), '10 Minute') + | WITH ( + | auto_refresh = true, + | checkpoint_location = '${checkpointDir.getAbsolutePath}', + | watermark_delay = '1 Second' + | ) + |""".stripMargin) + } + ex.getCause should have message + "Tumble function only supports simple timestamp column, but found: cast('timestamp as timestamp)" + } + } + } + + test("tumble function should succeed with casted time column within subquery") { + val httpLogs = s"$catalogName.default.mv_test_tumble" + withTable(httpLogs) { + createTableHttpLog(httpLogs) + + withTempDir { checkpointDir => + sql(s""" + | CREATE MATERIALIZED VIEW `$catalogName`.`default`.`mv_test_metrics` + | AS + | SELECT + | window.start AS startTime, + | COUNT(*) AS count + | FROM ( + | SELECT CAST(timestamp AS TIMESTAMP) AS time + | FROM $httpLogs + | ) + | GROUP BY + | TUMBLE(time, '10 Minute') + | WITH ( + | auto_refresh = true, + | checkpoint_location = '${checkpointDir.getAbsolutePath}', + | watermark_delay = '1 Second' + | ) + |""".stripMargin) + + // Wait for streaming job complete current micro batch + val job = spark.streams.active.find(_.name == testFlintIndex) + job shouldBe defined + failAfter(streamingTimeout) { + job.get.processAllAvailable() + } + + checkAnswer( + flint.queryIndex(testFlintIndex).select("startTime", "count"), + Seq( + Row(timestamp("2023-10-01 10:00:00"), 2), + Row(timestamp("2023-10-01 10:10:00"), 2) + /* + * The last row is pending to fire upon watermark + * Row(timestamp("2023-10-01 10:20:00"), 2) + */ + )) + } + } + } + private def timestamp(ts: String): Timestamp = Timestamp.valueOf(ts) } From a0c246b8c1300d5302b1797ecfae707640751344 Mon Sep 17 00:00:00 2001 From: qianheng Date: Tue, 12 Nov 2024 03:51:12 +0800 Subject: [PATCH 07/26] Add sanity test script (#878) * Add sanity test script Signed-off-by: Heng Qian * Add header Signed-off-by: Heng Qian * Minor fix Signed-off-by: Heng Qian * Minor fix Signed-off-by: Heng Qian * Minor fix Signed-off-by: Heng Qian * Support check expected_status if have that column in input file. Signed-off-by: Heng Qian * Add README.md Signed-off-by: Heng Qian * Minor fix Signed-off-by: Heng Qian * Support set log-level Signed-off-by: Heng Qian --------- Signed-off-by: Heng Qian --- integ-test/script/README.md | 158 +++++++++ integ-test/script/SanityTest.py | 291 ++++++++++++++++ integ-test/script/test_cases.csv | 567 +++++++++++++++++++++++++++++++ 3 files changed, 1016 insertions(+) create mode 100644 integ-test/script/README.md create mode 100644 integ-test/script/SanityTest.py create mode 100644 integ-test/script/test_cases.csv diff --git a/integ-test/script/README.md b/integ-test/script/README.md new file mode 100644 index 000000000..79b188158 --- /dev/null +++ b/integ-test/script/README.md @@ -0,0 +1,158 @@ +# Sanity Test Script + +### Description +This Python script executes test queries from a CSV file using an asynchronous query API and generates comprehensive test reports. + +The script produces two report types: +1. An Excel report with detailed test information for each query +2. A JSON report containing both test result overview and query-specific details + +Apart from the basic feature, it also has some advanced functionality includes: +1. Concurrent query execution (note: the async query service has session limits, so use thread workers moderately despite it already supports session ID reuse) +2. Configurable query timeout with periodic status checks and automatic cancellation if timeout occurs. +3. Flexible row selection from the input CSV file, by specifying start row and end row of the input CSV file. +4. Expected status validation when expected_status is present in the CSV +5. Ability to generate partial reports if testing is interrupted + +### Usage +To use this script, you need to have Python **3.6** or higher installed. It also requires the following Python libraries: +```shell +pip install requests pandas +``` + +After getting the requisite libraries, you can run the script with the following command line parameters in your shell: +```shell +python SanityTest.py --base-url ${URL_ADDRESS} --username *** --password *** --datasource ${DATASOURCE_NAME} --input-csv test_cases.csv --output-file test_report --max-workers 2 --check-interval 10 --timeout 600 +``` +You need to replace the placeholders with your actual values of URL_ADDRESS, DATASOURCE_NAME and USERNAME, PASSWORD for authentication to your endpoint. + +For more details of the command line parameters, you can see the help manual via command: +```shell +python SanityTest.py --help + +usage: SanityTest.py [-h] --base-url BASE_URL --username USERNAME --password PASSWORD --datasource DATASOURCE --input-csv INPUT_CSV + --output-file OUTPUT_FILE [--max-workers MAX_WORKERS] [--check-interval CHECK_INTERVAL] [--timeout TIMEOUT] + [--start-row START_ROW] [--end-row END_ROW] + +Run tests from a CSV file and generate a report. + +options: + -h, --help show this help message and exit + --base-url BASE_URL Base URL of the service + --username USERNAME Username for authentication + --password PASSWORD Password for authentication + --datasource DATASOURCE + Datasource name + --input-csv INPUT_CSV + Path to the CSV file containing test queries + --output-file OUTPUT_FILE + Path to the output report file + --max-workers MAX_WORKERS + optional, Maximum number of worker threads (default: 2) + --check-interval CHECK_INTERVAL + optional, Check interval in seconds (default: 10) + --timeout TIMEOUT optional, Timeout in seconds (default: 600) + --start-row START_ROW + optional, The start row of the query to run, start from 1 + --end-row END_ROW optional, The end row of the query to run, not included + --log-level LOG_LEVEL + optional, Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL, default: INFO) +``` + +### Input CSV File +As claimed in the description, the input CSV file should at least have the column of `query` to run the tests. It also supports an optional column of `expected_status`, the script will check the actual status against the expected status and generate a new column of `check_status` for the check result -- TRUE means the status check passed; FALSE means the status check failed. + +We also provide a sample input CSV file `test_cases.csv` for reference. It includes all sanity test cases we have currently in the Flint. + +**TODO**: the prerequisite data of the test cases and ingesting process + +### Report Explanation +The generated report contains two files: + +#### Excel Report +The Excel report provides the test result details of each query, including the query name(i.e. sequence number in the input csv file currently), query itself, expected status, actual status, and whether the status satisfy the expected status or not. + +It provides an error message if the query execution failed, otherwise it provides the query execution result with empty error. + +It also provides the query_id, session_id and start/end time for each query, which can be used to debug the query execution in the Flint. + +An example of Excel report: + +| query_name | query | expected_status | status | check_status | error | result | Duration (s) | query_id | session_id | Start Time | End Time | +|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|---------|--------------|------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|-------------------------------|------------------------------|----------------------|---------------------| +| 1 | describe myglue_test.default.http_logs | SUCCESS | SUCCESS | TRUE | | {'status': 'SUCCESS', 'schema': [{...}, ...], 'datarows': [[...], ...], 'total': 31, 'size': 31} | 37.51 | SHFEVWxDNnZjem15Z2x1ZV90ZXN0 | RkgzZm0xNlA5MG15Z2x1ZV90ZXN0 | 2024-11-07 13:34:10 | 2024-11-07 13:34:47 | +| 2 | source = myglue_test.default.http_logs \| dedup status CONSECUTIVE=true | SUCCESS | FAILED | FALSE | {"Message":"Fail to run query. Cause: Consecutive deduplication is not supported"} | | 39.53 | dVNlaVVxOFZrZW15Z2x1ZV90ZXN0 | ZGU2MllVYmI4dG15Z2x1ZV90ZXN0 | 2024-11-07 13:34:10 | 2024-11-07 13:34:49 | +| 3 | source = myglue_test.default.http_logs \| eval res = json_keys(json('{"account_number":1,"balance":39225,"age":32,"gender":"M"}')) \| head 1 \| fields res | SUCCESS | SUCCESS | TRUE | | {'status': 'SUCCESS', 'schema': [{'name': 'res', 'type': 'array'}], 'datarows': [[['account_number', 'balance', 'age', 'gender']]], 'total': 1, 'size': 1} | 12.77 | WHQxaXlVSGtGUm15Z2x1ZV90ZXN0 | RkgzZm0xNlA5MG15Z2x1ZV90ZXN0 | 2024-11-07 13:34:47 | 2024-11-07 13:38:45 | +| ... | ... | ... | ... | ... | | | ... | ... | ... | ... | ... | + + +#### JSON Report +The JSON report provides the same information as the Excel report, but in JSON format.Additionally, it includes a statistical summary of the test results at the beginning of the report. + +An example of JSON report: +```json +{ + "summary": { + "total_queries": 115, + "successful_queries": 110, + "failed_queries": 3, + "submit_failed_queries": 0, + "timeout_queries": 2, + "execution_time": 16793.223807 + }, + "detailed_results": [ + { + "query_name": 1, + "query": "source = myglue_test.default.http_logs | stats avg(size)", + "query_id": "eFZmTlpTa3EyTW15Z2x1ZV90ZXN0", + "session_id": "bFJDMWxzb2NVUm15Z2x1ZV90ZXN0", + "status": "SUCCESS", + "error": "", + "result": { + "status": "SUCCESS", + "schema": [ + { + "name": "avg(size)", + "type": "double" + } + ], + "datarows": [ + [ + 4654.305710913499 + ] + ], + "total": 1, + "size": 1 + }, + "duration": 170.621145, + "start_time": "2024-11-07 14:56:13.869226", + "end_time": "2024-11-07 14:59:04.490371" + }, + { + "query_name": 2, + "query": "source = myglue_test.default.http_logs | eval res = json_keys(json(\u2018{\"teacher\":\"Alice\",\"student\":[{\"name\":\"Bob\",\"rank\":1},{\"name\":\"Charlie\",\"rank\":2}]}')) | head 1 | fields res", + "query_id": "bjF4Y1VnbXdFYm15Z2x1ZV90ZXN0", + "session_id": "c3pvU1V6OW8xM215Z2x1ZV90ZXN0", + "status": "FAILED", + "error": "{\"Message\":\"Syntax error: \\n[PARSE_SYNTAX_ERROR] Syntax error at or near 'source'.(line 1, pos 0)\\n\\n== SQL ==\\nsource = myglue_test.default.http_logs | eval res = json_keys(json(\u2018{\\\"teacher\\\":\\\"Alice\\\",\\\"student\\\":[{\\\"name\\\":\\\"Bob\\\",\\\"rank\\\":1},{\\\"name\\\":\\\"Charlie\\\",\\\"rank\\\":2}]}')) | head 1 | fields res\\n^^^\\n\"}", + "result": null, + "duration": 14.051738, + "start_time": "2024-11-07 14:59:18.699335", + "end_time": "2024-11-07 14:59:32.751073" + }, + { + "query_name": 2, + "query": "source = myglue_test.default.http_logs | eval col1 = size, col2 = clientip | stats avg(col1) by col2", + "query_id": "azVyMFFORnBFRW15Z2x1ZV90ZXN0", + "session_id": "VWF0SEtrNWM3bm15Z2x1ZV90ZXN0", + "status": "TIMEOUT", + "error": "Query execution exceeded 600 seconds with last status: running", + "result": null, + "duration": 673.710946, + "start_time": "2024-11-07 14:45:00.157875", + "end_time": "2024-11-07 14:56:13.868821" + }, + ... + ] +} +``` diff --git a/integ-test/script/SanityTest.py b/integ-test/script/SanityTest.py new file mode 100644 index 000000000..1c51d4d20 --- /dev/null +++ b/integ-test/script/SanityTest.py @@ -0,0 +1,291 @@ +""" +Copyright OpenSearch Contributors +SPDX-License-Identifier: Apache-2.0 +""" + +import signal +import sys +import requests +import json +import csv +import time +import logging +from datetime import datetime +import pandas as pd +import argparse +from requests.auth import HTTPBasicAuth +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading + +""" +Environment: python3 + +Example to use this script: + +python SanityTest.py --base-url ${URL_ADDRESS} --username *** --password *** --datasource ${DATASOURCE_NAME} --input-csv test_queries.csv --output-file test_report --max-workers 2 --check-interval 10 --timeout 600 + +The input file test_queries.csv should contain column: `query` + +For more details, please use command: + +python SanityTest.py --help + +""" + +class FlintTester: + def __init__(self, base_url, username, password, datasource, max_workers, check_interval, timeout, output_file, start_row, end_row, log_level): + self.base_url = base_url + self.auth = HTTPBasicAuth(username, password) + self.datasource = datasource + self.headers = { 'Content-Type': 'application/json' } + self.max_workers = max_workers + self.check_interval = check_interval + self.timeout = timeout + self.output_file = output_file + self.start = start_row - 1 if start_row else None + self.end = end_row - 1 if end_row else None + self.log_level = log_level + self.max_attempts = (int)(timeout / check_interval) + self.logger = self._setup_logger() + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + self.thread_local = threading.local() + self.test_results = [] + + def _setup_logger(self): + logger = logging.getLogger('FlintTester') + logger.setLevel(self.log_level) + + fh = logging.FileHandler('flint_test.log') + fh.setLevel(self.log_level) + + ch = logging.StreamHandler() + ch.setLevel(self.log_level) + + formatter = logging.Formatter( + '%(asctime)s - %(threadName)s - %(levelname)s - %(message)s' + ) + fh.setFormatter(formatter) + ch.setFormatter(formatter) + + logger.addHandler(fh) + logger.addHandler(ch) + + return logger + + + def get_session_id(self): + if not hasattr(self.thread_local, 'session_id'): + self.thread_local.session_id = "empty_session_id" + self.logger.debug(f"get session id {self.thread_local.session_id}") + return self.thread_local.session_id + + def set_session_id(self, session_id): + """Reuse the session id for the same thread""" + self.logger.debug(f"set session id {session_id}") + self.thread_local.session_id = session_id + + # Call submit API to submit the query + def submit_query(self, query, session_id="Empty"): + url = f"{self.base_url}/_plugins/_async_query" + payload = { + "datasource": self.datasource, + "lang": "ppl", + "query": query, + "sessionId": session_id + } + self.logger.debug(f"Submit query with payload: {payload}") + response_json = None + try: + response = requests.post(url, auth=self.auth, json=payload, headers=self.headers) + response_json = response.json() + response.raise_for_status() + return response_json + except Exception as e: + return {"error": str(e), "response": response_json} + + # Call get API to check the query status + def get_query_result(self, query_id): + url = f"{self.base_url}/_plugins/_async_query/{query_id}" + response_json = None + try: + response = requests.get(url, auth=self.auth) + response_json = response.json() + response.raise_for_status() + return response_json + except Exception as e: + return {"status": "FAILED", "error": str(e), "response": response_json} + + # Call delete API to cancel the query + def cancel_query(self, query_id): + url = f"{self.base_url}/_plugins/_async_query/{query_id}" + response_json = None + try: + response = requests.delete(url, auth=self.auth) + response_json = response.json() + response.raise_for_status() + self.logger.info(f"Cancelled query [{query_id}] with info {response.json()}") + return response_json + except Exception as e: + self.logger.warning(f"Cancel query [{query_id}] error: {str(e)}, got response {response_json}") + + # Run the test and return the result + def run_test(self, query, seq_id, expected_status): + self.logger.info(f"Starting test: {seq_id}, {query}") + start_time = datetime.now() + pre_session_id = self.get_session_id() + submit_result = self.submit_query(query, pre_session_id) + if "error" in submit_result: + self.logger.warning(f"Submit error: {submit_result}") + return { + "query_name": seq_id, + "query": query, + "expected_status": expected_status, + "status": "SUBMIT_FAILED", + "check_status": "SUBMIT_FAILED" == expected_status if expected_status else None, + "error": submit_result["error"], + "duration": 0, + "start_time": start_time, + "end_time": datetime.now() + } + + query_id = submit_result["queryId"] + session_id = submit_result["sessionId"] + self.logger.info(f"Submit return: {submit_result}") + if (session_id != pre_session_id): + self.logger.info(f"Update session id from {pre_session_id} to {session_id}") + self.set_session_id(session_id) + + test_result = self.check_query_status(query_id) + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + + return { + "query_name": seq_id, + "query": query, + "query_id": query_id, + "session_id": session_id, + "expected_status": expected_status, + "status": test_result["status"], + "check_status": test_result["status"] == expected_status if expected_status else None, + "error": test_result.get("error", ""), + "result": test_result if test_result["status"] == "SUCCESS" else None, + "duration": duration, + "start_time": start_time, + "end_time": end_time + } + + # Check the status of the query periodically until it is completed or failed or exceeded the timeout + def check_query_status(self, query_id): + query_id = query_id + + for attempt in range(self.max_attempts): + time.sleep(self.check_interval) + result = self.get_query_result(query_id) + + if result["status"] == "FAILED" or result["status"] == "SUCCESS": + return result + + # Cancel the query if it exceeds the timeout + self.cancel_query(query_id) + return { + "status": "TIMEOUT", + "error": "Query execution exceeded " + str(self.timeout) + " seconds with last status: " + result["status"], + } + + def run_tests_from_csv(self, csv_file): + with open(csv_file, 'r') as f: + reader = csv.DictReader(f) + queries = [(row['query'], i, row.get('expected_status', None)) for i, row in enumerate(reader, start=1) if row['query'].strip()] + + # Filtering queries based on start and end + queries = queries[self.start:self.end] + + # Parallel execution + futures = [self.executor.submit(self.run_test, query, seq_id, expected_status) for query, seq_id, expected_status in queries] + for future in as_completed(futures): + result = future.result() + self.test_results.append(result) + + def generate_report(self): + self.logger.info("Generating report...") + total_queries = len(self.test_results) + successful_queries = sum(1 for r in self.test_results if r['status'] == 'SUCCESS') + failed_queries = sum(1 for r in self.test_results if r['status'] == 'FAILED') + submit_failed_queries = sum(1 for r in self.test_results if r['status'] == 'SUBMIT_FAILED') + timeout_queries = sum(1 for r in self.test_results if r['status'] == 'TIMEOUT') + + # Create report + report = { + "summary": { + "total_queries": total_queries, + "successful_queries": successful_queries, + "failed_queries": failed_queries, + "submit_failed_queries": submit_failed_queries, + "timeout_queries": timeout_queries, + "execution_time": sum(r['duration'] for r in self.test_results) + }, + "detailed_results": self.test_results + } + + # Save report to JSON file + with open(f"{self.output_file}.json", 'w') as f: + json.dump(report, f, indent=2, default=str) + + # Save reults to Excel file + df = pd.DataFrame(self.test_results) + df.to_excel(f"{self.output_file}.xlsx", index=False) + + self.logger.info(f"Generated report in {self.output_file}.xlsx and {self.output_file}.json") + +def signal_handler(sig, frame, tester): + print(f"Signal {sig} received, generating report...") + try: + tester.executor.shutdown(wait=False, cancel_futures=True) + tester.generate_report() + finally: + sys.exit(0) + +def main(): + # Parse command line arguments + parser = argparse.ArgumentParser(description="Run tests from a CSV file and generate a report.") + parser.add_argument("--base-url", required=True, help="Base URL of the service") + parser.add_argument("--username", required=True, help="Username for authentication") + parser.add_argument("--password", required=True, help="Password for authentication") + parser.add_argument("--datasource", required=True, help="Datasource name") + parser.add_argument("--input-csv", required=True, help="Path to the CSV file containing test queries") + parser.add_argument("--output-file", required=True, help="Path to the output report file") + parser.add_argument("--max-workers", type=int, default=2, help="optional, Maximum number of worker threads (default: 2)") + parser.add_argument("--check-interval", type=int, default=5, help="optional, Check interval in seconds (default: 5)") + parser.add_argument("--timeout", type=int, default=600, help="optional, Timeout in seconds (default: 600)") + parser.add_argument("--start-row", type=int, default=None, help="optional, The start row of the query to run, start from 1") + parser.add_argument("--end-row", type=int, default=None, help="optional, The end row of the query to run, not included") + parser.add_argument("--log-level", default="INFO", help="optional, Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL, default: INFO)") + + args = parser.parse_args() + + tester = FlintTester( + base_url=args.base_url, + username=args.username, + password=args.password, + datasource=args.datasource, + max_workers=args.max_workers, + check_interval=args.check_interval, + timeout=args.timeout, + output_file=args.output_file, + start_row=args.start_row, + end_row=args.end_row, + log_level=args.log_level, + ) + + # Register signal handlers to generate report on interrupt + signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame, tester)) + signal.signal(signal.SIGTERM, lambda sig, frame: signal_handler(sig, frame, tester)) + + # Running tests + tester.run_tests_from_csv(args.input_csv) + + # Gnerate report + tester.generate_report() + +if __name__ == "__main__": + main() diff --git a/integ-test/script/test_cases.csv b/integ-test/script/test_cases.csv new file mode 100644 index 000000000..7df05f5a3 --- /dev/null +++ b/integ-test/script/test_cases.csv @@ -0,0 +1,567 @@ +query,expected_status +describe myglue_test.default.http_logs,FAILED +describe `myglue_test`.`default`.`http_logs`,FAILED +"source = myglue_test.default.http_logs | dedup 1 status | fields @timestamp, clientip, status, size | head 10",SUCCESS +"source = myglue_test.default.http_logs | dedup status, size | head 10",SUCCESS +source = myglue_test.default.http_logs | dedup 1 status keepempty=true | head 10,SUCCESS +"source = myglue_test.default.http_logs | dedup status, size keepempty=true | head 10",SUCCESS +source = myglue_test.default.http_logs | dedup 2 status | head 10,SUCCESS +"source = myglue_test.default.http_logs | dedup 2 status, size | head 10",SUCCESS +"source = myglue_test.default.http_logs | dedup 2 status, size keepempty=true | head 10",SUCCESS +source = myglue_test.default.http_logs | dedup status CONSECUTIVE=true | fields status,FAILED +"source = myglue_test.default.http_logs | dedup 2 status, size CONSECUTIVE=true | fields status",FAILED +"source = myglue_test.default.http_logs | sort stat | fields @timestamp, clientip, status | head 10",SUCCESS +"source = myglue_test.default.http_logs | fields @timestamp, notexisted | head 10",FAILED +"source = myglue_test.default.nested | fields int_col, struct_col.field1, struct_col2.field1 | head 10",FAILED +"source = myglue_test.default.nested | where struct_col2.field1.subfield > 'valueA' | sort int_col | fields int_col, struct_col.field1.subfield, struct_col2.field1.subfield",FAILED +"source = myglue_test.default.http_logs | fields - @timestamp, clientip, status | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval new_time = @timestamp, new_clientip = clientip | fields - new_time, new_clientip, status | head 10",SUCCESS +source = myglue_test.default.http_logs | eval new_clientip = lower(clientip) | fields - new_clientip | head 10,SUCCESS +"source = myglue_test.default.http_logs | fields + @timestamp, clientip, status | fields - clientip, status | head 10",SUCCESS +"source = myglue_test.default.http_logs | fields - clientip, status | fields + @timestamp, clientip, status| head 10",SUCCESS +source = myglue_test.default.http_logs | where status = 200 | head 10,SUCCESS +source = myglue_test.default.http_logs | where status != 200 | head 10,SUCCESS +source = myglue_test.default.http_logs | where size > 0 | head 10,SUCCESS +source = myglue_test.default.http_logs | where size <= 0 | head 10,SUCCESS +source = myglue_test.default.http_logs | where clientip = '236.14.2.0' | head 10,SUCCESS +source = myglue_test.default.http_logs | where size > 0 AND status = 200 OR clientip = '236.14.2.0' | head 100,SUCCESS +"source = myglue_test.default.http_logs | where size <= 0 AND like(request, 'GET%') | head 10",SUCCESS +source = myglue_test.default.http_logs status = 200 | head 10,SUCCESS +source = myglue_test.default.http_logs size > 0 AND status = 200 OR clientip = '236.14.2.0' | head 100,SUCCESS +"source = myglue_test.default.http_logs size <= 0 AND like(request, 'GET%') | head 10",SUCCESS +"source = myglue_test.default.http_logs substring(clientip, 5, 2) = ""12"" | head 10",SUCCESS +source = myglue_test.default.http_logs | where isempty(size),FAILED +source = myglue_test.default.http_logs | where ispresent(size),FAILED +source = myglue_test.default.http_logs | where isnull(size) | head 10,SUCCESS +source = myglue_test.default.http_logs | where isnotnull(size) | head 10,SUCCESS +"source = myglue_test.default.http_logs | where isnotnull(coalesce(size, status)) | head 10",FAILED +"source = myglue_test.default.http_logs | where like(request, 'GET%') | head 10",SUCCESS +"source = myglue_test.default.http_logs | where like(request, '%bordeaux%') | head 10",SUCCESS +"source = myglue_test.default.http_logs | where substring(clientip, 5, 2) = ""12"" | head 10",SUCCESS +"source = myglue_test.default.http_logs | where lower(request) = ""get /images/backnews.gif http/1.0"" | head 10",SUCCESS +source = myglue_test.default.http_logs | where length(request) = 38 | head 10,SUCCESS +"source = myglue_test.default.http_logs | where case(status = 200, 'success' else 'failed') = 'success' | head 10",FAILED +"source = myglue_test.default.http_logs | eval h = ""Hello"", w = ""World"" | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval @h = ""Hello"" | eval @w = ""World"" | fields @timestamp, @h, @w",SUCCESS +source = myglue_test.default.http_logs | eval newF = clientip | head 10,SUCCESS +"source = myglue_test.default.http_logs | eval newF = clientip | fields clientip, newF | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval f = size | where f > 1 | sort f | fields size, clientip, status | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval f = status * 2 | eval h = f * 2 | fields status, f, h | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval f = size * 2, h = status | stats sum(f) by h",SUCCESS +"source = myglue_test.default.http_logs | eval f = UPPER(request) | eval h = 40 | fields f, h | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval request = ""test"" | fields request | head 10",FAILED +source = myglue_test.default.http_logs | eval size = abs(size) | where size < 500,FAILED +"source = myglue_test.default.http_logs | eval status_string = case(status = 200, 'success' else 'failed') | head 10",FAILED +"source = myglue_test.default.http_logs | eval n = now() | eval t = unix_timestamp(@timestamp) | fields n, t | head 10",SUCCESS +source = myglue_test.default.http_logs | eval e = isempty(size) | eval p = ispresent(size) | head 10,FAILED +"source = myglue_test.default.http_logs | eval c = coalesce(size, status) | head 10",FAILED +source = myglue_test.default.http_logs | eval c = coalesce(request) | head 10,FAILED +source = myglue_test.default.http_logs | eval col1 = ln(size) | eval col2 = unix_timestamp(@timestamp) | sort - col1 | head 10,SUCCESS +"source = myglue_test.default.http_logs | eval col1 = 1 | sort col1 | head 4 | eval col2 = 2 | sort - col2 | sort - size | head 2 | fields @timestamp, clientip, col2",SUCCESS +"source = myglue_test.default.mini_http_logs | eval stat = status | where stat > 300 | sort stat | fields @timestamp,clientip,status | head 5",SUCCESS +"source = myglue_test.default.http_logs | eval col1 = size, col2 = clientip | stats avg(col1) by col2",SUCCESS +source = myglue_test.default.http_logs | stats avg(size) by clientip,SUCCESS +"source = myglue_test.default.http_logs | eval new_request = upper(request) | eval compound_field = concat('Hello ', if(like(new_request, '%bordeaux%'), 'World', clientip)) | fields new_request, compound_field | head 10",SUCCESS +source = myglue_test.default.http_logs | stats avg(size),SUCCESS +source = myglue_test.default.nested | stats max(int_col) by struct_col.field2,SUCCESS +source = myglue_test.default.nested | stats distinct_count(int_col),SUCCESS +source = myglue_test.default.nested | stats stddev_samp(int_col),SUCCESS +source = myglue_test.default.nested | stats stddev_pop(int_col),SUCCESS +source = myglue_test.default.nested | stats percentile(int_col),SUCCESS +source = myglue_test.default.nested | stats percentile_approx(int_col),SUCCESS +source = myglue_test.default.mini_http_logs | stats stddev_samp(status),SUCCESS +"source = myglue_test.default.mini_http_logs | where stats > 200 | stats percentile_approx(status, 99)",SUCCESS +"source = myglue_test.default.nested | stats count(int_col) by span(struct_col.field2, 10) as a_span",SUCCESS +"source = myglue_test.default.nested | stats avg(int_col) by span(struct_col.field2, 10) as a_span, struct_col2.field2",SUCCESS +"source = myglue_test.default.http_logs | stats sum(size) by span(@timestamp, 1d) as age_size_per_day | sort - age_size_per_day | head 10",SUCCESS +"source = myglue_test.default.http_logs | stats distinct_count(clientip) by span(@timestamp, 1d) as age_size_per_day | sort - age_size_per_day | head 10",SUCCESS +"source = myglue_test.default.http_logs | stats avg(size) as avg_size by status, year | stats avg(avg_size) as avg_avg_size by year",SUCCESS +"source = myglue_test.default.http_logs | stats avg(size) as avg_size by status, year, month | stats avg(avg_size) as avg_avg_size by year, month | stats avg(avg_avg_size) as avg_avg_avg_size by year",SUCCESS +"source = myglue_test.default.nested | stats avg(int_col) as avg_int by struct_col.field2, struct_col2.field2 | stats avg(avg_int) as avg_avg_int by struct_col2.field2",FAILED +"source = myglue_test.default.nested | stats avg(int_col) as avg_int by struct_col.field2, struct_col2.field2 | eval new_col = avg_int | stats avg(avg_int) as avg_avg_int by new_col",SUCCESS +source = myglue_test.default.nested | rare int_col,SUCCESS +source = myglue_test.default.nested | rare int_col by struct_col.field2,SUCCESS +source = myglue_test.default.http_logs | rare request,SUCCESS +source = myglue_test.default.http_logs | where status > 300 | rare request by status,SUCCESS +source = myglue_test.default.http_logs | rare clientip,SUCCESS +source = myglue_test.default.http_logs | where status > 300 | rare clientip,SUCCESS +source = myglue_test.default.http_logs | where status > 300 | rare clientip by day,SUCCESS +source = myglue_test.default.nested | top int_col by struct_col.field2,SUCCESS +source = myglue_test.default.nested | top 1 int_col by struct_col.field2,SUCCESS +source = myglue_test.default.nested | top 2 int_col by struct_col.field2,SUCCESS +source = myglue_test.default.nested | top int_col,SUCCESS +source = myglue_test.default.http_logs | inner join left=l right=r on l.status = r.int_col myglue_test.default.nested | head 10,FAILED +"source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/.*' | fields request, domain | head 10",SUCCESS +source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/.*' | top 1 domain,SUCCESS +source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/.*' | stats count() by domain,SUCCESS +"source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/.*' | eval a = 1 | fields a, domain | head 10",SUCCESS +"source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/.*' | where size > 0 | sort - size | fields size, domain | head 10",SUCCESS +"source = myglue_test.default.http_logs | parse request 'GET /(?[a-zA-Z]+)/(?[a-zA-Z]+)/.*' | where domain = 'english' | sort - picName | fields domain, picName | head 10",SUCCESS +source = myglue_test.default.http_logs | patterns request | fields patterns_field | head 10,SUCCESS +source = myglue_test.default.http_logs | patterns request | where size > 0 | fields patterns_field | head 10,SUCCESS +"source = myglue_test.default.http_logs | patterns new_field='no_letter' pattern='[a-zA-Z]' request | fields request, no_letter | head 10",SUCCESS +source = myglue_test.default.http_logs | patterns new_field='no_letter' pattern='[a-zA-Z]' request | stats count() by no_letter,SUCCESS +"source = myglue_test.default.http_logs | patterns new_field='status' pattern='[a-zA-Z]' request | fields request, status | head 10",FAILED +source = myglue_test.default.http_logs | rename @timestamp as timestamp | head 10,FAILED +source = myglue_test.default.http_logs | sort size | head 10,SUCCESS +source = myglue_test.default.http_logs | sort + size | head 10,SUCCESS +source = myglue_test.default.http_logs | sort - size | head 10,SUCCESS +"source = myglue_test.default.http_logs | sort + size, + @timestamp | head 10",SUCCESS +"source = myglue_test.default.http_logs | sort - size, - @timestamp | head 10",SUCCESS +"source = myglue_test.default.http_logs | sort - size, @timestamp | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval c1 = upper(request) | eval c2 = concat('Hello ', if(like(c1, '%bordeaux%'), 'World', clientip)) | eval c3 = length(request) | eval c4 = ltrim(request) | eval c5 = rtrim(request) | eval c6 = substring(clientip, 5, 2) | eval c7 = trim(request) | eval c8 = upper(request) | eval c9 = position('bordeaux' IN request) | eval c10 = replace(request, 'GET', 'GGG') | fields c1, c2, c3, c4, c5, c6, c7, c8, c9, c10 | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval c1 = unix_timestamp(@timestamp) | eval c2 = now() | eval c3 = +DAY_OF_WEEK(@timestamp) | eval c4 = +DAY_OF_MONTH(@timestamp) | eval c5 = +DAY_OF_YEAR(@timestamp) | eval c6 = +WEEK_OF_YEAR(@timestamp) | eval c7 = +WEEK(@timestamp) | eval c8 = +MONTH_OF_YEAR(@timestamp) | eval c9 = +HOUR_OF_DAY(@timestamp) | eval c10 = +MINUTE_OF_HOUR(@timestamp) | eval c11 = +SECOND_OF_MINUTE(@timestamp) | eval c12 = +LOCALTIME() | fields c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12 | head 10",SUCCESS +"source=myglue_test.default.people | eval c1 = adddate(@timestamp, 1) | fields c1 | head 10",SUCCESS +"source=myglue_test.default.people | eval c2 = subdate(@timestamp, 1) | fields c2 | head 10",SUCCESS +source=myglue_test.default.people | eval c1 = date_add(@timestamp INTERVAL 1 DAY) | fields c1 | head 10,SUCCESS +source=myglue_test.default.people | eval c1 = date_sub(@timestamp INTERVAL 1 DAY) | fields c1 | head 10,SUCCESS +source=myglue_test.default.people | eval `CURDATE()` = CURDATE() | fields `CURDATE()`,SUCCESS +source=myglue_test.default.people | eval `CURRENT_DATE()` = CURRENT_DATE() | fields `CURRENT_DATE()`,SUCCESS +source=myglue_test.default.people | eval `CURRENT_TIMESTAMP()` = CURRENT_TIMESTAMP() | fields `CURRENT_TIMESTAMP()`,SUCCESS +source=myglue_test.default.people | eval `DATE('2020-08-26')` = DATE('2020-08-26') | fields `DATE('2020-08-26')`,SUCCESS +source=myglue_test.default.people | eval `DATE(TIMESTAMP('2020-08-26 13:49:00'))` = DATE(TIMESTAMP('2020-08-26 13:49:00')) | fields `DATE(TIMESTAMP('2020-08-26 13:49:00'))`,SUCCESS +source=myglue_test.default.people | eval `DATE('2020-08-26 13:49')` = DATE('2020-08-26 13:49') | fields `DATE('2020-08-26 13:49')`,SUCCESS +"source=myglue_test.default.people | eval `DATE_FORMAT('1998-01-31 13:14:15.012345', 'HH:mm:ss.SSSSSS')` = DATE_FORMAT('1998-01-31 13:14:15.012345', 'HH:mm:ss.SSSSSS'), `DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), 'yyyy-MMM-dd hh:mm:ss a')` = DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), 'yyyy-MMM-dd hh:mm:ss a') | fields `DATE_FORMAT('1998-01-31 13:14:15.012345', 'HH:mm:ss.SSSSSS')`, `DATE_FORMAT(TIMESTAMP('1998-01-31 13:14:15.012345'), 'yyyy-MMM-dd hh:mm:ss a')`",SUCCESS +"source=myglue_test.default.people | eval `'2000-01-02' - '2000-01-01'` = DATEDIFF(TIMESTAMP('2000-01-02 00:00:00'), TIMESTAMP('2000-01-01 23:59:59')), `'2001-02-01' - '2004-01-01'` = DATEDIFF(DATE('2001-02-01'), TIMESTAMP('2004-01-01 00:00:00')) | fields `'2000-01-02' - '2000-01-01'`, `'2001-02-01' - '2004-01-01'`", +source=myglue_test.default.people | eval `DAY(DATE('2020-08-26'))` = DAY(DATE('2020-08-26')) | fields `DAY(DATE('2020-08-26'))`, +source=myglue_test.default.people | eval `DAYNAME(DATE('2020-08-26'))` = DAYNAME(DATE('2020-08-26')) | fields `DAYNAME(DATE('2020-08-26'))`,FAILED +source=myglue_test.default.people | eval `CURRENT_TIMEZONE()` = CURRENT_TIMEZONE() | fields `CURRENT_TIMEZONE()`,SUCCESS +source=myglue_test.default.people | eval `UTC_TIMESTAMP()` = UTC_TIMESTAMP() | fields `UTC_TIMESTAMP()`,SUCCESS +"source=myglue_test.default.people | eval `TIMESTAMPDIFF(YEAR, '1997-01-01 00:00:00', '2001-03-06 00:00:00')` = TIMESTAMPDIFF(YEAR, '1997-01-01 00:00:00', '2001-03-06 00:00:00') | eval `TIMESTAMPDIFF(SECOND, timestamp('1997-01-01 00:00:23'), timestamp('1997-01-01 00:00:00'))` = TIMESTAMPDIFF(SECOND, timestamp('1997-01-01 00:00:23'), timestamp('1997-01-01 00:00:00')) | fields `TIMESTAMPDIFF(YEAR, '1997-01-01 00:00:00', '2001-03-06 00:00:00')`, `TIMESTAMPDIFF(SECOND, timestamp('1997-01-01 00:00:23'), timestamp('1997-01-01 00:00:00'))`",SUCCESS +"source=myglue_test.default.people | eval `TIMESTAMPADD(DAY, 17, '2000-01-01 00:00:00')` = TIMESTAMPADD(DAY, 17, '2000-01-01 00:00:00') | eval `TIMESTAMPADD(QUARTER, -1, '2000-01-01 00:00:00')` = TIMESTAMPADD(QUARTER, -1, '2000-01-01 00:00:00') | fields `TIMESTAMPADD(DAY, 17, '2000-01-01 00:00:00')`, `TIMESTAMPADD(QUARTER, -1, '2000-01-01 00:00:00')`",SUCCESS + source = myglue_test.default.http_logs | stats count(),SUCCESS +"source = myglue_test.default.http_logs | stats avg(size) as c1, max(size) as c2, min(size) as c3, sum(size) as c4, percentile(size, 50) as c5, stddev_pop(size) as c6, stddev_samp(size) as c7, distinct_count(size) as c8",SUCCESS +"source = myglue_test.default.http_logs | eval c1 = abs(size) | eval c2 = ceil(size) | eval c3 = floor(size) | eval c4 = sqrt(size) | eval c5 = ln(size) | eval c6 = pow(size, 2) | eval c7 = mod(size, 2) | fields c1, c2, c3, c4, c5, c6, c7 | head 10",SUCCESS +"source = myglue_test.default.http_logs | eval c1 = isnull(request) | eval c2 = isnotnull(request) | eval c3 = ifnull(request, +""Unknown"") | eval c4 = nullif(request, +""Unknown"") | eval c5 = isnull(size) | eval c6 = if(like(request, '%bordeaux%'), 'hello', 'world') | fields c1, c2, c3, c4, c5, c6 | head 10",SUCCESS +/* this is block comment */ source = myglue_test.tpch_csv.orders | head 1 // this is line comment,SUCCESS +"/* test in tpch q16, q18, q20 */ source = myglue_test.tpch_csv.orders | head 1 // add source=xx to avoid failure in automation",SUCCESS +"/* test in tpch q4, q21, q22 */ source = myglue_test.tpch_csv.orders | head 1",SUCCESS +"/* test in tpch q2, q11, q15, q17, q20, q22 */ source = myglue_test.tpch_csv.orders | head 1",SUCCESS +"/* test in tpch q7, q8, q9, q13, q15, q22 */ source = myglue_test.tpch_csv.orders | head 1",SUCCESS +/* lots of inner join tests in tpch */ source = myglue_test.tpch_csv.orders | head 1,SUCCESS +/* left join test in tpch q13 */ source = myglue_test.tpch_csv.orders | head 1,SUCCESS +"source = myglue_test.tpch_csv.orders + | right outer join ON c_custkey = o_custkey AND not like(o_comment, '%special%requests%') + myglue_test.tpch_csv.customer +| stats count(o_orderkey) as c_count by c_custkey +| sort - c_count",SUCCESS +"source = myglue_test.tpch_csv.orders + | full outer join ON c_custkey = o_custkey AND not like(o_comment, '%special%requests%') + myglue_test.tpch_csv.customer +| stats count(o_orderkey) as c_count by c_custkey +| sort - c_count",SUCCESS +"source = myglue_test.tpch_csv.customer +| semi join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| where c_mktsegment = 'BUILDING' + | sort - c_custkey +| head 10",SUCCESS +"source = myglue_test.tpch_csv.customer +| anti join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| where c_mktsegment = 'BUILDING' + | sort - c_custkey +| head 10",SUCCESS +"source = myglue_test.tpch_csv.supplier +| where like(s_comment, '%Customer%Complaints%') +| join ON s_nationkey > n_nationkey [ source = myglue_test.tpch_csv.nation | where n_name = 'SAUDI ARABIA' ] +| sort - s_name +| head 10",SUCCESS +"source = myglue_test.tpch_csv.supplier +| where like(s_comment, '%Customer%Complaints%') +| join [ source = myglue_test.tpch_csv.nation | where n_name = 'SAUDI ARABIA' ] +| sort - s_name +| head 10",SUCCESS +source=myglue_test.default.people | LOOKUP myglue_test.default.work_info uid AS id REPLACE department | stats distinct_count(department),SUCCESS +source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uid AS id APPEND department | stats distinct_count(department),SUCCESS +source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uid AS id REPLACE department AS country | stats distinct_count(country),SUCCESS +source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uid AS id APPEND department AS country | stats distinct_count(country),SUCCESS +"source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uID AS id, name REPLACE department | stats distinct_count(department)",SUCCESS +"source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uid AS ID, name APPEND department | stats distinct_count(department)",SUCCESS +"source = myglue_test.default.people| LOOKUP myglue_test.default.work_info uID AS id, name | head 10",SUCCESS +"source = myglue_test.default.people | eval major = occupation | fields id, name, major, country, salary | LOOKUP myglue_test.default.work_info name REPLACE occupation AS major | stats distinct_count(major)",SUCCESS +"source = myglue_test.default.people | eval major = occupation | fields id, name, major, country, salary | LOOKUP myglue_test.default.work_info name APPEND occupation AS major | stats distinct_count(major)",SUCCESS +"source = myglue_test.default.http_logs | eval res = json('{""account_number"":1,""balance"":39225,""age"":32,""gender"":""M""}') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json('{""f1"":""abc"",""f2"":{""f3"":""a"",""f4"":""b""}}') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json('[1,2,3,{""f1"":1,""f2"":[5,6]},4]') | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = json('[]') | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json(‘{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json('{""invalid"": ""json""') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json('[1,2,3]') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json(‘[1,2') | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = json('[invalid json]') | head 1 | fields res,SUCCESS +source = myglue_test.default.http_logs | eval res = json('invalid json') | head 1 | fields res,SUCCESS +source = myglue_test.default.http_logs | eval res = json(null) | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array('this', 'is', 'a', 'string', 'array') | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = json_array() | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array(1, 2, 0, -1, 1.1, -0.11) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array('this', 'is', 1.1, -0.11, true, false) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_array(1,2,0,-1,1.1,-0.11)) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = array_length(json_array(1,2,0,-1,1.1,-0.11)) | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = array_length(json_array()) | head 1 | fields res,SUCCESS +source = myglue_test.default.http_logs | eval res = json_array_length('[]') | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array_length('[1,2,3,{""f1"":1,""f2"":[5,6]},4]') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array_length('{\""key\"": 1}') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_array_length('[1,2') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('key', 'string_value')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('key', 123.45)) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('key', true)) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object(""a"", 1, ""b"", 2, ""c"", 3)) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('key', array())) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('key', array(1, 2, 3))) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object('outer', json_object('inner', 123.45))) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = to_json_string(json_object(""array"", json_array(1,2,0,-1,1.1,-0.11))) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | where json_valid(('{""account_number"":1,""balance"":39225,""age"":32,""gender"":""M""}') | head 1",SUCCESS +"source = myglue_test.default.http_logs | where not json_valid(('{""account_number"":1,""balance"":39225,""age"":32,""gender"":""M""}') | head 1",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('{""account_number"":1,""balance"":39225,""age"":32,""gender"":""M""}')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('{""f1"":""abc"",""f2"":{""f3"":""a"",""f4"":""b""}}')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('[1,2,3,{""f1"":1,""f2"":[5,6]},4]')) | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = json_keys(json('[]')) | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json(‘{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('{""invalid"": ""json""')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('[1,2,3]')) | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_keys(json('[1,2')) | head 1 | fields res",SUCCESS +source = myglue_test.default.http_logs | eval res = json_keys(json('[invalid json]')) | head 1 | fields res,SUCCESS +source = myglue_test.default.http_logs | eval res = json_keys(json('invalid json')) | head 1 | fields res,SUCCESS +source = myglue_test.default.http_logs | eval res = json_keys(json(null)) | head 1 | fields res,SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.teacher') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[*]') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[0]') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[*].name') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[1].name') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[0].not_exist_key') | head 1 | fields res",SUCCESS +"source = myglue_test.default.http_logs | eval res = json_extract('{""teacher"":""Alice"",""student"":[{""name"":""Bob"",""rank"":1},{""name"":""Charlie"",""rank"":2}]}', '$.student[10]') | head 1 | fields res",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = forall(array, x -> x > 0) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = forall(array, x -> x > -10) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(json_object(""a"",1,""b"",-1),json_object(""a"",-1,""b"",-1)), result = forall(array, x -> x.a > 0) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(json_object(""a"",1,""b"",-1),json_object(""a"",-1,""b"",-1)), result = exists(array, x -> x.b < 0) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = exists(array, x -> x > 0) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = exists(array, x -> x > 10) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = filter(array, x -> x > 0) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,0,-1,1.1,-0.11), result = filter(array, x -> x > 10) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,3), result = transform(array, x -> x + 1) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,3), result = transform(array, (x, y) -> x + y) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,3), result = reduce(array, 0, (acc, x) -> acc + x) | head 1 | fields result",SUCCESS +"source = myglue_test.default.people | eval array = json_array(1,2,3), result = reduce(array, 0, (acc, x) -> acc + x, acc -> acc * 10) | head 1 | fields result",SUCCESS +source=myglue_test.default.people | eval age = salary | eventstats avg(age) | sort id | head 10,SUCCESS +"source=myglue_test.default.people | eval age = salary | eventstats avg(age) as avg_age, max(age) as max_age, min(age) as min_age, count(age) as count | sort id | head 10",SUCCESS +source=myglue_test.default.people | eventstats avg(salary) by country | sort id | head 10,SUCCESS +"source=myglue_test.default.people | eval age = salary | eventstats avg(age) as avg_age, max(age) as max_age, min(age) as min_age, count(age) as count by country | sort id | head 10",SUCCESS +"source=myglue_test.default.people | eval age = salary | eventstats avg(age) as avg_age, max(age) as max_age, min(age) as min_age, count(age) as count +by span(age, 10) | sort id | head 10",SUCCESS +"source=myglue_test.default.people | eval age = salary | eventstats avg(age) as avg_age, max(age) as max_age, min(age) as min_age, count(age) as count by span(age, 10) as age_span, country | sort id | head 10",SUCCESS +"source=myglue_test.default.people | where country != 'USA' | eventstats stddev_samp(salary), stddev_pop(salary), percentile_approx(salary, 60) by span(salary, 1000) as salary_span | sort id | head 10",SUCCESS +"source=myglue_test.default.people | eval age = salary | eventstats avg(age) as avg_age by occupation, country | eventstats avg(avg_age) as avg_state_age by country | sort id | head 10",SUCCESS +"source=myglue_test.default.people | eventstats distinct_count(salary) by span(salary, 1000) as age_span",FAILED +"source = myglue_test.tpch_csv.lineitem +| where l_shipdate <= subdate(date('1998-12-01'), 90) +| stats sum(l_quantity) as sum_qty, + sum(l_extendedprice) as sum_base_price, + sum(l_extendedprice * (1 - l_discount)) as sum_disc_price, + sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)) as sum_charge, + avg(l_quantity) as avg_qty, + avg(l_extendedprice) as avg_price, + avg(l_discount) as avg_disc, + count() as count_order + by l_returnflag, l_linestatus +| sort l_returnflag, l_linestatus",SUCCESS +"source = myglue_test.tpch_csv.part +| join ON p_partkey = ps_partkey myglue_test.tpch_csv.partsupp +| join ON s_suppkey = ps_suppkey myglue_test.tpch_csv.supplier +| join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation +| join ON n_regionkey = r_regionkey myglue_test.tpch_csv.region +| where p_size = 15 AND like(p_type, '%BRASS') AND r_name = 'EUROPE' AND ps_supplycost = [ + source = myglue_test.tpch_csv.partsupp + | join ON s_suppkey = ps_suppkey myglue_test.tpch_csv.supplier + | join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation + | join ON n_regionkey = r_regionkey myglue_test.tpch_csv.region + | where r_name = 'EUROPE' + | stats MIN(ps_supplycost) + ] +| sort - s_acctbal, n_name, s_name, p_partkey +| head 100",SUCCESS +"source = myglue_test.tpch_csv.customer +| join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| join ON l_orderkey = o_orderkey myglue_test.tpch_csv.lineitem +| where c_mktsegment = 'BUILDING' AND o_orderdate < date('1995-03-15') AND l_shipdate > date('1995-03-15') +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by l_orderkey, o_orderdate, o_shippriority + | sort - revenue, o_orderdate +| head 10",SUCCESS +"source = myglue_test.tpch_csv.orders +| where o_orderdate >= date('1993-07-01') + and o_orderdate < date_add(date('1993-07-01'), interval 3 month) + and exists [ + source = myglue_test.tpch_csv.lineitem + | where l_orderkey = o_orderkey and l_commitdate < l_receiptdate + ] +| stats count() as order_count by o_orderpriority +| sort o_orderpriority",SUCCESS +"source = myglue_test.tpch_csv.customer +| join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| join ON l_orderkey = o_orderkey myglue_test.tpch_csv.lineitem +| join ON l_suppkey = s_suppkey AND c_nationkey = s_nationkey myglue_test.tpch_csv.supplier +| join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation +| join ON n_regionkey = r_regionkey myglue_test.tpch_csv.region +| where r_name = 'ASIA' AND o_orderdate >= date('1994-01-01') AND o_orderdate < date_add(date('1994-01-01'), interval 1 year) +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by n_name +| sort - revenue",SUCCESS +"source = myglue_test.tpch_csv.lineitem +| where l_shipdate >= date('1994-01-01') + and l_shipdate < adddate(date('1994-01-01'), 365) + and l_discount between .06 - 0.01 and .06 + 0.01 + and l_quantity < 24 +| stats sum(l_extendedprice * l_discount) as revenue",SUCCESS +"source = [ + source = myglue_test.tpch_csv.supplier + | join ON s_suppkey = l_suppkey myglue_test.tpch_csv.lineitem + | join ON o_orderkey = l_orderkey myglue_test.tpch_csv.orders + | join ON c_custkey = o_custkey myglue_test.tpch_csv.customer + | join ON s_nationkey = n1.n_nationkey myglue_test.tpch_csv.nation as n1 + | join ON c_nationkey = n2.n_nationkey myglue_test.tpch_csv.nation as n2 + | where l_shipdate between date('1995-01-01') and date('1996-12-31') + and n1.n_name = 'FRANCE' and n2.n_name = 'GERMANY' or n1.n_name = 'GERMANY' and n2.n_name = 'FRANCE' + | eval supp_nation = n1.n_name, cust_nation = n2.n_name, l_year = year(l_shipdate), volume = l_extendedprice * (1 - l_discount) + | fields supp_nation, cust_nation, l_year, volume + ] as shipping +| stats sum(volume) as revenue by supp_nation, cust_nation, l_year +| sort supp_nation, cust_nation, l_year",SUCCESS +"source = [ + source = myglue_test.tpch_csv.part + | join ON p_partkey = l_partkey myglue_test.tpch_csv.lineitem + | join ON s_suppkey = l_suppkey myglue_test.tpch_csv.supplier + | join ON l_orderkey = o_orderkey myglue_test.tpch_csv.orders + | join ON o_custkey = c_custkey myglue_test.tpch_csv.customer + | join ON c_nationkey = n1.n_nationkey myglue_test.tpch_csv.nation as n1 + | join ON s_nationkey = n2.n_nationkey myglue_test.tpch_csv.nation as n2 + | join ON n1.n_regionkey = r_regionkey myglue_test.tpch_csv.region + | where r_name = 'AMERICA' AND p_type = 'ECONOMY ANODIZED STEEL' + and o_orderdate between date('1995-01-01') and date('1996-12-31') + | eval o_year = year(o_orderdate) + | eval volume = l_extendedprice * (1 - l_discount) + | eval nation = n2.n_name + | fields o_year, volume, nation + ] as all_nations +| stats sum(case(nation = 'BRAZIL', volume else 0)) as sum_case, sum(volume) as sum_volume by o_year +| eval mkt_share = sum_case / sum_volume +| fields mkt_share, o_year +| sort o_year",SUCCESS +"source = [ + source = myglue_test.tpch_csv.part + | join ON p_partkey = l_partkey myglue_test.tpch_csv.lineitem + | join ON s_suppkey = l_suppkey myglue_test.tpch_csv.supplier + | join ON ps_partkey = l_partkey and ps_suppkey = l_suppkey myglue_test.tpch_csv.partsupp + | join ON o_orderkey = l_orderkey myglue_test.tpch_csv.orders + | join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation + | where like(p_name, '%green%') + | eval nation = n_name + | eval o_year = year(o_orderdate) + | eval amount = l_extendedprice * (1 - l_discount) - ps_supplycost * l_quantity + | fields nation, o_year, amount + ] as profit +| stats sum(amount) as sum_profit by nation, o_year +| sort nation, - o_year",SUCCESS +"source = myglue_test.tpch_csv.customer +| join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| join ON l_orderkey = o_orderkey myglue_test.tpch_csv.lineitem +| join ON c_nationkey = n_nationkey myglue_test.tpch_csv.nation +| where o_orderdate >= date('1993-10-01') + AND o_orderdate < date_add(date('1993-10-01'), interval 3 month) + AND l_returnflag = 'R' +| stats sum(l_extendedprice * (1 - l_discount)) as revenue by c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment +| sort - revenue +| head 20",SUCCESS +"source = myglue_test.tpch_csv.partsupp +| join ON ps_suppkey = s_suppkey myglue_test.tpch_csv.supplier +| join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation +| where n_name = 'GERMANY' +| stats sum(ps_supplycost * ps_availqty) as value by ps_partkey +| where value > [ + source = myglue_test.tpch_csv.partsupp + | join ON ps_suppkey = s_suppkey myglue_test.tpch_csv.supplier + | join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation + | where n_name = 'GERMANY' + | stats sum(ps_supplycost * ps_availqty) as check + | eval threshold = check * 0.0001000000 + | fields threshold + ] +| sort - value",SUCCESS +"source = myglue_test.tpch_csv.orders +| join ON o_orderkey = l_orderkey myglue_test.tpch_csv.lineitem +| where l_commitdate < l_receiptdate + and l_shipdate < l_commitdate + and l_shipmode in ('MAIL', 'SHIP') + and l_receiptdate >= date('1994-01-01') + and l_receiptdate < date_add(date('1994-01-01'), interval 1 year) +| stats sum(case(o_orderpriority = '1-URGENT' or o_orderpriority = '2-HIGH', 1 else 0)) as high_line_count, + sum(case(o_orderpriority != '1-URGENT' and o_orderpriority != '2-HIGH', 1 else 0)) as low_line_countby + by l_shipmode +| sort l_shipmode",SUCCESS +"source = [ + source = myglue_test.tpch_csv.customer + | left outer join ON c_custkey = o_custkey AND not like(o_comment, '%special%requests%') + myglue_test.tpch_csv.orders + | stats count(o_orderkey) as c_count by c_custkey + ] as c_orders +| stats count() as custdist by c_count +| sort - custdist, - c_count",SUCCESS +"source = myglue_test.tpch_csv.lineitem +| join ON l_partkey = p_partkey + AND l_shipdate >= date('1995-09-01') + AND l_shipdate < date_add(date('1995-09-01'), interval 1 month) + myglue_test.tpch_csv.part +| stats sum(case(like(p_type, 'PROMO%'), l_extendedprice * (1 - l_discount) else 0)) as sum1, + sum(l_extendedprice * (1 - l_discount)) as sum2 +| eval promo_revenue = 100.00 * sum1 / sum2 // Stats and Eval commands can combine when issues/819 resolved +| fields promo_revenue",SUCCESS +"source = myglue_test.tpch_csv.supplier +| join right = revenue0 ON s_suppkey = supplier_no [ + source = myglue_test.tpch_csv.lineitem + | where l_shipdate >= date('1996-01-01') AND l_shipdate < date_add(date('1996-01-01'), interval 3 month) + | eval supplier_no = l_suppkey + | stats sum(l_extendedprice * (1 - l_discount)) as total_revenue by supplier_no + ] +| where total_revenue = [ + source = [ + source = myglue_test.tpch_csv.lineitem + | where l_shipdate >= date('1996-01-01') AND l_shipdate < date_add(date('1996-01-01'), interval 3 month) + | eval supplier_no = l_suppkey + | stats sum(l_extendedprice * (1 - l_discount)) as total_revenue by supplier_no + ] + | stats max(total_revenue) + ] +| sort s_suppkey +| fields s_suppkey, s_name, s_address, s_phone, total_revenue",SUCCESS +"source = myglue_test.tpch_csv.partsupp +| join ON p_partkey = ps_partkey myglue_test.tpch_csv.part +| where p_brand != 'Brand#45' + and not like(p_type, 'MEDIUM POLISHED%') + and p_size in (49, 14, 23, 45, 19, 3, 36, 9) + and ps_suppkey not in [ + source = myglue_test.tpch_csv.supplier + | where like(s_comment, '%Customer%Complaints%') + | fields s_suppkey + ] +| stats distinct_count(ps_suppkey) as supplier_cnt by p_brand, p_type, p_size +| sort - supplier_cnt, p_brand, p_type, p_size",SUCCESS +"source = myglue_test.tpch_csv.lineitem +| join ON p_partkey = l_partkey myglue_test.tpch_csv.part +| where p_brand = 'Brand#23' + and p_container = 'MED BOX' + and l_quantity < [ + source = myglue_test.tpch_csv.lineitem + | where l_partkey = p_partkey + | stats avg(l_quantity) as avg + | eval `0.2 * avg` = 0.2 * avg + | fields `0.2 * avg` + ] +| stats sum(l_extendedprice) as sum +| eval avg_yearly = sum / 7.0 +| fields avg_yearly",SUCCESS +"source = myglue_test.tpch_csv.customer +| join ON c_custkey = o_custkey myglue_test.tpch_csv.orders +| join ON o_orderkey = l_orderkey myglue_test.tpch_csv.lineitem +| where o_orderkey in [ + source = myglue_test.tpch_csv.lineitem + | stats sum(l_quantity) as sum by l_orderkey + | where sum > 300 + | fields l_orderkey + ] +| stats sum(l_quantity) by c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice +| sort - o_totalprice, o_orderdate +| head 100",SUCCESS +"source = myglue_test.tpch_csv.lineitem +| join ON p_partkey = l_partkey + and p_brand = 'Brand#12' + and p_container in ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') + and l_quantity >= 1 and l_quantity <= 1 + 10 + and p_size between 1 and 5 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + OR p_partkey = l_partkey + and p_brand = 'Brand#23' + and p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') + and l_quantity >= 10 and l_quantity <= 10 + 10 + and p_size between 1 and 10 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + OR p_partkey = l_partkey + and p_brand = 'Brand#34' + and p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') + and l_quantity >= 20 and l_quantity <= 20 + 10 + and p_size between 1 and 15 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + myglue_test.tpch_csv.part",SUCCESS +"source = myglue_test.tpch_csv.supplier +| join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation +| where n_name = 'CANADA' + and s_suppkey in [ + source = myglue_test.tpch_csv.partsupp + | where ps_partkey in [ + source = myglue_test.tpch_csv.part + | where like(p_name, 'forest%') + | fields p_partkey + ] + and ps_availqty > [ + source = myglue_test.tpch_csv.lineitem + | where l_partkey = ps_partkey + and l_suppkey = ps_suppkey + and l_shipdate >= date('1994-01-01') + and l_shipdate < date_add(date('1994-01-01'), interval 1 year) + | stats sum(l_quantity) as sum_l_quantity + | eval half_sum_l_quantity = 0.5 * sum_l_quantity + | fields half_sum_l_quantity + ] + | fields ps_suppkey + ]",SUCCESS +"source = myglue_test.tpch_csv.supplier +| join ON s_suppkey = l1.l_suppkey myglue_test.tpch_csv.lineitem as l1 +| join ON o_orderkey = l1.l_orderkey myglue_test.tpch_csv.orders +| join ON s_nationkey = n_nationkey myglue_test.tpch_csv.nation +| where o_orderstatus = 'F' + and l1.l_receiptdate > l1.l_commitdate + and exists [ + source = myglue_test.tpch_csv.lineitem as l2 + | where l2.l_orderkey = l1.l_orderkey + and l2.l_suppkey != l1.l_suppkey + ] + and not exists [ + source = myglue_test.tpch_csv.lineitem as l3 + | where l3.l_orderkey = l1.l_orderkey + and l3.l_suppkey != l1.l_suppkey + and l3.l_receiptdate > l3.l_commitdate + ] + and n_name = 'SAUDI ARABIA' +| stats count() as numwait by s_name +| sort - numwait, s_name +| head 100",SUCCESS +"source = [ + source = myglue_test.tpch_csv.customer + | where substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + and c_acctbal > [ + source = myglue_test.tpch_csv.customer + | where c_acctbal > 0.00 + and substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + | stats avg(c_acctbal) + ] + and not exists [ + source = myglue_test.tpch_csv.orders + | where o_custkey = c_custkey + ] + | eval cntrycode = substring(c_phone, 1, 2) + | fields cntrycode, c_acctbal + ] as custsale +| stats count() as numcust, sum(c_acctbal) as totacctbal by cntrycode +| sort cntrycode",SUCCESS From b53a6993ed028671d33a6debe36574751b05d9de Mon Sep 17 00:00:00 2001 From: YANGDB Date: Mon, 11 Nov 2024 15:51:24 -0800 Subject: [PATCH 08/26] Ppl count approximate support (#884) * add functional approximation support for: - distinct count - top - rare Signed-off-by: YANGDB * update license and scalafmt Signed-off-by: YANGDB * update additional tests using APPROX_COUNT_DISTINCT Signed-off-by: YANGDB * add visitFirstChild(node, context) method for the PlanVisitor for simplify node inner child access visibility Signed-off-by: YANGDB * update inline documentation Signed-off-by: YANGDB * update according to PR comments - DISTINCT_COUNT_APPROX should be added to keywordsCanBeId Signed-off-by: YANGDB --------- Signed-off-by: YANGDB --- docs/ppl-lang/PPL-Example-Commands.md | 5 + docs/ppl-lang/ppl-rare-command.md | 10 +- docs/ppl-lang/ppl-top-command.md | 7 +- ...ntSparkPPLAggregationWithSpanITSuite.scala | 39 +++ .../FlintSparkPPLAggregationsITSuite.scala | 124 ++++++++ .../ppl/FlintSparkPPLTopAndRareITSuite.scala | 270 ++++++++++++++++++ .../src/main/antlr4/OpenSearchPPLLexer.g4 | 3 + .../src/main/antlr4/OpenSearchPPLParser.g4 | 9 +- .../sql/ast/tree/CountedAggregation.java | 16 ++ .../sql/ast/tree/RareAggregation.java | 10 +- .../sql/ast/tree/TopAggregation.java | 2 +- .../function/BuiltinFunctionName.java | 2 + .../sql/ppl/CatalystPlanContext.java | 3 +- .../sql/ppl/CatalystQueryPlanVisitor.java | 68 +++-- .../opensearch/sql/ppl/parser/AstBuilder.java | 20 +- .../sql/ppl/parser/AstExpressionBuilder.java | 3 +- .../sql/ppl/utils/AggregatorTransformer.java | 2 + .../ppl/utils/BuiltinFunctionTransformer.java | 3 + ...ggregationQueriesTranslatorTestSuite.scala | 92 ++++++ ...TopAndRareQueriesTranslatorTestSuite.scala | 36 +++ 20 files changed, 668 insertions(+), 56 deletions(-) create mode 100644 ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/CountedAggregation.java diff --git a/docs/ppl-lang/PPL-Example-Commands.md b/docs/ppl-lang/PPL-Example-Commands.md index 4ea564111..cb50431f6 100644 --- a/docs/ppl-lang/PPL-Example-Commands.md +++ b/docs/ppl-lang/PPL-Example-Commands.md @@ -177,6 +177,7 @@ source = table | where ispresent(a) | - `source = table | stats max(c) by b` - `source = table | stats count(c) by b | head 5` - `source = table | stats distinct_count(c)` +- `source = table | stats distinct_count_approx(c)` - `source = table | stats stddev_samp(c)` - `source = table | stats stddev_pop(c)` - `source = table | stats percentile(c, 90)` @@ -202,6 +203,7 @@ source = table | where ispresent(a) | - `source = table | where a < 50 | eventstats avg(c) ` - `source = table | eventstats max(c) by b` - `source = table | eventstats count(c) by b | head 5` +- `source = table | eventstats count(c) by b | head 5` - `source = table | eventstats stddev_samp(c)` - `source = table | eventstats stddev_pop(c)` - `source = table | eventstats percentile(c, 90)` @@ -246,12 +248,15 @@ source = table | where ispresent(a) | - `source=accounts | rare gender` - `source=accounts | rare age by gender` +- `source=accounts | rare 5 age by gender` +- `source=accounts | rare_approx age by gender` #### **Top** [See additional command details](ppl-top-command.md) - `source=accounts | top gender` - `source=accounts | top 1 gender` +- `source=accounts | top_approx 5 gender` - `source=accounts | top 1 age by gender` #### **Parse** diff --git a/docs/ppl-lang/ppl-rare-command.md b/docs/ppl-lang/ppl-rare-command.md index 5645382f8..e3ad21f4e 100644 --- a/docs/ppl-lang/ppl-rare-command.md +++ b/docs/ppl-lang/ppl-rare-command.md @@ -6,10 +6,13 @@ Using ``rare`` command to find the least common tuple of values of all fields in **Note**: A maximum of 10 results is returned for each distinct tuple of values of the group-by fields. **Syntax** -`rare [by-clause]` +`rare [N] [by-clause]` +`rare_approx [N] [by-clause]` +* N: number of results to return. **Default**: 10 * field-list: mandatory. comma-delimited list of field names. * by-clause: optional. one or more fields to group the results by. +* rare_approx: approximate count of the rare (n) fields by using estimated [cardinality by HyperLogLog++ algorithm](https://spark.apache.org/docs/3.5.2/sql-ref-functions-builtin.html). ### Example 1: Find the least common values in a field @@ -19,6 +22,8 @@ The example finds least common gender of all the accounts. PPL query: os> source=accounts | rare gender; + os> source=accounts | rare_approx 10 gender; + os> source=accounts | rare_approx gender; fetched rows / total rows = 2/2 +----------+ | gender | @@ -34,7 +39,8 @@ The example finds least common age of all the accounts group by gender. PPL query: - os> source=accounts | rare age by gender; + os> source=accounts | rare 5 age by gender; + os> source=accounts | rare_approx 5 age by gender; fetched rows / total rows = 4/4 +----------+-------+ | gender | age | diff --git a/docs/ppl-lang/ppl-top-command.md b/docs/ppl-lang/ppl-top-command.md index 4ba56f692..93d3a7148 100644 --- a/docs/ppl-lang/ppl-top-command.md +++ b/docs/ppl-lang/ppl-top-command.md @@ -6,11 +6,12 @@ Using ``top`` command to find the most common tuple of values of all fields in t ### Syntax `top [N] [by-clause]` +`top_approx [N] [by-clause]` * N: number of results to return. **Default**: 10 * field-list: mandatory. comma-delimited list of field names. * by-clause: optional. one or more fields to group the results by. - +* top_approx: approximate count of the (n) top fields by using estimated [cardinality by HyperLogLog++ algorithm](https://spark.apache.org/docs/3.5.2/sql-ref-functions-builtin.html). ### Example 1: Find the most common values in a field @@ -19,6 +20,7 @@ The example finds most common gender of all the accounts. PPL query: os> source=accounts | top gender; + os> source=accounts | top_approx gender; fetched rows / total rows = 2/2 +----------+ | gender | @@ -33,7 +35,7 @@ The example finds most common gender of all the accounts. PPL query: - os> source=accounts | top 1 gender; + os> source=accounts | top_approx 1 gender; fetched rows / total rows = 1/1 +----------+ | gender | @@ -48,6 +50,7 @@ The example finds most common age of all the accounts group by gender. PPL query: os> source=accounts | top 1 age by gender; + os> source=accounts | top_approx 1 age by gender; fetched rows / total rows = 2/2 +----------+-------+ | gender | age | diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationWithSpanITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationWithSpanITSuite.scala index 0bebca9b0..aa96d0991 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationWithSpanITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationWithSpanITSuite.scala @@ -494,4 +494,43 @@ class FlintSparkPPLAggregationWithSpanITSuite // Compare the two plans comparePlans(expectedPlan, logicalPlan, false) } + + test( + "create ppl simple distinct count age by span of interval of 10 years query with state filter test using approximation") { + val frame = sql(s""" + | source = $testTable | where state != 'Quebec' | stats distinct_count_approx(age) by span(age, 10) as age_span + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + // Define the expected results + val expectedResults: Array[Row] = Array(Row(1, 70L), Row(1, 30L), Row(1, 20L)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, Long](_.getAs[Long](1)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val ageField = UnresolvedAttribute("age") + val stateField = UnresolvedAttribute("state") + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test")) + + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(ageField), isDistinct = true), + "distinct_count_approx(age)")() + val span = Alias( + Multiply(Floor(Divide(UnresolvedAttribute("age"), Literal(10))), Literal(10)), + "age_span")() + val filterExpr = Not(EqualTo(stateField, Literal("Quebec"))) + val filterPlan = Filter(filterExpr, table) + val aggregatePlan = Aggregate(Seq(span), Seq(aggregateExpressions, span), filterPlan) + val expectedPlan = Project(star, aggregatePlan) + + // Compare the two plans + comparePlans(expectedPlan, logicalPlan, false) + } } diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationsITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationsITSuite.scala index bcfe22764..2275c775c 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationsITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLAggregationsITSuite.scala @@ -835,6 +835,43 @@ class FlintSparkPPLAggregationsITSuite comparePlans(expectedPlan, logicalPlan, false) } + test("create ppl simple country distinct_count using approximation ") { + val frame = sql(s""" + | source = $testTable| stats distinct_count_approx(country) + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + + // Define the expected results + val expectedResults: Array[Row] = Array(Row(2L)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](1)) + assert( + results.sorted.sameElements(expectedResults.sorted), + s"Expected: ${expectedResults.mkString(", ")}, but got: ${results.mkString(", ")}") + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val countryField = UnresolvedAttribute("country") + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test")) + + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(countryField), isDistinct = true), + "distinct_count_approx(country)")() + + val aggregatePlan = + Aggregate(Seq.empty, Seq(aggregateExpressions), table) + val expectedPlan = Project(star, aggregatePlan) + + // Compare the two plans + comparePlans(expectedPlan, logicalPlan, false) + } + test("create ppl simple age distinct_count group by country query test with sort") { val frame = sql(s""" | source = $testTable | stats distinct_count(age) by country | sort country @@ -881,6 +918,53 @@ class FlintSparkPPLAggregationsITSuite s"Expected plan: ${compareByString(expectedPlan)}, but got: ${compareByString(logicalPlan)}") } + test( + "create ppl simple age distinct_count group by country query test with sort using approximation") { + val frame = sql(s""" + | source = $testTable | stats distinct_count_approx(age) by country | sort country + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + // Define the expected results + val expectedResults: Array[Row] = Array(Row(2L, "Canada"), Row(2L, "USA")) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](1)) + assert( + results.sorted.sameElements(expectedResults.sorted), + s"Expected: ${expectedResults.mkString(", ")}, but got: ${results.mkString(", ")}") + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val countryField = UnresolvedAttribute("country") + val ageField = UnresolvedAttribute("age") + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test")) + + val groupByAttributes = Seq(Alias(countryField, "country")()) + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(ageField), isDistinct = true), + "distinct_count_approx(age)")() + val productAlias = Alias(countryField, "country")() + + val aggregatePlan = + Aggregate(groupByAttributes, Seq(aggregateExpressions, productAlias), table) + val sortedPlan: LogicalPlan = + Sort( + Seq(SortOrder(UnresolvedAttribute("country"), Ascending)), + global = true, + aggregatePlan) + val expectedPlan = Project(star, sortedPlan) + + // Compare the two plans + assert( + compareByString(expectedPlan) === compareByString(logicalPlan), + s"Expected plan: ${compareByString(expectedPlan)}, but got: ${compareByString(logicalPlan)}") + } + test("create ppl simple age distinct_count group by country with state filter query test") { val frame = sql(s""" | source = $testTable | where state != 'Ontario' | stats distinct_count(age) by country @@ -920,6 +1004,46 @@ class FlintSparkPPLAggregationsITSuite assert(compareByString(expectedPlan) === compareByString(logicalPlan)) } + test( + "create ppl simple age distinct_count group by country with state filter query test using approximation") { + val frame = sql(s""" + | source = $testTable | where state != 'Ontario' | stats distinct_count_approx(age) by country + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + // Define the expected results + val expectedResults: Array[Row] = Array(Row(1L, "Canada"), Row(2L, "USA")) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](1)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val stateField = UnresolvedAttribute("state") + val countryField = UnresolvedAttribute("country") + val ageField = UnresolvedAttribute("age") + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test")) + + val groupByAttributes = Seq(Alias(countryField, "country")()) + val filterExpr = Not(EqualTo(stateField, Literal("Ontario"))) + val filterPlan = Filter(filterExpr, table) + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(ageField), isDistinct = true), + "distinct_count_approx(age)")() + val productAlias = Alias(countryField, "country")() + val aggregatePlan = + Aggregate(groupByAttributes, Seq(aggregateExpressions, productAlias), filterPlan) + val expectedPlan = Project(star, aggregatePlan) + + // Compare the two plans + assert(compareByString(expectedPlan) === compareByString(logicalPlan)) + } + test("two-level stats") { val frame = sql(s""" | source = $testTable| stats avg(age) as avg_age by state, country | stats avg(avg_age) as avg_state_age by country diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTopAndRareITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTopAndRareITSuite.scala index f10b6e2f5..4a1633035 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTopAndRareITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTopAndRareITSuite.scala @@ -84,6 +84,48 @@ class FlintSparkPPLTopAndRareITSuite comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) } + test("create ppl rare address field query test with approximation") { + val frame = sql(s""" + | source = $testTable| rare_approx address + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val addressField = UnresolvedAttribute("address") + val projectList: Seq[NamedExpression] = Seq(UnresolvedStar(None)) + + val aggregateExpressions = Seq( + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(addressField), isDistinct = false), + "count_address")(), + addressField) + val aggregatePlan = + Aggregate( + Seq(addressField), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test"))) + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(addressField), + isDistinct = false), + "count_address")(), + Ascending)), + global = true, + aggregatePlan) + val expectedPlan = Project(projectList, sortedPlan) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + test("create ppl rare address by age field query test") { val frame = sql(s""" | source = $testTable| rare address by age @@ -132,6 +174,104 @@ class FlintSparkPPLTopAndRareITSuite comparePlans(expectedPlan, logicalPlan, false) } + test("create ppl rare 3 address by age field query test") { + val frame = sql(s""" + | source = $testTable| rare 3 address by age + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + val expectedRow = Row(1, "Vancouver", 60) + assert( + results.head == expectedRow, + s"Expected least frequent result to be $expectedRow, but got ${results.head}") + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val addressField = UnresolvedAttribute("address") + val ageField = UnresolvedAttribute("age") + val ageAlias = Alias(ageField, "age")() + + val projectList: Seq[NamedExpression] = Seq(UnresolvedStar(None)) + + val countExpr = Alias( + UnresolvedFunction(Seq("COUNT"), Seq(addressField), isDistinct = false), + "count_address")() + + val aggregateExpressions = Seq(countExpr, addressField, ageAlias) + val aggregatePlan = + Aggregate( + Seq(addressField, ageAlias), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test"))) + + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction(Seq("COUNT"), Seq(addressField), isDistinct = false), + "count_address")(), + Ascending)), + global = true, + aggregatePlan) + + val planWithLimit = + GlobalLimit(Literal(3), LocalLimit(Literal(3), sortedPlan)) + val expectedPlan = Project(Seq(UnresolvedStar(None)), planWithLimit) + comparePlans(expectedPlan, logicalPlan, false) + } + + test("create ppl rare 3 address by age field query test with approximation") { + val frame = sql(s""" + | source = $testTable| rare_approx 3 address by age + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val addressField = UnresolvedAttribute("address") + val ageField = UnresolvedAttribute("age") + val ageAlias = Alias(ageField, "age")() + + val projectList: Seq[NamedExpression] = Seq(UnresolvedStar(None)) + + val countExpr = Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(addressField), isDistinct = false), + "count_address")() + + val aggregateExpressions = Seq(countExpr, addressField, ageAlias) + val aggregatePlan = + Aggregate( + Seq(addressField, ageAlias), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test"))) + + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(addressField), + isDistinct = false), + "count_address")(), + Ascending)), + global = true, + aggregatePlan) + + val planWithLimit = + GlobalLimit(Literal(3), LocalLimit(Literal(3), sortedPlan)) + val expectedPlan = Project(Seq(UnresolvedStar(None)), planWithLimit) + comparePlans(expectedPlan, logicalPlan, false) + } + test("create ppl top address field query test") { val frame = sql(s""" | source = $testTable| top address @@ -179,6 +319,48 @@ class FlintSparkPPLTopAndRareITSuite comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) } + test("create ppl top address field query test with approximation") { + val frame = sql(s""" + | source = $testTable| top_approx address + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + // Define the expected logical plan + val addressField = UnresolvedAttribute("address") + val projectList: Seq[NamedExpression] = Seq(UnresolvedStar(None)) + + val aggregateExpressions = Seq( + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(addressField), isDistinct = false), + "count_address")(), + addressField) + val aggregatePlan = + Aggregate( + Seq(addressField), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test"))) + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(addressField), + isDistinct = false), + "count_address")(), + Descending)), + global = true, + aggregatePlan) + val expectedPlan = Project(projectList, sortedPlan) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + test("create ppl top 3 countries query test") { val frame = sql(s""" | source = $newTestTable| top 3 country @@ -226,6 +408,48 @@ class FlintSparkPPLTopAndRareITSuite comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) } + test("create ppl top 3 countries query test with approximation") { + val frame = sql(s""" + | source = $newTestTable| top_approx 3 country + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val countryField = UnresolvedAttribute("country") + val countExpr = Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(countryField), isDistinct = false), + "count_country")() + val aggregateExpressions = Seq(countExpr, countryField) + val aggregatePlan = + Aggregate( + Seq(countryField), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "new_flint_ppl_test"))) + + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(countryField), + isDistinct = false), + "count_country")(), + Descending)), + global = true, + aggregatePlan) + + val planWithLimit = + GlobalLimit(Literal(3), LocalLimit(Literal(3), sortedPlan)) + val expectedPlan = Project(Seq(UnresolvedStar(None)), planWithLimit) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + } + test("create ppl top 2 countries by occupation field query test") { val frame = sql(s""" | source = $newTestTable| top 3 country by occupation @@ -277,4 +501,50 @@ class FlintSparkPPLTopAndRareITSuite comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) } + + test("create ppl top 2 countries by occupation field query test with approximation") { + val frame = sql(s""" + | source = $newTestTable| top_approx 3 country by occupation + | """.stripMargin) + + // Retrieve the results + val results: Array[Row] = frame.collect() + assert(results.length == 3) + + // Retrieve the logical plan + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val countryField = UnresolvedAttribute("country") + val occupationField = UnresolvedAttribute("occupation") + val occupationFieldAlias = Alias(occupationField, "occupation")() + + val countExpr = Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(countryField), isDistinct = false), + "count_country")() + val aggregateExpressions = Seq(countExpr, countryField, occupationFieldAlias) + val aggregatePlan = + Aggregate( + Seq(countryField, occupationFieldAlias), + aggregateExpressions, + UnresolvedRelation(Seq("spark_catalog", "default", "new_flint_ppl_test"))) + + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(countryField), + isDistinct = false), + "count_country")(), + Descending)), + global = true, + aggregatePlan) + + val planWithLimit = + GlobalLimit(Literal(3), LocalLimit(Literal(3), sortedPlan)) + val expectedPlan = Project(Seq(UnresolvedStar(None)), planWithLimit) + comparePlans(expectedPlan, logicalPlan, checkAnalysis = false) + + } } diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 index 2c3344b3c..10b2e01b8 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 @@ -23,7 +23,9 @@ DEDUP: 'DEDUP'; SORT: 'SORT'; EVAL: 'EVAL'; HEAD: 'HEAD'; +TOP_APPROX: 'TOP_APPROX'; TOP: 'TOP'; +RARE_APPROX: 'RARE_APPROX'; RARE: 'RARE'; PARSE: 'PARSE'; METHOD: 'METHOD'; @@ -216,6 +218,7 @@ BIT_XOR_OP: '^'; AVG: 'AVG'; COUNT: 'COUNT'; DISTINCT_COUNT: 'DISTINCT_COUNT'; +DISTINCT_COUNT_APPROX: 'DISTINCT_COUNT_APPROX'; ESTDC: 'ESTDC'; ESTDC_ERROR: 'ESTDC_ERROR'; MAX: 'MAX'; diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index 1cfd172f7..63efd8c6c 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -76,7 +76,9 @@ commandName | SORT | HEAD | TOP + | TOP_APPROX | RARE + | RARE_APPROX | EVAL | GROK | PARSE @@ -180,11 +182,11 @@ headCommand ; topCommand - : TOP (number = integerLiteral)? fieldList (byClause)? + : (TOP | TOP_APPROX) (number = integerLiteral)? fieldList (byClause)? ; rareCommand - : RARE fieldList (byClause)? + : (RARE | RARE_APPROX) (number = integerLiteral)? fieldList (byClause)? ; grokCommand @@ -400,7 +402,7 @@ statsAggTerm statsFunction : statsFunctionName LT_PRTHS valueExpression RT_PRTHS # statsFunctionCall | COUNT LT_PRTHS RT_PRTHS # countAllFunctionCall - | (DISTINCT_COUNT | DC) LT_PRTHS valueExpression RT_PRTHS # distinctCountFunctionCall + | (DISTINCT_COUNT | DC | DISTINCT_COUNT_APPROX) LT_PRTHS valueExpression RT_PRTHS # distinctCountFunctionCall | percentileFunctionName = (PERCENTILE | PERCENTILE_APPROX) LT_PRTHS valueExpression COMMA percent = integerLiteral RT_PRTHS # percentileFunctionCall ; @@ -1122,6 +1124,7 @@ keywordsCanBeId // AGGREGATIONS | statsFunctionName | DISTINCT_COUNT + | DISTINCT_COUNT_APPROX | PERCENTILE | PERCENTILE_APPROX | ESTDC diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/CountedAggregation.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/CountedAggregation.java new file mode 100644 index 000000000..9a4aa5d7d --- /dev/null +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/CountedAggregation.java @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.sql.ast.tree; + +import org.opensearch.sql.ast.expression.Literal; + +import java.util.Optional; + +/** + * marker interface for numeric based count aggregation (specific number of returned results) + */ +public interface CountedAggregation { + Optional getResults(); +} diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/RareAggregation.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/RareAggregation.java index d5a637f3d..8e454685a 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/RareAggregation.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/RareAggregation.java @@ -6,21 +6,29 @@ package org.opensearch.sql.ast.tree; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.ToString; +import org.opensearch.sql.ast.expression.Literal; import org.opensearch.sql.ast.expression.UnresolvedExpression; import java.util.Collections; import java.util.List; +import java.util.Optional; /** Logical plan node of Rare (Aggregation) command, the interface for building aggregation actions in queries. */ @ToString +@Getter @EqualsAndHashCode(callSuper = true) -public class RareAggregation extends Aggregation { +public class RareAggregation extends Aggregation implements CountedAggregation{ + private final Optional results; + /** Aggregation Constructor without span and argument. */ public RareAggregation( + Optional results, List aggExprList, List sortExprList, List groupExprList) { super(aggExprList, sortExprList, groupExprList, null, Collections.emptyList()); + this.results = results; } } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/TopAggregation.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/TopAggregation.java index e87a3b0b0..90aac5838 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/TopAggregation.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/TopAggregation.java @@ -20,7 +20,7 @@ @ToString @Getter @EqualsAndHashCode(callSuper = true) -public class TopAggregation extends Aggregation { +public class TopAggregation extends Aggregation implements CountedAggregation { private final Optional results; /** Aggregation Constructor without span and argument. */ diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java index 1959d0f6d..f039bf47f 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java @@ -185,6 +185,7 @@ public enum BuiltinFunctionName { NESTED(FunctionName.of("nested")), PERCENTILE(FunctionName.of("percentile")), PERCENTILE_APPROX(FunctionName.of("percentile_approx")), + APPROX_COUNT_DISTINCT(FunctionName.of("approx_count_distinct")), /** Text Functions. */ ASCII(FunctionName.of("ascii")), @@ -332,6 +333,7 @@ public FunctionName getName() { .put("take", BuiltinFunctionName.TAKE) .put("percentile", BuiltinFunctionName.PERCENTILE) .put("percentile_approx", BuiltinFunctionName.PERCENTILE_APPROX) + .put("approx_count_distinct", BuiltinFunctionName.APPROX_COUNT_DISTINCT) .build(); public static Optional of(String str) { diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java index 53dc17576..1621e65d5 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystPlanContext.java @@ -26,6 +26,7 @@ import java.util.Stack; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -187,7 +188,7 @@ public LogicalPlan reduce(BiFunction tran return result; }).orElse(getPlan())); } - + /** * apply for each plan with the given function * diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java index d2ee46ae6..00a7905f0 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java @@ -14,13 +14,6 @@ import org.apache.spark.sql.catalyst.expressions.Explode; import org.apache.spark.sql.catalyst.expressions.Expression; import org.apache.spark.sql.catalyst.expressions.GeneratorOuter; -import org.apache.spark.sql.catalyst.expressions.In$; -import org.apache.spark.sql.catalyst.expressions.GreaterThanOrEqual; -import org.apache.spark.sql.catalyst.expressions.InSubquery$; -import org.apache.spark.sql.catalyst.expressions.LessThan; -import org.apache.spark.sql.catalyst.expressions.LessThanOrEqual; -import org.apache.spark.sql.catalyst.expressions.ListQuery$; -import org.apache.spark.sql.catalyst.expressions.MakeInterval$; import org.apache.spark.sql.catalyst.expressions.NamedExpression; import org.apache.spark.sql.catalyst.expressions.SortDirection; import org.apache.spark.sql.catalyst.expressions.SortOrder; @@ -38,6 +31,7 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap; import org.opensearch.flint.spark.FlattenGenerator; import org.opensearch.sql.ast.AbstractNodeVisitor; +import org.opensearch.sql.ast.Node; import org.opensearch.sql.ast.expression.Alias; import org.opensearch.sql.ast.expression.Argument; import org.opensearch.sql.ast.expression.Field; @@ -53,6 +47,7 @@ import org.opensearch.sql.ast.statement.Statement; import org.opensearch.sql.ast.tree.Aggregation; import org.opensearch.sql.ast.tree.Correlation; +import org.opensearch.sql.ast.tree.CountedAggregation; import org.opensearch.sql.ast.tree.Dedupe; import org.opensearch.sql.ast.tree.DescribeRelation; import org.opensearch.sql.ast.tree.Eval; @@ -72,7 +67,6 @@ import org.opensearch.sql.ast.tree.Rename; import org.opensearch.sql.ast.tree.Sort; import org.opensearch.sql.ast.tree.SubqueryAlias; -import org.opensearch.sql.ast.tree.TopAggregation; import org.opensearch.sql.ast.tree.Trendline; import org.opensearch.sql.ast.tree.Window; import org.opensearch.sql.common.antlr.SyntaxCheckException; @@ -90,6 +84,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -132,6 +127,10 @@ public LogicalPlan visitQuery(Query node, CatalystPlanContext context) { return node.getPlan().accept(this, context); } + public LogicalPlan visitFirstChild(Node node, CatalystPlanContext context) { + return node.getChild().get(0).accept(this, context); + } + @Override public LogicalPlan visitExplain(Explain node, CatalystPlanContext context) { node.getStatement().accept(this, context); @@ -140,6 +139,7 @@ public LogicalPlan visitExplain(Explain node, CatalystPlanContext context) { @Override public LogicalPlan visitRelation(Relation node, CatalystPlanContext context) { + //relations doesnt have a visitFirstChild call since its the leaf of the AST tree if (node instanceof DescribeRelation) { TableIdentifier identifier = getTableIdentifier(node.getTableQualifiedName()); return context.with( @@ -159,7 +159,7 @@ public LogicalPlan visitRelation(Relation node, CatalystPlanContext context) { @Override public LogicalPlan visitFilter(Filter node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); return context.apply(p -> { Expression conditionExpression = visitExpression(node.getCondition(), context); Optional innerConditionExpression = context.popNamedParseExpressions(); @@ -173,8 +173,7 @@ public LogicalPlan visitFilter(Filter node, CatalystPlanContext context) { */ @Override public LogicalPlan visitLookup(Lookup node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); - + visitFirstChild(node, context); return context.apply( searchSide -> { LogicalPlan lookupTable = node.getLookupRelation().accept(this, context); Expression lookupCondition = buildLookupMappingCondition(node, expressionAnalyzer, context); @@ -230,8 +229,7 @@ public LogicalPlan visitLookup(Lookup node, CatalystPlanContext context) { @Override public LogicalPlan visitTrendline(Trendline node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); - + visitFirstChild(node, context); node.getSortByField() .ifPresent(sortField -> { Expression sortFieldExpression = visitExpression(sortField, context); @@ -254,7 +252,7 @@ public LogicalPlan visitTrendline(Trendline node, CatalystPlanContext context) { @Override public LogicalPlan visitCorrelation(Correlation node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); context.reduce((left, right) -> { visitFieldList(node.getFieldsList().stream().map(Field::new).collect(Collectors.toList()), context); Seq fields = context.retainAllNamedParseExpressions(e -> e); @@ -272,7 +270,7 @@ public LogicalPlan visitCorrelation(Correlation node, CatalystPlanContext contex @Override public LogicalPlan visitJoin(Join node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); return context.apply(left -> { LogicalPlan right = node.getRight().accept(this, context); Optional joinCondition = node.getJoinCondition() @@ -285,7 +283,7 @@ public LogicalPlan visitJoin(Join node, CatalystPlanContext context) { @Override public LogicalPlan visitSubqueryAlias(SubqueryAlias node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); return context.apply(p -> { var alias = org.apache.spark.sql.catalyst.plans.logical.SubqueryAlias$.MODULE$.apply(node.getAlias(), p); context.withSubqueryAlias(alias); @@ -296,7 +294,7 @@ public LogicalPlan visitSubqueryAlias(SubqueryAlias node, CatalystPlanContext co @Override public LogicalPlan visitAggregation(Aggregation node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); List aggsExpList = visitExpressionList(node.getAggExprList(), context); List groupExpList = visitExpressionList(node.getGroupExprList(), context); if (!groupExpList.isEmpty()) { @@ -327,9 +325,9 @@ public LogicalPlan visitAggregation(Aggregation node, CatalystPlanContext contex context.apply(p -> new org.apache.spark.sql.catalyst.plans.logical.Sort(sortElements, true, logicalPlan)); } //visit TopAggregation results limit - if ((node instanceof TopAggregation) && ((TopAggregation) node).getResults().isPresent()) { + if ((node instanceof CountedAggregation) && ((CountedAggregation) node).getResults().isPresent()) { context.apply(p -> (LogicalPlan) Limit.apply(new org.apache.spark.sql.catalyst.expressions.Literal( - ((TopAggregation) node).getResults().get().getValue(), org.apache.spark.sql.types.DataTypes.IntegerType), p)); + ((CountedAggregation) node).getResults().get().getValue(), org.apache.spark.sql.types.DataTypes.IntegerType), p)); } return logicalPlan; } @@ -342,7 +340,7 @@ private static LogicalPlan extractedAggregation(CatalystPlanContext context) { @Override public LogicalPlan visitWindow(Window node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); List windowFunctionExpList = visitExpressionList(node.getWindowFunctionList(), context); Seq windowFunctionExpressions = context.retainAllNamedParseExpressions(p -> p); List partitionExpList = visitExpressionList(node.getPartExprList(), context); @@ -372,10 +370,11 @@ public LogicalPlan visitAlias(Alias node, CatalystPlanContext context) { @Override public LogicalPlan visitProject(Project node, CatalystPlanContext context) { + //update plan's context prior to visiting node children if (node.isExcluded()) { List intersect = context.getProjectedFields().stream() - .filter(node.getProjectList()::contains) - .collect(Collectors.toList()); + .filter(node.getProjectList()::contains) + .collect(Collectors.toList()); if (!intersect.isEmpty()) { // Fields in parent projection, but they have be excluded in child. For example, // source=t | fields - A, B | fields A, B, C will throw "[Field A, Field B] can't be resolved" @@ -384,7 +383,7 @@ public LogicalPlan visitProject(Project node, CatalystPlanContext context) { } else { context.withProjectedFields(node.getProjectList()); } - LogicalPlan child = node.getChild().get(0).accept(this, context); + LogicalPlan child = visitFirstChild(node, context); visitExpressionList(node.getProjectList(), context); // Create a projection list from the existing expressions @@ -405,7 +404,7 @@ public LogicalPlan visitProject(Project node, CatalystPlanContext context) { @Override public LogicalPlan visitSort(Sort node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); visitFieldList(node.getSortList(), context); Seq sortElements = context.retainAllNamedParseExpressions(exp -> SortUtils.getSortDirection(node, (NamedExpression) exp)); return context.apply(p -> (LogicalPlan) new org.apache.spark.sql.catalyst.plans.logical.Sort(sortElements, true, p)); @@ -413,20 +412,20 @@ public LogicalPlan visitSort(Sort node, CatalystPlanContext context) { @Override public LogicalPlan visitHead(Head node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); return context.apply(p -> (LogicalPlan) Limit.apply(new org.apache.spark.sql.catalyst.expressions.Literal( node.getSize(), DataTypes.IntegerType), p)); } @Override public LogicalPlan visitFieldSummary(FieldSummary fieldSummary, CatalystPlanContext context) { - fieldSummary.getChild().get(0).accept(this, context); + visitFirstChild(fieldSummary, context); return FieldSummaryTransformer.translate(fieldSummary, context); } @Override public LogicalPlan visitFillNull(FillNull fillNull, CatalystPlanContext context) { - fillNull.getChild().get(0).accept(this, context); + visitFirstChild(fillNull, context); List aliases = new ArrayList<>(); for(FillNull.NullableFieldFill nullableFieldFill : fillNull.getNullableFieldFills()) { Field field = nullableFieldFill.getNullableFieldReference(); @@ -457,7 +456,7 @@ public LogicalPlan visitFillNull(FillNull fillNull, CatalystPlanContext context) @Override public LogicalPlan visitFlatten(Flatten flatten, CatalystPlanContext context) { - flatten.getChild().get(0).accept(this, context); + visitFirstChild(flatten, context); if (context.getNamedParseExpressions().isEmpty()) { // Create an UnresolvedStar for all-fields projection context.getNamedParseExpressions().push(UnresolvedStar$.MODULE$.apply(Option.>empty())); @@ -471,7 +470,7 @@ public LogicalPlan visitFlatten(Flatten flatten, CatalystPlanContext context) { @Override public LogicalPlan visitExpand(org.opensearch.sql.ast.tree.Expand node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); if (context.getNamedParseExpressions().isEmpty()) { // Create an UnresolvedStar for all-fields projection context.getNamedParseExpressions().push(UnresolvedStar$.MODULE$.apply(Option.>empty())); @@ -507,7 +506,7 @@ private Expression visitExpression(UnresolvedExpression expression, CatalystPlan @Override public LogicalPlan visitParse(Parse node, CatalystPlanContext context) { - LogicalPlan child = node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); Expression sourceField = visitExpression(node.getSourceField(), context); ParseMethod parseMethod = node.getParseMethod(); java.util.Map arguments = node.getArguments(); @@ -517,7 +516,7 @@ public LogicalPlan visitParse(Parse node, CatalystPlanContext context) { @Override public LogicalPlan visitRename(Rename node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); if (context.getNamedParseExpressions().isEmpty()) { // Create an UnresolvedStar for all-fields projection context.getNamedParseExpressions().push(UnresolvedStar$.MODULE$.apply(Option.empty())); @@ -534,7 +533,7 @@ public LogicalPlan visitRename(Rename node, CatalystPlanContext context) { @Override public LogicalPlan visitEval(Eval node, CatalystPlanContext context) { - LogicalPlan child = node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); List aliases = new ArrayList<>(); List letExpressions = node.getExpressionList(); for (Let let : letExpressions) { @@ -548,8 +547,7 @@ public LogicalPlan visitEval(Eval node, CatalystPlanContext context) { List expressionList = visitExpressionList(aliases, context); Seq projectExpressions = context.retainAllNamedParseExpressions(p -> (NamedExpression) p); // build the plan with the projection step - child = context.apply(p -> new org.apache.spark.sql.catalyst.plans.logical.Project(projectExpressions, p)); - return child; + return context.apply(p -> new org.apache.spark.sql.catalyst.plans.logical.Project(projectExpressions, p)); } @Override @@ -574,7 +572,7 @@ public LogicalPlan visitWindowFunction(WindowFunction node, CatalystPlanContext @Override public LogicalPlan visitDedupe(Dedupe node, CatalystPlanContext context) { - node.getChild().get(0).accept(this, context); + visitFirstChild(node, context); List options = node.getOptions(); Integer allowedDuplication = (Integer) options.get(0).getValue().getValue(); Boolean keepEmpty = (Boolean) options.get(1).getValue().getValue(); diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java index f6581016f..7d1cc072b 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java @@ -432,8 +432,9 @@ private Trendline.TrendlineComputation toTrendlineComputation(OpenSearchPPLParse public UnresolvedPlan visitTopCommand(OpenSearchPPLParser.TopCommandContext ctx) { ImmutableList.Builder aggListBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder groupListBuilder = new ImmutableList.Builder<>(); + String funcName = ctx.TOP_APPROX() != null ? "approx_count_distinct" : "count"; ctx.fieldList().fieldExpression().forEach(field -> { - UnresolvedExpression aggExpression = new AggregateFunction("count",internalVisitExpression(field), + AggregateFunction aggExpression = new AggregateFunction(funcName,internalVisitExpression(field), Collections.singletonList(new Argument("countParam", new Literal(1, DataType.INTEGER)))); String name = field.qualifiedName().getText(); Alias alias = new Alias("count_"+name, aggExpression); @@ -458,14 +459,12 @@ public UnresolvedPlan visitTopCommand(OpenSearchPPLParser.TopCommandContext ctx) .collect(Collectors.toList())) .orElse(emptyList()) ); - UnresolvedExpression unresolvedPlan = (ctx.number != null ? internalVisitExpression(ctx.number) : null); - TopAggregation aggregation = - new TopAggregation( - Optional.ofNullable((Literal) unresolvedPlan), + UnresolvedExpression expectedResults = (ctx.number != null ? internalVisitExpression(ctx.number) : null); + return new TopAggregation( + Optional.ofNullable((Literal) expectedResults), aggListBuilder.build(), aggListBuilder.build(), groupListBuilder.build()); - return aggregation; } /** Fieldsummary command. */ @@ -479,8 +478,9 @@ public UnresolvedPlan visitFieldsummaryCommand(OpenSearchPPLParser.FieldsummaryC public UnresolvedPlan visitRareCommand(OpenSearchPPLParser.RareCommandContext ctx) { ImmutableList.Builder aggListBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder groupListBuilder = new ImmutableList.Builder<>(); + String funcName = ctx.RARE_APPROX() != null ? "approx_count_distinct" : "count"; ctx.fieldList().fieldExpression().forEach(field -> { - UnresolvedExpression aggExpression = new AggregateFunction("count",internalVisitExpression(field), + AggregateFunction aggExpression = new AggregateFunction(funcName,internalVisitExpression(field), Collections.singletonList(new Argument("countParam", new Literal(1, DataType.INTEGER)))); String name = field.qualifiedName().getText(); Alias alias = new Alias("count_"+name, aggExpression); @@ -505,12 +505,12 @@ public UnresolvedPlan visitRareCommand(OpenSearchPPLParser.RareCommandContext ct .collect(Collectors.toList())) .orElse(emptyList()) ); - RareAggregation aggregation = - new RareAggregation( + UnresolvedExpression expectedResults = (ctx.number != null ? internalVisitExpression(ctx.number) : null); + return new RareAggregation( + Optional.ofNullable((Literal) expectedResults), aggListBuilder.build(), aggListBuilder.build(), groupListBuilder.build()); - return aggregation; } @Override diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java index 4b7c8a1c1..36d9f9577 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java @@ -211,7 +211,8 @@ public UnresolvedExpression visitCountAllFunctionCall(OpenSearchPPLParser.CountA @Override public UnresolvedExpression visitDistinctCountFunctionCall(OpenSearchPPLParser.DistinctCountFunctionCallContext ctx) { - return new AggregateFunction("count", visit(ctx.valueExpression()), true); + String funcName = ctx.DISTINCT_COUNT_APPROX()!=null ? "approx_count_distinct" :"count"; + return new AggregateFunction(funcName, visit(ctx.valueExpression()), true); } @Override diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/AggregatorTransformer.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/AggregatorTransformer.java index 9788ac1bc..c06f37aa3 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/AggregatorTransformer.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/AggregatorTransformer.java @@ -57,6 +57,8 @@ static Expression aggregator(org.opensearch.sql.ast.expression.AggregateFunction return new UnresolvedFunction(seq("PERCENTILE"), seq(arg, new Literal(getPercentDoubleValue(aggregateFunction), DataTypes.DoubleType)), distinct, empty(),false); case PERCENTILE_APPROX: return new UnresolvedFunction(seq("PERCENTILE_APPROX"), seq(arg, new Literal(getPercentDoubleValue(aggregateFunction), DataTypes.DoubleType)), distinct, empty(),false); + case APPROX_COUNT_DISTINCT: + return new UnresolvedFunction(seq("APPROX_COUNT_DISTINCT"), seq(arg), distinct, empty(),false); } throw new IllegalStateException("Not Supported value: " + aggregateFunction.getFuncName()); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java index 0b0fb8314..0a4f19b53 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/BuiltinFunctionTransformer.java @@ -26,8 +26,10 @@ import java.util.Map; import java.util.function.Function; +import static org.opensearch.flint.spark.ppl.OpenSearchPPLLexer.DISTINCT_COUNT_APPROX; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADD; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADDDATE; +import static org.opensearch.sql.expression.function.BuiltinFunctionName.APPROX_COUNT_DISTINCT; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_LENGTH; import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATEDIFF; import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_ADD; @@ -109,6 +111,7 @@ public interface BuiltinFunctionTransformer { .put(TO_JSON_STRING, "to_json") .put(JSON_KEYS, "json_object_keys") .put(JSON_EXTRACT, "get_json_object") + .put(APPROX_COUNT_DISTINCT, "approx_count_distinct") .build(); /** diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanAggregationQueriesTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanAggregationQueriesTranslatorTestSuite.scala index 9946bff6a..42cc7ed10 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanAggregationQueriesTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanAggregationQueriesTranslatorTestSuite.scala @@ -754,6 +754,34 @@ class PPLLogicalPlanAggregationQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } + test("test approx distinct count product group by brand sorted") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source = table | stats distinct_count_approx(product) by brand | sort brand"), + context) + val star = Seq(UnresolvedStar(None)) + val brandField = UnresolvedAttribute("brand") + val productField = UnresolvedAttribute("product") + val tableRelation = UnresolvedRelation(Seq("table")) + + val groupByAttributes = Seq(Alias(brandField, "brand")()) + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(productField), isDistinct = true), + "distinct_count_approx(product)")() + val brandAlias = Alias(brandField, "brand")() + + val aggregatePlan = + Aggregate(groupByAttributes, Seq(aggregateExpressions, brandAlias), tableRelation) + val sortedPlan: LogicalPlan = + Sort(Seq(SortOrder(brandField, Ascending)), global = true, aggregatePlan) + val expectedPlan = Project(star, sortedPlan) + + comparePlans(expectedPlan, logPlan, false) + } + test("test distinct count product with alias and filter") { val context = new CatalystPlanContext val logPlan = planTransformer.visit( @@ -803,6 +831,34 @@ class PPLLogicalPlanAggregationQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } + test( + "test distinct count age by span of interval of 10 years query with sort using approximation ") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source = table | stats distinct_count_approx(age) by span(age, 10) as age_span | sort age"), + context) + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val ageField = UnresolvedAttribute("age") + val tableRelation = UnresolvedRelation(Seq("table")) + + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(ageField), isDistinct = true), + "distinct_count_approx(age)")() + val span = Alias( + Multiply(Floor(Divide(UnresolvedAttribute("age"), Literal(10))), Literal(10)), + "age_span")() + val aggregatePlan = Aggregate(Seq(span), Seq(aggregateExpressions, span), tableRelation) + val sortedPlan: LogicalPlan = + Sort(Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), global = true, aggregatePlan) + val expectedPlan = Project(star, sortedPlan) + + comparePlans(expectedPlan, logPlan, false) + } + test("test distinct count status by week window and group by status with limit") { val context = new CatalystPlanContext val logPlan = planTransformer.visit( @@ -838,6 +894,42 @@ class PPLLogicalPlanAggregationQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } + test( + "test distinct count status by week window and group by status with limit using approximation") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source = table | stats distinct_count_approx(status) by span(@timestamp, 1w) as status_count_by_week, status | head 100"), + context) + // Define the expected logical plan + val star = Seq(UnresolvedStar(None)) + val status = Alias(UnresolvedAttribute("status"), "status")() + val statusCount = UnresolvedAttribute("status") + val table = UnresolvedRelation(Seq("table")) + + val windowExpression = Alias( + TimeWindow( + UnresolvedAttribute("`@timestamp`"), + TimeWindow.parseExpression(Literal("1 week")), + TimeWindow.parseExpression(Literal("1 week")), + 0), + "status_count_by_week")() + + val aggregateExpressions = + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(statusCount), isDistinct = true), + "distinct_count_approx(status)")() + val aggregatePlan = Aggregate( + Seq(status, windowExpression), + Seq(aggregateExpressions, status, windowExpression), + table) + val planWithLimit = GlobalLimit(Literal(100), LocalLimit(Literal(100), aggregatePlan)) + val expectedPlan = Project(star, planWithLimit) + // Compare the two plans + comparePlans(expectedPlan, logPlan, false) + } + test("multiple stats - test average price and average age") { val context = new CatalystPlanContext val logPlan = diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTopAndRareQueriesTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTopAndRareQueriesTranslatorTestSuite.scala index 792a2dee6..106cba93a 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTopAndRareQueriesTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTopAndRareQueriesTranslatorTestSuite.scala @@ -59,6 +59,42 @@ class PPLLogicalPlanTopAndRareQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, checkAnalysis = false) } + test("test simple rare command with a single field approximation") { + // if successful build ppl logical plan and translate to catalyst logical plan + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit(plan(pplParser, "source=accounts | rare_approx address"), context) + val addressField = UnresolvedAttribute("address") + val tableRelation = UnresolvedRelation(Seq("accounts")) + + val projectList: Seq[NamedExpression] = Seq(UnresolvedStar(None)) + + val aggregateExpressions = Seq( + Alias( + UnresolvedFunction(Seq("APPROX_COUNT_DISTINCT"), Seq(addressField), isDistinct = false), + "count_address")(), + addressField) + + val aggregatePlan = + Aggregate(Seq(addressField), aggregateExpressions, tableRelation) + + val sortedPlan: LogicalPlan = + Sort( + Seq( + SortOrder( + Alias( + UnresolvedFunction( + Seq("APPROX_COUNT_DISTINCT"), + Seq(addressField), + isDistinct = false), + "count_address")(), + Ascending)), + global = true, + aggregatePlan) + val expectedPlan = Project(projectList, sortedPlan) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + test("test simple rare command with a by field test") { // if successful build ppl logical plan and translate to catalyst logical plan val context = new CatalystPlanContext From 98e1c0330af4eafe79558cebea3541545cb6a377 Mon Sep 17 00:00:00 2001 From: Louis Chu Date: Tue, 12 Nov 2024 09:53:15 -0800 Subject: [PATCH 09/26] Apply shaded rules (#885) Signed-off-by: Louis Chu --- build.sbt | 31 +++++++++++++++++++ .../apache/spark/sql/FlintJobExecutor.scala | 9 +++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/build.sbt b/build.sbt index 8752d3bf9..781b4f51f 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import Dependencies._ +import sbtassembly.AssemblyPlugin.autoImport.ShadeRule lazy val scala212 = "2.12.14" lazy val sparkVersion = "3.5.1" @@ -43,7 +44,35 @@ lazy val compileScalastyle = taskKey[Unit]("compileScalastyle") // Run as part of test task. lazy val testScalastyle = taskKey[Unit]("testScalastyle") +// Explanation: +// - ThisBuild / assemblyShadeRules sets the shading rules for the entire build +// - ShadeRule.rename(...) creates a rule to rename multiple package patterns +// - "shaded.@0" means prepend "shaded." to the original package name +// - .inAll applies the rule to all dependencies, not just direct dependencies +val packagesToShade = Seq( + "com.amazonaws.cloudwatch.**", + "com.fasterxml.jackson.core.**", + "com.fasterxml.jackson.dataformat.**", + "com.fasterxml.jackson.databind.**", + "com.sun.jna.**", + "com.thoughtworks.paranamer.**", + "javax.annotation.**", + "org.apache.commons.codec.**", + "org.apache.commons.logging.**", + "org.apache.hc.**", + "org.apache.http.**", + "org.glassfish.json.**", + "org.joda.time.**", + "org.reactivestreams.**", + "org.yaml.**", + "software.amazon.**" +) +ThisBuild / assemblyShadeRules := Seq( + ShadeRule.rename( + packagesToShade.map(_ -> "shaded.flint.@0"): _* + ).inAll +) lazy val commonSettings = Seq( javacOptions ++= Seq("-source", "11"), @@ -89,6 +118,8 @@ lazy val flintCore = (project in file("flint-core")) "com.amazonaws" % "aws-java-sdk-cloudwatch" % "1.12.593" exclude("com.fasterxml.jackson.core", "jackson-databind"), "software.amazon.awssdk" % "auth-crt" % "2.28.10", + "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion, + "com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion, "org.projectlombok" % "lombok" % "1.18.30" % "provided", "org.scalactic" %% "scalactic" % "3.2.15" % "test", "org.scalatest" %% "scalatest" % "3.2.15" % "test", diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala index c076f9974..8e037a53e 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala @@ -10,7 +10,6 @@ import java.util.Locale import com.amazonaws.services.glue.model.{AccessDeniedException, AWSGlueException} import com.amazonaws.services.s3.model.AmazonS3Exception import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.scala.DefaultScalaModule import org.apache.commons.text.StringEscapeUtils.unescapeJava import org.opensearch.common.Strings import org.opensearch.flint.core.IRestHighLevelClient @@ -45,7 +44,6 @@ trait FlintJobExecutor { this: Logging => val mapper = new ObjectMapper() - mapper.registerModule(DefaultScalaModule) var currentTimeProvider: TimeProvider = new RealTimeProvider() var threadPoolFactory: ThreadPoolFactory = new DefaultThreadPoolFactory() @@ -442,9 +440,10 @@ trait FlintJobExecutor { errorSource: Option[String] = None, statusCode: Option[Int] = None): String = { val errorMessage = s"$messagePrefix: ${e.getMessage}" - val errorDetails = Map("Message" -> errorMessage) ++ - errorSource.map("ErrorSource" -> _) ++ - statusCode.map(code => "StatusCode" -> code.toString) + val errorDetails = new java.util.LinkedHashMap[String, String]() + errorDetails.put("Message", errorMessage) + errorSource.foreach(es => errorDetails.put("ErrorSource", es)) + statusCode.foreach(code => errorDetails.put("StatusCode", code.toString)) val errorJson = mapper.writeValueAsString(errorDetails) From dd9c0cfbab0ca327ba655c8aa9f9ee44404d4fce Mon Sep 17 00:00:00 2001 From: Sean Kao Date: Tue, 12 Nov 2024 10:30:46 -0800 Subject: [PATCH 10/26] Fix bug for not able to get sourceTables from metadata (#883) * add logs Signed-off-by: Sean Kao * match Array when reading sourceTables Signed-off-by: Sean Kao * add test cases Signed-off-by: Sean Kao * use ArrayList only Signed-off-by: Sean Kao --------- Signed-off-by: Sean Kao --- .../flint/spark/FlintSparkIndexFactory.scala | 14 +--- .../metadatacache/FlintMetadataCache.scala | 9 +-- .../FlintOpenSearchMetadataCacheWriter.scala | 6 +- .../spark/mv/FlintSparkMaterializedView.scala | 44 ++++++++++-- .../mv/FlintSparkMaterializedViewSuite.scala | 4 +- .../FlintSparkMaterializedViewITSuite.scala | 68 ++++++++++++++++++- ...OpenSearchMetadataCacheWriterITSuite.scala | 29 ++++++-- 7 files changed, 139 insertions(+), 35 deletions(-) diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/FlintSparkIndexFactory.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/FlintSparkIndexFactory.scala index ca659550d..3a12b63fe 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/FlintSparkIndexFactory.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/FlintSparkIndexFactory.scala @@ -14,7 +14,7 @@ import org.opensearch.flint.common.metadata.FlintMetadata import org.opensearch.flint.spark.covering.FlintSparkCoveringIndex import org.opensearch.flint.spark.covering.FlintSparkCoveringIndex.COVERING_INDEX_TYPE import org.opensearch.flint.spark.mv.FlintSparkMaterializedView -import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.MV_INDEX_TYPE +import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.{getSourceTablesFromMetadata, MV_INDEX_TYPE} import org.opensearch.flint.spark.skipping.FlintSparkSkippingIndex import org.opensearch.flint.spark.skipping.FlintSparkSkippingIndex.SKIPPING_INDEX_TYPE import org.opensearch.flint.spark.skipping.FlintSparkSkippingStrategy.SkippingKind @@ -141,9 +141,9 @@ object FlintSparkIndexFactory extends Logging { } private def getMvSourceTables(spark: SparkSession, metadata: FlintMetadata): Array[String] = { - val sourceTables = getArrayString(metadata.properties, "sourceTables") + val sourceTables = getSourceTablesFromMetadata(metadata) if (sourceTables.isEmpty) { - FlintSparkMaterializedView.extractSourceTableNames(spark, metadata.source) + FlintSparkMaterializedView.extractSourceTablesFromQuery(spark, metadata.source) } else { sourceTables } @@ -161,12 +161,4 @@ object FlintSparkIndexFactory extends Logging { Some(value.asInstanceOf[String]) } } - - private def getArrayString(map: java.util.Map[String, AnyRef], key: String): Array[String] = { - map.get(key) match { - case list: java.util.ArrayList[_] => - list.toArray.map(_.toString) - case _ => Array.empty[String] - } - } } diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintMetadataCache.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintMetadataCache.scala index e1c0f318c..86267c881 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintMetadataCache.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintMetadataCache.scala @@ -10,7 +10,7 @@ import scala.collection.JavaConverters.mapAsScalaMapConverter import org.opensearch.flint.common.metadata.FlintMetadata import org.opensearch.flint.common.metadata.log.FlintMetadataLogEntry import org.opensearch.flint.spark.FlintSparkIndexOptions -import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.MV_INDEX_TYPE +import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.{getSourceTablesFromMetadata, MV_INDEX_TYPE} import org.opensearch.flint.spark.scheduler.util.IntervalSchedulerParser /** @@ -61,12 +61,7 @@ object FlintMetadataCache { None } val sourceTables = metadata.kind match { - case MV_INDEX_TYPE => - metadata.properties.get("sourceTables") match { - case list: java.util.ArrayList[_] => - list.toArray.map(_.toString) - case _ => Array.empty[String] - } + case MV_INDEX_TYPE => getSourceTablesFromMetadata(metadata) case _ => Array(metadata.source) } val lastRefreshTime: Option[Long] = metadata.latestLogEntry.flatMap { entry => diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriter.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriter.scala index 2bc373792..f6fc0ba6f 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriter.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriter.scala @@ -38,13 +38,15 @@ class FlintOpenSearchMetadataCacheWriter(options: FlintOptions) .isInstanceOf[FlintOpenSearchIndexMetadataService] override def updateMetadataCache(indexName: String, metadata: FlintMetadata): Unit = { - logInfo(s"Updating metadata cache for $indexName"); + logInfo(s"Updating metadata cache for $indexName with $metadata"); val osIndexName = OpenSearchClientUtils.sanitizeIndexName(indexName) var client: IRestHighLevelClient = null try { client = OpenSearchClientUtils.createClient(options) val request = new PutMappingRequest(osIndexName) - request.source(serialize(metadata), XContentType.JSON) + val serialized = serialize(metadata) + logInfo(s"Serialized: $serialized") + request.source(serialized, XContentType.JSON) client.updateIndexMapping(request, RequestOptions.DEFAULT) } catch { case e: Exception => diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala index e2a64d183..d5c450e7e 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedView.scala @@ -7,7 +7,7 @@ package org.opensearch.flint.spark.mv import java.util.Locale -import scala.collection.JavaConverters.mapAsJavaMapConverter +import scala.collection.JavaConverters._ import scala.collection.convert.ImplicitConversions.`map AsScala` import org.opensearch.flint.common.metadata.FlintMetadata @@ -18,6 +18,7 @@ import org.opensearch.flint.spark.FlintSparkIndexOptions.empty import org.opensearch.flint.spark.function.TumbleFunction import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.{getFlintIndexName, MV_INDEX_TYPE} +import org.apache.spark.internal.Logging import org.apache.spark.sql.{DataFrame, SparkSession} import org.apache.spark.sql.catalyst.FunctionIdentifier import org.apache.spark.sql.catalyst.analysis.{UnresolvedFunction, UnresolvedRelation} @@ -64,10 +65,14 @@ case class FlintSparkMaterializedView( }.toArray val schema = generateSchema(outputSchema).asJava + // Convert Scala Array to Java ArrayList for consistency with OpenSearch JSON parsing. + // OpenSearch uses Jackson, which deserializes JSON arrays into ArrayLists. + val sourceTablesProperty = new java.util.ArrayList[String](sourceTables.toSeq.asJava) + metadataBuilder(this) .name(mvName) .source(query) - .addProperty("sourceTables", sourceTables) + .addProperty("sourceTables", sourceTablesProperty) .indexedColumns(indexColumnMaps) .schema(schema) .build() @@ -153,7 +158,7 @@ case class FlintSparkMaterializedView( } } -object FlintSparkMaterializedView { +object FlintSparkMaterializedView extends Logging { /** MV index type name */ val MV_INDEX_TYPE = "mv" @@ -185,13 +190,40 @@ object FlintSparkMaterializedView { * @return * source table names */ - def extractSourceTableNames(spark: SparkSession, query: String): Array[String] = { - spark.sessionState.sqlParser + def extractSourceTablesFromQuery(spark: SparkSession, query: String): Array[String] = { + logInfo(s"Extracting source tables from query $query") + val sourceTables = spark.sessionState.sqlParser .parsePlan(query) .collect { case relation: UnresolvedRelation => qualifyTableName(spark, relation.tableName) } .toArray + logInfo(s"Extracted tables: [${sourceTables.mkString(", ")}]") + sourceTables + } + + /** + * Get source tables from Flint metadata properties field. + * + * @param metadata + * Flint metadata + * @return + * source table names + */ + def getSourceTablesFromMetadata(metadata: FlintMetadata): Array[String] = { + logInfo(s"Getting source tables from metadata $metadata") + val sourceTables = metadata.properties.get("sourceTables") + sourceTables match { + case list: java.util.ArrayList[_] => + logInfo(s"sourceTables is [${list.asScala.mkString(", ")}]") + list.toArray.map(_.toString) + case null => + logInfo("sourceTables property does not exist") + Array.empty[String] + case _ => + logInfo(s"sourceTables has unexpected type: ${sourceTables.getClass.getName}") + Array.empty[String] + } } /** Builder class for MV build */ @@ -223,7 +255,7 @@ object FlintSparkMaterializedView { */ def query(query: String): Builder = { this.query = query - this.sourceTables = extractSourceTableNames(flint.spark, query) + this.sourceTables = extractSourceTablesFromQuery(flint.spark, query) this } diff --git a/flint-spark-integration/src/test/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedViewSuite.scala b/flint-spark-integration/src/test/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedViewSuite.scala index 1c9a9e83c..78d2eb09e 100644 --- a/flint-spark-integration/src/test/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedViewSuite.scala +++ b/flint-spark-integration/src/test/scala/org/opensearch/flint/spark/mv/FlintSparkMaterializedViewSuite.scala @@ -64,7 +64,9 @@ class FlintSparkMaterializedViewSuite extends FlintSuite { metadata.kind shouldBe MV_INDEX_TYPE metadata.source shouldBe "SELECT 1" metadata.properties should contain key "sourceTables" - metadata.properties.get("sourceTables").asInstanceOf[Array[String]] should have size 0 + metadata.properties + .get("sourceTables") + .asInstanceOf[java.util.ArrayList[String]] should have size 0 metadata.indexedColumns shouldBe Array( Map("columnName" -> "test_col", "columnType" -> "integer").asJava) metadata.schema shouldBe Map("test_col" -> Map("type" -> "integer").asJava).asJava diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewITSuite.scala index fc77faaea..cf0347820 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/FlintSparkMaterializedViewITSuite.scala @@ -18,7 +18,7 @@ import org.opensearch.flint.core.FlintOptions import org.opensearch.flint.core.storage.{FlintOpenSearchIndexMetadataService, OpenSearchClientUtils} import org.opensearch.flint.spark.FlintSparkIndex.quotedTableName import org.opensearch.flint.spark.mv.FlintSparkMaterializedView -import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.{extractSourceTableNames, getFlintIndexName} +import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.{extractSourceTablesFromQuery, getFlintIndexName, getSourceTablesFromMetadata, MV_INDEX_TYPE} import org.opensearch.flint.spark.scheduler.OpenSearchAsyncQueryScheduler import org.scalatest.matchers.must.Matchers._ import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper @@ -65,14 +65,76 @@ class FlintSparkMaterializedViewITSuite extends FlintSparkSuite { | FROM spark_catalog.default.`table/3` | INNER JOIN spark_catalog.default.`table.4` |""".stripMargin - extractSourceTableNames(flint.spark, testComplexQuery) should contain theSameElementsAs + extractSourceTablesFromQuery(flint.spark, testComplexQuery) should contain theSameElementsAs Array( "spark_catalog.default.table1", "spark_catalog.default.table2", "spark_catalog.default.`table/3`", "spark_catalog.default.`table.4`") - extractSourceTableNames(flint.spark, "SELECT 1") should have size 0 + extractSourceTablesFromQuery(flint.spark, "SELECT 1") should have size 0 + } + + test("get source table names from index metadata successfully") { + val mv = FlintSparkMaterializedView( + "spark_catalog.default.mv", + s"SELECT 1 FROM $testTable", + Array(testTable), + Map("1" -> "integer")) + val metadata = mv.metadata() + getSourceTablesFromMetadata(metadata) should contain theSameElementsAs Array(testTable) + } + + test("get source table names from deserialized metadata successfully") { + val metadata = FlintOpenSearchIndexMetadataService.deserialize(s""" { + | "_meta": { + | "kind": "$MV_INDEX_TYPE", + | "properties": { + | "sourceTables": [ + | "$testTable" + | ] + | } + | }, + | "properties": { + | "age": { + | "type": "integer" + | } + | } + | } + |""".stripMargin) + getSourceTablesFromMetadata(metadata) should contain theSameElementsAs Array(testTable) + } + + test("get empty source tables from invalid field in metadata") { + val metadataWrongType = FlintOpenSearchIndexMetadataService.deserialize(s""" { + | "_meta": { + | "kind": "$MV_INDEX_TYPE", + | "properties": { + | "sourceTables": "$testTable" + | } + | }, + | "properties": { + | "age": { + | "type": "integer" + | } + | } + | } + |""".stripMargin) + val metadataMissingField = FlintOpenSearchIndexMetadataService.deserialize(s""" { + | "_meta": { + | "kind": "$MV_INDEX_TYPE", + | "properties": { } + | }, + | "properties": { + | "age": { + | "type": "integer" + | } + | } + | } + |""".stripMargin) + + getSourceTablesFromMetadata(metadataWrongType) shouldBe empty + getSourceTablesFromMetadata(metadataMissingField) shouldBe empty } test("create materialized view with metadata successfully") { diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriterITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriterITSuite.scala index c0d253fd3..5b4dd0208 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriterITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/metadatacache/FlintOpenSearchMetadataCacheWriterITSuite.scala @@ -18,6 +18,7 @@ import org.opensearch.flint.core.FlintOptions import org.opensearch.flint.core.storage.{FlintOpenSearchClient, FlintOpenSearchIndexMetadataService} import org.opensearch.flint.spark.{FlintSparkIndexOptions, FlintSparkSuite} import org.opensearch.flint.spark.covering.FlintSparkCoveringIndex.COVERING_INDEX_TYPE +import org.opensearch.flint.spark.mv.FlintSparkMaterializedView import org.opensearch.flint.spark.mv.FlintSparkMaterializedView.MV_INDEX_TYPE import org.opensearch.flint.spark.skipping.FlintSparkSkippingIndex.{getSkippingIndexName, SKIPPING_INDEX_TYPE} import org.scalatest.Entry @@ -161,12 +162,29 @@ class FlintOpenSearchMetadataCacheWriterITSuite extends FlintSparkSuite with Mat val properties = flintIndexMetadataService.getIndexMetadata(testFlintIndex).properties properties .get("sourceTables") - .asInstanceOf[List[String]] - .toArray should contain theSameElementsAs Array(testTable) + .asInstanceOf[java.util.ArrayList[String]] should contain theSameElementsAs Array( + testTable) } } - test(s"write metadata cache to materialized view index mappings with source tables") { + test("write metadata cache with source tables from index metadata") { + val mv = FlintSparkMaterializedView( + "spark_catalog.default.mv", + s"SELECT 1 FROM $testTable", + Array(testTable), + Map("1" -> "integer")) + val metadata = mv.metadata().copy(latestLogEntry = Some(flintMetadataLogEntry)) + + flintClient.createIndex(testFlintIndex, metadata) + flintMetadataCacheWriter.updateMetadataCache(testFlintIndex, metadata) + + val properties = flintIndexMetadataService.getIndexMetadata(testFlintIndex).properties + properties + .get("sourceTables") + .asInstanceOf[java.util.ArrayList[String]] should contain theSameElementsAs Array(testTable) + } + + test("write metadata cache with source tables from deserialized metadata") { val testTable2 = "spark_catalog.default.metadatacache_test2" val content = s""" { @@ -194,8 +212,9 @@ class FlintOpenSearchMetadataCacheWriterITSuite extends FlintSparkSuite with Mat val properties = flintIndexMetadataService.getIndexMetadata(testFlintIndex).properties properties .get("sourceTables") - .asInstanceOf[List[String]] - .toArray should contain theSameElementsAs Array(testTable, testTable2) + .asInstanceOf[java.util.ArrayList[String]] should contain theSameElementsAs Array( + testTable, + testTable2) } test("write metadata cache to index mappings with refresh interval") { From cbcab2ff8578c58f3fa529b7e9fc438eb89f5e04 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Wed, 13 Nov 2024 11:24:15 -0800 Subject: [PATCH 11/26] Add jvmGCTime metrics (#889) * Add jvmGCTime metrics Signed-off-by: Tomoyuki Morita * Fix style Signed-off-by: Tomoyuki Morita --------- Signed-off-by: Tomoyuki Morita --- .../flint/core/metrics/MetricConstants.java | 10 ++++++++++ ...stener.scala => MetricsSparkListener.scala} | 18 +++++++++++++----- .../flint/spark/refresh/AutoIndexRefresh.scala | 4 ++-- .../scala/org/apache/spark/sql/FlintREPL.scala | 4 ++-- .../org/apache/spark/sql/JobOperator.scala | 4 ++-- 5 files changed, 29 insertions(+), 11 deletions(-) rename flint-core/src/main/scala/org/opensearch/flint/core/metrics/{ReadWriteBytesSparkListener.scala => MetricsSparkListener.scala} (74%) diff --git a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java index ef4d01652..1ab8cf073 100644 --- a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java +++ b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java @@ -175,6 +175,16 @@ public final class MetricConstants { */ public static final String INITIAL_CONDITION_CHECK_FAILED_PREFIX = "initialConditionCheck.failed."; + /** + * Metric for tracking the JVM GC time per task + */ + public static final String TASK_JVM_GC_TIME_METRIC = "task.jvmGCTime.count"; + + /** + * Metric for tracking the total JVM GC time for query + */ + public static final String TOTAL_JVM_GC_TIME_METRIC = "query.totalJvmGCTime.count"; + private MetricConstants() { // Private constructor to prevent instantiation } diff --git a/flint-core/src/main/scala/org/opensearch/flint/core/metrics/ReadWriteBytesSparkListener.scala b/flint-core/src/main/scala/org/opensearch/flint/core/metrics/MetricsSparkListener.scala similarity index 74% rename from flint-core/src/main/scala/org/opensearch/flint/core/metrics/ReadWriteBytesSparkListener.scala rename to flint-core/src/main/scala/org/opensearch/flint/core/metrics/MetricsSparkListener.scala index bfafd3eb3..2ee941260 100644 --- a/flint-core/src/main/scala/org/opensearch/flint/core/metrics/ReadWriteBytesSparkListener.scala +++ b/flint-core/src/main/scala/org/opensearch/flint/core/metrics/MetricsSparkListener.scala @@ -6,17 +6,18 @@ package org.opensearch.flint.core.metrics import org.apache.spark.internal.Logging -import org.apache.spark.scheduler.{SparkListener, SparkListenerTaskEnd} +import org.apache.spark.scheduler.{SparkListener, SparkListenerExecutorMetricsUpdate, SparkListenerTaskEnd} import org.apache.spark.sql.SparkSession /** - * Collect and emit bytesRead/Written and recordsRead/Written metrics + * Collect and emit metrics by listening spark events */ -class ReadWriteBytesSparkListener extends SparkListener with Logging { +class MetricsSparkListener extends SparkListener with Logging { var bytesRead: Long = 0 var recordsRead: Long = 0 var bytesWritten: Long = 0 var recordsWritten: Long = 0 + var totalJvmGcTime: Long = 0 override def onTaskEnd(taskEnd: SparkListenerTaskEnd): Unit = { val inputMetrics = taskEnd.taskMetrics.inputMetrics @@ -31,21 +32,28 @@ class ReadWriteBytesSparkListener extends SparkListener with Logging { recordsRead += inputMetrics.recordsRead bytesWritten += outputMetrics.bytesWritten recordsWritten += outputMetrics.recordsWritten + totalJvmGcTime += taskEnd.taskMetrics.jvmGCTime + + MetricsUtil.addHistoricGauge( + MetricConstants.TASK_JVM_GC_TIME_METRIC, + taskEnd.taskMetrics.jvmGCTime) } def emitMetrics(): Unit = { logInfo(s"Input: totalBytesRead=${bytesRead}, totalRecordsRead=${recordsRead}") logInfo(s"Output: totalBytesWritten=${bytesWritten}, totalRecordsWritten=${recordsWritten}") + logInfo(s"totalJvmGcTime=${totalJvmGcTime}") MetricsUtil.addHistoricGauge(MetricConstants.INPUT_TOTAL_BYTES_READ, bytesRead) MetricsUtil.addHistoricGauge(MetricConstants.INPUT_TOTAL_RECORDS_READ, recordsRead) MetricsUtil.addHistoricGauge(MetricConstants.OUTPUT_TOTAL_BYTES_WRITTEN, bytesWritten) MetricsUtil.addHistoricGauge(MetricConstants.OUTPUT_TOTAL_RECORDS_WRITTEN, recordsWritten) + MetricsUtil.addHistoricGauge(MetricConstants.TOTAL_JVM_GC_TIME_METRIC, totalJvmGcTime) } } -object ReadWriteBytesSparkListener { +object MetricsSparkListener { def withMetrics[T](spark: SparkSession, lambda: () => T): T = { - val listener = new ReadWriteBytesSparkListener() + val listener = new MetricsSparkListener() spark.sparkContext.addSparkListener(listener) val result = lambda() diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/refresh/AutoIndexRefresh.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/refresh/AutoIndexRefresh.scala index bedeeba54..ba605d3bf 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/refresh/AutoIndexRefresh.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/refresh/AutoIndexRefresh.scala @@ -7,7 +7,7 @@ package org.opensearch.flint.spark.refresh import java.util.Collections -import org.opensearch.flint.core.metrics.ReadWriteBytesSparkListener +import org.opensearch.flint.core.metrics.MetricsSparkListener import org.opensearch.flint.spark.{FlintSparkIndex, FlintSparkIndexOptions, FlintSparkValidationHelper} import org.opensearch.flint.spark.FlintSparkIndex.{quotedTableName, StreamingRefresh} import org.opensearch.flint.spark.refresh.FlintSparkIndexRefresh.RefreshMode.{AUTO, RefreshMode} @@ -68,7 +68,7 @@ class AutoIndexRefresh(indexName: String, index: FlintSparkIndex) // Flint index has specialized logic and capability for incremental refresh case refresh: StreamingRefresh => logInfo("Start refreshing index in streaming style") - val job = ReadWriteBytesSparkListener.withMetrics( + val job = MetricsSparkListener.withMetrics( spark, () => refresh diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala index ef0e76557..7f819415c 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala @@ -17,7 +17,7 @@ import com.codahale.metrics.Timer import org.opensearch.flint.common.model.{FlintStatement, InteractiveSession, SessionStates} import org.opensearch.flint.core.FlintOptions import org.opensearch.flint.core.logging.CustomLogging -import org.opensearch.flint.core.metrics.{MetricConstants, ReadWriteBytesSparkListener} +import org.opensearch.flint.core.metrics.{MetricConstants, MetricsSparkListener} import org.opensearch.flint.core.metrics.MetricsUtil.{getTimerContext, incrementCounter, registerGauge, stopTimer} import org.apache.spark.SparkConf @@ -525,7 +525,7 @@ object FlintREPL extends Logging with FlintJobExecutor { val statementTimerContext = getTimerContext( MetricConstants.STATEMENT_PROCESSING_TIME_METRIC) val (dataToWrite, returnedVerificationResult) = - ReadWriteBytesSparkListener.withMetrics( + MetricsSparkListener.withMetrics( spark, () => { processStatementOnVerification( diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala index 6cdbdb16d..8582d3037 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala @@ -14,7 +14,7 @@ import scala.util.{Failure, Success, Try} import org.opensearch.flint.common.model.FlintStatement import org.opensearch.flint.common.scheduler.model.LangType -import org.opensearch.flint.core.metrics.{MetricConstants, MetricsUtil, ReadWriteBytesSparkListener} +import org.opensearch.flint.core.metrics.{MetricConstants, MetricsSparkListener, MetricsUtil} import org.opensearch.flint.core.metrics.MetricsUtil.incrementCounter import org.opensearch.flint.spark.FlintSpark @@ -70,7 +70,7 @@ case class JobOperator( val statementExecutionManager = instantiateStatementExecutionManager(commandContext, resultIndex, osClient) - val readWriteBytesSparkListener = new ReadWriteBytesSparkListener() + val readWriteBytesSparkListener = new MetricsSparkListener() sparkSession.sparkContext.addSparkListener(readWriteBytesSparkListener) val statement = From 33208858ff3c13828b213a7c75898845403e0428 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Wed, 13 Nov 2024 11:27:28 -0800 Subject: [PATCH 12/26] Add query count per session metric (#890) Signed-off-by: Tomoyuki Morita Signed-off-by: Tomoyuki MORITA --- .../org/opensearch/flint/core/metrics/MetricConstants.java | 5 +++++ .../src/main/scala/org/apache/spark/sql/FlintREPL.scala | 3 +++ 2 files changed, 8 insertions(+) diff --git a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java index 1ab8cf073..2fdecadd3 100644 --- a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java +++ b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java @@ -70,6 +70,11 @@ public final class MetricConstants { */ public static final String REPL_PROCESSING_TIME_METRIC = "session.processingTime"; + /** + * Metric name for counting the number of queries executed per session. + */ + public static final String REPL_QUERY_COUNT_METRIC = "session.query.count"; + /** * Prefix for metrics related to the request metadata read operations. */ diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala index 7f819415c..869541cc8 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala @@ -57,6 +57,7 @@ object FlintREPL extends Logging with FlintJobExecutor { private val sessionRunningCount = new AtomicInteger(0) private val statementRunningCount = new AtomicInteger(0) + private var queryCountMetric = 0 def main(args: Array[String]) { val (queryOption, resultIndexOption) = parseArgs(args) @@ -365,6 +366,7 @@ object FlintREPL extends Logging with FlintJobExecutor { if (threadPool != null) { threadPoolFactory.shutdownThreadPool(threadPool) } + MetricsUtil.addHistoricGauge(MetricConstants.REPL_QUERY_COUNT_METRIC, queryCountMetric) } } @@ -521,6 +523,7 @@ object FlintREPL extends Logging with FlintJobExecutor { flintStatement.running() statementExecutionManager.updateStatement(flintStatement) statementRunningCount.incrementAndGet() + queryCountMetric += 1 val statementTimerContext = getTimerContext( MetricConstants.STATEMENT_PROCESSING_TIME_METRIC) From 9d504eab34a49944c05b465f4e7a3c27cfadc1f7 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Wed, 13 Nov 2024 11:29:29 -0800 Subject: [PATCH 13/26] Add metrics for query types (#891) Signed-off-by: Tomoyuki Morita --- .../flint/core/metrics/MetricConstants.java | 11 ++++++ .../flint/spark/sql/IndexMetricHelper.scala | 35 +++++++++++++++++++ .../FlintSparkCoveringIndexAstBuilder.scala | 13 +++++-- ...FlintSparkMaterializedViewAstBuilder.scala | 12 +++++-- .../FlintSparkSkippingIndexAstBuilder.scala | 12 +++++-- 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/IndexMetricHelper.scala diff --git a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java index 2fdecadd3..978950b3c 100644 --- a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java +++ b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java @@ -140,6 +140,17 @@ public final class MetricConstants { */ public static final String QUERY_EXECUTION_TIME_METRIC = "query.execution.processingTime"; + /** + * Metric for query count of each query type (DROP/VACUUM/ALTER/REFRESH/CREATE INDEX) + */ + public static final String QUERY_DROP_COUNT_METRIC = "query.drop.count"; + public static final String QUERY_VACUUM_COUNT_METRIC = "query.vacuum.count"; + public static final String QUERY_ALTER_COUNT_METRIC = "query.alter.count"; + public static final String QUERY_REFRESH_COUNT_METRIC = "query.refresh.count"; + public static final String QUERY_CREATE_INDEX_COUNT_METRIC = "query.createIndex.count"; + public static final String QUERY_CREATE_INDEX_AUTO_REFRESH_COUNT_METRIC = "query.createIndex.autoRefresh.count"; + public static final String QUERY_CREATE_INDEX_MANUAL_REFRESH_COUNT_METRIC = "query.createIndex.manualRefresh.count"; + /** * Metric for tracking the total bytes read from input */ diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/IndexMetricHelper.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/IndexMetricHelper.scala new file mode 100644 index 000000000..45b439ff0 --- /dev/null +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/IndexMetricHelper.scala @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.flint.spark.sql + +import org.opensearch.flint.core.metrics.{MetricConstants, MetricsUtil} + +trait IndexMetricHelper { + def emitCreateIndexMetric(autoRefresh: Boolean): Unit = { + MetricsUtil.incrementCounter(MetricConstants.QUERY_CREATE_INDEX_COUNT_METRIC) + if (autoRefresh) { + MetricsUtil.incrementCounter(MetricConstants.QUERY_CREATE_INDEX_AUTO_REFRESH_COUNT_METRIC) + } else { + MetricsUtil.incrementCounter(MetricConstants.QUERY_CREATE_INDEX_MANUAL_REFRESH_COUNT_METRIC) + } + } + + def emitRefreshIndexMetric(): Unit = { + MetricsUtil.incrementCounter(MetricConstants.QUERY_REFRESH_COUNT_METRIC) + } + + def emitAlterIndexMetric(): Unit = { + MetricsUtil.incrementCounter(MetricConstants.QUERY_ALTER_COUNT_METRIC) + } + + def emitDropIndexMetric(): Unit = { + MetricsUtil.incrementCounter(MetricConstants.QUERY_DROP_COUNT_METRIC) + } + + def emitVacuumIndexMetric(): Unit = { + MetricsUtil.incrementCounter(MetricConstants.QUERY_VACUUM_COUNT_METRIC) + } +} diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/covering/FlintSparkCoveringIndexAstBuilder.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/covering/FlintSparkCoveringIndexAstBuilder.scala index 4a8f9018e..35a780020 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/covering/FlintSparkCoveringIndexAstBuilder.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/covering/FlintSparkCoveringIndexAstBuilder.scala @@ -6,9 +6,10 @@ package org.opensearch.flint.spark.sql.covering import org.antlr.v4.runtime.tree.RuleNode +import org.opensearch.flint.core.metrics.{MetricConstants, MetricsUtil} import org.opensearch.flint.spark.FlintSpark import org.opensearch.flint.spark.covering.FlintSparkCoveringIndex -import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, SparkSqlAstBuilder} +import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, IndexMetricHelper, SparkSqlAstBuilder} import org.opensearch.flint.spark.sql.FlintSparkSqlAstBuilder.{getFullTableName, getSqlText} import org.opensearch.flint.spark.sql.FlintSparkSqlExtensionsParser._ @@ -20,7 +21,9 @@ import org.apache.spark.sql.types.StringType /** * Flint Spark AST builder that builds Spark command for Flint covering index statement. */ -trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[AnyRef] { +trait FlintSparkCoveringIndexAstBuilder + extends FlintSparkSqlExtensionsVisitor[AnyRef] + with IndexMetricHelper { self: SparkSqlAstBuilder => override def visitCreateCoveringIndexStatement( @@ -49,6 +52,8 @@ trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A .options(indexOptions, indexName) .create(ignoreIfExists) + emitCreateIndexMetric(indexOptions.autoRefresh()) + // Trigger auto refresh if enabled and not using external scheduler if (indexOptions .autoRefresh() && !indexBuilder.isExternalSchedulerEnabled()) { @@ -62,6 +67,7 @@ trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitRefreshCoveringIndexStatement( ctx: RefreshCoveringIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + MetricsUtil.incrementCounter(MetricConstants.QUERY_REFRESH_COUNT_METRIC) val flintIndexName = getFlintIndexName(flint, ctx.indexName, ctx.tableName) flint.refreshIndex(flintIndexName) Seq.empty @@ -107,6 +113,7 @@ trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitAlterCoveringIndexStatement( ctx: AlterCoveringIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitAlterIndexMetric() val indexName = getFlintIndexName(flint, ctx.indexName, ctx.tableName) val indexOptions = visitPropertyList(ctx.propertyList()) val index = flint @@ -121,6 +128,7 @@ trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitDropCoveringIndexStatement( ctx: DropCoveringIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitDropIndexMetric() val flintIndexName = getFlintIndexName(flint, ctx.indexName, ctx.tableName) flint.deleteIndex(flintIndexName) Seq.empty @@ -130,6 +138,7 @@ trait FlintSparkCoveringIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitVacuumCoveringIndexStatement( ctx: VacuumCoveringIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitVacuumIndexMetric() val flintIndexName = getFlintIndexName(flint, ctx.indexName, ctx.tableName) flint.vacuumIndex(flintIndexName) Seq.empty diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/mv/FlintSparkMaterializedViewAstBuilder.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/mv/FlintSparkMaterializedViewAstBuilder.scala index 8f3aa9917..9c8d2da0b 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/mv/FlintSparkMaterializedViewAstBuilder.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/mv/FlintSparkMaterializedViewAstBuilder.scala @@ -10,7 +10,7 @@ import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` import org.antlr.v4.runtime.tree.RuleNode import org.opensearch.flint.spark.FlintSpark import org.opensearch.flint.spark.mv.FlintSparkMaterializedView -import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, SparkSqlAstBuilder} +import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, IndexMetricHelper, SparkSqlAstBuilder} import org.opensearch.flint.spark.sql.FlintSparkSqlAstBuilder.{getFullTableName, getSqlText, IndexBelongsTo} import org.opensearch.flint.spark.sql.FlintSparkSqlExtensionsParser._ @@ -22,7 +22,9 @@ import org.apache.spark.sql.types.StringType /** * Flint Spark AST builder that builds Spark command for Flint materialized view statement. */ -trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisitor[AnyRef] { +trait FlintSparkMaterializedViewAstBuilder + extends FlintSparkSqlExtensionsVisitor[AnyRef] + with IndexMetricHelper { self: SparkSqlAstBuilder => override def visitCreateMaterializedViewStatement( @@ -40,6 +42,8 @@ trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisito val indexOptions = visitPropertyList(ctx.propertyList()) val flintIndexName = getFlintIndexName(flint, ctx.mvName) + emitCreateIndexMetric(indexOptions.autoRefresh()) + mvBuilder .options(indexOptions, flintIndexName) .create(ignoreIfExists) @@ -56,6 +60,7 @@ trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisito override def visitRefreshMaterializedViewStatement( ctx: RefreshMaterializedViewStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitRefreshIndexMetric() val flintIndexName = getFlintIndexName(flint, ctx.mvName) flint.refreshIndex(flintIndexName) Seq.empty @@ -106,6 +111,7 @@ trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisito override def visitAlterMaterializedViewStatement( ctx: AlterMaterializedViewStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitAlterIndexMetric() val indexName = getFlintIndexName(flint, ctx.mvName) val indexOptions = visitPropertyList(ctx.propertyList()) val index = flint @@ -120,6 +126,7 @@ trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisito override def visitDropMaterializedViewStatement( ctx: DropMaterializedViewStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitDropIndexMetric() flint.deleteIndex(getFlintIndexName(flint, ctx.mvName)) Seq.empty } @@ -128,6 +135,7 @@ trait FlintSparkMaterializedViewAstBuilder extends FlintSparkSqlExtensionsVisito override def visitVacuumMaterializedViewStatement( ctx: VacuumMaterializedViewStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitVacuumIndexMetric() flint.vacuumIndex(getFlintIndexName(flint, ctx.mvName)) Seq.empty } diff --git a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/skipping/FlintSparkSkippingIndexAstBuilder.scala b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/skipping/FlintSparkSkippingIndexAstBuilder.scala index 67f6bc3d4..9ed06f6b0 100644 --- a/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/skipping/FlintSparkSkippingIndexAstBuilder.scala +++ b/flint-spark-integration/src/main/scala/org/opensearch/flint/spark/sql/skipping/FlintSparkSkippingIndexAstBuilder.scala @@ -14,7 +14,7 @@ import org.opensearch.flint.spark.skipping.FlintSparkSkippingIndex import org.opensearch.flint.spark.skipping.FlintSparkSkippingStrategy.SkippingKind import org.opensearch.flint.spark.skipping.FlintSparkSkippingStrategy.SkippingKind.{BLOOM_FILTER, MIN_MAX, PARTITION, VALUE_SET} import org.opensearch.flint.spark.skipping.valueset.ValueSetSkippingStrategy.VALUE_SET_MAX_SIZE_KEY -import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, SparkSqlAstBuilder} +import org.opensearch.flint.spark.sql.{FlintSparkSqlCommand, FlintSparkSqlExtensionsVisitor, IndexMetricHelper, SparkSqlAstBuilder} import org.opensearch.flint.spark.sql.FlintSparkSqlAstBuilder.{getFullTableName, getSqlText} import org.opensearch.flint.spark.sql.FlintSparkSqlExtensionsParser._ @@ -26,7 +26,9 @@ import org.apache.spark.sql.types.StringType /** * Flint Spark AST builder that builds Spark command for Flint skipping index statement. */ -trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[AnyRef] { +trait FlintSparkSkippingIndexAstBuilder + extends FlintSparkSqlExtensionsVisitor[AnyRef] + with IndexMetricHelper { self: SparkSqlAstBuilder => override def visitCreateSkippingIndexStatement( @@ -73,6 +75,8 @@ trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A val indexOptions = visitPropertyList(ctx.propertyList()) val indexName = getSkippingIndexName(flint, ctx.tableName) + emitCreateIndexMetric(indexOptions.autoRefresh()) + indexBuilder .options(indexOptions, indexName) .create(ignoreIfExists) @@ -88,6 +92,7 @@ trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitRefreshSkippingIndexStatement( ctx: RefreshSkippingIndexStatementContext): Command = FlintSparkSqlCommand() { flint => + emitRefreshIndexMetric() val indexName = getSkippingIndexName(flint, ctx.tableName) flint.refreshIndex(indexName) Seq.empty @@ -115,6 +120,7 @@ trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitAlterSkippingIndexStatement( ctx: AlterSkippingIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitAlterIndexMetric() val indexName = getSkippingIndexName(flint, ctx.tableName) val indexOptions = visitPropertyList(ctx.propertyList()) val index = flint @@ -142,6 +148,7 @@ trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitDropSkippingIndexStatement(ctx: DropSkippingIndexStatementContext): Command = FlintSparkSqlCommand() { flint => + emitDropIndexMetric() val indexName = getSkippingIndexName(flint, ctx.tableName) flint.deleteIndex(indexName) Seq.empty @@ -150,6 +157,7 @@ trait FlintSparkSkippingIndexAstBuilder extends FlintSparkSqlExtensionsVisitor[A override def visitVacuumSkippingIndexStatement( ctx: VacuumSkippingIndexStatementContext): Command = { FlintSparkSqlCommand() { flint => + emitVacuumIndexMetric() val indexName = getSkippingIndexName(flint, ctx.tableName) flint.vacuumIndex(indexName) Seq.empty From 78f2fbe31fcefdea26b00b75ab027da7ee1e4f15 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Wed, 13 Nov 2024 11:31:43 -0800 Subject: [PATCH 14/26] Enable HTML test report (#897) * Enable HTML test report Signed-off-by: Tomoyuki Morita * Extend heap memory Signed-off-by: Tomoyuki Morita * Try extending memory for subproject Signed-off-by: Tomoyuki Morita * Use env variable to extend heap size Signed-off-by: Tomoyuki Morita * Add comment Signed-off-by: Tomoyuki Morita * Scope library dependency to test Signed-off-by: Tomoyuki Morita --------- Signed-off-by: Tomoyuki Morita --- .github/workflows/test-and-build-workflow.yml | 11 +++++++++++ build.sbt | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/test-and-build-workflow.yml b/.github/workflows/test-and-build-workflow.yml index f8d9bd682..e3b2b20f4 100644 --- a/.github/workflows/test-and-build-workflow.yml +++ b/.github/workflows/test-and-build-workflow.yml @@ -25,5 +25,16 @@ jobs: - name: Style check run: sbt scalafmtCheckAll + - name: Set SBT_OPTS + # Needed to extend the JVM memory size to avoid OutOfMemoryError for HTML test report + run: echo "SBT_OPTS=-Xmx2G" >> $GITHUB_ENV + - name: Integ Test run: sbt integtest/integration + + - name: Upload test report + if: always() # Ensures the artifact is saved even if tests fail + uses: actions/upload-artifact@v3 + with: + name: test-reports + path: target/test-reports # Adjust this path if necessary \ No newline at end of file diff --git a/build.sbt b/build.sbt index 781b4f51f..724d348ae 100644 --- a/build.sbt +++ b/build.sbt @@ -82,7 +82,11 @@ lazy val commonSettings = Seq( compileScalastyle := (Compile / scalastyle).toTask("").value, Compile / compile := ((Compile / compile) dependsOn compileScalastyle).value, testScalastyle := (Test / scalastyle).toTask("").value, + // Enable HTML report and output to separate folder per package + Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-h", s"target/test-reports/${name.value}"), Test / test := ((Test / test) dependsOn testScalastyle).value, + // Needed for HTML report + libraryDependencies += "com.vladsch.flexmark" % "flexmark-all" % "0.64.8" % "test", dependencyOverrides ++= Seq( "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion, "com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion From c2d665435a20afbe330491afd4c5897989b61056 Mon Sep 17 00:00:00 2001 From: Tomoyuki MORITA Date: Wed, 13 Nov 2024 16:42:19 -0800 Subject: [PATCH 15/26] Fix build issue due to import (#903) Signed-off-by: Tomoyuki Morita --- .../src/main/scala/org/apache/spark/sql/FlintREPL.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala index 869541cc8..9b6ff4ff6 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala @@ -17,7 +17,7 @@ import com.codahale.metrics.Timer import org.opensearch.flint.common.model.{FlintStatement, InteractiveSession, SessionStates} import org.opensearch.flint.core.FlintOptions import org.opensearch.flint.core.logging.CustomLogging -import org.opensearch.flint.core.metrics.{MetricConstants, MetricsSparkListener} +import org.opensearch.flint.core.metrics.{MetricConstants, MetricsSparkListener, MetricsUtil} import org.opensearch.flint.core.metrics.MetricsUtil.{getTimerContext, incrementCounter, registerGauge, stopTimer} import org.apache.spark.SparkConf From a80aa04b9bbadfe68a3ed0ff59c3edce5251aac6 Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Thu, 14 Nov 2024 09:03:51 +0800 Subject: [PATCH 16/26] Support parenthesized expression in filter (#888) --- docs/ppl-lang/PPL-Example-Commands.md | 4 + docs/ppl-lang/ppl-where-command.md | 13 +- .../src/integration/resources/tpch/q19.ppl | 47 ++-- .../src/integration/resources/tpch/q7.ppl | 2 +- .../ppl/FlintSparkPPLFiltersITSuite.scala | 92 +++++++ .../src/main/antlr4/OpenSearchPPLParser.g4 | 1 + .../sql/ppl/parser/AstExpressionBuilder.java | 5 + ...lPlanParenthesizedConditionTestSuite.scala | 244 ++++++++++++++++++ 8 files changed, 378 insertions(+), 30 deletions(-) create mode 100644 ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanParenthesizedConditionTestSuite.scala diff --git a/docs/ppl-lang/PPL-Example-Commands.md b/docs/ppl-lang/PPL-Example-Commands.md index cb50431f6..851531b5b 100644 --- a/docs/ppl-lang/PPL-Example-Commands.md +++ b/docs/ppl-lang/PPL-Example-Commands.md @@ -50,6 +50,10 @@ _- **Limitation: new field added by eval command with a function cannot be dropp - `source = table | where a < 1 | fields a,b,c` - `source = table | where b != 'test' | fields a,b,c` - `source = table | where c = 'test' | fields a,b,c | head 3` +- `source = table | where c = 'test' AND a = 1 | fields a,b,c` +- `source = table | where c != 'test' OR a > 1 | fields a,b,c` +- `source = table | where (b > 1 OR a > 1) AND c != 'test' | fields a,b,c` +- `source = table | where c = 'test' NOT a > 1 | fields a,b,c` - Note: "AND" is optional - `source = table | where ispresent(b)` - `source = table | where isnull(coalesce(a, b)) | fields a,b,c | head 3` - `source = table | where isempty(a)` diff --git a/docs/ppl-lang/ppl-where-command.md b/docs/ppl-lang/ppl-where-command.md index c954623c3..aa7d9299e 100644 --- a/docs/ppl-lang/ppl-where-command.md +++ b/docs/ppl-lang/ppl-where-command.md @@ -27,15 +27,15 @@ PPL query: ### Additional Examples #### **Filters With Logical Conditions** -``` -- `source = table | where c = 'test' AND a = 1 | fields a,b,c` -- `source = table | where c != 'test' OR a > 1 | fields a,b,c | head 1` -- `source = table | where c = 'test' NOT a > 1 | fields a,b,c` - `source = table | where a = 1 | fields a,b,c` - `source = table | where a >= 1 | fields a,b,c` - `source = table | where a < 1 | fields a,b,c` - `source = table | where b != 'test' | fields a,b,c` - `source = table | where c = 'test' | fields a,b,c | head 3` +- `source = table | where c = 'test' AND a = 1 | fields a,b,c` +- `source = table | where c != 'test' OR a > 1 | fields a,b,c` +- `source = table | where (b > 1 OR a > 1) AND c != 'test' | fields a,b,c` +- `source = table | where c = 'test' NOT a > 1 | fields a,b,c` - Note: "AND" is optional - `source = table | where ispresent(b)` - `source = table | where isnull(coalesce(a, b)) | fields a,b,c | head 3` - `source = table | where isempty(a)` @@ -45,7 +45,6 @@ PPL query: - `source = table | where b not between '2024-09-10' and '2025-09-10'` - Note: This returns b >= '2024-09-10' and b <= '2025-09-10' - `source = table | where cidrmatch(ip, '192.169.1.0/24')` - `source = table | where cidrmatch(ipv6, '2003:db8::/32')` - - `source = table | eval status_category = case(a >= 200 AND a < 300, 'Success', a >= 300 AND a < 400, 'Redirection', @@ -57,10 +56,8 @@ PPL query: a >= 400 AND a < 500, 'Client Error', a >= 500, 'Server Error' else 'Incorrect HTTP status code' - ) = 'Incorrect HTTP status code' - + ) = 'Incorrect HTTP status code'` - `source = table | eval factor = case(a > 15, a - 14, isnull(b), a - 7, a < 3, a + 1 else 1) | where case(factor = 2, 'even', factor = 4, 'even', factor = 6, 'even', factor = 8, 'even' else 'odd') = 'even' | stats count() by factor` -``` \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q19.ppl b/integ-test/src/integration/resources/tpch/q19.ppl index 630d63bcc..63312d2f0 100644 --- a/integ-test/src/integration/resources/tpch/q19.ppl +++ b/integ-test/src/integration/resources/tpch/q19.ppl @@ -37,25 +37,30 @@ where */ source = lineitem -| join ON p_partkey = l_partkey - and p_brand = 'Brand#12' - and p_container in ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') - and l_quantity >= 1 and l_quantity <= 1 + 10 - and p_size between 1 and 5 - and l_shipmode in ('AIR', 'AIR REG') - and l_shipinstruct = 'DELIVER IN PERSON' - OR p_partkey = l_partkey - and p_brand = 'Brand#23' - and p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') - and l_quantity >= 10 and l_quantity <= 10 + 10 - and p_size between 1 and 10 - and l_shipmode in ('AIR', 'AIR REG') - and l_shipinstruct = 'DELIVER IN PERSON' - OR p_partkey = l_partkey - and p_brand = 'Brand#34' - and p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') - and l_quantity >= 20 and l_quantity <= 20 + 10 - and p_size between 1 and 15 - and l_shipmode in ('AIR', 'AIR REG') - and l_shipinstruct = 'DELIVER IN PERSON' +| join ON + ( + p_partkey = l_partkey + and p_brand = 'Brand#12' + and p_container in ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') + and l_quantity >= 1 and l_quantity <= 1 + 10 + and p_size between 1 and 5 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) OR ( + p_partkey = l_partkey + and p_brand = 'Brand#23' + and p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') + and l_quantity >= 10 and l_quantity <= 10 + 10 + and p_size between 1 and 10 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) OR ( + p_partkey = l_partkey + and p_brand = 'Brand#34' + and p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') + and l_quantity >= 20 and l_quantity <= 20 + 10 + and p_size between 1 and 15 + and l_shipmode in ('AIR', 'AIR REG') + and l_shipinstruct = 'DELIVER IN PERSON' + ) part \ No newline at end of file diff --git a/integ-test/src/integration/resources/tpch/q7.ppl b/integ-test/src/integration/resources/tpch/q7.ppl index ceda602b3..a6ea66d63 100644 --- a/integ-test/src/integration/resources/tpch/q7.ppl +++ b/integ-test/src/integration/resources/tpch/q7.ppl @@ -48,7 +48,7 @@ source = [ | join ON s_nationkey = n1.n_nationkey nation as n1 | join ON c_nationkey = n2.n_nationkey nation as n2 | where l_shipdate between date('1995-01-01') and date('1996-12-31') - and n1.n_name = 'FRANCE' and n2.n_name = 'GERMANY' or n1.n_name = 'GERMANY' and n2.n_name = 'FRANCE' + and ((n1.n_name = 'FRANCE' and n2.n_name = 'GERMANY') or (n1.n_name = 'GERMANY' and n2.n_name = 'FRANCE')) | eval supp_nation = n1.n_name, cust_nation = n2.n_name, l_year = year(l_shipdate), volume = l_extendedprice * (1 - l_discount) | fields supp_nation, cust_nation, l_year, volume ] as shipping diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLFiltersITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLFiltersITSuite.scala index f2d7ee844..62c735597 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLFiltersITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLFiltersITSuite.scala @@ -467,4 +467,96 @@ class FlintSparkPPLFiltersITSuite val expectedPlan = Project(Seq(UnresolvedAttribute("state")), filter) comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) } + + test("test parenthesis in filter") { + val frame = sql(s""" + | source = $testTable | where country = 'Canada' or age > 60 and age < 25 | fields name, age, country + | """.stripMargin) + assertSameRows(Seq(Row("John", 25, "Canada"), Row("Jane", 20, "Canada")), frame) + + val frameWithParenthesis = sql(s""" + | source = $testTable | where (country = 'Canada' or age > 60) and age < 25 | fields name, age, country + | """.stripMargin) + assertSameRows(Seq(Row("Jane", 20, "Canada")), frameWithParenthesis) + + val logicalPlan: LogicalPlan = frameWithParenthesis.queryExecution.logical + val table = UnresolvedRelation(Seq("spark_catalog", "default", "flint_ppl_test")) + val filter = Filter( + And( + Or( + EqualTo(UnresolvedAttribute("country"), Literal("Canada")), + GreaterThan(UnresolvedAttribute("age"), Literal(60))), + LessThan(UnresolvedAttribute("age"), Literal(25))), + table) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("name"), + UnresolvedAttribute("age"), + UnresolvedAttribute("country")), + filter) + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("test complex and nested parenthesis in filter") { + val frame1 = sql(s""" + | source = $testTable | WHERE (age > 18 AND (state = 'California' OR state = 'New York')) + | """.stripMargin) + assertSameRows( + Seq( + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4)), + frame1) + + val frame2 = sql(s""" + | source = $testTable | WHERE ((((age > 18) AND ((((state = 'California') OR state = 'New York')))))) + | """.stripMargin) + assertSameRows( + Seq( + Row("Hello", 30, "New York", "USA", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4)), + frame2) + + val frame3 = sql(s""" + | source = $testTable | WHERE (year = 2023 AND (month BETWEEN 1 AND 6)) AND (age >= 31 OR country = 'Canada') + | """.stripMargin) + assertSameRows( + Seq( + Row("John", 25, "Ontario", "Canada", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4), + Row("Jane", 20, "Quebec", "Canada", 2023, 4)), + frame3) + + val frame4 = sql(s""" + | source = $testTable | WHERE ((state = 'Texas' OR state = 'California') AND (age < 30 OR (country = 'USA' AND year > 2020))) + | """.stripMargin) + assertSameRows(Seq(Row("Jake", 70, "California", "USA", 2023, 4)), frame4) + + val frame5 = sql(s""" + | source = $testTable | WHERE (LIKE(LOWER(name), 'a%') OR LIKE(LOWER(name), 'j%')) AND (LENGTH(state) > 6 OR (country = 'USA' AND age > 18)) + | """.stripMargin) + assertSameRows( + Seq( + Row("John", 25, "Ontario", "Canada", 2023, 4), + Row("Jake", 70, "California", "USA", 2023, 4)), + frame5) + + val frame6 = sql(s""" + | source = $testTable | WHERE (age BETWEEN 25 AND 40) AND ((state IN ('California', 'New York', 'Texas') AND year = 2023) OR (country != 'USA' AND (month = 1 OR month = 12))) + | """.stripMargin) + assertSameRows(Seq(Row("Hello", 30, "New York", "USA", 2023, 4)), frame6) + + val frame7 = sql(s""" + | source = $testTable | WHERE NOT (age < 18 OR (state = 'Alaska' AND year < 2020)) AND (country = 'USA' OR (country = 'Mexico' AND month BETWEEN 6 AND 8)) + | """.stripMargin) + assertSameRows( + Seq( + Row("Jake", 70, "California", "USA", 2023, 4), + Row("Hello", 30, "New York", "USA", 2023, 4)), + frame7) + + val frame8 = sql(s""" + | source = $testTable | WHERE (NOT (year < 2020 OR age < 18)) AND ((state = 'Texas' AND month % 2 = 0) OR (country = 'Mexico' AND (year = 2023 OR (year = 2022 AND month > 6)))) + | """.stripMargin) + assertSameRows(Seq(), frame8) + } } diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index 63efd8c6c..e44964c72 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -424,6 +424,7 @@ expression logicalExpression : NOT logicalExpression # logicalNot + | LT_PRTHS logicalExpression RT_PRTHS # parentheticLogicalExpr | comparisonExpression # comparsion | left = logicalExpression (AND)? right = logicalExpression # logicalAnd | left = logicalExpression OR right = logicalExpression # logicalOr diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java index 36d9f9577..e683a1395 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java @@ -157,6 +157,11 @@ public UnresolvedExpression visitBinaryArithmetic(OpenSearchPPLParser.BinaryArit ctx.binaryOperator.getText(), Arrays.asList(visit(ctx.left), visit(ctx.right))); } + @Override + public UnresolvedExpression visitParentheticLogicalExpr(OpenSearchPPLParser.ParentheticLogicalExprContext ctx) { + return visit(ctx.logicalExpression()); // Discard parenthesis around + } + @Override public UnresolvedExpression visitParentheticValueExpr(OpenSearchPPLParser.ParentheticValueExprContext ctx) { return visit(ctx.valueExpression()); // Discard parenthesis around diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanParenthesizedConditionTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanParenthesizedConditionTestSuite.scala new file mode 100644 index 000000000..a70415aab --- /dev/null +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanParenthesizedConditionTestSuite.scala @@ -0,0 +1,244 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.flint.spark.ppl + +import org.opensearch.flint.spark.ppl.PlaneUtils.plan +import org.opensearch.sql.ppl.{CatalystPlanContext, CatalystQueryPlanVisitor} +import org.scalatest.matchers.should.Matchers + +import org.apache.spark.SparkFunSuite +import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} +import org.apache.spark.sql.catalyst.expressions.{And, EqualTo, GreaterThan, GreaterThanOrEqual, In, LessThan, LessThanOrEqual, Literal, Not, Or} +import org.apache.spark.sql.catalyst.plans.PlanTest +import org.apache.spark.sql.catalyst.plans.logical.{Filter, Project} + +class PPLLogicalPlanParenthesizedConditionTestSuite + extends SparkFunSuite + with PlanTest + with LogicalPlanTestUtils + with Matchers { + + private val planTransformer = new CatalystQueryPlanVisitor() + private val pplParser = new PPLSyntaxParser() + + test("test simple nested condition") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE (age > 18 AND (state = 'California' OR state = 'New York'))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + GreaterThan(UnresolvedAttribute("age"), Literal(18)), + Or( + EqualTo(UnresolvedAttribute("state"), Literal("California")), + EqualTo(UnresolvedAttribute("state"), Literal("New York")))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test nested condition with duplicated parentheses") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE ((((age > 18) AND ((((state = 'California') OR state = 'New York'))))))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + GreaterThan(UnresolvedAttribute("age"), Literal(18)), + Or( + EqualTo(UnresolvedAttribute("state"), Literal("California")), + EqualTo(UnresolvedAttribute("state"), Literal("New York")))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test combining between function") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE (year = 2023 AND (month BETWEEN 1 AND 6)) AND (age >= 31 OR country = 'Canada')"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val betweenCondition = And( + GreaterThanOrEqual(UnresolvedAttribute("month"), Literal(1)), + LessThanOrEqual(UnresolvedAttribute("month"), Literal(6))) + val filter = Filter( + And( + And(EqualTo(UnresolvedAttribute("year"), Literal(2023)), betweenCondition), + Or( + GreaterThanOrEqual(UnresolvedAttribute("age"), Literal(31)), + EqualTo(UnresolvedAttribute("country"), Literal("Canada")))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test multiple levels of nesting") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE ((state = 'Texas' OR state = 'California') AND (age < 30 OR (country = 'USA' AND year > 2020)))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + Or( + EqualTo(UnresolvedAttribute("state"), Literal("Texas")), + EqualTo(UnresolvedAttribute("state"), Literal("California"))), + Or( + LessThan(UnresolvedAttribute("age"), Literal(30)), + And( + EqualTo(UnresolvedAttribute("country"), Literal("USA")), + GreaterThan(UnresolvedAttribute("year"), Literal(2020))))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test with string functions") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE (LIKE(LOWER(name), 'a%') OR LIKE(LOWER(name), 'j%')) AND (LENGTH(state) > 6 OR (country = 'USA' AND age > 18))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + Or( + UnresolvedFunction( + "like", + Seq( + UnresolvedFunction("lower", Seq(UnresolvedAttribute("name")), isDistinct = false), + Literal("a%")), + isDistinct = false), + UnresolvedFunction( + "like", + Seq( + UnresolvedFunction("lower", Seq(UnresolvedAttribute("name")), isDistinct = false), + Literal("j%")), + isDistinct = false)), + Or( + GreaterThan( + UnresolvedFunction("length", Seq(UnresolvedAttribute("state")), isDistinct = false), + Literal(6)), + And( + EqualTo(UnresolvedAttribute("country"), Literal("USA")), + GreaterThan(UnresolvedAttribute("age"), Literal(18))))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test complex age ranges with nested conditions") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE (age BETWEEN 25 AND 40) AND ((state IN ('California', 'New York', 'Texas') AND year = 2023) OR (country != 'USA' AND (month = 1 OR month = 12)))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + And( + GreaterThanOrEqual(UnresolvedAttribute("age"), Literal(25)), + LessThanOrEqual(UnresolvedAttribute("age"), Literal(40))), + Or( + And( + In( + UnresolvedAttribute("state"), + Seq(Literal("California"), Literal("New York"), Literal("Texas"))), + EqualTo(UnresolvedAttribute("year"), Literal(2023))), + And( + Not(EqualTo(UnresolvedAttribute("country"), Literal("USA"))), + Or( + EqualTo(UnresolvedAttribute("month"), Literal(1)), + EqualTo(UnresolvedAttribute("month"), Literal(12)))))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test nested NOT conditions") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE NOT (age < 18 OR (state = 'Alaska' AND year < 2020)) AND (country = 'USA' OR (country = 'Mexico' AND month BETWEEN 6 AND 8))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + Not( + Or( + LessThan(UnresolvedAttribute("age"), Literal(18)), + And( + EqualTo(UnresolvedAttribute("state"), Literal("Alaska")), + LessThan(UnresolvedAttribute("year"), Literal(2020))))), + Or( + EqualTo(UnresolvedAttribute("country"), Literal("USA")), + And( + EqualTo(UnresolvedAttribute("country"), Literal("Mexico")), + And( + GreaterThanOrEqual(UnresolvedAttribute("month"), Literal(6)), + LessThanOrEqual(UnresolvedAttribute("month"), Literal(8)))))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } + + test("test complex boolean logic") { + val context = new CatalystPlanContext + val logPlan = planTransformer.visit( + plan( + pplParser, + "source=employees | WHERE (NOT (year < 2020 OR age < 18)) AND ((state = 'Texas' AND month % 2 = 0) OR (country = 'Mexico' AND (year = 2023 OR (year = 2022 AND month > 6))))"), + context) + + val table = UnresolvedRelation(Seq("employees")) + val filter = Filter( + And( + Not( + Or( + LessThan(UnresolvedAttribute("year"), Literal(2020)), + LessThan(UnresolvedAttribute("age"), Literal(18)))), + Or( + And( + EqualTo(UnresolvedAttribute("state"), Literal("Texas")), + EqualTo( + UnresolvedFunction( + "%", + Seq(UnresolvedAttribute("month"), Literal(2)), + isDistinct = false), + Literal(0))), + And( + EqualTo(UnresolvedAttribute("country"), Literal("Mexico")), + Or( + EqualTo(UnresolvedAttribute("year"), Literal(2023)), + And( + EqualTo(UnresolvedAttribute("year"), Literal(2022)), + GreaterThan(UnresolvedAttribute("month"), Literal(6))))))), + table) + val expectedPlan = Project(Seq(UnresolvedStar(None)), filter) + comparePlans(expectedPlan, logPlan, checkAnalysis = false) + } +} From 439cf3e1bcb5daef54be92195a100ca3b26d90cd Mon Sep 17 00:00:00 2001 From: Andy Kwok Date: Wed, 13 Nov 2024 22:38:11 -0800 Subject: [PATCH 17/26] New trendline ppl command (WMA) (#872) * WMA implementation Signed-off-by: Andy Kwok * Update test cases Signed-off-by: Andy Kwok * Update tests Signed-off-by: Andy Kwok * Refactor code Signed-off-by: Andy Kwok * Addres comments Signed-off-by: Andy Kwok * Update doc Signed-off-by: Andy Kwok * Update example Signed-off-by: Andy Kwok * Update readme Signed-off-by: Andy Kwok * Update scalafmt Signed-off-by: Andy Kwok * Update grammar rule Signed-off-by: Andy Kwok * Address review comments Signed-off-by: Andy Kwok * Address review comments Signed-off-by: Andy Kwok --------- Signed-off-by: Andy Kwok --- DEVELOPER_GUIDE.md | 12 + docs/ppl-lang/PPL-Example-Commands.md | 1 + docs/ppl-lang/ppl-trendline-command.md | 64 ++++- .../ppl/FlintSparkPPLTrendlineITSuite.scala | 268 +++++++++++++++++- .../src/main/antlr4/OpenSearchPPLLexer.g4 | 1 + .../src/main/antlr4/OpenSearchPPLParser.g4 | 3 +- .../opensearch/sql/ast/tree/Trendline.java | 2 +- .../function/BuiltinFunctionName.java | 1 - .../sql/ppl/CatalystQueryPlanVisitor.java | 2 +- .../sql/ppl/utils/TrendlineCatalystUtils.java | 193 +++++++++++-- ...nTrendlineCommandTranslatorTestSuite.scala | 148 +++++++++- 11 files changed, 658 insertions(+), 37 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index bb8f697ec..834a2a201 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -11,6 +11,11 @@ To execute the unit tests, run the following command: ``` sbt test ``` +To run a specific unit test in SBT, use the testOnly command with the full path of the test class: +``` +sbt "; project pplSparkIntegration; test:testOnly org.opensearch.flint.spark.ppl.PPLLogicalPlanTrendlineCommandTranslatorTestSuite" +``` + ## Integration Test The integration test is defined in the `integration` directory of the project. The integration tests will automatically trigger unit tests and will only run if all unit tests pass. If you want to run the integration test for the project, you can do so by running the following command: @@ -23,6 +28,13 @@ If you get integration test failures with error message "Previous attempts to fi 3. Run `sudo ln -s $HOME/.docker/desktop/docker.sock /var/run/docker.sock` or `sudo ln -s $HOME/.docker/run/docker.sock /var/run/docker.sock` 4. If you use Docker Desktop, as an alternative of `3`, check mark the "Allow the default Docker socket to be used (requires password)" in advanced settings of Docker Desktop. +Running only a selected set of integration test suites is possible with the following command: +``` +sbt "; project integtest; it:testOnly org.opensearch.flint.spark.ppl.FlintSparkPPLTrendlineITSuite" +``` +This command runs only the specified test suite within the integtest submodule. + + ### AWS Integration Test The `aws-integration` folder contains tests for cloud server providers. For instance, test against AWS OpenSearch domain, configure the following settings. The client will use the default credential provider to access the AWS OpenSearch domain. ``` diff --git a/docs/ppl-lang/PPL-Example-Commands.md b/docs/ppl-lang/PPL-Example-Commands.md index 851531b5b..7766c3b50 100644 --- a/docs/ppl-lang/PPL-Example-Commands.md +++ b/docs/ppl-lang/PPL-Example-Commands.md @@ -65,6 +65,7 @@ _- **Limitation: new field added by eval command with a function cannot be dropp - `source = table | where cidrmatch(ip, '192.169.1.0/24')` - `source = table | where cidrmatch(ipv6, '2003:db8::/32')` - `source = table | trendline sma(2, temperature) as temp_trend` +- `source = table | trendline sort timestamp wma(2, temperature) as temp_trend` #### **IP related queries** [See additional command details](functions/ppl-ip.md) diff --git a/docs/ppl-lang/ppl-trendline-command.md b/docs/ppl-lang/ppl-trendline-command.md index 393a9dd59..b466e2e8f 100644 --- a/docs/ppl-lang/ppl-trendline-command.md +++ b/docs/ppl-lang/ppl-trendline-command.md @@ -3,8 +3,7 @@ **Description** Using ``trendline`` command to calculate moving averages of fields. - -### Syntax +### Syntax - SMA (Simple Moving Average) `TRENDLINE [sort <[+|-] sort-field>] SMA(number-of-datapoints, field) [AS alias] [SMA(number-of-datapoints, field) [AS alias]]...` * [+|-]: optional. The plus [+] stands for ascending order and NULL/MISSING first and a minus [-] stands for descending order and NULL/MISSING last. **Default:** ascending order and NULL/MISSING first. @@ -13,8 +12,6 @@ Using ``trendline`` command to calculate moving averages of fields. * field: mandatory. the name of the field the moving average should be calculated for. * alias: optional. the name of the resulting column containing the moving average. -And the moment only the Simple Moving Average (SMA) type is supported. - It is calculated like f[i]: The value of field 'f' in the i-th data-point @@ -23,7 +20,7 @@ It is calculated like SMA(t) = (1/n) * Σ(f[i]), where i = t-n+1 to t -### Example 1: Calculate simple moving average for a timeseries of temperatures +#### Example 1: Calculate simple moving average for a timeseries of temperatures The example calculates the simple moving average over temperatures using two datapoints. @@ -41,7 +38,7 @@ PPL query: | 15| 258|2023-04-06 17:07:...| 14.5| +-----------+---------+--------------------+----------+ -### Example 2: Calculate simple moving averages for a timeseries of temperatures with sorting +#### Example 2: Calculate simple moving averages for a timeseries of temperatures with sorting The example calculates two simple moving average over temperatures using two and three datapoints sorted descending by device-id. @@ -58,3 +55,58 @@ PPL query: | 12| 1492|2023-04-06 17:07:...| 12.5| 13.0| | 12| 1492|2023-04-06 17:07:...| 12.0|12.333333333333334| +-----------+---------+--------------------+------------+------------------+ + + +### Syntax - WMA (Weighted Moving Average) +`TRENDLINE sort <[+|-] sort-field> WMA(number-of-datapoints, field) [AS alias] [WMA(number-of-datapoints, field) [AS alias]]...` + +* [+|-]: optional. The plus [+] stands for ascending order and NULL/MISSING first and a minus [-] stands for descending order and NULL/MISSING last. **Default:** ascending order and NULL/MISSING first. +* sort-field: mandatory. this field specifies the ordering of data poients when calculating the nth_value aggregation. +* number-of-datapoints: mandatory. number of datapoints to calculate the moving average (must be greater than zero). +* field: mandatory. the name of the field the moving averag should be calculated for. +* alias: optional. the name of the resulting column containing the moving average. + +It is calculated like + + f[i]: The value of field 'f' in the i-th data point + n: The number of data points in the moving window (period) + t: The current time index + w[i]: The weight assigned to the i-th data point, typically increasing for more recent points + + WMA(t) = ( Σ from i=t−n+1 to t of (w[i] * f[i]) ) / ( Σ from i=t−n+1 to t of w[i] ) + +#### Example 1: Calculate weighted moving average for a timeseries of temperatures + +The example calculates the simple moving average over temperatures using two datapoints. + +PPL query: + + os> source=t | trendline sort timestamp wma(2, temperature) as temp_trend; + fetched rows / total rows = 5/5 + +-----------+---------+--------------------+----------+ + |temperature|device-id| timestamp|temp_trend| + +-----------+---------+--------------------+----------+ + | 12| 1492|2023-04-06 17:07:...| NULL| + | 12| 1492|2023-04-06 17:07:...| 12.0| + | 13| 256|2023-04-06 17:07:...| 12.6| + | 14| 257|2023-04-06 17:07:...| 13.6| + | 15| 258|2023-04-06 17:07:...| 14.6| + +-----------+---------+--------------------+----------+ + +#### Example 2: Calculate simple moving averages for a timeseries of temperatures with sorting + +The example calculates two simple moving average over temperatures using two and three datapoints sorted descending by device-id. + +PPL query: + + os> source=t | trendline sort - device-id wma(2, temperature) as temp_trend_2 wma(3, temperature) as temp_trend_3; + fetched rows / total rows = 5/5 + +-----------+---------+--------------------+------------+------------------+ + |temperature|device-id| timestamp|temp_trend_2| temp_trend_3| + +-----------+---------+--------------------+------------+------------------+ + | 15| 258|2023-04-06 17:07:...| NULL| NULL| + | 14| 257|2023-04-06 17:07:...| 14.3| NULL| + | 13| 256|2023-04-06 17:07:...| 13.3| 13.6| + | 12| 1492|2023-04-06 17:07:...| 12.3| 12.6| + | 12| 1492|2023-04-06 17:07:...| 12.0| 12.16| + +-----------+---------+--------------------+------------+------------------+ diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTrendlineITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTrendlineITSuite.scala index bc4463537..9a8379288 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTrendlineITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLTrendlineITSuite.scala @@ -5,9 +5,14 @@ package org.opensearch.flint.spark.ppl +import org.opensearch.sql.ppl.utils.DataTypeTransformer.seq +import org.opensearch.sql.ppl.utils.SortUtils +import org.scalatest.matchers.should.Matchers.{a, convertToAnyShouldWrapper} + import org.apache.spark.sql.{QueryTest, Row} import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} -import org.apache.spark.sql.catalyst.expressions.{Alias, Ascending, CaseWhen, CurrentRow, Descending, LessThan, Literal, RowFrame, SortOrder, SpecifiedWindowFrame, WindowExpression, WindowSpecDefinition} +import org.apache.spark.sql.catalyst.expressions.{Add, Alias, Ascending, CaseWhen, CurrentRow, Descending, Divide, Expression, LessThan, Literal, Multiply, RowFrame, SortOrder, SpecifiedWindowFrame, WindowExpression, WindowSpecDefinition} +import org.apache.spark.sql.catalyst.parser.ParseException import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.streaming.StreamTest @@ -244,4 +249,265 @@ class FlintSparkPPLTrendlineITSuite implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](0)) assert(results.sorted.sameElements(expectedResults.sorted)) } + + test("test trendline wma command with sort field and without alias") { + val frame = sql(s""" + | source = $testTable | trendline sort + age wma(3, age) + | """.stripMargin) + + // Compare the headers + assert( + frame.columns.sameElements( + Array("name", "age", "state", "country", "year", "month", "age_trendline"))) + // Retrieve the results + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = + Array( + Row("Jane", 20, "Quebec", "Canada", 2023, 4, null), + Row("John", 25, "Ontario", "Canada", 2023, 4, null), + Row("Hello", 30, "New York", "USA", 2023, 4, 26.666666666666668), + Row("Jake", 70, "California", "USA", 2023, 4, 49.166666666666664)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](0)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Compare the logical plans + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val wmaExpression = Divide(dividend, Literal(6)) + val trendlineProjectList = Seq(UnresolvedStar(None), Alias(wmaExpression, "age_trendline")()) + val unresolvedRelation = UnresolvedRelation(testTable.split("\\.").toSeq) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + unresolvedRelation) + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + + /** + * Expected logical plan: 'Project [*] +- 'Project [*, ((( ('nth_value('age, 1) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 1) + ('nth_value('age, 2) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 2)) + ('nth_value('age, 3) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 3)) / 6) AS age_trendline#185] +- 'Sort ['age ASC NULLS FIRST], true +- + * 'UnresolvedRelation [spark_catalog, default, flint_ppl_test], [], false + */ + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("test trendline wma command with sort field and with alias") { + val frame = sql(s""" + | source = $testTable | trendline sort + age wma(3, age) as trendline_alias + | """.stripMargin) + + // Compare the headers + assert( + frame.columns.sameElements( + Array("name", "age", "state", "country", "year", "month", "trendline_alias"))) + // Retrieve the results + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = + Array( + Row("Jane", 20, "Quebec", "Canada", 2023, 4, null), + Row("John", 25, "Ontario", "Canada", 2023, 4, null), + Row("Hello", 30, "New York", "USA", 2023, 4, 26.666666666666668), + Row("Jake", 70, "California", "USA", 2023, 4, 49.166666666666664)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](0)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Compare the logical plans + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val wmaExpression = Divide(dividend, Literal(6)) + val trendlineProjectList = + Seq(UnresolvedStar(None), Alias(wmaExpression, "trendline_alias")()) + val unresolvedRelation = UnresolvedRelation(testTable.split("\\.").toSeq) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + unresolvedRelation) + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + + /** + * 'Project [*] +- 'Project [*, ((( ('nth_value('age, 1) windowspecdefinition('age ASC NULLS + * FIRST, specifiedwindowframe(RowFrame, -2, currentrow$())) * 1) + ('nth_value('age, 2) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 2)) + ('nth_value('age, 3) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 3)) / 6) AS trendline_alias#185] +- + * 'Sort ['age ASC NULLS FIRST], true +- 'UnresolvedRelation [spark_catalog, default, + * flint_ppl_test], [], false + */ + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("test multiple trendline wma commands") { + val frame = sql(s""" + | source = $testTable | trendline sort + age wma(2, age) as two_points_wma wma(3, age) as three_points_wma + | """.stripMargin) + + // Compare the headers + assert( + frame.columns.sameElements( + Array( + "name", + "age", + "state", + "country", + "year", + "month", + "two_points_wma", + "three_points_wma"))) + // Retrieve the results + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = + Array( + Row("Jane", 20, "Quebec", "Canada", 2023, 4, null, null), + Row("John", 25, "Ontario", "Canada", 2023, 4, 23.333333333333332, null), + Row("Hello", 30, "New York", "USA", 2023, 4, 28.333333333333332, 26.666666666666668), + Row("Jake", 70, "California", "USA", 2023, 4, 56.666666666666664, 49.166666666666664)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](0)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Compare the logical plans + val logicalPlan: LogicalPlan = frame.queryExecution.logical + + val dividendTwo = Add( + getNthValueAggregation("age", "age", 1, -1), + getNthValueAggregation("age", "age", 2, -1)) + val twoPointsExpression = Divide(dividendTwo, Literal(3)) + + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val threePointsExpression = Divide(dividend, Literal(6)) + + val trendlineProjectList = Seq( + UnresolvedStar(None), + Alias(twoPointsExpression, "two_points_wma")(), + Alias(threePointsExpression, "three_points_wma")()) + val unresolvedRelation = UnresolvedRelation(testTable.split("\\.").toSeq) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + unresolvedRelation) + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + + /** + * 'Project [*] +- 'Project [*, (( ('nth_value('age, 1) windowspecdefinition('age ASC NULLS + * FIRST, specifiedwindowframe(RowFrame, -1, currentrow$())) * 1) + ('nth_value('age, 2) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -1, + * currentrow$())) * 2)) / 3) AS two_points_wma#247, + * + * ((( ('nth_value('age, 1) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 1) + ('nth_value('age, 2) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 2)) + ('nth_value('age, 3) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 3)) / 6) AS three_points_wma#248] +- + * 'Sort ['age ASC NULLS FIRST], true +- 'UnresolvedRelation [spark_catalog, default, + * flint_ppl_test], [], false + */ + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + + test("test trendline wma command on evaluated column") { + val frame = sql(s""" + | source = $testTable | eval doubled_age = age * 2 | trendline sort + age wma(2, doubled_age) as doubled_age_wma | fields name, doubled_age, doubled_age_wma + | """.stripMargin) + + // Compare the headers + assert(frame.columns.sameElements(Array("name", "doubled_age", "doubled_age_wma"))) + // Retrieve the results + val results: Array[Row] = frame.collect() + val expectedResults: Array[Row] = + Array( + Row("Jane", 40, null), + Row("John", 50, 46.666666666666664), + Row("Hello", 60, 56.666666666666664), + Row("Jake", 140, 113.33333333333333)) + + // Compare the results + implicit val rowOrdering: Ordering[Row] = Ordering.by[Row, String](_.getAs[String](0)) + assert(results.sorted.sameElements(expectedResults.sorted)) + + // Compare the logical plans + val logicalPlan: LogicalPlan = frame.queryExecution.logical + val dividend = Add( + getNthValueAggregation("doubled_age", "age", 1, -1), + getNthValueAggregation("doubled_age", "age", 2, -1)) + val wmaExpression = Divide(dividend, Literal(3)) + val trendlineProjectList = + Seq(UnresolvedStar(None), Alias(wmaExpression, "doubled_age_wma")()) + val unresolvedRelation = UnresolvedRelation(testTable.split("\\.").toSeq) + val doubledAged = Alias( + UnresolvedFunction( + seq("*"), + seq(UnresolvedAttribute("age"), Literal(2)), + isDistinct = false), + "doubled_age")() + val doubleAgeProject = Project(seq(UnresolvedStar(None), doubledAged), unresolvedRelation) + val sortedTable = + Sort(Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), global = true, doubleAgeProject) + val expectedPlan = Project( + Seq( + UnresolvedAttribute("name"), + UnresolvedAttribute("doubled_age"), + UnresolvedAttribute("doubled_age_wma")), + Project(trendlineProjectList, sortedTable)) + + /** + * 'Project ['name, 'doubled_age, 'doubled_age_wma] +- 'Project [*, (( + * ('nth_value('doubled_age, 1) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -1, currentrow$())) * 1) + ('nth_value('doubled_age, 2) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -1, + * currentrow$())) * 2)) / 3) AS doubled_age_wma#288] +- 'Sort ['age ASC NULLS FIRST], true +- + * 'Project [*, '`*`('age, 2) AS doubled_age#287] +- 'UnresolvedRelation [spark_catalog, + * default, flint_ppl_test], [], false + */ + comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + + } + + test("test invalid wma command with negative dataPoint value") { + val exception = intercept[ParseException](sql(s""" + | source = $testTable | trendline sort + age wma(-3, age) + | """.stripMargin)) + assert(exception.getMessage contains "[PARSE_SYNTAX_ERROR] Syntax error") + } + + private def getNthValueAggregation( + dataField: String, + sortField: String, + lookBackPos: Int, + lookBackRange: Int): Expression = { + Multiply( + WindowExpression( + UnresolvedFunction( + "nth_value", + Seq(UnresolvedAttribute(dataField), Literal(lookBackPos)), + isDistinct = false), + WindowSpecDefinition( + Seq(), + seq(SortUtils.sortOrder(UnresolvedAttribute(sortField), true)), + SpecifiedWindowFrame(RowFrame, Literal(lookBackRange), CurrentRow))), + Literal(lookBackPos)) + } + } diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 index 10b2e01b8..3ce8b6f1e 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 @@ -96,6 +96,7 @@ NULLS: 'NULLS'; //TRENDLINE KEYWORDS SMA: 'SMA'; +WMA: 'WMA'; // ARGUMENT KEYWORDS KEEPEMPTY: 'KEEPEMPTY'; diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index e44964c72..357673e73 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -267,11 +267,12 @@ trendlineCommand ; trendlineClause - : trendlineType LT_PRTHS numberOfDataPoints = integerLiteral COMMA field = fieldExpression RT_PRTHS (AS alias = qualifiedName)? + : trendlineType LT_PRTHS numberOfDataPoints = INTEGER_LITERAL COMMA field = fieldExpression RT_PRTHS (AS alias = qualifiedName)? ; trendlineType : SMA + | WMA ; kmeansCommand diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Trendline.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Trendline.java index 9fa1ae81d..d08e89e3b 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Trendline.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ast/tree/Trendline.java @@ -62,6 +62,6 @@ public TrendlineComputation(Integer numberOfDataPoints, UnresolvedExpression dat } public enum TrendlineType { - SMA + SMA, WMA } } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java index f039bf47f..86970cefb 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java @@ -292,7 +292,6 @@ public enum BuiltinFunctionName { MULTIMATCHQUERY(FunctionName.of("multimatchquery")), WILDCARDQUERY(FunctionName.of("wildcardquery")), WILDCARD_QUERY(FunctionName.of("wildcard_query")), - COALESCE(FunctionName.of("coalesce")); private FunctionName name; diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java index 00a7905f0..debd37376 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java @@ -245,7 +245,7 @@ public LogicalPlan visitTrendline(Trendline node, CatalystPlanContext context) { trendlineProjectExpressions.add(UnresolvedStar$.MODULE$.apply(Option.empty())); } - trendlineProjectExpressions.addAll(TrendlineCatalystUtils.visitTrendlineComputations(expressionAnalyzer, node.getComputations(), context)); + trendlineProjectExpressions.addAll(TrendlineCatalystUtils.visitTrendlineComputations(expressionAnalyzer, node.getComputations(), node.getSortByField(), context)); return context.apply(p -> new org.apache.spark.sql.catalyst.plans.logical.Project(seq(trendlineProjectExpressions), p)); } diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/TrendlineCatalystUtils.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/TrendlineCatalystUtils.java index 67603ccc7..647f4542e 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/TrendlineCatalystUtils.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/TrendlineCatalystUtils.java @@ -5,31 +5,40 @@ package org.opensearch.sql.ppl.utils; +import org.apache.spark.sql.catalyst.analysis.UnresolvedFunction; import org.apache.spark.sql.catalyst.expressions.*; -import org.opensearch.sql.ast.expression.AggregateFunction; -import org.opensearch.sql.ast.expression.DataType; +import org.opensearch.sql.ast.expression.*; import org.opensearch.sql.ast.expression.Literal; import org.opensearch.sql.ast.tree.Trendline; import org.opensearch.sql.expression.function.BuiltinFunctionName; import org.opensearch.sql.ppl.CatalystExpressionVisitor; import org.opensearch.sql.ppl.CatalystPlanContext; +import scala.collection.mutable.Seq; import scala.Option; import scala.Tuple2; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import static org.opensearch.sql.ppl.utils.DataTypeTransformer.seq; +import static scala.Option.empty; +import static scala.collection.JavaConverters.asScalaBufferConverter; public interface TrendlineCatalystUtils { - static List visitTrendlineComputations(CatalystExpressionVisitor expressionVisitor, List computations, CatalystPlanContext context) { + + static List visitTrendlineComputations(CatalystExpressionVisitor expressionVisitor, List computations, Optional sortField, CatalystPlanContext context) { return computations.stream() - .map(computation -> visitTrendlineComputation(expressionVisitor, computation, context)) + .map(computation -> visitTrendlineComputation(expressionVisitor, computation, sortField, context)) .collect(Collectors.toList()); } - static NamedExpression visitTrendlineComputation(CatalystExpressionVisitor expressionVisitor, Trendline.TrendlineComputation node, CatalystPlanContext context) { + + static NamedExpression visitTrendlineComputation(CatalystExpressionVisitor expressionVisitor, Trendline.TrendlineComputation node, Optional sortField, CatalystPlanContext context) { + //window lower boundary expressionVisitor.visitLiteral(new Literal(Math.negateExact(node.getNumberOfDataPoints() - 1), DataType.INTEGER), context); Expression windowLowerBoundary = context.popNamedParseExpressions().get(); @@ -40,26 +49,28 @@ static NamedExpression visitTrendlineComputation(CatalystExpressionVisitor expre seq(), new SpecifiedWindowFrame(RowFrame$.MODULE$, windowLowerBoundary, CurrentRow$.MODULE$)); - if (node.getComputationType() == Trendline.TrendlineType.SMA) { - //calculate avg value of the data field - expressionVisitor.visitAggregateFunction(new AggregateFunction(BuiltinFunctionName.AVG.name(), node.getDataField()), context); - Expression avgFunction = context.popNamedParseExpressions().get(); - - //sma window - WindowExpression sma = new WindowExpression( - avgFunction, - windowDefinition); - - CaseWhen smaOrNull = trendlineOrNullWhenThereAreTooFewDataPoints(expressionVisitor, sma, node, context); - - return org.apache.spark.sql.catalyst.expressions.Alias$.MODULE$.apply(smaOrNull, - node.getAlias(), - NamedExpression.newExprId(), - seq(new java.util.ArrayList()), - Option.empty(), - seq(new java.util.ArrayList())); - } else { - throw new IllegalArgumentException(node.getComputationType()+" is not supported"); + switch (node.getComputationType()) { + case SMA: + //calculate avg value of the data field + expressionVisitor.visitAggregateFunction(new AggregateFunction(BuiltinFunctionName.AVG.name(), node.getDataField()), context); + Expression avgFunction = context.popNamedParseExpressions().get(); + + //sma window + WindowExpression sma = new WindowExpression( + avgFunction, + windowDefinition); + + CaseWhen smaOrNull = trendlineOrNullWhenThereAreTooFewDataPoints(expressionVisitor, sma, node, context); + + return getAlias(node.getAlias(), smaOrNull); + case WMA: + if (sortField.isPresent()) { + return getWMAComputationExpression(expressionVisitor, node, sortField.get(), context); + } else { + throw new IllegalArgumentException(node.getComputationType()+" requires a sort field for computation"); + } + default: + throw new IllegalArgumentException(node.getComputationType()+" is not supported"); } } @@ -84,4 +95,136 @@ private static CaseWhen trendlineOrNullWhenThereAreTooFewDataPoints(CatalystExpr ); return new CaseWhen(seq(nullWhenNumberOfDataPointsLessThenRequired), Option.apply(trendlineWindow)); } + + /** + * Responsible to produce a Spark Logical Plan with given TrendLine command arguments, below is the sample logical plan + * with configuration [dataField=salary, sortField=age, dataPoints=3] + * -- +- 'Project [ + * -- (((('nth_value('salary, 1) windowspecdefinition(Field(field=age, fieldArgs=[]) ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, currentrow$())) * 1) + + * -- ('nth_value('salary, 2) windowspecdefinition(Field(field=age, fieldArgs=[]) ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, currentrow$())) * 2)) + + * -- ('nth_value('salary, 3) windowspecdefinition(Field(field=age, fieldArgs=[]) ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, currentrow$())) * 3)) / 6) + * -- AS WMA#702] + * . + * And the corresponded SQL query: + * . + * SELECT name, salary, + * ( nth_value(salary, 1) OVER (ORDER BY age ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) *1 + + * nth_value(salary, 2) OVER (ORDER BY age ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) *2 + + * nth_value(salary, 3) OVER (ORDER BY age ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) *3 )/6 AS WMA + * FROM employees + * ORDER BY age; + * + * @param visitor Visitor instance to process any UnresolvedExpression. + * @param node Trendline command's arguments. + * @param sortField Field used for window aggregation. + * @param context Context instance to retrieved Expression in resolved form. + * @return a NamedExpression instance which will calculate WMA with provided argument. + */ + private static NamedExpression getWMAComputationExpression(CatalystExpressionVisitor visitor, + Trendline.TrendlineComputation node, + Field sortField, + CatalystPlanContext context) { + int dataPoints = node.getNumberOfDataPoints(); + //window lower boundary + Expression windowLowerBoundary = parseIntToExpression(visitor, context, + Math.negateExact(dataPoints - 1)); + //window definition + visitor.analyze(sortField, context); + Expression sortDefinition = context.popNamedParseExpressions().get(); + WindowSpecDefinition windowDefinition = getWmaCommonWindowDefinition( + sortDefinition, + SortUtils.isSortedAscending(sortField), + windowLowerBoundary); + // Divisor + Expression divisor = parseIntToExpression(visitor, context, + (dataPoints * (dataPoints + 1) / 2)); + // Aggregation + Expression wmaExpression = getNthValueAggregations(visitor, node, context, windowDefinition, dataPoints) + .stream() + .reduce(Add::new) + .orElse(null); + + return getAlias(node.getAlias(), new Divide(wmaExpression, divisor)); + } + + /** + * Helper method to produce an Alias Expression with provide value and name. + * @param name The name for the Alias. + * @param expression The expression which will be evaluated. + * @return An Alias instance with logical plan representation of `expression AS name`. + */ + private static NamedExpression getAlias(String name, Expression expression) { + return org.apache.spark.sql.catalyst.expressions.Alias$.MODULE$.apply(expression, + name, + NamedExpression.newExprId(), + seq(Collections.emptyList()), + Option.empty(), + seq(Collections.emptyList())); + } + + /** + * Helper method to retrieve an Int expression instance for logical plan composition purpose. + * @param expressionVisitor Visitor instance to process the incoming object. + * @param context Context instance to retrieve the Expression instance. + * @param i Target value for the expression. + * @return An expression object which contain integer value i. + */ + static Expression parseIntToExpression(CatalystExpressionVisitor expressionVisitor, CatalystPlanContext context, int i) { + expressionVisitor.visitLiteral(new Literal(i, + DataType.INTEGER), context); + return context.popNamedParseExpressions().get(); + } + + + /** + * Helper method to retrieve a WindowSpecDefinition with provided sorting condition. + * `windowspecdefinition('sortField ascending NULLS FIRST, specifiedwindowframe(RowFrame, windowLowerBoundary, currentrow$())` + * + * @param sortField The field being used for the sorting operation. + * @param ascending The boolean instance for the sorting order. + * @param windowLowerBoundary The Integer expression instance which specify the even lookbehind / lookahead. + * @return A WindowSpecDefinition instance which will be used to composite the WMA calculation. + */ + static WindowSpecDefinition getWmaCommonWindowDefinition(Expression sortField, boolean ascending, Expression windowLowerBoundary) { + return new WindowSpecDefinition( + seq(), + seq(SortUtils.sortOrder(sortField, ascending)), + new SpecifiedWindowFrame(RowFrame$.MODULE$, windowLowerBoundary, CurrentRow$.MODULE$)); + } + + /** + * To produce a list of Expressions responsible to return appropriate lookbehind / lookahead value for WMA calculation, sample logical plan listed below. + * (((('nth_value('salary, 1) windowspecdefinition(Field(field=age, fieldArgs=[]) ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, currentrow$())) * 1) + + * + * @param visitor Visitor instance to resolve Expression. + * @param node Treeline command instruction. + * @param context Context instance to retrieve the resolved expression. + * @param windowDefinition The windowDefinition for the individual datapoint lookbehind / lookahead. + * @param dataPoints Number of data-points for WMA calculation, this will always equal to number of Expression being generated. + * @return List instance which contain the SQL statement for WMA individual datapoint's calculations. + */ + private static List getNthValueAggregations(CatalystExpressionVisitor visitor, + Trendline.TrendlineComputation node, + CatalystPlanContext context, + WindowSpecDefinition windowDefinition, + int dataPoints) { + List expressions = new ArrayList<>(); + for (int i = 1; i <= dataPoints; i++) { + // Get the offset parameter + Expression offSetExpression = parseIntToExpression(visitor, context, i); + // Get the dataField in Expression + visitor.analyze(node.getDataField(), context); + Expression dataField = context.popNamedParseExpressions().get(); + // nth_value Expression + UnresolvedFunction nthValueExp = new UnresolvedFunction( + asScalaBufferConverter(List.of("nth_value")).asScala().seq(), + asScalaBufferConverter(List.of(dataField, offSetExpression)).asScala().seq(), + false, empty(), false); + + expressions.add(new Multiply( + new WindowExpression(nthValueExp, windowDefinition), offSetExpression)); + } + return expressions; + } + } diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTrendlineCommandTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTrendlineCommandTranslatorTestSuite.scala index d22750ee0..ec1775631 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTrendlineCommandTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanTrendlineCommandTranslatorTestSuite.scala @@ -6,12 +6,15 @@ package org.opensearch.flint.spark.ppl import org.opensearch.flint.spark.ppl.PlaneUtils.plan +import org.opensearch.sql.common.antlr.SyntaxCheckException import org.opensearch.sql.ppl.{CatalystPlanContext, CatalystQueryPlanVisitor} +import org.opensearch.sql.ppl.utils.DataTypeTransformer.seq +import org.opensearch.sql.ppl.utils.SortUtils import org.scalatest.matchers.should.Matchers import org.apache.spark.SparkFunSuite import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation, UnresolvedStar} -import org.apache.spark.sql.catalyst.expressions.{Alias, Ascending, CaseWhen, CurrentRow, Descending, LessThan, Literal, RowFrame, SortOrder, SpecifiedWindowFrame, WindowExpression, WindowSpecDefinition} +import org.apache.spark.sql.catalyst.expressions.{Add, Alias, Ascending, CaseWhen, CurrentRow, Descending, Divide, Expression, LessThan, Literal, Multiply, RowFrame, SortOrder, SpecifiedWindowFrame, WindowExpression, WindowSpecDefinition} import org.apache.spark.sql.catalyst.plans.PlanTest import org.apache.spark.sql.catalyst.plans.logical.{Project, Sort} @@ -132,4 +135,147 @@ class PPLLogicalPlanTrendlineCommandTranslatorTestSuite Project(trendlineProjectList, sort)) comparePlans(logPlan, expectedPlan, checkAnalysis = false) } + + test("WMA - with sort") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan(pplParser, "source=relation | trendline sort age wma(3, age)"), + context) + + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val wmaExpression = Divide(dividend, Literal(6)) + val trendlineProjectList = Seq(UnresolvedStar(None), Alias(wmaExpression, "age_trendline")()) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + UnresolvedRelation(Seq("relation"))) + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + + /** + * Expected logical plan: 'Project [*] !+- 'Project [*, ((( ('nth_value('age, 1) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 1) + ('nth_value('age, 2) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 2)) + ('nth_value('age, 3) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 3)) / 6) AS age_trendline#0] ! +- 'Sort ['age ASC NULLS FIRST], true ! +- + * 'UnresolvedRelation [relation], [], false + */ + comparePlans(logPlan, expectedPlan, checkAnalysis = false) + } + + test("WMA - with sort and alias") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan(pplParser, "source=relation | trendline sort age wma(3, age) as TEST_CUSTOM_COLUMN"), + context) + + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val wmaExpression = Divide(dividend, Literal(6)) + val trendlineProjectList = + Seq(UnresolvedStar(None), Alias(wmaExpression, "TEST_CUSTOM_COLUMN")()) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + UnresolvedRelation(Seq("relation"))) + + /** + * Expected logical plan: 'Project [*] !+- 'Project [*, ((( ('nth_value('age, 1) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 1) + ('nth_value('age, 2) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 2)) + ('nth_value('age, 3) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 3)) / 6) AS TEST_CUSTOM_COLUMN#0] ! +- 'Sort ['age ASC NULLS FIRST], true + * ! +- 'UnresolvedRelation [relation], [], false + */ + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + comparePlans(logPlan, expectedPlan, checkAnalysis = false) + + } + + test("WMA - multiple trendline commands") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "source=relation | trendline sort age wma(2, age) as two_points_wma wma(3, age) as three_points_wma"), + context) + + val dividendTwo = Add( + getNthValueAggregation("age", "age", 1, -1), + getNthValueAggregation("age", "age", 2, -1)) + val twoPointsExpression = Divide(dividendTwo, Literal(3)) + + val dividend = Add( + Add( + getNthValueAggregation("age", "age", 1, -2), + getNthValueAggregation("age", "age", 2, -2)), + getNthValueAggregation("age", "age", 3, -2)) + val threePointsExpression = Divide(dividend, Literal(6)) + val trendlineProjectList = Seq( + UnresolvedStar(None), + Alias(twoPointsExpression, "two_points_wma")(), + Alias(threePointsExpression, "three_points_wma")()) + val sortedTable = Sort( + Seq(SortOrder(UnresolvedAttribute("age"), Ascending)), + global = true, + UnresolvedRelation(Seq("relation"))) + + /** + * Expected logical plan: 'Project [*] +- 'Project [*, (( ('nth_value('age, 1) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -1, + * currentrow$())) * 1) + ('nth_value('age, 2) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -1, currentrow$())) * 2)) / 3) AS two_points_wma#0, + * + * ((( ('nth_value('age, 1) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 1) + ('nth_value('age, 2) + * windowspecdefinition('age ASC NULLS FIRST, specifiedwindowframe(RowFrame, -2, + * currentrow$())) * 2)) + ('nth_value('age, 3) windowspecdefinition('age ASC NULLS FIRST, + * specifiedwindowframe(RowFrame, -2, currentrow$())) * 3)) / 6) AS three_points_wma#1] +- + * 'Sort ['age ASC NULLS FIRST], true +- 'UnresolvedRelation [relation], [], false + */ + val expectedPlan = + Project(Seq(UnresolvedStar(None)), Project(trendlineProjectList, sortedTable)) + comparePlans(logPlan, expectedPlan, checkAnalysis = false) + + } + + test("WMA - with negative dataPoint value") { + val context = new CatalystPlanContext + val exception = intercept[SyntaxCheckException]( + planTransformer + .visit(plan(pplParser, "source=relation | trendline sort age wma(-3, age)"), context)) + assert(exception.getMessage startsWith "Failed to parse query due to offending symbol [-]") + } + + private def getNthValueAggregation( + dataField: String, + sortField: String, + lookBackPos: Int, + lookBackRange: Int): Expression = { + Multiply( + WindowExpression( + UnresolvedFunction( + "nth_value", + Seq(UnresolvedAttribute(dataField), Literal(lookBackPos)), + isDistinct = false), + WindowSpecDefinition( + Seq(), + seq(SortUtils.sortOrder(UnresolvedAttribute(sortField), true)), + SpecifiedWindowFrame(RowFrame, Literal(lookBackRange), CurrentRow))), + Literal(lookBackPos)) + } + } From bf60e597456d2a482551b4fbd1b9d0231d88f7c7 Mon Sep 17 00:00:00 2001 From: YANGDB Date: Thu, 14 Nov 2024 10:36:18 -0700 Subject: [PATCH 18/26] local spark ppl testing documentation (#902) * add local spark ppl testing documentation and details Signed-off-by: YANGDB * update more sample test tables and commands Signed-off-by: YANGDB * update more sample test tables and commands Signed-off-by: YANGDB * update more sample test tables and commands Signed-off-by: YANGDB * update for using opensearch-spark-ppl-assembly-x.y.z-SNAPSHOT.jar Signed-off-by: YANGDB * update tutorial documentation on using a local spark-cluster with ppl queries Signed-off-by: YANGDB * typo fix Signed-off-by: YANGDB --------- Signed-off-by: YANGDB --- README.md | 10 +- docs/img/spark-ui.png | Bin 0 -> 392090 bytes docs/ppl-lang/README.md | 5 + .../local-spark-ppl-test-instruction.md | 336 ++++++++++++++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 docs/img/spark-ui.png create mode 100644 docs/ppl-lang/local-spark-ppl-test-instruction.md diff --git a/README.md b/README.md index 592b2645d..12123b456 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Please refer to the [Flint Index Reference Manual](./docs/index.md) for more inf * For additional details on Spark PPL commands project, see [PPL Project](https://github.com/orgs/opensearch-project/projects/214/views/2) +* Experiment ppl queries on local spark cluster[PPL on local spark ](docs/ppl-lang/local-spark-ppl-test-instruction.md) + ## Prerequisites Version compatibility: @@ -75,7 +77,9 @@ To build and run this PPL in Spark, you can run (requires Java 11): ``` sbt clean sparkPPLCosmetic/publishM2 ``` -then add org.opensearch:opensearch-spark-ppl_2.12 when run spark application, for example, + +Then add org.opensearch:opensearch-spark-ppl_2.12 when run spark application, for example, + ``` bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPSHOT" \ --conf "spark.sql.extensions=org.opensearch.flint.spark.FlintPPLSparkExtensions" \ @@ -83,6 +87,10 @@ bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPS ``` +### PPL Run queries on a local spark cluster +See ppl usage sample on local spark cluster[PPL on local spark ](local-spark-ppl-test-instruction.md) + + ## Code of Conduct This project has adopted an [Open Source Code of Conduct](./CODE_OF_CONDUCT.md). diff --git a/docs/img/spark-ui.png b/docs/img/spark-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..dc26062721ce57994d48a2dc33931f223ac0417b GIT binary patch literal 392090 zcmZs?cTiJZ+ci#+qLfgiNePHFk**0PfPe}pMdTJxItYR!fk=nYd#_RqMUY~lD$)s^ z(7O;I^xg?I^e^}G&iBo{&+j*L=KPU4nRE8u*S@ZGt+jTDya?_iigv6gj^NGqo9%fq&VW`(nYcB^HopyISBHS~i3K3|lCogH! zq;k{vzxaPi(~}BJYYMh}CYcj^gN2rzgN1@Nyse;14kPFic;c?)dZze~VK)5E=h4Hr z1HZGJKkQC^`gvU9zWrnRn`V-rU}UcFe_vLY7GD=XgC6PKcCZ~;L`4`k+g^Yi4Dz|2 z>fQd|SCIl|%;k|Gv%^ZM6Rvs>mBq|S8Ai|jft-@IEFg%IlK##^VN7!n71*xBW`6kq zUQndO0V;Yf_up^KHF5_@t1wiCua6W`1-Q2m@F2^U8z@|?MTw3Z6YuCdn8nYy=KR+_Nw`cDy zQXvdq{`*uNx=w|^?Y@)%#hy*1FjWaF#0mh@om_MJkb6HrIJm451UO-S-0y# zmWHJ3#wf2zO_|}#mkgcVN^OmyTHPeCc*1>>8AVTR2eP=k#l>|0dm(RcKD&wZw6FYL z-#d13SkqU#`vB$AHv_t=^?|)YT|&La^#NFe zELd^HSpA?w1l*Rj}J@DEVutj4Z_Y3~BUN*TGQbVQXrU%aQa{HA0`^up3r-p7E#tKlpUp#!J@KIseE3mFxKWW-RC_}@iz?gf=OC!&MEu(6 z@yGC|N^OHR&Z|2HWI*f8IQJ{_|7;|L?)x*Xd;i%CSO#`wn*0HE;%s@7am)rb2?_Sz z)>@Bj2UY35N8xsOQErAb#)|uD#``nBLE9;Upyjhm*xBXkL^mT4I~kY_U9Jj(4*6qX z{m$IbV}7=Y=sMl<7F2ARCNWgjGwG|IUhe!!WO)H)79}64Zkx_>Nluq}AL`g!&XOPH zUH%R8;ZJC%(biWj;!dNM8l^y2K>qh$Cw_4er@-Fp)+6UzP}_l?;<>X+sBPQ}*z_Ty z(h{E2=dY6gAXvu={}oPpxGU&CW@cu~7HaQ2-=u#^y=~mJ!D9JmCfD6-a#UO!xv5h8XU_4Ij=wH z!)4o6l-!ew#V+KiL+z1Pm$@`ZwBq{*JNYe!YE}>S^#sw{LN@KE)ArfU9rD24i^l&< z(?VANVYywWR`Zv(~(#FblO=Hog-I2F^E9hu!jk-3L4eH;O zi1Cb06->L;v&?vW>+{@rD|$z{rTcl*@1G|>@>Gf>^1c#SGmV)DD+AfHylIihM>mo7 zp8wyx4+d!jlol2BJ~t4n%8i(v8jibyJDb=3wRLaA+TwGGA{1>mA~UWFbC2)an3*)VCVc+{GfEBjw$XgM zR07zUyoLNuU0LY7Nr6T#a{{M^;Qv|2_R#*r2@PVBEt%aRq!E*_SQYMX-6e4<7qZ%) z+1R`WjDhcB^D#dZ`pfB}RwokyXV?s$V%TJ{Htb_vP%$y4=TV*`J0C2av(e2dv>P^} z>b`akg>kz2ti^$6}_?VQTA@N#W~lq%s7?x)n0QMOD!~PMK+c_cRF}P&@D}^ z^R1r9{ase}lFYnqk{UUWO7ZL-nZ}oM?}+zESBqXzxl{a4t@yWKoII&gra3)GAbxDa zYW1S^f4=#n5ZsTar6FWh;NJqWbGByN>yR92pn&28LCcOd$BpgVZ`C9wG8z9a=!}ev zY>^tK8ZR+L;p>!E#l^+TU;i|i*!cUi^${08*A>Rnw$<2L)Uc$NOQP~uwD&SG?H^x? z70=cBKo|a;W0xwaCZnnPe?KMnp4gBwpQxRnFsT2i* IaQEdq!})eFjZv*{W-%8Y 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�UJka=cb{+n9pyjqT`nMKR3ul1fQOjjq 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≺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

W0pV8{)P`CzmPLhgI9Bq~sdAUpwfC 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

s*P5R-<%VR+oT%Ihc4w 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 U?kt-Q) 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 literal 0 HcmV?d00001 diff --git a/docs/ppl-lang/README.md b/docs/ppl-lang/README.md index d72c973be..9df9f5986 100644 --- a/docs/ppl-lang/README.md +++ b/docs/ppl-lang/README.md @@ -106,6 +106,11 @@ For additional examples see the next [documentation](PPL-Example-Commands.md). ### Example PPL Queries See samples of [PPL queries](PPL-Example-Commands.md) +--- + +### Experiment PPL locally using Spark-Cluster +See ppl usage sample on local spark cluster[PPL on local spark ](local-spark-ppl-test-instruction.md) + --- ### TPC-H PPL Query Rewriting See samples of [TPC-H PPL query rewriting](ppl-tpch.md) diff --git a/docs/ppl-lang/local-spark-ppl-test-instruction.md b/docs/ppl-lang/local-spark-ppl-test-instruction.md new file mode 100644 index 000000000..537ac043b --- /dev/null +++ b/docs/ppl-lang/local-spark-ppl-test-instruction.md @@ -0,0 +1,336 @@ +# Testing PPL using local Spark + +## Produce the PPL artifact +The first step would be to produce the spark-ppl artifact: `sbt clean sparkPPLCosmetic/assembly` + +The resulting artifact would be located in the project's build directory: +```sql +[info] Built: ./opensearch-spark/sparkPPLCosmetic/target/scala-2.12/opensearch-spark-ppl-assembly-x.y.z-SNAPSHOT.jar +``` +## Downloading spark 3.5.3 version +Download spark from the [official website](https://spark.apache.org/downloads.html) and install locally. + +## Start Spark with the plugin +Once installed, run spark with the generated PPL artifact: +```shell +bin/spark-sql --jars "/PATH_TO_ARTIFACT/opensearch-spark-ppl-assembly-x.y.z-SNAPSHOT.jar" \ +--conf "spark.sql.extensions=org.opensearch.flint.spark.FlintPPLSparkExtensions" \ +--conf "spark.sql.catalog.dev=org.apache.spark.opensearch.catalog.OpenSearchCatalog" \ +--conf "spark.hadoop.hive.cli.print.header=true" + +WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable +Setting default log level to "WARN". +To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel). +WARN HiveConf: HiveConf of name hive.stats.jdbc.timeout does not exist +WARN HiveConf: HiveConf of name hive.stats.retries.wait does not exist +WARN ObjectStore: Version information not found in metastore. hive.metastore.schema.verification is not enabled so recording the schema version 2.3.0 +WARN ObjectStore: setMetaStoreSchemaVersion called but recording version is disabled: version = 2.3.0, comment = Set by MetaStore +Spark Web UI available at http://*.*.*.*:4040 +Spark master: local[*], Application Id: local-1731523264660 + +spark-sql (default)> +``` +The resulting would be a spark-sql prompt: `spark-sql (default)> ...` + +### Spark UI Html +One can also explore spark's UI portal to examine the execution jobs and how they are performing: + +![Spark-UX](../img/spark-ui.png) + + +### Configuring hive partition mode +For simpler configuration of partitioned tables, use the following non-strict mode: + +```shell +spark-sql (default)> SET hive.exec.dynamic.partition.mode = nonstrict; +``` + +--- + +# Testing PPL Commands + +In order to test ppl commands using the spark-sql command line - create and populate the following set of tables: + +## emails table +```sql +CREATE TABLE emails (name STRING, age INT, email STRING, street_address STRING, year INT, month INT) PARTITIONED BY (year, month); +INSERT INTO emails (name, age, email, street_address, year, month) VALUES ('Alice', 30, 'alice@example.com', '123 Main St, Seattle', 2023, 4), ('Bob', 55, 'bob@test.org', '456 Elm St, Portland', 2023, 5), ('Charlie', 65, 'charlie@domain.net', '789 Pine St, San Francisco', 2023, 4), ('David', 19, 'david@anotherdomain.com', '101 Maple St, New York', 2023, 5), ('Eve', 21, 'eve@examples.com', '202 Oak St, Boston', 2023, 4), ('Frank', 76, 'frank@sample.org', '303 Cedar St, Austin', 2023, 5), ('Grace', 41, 'grace@demo.net', '404 Birch St, Chicago', 2023, 4), ('Hank', 32, 'hank@demonstration.com', '505 Spruce St, Miami', 2023, 5), ('Ivy', 9, 'ivy@examples.com', '606 Fir St, Denver', 2023, 4), ('Jack', 12, 'jack@sample.net', '707 Ash St, Seattle', 2023, 5); +``` + +Now one can run the following ppl commands to test functionality: + +### Test `describe` command + +```sql +describe emails; + +col_name data_type comment +name string +age int +email string +street_address string +year int +month int +# Partition Information +# col_name data_type comment +year int +month int + +# Detailed Table Information +Catalog spark_catalog +Database default +Table emails +Owner USER +Created Time Wed Nov 13 14:45:12 MST 2024 +Last Access UNKNOWN +Created By Spark 3.5.3 +Type MANAGED +Provider hive +Table Properties [transient_lastDdlTime=1731534312] +Location file:/Users/USER/tools/spark-3.5.3-bin-hadoop3/bin/spark-warehouse/emails +Serde Library org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe +InputFormat org.apache.hadoop.mapred.TextInputFormat +OutputFormat org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat +Storage Properties [serialization.format=1] +Partition Provider Catalog + +Time taken: 0.128 seconds, Fetched 28 row(s) +``` + +### Test `grok` command +```sql +source=emails| grok email '.+@%{HOSTNAME:host}' | fields email, host; + +email host +hank@demonstration.com demonstration.com +bob@test.org test.org +jack@sample.net sample.net +frank@sample.org sample.org +david@anotherdomain.com anotherdomain.com +grace@demo.net demo.net +alice@example.com example.com +ivy@examples.com examples.com +eve@examples.com examples.com +charlie@domain.net domain.net + +Time taken: 0.626 seconds, Fetched 10 row(s) +``` + +```sql + source=emails| parse email '.+@(?.+)' | where age > 45 | sort - age | fields age, email, host; + +age email host +76 frank@sample.org sample.org +65 charlie@domain.net domain.net +55 bob@test.org test.org + +Time taken: 1.555 seconds, Fetched 3 row(s) +``` + +### Test `grok` | `top` commands combination +```sql +source=emails| grok email '.+@%{HOSTNAME:host}' | fields email, host | top 3 host; + +count_host host +2 examples.com +1 demonstration.com +1 test.org + +Time taken: 1.274 seconds, Fetched 3 row(s) +``` + +### Test `fieldsummary` command + +```sql +source=emails| fieldsummary includefields=age, email; + +Field COUNT DISTINCT MIN MAX AVG MEAN STDDEV Nulls TYPEOF +age 10 10 9 76 36.0 36.0 22.847319317591726 0 int +email 10 10 alice@example.com jack@sample.net NULL NULL NULL 0 string + +Time taken: 1.535 seconds, Fetched 2 row(s) +``` + +### Test `trendline` command + +```sql +source=email | sort - age | trendline sma(2, age); + +name age email street_address year month age_trendline +Frank 76 frank@sample.org 303 Cedar St, Austin 2023 5 NULL +Charlie 65 charlie@domain.net 789 Pine St, San Francisco 2023 4 70.5 +Bob 55 bob@test.org 456 Elm St, Portland 2023 5 60.0 +Grace 41 grace@demo.net 404 Birch St, Chicago 2023 4 48.0 +Hank 32 hank@demonstration.com 505 Spruce St, Miami 2023 5 36.5 +Alice 30 alice@example.com 123 Main St, Seattle 2023 4 31.0 +Eve 21 eve@examples.com 202 Oak St, Boston 2023 4 25.5 +David 19 david@anotherdomain.com 101 Maple St, New York 2023 5 20.0 +Jack 12 jack@sample.net 707 Ash St, Seattle 2023 5 15.5 +Ivy 9 ivy@examples.com 606 Fir St, Denver 2023 4 10.5 + +Time taken: 1.048 seconds, Fetched 10 row(s) +``` +### Test `expand` command + +```sql + +source=emails | eval array=json_array(1, 2 ) | expand array as uid | fields uid, name, age, email; + +uid name age email +1 Hank 32 hank@demonstration.com +2 Hank 32 hank@demonstration.com +1 Bob 55 bob@test.org +2 Bob 55 bob@test.org +1 Jack 12 jack@sample.net +2 Jack 12 jack@sample.net +1 Frank 76 frank@sample.org +2 Frank 76 frank@sample.org +1 David 19 david@anotherdomain.com +2 David 19 david@anotherdomain.com +1 Grace 41 grace@demo.net +2 Grace 41 grace@demo.net +1 Alice 30 alice@example.com +2 Alice 30 alice@example.com +1 Ivy 9 ivy@examples.com +2 Ivy 9 ivy@examples.com +1 Eve 21 eve@examples.com +2 Eve 21 eve@examples.com +1 Charlie 65 charlie@domain.net +2 Charlie 65 charlie@domain.net + +Time taken: 0.495 seconds, Fetched 20 row(s) +``` + +## nested table + +```sql +CREATE TABLE nested (int_col INT, struct_col STRUCT, field2: INT>, struct_col2 STRUCT, field2: INT>) USING JSON; +INSERT INTO nested SELECT /*+ COALESCE(1) */ * from VALUES ( 30, STRUCT(STRUCT("value1"),123), STRUCT(STRUCT("valueA"),23) ), ( 40, STRUCT(STRUCT("value5"),123), STRUCT(STRUCT("valueB"),33) ), ( 30, STRUCT(STRUCT("value4"),823), STRUCT(STRUCT("valueC"),83) ), ( 40, STRUCT(STRUCT("value2"),456), STRUCT(STRUCT("valueD"),46) ), ( 50, STRUCT(STRUCT("value3"),789), STRUCT(STRUCT("valueE"),89) ); +``` + +### Test `flatten` command + +```sql +source=nested | flatten struct_col | flatten field1 | flatten struct_col2; + +int_col field2 subfield field1 field2 +30 123 value1 {"subfield":"valueA"} 23 +40 123 value5 {"subfield":"valueB"} 33 +30 823 value4 {"subfield":"valueC"} 83 +40 456 value2 {"subfield":"valueD"} 46 +50 789 value3 {"subfield":"valueE"} 89 +30 123 value1 {"subfield":"valueA"} 23 + +Time taken: 2.682 seconds, Fetched 6 row(s) +``` + +```sql +source=nested| where struct_col.field2 > 200 | sort - struct_col.field2 | fields int_col, struct_col.field2; + +int_col field2 +30 823 +50 789 +40 456 + +Time taken: 0.722 seconds, Fetched 3 row(s) +``` + +## array table + +```sql +CREATE TABLE arrayTable (int_col INT, multi_valueA ARRAY>, multi_valueB ARRAY>) USING JSON; +INSERT INTO arrayTable VALUES (1, array(STRUCT("1_one", 1), STRUCT(null, 11), STRUCT("1_three", null)), array(STRUCT("2_Monday", 2), null)), (2, array(STRUCT("2_Monday", 2), null), array(STRUCT("3_third", 3), STRUCT("3_4th", 4))), (3, array(STRUCT("3_third", 3), STRUCT("3_4th", 4)), array(STRUCT("1_one", 1))), (4, null, array(STRUCT("1_one", 1))); +``` + +### Test `expand` command + +```sql +source=arrayTable | expand multi_valueA as multiA | expand multi_valueB as multiB; + +int_col multiA multiB +1 {"name":"1_one","value":1} {"name":"2_Monday","value":2} +1 {"name":"1_one","value":1} NULL +1 {"name":null,"value":11} {"name":"2_Monday","value":2} +1 {"name":null,"value":11} NULL +1 {"name":"1_three","value":null} {"name":"2_Monday","value":2} +1 {"name":"1_three","value":null} NULL +2 {"name":"2_Monday","value":2} {"name":"3_third","value":3} +2 {"name":"2_Monday","value":2} {"name":"3_4th","value":4} +2 NULL {"name":"3_third","value":3} +2 NULL {"name":"3_4th","value":4} +3 {"name":"3_third","value":3} {"name":"1_one","value":1} +3 {"name":"3_4th","value":4} {"name":"1_one","value":1} + +Time taken: 0.173 seconds, Fetched 12 row(s) +``` + +### Test `expand` | `flattern` command combination + +```sql +source=arrayTable | flatten multi_valueA | expand multi_valueB; + +int_col multi_valueB name value col +1 [{"name":"2_Monday","value":2},null] 1_one 1 {"name":"2_Monday","value":2} +1 [{"name":"2_Monday","value":2},null] 1_one 1 NULL +1 [{"name":"2_Monday","value":2},null] NULL 11 {"name":"2_Monday","value":2} +1 [{"name":"2_Monday","value":2},null] NULL 11 NULL +1 [{"name":"2_Monday","value":2},null] 1_three NULL {"name":"2_Monday","value":2} +1 [{"name":"2_Monday","value":2},null] 1_three NULL NULL +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] 2_Monday 2 {"name":"3_third","value":3} +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] 2_Monday 2 {"name":"3_4th","value":4} +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] NULL NULL {"name":"3_third","value":3} +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] NULL NULL {"name":"3_4th","value":4} +3 [{"name":"1_one","value":1}] 3_third 3 {"name":"1_one","value":1} +3 [{"name":"1_one","value":1}] 3_4th 4 {"name":"1_one","value":1} +4 [{"name":"1_one","value":1}] NULL NULL {"name":"1_one","value":1} + +Time taken: 0.12 seconds, Fetched 13 row(s) +``` +### Test `fillnull` | `flattern` command combination + +```sql +source=arrayTable | flatten multi_valueA | fillnull with '1_zero' in name; + +int_col multi_valueB value name +1 [{"name":"2_Monday","value":2},null] 1 1_one +1 [{"name":"2_Monday","value":2},null] 11 1_zero +1 [{"name":"2_Monday","value":2},null] NULL 1_three +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] 2 2_Monday +2 [{"name":"3_third","value":3},{"name":"3_4th","value":4}] NULL 1_zero +3 [{"name":"1_one","value":1}] 3 3_third +3 [{"name":"1_one","value":1}] 4 3_4th +4 [{"name":"1_one","value":1}] NULL 1_zero + +Time taken: 0.111 seconds, Fetched 8 row(s) +``` +## ip table + +```sql +CREATE TABLE ipTable ( id INT,ipAddress STRING,isV6 BOOLEAN, isValid BOOLEAN) using csv OPTIONS (header 'false',delimiter '\\t'); +INSERT INTO ipTable values (1, '127.0.0.1', false, true), (2, '192.168.1.0', false, true),(3, '192.168.1.1', false, true),(4, '192.168.2.1', false, true), (5, '192.168.2.', false, false),(6, '2001:db8::ff00:12:3455', true, true),(7, '2001:db8::ff00:12:3456', true, true),(8, '2001:db8::ff00:13:3457', true, true), (9, '2001:db8::ff00:12:', true, false); +``` + +### Test `cidr` command + +```sql +source=ipTable | where isV6 = false and isValid = true and cidrmatch(ipAddress, '192.168.1.0/24'); + +id ipAddress isV6 isValid +2 192.168.1.0 false true +3 192.168.1.1 false true + +Time taken: 0.317 seconds, Fetched 2 row(s) +``` + +```sql +source=ipTable | where isV6 = true and isValid = true and cidrmatch(ipAddress, '2001:db8::/32'); + +id ipAddress isV6 isValid +6 2001:db8::ff00:12:3455 true true +8 2001:db8::ff00:13:3457 true true +7 2001:db8::ff00:12:3456 true true + +Time taken: 0.09 seconds, Fetched 3 row(s) +``` + +--- \ No newline at end of file From f8a750181b2e41dbb108fc7a73550085cfbb502d Mon Sep 17 00:00:00 2001 From: YANGDB Date: Thu, 14 Nov 2024 15:29:11 -0700 Subject: [PATCH 19/26] replace loading of grok patterns from the resources folder in favour of hard coded java map (#906) Signed-off-by: YANGDB --- .../sql/common/grok/DefaultPatterns.java | 89 +++++++++++++++++++ .../sql/common/grok/GrokCompiler.java | 78 +--------------- .../opensearch/sql/ppl/utils/ParseUtils.java | 4 - ...PLLogicalPlanGrokTranslatorTestSuite.scala | 1 - 4 files changed, 92 insertions(+), 80 deletions(-) create mode 100644 ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/DefaultPatterns.java diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/DefaultPatterns.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/DefaultPatterns.java new file mode 100644 index 000000000..411542fb4 --- /dev/null +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/DefaultPatterns.java @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.sql.common.grok; + +import java.util.Map; + +public interface DefaultPatterns { + + /** + * populate map with default patterns as they appear under the '/resources/patterns/*' resource folder + */ + static Map withDefaultPatterns(Map patterns) { + patterns.put("PATH" , "(?:%{UNIXPATH}|%{WINPATH})"); + patterns.put("MONTH" , "\\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\b"); + patterns.put("TZ" , "(?:[PMCE][SD]T|UTC)"); + patterns.put("DATESTAMP_OTHER" , "%{DAY} %{MONTH} %{MONTHDAY} %{TIME} %{TZ} %{YEAR}"); + patterns.put("HTTPDATE" , "%{MONTHDAY}/%{MONTH}/%{YEAR}:%{TIME} %{INT}"); + patterns.put("HOST" , "%{HOSTNAME:UNWANTED}"); + patterns.put("DATESTAMP_EVENTLOG" , "%{YEAR}%{MONTHNUM2}%{MONTHDAY}%{HOUR}%{MINUTE}%{SECOND}"); + patterns.put("MESSAGESLOG" , "%{SYSLOGBASE} %{DATA}"); + patterns.put("WINDOWSMAC" , "(?:(?:[A-Fa-f0-9]{2}-){5}[A-Fa-f0-9]{2})"); + patterns.put("YEAR" , "(?>\\d\\d){1,2}"); + patterns.put("POSINT" , "\\b(?:[1-9][0-9]*)\\b"); + patterns.put("USERNAME" , "[a-zA-Z0-9._-]+"); + patterns.put("MINUTE" , "(?:[0-5][0-9])"); + patterns.put("UUID" , "[A-Fa-f0-9]{8}-(?:[A-Fa-f0-9]{4}-){3}[A-Fa-f0-9]{12}"); + patterns.put("DATE_US" , "%{MONTHNUM}[/-]%{MONTHDAY}[/-]%{YEAR}"); + patterns.put("LOGLEVEL" , "([A|a]lert|ALERT|[T|t]race|TRACE|[D|d]ebug|DEBUG|[N|n]otice|NOTICE|[I|i]nfo|INFO|[W|w]arn?(?:ing)?|WARN?(?:ING)?|[E|e]rr?(?:or)?|ERR?(?:OR)?|[C|c]rit?(?:ical)?|CRIT?(?:ICAL)?|[F|f]atal|FATAL|[S|s]evere|SEVERE|EMERG(?:ENCY)?|[Ee]merg(?:ency)?)"); + patterns.put("WINPATH" , "(?>[A-Za-z]+:|\\)(?:\\[^\\?*]*)+"); + patterns.put("NUMBER" , "(?:%{BASE10NUM:UNWANTED})"); + patterns.put("WORD" , "\\b\\w+\\b"); + patterns.put("QS" , "%{QUOTEDSTRING:UNWANTED}"); + patterns.put("TIMESTAMP_ISO8601" , "%{YEAR}-%{MONTHNUM}-%{MONTHDAY}[T ]%{HOUR}:?%{MINUTE}(?::?%{SECOND})?%{ISO8601_TIMEZONE}?"); + patterns.put("MONTHNUM" , "(?:0?[1-9]|1[0-2])"); + patterns.put("NOTSPACE" , "\\S+"); + patterns.put("IPV6" , "((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?"); + patterns.put("IPV4" , "(?[+-]?(?:(?:[0-9]+(?:\\.[0-9]+)?)|(?:\\.[0-9]+)))"); + patterns.put("NONNEGINT" , "\\b(?:[0-9]+)\\b"); + patterns.put("DATESTAMP_RFC822" , "%{DAY} %{MONTH} %{MONTHDAY} %{YEAR} %{TIME} %{TZ}"); + patterns.put("URI" , "%{URIPROTO}://(?:%{USER}(?::[^@]*)?@)?(?:%{URIHOST})?(?:%{URIPATHPARAM})?"); + patterns.put("INT" , "(?:[+-]?(?:[0-9]+))"); + patterns.put("SPACE" , "\\s*"); + patterns.put("GREEDYDATA" , ".*"); + patterns.put("ISO8601_SECOND" , "(?:%{SECOND}|60)"); + patterns.put("UNIXPATH" , "(?>/(?>[\\w_%!$@:.,~-]+|\\.)*)+"); + patterns.put("TTY" , "(?:/dev/(pts|tty([pq])?)(\\w+)?/?(?:[0-9]+))"); + patterns.put("COMBINEDAPACHELOG" , "%{COMMONAPACHELOG} %{QS:referrer} %{QS:agent}"); + patterns.put("URIPROTO" , "[A-Za-z]+(\\+[A-Za-z+]+)?"); + patterns.put("HOSTPORT" , "(?:%{IPORHOST}:%{POSINT:PORT})"); + patterns.put("SYSLOGPROG" , "%{PROG:program}(?:\\[%{POSINT:pid}\\])?"); + patterns.put("SYSLOGBASE" , "%{SYSLOGTIMESTAMP:timestamp} (?:%{SYSLOGFACILITY} )?%{SYSLOGHOST:logsource} %{SYSLOGPROG}:"); + patterns.put("SYSLOGFACILITY" , "<%{NONNEGINT:facility}.%{NONNEGINT:priority}>"); + patterns.put("DATESTAMP" , "%{DATE}[- ]%{TIME}"); + patterns.put("TIME" , "(?!<[0-9])%{HOUR}:%{MINUTE}(?::%{SECOND})(?![0-9])"); + patterns.put("USER" , "%{USERNAME:UNWANTED}"); + patterns.put("COMMONMAC" , "(?:(?:[A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2})"); + patterns.put("IPORHOST" , "(?:%{HOSTNAME:UNWANTED}|%{IP:UNWANTED})"); + patterns.put("BASE16NUM" , "(?(?\"(?>\\\\.|[^\\\\\"]+)+\"|\"\"|(?>'(?>\\\\.|[^\\\\']+)+')|''|(?>`(?>\\\\.|[^\\\\`]+)+`)|``))"); + patterns.put("DAY" , "(?:Mon(?:day)?|Tue(?:sday)?|Wed(?:nesday)?|Thu(?:rsday)?|Fri(?:day)?|Sat(?:urday)?|Sun(?:day)?)"); + patterns.put("ISO8601_TIMEZONE" , "(?:Z|[+-]%{HOUR}(?::?%{MINUTE}))"); + patterns.put("PROG" , "(?:[\\w._/%-]+)"); + return patterns; + } +} diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/GrokCompiler.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/GrokCompiler.java index 7d51038cd..b9dd2df83 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/GrokCompiler.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/common/grok/GrokCompiler.java @@ -26,14 +26,12 @@ import java.util.regex.Pattern; import static java.lang.String.format; +import static org.opensearch.sql.common.grok.DefaultPatterns.withDefaultPatterns; public class GrokCompiler implements Serializable { - - // We don't want \n and commented line - private static final Pattern patternLinePattern = Pattern.compile("^([A-z0-9_]+)\\s+(.*)$"); - + /** {@code Grok} patterns definitions. */ - private final Map grokPatternDefinitions = new HashMap<>(); + private final Map grokPatternDefinitions = withDefaultPatterns(new HashMap<>()); private GrokCompiler() {} @@ -41,76 +39,6 @@ public static GrokCompiler newInstance() { return new GrokCompiler(); } - public Map getPatternDefinitions() { - return grokPatternDefinitions; - } - - /** - * Registers a new pattern definition. - * - * @param name : Pattern Name - * @param pattern : Regular expression Or {@code Grok} pattern - * @throws GrokException runtime expt - */ - public void register(String name, String pattern) { - name = Objects.requireNonNull(name).trim(); - pattern = Objects.requireNonNull(pattern).trim(); - - if (!name.isEmpty() && !pattern.isEmpty()) { - grokPatternDefinitions.put(name, pattern); - } - } - - /** Registers multiple pattern definitions. */ - public void register(Map patternDefinitions) { - Objects.requireNonNull(patternDefinitions); - patternDefinitions.forEach(this::register); - } - - /** - * Registers multiple pattern definitions from a given inputStream, and decoded as a UTF-8 source. - */ - public void register(InputStream input) throws IOException { - register(input, StandardCharsets.UTF_8); - } - - /** Registers multiple pattern definitions from a given inputStream. */ - public void register(InputStream input, Charset charset) throws IOException { - try (BufferedReader in = new BufferedReader(new InputStreamReader(input, charset))) { - in.lines() - .map(patternLinePattern::matcher) - .filter(Matcher::matches) - .forEach(m -> register(m.group(1), m.group(2))); - } - } - - /** Registers multiple pattern definitions from a given Reader. */ - public void register(Reader input) throws IOException { - new BufferedReader(input) - .lines() - .map(patternLinePattern::matcher) - .filter(Matcher::matches) - .forEach(m -> register(m.group(1), m.group(2))); - } - - public void registerDefaultPatterns() { - registerPatternFromClasspath("/patterns/patterns"); - } - - public void registerPatternFromClasspath(String path) throws GrokException { - registerPatternFromClasspath(path, StandardCharsets.UTF_8); - } - - /** registerPatternFromClasspath. */ - public void registerPatternFromClasspath(String path, Charset charset) throws GrokException { - final InputStream inputStream = this.getClass().getResourceAsStream(path); - try (Reader reader = new InputStreamReader(inputStream, charset)) { - register(reader); - } catch (IOException e) { - throw new GrokException(e.getMessage(), e); - } - } - /** Compiles a given Grok pattern and returns a Grok object which can parse the pattern. */ public Grok compile(String pattern) throws IllegalArgumentException { return compile(pattern, false); diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/ParseUtils.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/ParseUtils.java index a463767f0..6a4d4b032 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/ParseUtils.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/ParseUtils.java @@ -138,10 +138,6 @@ public static String extractPattern(String patterns, List columns) { public static class GrokExpression { private static final GrokCompiler grokCompiler = GrokCompiler.newInstance(); - static { - grokCompiler.registerDefaultPatterns(); - } - public static Expression getRegExpCommand(Expression sourceField, org.apache.spark.sql.catalyst.expressions.Literal patternLiteral, org.apache.spark.sql.catalyst.expressions.Literal groupIndexLiteral) { return new RegExpExtract(sourceField, patternLiteral, groupIndexLiteral); } diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanGrokTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanGrokTranslatorTestSuite.scala index f33a4a66b..91da923de 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanGrokTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanGrokTranslatorTestSuite.scala @@ -30,7 +30,6 @@ class PPLLogicalPlanGrokTranslatorTestSuite test("test grok email & host expressions") { val grokCompiler = GrokCompiler.newInstance - grokCompiler.registerDefaultPatterns() /* Grok pattern to compile, here httpd logs */ /* Grok pattern to compile, here httpd logs */ val grok = grokCompiler.compile(".+@%{HOSTNAME:host}") From 4f58bc8e08a49ce39a55ca442a448e0e75385e6b Mon Sep 17 00:00:00 2001 From: Louis Chu Date: Thu, 14 Nov 2024 21:54:56 -0800 Subject: [PATCH 20/26] Support UnrecoverableException (#898) * Support UnrecoverableException Signed-off-by: Louis Chu * Add UT and IT Signed-off-by: Louis Chu --------- Signed-off-by: Louis Chu --- .../exception/UnrecoverableException.scala | 25 +++ .../common/model/InteractiveSession.scala | 4 +- .../apache/spark/sql/FlintREPLITSuite.scala | 61 +++++- .../apache/spark/sql/FlintJobExecutor.scala | 41 +++-- .../org/apache/spark/sql/FlintREPL.scala | 81 +++++--- .../org/apache/spark/sql/JobOperator.scala | 57 +++--- .../spark/sql/util/ThrowableHandler.scala | 41 +++++ .../org/apache/spark/sql/FlintREPLTest.scala | 174 ++++++++++++++++-- 8 files changed, 396 insertions(+), 88 deletions(-) create mode 100644 flint-commons/src/main/scala/org/apache/spark/sql/exception/UnrecoverableException.scala create mode 100644 spark-sql-application/src/main/scala/org/apache/spark/sql/util/ThrowableHandler.scala diff --git a/flint-commons/src/main/scala/org/apache/spark/sql/exception/UnrecoverableException.scala b/flint-commons/src/main/scala/org/apache/spark/sql/exception/UnrecoverableException.scala new file mode 100644 index 000000000..c23178f00 --- /dev/null +++ b/flint-commons/src/main/scala/org/apache/spark/sql/exception/UnrecoverableException.scala @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.apache.spark.sql.exception + +/** + * Represents an unrecoverable exception in session management and statement execution. This + * exception is used for errors that cannot be handled or recovered from. + */ +class UnrecoverableException private (message: String, cause: Throwable) + extends RuntimeException(message, cause) { + + def this(cause: Throwable) = + this(cause.getMessage, cause) +} + +object UnrecoverableException { + def apply(cause: Throwable): UnrecoverableException = + new UnrecoverableException(cause) + + def apply(message: String, cause: Throwable): UnrecoverableException = + new UnrecoverableException(message, cause) +} diff --git a/flint-commons/src/main/scala/org/opensearch/flint/common/model/InteractiveSession.scala b/flint-commons/src/main/scala/org/opensearch/flint/common/model/InteractiveSession.scala index 915d5e229..16c9747d9 100644 --- a/flint-commons/src/main/scala/org/opensearch/flint/common/model/InteractiveSession.scala +++ b/flint-commons/src/main/scala/org/opensearch/flint/common/model/InteractiveSession.scala @@ -52,7 +52,7 @@ class InteractiveSession( val lastUpdateTime: Long, val jobStartTime: Long = 0, val excludedJobIds: Seq[String] = Seq.empty[String], - val error: Option[String] = None, + var error: Option[String] = None, sessionContext: Map[String, Any] = Map.empty[String, Any]) extends ContextualDataStore with Logging { @@ -72,7 +72,7 @@ class InteractiveSession( val excludedJobIdsStr = excludedJobIds.mkString("[", ", ", "]") val errorStr = error.getOrElse("None") // Does not include context, which could contain sensitive information. - s"FlintInstance(applicationId=$applicationId, jobId=$jobId, sessionId=$sessionId, state=$state, " + + s"InteractiveSession(applicationId=$applicationId, jobId=$jobId, sessionId=$sessionId, state=$state, " + s"lastUpdateTime=$lastUpdateTime, jobStartTime=$jobStartTime, excludedJobIds=$excludedJobIdsStr, error=$errorStr)" } } diff --git a/integ-test/src/integration/scala/org/apache/spark/sql/FlintREPLITSuite.scala b/integ-test/src/integration/scala/org/apache/spark/sql/FlintREPLITSuite.scala index 1ddfa540b..51bcf8e40 100644 --- a/integ-test/src/integration/scala/org/apache/spark/sql/FlintREPLITSuite.scala +++ b/integ-test/src/integration/scala/org/apache/spark/sql/FlintREPLITSuite.scala @@ -17,15 +17,49 @@ import org.opensearch.OpenSearchStatusException import org.opensearch.flint.OpenSearchSuite import org.opensearch.flint.common.model.{FlintStatement, InteractiveSession} import org.opensearch.flint.core.{FlintClient, FlintOptions} -import org.opensearch.flint.core.storage.{FlintOpenSearchClient, FlintReader, OpenSearchUpdater} -import org.opensearch.search.sort.SortOrder +import org.opensearch.flint.core.storage.{FlintOpenSearchClient, OpenSearchUpdater} import org.apache.spark.SparkFunSuite import org.apache.spark.sql.FlintREPLConfConstants.DEFAULT_QUERY_LOOP_EXECUTION_FREQUENCY -import org.apache.spark.sql.flint.config.FlintSparkConf.{DATA_SOURCE_NAME, EXCLUDE_JOB_IDS, HOST_ENDPOINT, HOST_PORT, JOB_TYPE, REFRESH_POLICY, REPL_INACTIVITY_TIMEOUT_MILLIS, REQUEST_INDEX, SESSION_ID} +import org.apache.spark.sql.exception.UnrecoverableException +import org.apache.spark.sql.flint.config.FlintSparkConf.{CUSTOM_STATEMENT_MANAGER, DATA_SOURCE_NAME, EXCLUDE_JOB_IDS, HOST_ENDPOINT, HOST_PORT, JOB_TYPE, REFRESH_POLICY, REPL_INACTIVITY_TIMEOUT_MILLIS, REQUEST_INDEX, SESSION_ID} import org.apache.spark.sql.util.MockEnvironment import org.apache.spark.util.ThreadUtils +/** + * A StatementExecutionManagerImpl that throws UnrecoverableException during statement execution. + * Used for testing error handling in FlintREPL. + */ +class FailingStatementExecutionManager( + private var spark: SparkSession, + private var sessionId: String) + extends StatementExecutionManager { + + def this() = { + this(null, null) + } + + override def prepareStatementExecution(): Either[String, Unit] = { + throw UnrecoverableException(new RuntimeException("Simulated execution failure")) + } + + override def executeStatement(statement: FlintStatement): DataFrame = { + throw UnrecoverableException(new RuntimeException("Simulated execution failure")) + } + + override def getNextStatement(): Option[FlintStatement] = { + throw UnrecoverableException(new RuntimeException("Simulated execution failure")) + } + + override def updateStatement(statement: FlintStatement): Unit = { + throw UnrecoverableException(new RuntimeException("Simulated execution failure")) + } + + override def terminateStatementExecution(): Unit = { + throw UnrecoverableException(new RuntimeException("Simulated execution failure")) + } +} + class FlintREPLITSuite extends SparkFunSuite with OpenSearchSuite with JobTest { var flintClient: FlintClient = _ @@ -584,6 +618,27 @@ class FlintREPLITSuite extends SparkFunSuite with OpenSearchSuite with JobTest { } } + test("REPL should handle unrecoverable exception from statement execution") { + // Note: This test sharing system property with other test cases so cannot run alone + System.setProperty( + CUSTOM_STATEMENT_MANAGER.key, + "org.apache.spark.sql.FailingStatementExecutionManager") + try { + createSession(jobRunId, "") + FlintREPL.main(Array(resultIndex)) + fail("The REPL should throw an unrecoverable exception, but it succeeded instead.") + } catch { + case ex: UnrecoverableException => + assert( + ex.getMessage.contains("Simulated execution failure"), + s"Unexpected exception message: ${ex.getMessage}") + case ex: Throwable => + fail(s"Unexpected exception type: ${ex.getClass} with message: ${ex.getMessage}") + } finally { + System.setProperty(CUSTOM_STATEMENT_MANAGER.key, "") + } + } + /** * JSON does not support raw newlines (\n) in string values. All newlines must be escaped or * removed when inside a JSON string. The same goes for tab characters, which should be diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala index 8e037a53e..63c120a2c 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintJobExecutor.scala @@ -22,6 +22,7 @@ import org.apache.spark.{SparkConf, SparkException} import org.apache.spark.internal.Logging import org.apache.spark.sql.SparkConfConstants.{DEFAULT_SQL_EXTENSIONS, SQL_EXTENSIONS_KEY} import org.apache.spark.sql.catalyst.parser.ParseException +import org.apache.spark.sql.exception.UnrecoverableException import org.apache.spark.sql.flint.config.FlintSparkConf import org.apache.spark.sql.flint.config.FlintSparkConf.REFRESH_POLICY import org.apache.spark.sql.types._ @@ -44,12 +45,13 @@ trait FlintJobExecutor { this: Logging => val mapper = new ObjectMapper() + val throwableHandler = new ThrowableHandler() var currentTimeProvider: TimeProvider = new RealTimeProvider() var threadPoolFactory: ThreadPoolFactory = new DefaultThreadPoolFactory() var environmentProvider: EnvironmentProvider = new RealEnvironment() var enableHiveSupport: Boolean = true - // termiante JVM in the presence non-deamon thread before exiting + // terminate JVM in the presence non-daemon thread before exiting var terminateJVM = true // The enabled setting, which can be applied only to the top-level mapping definition and to object fields, @@ -435,11 +437,13 @@ trait FlintJobExecutor { } private def handleQueryException( - e: Exception, + t: Throwable, messagePrefix: String, errorSource: Option[String] = None, statusCode: Option[Int] = None): String = { - val errorMessage = s"$messagePrefix: ${e.getMessage}" + throwableHandler.setThrowable(t) + + val errorMessage = s"$messagePrefix: ${t.getMessage}" val errorDetails = new java.util.LinkedHashMap[String, String]() errorDetails.put("Message", errorMessage) errorSource.foreach(es => errorDetails.put("ErrorSource", es)) @@ -450,25 +454,25 @@ trait FlintJobExecutor { // CustomLogging will call log4j logger.error() underneath statusCode match { case Some(code) => - CustomLogging.logError(new OperationMessage(errorMessage, code), e) + CustomLogging.logError(new OperationMessage(errorMessage, code), t) case None => - CustomLogging.logError(errorMessage, e) + CustomLogging.logError(errorMessage, t) } errorJson } - def getRootCause(e: Throwable): Throwable = { - if (e.getCause == null) e - else getRootCause(e.getCause) + def getRootCause(t: Throwable): Throwable = { + if (t.getCause == null) t + else getRootCause(t.getCause) } /** * This method converts query exception into error string, which then persist to query result * metadata */ - def processQueryException(ex: Exception): String = { - getRootCause(ex) match { + def processQueryException(throwable: Throwable): String = { + getRootCause(throwable) match { case r: ParseException => handleQueryException(r, ExceptionMessages.SyntaxErrorPrefix) case r: AmazonS3Exception => @@ -495,15 +499,15 @@ trait FlintJobExecutor { handleQueryException(r, ExceptionMessages.QueryAnalysisErrorPrefix) case r: SparkException => handleQueryException(r, ExceptionMessages.SparkExceptionErrorPrefix) - case r: Exception => - val rootCauseClassName = r.getClass.getName - val errMsg = r.getMessage + case t: Throwable => + val rootCauseClassName = t.getClass.getName + val errMsg = t.getMessage if (rootCauseClassName == "org.apache.hadoop.hive.metastore.api.MetaException" && errMsg.contains("com.amazonaws.services.glue.model.AccessDeniedException")) { val e = new SecurityException(ExceptionMessages.GlueAccessDeniedMessage) handleQueryException(e, ExceptionMessages.QueryRunErrorPrefix) } else { - handleQueryException(r, ExceptionMessages.QueryRunErrorPrefix) + handleQueryException(t, ExceptionMessages.QueryRunErrorPrefix) } } } @@ -532,6 +536,14 @@ trait FlintJobExecutor { throw t } + def checkAndThrowUnrecoverableExceptions(): Unit = { + throwableHandler.exceptionThrown.foreach { + case e: UnrecoverableException => + throw e + case _ => // Do nothing for other types of exceptions + } + } + def instantiate[T](defaultConstructor: => T, className: String, args: Any*): T = { if (Strings.isNullOrEmpty(className)) { defaultConstructor @@ -551,5 +563,4 @@ trait FlintJobExecutor { } } } - } diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala index 9b6ff4ff6..6d7dcc0e7 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/FlintREPL.scala @@ -187,9 +187,9 @@ object FlintREPL extends Logging with FlintJobExecutor { } recordSessionSuccess(sessionTimerContext) } catch { - case e: Exception => + case t: Throwable => handleSessionError( - e, + t, applicationId, jobId, sessionId, @@ -204,6 +204,10 @@ object FlintREPL extends Logging with FlintJobExecutor { stopTimer(sessionTimerContext) spark.stop() + // After handling any exceptions from stopping the Spark session, + // check if there's a stored exception and throw it if it's an UnrecoverableException + checkAndThrowUnrecoverableExceptions() + // Check for non-daemon threads that may prevent the driver from shutting down. // Non-daemon threads other than the main thread indicate that the driver is still processing tasks, // which may be due to unresolved bugs in dependencies or threads not being properly shut down. @@ -356,6 +360,11 @@ object FlintREPL extends Logging with FlintJobExecutor { verificationResult = updatedVerificationResult canPickUpNextStatement = updatedCanPickUpNextStatement lastCanPickCheckTime = updatedLastCanPickCheckTime + } catch { + case t: Throwable => + // Record and rethrow in query loop + throwableHandler.recordThrowable(s"Query loop execution failed.", t) + throw t } finally { statementsExecutionManager.terminateStatementExecution() } @@ -412,32 +421,40 @@ object FlintREPL extends Logging with FlintJobExecutor { error = error, excludedJobIds = excludedJobIds)) logInfo(s"Current session: ${sessionDetails}") - logInfo(s"State is: ${sessionDetails.state}") sessionDetails.state = state - logInfo(s"State is: ${sessionDetails.state}") + sessionDetails.error = error sessionManager.updateSessionDetails(sessionDetails, updateMode = UPSERT) + logInfo(s"Updated session: ${sessionDetails}") sessionDetails } def handleSessionError( - e: Exception, + t: Throwable, applicationId: String, jobId: String, sessionId: String, sessionManager: SessionManager, jobStartTime: Long, sessionTimerContext: Timer.Context): Unit = { - val error = s"Session error: ${e.getMessage}" - CustomLogging.logError(error, e) + val error = s"Session error: ${t.getMessage}" + throwableHandler.recordThrowable(error, t) + + try { + refreshSessionState( + applicationId, + jobId, + sessionId, + sessionManager, + jobStartTime, + SessionStates.FAIL, + Some(error)) + } catch { + case t: Throwable => + throwableHandler.recordThrowable( + s"Failed to update session state. Original error: $error", + t) + } - refreshSessionState( - applicationId, - jobId, - sessionId, - sessionManager, - jobStartTime, - SessionStates.FAIL, - Some(e.getMessage)) recordSessionFailed(sessionTimerContext) } @@ -485,8 +502,8 @@ object FlintREPL extends Logging with FlintJobExecutor { startTime) } - def processQueryException(ex: Exception, flintStatement: FlintStatement): String = { - val error = super.processQueryException(ex) + def processQueryException(t: Throwable, flintStatement: FlintStatement): String = { + val error = super.processQueryException(t) flintStatement.fail() flintStatement.error = Some(error) error @@ -581,11 +598,13 @@ object FlintREPL extends Logging with FlintJobExecutor { } catch { // e.g., maybe due to authentication service connection issue // or invalid catalog (e.g., we are operating on data not defined in provided data source) - case e: Exception => - val error = s"""Fail to write result of ${flintStatement}, cause: ${e.getMessage}""" - CustomLogging.logError(error, e) + case e: Throwable => + throwableHandler.recordThrowable( + s"""Fail to write result of ${flintStatement}, cause: ${e.getMessage}""", + e) flintStatement.fail() } finally { + logInfo(s"command complete: $flintStatement") statementExecutionManager.updateStatement(flintStatement) recordStatementStateChange(flintStatement, statementTimerContext) } @@ -671,8 +690,8 @@ object FlintREPL extends Logging with FlintJobExecutor { flintStatement, sessionId, startTime)) - case e: Exception => - val error = processQueryException(e, flintStatement) + case t: Throwable => + val error = processQueryException(t, flintStatement) Some( handleCommandFailureAndGetFailedData( applicationId, @@ -747,7 +766,7 @@ object FlintREPL extends Logging with FlintJobExecutor { startTime)) case NonFatal(e) => val error = s"An unexpected error occurred: ${e.getMessage}" - CustomLogging.logError(error, e) + throwableHandler.recordThrowable(error, e) dataToWrite = Some( handleCommandFailureAndGetFailedData( applicationId, @@ -786,7 +805,6 @@ object FlintREPL extends Logging with FlintJobExecutor { queryWaitTimeMillis) } - logInfo(s"command complete: $flintStatement") (dataToWrite, verificationResult) } @@ -858,7 +876,8 @@ object FlintREPL extends Logging with FlintJobExecutor { } } } catch { - case e: Exception => logError(s"Failed to update session state for $sessionId", e) + case t: Throwable => + throwableHandler.recordThrowable(s"Failed to update session state for $sessionId", t) } } } @@ -897,10 +916,10 @@ object FlintREPL extends Logging with FlintJobExecutor { MetricConstants.REQUEST_METADATA_HEARTBEAT_FAILED_METRIC ) // Record heartbeat failure metric // maybe due to invalid sequence number or primary term - case e: Exception => - CustomLogging.logWarning( + case t: Throwable => + throwableHandler.recordThrowable( s"""Fail to update the last update time of the flint instance ${sessionId}""", - e) + t) incrementCounter( MetricConstants.REQUEST_METADATA_HEARTBEAT_FAILED_METRIC ) // Record heartbeat failure metric @@ -948,8 +967,10 @@ object FlintREPL extends Logging with FlintJobExecutor { } } catch { // still proceed since we are not sure what happened (e.g., OpenSearch cluster may be unresponsive) - case e: Exception => - CustomLogging.logError(s"""Fail to find id ${sessionId} from session index.""", e) + case t: Throwable => + throwableHandler.recordThrowable( + s"""Fail to find id ${sessionId} from session index.""", + t) true } } diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala index 8582d3037..27b0be84f 100644 --- a/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/JobOperator.scala @@ -82,9 +82,6 @@ case class JobOperator( LangType.SQL, currentTimeProvider.currentEpochMillis()) - var exceptionThrown = true - var error: String = null - try { val futurePrepareQueryExecution = Future { statementExecutionManager.prepareStatementExecution() @@ -94,7 +91,7 @@ case class JobOperator( ThreadUtils.awaitResult(futurePrepareQueryExecution, Duration(1, MINUTES)) match { case Right(_) => data case Left(err) => - error = err + throwableHandler.setError(err) constructErrorDF( applicationId, jobId, @@ -107,11 +104,9 @@ case class JobOperator( "", startTime) }) - exceptionThrown = false } catch { case e: TimeoutException => - error = s"Preparation for query execution timed out" - logError(error, e) + throwableHandler.recordThrowable(s"Preparation for query execution timed out", e) dataToWrite = Some( constructErrorDF( applicationId, @@ -119,13 +114,13 @@ case class JobOperator( sparkSession, dataSource, "TIMEOUT", - error, + throwableHandler.error, queryId, query, "", startTime)) - case e: Exception => - val error = processQueryException(e) + case t: Throwable => + val error = processQueryException(t) dataToWrite = Some( constructErrorDF( applicationId, @@ -146,27 +141,32 @@ case class JobOperator( try { dataToWrite.foreach(df => writeDataFrameToOpensearch(df, resultIndex, osClient)) } catch { - case e: Exception => - exceptionThrown = true - error = s"Failed to write to result index. originalError='${error}'" - logError(error, e) + case t: Throwable => + throwableHandler.recordThrowable( + s"Failed to write to result index. originalError='${throwableHandler.error}'", + t) } - if (exceptionThrown) statement.fail() else statement.complete() - statement.error = Some(error) - statementExecutionManager.updateStatement(statement) + if (throwableHandler.hasException) statement.fail() else statement.complete() + statement.error = Some(throwableHandler.error) - cleanUpResources(exceptionThrown, threadPool, startTime) + try { + statementExecutionManager.updateStatement(statement) + } catch { + case t: Throwable => + throwableHandler.recordThrowable( + s"Failed to update statement. originalError='${throwableHandler.error}'", + t) + } + + cleanUpResources(threadPool) } } - def cleanUpResources( - exceptionThrown: Boolean, - threadPool: ThreadPoolExecutor, - startTime: Long): Unit = { + def cleanUpResources(threadPool: ThreadPoolExecutor): Unit = { val isStreaming = jobType.equalsIgnoreCase(FlintJobType.STREAMING) try { // Wait for streaming job complete if no error - if (!exceptionThrown && isStreaming) { + if (!throwableHandler.hasException && isStreaming) { // Clean Spark shuffle data after each microBatch. sparkSession.streams.addListener(new ShuffleCleaner(sparkSession)) // Await index monitor before the main thread terminates @@ -174,7 +174,7 @@ case class JobOperator( } else { logInfo(s""" | Skip streaming job await due to conditions not met: - | - exceptionThrown: $exceptionThrown + | - exceptionThrown: ${throwableHandler.hasException} | - streaming: $isStreaming | - activeStreams: ${sparkSession.streams.active.mkString(",")} |""".stripMargin) @@ -190,7 +190,7 @@ case class JobOperator( } catch { case e: Exception => logError("Fail to close threadpool", e) } - recordStreamingCompletionStatus(exceptionThrown) + recordStreamingCompletionStatus(throwableHandler.hasException) // Check for non-daemon threads that may prevent the driver from shutting down. // Non-daemon threads other than the main thread indicate that the driver is still processing tasks, @@ -219,8 +219,13 @@ case class JobOperator( logInfo("Stopped Spark session") } match { case Success(_) => - case Failure(e) => logError("unexpected error while stopping spark session", e) + case Failure(e) => + throwableHandler.recordThrowable("unexpected error while stopping spark session", e) } + + // After handling any exceptions from stopping the Spark session, + // check if there's a stored exception and throw it if it's an UnrecoverableException + checkAndThrowUnrecoverableExceptions() } /** diff --git a/spark-sql-application/src/main/scala/org/apache/spark/sql/util/ThrowableHandler.scala b/spark-sql-application/src/main/scala/org/apache/spark/sql/util/ThrowableHandler.scala new file mode 100644 index 000000000..01c90bdd4 --- /dev/null +++ b/spark-sql-application/src/main/scala/org/apache/spark/sql/util/ThrowableHandler.scala @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.apache.spark.sql.util + +import org.opensearch.flint.core.logging.CustomLogging + +/** + * Handles and manages exceptions and error messages during each emr job run. Provides methods to + * set, retrieve, and reset exception information. + */ +class ThrowableHandler { + private var _throwableOption: Option[Throwable] = None + private var _error: String = _ + + def exceptionThrown: Option[Throwable] = _throwableOption + def error: String = _error + + def recordThrowable(err: String, t: Throwable): Unit = { + _error = err + _throwableOption = Some(t) + CustomLogging.logError(err, t) + } + + def setError(err: String): Unit = { + _error = err + } + + def setThrowable(t: Throwable): Unit = { + _throwableOption = Some(t) + } + + def reset(): Unit = { + _throwableOption = None + _error = null + } + + def hasException: Boolean = _throwableOption.isDefined +} diff --git a/spark-sql-application/src/test/scala/org/apache/spark/sql/FlintREPLTest.scala b/spark-sql-application/src/test/scala/org/apache/spark/sql/FlintREPLTest.scala index 07ed94bdc..7edb0d4c3 100644 --- a/spark-sql-application/src/test/scala/org/apache/spark/sql/FlintREPLTest.scala +++ b/spark-sql-application/src/test/scala/org/apache/spark/sql/FlintREPLTest.scala @@ -33,9 +33,11 @@ import org.apache.spark.{SparkConf, SparkContext, SparkFunSuite} import org.apache.spark.scheduler.SparkListenerApplicationEnd import org.apache.spark.sql.FlintREPL.PreShutdownListener import org.apache.spark.sql.FlintREPLConfConstants.DEFAULT_QUERY_LOOP_EXECUTION_FREQUENCY +import org.apache.spark.sql.SessionUpdateMode.SessionUpdateMode import org.apache.spark.sql.SparkConfConstants.{DEFAULT_SQL_EXTENSIONS, SQL_EXTENSIONS_KEY} import org.apache.spark.sql.catalyst.parser.ParseException import org.apache.spark.sql.catalyst.trees.Origin +import org.apache.spark.sql.exception.UnrecoverableException import org.apache.spark.sql.flint.config.FlintSparkConf import org.apache.spark.sql.types.{LongType, NullType, StringType, StructField, StructType} import org.apache.spark.sql.util.{DefaultThreadPoolFactory, MockThreadPoolFactory, MockTimeProvider, RealTimeProvider, ShutdownHookManagerTrait} @@ -195,19 +197,44 @@ class FlintREPLTest scheduledFutureRaw }) - // Invoke the method FlintREPL.createHeartBeatUpdater(sessionId, sessionManager, threadPool) - // Verifications verify(sessionManager, atLeastOnce()).recordHeartbeat(sessionId) + FlintREPL.throwableHandler.hasException shouldBe false } - test("PreShutdownListener updates FlintInstance if conditions are met") { + test("createHeartBeatUpdater should handle unrecoverable exception") { + val threadPool = mock[ScheduledExecutorService] + val scheduledFutureRaw = mock[ScheduledFuture[_]] + val sessionManager = mock[SessionManager] + val sessionId = "session1" + + FlintREPL.throwableHandler.reset() + val unrecoverableException = + UnrecoverableException(new RuntimeException("Unrecoverable error")) + when(sessionManager.recordHeartbeat(sessionId)) + .thenThrow(unrecoverableException) + + when(threadPool + .scheduleAtFixedRate(any[Runnable], *, *, eqTo(java.util.concurrent.TimeUnit.MILLISECONDS))) + .thenAnswer((invocation: InvocationOnMock) => { + val runnable = invocation.getArgument[Runnable](0) + runnable.run() + scheduledFutureRaw + }) + + FlintREPL.createHeartBeatUpdater(sessionId, sessionManager, threadPool) + + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(unrecoverableException) + } + + test("PreShutdownListener updates InteractiveSession if conditions are met") { // Mock dependencies val sessionId = "testSessionId" val timerContext = mock[Timer.Context] val sessionManager = mock[SessionManager] + FlintREPL.throwableHandler.reset() val interactiveSession = new InteractiveSession( "app123", "job123", @@ -227,6 +254,28 @@ class FlintREPLTest interactiveSession.state shouldBe SessionStates.DEAD } + test("PreShutdownListener handles unrecoverable exception from sessionManager") { + val sessionId = "testSessionId" + val timerContext = mock[Timer.Context] + val sessionManager = mock[SessionManager] + + FlintREPL.throwableHandler.reset() + val unrecoverableException = + UnrecoverableException(new RuntimeException("Unrecoverable database error")) + when(sessionManager.getSessionDetails(sessionId)) + .thenThrow(unrecoverableException) + + val listener = new PreShutdownListener(sessionId, sessionManager, timerContext) + + listener.onApplicationEnd(SparkListenerApplicationEnd(System.currentTimeMillis())) + + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(unrecoverableException) + FlintREPL.throwableHandler.error shouldBe s"Failed to update session state for $sessionId" + + verify(sessionManager, never()) + .updateSessionDetails(any[InteractiveSession], any[SessionUpdateMode]) + } + test("Test super.constructErrorDF should construct dataframe properly") { // Define expected dataframe val dataSourceName = "myGlueS3" @@ -463,6 +512,29 @@ class FlintREPLTest assert(result) } + test("test canPickNextStatement: sessionManager throws unrecoverableException") { + val sessionId = "session123" + val jobId = "jobABC" + val sessionIndex = "sessionIndex" + val mockSparkSession = mock[SparkSession] + val mockConf = mock[RuntimeConfig] + when(mockSparkSession.conf).thenReturn(mockConf) + when(mockSparkSession.conf.get(FlintSparkConf.REQUEST_INDEX.key, "")) + .thenReturn(sessionIndex) + + FlintREPL.throwableHandler.reset() + val sessionManager = mock[SessionManager] + val unrecoverableException = + UnrecoverableException(new RuntimeException("OpenSearch cluster unresponsive")) + when(sessionManager.getSessionDetails(sessionId)) + .thenThrow(unrecoverableException) + + val result = FlintREPL.canPickNextStatement(sessionId, sessionManager, jobId) + + assert(result) + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(unrecoverableException) + } + test( "test canPickNextStatement: Doc Exists and excludeJobIds is a Single String Not Matching JobId") { val sessionId = "session123" @@ -521,6 +593,7 @@ class FlintREPLTest verify(mockFlintStatement).error = Some(expectedError) assert(result == expectedError) + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(exception) } test("processQueryException should handle MetaException with AccessDeniedException properly") { @@ -665,8 +738,6 @@ class FlintREPLTest override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] - val commandContext = CommandContext( applicationId, jobId, @@ -1026,6 +1097,87 @@ class FlintREPLTest assert(!result) // Expecting false as the job proceeds normally } + test("handleSessionError handles unrecoverable exception") { + val sessionManager = mock[SessionManager] + val timerContext = mock[Timer.Context] + val applicationId = "app123" + val jobId = "job123" + val sessionId = "session123" + val jobStartTime = System.currentTimeMillis() + + FlintREPL.throwableHandler.reset() + val unrecoverableException = + UnrecoverableException(new RuntimeException("Unrecoverable error")) + val interactiveSession = new InteractiveSession( + applicationId, + jobId, + sessionId, + SessionStates.RUNNING, + System.currentTimeMillis(), + System.currentTimeMillis() - 10000) + when(sessionManager.getSessionDetails(sessionId)).thenReturn(Some(interactiveSession)) + + FlintREPL.handleSessionError( + unrecoverableException, + applicationId, + jobId, + sessionId, + sessionManager, + jobStartTime, + timerContext) + + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(unrecoverableException) + + verify(sessionManager).updateSessionDetails( + argThat { (session: InteractiveSession) => + session.applicationId == applicationId && + session.jobId == jobId && + session.sessionId == sessionId && + session.state == SessionStates.FAIL && + session.error.contains(s"Session error: ${unrecoverableException.getMessage}") + }, + any[SessionUpdateMode]) + + verify(timerContext).stop() + } + + test("handleSessionError handles exception during refreshSessionState") { + val sessionManager = mock[SessionManager] + val timerContext = mock[Timer.Context] + val applicationId = "app123" + val jobId = "job123" + val sessionId = "session123" + val jobStartTime = System.currentTimeMillis() + + FlintREPL.throwableHandler.reset() + val initialException = UnrecoverableException(new RuntimeException("Unrecoverable error")) + val refreshException = + UnrecoverableException(new RuntimeException("Failed to refresh session state")) + + val interactiveSession = new InteractiveSession( + applicationId, + jobId, + sessionId, + SessionStates.RUNNING, + System.currentTimeMillis(), + System.currentTimeMillis() - 10000) + when(sessionManager.getSessionDetails(sessionId)).thenReturn(Some(interactiveSession)) + when(sessionManager.updateSessionDetails(any[InteractiveSession], any[SessionUpdateMode])) + .thenThrow(refreshException) + + FlintREPL.handleSessionError( + initialException, + applicationId, + jobId, + sessionId, + sessionManager, + jobStartTime, + timerContext) + + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(refreshException) + verify(timerContext).stop() + } + test("queryLoop continue until inactivity limit is reached") { val resultIndex = "testResultIndex" val dataSource = "testDataSource" @@ -1064,7 +1216,6 @@ class FlintREPLTest val sessionManager = new SessionManagerImpl(spark, Some(resultIndex)) { override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] val commandContext = CommandContext( applicationId, @@ -1133,7 +1284,6 @@ class FlintREPLTest val sessionManager = new SessionManagerImpl(spark, Some(resultIndex)) { override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] val commandContext = CommandContext( applicationId, @@ -1198,7 +1348,6 @@ class FlintREPLTest val sessionManager = new SessionManagerImpl(spark, Some(resultIndex)) { override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] val commandContext = CommandContext( applicationId, @@ -1255,7 +1404,8 @@ class FlintREPLTest mockOSClient.createQueryReader(any[String], any[String], any[String], eqTo(SortOrder.ASC))) .thenReturn(mockReader) // Simulate an exception thrown when hasNext is called - when(mockReader.hasNext).thenThrow(new RuntimeException("Test exception")) + val unrecoverableException = UnrecoverableException(new RuntimeException("Test exception")) + when(mockReader.hasNext).thenThrow(unrecoverableException) when(mockOSClient.doesIndexExist(*)).thenReturn(true) when(mockOSClient.getIndexMetadata(*)).thenReturn(FlintREPL.resultIndexMapping) @@ -1268,7 +1418,6 @@ class FlintREPLTest val sessionManager = new SessionManagerImpl(spark, Some(resultIndex)) { override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] val commandContext = CommandContext( applicationId, @@ -1287,13 +1436,15 @@ class FlintREPLTest // Mocking ThreadUtils to track the shutdown call val mockThreadPool = mock[ScheduledExecutorService] FlintREPL.threadPoolFactory = new MockThreadPoolFactory(mockThreadPool) + FlintREPL.throwableHandler.reset() - intercept[RuntimeException] { + intercept[UnrecoverableException] { FlintREPL.queryLoop(commandContext) } // Verify if the shutdown method was called on the thread pool verify(mockThreadPool).shutdown() + FlintREPL.throwableHandler.exceptionThrown shouldBe Some(unrecoverableException) } finally { // Stop the SparkSession spark.stop() @@ -1436,7 +1587,6 @@ class FlintREPLTest val sessionManager = new SessionManagerImpl(spark, Some(resultIndex)) { override val osClient: OSClient = mockOSClient } - val queryResultWriter = mock[QueryResultWriter] val commandContext = CommandContext( applicationId, From 6f37b2d180793bc2ca32548086ccb20633b30ac5 Mon Sep 17 00:00:00 2001 From: Simeon Widdis Date: Thu, 14 Nov 2024 22:05:29 -0800 Subject: [PATCH 21/26] Add metrics for successful/failed Spark index creation (#837) * Add index creation metrics Signed-off-by: Simeon Widdis * Update emit metric function location Signed-off-by: Simeon Widdis * Apply scalafmt Signed-off-by: Simeon Widdis * revert FlintSparkIndexFactory metrics emission Signed-off-by: Simeon Widdis * Update OS client to emit metrics Signed-off-by: Simeon Widdis * Fix index kind detection Signed-off-by: Simeon Widdis * Remove cumbersome with() method Signed-off-by: Simeon Widdis * Revert the void change Signed-off-by: Simeon Widdis * Make the code branchless Signed-off-by: Simeon Widdis * Add default case to switches Signed-off-by: Simeon Widdis * Apply PR feedback Signed-off-by: Simeon Widdis --------- Signed-off-by: Simeon Widdis --- .../flint/core/metrics/MetricConstants.java | 15 ++++++++ .../core/storage/FlintOpenSearchClient.java | 34 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java index 978950b3c..79e70b8c2 100644 --- a/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java +++ b/flint-core/src/main/java/org/opensearch/flint/core/metrics/MetricConstants.java @@ -171,6 +171,21 @@ public final class MetricConstants { */ public static final String OUTPUT_TOTAL_RECORDS_WRITTEN = "output.totalRecordsWritten.count"; + /** + * Metric group related to skipping indices, such as create success and failure + */ + public static final String CREATE_SKIPPING_INDICES = "query.execution.index.skipping"; + + /** + * Metric group related to covering indices, such as create success and failure + */ + public static final String CREATE_COVERING_INDICES = "query.execution.index.covering"; + + /** + * Metric group related to materialized view indices, such as create success and failure + */ + public static final String CREATE_MV_INDICES = "query.execution.index.mv"; + /** * Metric for tracking the latency of checkpoint deletion */ diff --git a/flint-core/src/main/scala/org/opensearch/flint/core/storage/FlintOpenSearchClient.java b/flint-core/src/main/scala/org/opensearch/flint/core/storage/FlintOpenSearchClient.java index da22e3751..2bc097bba 100644 --- a/flint-core/src/main/scala/org/opensearch/flint/core/storage/FlintOpenSearchClient.java +++ b/flint-core/src/main/scala/org/opensearch/flint/core/storage/FlintOpenSearchClient.java @@ -16,6 +16,8 @@ import org.opensearch.flint.core.FlintClient; import org.opensearch.flint.core.FlintOptions; import org.opensearch.flint.core.IRestHighLevelClient; +import org.opensearch.flint.core.metrics.MetricConstants; +import org.opensearch.flint.core.metrics.MetricsUtil; import scala.Option; import java.io.IOException; @@ -40,7 +42,13 @@ public FlintOpenSearchClient(FlintOptions options) { @Override public void createIndex(String indexName, FlintMetadata metadata) { LOG.info("Creating Flint index " + indexName + " with metadata " + metadata); - createIndex(indexName, FlintOpenSearchIndexMetadataService.serialize(metadata, false), metadata.indexSettings()); + try { + createIndex(indexName, FlintOpenSearchIndexMetadataService.serialize(metadata, false), metadata.indexSettings()); + emitIndexCreationSuccessMetric(metadata.kind()); + } catch (IllegalStateException ex) { + emitIndexCreationFailureMetric(metadata.kind()); + throw ex; + } } protected void createIndex(String indexName, String mapping, Option settings) { @@ -122,4 +130,28 @@ public IRestHighLevelClient createClient() { private String sanitizeIndexName(String indexName) { return OpenSearchClientUtils.sanitizeIndexName(indexName); } + + private void emitIndexCreationSuccessMetric(String indexKind) { + emitIndexCreationMetric(indexKind, "success"); + } + + private void emitIndexCreationFailureMetric(String indexKind) { + emitIndexCreationMetric(indexKind, "failed"); + } + + private void emitIndexCreationMetric(String indexKind, String status) { + switch (indexKind) { + case "skipping": + MetricsUtil.addHistoricGauge(String.format("%s.%s.count", MetricConstants.CREATE_SKIPPING_INDICES, status), 1); + break; + case "covering": + MetricsUtil.addHistoricGauge(String.format("%s.%s.count", MetricConstants.CREATE_COVERING_INDICES, status), 1); + break; + case "mv": + MetricsUtil.addHistoricGauge(String.format("%s.%s.count", MetricConstants.CREATE_MV_INDICES, status), 1); + break; + default: + break; + } + } } From ec337b42e8f042f76e7cb229356ec1737ec0a022 Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Fri, 15 Nov 2024 15:15:24 +0800 Subject: [PATCH 22/26] Temporarily support 4+ parts table identifier (#913) * Temporary support 4+ parts table identifier Signed-off-by: Lantao Jin * fix style Signed-off-by: Lantao Jin * add a ut to test the TableIdentifier can be built as expected Signed-off-by: Lantao Jin * select ut Signed-off-by: Lantao Jin * add complex case Signed-off-by: Lantao Jin --------- Signed-off-by: Lantao Jin --- .../spark/ppl/FlintSparkPPLBasicITSuite.scala | 33 +++++----- .../sql/ppl/utils/RelationUtils.java | 11 +++- ...lPlanBasicQueriesTranslatorTestSuite.scala | 63 ++++++++++++++++++- 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala index 3bd98edf1..c1bb1cd24 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala @@ -541,11 +541,6 @@ class FlintSparkPPLBasicITSuite | """.stripMargin)) assert(ex.getMessage().contains("TABLE_OR_VIEW_NOT_FOUND")) } - val t7 = "spark_catalog.default.flint_ppl_test7.log" - val ex = intercept[IllegalArgumentException](sql(s""" - | source = $t7| head 2 - | """.stripMargin)) - assert(ex.getMessage().contains("Invalid table name")) } test("test describe backtick table names and name contains '.'") { @@ -564,11 +559,6 @@ class FlintSparkPPLBasicITSuite | """.stripMargin)) assert(ex.getMessage().contains("TABLE_OR_VIEW_NOT_FOUND")) } - val t7 = "spark_catalog.default.flint_ppl_test7.log" - val ex = intercept[IllegalArgumentException](sql(s""" - | describe $t7 - | """.stripMargin)) - assert(ex.getMessage().contains("Invalid table name")) } test("test explain backtick table names and name contains '.'") { @@ -590,12 +580,27 @@ class FlintSparkPPLBasicITSuite Project(Seq(UnresolvedStar(None)), relation), ExplainMode.fromString("extended")) comparePlans(logicalPlan, expectedPlan, checkAnalysis = false) + } + // TODO Do not support 4+ parts table identifier in future (may be reverted this PR in 0.8.0) + test("test table name with more than 3 parts") { val t7 = "spark_catalog.default.flint_ppl_test7.log" - val ex = intercept[IllegalArgumentException](sql(s""" - | explain extended | source = $t7 - | """.stripMargin)) - assert(ex.getMessage().contains("Invalid table name")) + val t4Parts = "`spark_catalog`.default.`startTime:1,endTime:2`.`this(is:['a/name'])`" + val t5Parts = + "`spark_catalog`.default.`startTime:1,endTime:2`.`this(is:['sub/name'])`.`this(is:['sub-sub/name'])`" + Seq(t7, t4Parts, t5Parts).foreach { table => + val ex = intercept[AnalysisException](sql(s""" + | source = $table| head 2 + | """.stripMargin)) + assert(ex.getMessage().contains("TABLE_OR_VIEW_NOT_FOUND")) + } + + Seq(t7, t4Parts, t5Parts).foreach { table => + val ex = intercept[AnalysisException](sql(s""" + | describe $table + | """.stripMargin)) + assert(ex.getMessage().contains("TABLE_OR_VIEW_NOT_FOUND")) + } } test("Search multiple tables - translated into union call with fields") { diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/RelationUtils.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/RelationUtils.java index 1dc7b9878..f959fe199 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/RelationUtils.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/utils/RelationUtils.java @@ -53,8 +53,15 @@ static TableIdentifier getTableIdentifier(QualifiedName qualifiedName) { Option$.MODULE$.apply(qualifiedName.getParts().get(1)), Option$.MODULE$.apply(qualifiedName.getParts().get(0))); } else { - throw new IllegalArgumentException("Invalid table name: " + qualifiedName - + " Syntax: [ database_name. ] table_name"); + // TODO Do not support 4+ parts table identifier in future (may be reverted this PR in 0.8.0) + // qualifiedName.getParts().size() > 3 + // A Spark TableIdentifier should only contain 3 parts: tableName, databaseName and catalogName. + // If the qualifiedName has more than 3 parts, + // we merge all parts from 3 to last parts into the tableName as one whole + identifier = new TableIdentifier( + String.join(".", qualifiedName.getParts().subList(2, qualifiedName.getParts().size())), + Option$.MODULE$.apply(qualifiedName.getParts().get(1)), + Option$.MODULE$.apply(qualifiedName.getParts().get(0))); } return identifier; } diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala index 50ef985d6..f33b1578a 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala @@ -27,7 +27,8 @@ class PPLLogicalPlanBasicQueriesTranslatorTestSuite private val planTransformer = new CatalystQueryPlanVisitor() private val pplParser = new PPLSyntaxParser() - test("test error describe clause") { + // TODO Do not support 4+ parts table identifier in future (may be reverted this PR in 0.8.0) + ignore("test error describe clause") { val context = new CatalystPlanContext val thrown = intercept[IllegalArgumentException] { planTransformer.visit(plan(pplParser, "describe t.b.c.d"), context) @@ -50,6 +51,66 @@ class PPLLogicalPlanBasicQueriesTranslatorTestSuite comparePlans(expectedPlan, logPlan, false) } + // TODO Do not support 4+ parts table identifier in future (may be reverted this PR in 0.8.0) + test("test describe with backticks and more then 3 parts") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit(plan(pplParser, "describe `t`.b.`c.d`.`e.f`"), context) + + val expectedPlan = DescribeTableCommand( + TableIdentifier("c.d.e.f", Option("b"), Option("t")), + Map.empty[String, String].empty, + isExtended = true, + output = DescribeRelation.getOutputAttrs) + comparePlans(expectedPlan, logPlan, false) + } + + test("test read table with backticks and more then 3 parts") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit(plan(pplParser, "source=`t`.b.`c.d`.`e.f`"), context) + val table = UnresolvedRelation(Seq("t", "b", "c.d.e.f")) + val expectedPlan = Project(Seq(UnresolvedStar(None)), table) + comparePlans(expectedPlan, logPlan, false) + } + + test("test describe with complex backticks and more then 3 parts") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "describe `_Basic`.default.`startTime:0,endTime:1`.`logGroups(logGroupIdentifier:['hello/service_log'])`"), + context) + + val expectedPlan = DescribeTableCommand( + TableIdentifier( + "startTime:0,endTime:1.logGroups(logGroupIdentifier:['hello/service_log'])", + Option("default"), + Option("_Basic")), + Map.empty[String, String].empty, + isExtended = true, + output = DescribeRelation.getOutputAttrs) + comparePlans(expectedPlan, logPlan, false) + } + + test("test read complex table with backticks and more then 3 parts") { + val context = new CatalystPlanContext + val logPlan = + planTransformer.visit( + plan( + pplParser, + "source=`_Basic`.default.`startTime:0,endTime:1`.`logGroups(logGroupIdentifier:['hello/service_log'])`"), + context) + val table = UnresolvedRelation( + Seq( + "_Basic", + "default", + "startTime:0,endTime:1.logGroups(logGroupIdentifier:['hello/service_log'])")) + val expectedPlan = Project(Seq(UnresolvedStar(None)), table) + comparePlans(expectedPlan, logPlan, false) + } + test("test describe FQN table clause") { val context = new CatalystPlanContext val logPlan = From 06f14207d54570c6e6d0f262e4c12e747e782857 Mon Sep 17 00:00:00 2001 From: YANGDB Date: Fri, 15 Nov 2024 09:50:16 -0700 Subject: [PATCH 23/26] update `expand` antlr command (#918) Signed-off-by: YANGDB --- .../src/main/antlr4/OpenSearchPPLLexer.g4 | 3 +++ .../src/main/antlr4/OpenSearchPPLParser.g4 | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 index 3ce8b6f1e..d15f5c8e3 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLLexer.g4 @@ -416,6 +416,9 @@ ISPRESENT: 'ISPRESENT'; BETWEEN: 'BETWEEN'; CIDRMATCH: 'CIDRMATCH'; +// Geo Loction +GEOIP: 'GEOIP'; + // FLOWCONTROL FUNCTIONS IFNULL: 'IFNULL'; NULLIF: 'NULLIF'; diff --git a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 index 357673e73..f7e1b3da4 100644 --- a/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 +++ b/ppl-spark-integration/src/main/antlr4/OpenSearchPPLParser.g4 @@ -451,6 +451,7 @@ valueExpression | positionFunction # positionFunctionCall | caseFunction # caseExpr | timestampFunction # timestampFunctionCall + | geoipFunction # geoFunctionCall | LT_PRTHS valueExpression RT_PRTHS # parentheticValueExpr | LT_SQR_PRTHS subSearch RT_SQR_PRTHS # scalarSubqueryExpr | ident ARROW expression # lambda @@ -547,6 +548,11 @@ dataTypeFunctionCall : CAST LT_PRTHS expression AS convertedDataType RT_PRTHS ; +// geoip function +geoipFunction + : GEOIP LT_PRTHS (datasource = functionArg COMMA)? ipAddress = functionArg (COMMA properties = stringLiteral)? RT_PRTHS + ; + // boolean functions booleanFunctionCall : conditionFunctionBase LT_PRTHS functionArgs RT_PRTHS @@ -580,6 +586,7 @@ evalFunctionName | cryptographicFunctionName | jsonFunctionName | collectionFunctionName + | geoipFunctionName | lambdaFunctionName ; @@ -898,6 +905,10 @@ lambdaFunctionName | REDUCE ; +geoipFunctionName + : GEOIP + ; + positionFunctionName : POSITION ; From c37b3bdee04ae559d912a8b115d9007f69bd3d99 Mon Sep 17 00:00:00 2001 From: qianheng Date: Sat, 16 Nov 2024 00:51:52 +0800 Subject: [PATCH 24/26] Put response json in error message (#920) Signed-off-by: Heng Qian --- integ-test/script/README.md | 2 +- integ-test/script/SanityTest.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/integ-test/script/README.md b/integ-test/script/README.md index 79b188158..7ce0c6886 100644 --- a/integ-test/script/README.md +++ b/integ-test/script/README.md @@ -17,7 +17,7 @@ Apart from the basic feature, it also has some advanced functionality includes: ### Usage To use this script, you need to have Python **3.6** or higher installed. It also requires the following Python libraries: ```shell -pip install requests pandas +pip install requests pandas openpyxl ``` After getting the requisite libraries, you can run the script with the following command line parameters in your shell: diff --git a/integ-test/script/SanityTest.py b/integ-test/script/SanityTest.py index 1c51d4d20..eb97752b4 100644 --- a/integ-test/script/SanityTest.py +++ b/integ-test/script/SanityTest.py @@ -101,7 +101,7 @@ def submit_query(self, query, session_id="Empty"): response.raise_for_status() return response_json except Exception as e: - return {"error": str(e), "response": response_json} + return {"error": f"{str(e)}, got response {response_json}"} # Call get API to check the query status def get_query_result(self, query_id): @@ -113,7 +113,7 @@ def get_query_result(self, query_id): response.raise_for_status() return response_json except Exception as e: - return {"status": "FAILED", "error": str(e), "response": response_json} + return {"status": "FAILED", "error": f"{str(e)}, got response {response_json}"} # Call delete API to cancel the query def cancel_query(self, query_id): @@ -204,6 +204,7 @@ def run_tests_from_csv(self, csv_file): futures = [self.executor.submit(self.run_test, query, seq_id, expected_status) for query, seq_id, expected_status in queries] for future in as_completed(futures): result = future.result() + self.logger.info(f"Completed test: {result["query_name"]}, {result["query"]}, got result status: {result["status"]}") self.test_results.append(result) def generate_report(self): From b050da352cd50c32fc7708ca8eb7aad09b211376 Mon Sep 17 00:00:00 2001 From: Louis Chu Date: Fri, 15 Nov 2024 15:15:04 -0800 Subject: [PATCH 25/26] Temp fix for table name more than 3 parts (#923) --- .../flint/spark/ppl/FlintSparkPPLBasicITSuite.scala | 11 ++++++++--- .../opensearch/sql/ppl/CatalystQueryPlanVisitor.java | 8 ++++++-- ...PLLogicalPlanBasicQueriesTranslatorTestSuite.scala | 11 +++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala index c1bb1cd24..300b44b5a 100644 --- a/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala +++ b/integ-test/src/integration/scala/org/opensearch/flint/spark/ppl/FlintSparkPPLBasicITSuite.scala @@ -588,11 +588,16 @@ class FlintSparkPPLBasicITSuite val t4Parts = "`spark_catalog`.default.`startTime:1,endTime:2`.`this(is:['a/name'])`" val t5Parts = "`spark_catalog`.default.`startTime:1,endTime:2`.`this(is:['sub/name'])`.`this(is:['sub-sub/name'])`" + Seq(t7, t4Parts, t5Parts).foreach { table => val ex = intercept[AnalysisException](sql(s""" - | source = $table| head 2 - | """.stripMargin)) - assert(ex.getMessage().contains("TABLE_OR_VIEW_NOT_FOUND")) + | source = $table| head 2 + | """.stripMargin)) + // Expected since V2SessionCatalog only supports 3 parts + assert( + ex.getMessage() + .contains( + "[REQUIRES_SINGLE_PART_NAMESPACE] spark_catalog requires a single-part namespace")) } Seq(t7, t4Parts, t5Parts).foreach { table => diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java index debd37376..000c16b92 100644 --- a/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/ppl/CatalystQueryPlanVisitor.java @@ -151,8 +151,12 @@ public LogicalPlan visitRelation(Relation node, CatalystPlanContext context) { } //regular sql algebraic relations node.getQualifiedNames().forEach(q -> - // Resolving the qualifiedName which is composed of a datasource.schema.table - context.withRelation(new UnresolvedRelation(getTableIdentifier(q).nameParts(), CaseInsensitiveStringMap.empty(), false)) + // TODO Do not support 4+ parts table identifier in future (may be reverted this PR in 0.8.0) + // node.getQualifiedNames.getParts().size() > 3 + // A Spark TableIdentifier should only contain 3 parts: tableName, databaseName and catalogName. + // If the qualifiedName has more than 3 parts, + // we merge all parts from 3 to last parts into the tableName as one whole + context.withRelation(new UnresolvedRelation(seq(q.getParts()), CaseInsensitiveStringMap.empty(), false)) ); return context.getPlan(); } diff --git a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala index f33b1578a..1f081bd72 100644 --- a/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala +++ b/ppl-spark-integration/src/test/scala/org/opensearch/flint/spark/ppl/PPLLogicalPlanBasicQueriesTranslatorTestSuite.scala @@ -67,9 +67,11 @@ class PPLLogicalPlanBasicQueriesTranslatorTestSuite test("test read table with backticks and more then 3 parts") { val context = new CatalystPlanContext - val logPlan = + val logPlan = { planTransformer.visit(plan(pplParser, "source=`t`.b.`c.d`.`e.f`"), context) - val table = UnresolvedRelation(Seq("t", "b", "c.d.e.f")) + } + + val table = UnresolvedRelation(Seq("t", "b", "c.d", "e.f")) val expectedPlan = Project(Seq(UnresolvedStar(None)), table) comparePlans(expectedPlan, logPlan, false) } @@ -100,13 +102,14 @@ class PPLLogicalPlanBasicQueriesTranslatorTestSuite planTransformer.visit( plan( pplParser, - "source=`_Basic`.default.`startTime:0,endTime:1`.`logGroups(logGroupIdentifier:['hello/service_log'])`"), + "source=`_Basic`.default.`startTime:0,endTime:1`.`123.logGroups(logGroupIdentifier:['hello.world/service_log'])`"), context) val table = UnresolvedRelation( Seq( "_Basic", "default", - "startTime:0,endTime:1.logGroups(logGroupIdentifier:['hello/service_log'])")) + "startTime:0,endTime:1", + "123.logGroups(logGroupIdentifier:['hello.world/service_log'])")) val expectedPlan = Project(Seq(UnresolvedStar(None)), table) comparePlans(expectedPlan, logPlan, false) } From 7b6e485b374e1218da94bef0796da5eb2e5cb712 Mon Sep 17 00:00:00 2001 From: Lantao Jin Date: Tue, 19 Nov 2024 10:29:19 +0800 Subject: [PATCH 26/26] [DOC] Ensure PPL docs have consistent look (#926) * refactor ppl docs to keep consistent look Signed-off-by: Lantao Jin * remove auto generated file Signed-off-by: Lantao Jin * minor updates Signed-off-by: Lantao Jin * address comments Signed-off-by: Lantao Jin * fix hyper-link issue Signed-off-by: Lantao Jin --------- Signed-off-by: Lantao Jin --- README.md | 4 +- docs/ppl-lang/README.md | 2 +- .../{ppl-lambda.md => ppl-collection.md} | 80 +++- docs/ppl-lang/functions/ppl-json.md | 214 ++++++++- docs/ppl-lang/ppl-correlation-command.md | 2 +- docs/ppl-lang/ppl-dedup-command.md | 8 +- docs/ppl-lang/ppl-eval-command.md | 6 +- docs/ppl-lang/ppl-fields-command.md | 6 +- docs/ppl-lang/ppl-fieldsummary-command.md | 4 +- docs/ppl-lang/ppl-grok-command.md | 2 +- docs/ppl-lang/ppl-head-command.md | 2 +- docs/ppl-lang/ppl-join-command.md | 258 ++++++----- docs/ppl-lang/ppl-lookup-command.md | 85 ++-- docs/ppl-lang/ppl-parse-command.md | 2 +- docs/ppl-lang/ppl-rare-command.md | 8 +- docs/ppl-lang/ppl-search-command.md | 2 +- docs/ppl-lang/ppl-sort-command.md | 4 +- docs/ppl-lang/ppl-stats-command.md | 2 +- docs/ppl-lang/ppl-subquery-command.md | 405 +++++------------- docs/ppl-lang/ppl-top-command.md | 4 +- docs/ppl-lang/ppl-trendline-command.md | 6 +- docs/ppl-lang/ppl-where-command.md | 2 +- 22 files changed, 587 insertions(+), 521 deletions(-) rename docs/ppl-lang/functions/{ppl-lambda.md => ppl-collection.md} (57%) diff --git a/README.md b/README.md index 12123b456..db3790e64 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Please refer to the [Flint Index Reference Manual](./docs/index.md) for more inf * For additional details on Spark PPL commands project, see [PPL Project](https://github.com/orgs/opensearch-project/projects/214/views/2) -* Experiment ppl queries on local spark cluster[PPL on local spark ](docs/ppl-lang/local-spark-ppl-test-instruction.md) +* Experiment ppl queries on local spark cluster [PPL on local spark ](docs/ppl-lang/local-spark-ppl-test-instruction.md) ## Prerequisites @@ -88,7 +88,7 @@ bin/spark-shell --packages "org.opensearch:opensearch-spark-ppl_2.12:0.7.0-SNAPS ``` ### PPL Run queries on a local spark cluster -See ppl usage sample on local spark cluster[PPL on local spark ](local-spark-ppl-test-instruction.md) +See ppl usage sample on local spark cluster [PPL on local spark ](docs/ppl-lang/local-spark-ppl-test-instruction.md) ## Code of Conduct diff --git a/docs/ppl-lang/README.md b/docs/ppl-lang/README.md index 9df9f5986..19e1a6ee0 100644 --- a/docs/ppl-lang/README.md +++ b/docs/ppl-lang/README.md @@ -94,7 +94,7 @@ For additional examples see the next [documentation](PPL-Example-Commands.md). - [`IP Address Functions`](functions/ppl-ip.md) - - [`Lambda Functions`](functions/ppl-lambda.md) + - [`Collection Functions`](functions/ppl-collection) --- ### PPL On Spark diff --git a/docs/ppl-lang/functions/ppl-lambda.md b/docs/ppl-lang/functions/ppl-collection.md similarity index 57% rename from docs/ppl-lang/functions/ppl-lambda.md rename to docs/ppl-lang/functions/ppl-collection.md index cdb6f9e8f..b98f5f5ca 100644 --- a/docs/ppl-lang/functions/ppl-lambda.md +++ b/docs/ppl-lang/functions/ppl-collection.md @@ -1,4 +1,56 @@ -## Lambda Functions +## PPL Collection Functions + +### `ARRAY` + +**Description** + +`array(...)` Returns an array with the given elements. + +**Argument type:** +- A \ can be any kind of value such as string, number, or boolean. + +**Return type:** ARRAY + +Example: + + os> source=people | eval `array` = array(1, 2, 0, -1, 1.1, -0.11) + fetched rows / total rows = 1/1 + +------------------------------+ + | array | + +------------------------------+ + | [1.0,2.0,0.0,-1.0,1.1,-0.11] | + +------------------------------+ + os> source=people | eval `array` = array(true, false, true, true) + fetched rows / total rows = 1/1 + +------------------------------+ + | array | + +------------------------------+ + | [true, false, true, true] | + +------------------------------+ + + +### `ARRAY_LENGTH` + +**Description** + +`array_length(array)` Returns the number of elements in the outermost array. + +**Argument type:** ARRAY + +ARRAY or JSON_ARRAY object. + +**Return type:** INTEGER + +Example: + + os> source=people | eval `array` = array_length(array(1,2,3,4)), `empty_array` = array_length(array()) + fetched rows / total rows = 1/1 + +---------+---------------+ + | array | empty_array | + +---------+---------------+ + | 4 | 0 | + +---------+---------------+ + ### `FORALL` @@ -14,7 +66,7 @@ Returns `TRUE` if all elements in the array satisfy the lambda predicate, otherw Example: - os> source=people | eval array = json_array(1, -1, 2), result = forall(array, x -> x > 0) | fields result + os> source=people | eval array = array(1, -1, 2), result = forall(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -22,7 +74,7 @@ Example: | false | +-----------+ - os> source=people | eval array = json_array(1, 3, 2), result = forall(array, x -> x > 0) | fields result + os> source=people | eval array = array(1, 3, 2), result = forall(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -41,7 +93,7 @@ Consider constructing the following array: and perform lambda functions against the nested fields `a` or `b`. See the examples: - os> source=people | eval array = json_array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.a > 0) | fields result + os> source=people | eval array = array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.a > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -49,7 +101,7 @@ and perform lambda functions against the nested fields `a` or `b`. See the examp | false | +-----------+ - os> source=people | eval array = json_array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.b > 0) | fields result + os> source=people | eval array = array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.b > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -71,7 +123,7 @@ Returns `TRUE` if at least one element in the array satisfies the lambda predica Example: - os> source=people | eval array = json_array(1, -1, 2), result = exists(array, x -> x > 0) | fields result + os> source=people | eval array = array(1, -1, 2), result = exists(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -79,7 +131,7 @@ Example: | true | +-----------+ - os> source=people | eval array = json_array(-1, -3, -2), result = exists(array, x -> x > 0) | fields result + os> source=people | eval array = array(-1, -3, -2), result = exists(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -102,7 +154,7 @@ An ARRAY that contains all elements in the input array that satisfy the lambda p Example: - os> source=people | eval array = json_array(1, -1, 2), result = filter(array, x -> x > 0) | fields result + os> source=people | eval array = array(1, -1, 2), result = filter(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -110,7 +162,7 @@ Example: | [1, 2] | +-----------+ - os> source=people | eval array = json_array(-1, -3, -2), result = filter(array, x -> x > 0) | fields result + os> source=people | eval array = array(-1, -3, -2), result = filter(array, x -> x > 0) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -132,7 +184,7 @@ An ARRAY that contains the result of applying the lambda transform function to e Example: - os> source=people | eval array = json_array(1, 2, 3), result = transform(array, x -> x + 1) | fields result + os> source=people | eval array = array(1, 2, 3), result = transform(array, x -> x + 1) | fields result fetched rows / total rows = 1/1 +--------------+ | result | @@ -140,7 +192,7 @@ Example: | [2, 3, 4] | +--------------+ - os> source=people | eval array = json_array(1, 2, 3), result = transform(array, (x, i) -> x + i) | fields result + os> source=people | eval array = array(1, 2, 3), result = transform(array, (x, i) -> x + i) | fields result fetched rows / total rows = 1/1 +--------------+ | result | @@ -162,7 +214,7 @@ The final result of applying the lambda functions to the start value and the inp Example: - os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x) | fields result + os> source=people | eval array = array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -170,7 +222,7 @@ Example: | 6 | +-----------+ - os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 10, (acc, x) -> acc + x) | fields result + os> source=people | eval array = array(1, 2, 3), result = reduce(array, 10, (acc, x) -> acc + x) | fields result fetched rows / total rows = 1/1 +-----------+ | result | @@ -178,7 +230,7 @@ Example: | 16 | +-----------+ - os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x, acc -> acc * 10) | fields result + os> source=people | eval array = array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x, acc -> acc * 10) | fields result fetched rows / total rows = 1/1 +-----------+ | result | diff --git a/docs/ppl-lang/functions/ppl-json.md b/docs/ppl-lang/functions/ppl-json.md index 5b26ee427..2c0c0ca67 100644 --- a/docs/ppl-lang/functions/ppl-json.md +++ b/docs/ppl-lang/functions/ppl-json.md @@ -95,6 +95,11 @@ Example: | {"array":[1.0,2.0,0.0,-1.0,1.1,-0.11]} | +----------------------------------------+ +**Limitation** + +The list of parameters of `json_array` should all be the same type. +`json_array('this', 'is', 1.1, -0.11, true, false)` throws exception. + ### `TO_JSON_STRING` **Description** @@ -149,29 +154,6 @@ Example: +-----------+-----------+-------------+ -### `ARRAY_LENGTH` - -**Description** - -`array_length(jsonArray)` Returns the number of elements in the outermost array. - -**Argument type:** ARRAY - -ARRAY or JSON_ARRAY object. - -**Return type:** INTEGER - -Example: - - os> source=people | eval `json_array` = json_array_length(json_array(1,2,3,4)), `empty_array` = json_array_length(json_array()) - fetched rows / total rows = 1/1 - +--------------+---------------+ - | json_array | empty_array | - +--------------+---------------+ - | 4 | 0 | - +--------------+---------------+ - - ### `JSON_EXTRACT` **Description** @@ -280,3 +262,189 @@ Example: |------------------+---------| | 13 | null | +------------------+---------+ + +### `FORALL` + +**Description** + +`forall(json_array, lambda)` Evaluates whether a lambda predicate holds for all elements in the json_array. + +**Argument type:** ARRAY, LAMBDA + +**Return type:** BOOLEAN + +Returns `TRUE` if all elements in the array satisfy the lambda predicate, otherwise `FALSE`. + +Example: + + os> source=people | eval array = json_array(1, -1, 2), result = forall(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | false | + +-----------+ + + os> source=people | eval array = json_array(1, 3, 2), result = forall(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | true | + +-----------+ + +**Note:** The lambda expression can access the nested fields of the array elements. This applies to all lambda functions introduced in this document. + +Consider constructing the following array: + + array = [ + {"a":1, "b":1}, + {"a":-1, "b":2} + ] + +and perform lambda functions against the nested fields `a` or `b`. See the examples: + + os> source=people | eval array = json_array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.a > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | false | + +-----------+ + + os> source=people | eval array = json_array(json_object("a", 1, "b", 1), json_object("a" , -1, "b", 2)), result = forall(array, x -> x.b > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | true | + +-----------+ + +### `EXISTS` + +**Description** + +`exists(json_array, lambda)` Evaluates whether a lambda predicate holds for one or more elements in the json_array. + +**Argument type:** ARRAY, LAMBDA + +**Return type:** BOOLEAN + +Returns `TRUE` if at least one element in the array satisfies the lambda predicate, otherwise `FALSE`. + +Example: + + os> source=people | eval array = json_array(1, -1, 2), result = exists(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | true | + +-----------+ + + os> source=people | eval array = json_array(-1, -3, -2), result = exists(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | false | + +-----------+ + + +### `FILTER` + +**Description** + +`filter(json_array, lambda)` Filters the input json_array using the given lambda function. + +**Argument type:** ARRAY, LAMBDA + +**Return type:** ARRAY + +An ARRAY that contains all elements in the input json_array that satisfy the lambda predicate. + +Example: + + os> source=people | eval array = json_array(1, -1, 2), result = filter(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | [1, 2] | + +-----------+ + + os> source=people | eval array = json_array(-1, -3, -2), result = filter(array, x -> x > 0) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | [] | + +-----------+ + +### `TRANSFORM` + +**Description** + +`transform(json_array, lambda)` Transform elements in a json_array using the lambda transform function. The second argument implies the index of the element if using binary lambda function. This is similar to a `map` in functional programming. + +**Argument type:** ARRAY, LAMBDA + +**Return type:** ARRAY + +An ARRAY that contains the result of applying the lambda transform function to each element in the input array. + +Example: + + os> source=people | eval array = json_array(1, 2, 3), result = transform(array, x -> x + 1) | fields result + fetched rows / total rows = 1/1 + +--------------+ + | result | + +--------------+ + | [2, 3, 4] | + +--------------+ + + os> source=people | eval array = json_array(1, 2, 3), result = transform(array, (x, i) -> x + i) | fields result + fetched rows / total rows = 1/1 + +--------------+ + | result | + +--------------+ + | [1, 3, 5] | + +--------------+ + +### `REDUCE` + +**Description** + +`reduce(json_array, start, merge_lambda, finish_lambda)` Applies a binary merge lambda function to a start value and all elements in the json_array, and reduces this to a single state. The final state is converted into the final result by applying a finish lambda function. + +**Argument type:** ARRAY, ANY, LAMBDA, LAMBDA + +**Return type:** ANY + +The final result of applying the lambda functions to the start value and the input json_array. + +Example: + + os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | 6 | + +-----------+ + + os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 10, (acc, x) -> acc + x) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | 16 | + +-----------+ + + os> source=people | eval array = json_array(1, 2, 3), result = reduce(array, 0, (acc, x) -> acc + x, acc -> acc * 10) | fields result + fetched rows / total rows = 1/1 + +-----------+ + | result | + +-----------+ + | 60 | + +-----------+ diff --git a/docs/ppl-lang/ppl-correlation-command.md b/docs/ppl-lang/ppl-correlation-command.md index 2e8507a14..74e04da86 100644 --- a/docs/ppl-lang/ppl-correlation-command.md +++ b/docs/ppl-lang/ppl-correlation-command.md @@ -1,4 +1,4 @@ -## PPL Correlation Command +## PPL `correlation` command > This is an experimental command - it may be removed in future versions diff --git a/docs/ppl-lang/ppl-dedup-command.md b/docs/ppl-lang/ppl-dedup-command.md index 28fe7f4a4..4e06d275e 100644 --- a/docs/ppl-lang/ppl-dedup-command.md +++ b/docs/ppl-lang/ppl-dedup-command.md @@ -1,6 +1,6 @@ -# PPL dedup command +## PPL `dedup` command -## Table of contents +### Table of contents - [Description](#description) - [Syntax](#syntax) @@ -11,11 +11,11 @@ - [Example 4: Dedup in consecutive document](#example-4-dedup-in-consecutive-document) - [Limitation](#limitation) -## Description +### Description Using `dedup` command to remove identical document defined by field from the search result. -## Syntax +### Syntax ```sql dedup [int] [keepempty=] [consecutive=] diff --git a/docs/ppl-lang/ppl-eval-command.md b/docs/ppl-lang/ppl-eval-command.md index 1908c087c..e98d4d4f2 100644 --- a/docs/ppl-lang/ppl-eval-command.md +++ b/docs/ppl-lang/ppl-eval-command.md @@ -1,10 +1,10 @@ -# PPL `eval` command +## PPL `eval` command -## Description +### Description The ``eval`` command evaluate the expression and append the result to the search result. -## Syntax +### Syntax ```sql eval = ["," = ]... ``` diff --git a/docs/ppl-lang/ppl-fields-command.md b/docs/ppl-lang/ppl-fields-command.md index e37fc644f..4ef041ee2 100644 --- a/docs/ppl-lang/ppl-fields-command.md +++ b/docs/ppl-lang/ppl-fields-command.md @@ -1,12 +1,12 @@ ## PPL `fields` command -**Description** +### Description Using ``field`` command to keep or remove fields from the search result. -**Syntax** +### Syntax -field [+|-] +`field [+|-] ` * index: optional. if the plus (+) is used, only the fields specified in the field list will be keep. if the minus (-) is used, all the fields specified in the field list will be removed. **Default** + * field list: mandatory. comma-delimited keep or remove fields. diff --git a/docs/ppl-lang/ppl-fieldsummary-command.md b/docs/ppl-lang/ppl-fieldsummary-command.md index 468c2046b..2015cf815 100644 --- a/docs/ppl-lang/ppl-fieldsummary-command.md +++ b/docs/ppl-lang/ppl-fieldsummary-command.md @@ -1,11 +1,11 @@ ## PPL `fieldsummary` command -**Description** +### Description Using `fieldsummary` command to : - Calculate basic statistics for each field (count, distinct count, min, max, avg, stddev, mean ) - Determine the data type of each field -**Syntax** +### Syntax `... | fieldsummary (nulls=true/false)` diff --git a/docs/ppl-lang/ppl-grok-command.md b/docs/ppl-lang/ppl-grok-command.md index 06028109b..8d5946563 100644 --- a/docs/ppl-lang/ppl-grok-command.md +++ b/docs/ppl-lang/ppl-grok-command.md @@ -1,4 +1,4 @@ -## PPL Correlation Command +## PPL `grok` command ### Description diff --git a/docs/ppl-lang/ppl-head-command.md b/docs/ppl-lang/ppl-head-command.md index e4172b1c6..51a87db3b 100644 --- a/docs/ppl-lang/ppl-head-command.md +++ b/docs/ppl-lang/ppl-head-command.md @@ -1,4 +1,4 @@ -## PPL `head` Command +## PPL `head` command **Description** The ``head`` command returns the first N number of specified results after an optional offset in search order. diff --git a/docs/ppl-lang/ppl-join-command.md b/docs/ppl-lang/ppl-join-command.md index b374bce5f..f04f1c5c1 100644 --- a/docs/ppl-lang/ppl-join-command.md +++ b/docs/ppl-lang/ppl-join-command.md @@ -1,10 +1,115 @@ -## PPL Join Command +## PPL `join` command -## Overview +### Description -[Trace analytics](https://opensearch.org/docs/latest/observability-plugin/trace/ta-dashboards/) considered using SQL/PPL for its queries, but some graphs rely on joining two indices (span index and service map index) together which is not supported by SQL/PPL. Trace analytics was implemented with DSL + javascript, would be good if `join` being added to SQL could support this use case. +`JOIN` command combines two datasets together. The left side could be an index or results from a piped commands, the right side could be either an index or a subquery. -### Schema +### Syntax + +`[joinType] join [leftAlias] [rightAlias] [joinHints] on ` + +**joinType** +- Syntax: `[INNER] | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS | [LEFT] SEMI | [LEFT] ANTI` +- Optional +- Description: The type of join to perform. The default is `INNER` if not specified. + +**leftAlias** +- Syntax: `left = ` +- Optional +- Description: The subquery alias to use with the left join side, to avoid ambiguous naming. + +**rightAlias** +- Syntax: `right = ` +- Optional +- Description: The subquery alias to use with the right join side, to avoid ambiguous naming. + +**joinHints** +- Syntax: `[hint.left.key1 = value1 hint.right.key2 = value2]` +- Optional +- Description: Zero or more space-separated join hints in the form of `Key` = `Value`. The key must start with `hint.left.` or `hint.right.` + +**joinCriteria** +- Syntax: `` +- Required +- Description: The syntax starts with `ON`. It could be any comparison expression. Generally, the join criteria looks like `.=.`. For example: `l.id = r.id`. If the join criteria contains multiple conditions, you can specify `AND` and `OR` operator between each comparison expression. For example, `l.id = r.id AND l.email = r.email AND (r.age > 65 OR r.age < 18)`. + +**right-dataset** +- Required +- Description: Right dataset could be either an index or a subquery with/without alias. + +### Example 1: two indices join + +PPL query: + + os> source=customer | join ON c_custkey = o_custkey orders + | fields c_custkey, c_nationkey, c_mktsegment, o_orderkey, o_orderstatus, o_totalprice | head 10 + fetched rows / total rows = 10/10 + +----------+-------------+-------------+------------+---------------+-------------+ + | c_custkey| c_nationkey | c_mktsegment| o_orderkey | o_orderstatus | o_totalprice| + +----------+-------------+-------------+------------+---------------+-------------+ + | 36901 | 13 | AUTOMOBILE | 1 | O | 173665.47 | + | 78002 | 10 | AUTOMOBILE | 2 | O | 46929.18 | + | 123314 | 15 | MACHINERY | 3 | F | 193846.25 | + | 136777 | 10 | HOUSEHOLD | 4 | O | 32151.78 | + | 44485 | 20 | FURNITURE | 5 | F | 144659.2 | + | 55624 | 7 | AUTOMOBILE | 6 | F | 58749.59 | + | 39136 | 5 | FURNITURE | 7 | O | 252004.18 | + | 130057 | 9 | FURNITURE | 32 | O | 208660.75 | + | 66958 | 18 | MACHINERY | 33 | F | 163243.98 | + | 61001 | 3 | FURNITURE | 34 | O | 58949.67 | + +----------+-------------+-------------+------------+---------------+-------------+ + +### Example 2: three indices join + +PPL query: + + os> source=customer | join ON c_custkey = o_custkey orders | join ON c_nationkey = n_nationkey nation + | fields c_custkey, c_mktsegment, o_orderkey, o_orderstatus, o_totalprice, n_name | head 10 + fetched rows / total rows = 10/10 + +----------+-------------+------------+---------------+-------------+--------------+ + | c_custkey| c_mktsegment| o_orderkey | o_orderstatus | o_totalprice| n_name | + +----------+-------------+------------+---------------+-------------+--------------+ + | 36901 | AUTOMOBILE | 1 | O | 173665.47 | JORDAN | + | 78002 | AUTOMOBILE | 2 | O | 46929.18 | IRAN | + | 123314 | MACHINERY | 3 | F | 193846.25 | MOROCCO | + | 136777 | HOUSEHOLD | 4 | O | 32151.78 | IRAN | + | 44485 | FURNITURE | 5 | F | 144659.2 | SAUDI ARABIA | + | 55624 | AUTOMOBILE | 6 | F | 58749.59 | GERMANY | + | 39136 | FURNITURE | 7 | O | 252004.18 | ETHIOPIA | + | 130057 | FURNITURE | 32 | O | 208660.75 | INDONESIA | + | 66958 | MACHINERY | 33 | F | 163243.98 | CHINA | + | 61001 | FURNITURE | 34 | O | 58949.67 | CANADA | + +----------+-------------+------------+---------------+-------------+--------------+ + +### Example 3: join a subquery in right side + +PPL query: + + os>source=supplier| join right = revenue0 ON s_suppkey = supplier_no + [ + source=lineitem | where l_shipdate >= date('1996-01-01') AND l_shipdate < date_add(date('1996-01-01'), interval 3 month) + | eval supplier_no = l_suppkey | stats sum(l_extendedprice * (1 - l_discount)) as total_revenue by supplier_no + ] + | fields s_name, s_phone, total_revenue, supplier_no | head 10 + fetched rows / total rows = 10/10 + +---------------------+----------------+-------------------+-------------+ + | s_name | s_phone | total_revenue | supplier_no | + +---------------------+----------------+-------------------+-------------+ + | Supplier#000007747 | 24-911-546-3505| 636204.0279 | 7747 | + | Supplier#000007748 | 29-535-184-2277| 538311.8099 | 7748 | + | Supplier#000007749 | 18-225-478-7489| 743462.4473000001 | 7749 | + | Supplier#000007750 | 28-680-484-7044| 616828.2220999999 | 7750 | + | Supplier#000007751 | 20-990-606-7343| 1092975.1925 | 7751 | + | Supplier#000007752 | 12-936-258-6650| 1090399.9666 | 7752 | + | Supplier#000007753 | 22-394-329-1153| 777130.7457000001 | 7753 | + | Supplier#000007754 | 26-941-591-5320| 866600.0501 | 7754 | + | Supplier#000007755 | 32-138-467-4225| 702256.7030000001 | 7755 | + | Supplier#000007756 | 29-860-205-8019| 1304979.0511999999| 7756 | + +---------------------+----------------+-------------------+-------------+ + +### Example 4: complex example in OTEL + +**Schema** There will be at least 2 indices, `otel-v1-apm-span-*` (large) and `otel-v1-apm-service-map` (small). @@ -30,154 +135,47 @@ Relevant fields from indices: Full schemas are defined in data-prepper repo: [`otel-v1-apm-span-*`](https://github.com/opensearch-project/data-prepper/blob/04dd7bd18977294800cf4b77d7f01914def75f23/docs/schemas/trace-analytics/otel-v1-apm-span-index-template.md), [`otel-v1-apm-service-map`](https://github.com/opensearch-project/data-prepper/blob/4e5f83814c4a0eed2a1ca9bab0693b9e32240c97/docs/schemas/trace-analytics/otel-v1-apm-service-map-index-template.md) -### Requirement - -Support `join` to calculate the following: +**Requirement** For each service, join span index on service map index to calculate metrics under different type of filters. ![image](https://user-images.githubusercontent.com/28062824/194170062-f0dd1d57-c5eb-44db-95e0-6b3b4e52f25a.png) -This sample query calculates latency when filtered by trace group `client_cancel_order` for the `order` service. I only have a subquery example, don't have the join version of the query.. - -```sql -SELECT avg(durationInNanos) -FROM `otel-v1-apm-span-000001` t1 -WHERE t1.serviceName = `order` - AND ((t1.name in - (SELECT target.resource - FROM `otel-v1-apm-service-map` - WHERE serviceName = `order` - AND traceGroupName = `client_cancel_order`) - AND t1.parentSpanId != NULL) - OR (t1.parentSpanId = NULL - AND t1.name = `client_cancel_order`)) - AND t1.traceId in - (SELECT traceId - FROM `otel-v1-apm-span-000001` - WHERE serviceName = `order`) -``` -## Migrate to PPL - -### Syntax of Join Command - -```sql -SEARCH source= -| -| [joinType] JOIN - [leftAlias] - [rightAlias] - [joinHints] - ON joinCriteria - -| -``` -**joinType** -- Syntax: `[INNER] | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS | [LEFT] SEMI | [LEFT] ANTI` -- Optional -- Description: The type of join to perform. The default is `INNER` if not specified. +This sample query calculates latency when filtered by trace group `client_cancel_order` for the `order` service. I only have a subquery example, don't have the join version of the query. -**leftAlias** -- Syntax: `left = ` -- Optional -- Description: The subquery alias to use with the left join side, to avoid ambiguous naming. - -**rightAlias** -- Syntax: `right = ` -- Optional -- Description: The subquery alias to use with the right join side, to avoid ambiguous naming. - -**joinHints** -- Syntax: `[hint.left.key1 = value1 hint.right.key2 = value2]` -- Optional -- Description: Zero or more space-separated join hints in the form of `Key` = `Value`. The key must start with `hint.left.` or `hint.right.` - -**joinCriteria** -- Syntax: `` -- Required -- Description: The syntax starts with `ON`. It could be any comparison expression. Generally, the join criteria looks like `.=.`. For example: `l.id = r.id`. If the join criteria contains multiple conditions, you can specify `AND` and `OR` operator between each comparison expression. For example, `l.id = r.id AND l.email = r.email AND (r.age > 65 OR r.age < 18)`. - -**right-table** -- Required -- Description: The index or table name of join right-side. Sub-search is unsupported in join right side for now. - -### Rewriting -```sql -SEARCH source=otel-v1-apm-span-000001 +PPL query: +``` +source=otel-v1-apm-span-000001 | WHERE serviceName = 'order' | JOIN left=t1 right=t2 ON t1.traceId = t2.traceId AND t2.serviceName = 'order' - otel-v1-apm-span-000001 -- self inner join -| EVAL s_name = t1.name -- rename to avoid ambiguous -| EVAL s_parentSpanId = t1.parentSpanId -- RENAME command would be better when it is supported -| EVAL s_durationInNanos = t1.durationInNanos -| FIELDS s_name, s_parentSpanId, s_durationInNanos -- reduce colunms in join + otel-v1-apm-span-000001 // self inner join +| RENAME s_name as t1.name +| RENAME s_parentSpanId as t1.parentSpanId +| RENAME s_durationInNanos as t1.durationInNanos +| FIELDS s_name, s_parentSpanId, s_durationInNanos // reduce colunms in join | LEFT JOIN left=s1 right=t3 ON s_name = t3.target.resource AND t3.serviceName = 'order' AND t3.traceGroupName = 'client_cancel_order' otel-v1-apm-service-map | WHERE (s_parentSpanId IS NOT NULL OR (s_parentSpanId IS NULL AND s_name = 'client_cancel_order')) -| STATS avg(s_durationInNanos) -- no need to add alias if there is no ambiguous -``` - - -### More examples - -Migration from SQL query (TPC-H Q13): -```sql -SELECT c_count, COUNT(*) AS custdist -FROM - ( SELECT c_custkey, COUNT(o_orderkey) c_count - FROM customer LEFT OUTER JOIN orders ON c_custkey = o_custkey - AND o_comment NOT LIKE '%unusual%packages%' - GROUP BY c_custkey - ) AS c_orders -GROUP BY c_count -ORDER BY custdist DESC, c_count DESC; -``` -Rewritten by PPL Join query: -```sql -SEARCH source=customer -| FIELDS c_custkey -| LEFT OUTER JOIN - ON c_custkey = o_custkey AND o_comment NOT LIKE '%unusual%packages%' - orders -| STATS count(o_orderkey) AS c_count BY c_custkey -| STATS count() AS custdist BY c_count -| SORT - custdist, - c_count -``` -_- **Limitation: sub-searches is unsupported in join right side**_ - -If sub-searches is supported, above ppl query could be rewritten as: -```sql -SEARCH source=customer -| FIELDS c_custkey -| LEFT OUTER JOIN - ON c_custkey = o_custkey - [ - SEARCH source=orders - | WHERE o_comment NOT LIKE '%unusual%packages%' - | FIELDS o_orderkey, o_custkey - ] -| STATS count(o_orderkey) AS c_count BY c_custkey -| STATS count() AS custdist BY c_count -| SORT - custdist, - c_count +| STATS avg(s_durationInNanos) ``` ### Comparison with [Correlation](ppl-correlation-command) A primary difference between `correlate` and `join` is that both sides of `correlate` are tables, but both sides of `join` are subqueries. For example: -```sql +``` source = testTable1 - | where country = 'Canada' OR country = 'England' - | eval cname = lower(name) - | fields cname, country, year, month - | inner join left=l, right=r - ON l.cname = r.name AND l.country = r.country AND l.year = 2023 AND r.month = 4 - testTable2s +| where country = 'Canada' OR country = 'England' +| eval cname = lower(name) +| fields cname, country, year, month +| inner join left=l right=r + ON l.cname = r.name AND l.country = r.country AND l.year = 2023 AND r.month = 4 + testTable2s ``` The subquery alias `l` does not represent the `testTable1` table itself. Instead, it represents the subquery: -```sql +``` source = testTable1 | where country = 'Canada' OR country = 'England' | eval cname = lower(name) diff --git a/docs/ppl-lang/ppl-lookup-command.md b/docs/ppl-lang/ppl-lookup-command.md index 1b8350533..87cf34bac 100644 --- a/docs/ppl-lang/ppl-lookup-command.md +++ b/docs/ppl-lang/ppl-lookup-command.md @@ -1,20 +1,18 @@ -## PPL Lookup Command +## PPL `lookup` command -## Overview +### Description Lookup command enriches your search data by adding or replacing data from a lookup index (dimension table). You can extend fields of an index with values from a dimension table, append or replace values when lookup condition is matched. As an alternative of [Join command](ppl-join-command), lookup command is more suitable for enriching the source data with a static dataset. -### Syntax of Lookup Command +### Syntax -```sql -SEARCH source= -| -| LOOKUP ( [AS ])... - [(REPLACE | APPEND) ( [AS ])...] -| ``` +LOOKUP ( [AS ])... + [(REPLACE | APPEND) ( [AS ])...] +``` + **lookupIndex** - Required - Description: the name of lookup index (dimension table) @@ -44,26 +42,49 @@ SEARCH source= - Description: If you specify REPLACE, matched values in \ field overwrite the values in result. If you specify APPEND, matched values in \ field only append to the missing values in result. ### Usage -> LOOKUP id AS cid REPLACE mail AS email
-> LOOKUP name REPLACE mail AS email
-> LOOKUP id AS cid, name APPEND address, mail AS email
-> LOOKUP id
- -### Example -```sql -SEARCH source= -| WHERE orderType = 'Cancelled' -| LOOKUP account_list, mkt_id AS mkt_code REPLACE amount, account_name AS name -| STATS count(mkt_code), avg(amount) BY name -``` -```sql -SEARCH source= -| DEDUP market_id -| EVAL category=replace(category, "-", ".") -| EVAL category=ltrim(category, "dvp.") -| LOOKUP bounce_category category AS category APPEND classification -``` -```sql -SEARCH source= -| LOOKUP bounce_category category -``` +- `LOOKUP id AS cid REPLACE mail AS email` +- `LOOKUP name REPLACE mail AS email` +- `LOOKUP id AS cid, name APPEND address, mail AS email` +- `LOOKUP id` + +### Examples 1: replace + +PPL query: + + os>source=people | LOOKUP work_info uid AS id REPLACE department | head 10 + fetched rows / total rows = 10/10 + +------+-----------+-------------+-----------+--------+------------------+ + | id | name | occupation | country | salary | department | + +------+-----------+-------------+-----------+--------+------------------+ + | 1000 | Daniel | Teacher | Canada | 56486 | CUSTOMER_SERVICE | + | 1001 | Joseph | Lawyer | Denmark | 135943 | FINANCE | + | 1002 | David | Artist | Finland | 60391 | DATA | + | 1003 | Charlotte | Lawyer | Denmark | 42173 | LEGAL | + | 1004 | Isabella | Veterinarian| Australia | 117699 | MARKETING | + | 1005 | Lily | Engineer | Italy | 37526 | IT | + | 1006 | Emily | Dentist | Denmark | 125340 | MARKETING | + | 1007 | James | Lawyer | Germany | 56532 | LEGAL | + | 1008 | Lucas | Lawyer | Japan | 87782 | DATA | + | 1009 | Sophia | Architect | Sweden | 37597 | MARKETING | + +------+-----------+-------------+-----------+--------+------------------+ + +### Examples 2: append + +PPL query: + + os>source=people| LOOKUP work_info uid AS ID, name APPEND department | where isnotnull(department) | head 10 + fetched rows / total rows = 10/10 + +------+---------+-------------+-------------+--------+------------+ + | id | name | occupation | country | salary | department | + +------+---------+-------------+-------------+--------+------------+ + | 1018 | Emma | Architect | USA | 72400 | IT | + | 1032 | James | Pilot | Netherlands | 71698 | SALES | + | 1043 | Jane | Nurse | Brazil | 45016 | FINANCE | + | 1046 | Joseph | Pharmacist | Mexico | 109152 | OPERATIONS | + | 1064 | Joseph | Electrician | New Zealand | 50253 | LEGAL | + | 1090 | Matthew | Psychologist| Germany | 73396 | DATA | + | 1103 | Emily | Electrician | Switzerland | 98391 | DATA | + | 1114 | Jake | Nurse | Denmark | 53418 | SALES | + | 1115 | Sofia | Engineer | Mexico | 64829 | OPERATIONS | + | 1122 | Oliver | Scientist | Netherlands | 31146 | DATA | + +------+---------+-------------+-------------+--------+------------+ diff --git a/docs/ppl-lang/ppl-parse-command.md b/docs/ppl-lang/ppl-parse-command.md index 10be21cc0..0e000756e 100644 --- a/docs/ppl-lang/ppl-parse-command.md +++ b/docs/ppl-lang/ppl-parse-command.md @@ -1,4 +1,4 @@ -## PPL Parse Command +## PPL `parse` command ### Description diff --git a/docs/ppl-lang/ppl-rare-command.md b/docs/ppl-lang/ppl-rare-command.md index e3ad21f4e..93967e6fe 100644 --- a/docs/ppl-lang/ppl-rare-command.md +++ b/docs/ppl-lang/ppl-rare-command.md @@ -1,11 +1,11 @@ -## PPL rare Command +## PPL `rare` command -**Description** -Using ``rare`` command to find the least common tuple of values of all fields in the field list. +### Description +Using `rare` command to find the least common tuple of values of all fields in the field list. **Note**: A maximum of 10 results is returned for each distinct tuple of values of the group-by fields. -**Syntax** +### Syntax `rare [N] [by-clause]` `rare_approx [N] [by-clause]` diff --git a/docs/ppl-lang/ppl-search-command.md b/docs/ppl-lang/ppl-search-command.md index bccfd04f0..6e1cf0e50 100644 --- a/docs/ppl-lang/ppl-search-command.md +++ b/docs/ppl-lang/ppl-search-command.md @@ -1,7 +1,7 @@ ## PPL `search` command ### Description -Using ``search`` command to retrieve document from the index. ``search`` command could be only used as the first command in the PPL query. +Using `search` command to retrieve document from the index. `search` command could be only used as the first command in the PPL query. ### Syntax diff --git a/docs/ppl-lang/ppl-sort-command.md b/docs/ppl-lang/ppl-sort-command.md index c3bf304d7..dd9b4b33d 100644 --- a/docs/ppl-lang/ppl-sort-command.md +++ b/docs/ppl-lang/ppl-sort-command.md @@ -1,7 +1,7 @@ -## PPL `sort`command +## PPL `sort` command ### Description -Using ``sort`` command to sorts all the search result by the specified fields. +Using `sort` command to sorts all the search result by the specified fields. ### Syntax diff --git a/docs/ppl-lang/ppl-stats-command.md b/docs/ppl-lang/ppl-stats-command.md index 552f83e46..a73800b26 100644 --- a/docs/ppl-lang/ppl-stats-command.md +++ b/docs/ppl-lang/ppl-stats-command.md @@ -1,7 +1,7 @@ ## PPL `stats` command ### Description -Using ``stats`` command to calculate the aggregation from search result. +Using `stats` command to calculate the aggregation from search result. ### NULL/MISSING values handling: diff --git a/docs/ppl-lang/ppl-subquery-command.md b/docs/ppl-lang/ppl-subquery-command.md index c4a0c337c..766b37130 100644 --- a/docs/ppl-lang/ppl-subquery-command.md +++ b/docs/ppl-lang/ppl-subquery-command.md @@ -1,27 +1,27 @@ -## PPL SubQuery Commands: +## PPL `subquery` command -### Syntax -The subquery command should be implemented using a clean, logical syntax that integrates with existing PPL structure. +### Description +The subquery commands contain 4 types: `InSubquery`, `ExistsSubquery`, `ScalarSubquery` and `RelationSubquery`. +`InSubquery`, `ExistsSubquery` and `ScalarSubquery` are subquery expressions, their common usage is in Where clause(`where `) and Search filter(`search source=* `). -```sql -source=logs | where field in [ subquery source=events | where condition | fields field ] +For example, a subquery expression could be used in boolean expression: ``` - -In this example, the primary search (`source=logs`) is filtered by results from the subquery (`source=events`). - -The subquery command should allow nested queries to be as complex as necessary, supporting multiple levels of nesting. - -Example: - -```sql - source=logs | where id in [ subquery source=users | where user in [ subquery source=actions | where action="login" | fields user] | fields uid ] +| where orders.order_id in [ source=returns | where return_reason="damaged" | field order_id ] ``` +The `orders.order_id in [ source=... ]` is a ``. -For additional info See [Issue](https://github.com/opensearch-project/opensearch-spark/issues/661) - ---- +But `RelationSubquery` is not a subquery expression, it is a subquery plan. +[Recall the join command doc](ppl-join-command.md), the example is a subquery/subsearch **plan**, rather than a **expression**. -### InSubquery usage +### Syntax +- `where [not] in [ source=... | ... | ... ]` (InSubquery) +- `where [not] exists [ source=... | ... | ... ]` (ExistsSubquery) +- `where = [ source=... | ... | ... ]` (ScalarSubquery) +- `source=[ source= ...]` (RelationSubquery) +- `| join ON condition [ source= ]` (RelationSubquery in join right side) + +### Usage +InSubquery: - `source = outer | where a in [ source = inner | fields b ]` - `source = outer | where (a) in [ source = inner | fields b ]` - `source = outer | where (a,b,c) in [ source = inner | fields d,e,f ]` @@ -33,92 +33,9 @@ For additional info See [Issue](https://github.com/opensearch-project/opensearch - `source = outer | where a in [ source = inner1 | where b not in [ source = inner2 | fields c ] | fields b ]` (nested) - `source = table1 | inner join left = l right = r on l.a = r.a AND r.a in [ source = inner | fields d ] | fields l.a, r.a, b, c` (as join filter) -**_SQL Migration examples with IN-Subquery PPL:_** -1. tpch q4 (in-subquery with aggregation) -```sql -select - o_orderpriority, - count(*) as order_count -from - orders -where - o_orderdate >= date '1993-07-01' - and o_orderdate < date '1993-07-01' + interval '3' month - and o_orderkey in ( - select - l_orderkey - from - lineitem - where l_commitdate < l_receiptdate - ) -group by - o_orderpriority -order by - o_orderpriority -``` -Rewritten by PPL InSubquery query: -```sql -source = orders -| where o_orderdate >= "1993-07-01" and o_orderdate < "1993-10-01" and o_orderkey IN - [ source = lineitem - | where l_commitdate < l_receiptdate - | fields l_orderkey - ] -| stats count(1) as order_count by o_orderpriority -| sort o_orderpriority -| fields o_orderpriority, order_count -``` -2.tpch q20 (nested in-subquery) -```sql -select - s_name, - s_address -from - supplier, - nation -where - s_suppkey in ( - select - ps_suppkey - from - partsupp - where - ps_partkey in ( - select - p_partkey - from - part - where - p_name like 'forest%' - ) - ) - and s_nationkey = n_nationkey - and n_name = 'CANADA' -order by - s_name -``` -Rewritten by PPL InSubquery query: -```sql -source = supplier -| where s_suppkey IN [ - source = partsupp - | where ps_partkey IN [ - source = part - | where like(p_name, "forest%") - | fields p_partkey - ] - | fields ps_suppkey - ] -| inner join left=l right=r on s_nationkey = n_nationkey and n_name = 'CANADA' - nation -| sort s_name -``` ---- - -### ExistsSubquery usage - -Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table inner, `e`, `f` are fields of table inner2 +ExistsSubquery: +(Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table inner, `e`, `f` are fields of table inner2) - `source = outer | where exists [ source = inner | where a = c ]` - `source = outer | where not exists [ source = inner | where a = c ]` - `source = outer | where exists [ source = inner | where a = c and b = d ]` @@ -132,48 +49,9 @@ Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table in - `source = outer | where not exists [ source = inner | where c > 10 ]` (uncorrelated exists) - `source = outer | where exists [ source = inner ] | eval l = "nonEmpty" | fields l` (special uncorrelated exists) -**_SQL Migration examples with Exists-Subquery PPL:_** - -tpch q4 (exists subquery with aggregation) -```sql -select - o_orderpriority, - count(*) as order_count -from - orders -where - o_orderdate >= date '1993-07-01' - and o_orderdate < date '1993-07-01' + interval '3' month - and exists ( - select - l_orderkey - from - lineitem - where l_orderkey = o_orderkey - and l_commitdate < l_receiptdate - ) -group by - o_orderpriority -order by - o_orderpriority -``` -Rewritten by PPL ExistsSubquery query: -```sql -source = orders -| where o_orderdate >= "1993-07-01" and o_orderdate < "1993-10-01" - and exists [ - source = lineitem - | where l_orderkey = o_orderkey and l_commitdate < l_receiptdate - ] -| stats count(1) as order_count by o_orderpriority -| sort o_orderpriority -| fields o_orderpriority, order_count -``` ---- - -### ScalarSubquery usage +ScalarSubquery: -Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table inner, `e`, `f` are fields of table nested +(Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table inner, `e`, `f` are fields of table nested) **Uncorrelated scalar subquery in Select** - `source = outer | eval m = [ source = inner | stats max(c) ] | fields m, a` @@ -203,146 +81,102 @@ Assumptions: `a`, `b` are fields of table outer, `c`, `d` are fields of table in - `source = outer | where a = [ source = inner | stats max(c) | sort c ] OR b = [ source = inner | where c = 1 | stats min(d) | sort d ]` - `source = outer | where a = [ source = inner | where c = [ source = nested | stats max(e) by f | sort f ] | stats max(d) by c | sort c | head 1 ]` -_SQL Migration examples with Scalar-Subquery PPL:_ -Example 1 -```sql -SELECT * -FROM outer -WHERE a = (SELECT max(c) - FROM inner1 - WHERE c = (SELECT max(e) - FROM inner2 - GROUP BY f - ORDER BY f - ) - GROUP BY c - ORDER BY c - LIMIT 1) -``` -Rewritten by PPL ScalarSubquery query: -```sql -source = spark_catalog.default.outer -| where a = [ - source = spark_catalog.default.inner1 - | where c = [ - source = spark_catalog.default.inner2 - | stats max(e) by f - | sort f - ] - | stats max(d) by c - | sort c - | head 1 - ] -``` -Example 2 -```sql -SELECT * FROM outer -WHERE a = (SELECT max(c) - FROM inner - ORDER BY c) -OR b = (SELECT min(d) - FROM inner - WHERE c = 1 - ORDER BY d) -``` -Rewritten by PPL ScalarSubquery query: -```sql -source = spark_catalog.default.outer -| where a = [ - source = spark_catalog.default.inner | stats max(c) | sort c - ] OR b = [ - source = spark_catalog.default.inner | where c = 1 | stats min(d) | sort d - ] -``` ---- - -### (Relation) Subquery -`InSubquery`, `ExistsSubquery` and `ScalarSubquery` are all subquery expressions. But `RelationSubquery` is not a subquery expression, it is a subquery plan which is common used in Join or From clause. - -- `source = table1 | join left = l right = r [ source = table2 | where d > 10 | head 5 ]` (subquery in join right side) +RelationSubquery: +- `source = table1 | join left = l right = r on condition [ source = table2 | where d > 10 | head 5 ]` (subquery in join right side) - `source = [ source = table1 | join left = l right = r [ source = table2 | where d > 10 | head 5 ] | stats count(a) by b ] as outer | head 1` -**_SQL Migration examples with Subquery PPL:_** - -tpch q13 -```sql -select - c_count, - count(*) as custdist -from - ( - select - c_custkey, - count(o_orderkey) as c_count - from - customer left outer join orders on - c_custkey = o_custkey - and o_comment not like '%special%requests%' - group by - c_custkey - ) as c_orders -group by - c_count -order by - custdist desc, - c_count desc -``` -Rewritten by PPL (Relation) Subquery: -```sql -SEARCH source = [ - SEARCH source = customer - | LEFT OUTER JOIN left = c right = o ON c_custkey = o_custkey - [ - SEARCH source = orders - | WHERE not like(o_comment, '%special%requests%') - ] - | STATS COUNT(o_orderkey) AS c_count BY c_custkey -] AS c_orders -| STATS COUNT(o_orderkey) AS c_count BY c_custkey -| STATS COUNT(1) AS custdist BY c_count -| SORT - custdist, - c_count -``` ---- +### Examples 1: TPC-H q20 + +InSubquery and ScalarSubquery + +PPL query: + + os> source=supplier + | join ON s_nationkey = n_nationkey nation + | where n_name = 'CANADA' + and s_suppkey in [ // InSubquery + source = partsupp + | where ps_partkey in [ // InSubquery + source = part + | where like(p_name, 'forest%') + | fields p_partkey + ] + and ps_availqty > [ // ScalarSubquery + source = lineitem + | where l_partkey = ps_partkey + and l_suppkey = ps_suppkey + and l_shipdate >= date('1994-01-01') + and l_shipdate < date_add(date('1994-01-01'), interval 1 year) + | stats sum(l_quantity) as sum_l_quantity + | eval half_sum_l_quantity = 0.5 * sum_l_quantity + | fields half_sum_l_quantity + ] + | fields ps_suppkey + ] + | fields s_suppkey, s_name, s_phone, s_acctbal, n_name | head 10 + fetched rows / total rows = 10/10 + +-----------+---------------------+----------------+----------+---------+ + | s_suppkey | s_name | s_phone | s_acctbal| n_name | + +-----------+---------------------+----------------+----------+---------+ + | 8243 | Supplier#000008243 | 13-707-547-1386| 9067.07 | CANADA | + | 736 | Supplier#000000736 | 13-681-806-8650| 5700.83 | CANADA | + | 9032 | Supplier#000009032 | 13-441-662-5539| 3982.32 | CANADA | + | 3201 | Supplier#000003201 | 13-600-413-7165| 3799.41 | CANADA | + | 3849 | Supplier#000003849 | 13-582-965-9117| 52.33 | CANADA | + | 5505 | Supplier#000005505 | 13-531-190-6523| 2023.4 | CANADA | + | 5195 | Supplier#000005195 | 13-622-661-2956| 3717.34 | CANADA | + | 9753 | Supplier#000009753 | 13-724-256-7877| 4406.93 | CANADA | + | 7135 | Supplier#000007135 | 13-367-994-6705| 4950.29 | CANADA | + | 5256 | Supplier#000005256 | 13-180-538-8836| 5624.79 | CANADA | + +-----------+---------------------+----------------+----------+---------+ + + +### Examples 2: TPC-H q22 + +RelationSubquery, ScalarSubquery and ExistsSubquery + +PPL query: + + os> source = [ // RelationSubquery + source = customer + | where substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + and c_acctbal > [ // ScalarSubquery + source = customer + | where c_acctbal > 0.00 + and substring(c_phone, 1, 2) in ('13', '31', '23', '29', '30', '18', '17') + | stats avg(c_acctbal) + ] + and not exists [ // ExistsSubquery + source = orders + | where o_custkey = c_custkey + ] + | eval cntrycode = substring(c_phone, 1, 2) + | fields cntrycode, c_acctbal + ] as custsale + | stats count() as numcust, sum(c_acctbal) as totacctbal by cntrycode + | sort cntrycode + fetched rows / total rows = 10/10 + +---------+--------------------+------------+ + | numcust | totacctbal | cntrycode | + +---------+--------------------+------------+ + | 888 | 6737713.989999999 | 13 | + | 861 | 6460573.72 | 17 | + | 964 | 7236687.4 | 18 | + | 892 | 6701457.950000001 | 23 | + | 948 | 7158866.630000001 | 29 | + | 909 | 6808436.129999999 | 30 | + | 922 | 6806670.179999999 | 31 | + +---------+--------------------+------------+ ### Additional Context -`InSubquery`, `ExistsSubquery` and `ScalarSubquery` as subquery expressions, their common usage is in `where` clause and `search filter`. - -Where command: -``` -| where | ... -``` -Search filter: -``` -search source=* | ... -``` -A subquery expression could be used in boolean expression, for example - -```sql -| where orders.order_id in [ source=returns | where return_reason="damaged" | field order_id ] -``` - -The `orders.order_id in [ source=... ]` is a ``. - -In general, we name this kind of subquery clause the `InSubquery` expression, it is a ``. - -**Subquery with Different Join Types** +#### RelationSubquery -In issue description is a `ScalarSubquery`: - -```sql -source=employees -| join source=sales on employees.employee_id = sales.employee_id -| where sales.sale_amount > [ source=targets | where target_met="true" | fields target_value ] +RelationSubquery is plan instead of expression, for example ``` - -But `RelationSubquery` is not a subquery expression, it is a subquery plan. -[Recall the join command doc](ppl-join-command.md), the example is a subquery/subsearch **plan**, rather than a **expression**. - -```sql -SEARCH source=customer +source=customer | FIELDS c_custkey -| LEFT OUTER JOIN left = c, right = o ON c.c_custkey = o.o_custkey +| LEFT OUTER JOIN left = c right = o ON c.c_custkey = o.o_custkey [ SEARCH source=orders | WHERE o_comment NOT LIKE '%unusual%packages%' @@ -351,7 +185,7 @@ SEARCH source=customer | STATS ... ``` simply into -```sql +``` SEARCH | LEFT OUTER JOIN ON [ @@ -359,21 +193,14 @@ SEARCH ] | STATS ... ``` -Apply the syntax here and simply into - -```sql -search | left join on [ search ... ] -``` - -The `[ search ...]` is not a `expression`, it's `plan`, similar to the `relation` plan -**Uncorrelated Subquery** +#### Uncorrelated Subquery An uncorrelated subquery is independent of the outer query. It is executed once, and the result is used by the outer query. It's **less common** when using `ExistsSubquery` because `ExistsSubquery` typically checks for the presence of rows that are dependent on the outer query’s row. There is a very special exists subquery which highlight by `(special uncorrelated exists)`: -```sql +``` SELECT 'nonEmpty' FROM outer WHERE EXISTS ( @@ -382,7 +209,7 @@ FROM outer ); ``` Rewritten by PPL ExistsSubquery query: -```sql +``` source = outer | where exists [ source = inner @@ -392,11 +219,11 @@ source = outer ``` This query just print "nonEmpty" if the inner table is not empty. -**Table alias in subquery** +#### Table alias in subquery Table alias is useful in query which contains a subquery, for example -```sql +``` select a, ( select sum(b) from catalog.schema.table1 as t1 diff --git a/docs/ppl-lang/ppl-top-command.md b/docs/ppl-lang/ppl-top-command.md index 93d3a7148..2bacdba50 100644 --- a/docs/ppl-lang/ppl-top-command.md +++ b/docs/ppl-lang/ppl-top-command.md @@ -1,6 +1,6 @@ -## PPL top Command +## PPL `top` command -**Description** +### Description Using ``top`` command to find the most common tuple of values of all fields in the field list. diff --git a/docs/ppl-lang/ppl-trendline-command.md b/docs/ppl-lang/ppl-trendline-command.md index b466e2e8f..b2be172cd 100644 --- a/docs/ppl-lang/ppl-trendline-command.md +++ b/docs/ppl-lang/ppl-trendline-command.md @@ -1,7 +1,7 @@ -## PPL trendline Command +## PPL `trendline` command -**Description** -Using ``trendline`` command to calculate moving averages of fields. +### Description +Using `trendline` command to calculate moving averages of fields. ### Syntax - SMA (Simple Moving Average) `TRENDLINE [sort <[+|-] sort-field>] SMA(number-of-datapoints, field) [AS alias] [SMA(number-of-datapoints, field) [AS alias]]...` diff --git a/docs/ppl-lang/ppl-where-command.md b/docs/ppl-lang/ppl-where-command.md index aa7d9299e..ec676ab62 100644 --- a/docs/ppl-lang/ppl-where-command.md +++ b/docs/ppl-lang/ppl-where-command.md @@ -1,4 +1,4 @@ -## PPL where Command +## PPL `where` command ### Description The ``where`` command bool-expression to filter the search result. The ``where`` command only return the result when bool-expression evaluated to true.