diff --git a/docs/changelog/112350.yaml b/docs/changelog/112350.yaml new file mode 100644 index 0000000000000..994cd3a65c633 --- /dev/null +++ b/docs/changelog/112350.yaml @@ -0,0 +1,5 @@ +pr: 112350 +summary: "[ESQL] Add `SPACE` function" +area: ES|QL +type: enhancement +issues: [] diff --git a/docs/reference/esql/functions/description/space.asciidoc b/docs/reference/esql/functions/description/space.asciidoc new file mode 100644 index 0000000000000..ee01da64f590f --- /dev/null +++ b/docs/reference/esql/functions/description/space.asciidoc @@ -0,0 +1,5 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Description* + +Returns a string made of `number` spaces. diff --git a/docs/reference/esql/functions/examples/space.asciidoc b/docs/reference/esql/functions/examples/space.asciidoc new file mode 100644 index 0000000000000..cef3cd6139021 --- /dev/null +++ b/docs/reference/esql/functions/examples/space.asciidoc @@ -0,0 +1,13 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Example* + +[source.merge.styled,esql] +---- +include::{esql-specs}/string.csv-spec[tag=space] +---- +[%header.monospaced.styled,format=dsv,separator=|] +|=== +include::{esql-specs}/string.csv-spec[tag=space-result] +|=== + diff --git a/docs/reference/esql/functions/kibana/definition/space.json b/docs/reference/esql/functions/kibana/definition/space.json new file mode 100644 index 0000000000000..acf7466284d3b --- /dev/null +++ b/docs/reference/esql/functions/kibana/definition/space.json @@ -0,0 +1,23 @@ +{ + "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.", + "type" : "eval", + "name" : "space", + "description" : "Returns a string made of `number` spaces.", + "signatures" : [ + { + "params" : [ + { + "name" : "number", + "type" : "integer", + "optional" : false, + "description" : "Number of spaces in result." + } + ], + "variadic" : false, + "returnType" : "keyword" + } + ], + "examples" : [ + "ROW message = CONCAT(\"Hello\", SPACE(1), \"World!\");" + ] +} diff --git a/docs/reference/esql/functions/kibana/docs/space.md b/docs/reference/esql/functions/kibana/docs/space.md new file mode 100644 index 0000000000000..3112bf953dd65 --- /dev/null +++ b/docs/reference/esql/functions/kibana/docs/space.md @@ -0,0 +1,10 @@ + + +### SPACE +Returns a string made of `number` spaces. + +``` +ROW message = CONCAT("Hello", SPACE(1), "World!"); +``` diff --git a/docs/reference/esql/functions/layout/space.asciidoc b/docs/reference/esql/functions/layout/space.asciidoc new file mode 100644 index 0000000000000..22355d1e24978 --- /dev/null +++ b/docs/reference/esql/functions/layout/space.asciidoc @@ -0,0 +1,15 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +[discrete] +[[esql-space]] +=== `SPACE` + +*Syntax* + +[.text-center] +image::esql/functions/signature/space.svg[Embedded,opts=inline] + +include::../parameters/space.asciidoc[] +include::../description/space.asciidoc[] +include::../types/space.asciidoc[] +include::../examples/space.asciidoc[] diff --git a/docs/reference/esql/functions/parameters/space.asciidoc b/docs/reference/esql/functions/parameters/space.asciidoc new file mode 100644 index 0000000000000..de4efd34c0ba4 --- /dev/null +++ b/docs/reference/esql/functions/parameters/space.asciidoc @@ -0,0 +1,6 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Parameters* + +`number`:: +Number of spaces in result. diff --git a/docs/reference/esql/functions/signature/space.svg b/docs/reference/esql/functions/signature/space.svg new file mode 100644 index 0000000000000..c506c25dfcb16 --- /dev/null +++ b/docs/reference/esql/functions/signature/space.svg @@ -0,0 +1 @@ +SPACE(number) \ No newline at end of file diff --git a/docs/reference/esql/functions/string-functions.asciidoc b/docs/reference/esql/functions/string-functions.asciidoc index d4b120ad1c45b..ed97769b900e7 100644 --- a/docs/reference/esql/functions/string-functions.asciidoc +++ b/docs/reference/esql/functions/string-functions.asciidoc @@ -19,6 +19,7 @@ * <> * <> * <> +* <> * <> * <> * <> @@ -39,6 +40,7 @@ include::layout/repeat.asciidoc[] include::layout/replace.asciidoc[] include::layout/right.asciidoc[] include::layout/rtrim.asciidoc[] +include::layout/space.asciidoc[] include::layout/split.asciidoc[] include::layout/starts_with.asciidoc[] include::layout/substring.asciidoc[] diff --git a/docs/reference/esql/functions/types/space.asciidoc b/docs/reference/esql/functions/types/space.asciidoc new file mode 100644 index 0000000000000..3f2e89f80d3e5 --- /dev/null +++ b/docs/reference/esql/functions/types/space.asciidoc @@ -0,0 +1,9 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Supported types* + +[%header.monospaced.styled,format=dsv,separator=|] +|=== +number | result +integer | keyword +|=== diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec index 5cb174f9777f5..29f0bdf0c4805 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec @@ -72,6 +72,7 @@ double pi() "double signum(number:double|integer|long|unsigned_long)" "double sin(angle:double|integer|long|unsigned_long)" "double sinh(number:double|integer|long|unsigned_long)" +"keyword space(number:integer)" "keyword split(string:keyword|text, delim:keyword|text)" "double sqrt(number:double|integer|long|unsigned_long)" "geo_point|cartesian_point st_centroid_agg(field:geo_point|cartesian_point)" @@ -196,6 +197,7 @@ rtrim |string |"keyword|text" signum |number |"double|integer|long|unsigned_long" |"Numeric expression. If `null`, the function returns `null`." sin |angle |"double|integer|long|unsigned_long" |An angle, in radians. If `null`, the function returns `null`. sinh |number |"double|integer|long|unsigned_long" |Numeric expression. If `null`, the function returns `null`. +space |number |"integer" |Number of spaces in result. split |[string, delim] |["keyword|text", "keyword|text"] |[String expression. If `null`\, the function returns `null`., Delimiter. Only single byte delimiters are currently supported.] sqrt |number |"double|integer|long|unsigned_long" |"Numeric expression. If `null`, the function returns `null`." st_centroid_ag|field |"geo_point|cartesian_point" |[""] @@ -320,6 +322,7 @@ rtrim |Removes trailing whitespaces from a string. signum |Returns the sign of the given number. It returns `-1` for negative numbers, `0` for `0` and `1` for positive numbers. sin |Returns the {wikipedia}/Sine_and_cosine[sine] of an angle. sinh |Returns the {wikipedia}/Hyperbolic_functions[hyperbolic sine] of a number. +space |Returns a string made of `number` spaces. split |Split a single valued string into multiple strings. sqrt |Returns the square root of a number. The input can be any numeric value, the return value is always a double. Square roots of negative numbers and infinities are null. st_centroid_ag|Calculate the spatial centroid over a field with spatial point geometry type. @@ -446,6 +449,7 @@ rtrim |"keyword|text" signum |double |false |false |false sin |double |false |false |false sinh |double |false |false |false +space |keyword |false |false |false split |keyword |[false, false] |false |false sqrt |double |false |false |false st_centroid_ag|"geo_point|cartesian_point" |false |false |true @@ -508,5 +512,5 @@ countFunctions#[skip:-8.15.99] meta functions | stats a = count(*), b = count(*), c = count(*) | mv_expand c; a:long | b:long | c:long -115 | 115 | 115 +116 | 116 | 116 ; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec index f85a3bb01ad40..ffcceab26bcaf 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec @@ -1595,4 +1595,100 @@ emp_no:integer | languages:integer | first_name:keyword 10004 | 5 | ChirstianChirstianChirstianChirstianChirstian ; +space +required_capability: space +// tag::space[] +ROW message = CONCAT("Hello", SPACE(1), "World!"); +// end::space[] +// tag::space-result[] +message:keyword +Hello World! +// end::space-result[] +; + +spaceLength +required_capability: space +ROW len = LENGTH(SPACE(12)); + +len:integer +12 +; + +spaceCalculated +required_capability: space +ROW inner_width = 20, title = "Title" +| EVAL lspace = SPACE((inner_width-LENGTH(title))/2), + rspace = SPACE(inner_width-LENGTH(lspace)-LENGTH(title)), + centered_title = CONCAT("<",lspace,title,rspace,">") +| KEEP inner_width, centered_title; + +inner_width:integer | centered_title:keyword +20 | < Title > +; + +spaceNumberFromIndex +required_capability: space +FROM employees +| EVAL s = CONCAT("<",SPACE(languages),">") +| WHERE emp_no < 10005 +| SORT emp_no +| KEEP emp_no, languages, s; + +emp_no:integer | languages:integer | s:keyword +10001 | 2 | < > +10002 | 5 | < > +10003 | 4 | < > +10004 | 5 | < > +; + +spaceZero +required_capability: space +ROW s = SPACE(0); + +s:keyword +"" +; + +spaceNull +required_capability: space +ROW s = SPACE(null); + +s:keyword +null +; + +spaceNegative +required_capability: space +FROM employees | SORT emp_no | LIMIT 1 | EVAL s = SPACE(-LENGTH(first_name)) | KEEP s; + +warning:Line 1:51: evaluation of [SPACE(-LENGTH(first_name))] failed, treating result as null. Only first 20 failures recorded. +warning:Line 1:51: java.lang.IllegalArgumentException: Number parameter cannot be negative, found [-6] + +s:keyword +null +; + +spaceComplex +required_capability: space +ROW x = 1+2+3, y = null | EVAL z = x + 5 | LIMIT 1 +| EVAL xs = SPACE(x), xsc = CONCAT("<",xs,">"), + xn = SPACE(null), + xz = SPACE(z), xzc = CONCAT("<",xz,">"), + lxz = LENGTH(xz), lxs = LENGTH(xs), ltr = LENGTH(TRIM(xz)) +| DROP xs, xz; + +x:integer | y:null | z:integer | xsc:keyword | xn:keyword | xzc:keyword | lxz:integer | lxs:integer | ltr:integer +6 | null | 11 | < > | null | < > | 11 | 6 | 0 +; + +spaceMV +required_capability: space +ROW mv = [1,2,3] | EVAL x = space(mv) | KEEP x; + +warning:Line 1:29: evaluation of [space(mv)] failed, treating result as null. Only first 20 failures recorded. +warning:Line 1:29: java.lang.IllegalArgumentException: single-value function encountered multi-value + +x:keyword +null +; diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java new file mode 100644 index 0000000000000..0252bd85f6ff8 --- /dev/null +++ b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceEvaluator.java @@ -0,0 +1,128 @@ +// 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 java.util.function.Function; +import org.elasticsearch.compute.data.Block; +import org.elasticsearch.compute.data.BytesRefBlock; +import org.elasticsearch.compute.data.IntBlock; +import org.elasticsearch.compute.data.IntVector; +import org.elasticsearch.compute.data.Page; +import org.elasticsearch.compute.operator.BreakingBytesRefBuilder; +import org.elasticsearch.compute.operator.DriverContext; +import org.elasticsearch.compute.operator.EvalOperator; +import org.elasticsearch.core.Releasables; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.function.Warnings; + +/** + * {@link EvalOperator.ExpressionEvaluator} implementation for {@link Space}. + * This class is generated. Do not edit it. + */ +public final class SpaceEvaluator implements EvalOperator.ExpressionEvaluator { + private final Warnings warnings; + + private final BreakingBytesRefBuilder scratch; + + private final EvalOperator.ExpressionEvaluator number; + + private final DriverContext driverContext; + + public SpaceEvaluator(Source source, BreakingBytesRefBuilder scratch, + EvalOperator.ExpressionEvaluator number, DriverContext driverContext) { + this.scratch = scratch; + this.number = number; + this.driverContext = driverContext; + this.warnings = Warnings.createWarnings(driverContext.warningsMode(), source); + } + + @Override + public Block eval(Page page) { + try (IntBlock numberBlock = (IntBlock) number.eval(page)) { + IntVector numberVector = numberBlock.asVector(); + if (numberVector == null) { + return eval(page.getPositionCount(), numberBlock); + } + return eval(page.getPositionCount(), numberVector); + } + } + + public BytesRefBlock eval(int positionCount, IntBlock numberBlock) { + try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) { + position: for (int p = 0; p < positionCount; p++) { + if (numberBlock.isNull(p)) { + result.appendNull(); + continue position; + } + if (numberBlock.getValueCount(p) != 1) { + if (numberBlock.getValueCount(p) > 1) { + warnings.registerException(new IllegalArgumentException("single-value function encountered multi-value")); + } + result.appendNull(); + continue position; + } + try { + result.appendBytesRef(Space.process(this.scratch, numberBlock.getInt(numberBlock.getFirstValueIndex(p)))); + } catch (IllegalArgumentException e) { + warnings.registerException(e); + result.appendNull(); + } + } + return result.build(); + } + } + + public BytesRefBlock eval(int positionCount, IntVector numberVector) { + try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) { + position: for (int p = 0; p < positionCount; p++) { + try { + result.appendBytesRef(Space.process(this.scratch, numberVector.getInt(p))); + } catch (IllegalArgumentException e) { + warnings.registerException(e); + result.appendNull(); + } + } + return result.build(); + } + } + + @Override + public String toString() { + return "SpaceEvaluator[" + "number=" + number + "]"; + } + + @Override + public void close() { + Releasables.closeExpectNoException(scratch, number); + } + + static class Factory implements EvalOperator.ExpressionEvaluator.Factory { + private final Source source; + + private final Function scratch; + + private final EvalOperator.ExpressionEvaluator.Factory number; + + public Factory(Source source, Function scratch, + EvalOperator.ExpressionEvaluator.Factory number) { + this.source = source; + this.scratch = scratch; + this.number = number; + } + + @Override + public SpaceEvaluator get(DriverContext context) { + return new SpaceEvaluator(source, scratch.apply(context), number.get(context), context); + } + + @Override + public String toString() { + return "SpaceEvaluator[" + "number=" + number + "]"; + } + } +} 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 120323ebeb7a6..ef40371b27036 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 @@ -274,7 +274,12 @@ public enum Cap { /** * Allow mixed numeric types in coalesce */ - MIXED_NUMERIC_TYPES_IN_COALESCE; + MIXED_NUMERIC_TYPES_IN_COALESCE, + + /** + * Support for requesting the "SPACE" function. + */ + SPACE; private final boolean snapshotOnly; private final FeatureFlag featureFlag; 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 0d50623fe77eb..bb2ecefabf23e 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 @@ -119,6 +119,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.string.Repeat; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Replace; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Right; +import org.elasticsearch.xpack.esql.expression.function.scalar.string.Space; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Split; import org.elasticsearch.xpack.esql.expression.function.scalar.string.StartsWith; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Substring; @@ -307,7 +308,8 @@ private FunctionDefinition[][] functions() { def(ToLower.class, ToLower::new, "to_lower"), def(ToUpper.class, ToUpper::new, "to_upper"), def(Locate.class, Locate::new, "locate"), - def(Repeat.class, Repeat::new, "repeat") }, + def(Repeat.class, Repeat::new, "repeat"), + def(Space.class, Space::new, "space") }, // date new FunctionDefinition[] { def(DateDiff.class, DateDiff::new, "date_diff"), diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java index 980d3ab0e7552..bdbc9b649c101 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/UnaryScalarFunction.java @@ -58,6 +58,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length; import org.elasticsearch.xpack.esql.expression.function.scalar.string.RLike; import org.elasticsearch.xpack.esql.expression.function.scalar.string.RTrim; +import org.elasticsearch.xpack.esql.expression.function.scalar.string.Space; import org.elasticsearch.xpack.esql.expression.function.scalar.string.Trim; import org.elasticsearch.xpack.esql.expression.function.scalar.string.WildcardLike; import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.Neg; @@ -96,6 +97,7 @@ public static List getNamedWriteables() { entries.add(Signum.ENTRY); entries.add(Sin.ENTRY); entries.add(Sinh.ENTRY); + entries.add(Space.ENTRY); entries.add(Sqrt.ENTRY); entries.add(StX.ENTRY); entries.add(StY.ENTRY); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java index b4781c7e41f98..f8b05aea324dc 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java @@ -120,15 +120,17 @@ * Rerun the {@code CsvTests}. They should find your function and maybe even pass. Add a * few more tests in the csv-spec tests. They run quickly so it isn't a big deal having * half a dozen of them per function. In fact, it's useful to add more complex combinations - * of things here, just to catch any accidental strange interactions. For example, it is - * probably a good idea to have your function passes as a parameter to another function + * of things here, just to catch any accidental strange interactions. For example, have + * your function take its input from an index like {@code FROM employees | EVAL foo=MY_FUNCTION(emp_no)}. + * It's probably a good idea to have your function passed as a parameter to another function * like {@code EVAL foo=MOST(0, MY_FUNCTION(emp_no))}. And likely useful to try the reverse * like {@code EVAL foo=MY_FUNCTION(MOST(languages + 10000, emp_no)}. * *
  • * Now it's time to make a unit test! The infrastructure for these is under some flux at - * the moment, but it's good to extend from {@code AbstractScalarFunctionTestCase}. All of + * the moment, but it's good to extend {@code AbstractScalarFunctionTestCase}. All of * these tests are parameterized and expect to spend some time finding good parameters. + * Also add serialization tests that extend {@code AbstractExpressionSerializationTests<>}. *
  • *
  • * Once you are happy with the tests run the auto formatter: diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java new file mode 100644 index 0000000000000..e6225a008fceb --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Space.java @@ -0,0 +1,127 @@ +/* + * 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.ann.Fixed; +import org.elasticsearch.compute.operator.BreakingBytesRefBuilder; +import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +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.UnaryScalarFunction; +import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import static org.elasticsearch.common.unit.ByteSizeUnit.MB; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType; +import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD; + +public class Space extends UnaryScalarFunction { + public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Space", Space::new); + + private static final long MAX_LENGTH = MB.toBytes(1); + + @FunctionInfo( + returnType = "keyword", + description = "Returns a string made of `number` spaces.", + examples = @Example(file = "string", tag = "space") + ) + public Space( + Source source, + @Param(name = "number", type = { "integer" }, description = "Number of spaces in result.") Expression number + ) { + super(source, number); + } + + private Space(StreamInput in) throws IOException { + this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + source().writeTo(out); + out.writeNamedWriteable(field); + } + + @Override + public String getWriteableName() { + return ENTRY.name; + } + + @Override + public DataType dataType() { + return KEYWORD; + } + + @Override + protected TypeResolution resolveType() { + if (childrenResolved() == false) { + return new TypeResolution("Unresolved children"); + } + + return isType(field, dt -> dt == DataType.INTEGER, sourceText(), DEFAULT, "integer"); + } + + @Evaluator(warnExceptions = { IllegalArgumentException.class }) + static BytesRef process(@Fixed(includeInToString = false, build = true) BreakingBytesRefBuilder scratch, int number) { + checkNumber(number); + scratch.grow(number); + scratch.setLength(number); + Arrays.fill(scratch.bytes(), 0, number, (byte) ' '); + return scratch.bytesRefView(); + } + + static void checkNumber(int number) { + if (number < 0) { + throw new IllegalArgumentException("Number parameter cannot be negative, found [" + number + "]"); + } + if (number > MAX_LENGTH) { + throw new IllegalArgumentException("Creating strings longer than [" + MAX_LENGTH + "] bytes is not supported"); + } + } + + @Override + public Expression replaceChildren(List newChildren) { + return new Space(source(), newChildren.get(0)); + } + + @Override + protected NodeInfo info() { + return NodeInfo.create(this, Space::new, field); + } + + @Override + public ExpressionEvaluator.Factory toEvaluator(Function toEvaluator) { + if (field.foldable()) { + Object folded = field.fold(); + if (folded instanceof Integer num) { + checkNumber(num); + return toEvaluator.apply(new Literal(source(), " ".repeat(num), KEYWORD)); + } + } + + ExpressionEvaluator.Factory numberExpr = toEvaluator.apply(field); + return new SpaceEvaluator.Factory(source(), context -> new BreakingBytesRefBuilder(context.breaker(), "space"), numberExpr); + } + +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java new file mode 100644 index 0000000000000..bf3b15145e0f3 --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceSerializationTests.java @@ -0,0 +1,31 @@ +/* + * 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 SpaceSerializationTests extends AbstractExpressionSerializationTests { + @Override + protected Space createTestInstance() { + Source source = randomSource(); + Expression number = randomChild(); + return new Space(source, number); + } + + @Override + protected Space mutateInstance(Space instance) throws IOException { + Source source = instance.source(); + Expression number = instance.field(); + number = randomValueOtherThan(number, AbstractExpressionSerializationTests::randomChild); + return new Space(source, number); + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java new file mode 100644 index 0000000000000..308ce2c9d932f --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/SpaceTests.java @@ -0,0 +1,81 @@ +/* + * 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.elasticsearch.common.unit.ByteSizeUnit.MB; +import static org.hamcrest.Matchers.nullValue; + +public class SpaceTests extends AbstractScalarFunctionTestCase { + public SpaceTests(@Name("TestCase") Supplier testCaseSupplier) { + this.testCase = testCaseSupplier.get(); + } + + @ParametersFactory + public static Iterable parameters() { + + List cases = new ArrayList<>(); + + TestCaseSupplier.forUnaryInt( + cases, + "SpaceEvaluator[number=Attribute[channel=0]]", + DataType.KEYWORD, + i -> new BytesRef(" ".repeat(i)), + 0, + 10, + List.of() + ); + + cases.add(new TestCaseSupplier("Space with negative number", List.of(DataType.INTEGER), () -> { + int number = randomIntBetween(-10, -1); + return new TestCaseSupplier.TestCase( + List.of(new TestCaseSupplier.TypedData(number, DataType.INTEGER, "number")), + "SpaceEvaluator[number=Attribute[channel=0]]", + DataType.KEYWORD, + nullValue() + ).withWarning("Line -1:-1: evaluation of [] failed, treating result as null. Only first 20 failures recorded.") + .withWarning("Line -1:-1: java.lang.IllegalArgumentException: Number parameter cannot be negative, found [" + number + "]") + .withFoldingException(IllegalArgumentException.class, "Number parameter cannot be negative, found [" + number + "]"); + })); + + cases.add(new TestCaseSupplier("Space with number too large", List.of(DataType.INTEGER), () -> { + int max = (int) MB.toBytes(1); + int number = randomIntBetween(max + 1, max + 10); + return new TestCaseSupplier.TestCase( + List.of(new TestCaseSupplier.TypedData(number, DataType.INTEGER, "number")), + "SpaceEvaluator[number=Attribute[channel=0]]", + DataType.KEYWORD, + nullValue() + ).withWarning("Line -1:-1: evaluation of [] failed, treating result as null. Only first 20 failures recorded.") + .withWarning( + "Line -1:-1: java.lang.IllegalArgumentException: Creating strings longer than [" + max + "] bytes is not supported" + ) + .withFoldingException(IllegalArgumentException.class, "Creating strings longer than [" + max + "] bytes is not supported"); + })); + + return parameterSuppliersFromTypedDataWithDefaultChecks(true, cases, (v, p) -> "integer"); + } + + @Override + protected Expression build(Source source, List args) { + return new Space(source, args.get(0)); + } +}