diff --git a/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md new file mode 100644 index 0000000000000..a802d002c5cf0 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/description/json_extract.md @@ -0,0 +1,6 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Description** + +Extracts a value from a JSON string using a dot-notation path expression. Path matching is case-sensitive (per the JSON specification). Returns the extracted value as a keyword string. String values are returned without surrounding quotes, numbers and booleans are returned as their string representation, and objects or arrays are returned as JSON strings. Returns `null` if either parameter is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning if the input is not valid JSON, the path does not exist, the array index is out of bounds, or the path attempts to traverse through a non-object/non-array value. + diff --git a/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md new file mode 100644 index 0000000000000..b2831394db76b --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/examples/json_extract.md @@ -0,0 +1,25 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Examples** + +```esql +ROW json = "{\\"name\\":\\"Alice\\",\\"age\\":30}" +| EVAL name = JSON_EXTRACT(json, "name") +``` + +| json:keyword | name:keyword | +| --- | --- | +| "{""name"":""Alice"",""age"":30}" | Alice | + +Extract a nested value using dot-notation: + +```esql +ROW json = "{\\"user\\":{\\"address\\":{\\"city\\":\\"London\\"}}}" +| EVAL city = JSON_EXTRACT(json, "user.address.city") +``` + +| json:keyword | city:keyword | +| --- | --- | +| "{""user"":{""address"":{""city"":""London""}}}" | London | + + diff --git a/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md new file mode 100644 index 0000000000000..637827131c891 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/layout/json_extract.md @@ -0,0 +1,23 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +## `JSON_EXTRACT` [esql-json_extract] + +**Syntax** + +:::{image} ../../../images/functions/json_extract.svg +:alt: Embedded +:class: text-center +::: + + +:::{include} ../parameters/json_extract.md +::: + +:::{include} ../description/json_extract.md +::: + +:::{include} ../types/json_extract.md +::: + +:::{include} ../examples/json_extract.md +::: diff --git a/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md new file mode 100644 index 0000000000000..e34a4a15ecebe --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/parameters/json_extract.md @@ -0,0 +1,10 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Parameters** + +`json_input` +: A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`. + +`path` +: A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`. + diff --git a/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md b/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md new file mode 100644 index 0000000000000..660271b99e470 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/types/json_extract.md @@ -0,0 +1,13 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Supported types** + +| json_input | path | result | +| --- | --- | --- | +| _source | keyword | keyword | +| _source | text | keyword | +| keyword | keyword | keyword | +| keyword | text | keyword | +| text | keyword | keyword | +| text | text | keyword | + diff --git a/docs/reference/query-languages/esql/images/functions/json_extract.svg b/docs/reference/query-languages/esql/images/functions/json_extract.svg new file mode 100644 index 0000000000000..6a729f26e88b5 --- /dev/null +++ b/docs/reference/query-languages/esql/images/functions/json_extract.svg @@ -0,0 +1 @@ +JSON_EXTRACT(json_input,path) \ No newline at end of file diff --git a/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json b/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json new file mode 100644 index 0000000000000..5443c57615382 --- /dev/null +++ b/docs/reference/query-languages/esql/kibana/definition/functions/json_extract.json @@ -0,0 +1,122 @@ +{ + "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.", + "type" : "scalar", + "name" : "json_extract", + "description" : "Extracts a value from a JSON string using a dot-notation path expression.\nPath matching is case-sensitive (per the JSON specification).\nReturns the extracted value as a keyword string. String values are returned without\nsurrounding quotes, numbers and booleans are returned as their string representation,\nand objects or arrays are returned as JSON strings. Returns `null` if either parameter\nis `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning\nif the input is not valid JSON, the path does not exist, the array index is out of bounds,\nor the path attempts to traverse through a non-object/non-array value.", + "signatures" : [ + { + "params" : [ + { + "name" : "json_input", + "type" : "_source", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "keyword", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + }, + { + "params" : [ + { + "name" : "json_input", + "type" : "_source", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "text", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + }, + { + "params" : [ + { + "name" : "json_input", + "type" : "keyword", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "keyword", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + }, + { + "params" : [ + { + "name" : "json_input", + "type" : "keyword", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "text", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + }, + { + "params" : [ + { + "name" : "json_input", + "type" : "text", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "keyword", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + }, + { + "params" : [ + { + "name" : "json_input", + "type" : "text", + "optional" : false, + "description" : "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + }, + { + "name" : "path", + "type" : "text", + "optional" : false, + "description" : "A dot-notation path expression identifying the value to extract. Use dot notation for nested fields (e.g., `user.name`) and bracket notation for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + } + ], + "variadic" : false, + "returnType" : "keyword" + } + ], + "examples" : [ + "ROW json = \"{\\\\\"name\\\\\":\\\\\"Alice\\\\\",\\\\\"age\\\\\":30}\"\n| EVAL name = JSON_EXTRACT(json, \"name\")", + "ROW json = \"{\\\\\"user\\\\\":{\\\\\"address\\\\\":{\\\\\"city\\\\\":\\\\\"London\\\\\"}}}\"\n| EVAL city = JSON_EXTRACT(json, \"user.address.city\")" + ], + "preview" : false, + "snapshot_only" : false +} diff --git a/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md b/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md new file mode 100644 index 0000000000000..7422beba8e144 --- /dev/null +++ b/docs/reference/query-languages/esql/kibana/docs/functions/json_extract.md @@ -0,0 +1,16 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +### JSON EXTRACT +Extracts a value from a JSON string using a dot-notation path expression. +Path matching is case-sensitive (per the JSON specification). +Returns the extracted value as a keyword string. String values are returned without +surrounding quotes, numbers and booleans are returned as their string representation, +and objects or arrays are returned as JSON strings. Returns `null` if either parameter +is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning +if the input is not valid JSON, the path does not exist, the array index is out of bounds, +or the path attempts to traverse through a non-object/non-array value. + +```esql +ROW json = "{\\"name\\":\\"Alice\\",\\"age\\":30}" +| EVAL name = JSON_EXTRACT(json, "name") +``` diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec new file mode 100644 index 0000000000000..c7d8b10608dd9 --- /dev/null +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/json_extract.csv-spec @@ -0,0 +1,155 @@ +jsonExtractString +required_capability: fn_json_extract +// tag::json_extract[] +ROW json = "{\"name\":\"Alice\",\"age\":30}" +| EVAL name = JSON_EXTRACT(json, "name") +// end::json_extract[] +; + +// tag::json_extract-result[] +json:keyword | name:keyword +"{""name"":""Alice"",""age"":30}" | Alice +// end::json_extract-result[] +; + +jsonExtractNumber +required_capability: fn_json_extract +ROW json = "{\"name\":\"Alice\",\"age\":30}" +| EVAL age = JSON_EXTRACT(json, "age") +; + +json:keyword | age:keyword +"{""name"":""Alice"",""age"":30}" | 30 +; + +jsonExtractBoolean +required_capability: fn_json_extract +ROW json = "{\"active\":true}" +| EVAL active = JSON_EXTRACT(json, "active") +; + +json:keyword | active:keyword +"{""active"":true}" | true +; + +jsonExtractNested +required_capability: fn_json_extract +// tag::json_extract_nested[] +ROW json = "{\"user\":{\"address\":{\"city\":\"London\"}}}" +| EVAL city = JSON_EXTRACT(json, "user.address.city") +// end::json_extract_nested[] +; + +// tag::json_extract_nested-result[] +json:keyword | city:keyword +"{""user"":{""address"":{""city"":""London""}}}" | London +// end::json_extract_nested-result[] +; + +jsonExtractArrayIndex +required_capability: fn_json_extract +ROW json = "{\"tags\":[\"a\",\"b\",\"c\"]}" +| EVAL first_tag = JSON_EXTRACT(json, "tags[0]") +; + +json:keyword | first_tag:keyword +"{""tags"":[""a"",""b"",""c""]}" | a +; + +jsonExtractMixedNesting +required_capability: fn_json_extract +ROW json = "{\"orders\":[{\"id\":1,\"item\":\"book\"},{\"id\":2,\"item\":\"pen\"}]}" +| EVAL second_item = JSON_EXTRACT(json, "orders[1].item") +; + +json:keyword | second_item:keyword +"{""orders"":[{""id"":1,""item"":""book""},{""id"":2,""item"":""pen""}]}" | pen +; + +jsonExtractObject +required_capability: fn_json_extract +ROW json = "{\"user\":{\"name\":\"Alice\",\"age\":30}}" +| EVAL user = JSON_EXTRACT(json, "user") +; + +json:keyword | user:keyword +"{""user"":{""name"":""Alice"",""age"":30}}" | "{""name"":""Alice"",""age"":30}" +; + +jsonExtractMissing +required_capability: fn_json_extract +ROW json = "{\"name\":\"Alice\"}" +| EVAL missing = JSON_EXTRACT(json, "nonexistent") +; +warning:Line 2:18: evaluation of [JSON_EXTRACT(json, \"nonexistent\")] failed, treating result as null. Only first 20 failures recorded. +warning:Line 2:18: java.lang.IllegalArgumentException: path [nonexistent] does not exist + +json:keyword | missing:keyword +"{""name"":""Alice""}" | null +; + +jsonExtractJsonNull +required_capability: fn_json_extract +ROW json = "{\"value\":null}" +| EVAL val = JSON_EXTRACT(json, "value") +; + +json:keyword | val:keyword +"{""value"":null}" | null +; + +jsonExtractInvalidJson +required_capability: fn_json_extract +ROW json = "not valid json" +| EVAL result = JSON_EXTRACT(json, "field") +; +warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"field\")] failed, treating result as null. Only first 20 failures recorded. +warning:Line 2:17: java.lang.IllegalArgumentException: invalid JSON input + +json:keyword | result:keyword +not valid json | null +; + +jsonExtractNullInput +required_capability: fn_json_extract +ROW json = null +| EVAL result = JSON_EXTRACT(json::keyword, "field") +; + +json:null | result:keyword +null | null +; + +jsonExtractNullPath +required_capability: fn_json_extract +ROW json = "{\"name\":\"Alice\"}" +| EVAL result = JSON_EXTRACT(json, null::keyword) +; + +json:keyword | result:keyword +"{""name"":""Alice""}" | null +; + +jsonExtractArrayOutOfBounds +required_capability: fn_json_extract +ROW json = "{\"tags\":[\"a\",\"b\"]}" +| EVAL result = JSON_EXTRACT(json, "tags[5]") +; +warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"tags[5]\")] failed, treating result as null. Only first 20 failures recorded. +warning:Line 2:17: java.lang.IllegalArgumentException: array index out of bounds + +json:keyword | result:keyword +"{""tags"":[""a"",""b""]}" | null +; + +jsonExtractNonObjectTraversal +required_capability: fn_json_extract +ROW json = "{\"name\":\"Alice\"}" +| EVAL result = JSON_EXTRACT(json, "name.nested") +; +warning:Line 2:17: evaluation of [JSON_EXTRACT(json, \"name.nested\")] failed, treating result as null. Only first 20 failures recorded. +warning:Line 2:17: java.lang.IllegalArgumentException: path [name.nested] does not exist + +json:keyword | result:keyword +"{""name"":""Alice""}" | null +; diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java new file mode 100644 index 0000000000000..4f23b8c716a66 --- /dev/null +++ b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractEvaluator.java @@ -0,0 +1,178 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License +// 2.0; you may not use this file except in compliance with the Elastic License +// 2.0. +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import java.lang.IllegalArgumentException; +import java.lang.Override; +import java.lang.String; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.RamUsageEstimator; +import org.elasticsearch.compute.data.Block; +import org.elasticsearch.compute.data.BytesRefBlock; +import org.elasticsearch.compute.data.BytesRefVector; +import org.elasticsearch.compute.data.Page; +import org.elasticsearch.compute.operator.DriverContext; +import org.elasticsearch.compute.operator.EvalOperator; +import org.elasticsearch.compute.operator.Warnings; +import org.elasticsearch.core.Releasables; +import org.elasticsearch.xpack.esql.core.tree.Source; + +/** + * {@link EvalOperator.ExpressionEvaluator} implementation for {@link JsonExtract}. + * This class is generated. Edit {@code EvaluatorImplementer} instead. + */ +public final class JsonExtractEvaluator implements EvalOperator.ExpressionEvaluator { + private static final long BASE_RAM_BYTES_USED = RamUsageEstimator.shallowSizeOfInstance(JsonExtractEvaluator.class); + + private final Source source; + + private final EvalOperator.ExpressionEvaluator jsonInput; + + private final EvalOperator.ExpressionEvaluator path; + + private final DriverContext driverContext; + + private Warnings warnings; + + public JsonExtractEvaluator(Source source, EvalOperator.ExpressionEvaluator jsonInput, + EvalOperator.ExpressionEvaluator path, DriverContext driverContext) { + this.source = source; + this.jsonInput = jsonInput; + this.path = path; + this.driverContext = driverContext; + } + + @Override + public Block eval(Page page) { + try (BytesRefBlock jsonInputBlock = (BytesRefBlock) jsonInput.eval(page)) { + try (BytesRefBlock pathBlock = (BytesRefBlock) path.eval(page)) { + BytesRefVector jsonInputVector = jsonInputBlock.asVector(); + if (jsonInputVector == null) { + return eval(page.getPositionCount(), jsonInputBlock, pathBlock); + } + BytesRefVector pathVector = pathBlock.asVector(); + if (pathVector == null) { + return eval(page.getPositionCount(), jsonInputBlock, pathBlock); + } + return eval(page.getPositionCount(), jsonInputVector, pathVector); + } + } + } + + @Override + public long baseRamBytesUsed() { + long baseRamBytesUsed = BASE_RAM_BYTES_USED; + baseRamBytesUsed += jsonInput.baseRamBytesUsed(); + baseRamBytesUsed += path.baseRamBytesUsed(); + return baseRamBytesUsed; + } + + public BytesRefBlock eval(int positionCount, BytesRefBlock jsonInputBlock, + BytesRefBlock pathBlock) { + try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) { + BytesRef jsonInputScratch = new BytesRef(); + BytesRef pathScratch = new BytesRef(); + position: for (int p = 0; p < positionCount; p++) { + switch (jsonInputBlock.getValueCount(p)) { + case 0: + result.appendNull(); + continue position; + case 1: + break; + default: + warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value")); + result.appendNull(); + continue position; + } + switch (pathBlock.getValueCount(p)) { + case 0: + result.appendNull(); + continue position; + case 1: + break; + default: + warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value")); + result.appendNull(); + continue position; + } + BytesRef jsonInput = jsonInputBlock.getBytesRef(jsonInputBlock.getFirstValueIndex(p), jsonInputScratch); + BytesRef path = pathBlock.getBytesRef(pathBlock.getFirstValueIndex(p), pathScratch); + try { + JsonExtract.process(result, jsonInput, path); + } catch (IllegalArgumentException e) { + warnings().registerException(e); + result.appendNull(); + } + } + return result.build(); + } + } + + public BytesRefBlock eval(int positionCount, BytesRefVector jsonInputVector, + BytesRefVector pathVector) { + try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) { + BytesRef jsonInputScratch = new BytesRef(); + BytesRef pathScratch = new BytesRef(); + position: for (int p = 0; p < positionCount; p++) { + BytesRef jsonInput = jsonInputVector.getBytesRef(p, jsonInputScratch); + BytesRef path = pathVector.getBytesRef(p, pathScratch); + try { + JsonExtract.process(result, jsonInput, path); + } catch (IllegalArgumentException e) { + warnings().registerException(e); + result.appendNull(); + } + } + return result.build(); + } + } + + @Override + public String toString() { + return "JsonExtractEvaluator[" + "jsonInput=" + jsonInput + ", path=" + path + "]"; + } + + @Override + public void close() { + Releasables.closeExpectNoException(jsonInput, path); + } + + private Warnings warnings() { + if (warnings == null) { + this.warnings = Warnings.createWarnings( + driverContext.warningsMode(), + source.source().getLineNumber(), + source.source().getColumnNumber(), + source.text() + ); + } + return warnings; + } + + static class Factory implements EvalOperator.ExpressionEvaluator.Factory { + private final Source source; + + private final EvalOperator.ExpressionEvaluator.Factory jsonInput; + + private final EvalOperator.ExpressionEvaluator.Factory path; + + public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory jsonInput, + EvalOperator.ExpressionEvaluator.Factory path) { + this.source = source; + this.jsonInput = jsonInput; + this.path = path; + } + + @Override + public JsonExtractEvaluator get(DriverContext context) { + return new JsonExtractEvaluator(source, jsonInput.get(context), path.get(context), context); + } + + @Override + public String toString() { + return "JsonExtractEvaluator[" + "jsonInput=" + jsonInput + ", path=" + path + "]"; + } + } +} diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 8e1598bfcfa18..0a0801c96566c 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -1998,9 +1998,9 @@ public enum Cap { APPROXIMATION(Build.current().isSnapshot()), /** - * Periodically emit partial aggregation results when the number of groups exceeds the threshold. + * Support for function {@code JSON_EXTRACT}. */ - PERIODIC_EMIT_PARTIAL_AGGREGATION_RESULTS, + FN_JSON_EXTRACT(Build.current().isSnapshot()), // Last capability should still have a comma for fewer merge conflicts when adding new ones :) // This comment prevents the semicolon from being on the previous capability when Spotless formats the file. diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java index e1c0ae41c533d..8b39fd5809644 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java @@ -201,6 +201,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains; import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash; +import org.elasticsearch.xpack.esql.expression.function.scalar.string.JsonExtract; import org.elasticsearch.xpack.esql.expression.function.scalar.string.LTrim; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length; @@ -427,6 +428,7 @@ private static FunctionDefinition[][] functions() { def(Contains.class, Contains::new, "contains"), def(EndsWith.class, EndsWith::new, "ends_with"), def(Hash.class, Hash::new, "hash"), + def(JsonExtract.class, JsonExtract::new, "json_extract"), def(LTrim.class, LTrim::new, "ltrim"), def(Left.class, Left::new, "left"), def(Length.class, Length::new, "length"), diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java index c7f507dd9048e..8cf34c2e1bab5 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java @@ -46,6 +46,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains; import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash; +import org.elasticsearch.xpack.esql.expression.function.scalar.string.JsonExtract; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Locate; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Md5; @@ -87,6 +88,7 @@ public static List getNamedWriteables() { entries.add(Greatest.ENTRY); entries.add(CopySign.ENTRY); entries.add(Hash.ENTRY); + entries.add(JsonExtract.ENTRY); entries.add(Hypot.ENTRY); entries.add(In.ENTRY); entries.add(InsensitiveEquals.ENTRY); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java new file mode 100644 index 0000000000000..13c2921e35949 --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtract.java @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.compute.ann.Evaluator; +import org.elasticsearch.compute.data.BytesRefBlock; +import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator; +import org.elasticsearch.xcontent.XContentParseException; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.json.JsonXContent; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.NodeInfo; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.Example; +import org.elasticsearch.xpack.esql.expression.function.FunctionInfo; +import org.elasticsearch.xpack.esql.expression.function.Param; +import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction; +import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType; + +public class JsonExtract extends EsqlScalarFunction { + /** + * Internal exception used to distinguish application-level errors (path not found, + * array index out of bounds) from JSON parser errors (malformed JSON). + *

+ * This is necessary because {@link XContentParseException} extends {@link IllegalArgumentException}. + * Without a separate exception type, we couldn't differentiate between: + *

+ */ + private static class JsonExtractException extends Exception { + JsonExtractException(String message) { + super(message); + } + } + + public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry( + Expression.class, + "JsonExtract", + JsonExtract::new + ); + + private final Expression jsonInput; + private final Expression path; + + @FunctionInfo( + returnType = "keyword", + description = """ + Extracts a value from a JSON string using a dot-notation path expression. + Path matching is case-sensitive (per the JSON specification). + Returns the extracted value as a keyword string. String values are returned without + surrounding quotes, numbers and booleans are returned as their string representation, + and objects or arrays are returned as JSON strings. Returns `null` if either parameter + is `null` or if the extracted JSON value is `null`. Returns `null` and emits a warning + if the input is not valid JSON, the path does not exist, the array index is out of bounds, + or the path attempts to traverse through a non-object/non-array value.""", + examples = { + @Example(file = "json_extract", tag = "json_extract"), + @Example(file = "json_extract", tag = "json_extract_nested", description = "Extract a nested value using dot-notation:") } + ) + public JsonExtract( + Source source, + @Param( + name = "json_input", + type = { "keyword", "text", "_source" }, + description = "A string containing valid JSON, or the `_source` field. If `null`, the function returns `null`." + ) Expression jsonInput, + @Param( + name = "path", + type = { "keyword", "text" }, + description = "A dot-notation path expression identifying the value to extract. " + + "Use dot notation for nested fields (e.g., `user.name`) and bracket notation " + + "for array indices (e.g., `items[0]`). If `null`, the function returns `null`." + ) Expression path + ) { + super(source, Arrays.asList(jsonInput, path)); + this.jsonInput = jsonInput; + this.path = path; + } + + private JsonExtract(StreamInput in) throws IOException { + this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class), in.readNamedWriteable(Expression.class)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + source().writeTo(out); + out.writeNamedWriteable(jsonInput); + out.writeNamedWriteable(path); + } + + @Override + public String getWriteableName() { + return ENTRY.name; + } + + @Override + public DataType dataType() { + return DataType.KEYWORD; + } + + /** + * Returns true if the given type is a string type or SOURCE (for _source field). + */ + private static boolean isStringOrSource(DataType t) { + return DataType.isString(t) || t == DataType.SOURCE; + } + + @Override + protected TypeResolution resolveType() { + if (childrenResolved() == false) { + return new TypeResolution("Unresolved children"); + } + + // First parameter accepts string types or SOURCE (for _source field) + TypeResolution resolution = isType(jsonInput, JsonExtract::isStringOrSource, sourceText(), FIRST, "keyword", "text", "_source"); + if (resolution.unresolved()) { + return resolution; + } + resolution = isString(path, sourceText(), SECOND); + if (resolution.unresolved()) { + return resolution; + } + + return TypeResolution.TYPE_RESOLVED; + } + + @Override + public boolean foldable() { + return jsonInput.foldable() && path.foldable(); + } + + /** + * Parses a path string into segments, converting bracket notation to segments. + * E.g., "orders[1].item" -> ["orders", "1", "item"] + */ + private static String[] parsePath(String path) { + if (path.isEmpty()) { + return new String[0]; + } + // Convert bracket notation to dot notation: "orders[1].item" -> "orders.1.item" + String converted = path.replace("[", ".").replace("]", ""); + return converted.split("\\."); + } + + @Evaluator(warnExceptions = IllegalArgumentException.class) + static void process(BytesRefBlock.Builder builder, BytesRef jsonInput, BytesRef path) { + String jsonStr = jsonInput.utf8ToString(); + String pathStr = path.utf8ToString(); + String[] pathSegments = parsePath(pathStr); + + try (XContentParser parser = JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, jsonStr)) { + XContentParser.Token token = parser.nextToken(); + if (token == null) { + throw new JsonExtractException("invalid JSON input"); + } + extractValue(builder, parser, pathSegments, 0, pathStr); + } catch (JsonExtractException e) { + throw new IllegalArgumentException(e.getMessage()); + } catch (IOException | XContentParseException e) { + // JSON parsing errors + throw new IllegalArgumentException("invalid JSON input"); + } + } + + /** + * Recursively navigates the JSON stream to extract the value at the given path. + *

+ * This method uses streaming parsing to avoid creating intermediate objects for paths + * we don't need. It walks through the JSON structure one token at a time, descending + * only into the path segments we care about and skipping everything else. + *

+ * The recursion works as follows: + *

    + *
  1. If we've consumed all path segments ({@code depth == pathSegments.length}), + * we've reached the target value - extract it.
  2. + *
  3. If the current token is an object, iterate through its fields looking for + * a key matching the current segment. Skip non-matching fields entirely.
  4. + *
  5. If the current token is an array, the segment must be a numeric index. + * Iterate through elements, skipping until we reach the target index.
  6. + *
  7. If the current token is a scalar (string, number, boolean, null) but we + * still have path segments to consume, the path is invalid.
  8. + *
+ * + * @param builder the block builder to append the extracted value to + * @param parser the JSON parser positioned at the current token + * @param pathSegments the path split into segments (e.g., ["user", "address", "city"]) + * @param depth current position in pathSegments (0-indexed) + * @param originalPath the original path string for error messages + */ + private static void extractValue( + BytesRefBlock.Builder builder, + XContentParser parser, + String[] pathSegments, + int depth, + String originalPath + ) throws IOException, JsonExtractException { + XContentParser.Token token = parser.currentToken(); + + // Base case: we've consumed all path segments, so the parser is now positioned + // at the value we want to extract + if (depth == pathSegments.length) { + extractCurrentValue(builder, parser); + return; + } + + String segment = pathSegments[depth]; + + if (token == XContentParser.Token.START_OBJECT) { + // Current value is an object - look for a field matching the current path segment. + // We iterate through all fields: FIELD_NAME token followed by the field's value. + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + String fieldName = parser.currentName(); + parser.nextToken(); // Advance from FIELD_NAME to the field's value + if (fieldName.equals(segment)) { + // Found the matching key - recurse to process the next path segment + extractValue(builder, parser, pathSegments, depth + 1, originalPath); + return; + } else { + // Not the key we want - skip this field's entire value (including nested structures) + parser.skipChildren(); + } + } + } + // Exhausted all fields without finding a match + throw new JsonExtractException("path [" + originalPath + "] does not exist"); + + } else if (token == XContentParser.Token.START_ARRAY) { + // Current value is an array - the path segment must be a numeric index. + int targetIndex; + try { + targetIndex = Integer.parseInt(segment); + } catch (NumberFormatException e) { + // Path segment isn't a number, but we're at an array - path is invalid + throw new JsonExtractException("path [" + originalPath + "] does not exist"); + } + if (targetIndex < 0) { + throw new JsonExtractException("array index out of bounds"); + } + + // Iterate through array elements until we reach the target index + int currentIndex = 0; + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + if (currentIndex == targetIndex) { + // Found the target element - recurse to process the next path segment + extractValue(builder, parser, pathSegments, depth + 1, originalPath); + return; + } + // Not the index we want - skip this element entirely + parser.skipChildren(); + currentIndex++; + } + // Reached end of array without finding the target index + throw new JsonExtractException("array index out of bounds"); + + } else { + // Current value is a scalar (string, number, boolean, null), but we still have + // path segments to consume. Can't traverse into a scalar value. + throw new JsonExtractException("path [" + originalPath + "] does not exist"); + } + } + + /** + * Extracts the current value from the parser and appends it to the builder. + */ + private static void extractCurrentValue(BytesRefBlock.Builder builder, XContentParser parser) throws IOException { + XContentParser.Token token = parser.currentToken(); + + switch (token) { + case VALUE_STRING -> builder.appendBytesRef(new BytesRef(parser.text())); + case VALUE_NUMBER -> builder.appendBytesRef(new BytesRef(parser.text())); + case VALUE_BOOLEAN -> builder.appendBytesRef(new BytesRef(Boolean.toString(parser.booleanValue()))); + case VALUE_NULL -> builder.appendNull(); + case START_OBJECT, START_ARRAY -> { + // For objects and arrays, we need to serialize them back to JSON + // Use the parser's built-in copyCurrentStructure would require an XContentBuilder + // Instead, we'll read the raw text - but XContentParser doesn't give us that easily + // So we fall back to building the structure + StringBuilder sb = new StringBuilder(); + copyCurrentStructure(sb, parser); + builder.appendBytesRef(new BytesRef(sb.toString())); + } + default -> throw new IllegalArgumentException("unexpected token: " + token); + } + } + + /** + * Copies the current JSON structure (object or array) to a StringBuilder. + */ + private static void copyCurrentStructure(StringBuilder sb, XContentParser parser) throws IOException { + XContentParser.Token token = parser.currentToken(); + + if (token == XContentParser.Token.START_OBJECT) { + sb.append('{'); + boolean first = true; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + if (first == false) { + sb.append(','); + } + first = false; + sb.append('"').append(escapeJson(parser.currentName())).append("\":"); + parser.nextToken(); + copyValue(sb, parser); + } + } + sb.append('}'); + } else if (token == XContentParser.Token.START_ARRAY) { + sb.append('['); + boolean first = true; + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + if (first == false) { + sb.append(','); + } + first = false; + copyValue(sb, parser); + } + sb.append(']'); + } + } + + /** + * Copies the current value to a StringBuilder. + */ + private static void copyValue(StringBuilder sb, XContentParser parser) throws IOException { + XContentParser.Token token = parser.currentToken(); + + switch (token) { + case VALUE_STRING -> sb.append('"').append(escapeJson(parser.text())).append('"'); + case VALUE_NUMBER -> sb.append(parser.text()); + case VALUE_BOOLEAN -> sb.append(parser.booleanValue()); + case VALUE_NULL -> sb.append("null"); + case START_OBJECT, START_ARRAY -> copyCurrentStructure(sb, parser); + default -> throw new IllegalArgumentException("unexpected token: " + token); + } + } + + /** + * Escapes special characters in a JSON string. + */ + private static String escapeJson(String s) { + StringBuilder sb = null; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + String escape = switch (c) { + case '"' -> "\\\""; + case '\\' -> "\\\\"; + case '\b' -> "\\b"; + case '\f' -> "\\f"; + case '\n' -> "\\n"; + case '\r' -> "\\r"; + case '\t' -> "\\t"; + default -> null; + }; + if (escape != null) { + if (sb == null) { + sb = new StringBuilder(s.length() + 16); + sb.append(s, 0, i); + } + sb.append(escape); + } else if (sb != null) { + sb.append(c); + } + } + return sb == null ? s : sb.toString(); + } + + @Override + public Expression replaceChildren(List newChildren) { + return new JsonExtract(source(), newChildren.get(0), newChildren.get(1)); + } + + @Override + protected NodeInfo info() { + return NodeInfo.create(this, JsonExtract::new, jsonInput, path); + } + + @Override + public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) { + ExpressionEvaluator.Factory jsonInputExpr = toEvaluator.apply(jsonInput); + ExpressionEvaluator.Factory pathExpr = toEvaluator.apply(path); + return new JsonExtractEvaluator.Factory(source(), jsonInputExpr, pathExpr); + } + + Expression jsonInput() { + return jsonInput; + } + + Expression path() { + return path; + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java new file mode 100644 index 0000000000000..edcbf192be93d --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractErrorTests.java @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.hamcrest.Matcher; + +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.equalTo; + +public class JsonExtractErrorTests extends ErrorsForCasesWithoutExamplesTestCase { + @Override + protected List cases() { + return paramsToSuppliers(JsonExtractTests.parameters()); + } + + @Override + protected Expression build(Source source, List args) { + return new JsonExtract(source, args.get(0), args.get(1)); + } + + @Override + protected Matcher expectedTypeErrorMatcher(List> validPerPosition, List signature) { + // First parameter accepts keyword, text, or _source; second parameter accepts string + return equalTo(typeErrorMessage(true, validPerPosition, signature, (v, p) -> p == 0 ? "keyword, text or _source" : "string")); + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java new file mode 100644 index 0000000000000..dd7f8f06a70d7 --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractSerializationTests.java @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests; + +import java.io.IOException; + +public class JsonExtractSerializationTests extends AbstractExpressionSerializationTests { + @Override + protected JsonExtract createTestInstance() { + Source source = randomSource(); + Expression jsonInput = randomChild(); + Expression path = randomChild(); + return new JsonExtract(source, jsonInput, path); + } + + @Override + protected JsonExtract mutateInstance(JsonExtract instance) throws IOException { + Source source = instance.source(); + Expression jsonInput = instance.jsonInput(); + Expression path = instance.path(); + switch (between(0, 1)) { + case 0 -> jsonInput = randomValueOtherThan(jsonInput, AbstractExpressionSerializationTests::randomChild); + case 1 -> path = randomValueOtherThan(path, AbstractExpressionSerializationTests::randomChild); + } + return new JsonExtract(source, jsonInput, path); + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java new file mode 100644 index 0000000000000..07f01d5d817fd --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/JsonExtractTests.java @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +/** + * Tests for {@link JsonExtract} function. + */ +public class JsonExtractTests extends AbstractScalarFunctionTestCase { + public JsonExtractTests(@Name("TestCase") Supplier testCaseSupplier) { + this.testCase = testCaseSupplier.get(); + } + + @ParametersFactory + public static Iterable parameters() { + List suppliers = new ArrayList<>(); + + // Randomized test across string type combinations + for (DataType jsonType : DataType.stringTypes()) { + for (DataType pathType : DataType.stringTypes()) { + suppliers.add( + new TestCaseSupplier( + "extract string " + TestCaseSupplier.nameFromTypes(types(jsonType, pathType)), + types(jsonType, pathType), + () -> new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("{\"name\":\"Alice\"}"), jsonType, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("name"), pathType, "path") + ), + expectedToString(), + DataType.KEYWORD, + equalTo(new BytesRef("Alice")) + ) + ) + ); + } + } + + // String value extraction + suppliers.add(supplier("{\"name\":\"Alice\",\"age\":30}", "name", new BytesRef("Alice"))); + + // Number extraction (returned as keyword string) + suppliers.add(supplier("{\"name\":\"Alice\",\"age\":30}", "age", new BytesRef("30"))); + + // Boolean extraction + suppliers.add(supplier("{\"active\":true}", "active", new BytesRef("true"))); + suppliers.add(supplier("{\"active\":false}", "active", new BytesRef("false"))); + + // Nested field extraction + suppliers.add(supplier("{\"user\":{\"address\":{\"city\":\"London\"}}}", "user.address.city", new BytesRef("London"))); + + // Array index extraction + suppliers.add(supplier("{\"tags\":[\"a\",\"b\",\"c\"]}", "tags[0]", new BytesRef("a"))); + suppliers.add(supplier("{\"tags\":[\"a\",\"b\",\"c\"]}", "tags[2]", new BytesRef("c"))); + + // Mixed nesting + suppliers.add( + supplier("{\"orders\":[{\"id\":1,\"item\":\"book\"},{\"id\":2,\"item\":\"pen\"}]}", "orders[1].item", new BytesRef("pen")) + ); + + // Missing path returns null with warning + suppliers.add(new TestCaseSupplier("missing path", types(DataType.KEYWORD, DataType.KEYWORD), () -> { + return new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("{\"name\":\"Alice\"}"), DataType.KEYWORD, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("nonexistent"), DataType.KEYWORD, "path") + ), + expectedToString(), + DataType.KEYWORD, + nullValue() + ).withWarning("Line 1:1: evaluation of [source] failed, treating result as null. Only first 20 failures recorded.") + .withWarning("Line 1:1: java.lang.IllegalArgumentException: path [nonexistent] does not exist"); + })); + + // JSON null returns ES|QL null (no warning) + suppliers.add(supplier("{\"value\":null}", "value", null)); + + // Array out of bounds returns null with warning + suppliers.add(new TestCaseSupplier("array out of bounds", types(DataType.KEYWORD, DataType.KEYWORD), () -> { + return new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("{\"tags\":[\"a\",\"b\"]}"), DataType.KEYWORD, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("tags[5]"), DataType.KEYWORD, "path") + ), + expectedToString(), + DataType.KEYWORD, + nullValue() + ).withWarning("Line 1:1: evaluation of [source] failed, treating result as null. Only first 20 failures recorded.") + .withWarning("Line 1:1: java.lang.IllegalArgumentException: array index out of bounds"); + })); + + // Traversal through non-object returns null with warning + suppliers.add(new TestCaseSupplier("non-object traversal", types(DataType.KEYWORD, DataType.KEYWORD), () -> { + return new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("{\"name\":\"Alice\"}"), DataType.KEYWORD, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("name.nested"), DataType.KEYWORD, "path") + ), + expectedToString(), + DataType.KEYWORD, + nullValue() + ).withWarning("Line 1:1: evaluation of [source] failed, treating result as null. Only first 20 failures recorded.") + .withWarning("Line 1:1: java.lang.IllegalArgumentException: path [name.nested] does not exist"); + })); + + // Extract nested object as JSON string + suppliers.add(supplier("{\"user\":{\"name\":\"Alice\",\"age\":30}}", "user", new BytesRef("{\"name\":\"Alice\",\"age\":30}"))); + + // Extract array as JSON string + suppliers.add(supplier("{\"tags\":[\"a\",\"b\",\"c\"]}", "tags", new BytesRef("[\"a\",\"b\",\"c\"]"))); + + // Invalid JSON - warning case + suppliers.add(new TestCaseSupplier("invalid json", types(DataType.KEYWORD, DataType.KEYWORD), () -> { + return new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("not valid json"), DataType.KEYWORD, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("field"), DataType.KEYWORD, "path") + ), + expectedToString(), + DataType.KEYWORD, + nullValue() + ).withWarning("Line 1:1: evaluation of [source] failed, treating result as null. Only first 20 failures recorded.") + .withWarning("Line 1:1: java.lang.IllegalArgumentException: invalid JSON input"); + })); + + // SOURCE type support (for _source field) + for (DataType pathType : DataType.stringTypes()) { + suppliers.add( + new TestCaseSupplier( + "extract from source " + TestCaseSupplier.nameFromTypes(types(DataType.SOURCE, pathType)), + types(DataType.SOURCE, pathType), + () -> new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef("{\"name\":\"Alice\"}"), DataType.SOURCE, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef("name"), pathType, "path") + ), + expectedToString(), + DataType.KEYWORD, + equalTo(new BytesRef("Alice")) + ) + ) + ); + } + + return parameterSuppliersFromTypedDataWithDefaultChecks(true, suppliers); + } + + @Override + protected Expression build(Source source, List args) { + return new JsonExtract(source, args.get(0), args.get(1)); + } + + private static TestCaseSupplier supplier(String json, String path, BytesRef expectedValue) { + String name = String.format("extract \"%s\" from json", path); + return new TestCaseSupplier(name, types(DataType.KEYWORD, DataType.KEYWORD), () -> { + return new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(new BytesRef(json), DataType.KEYWORD, "jsonInput"), + new TestCaseSupplier.TypedData(new BytesRef(path), DataType.KEYWORD, "path") + ), + expectedToString(), + DataType.KEYWORD, + expectedValue == null ? nullValue() : equalTo(expectedValue) + ); + }); + } + + private static String expectedToString() { + return "JsonExtractEvaluator[jsonInput=Attribute[channel=0], path=Attribute[channel=1]]"; + } + + private static List types(DataType firstType, DataType secondType) { + List types = new ArrayList<>(); + types.add(firstType); + types.add(secondType); + return types; + } +} diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml index 18c9034c2597c..b3f32a1955e7d 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml @@ -59,6 +59,7 @@ setup: - cosine_vector_similarity_function - inline_stats - promql_command_v0 + - fn_json_extract reason: "Test that should only be executed on snapshot versions" - do: { xpack.usage: { } } @@ -361,6 +362,7 @@ setup: # There's one of these per function but that's a ton of things to check. So we just spot check that a few exist. - not_exists: esql.functions.delay + - not_exists: esql.functions.json_extract - exists: esql.functions.idelta - exists: esql.functions.mv_sum - exists: esql.functions.to_dateperiod