Browse Source

Refine ESQL docs handling of applies_to (#125835)

This primarily splits the old preview:true warning from the newer applies_to approach. Since all of our current applies_to examples are actually just behaviour modifications of current functions, we do not use the official docs {applies_to} syntax. However there is code to make use of that in the case where we have an entirely new function which will appear in a new version.

Co-authored-by: Alexander Spies <alexander.spies@elastic.co>
Craig Taverner 6 months ago
parent
commit
98a2c711f8
19 changed files with 324 additions and 115 deletions
  1. 1 0
      docs/reference/query-languages/esql/_snippets/functions/layout/categorize.md
  2. 1 0
      docs/reference/query-languages/esql/_snippets/functions/layout/kql.md
  3. 4 3
      docs/reference/query-languages/esql/_snippets/functions/layout/match.md
  4. 4 3
      docs/reference/query-languages/esql/_snippets/functions/layout/qstr.md
  5. 1 0
      docs/reference/query-languages/esql/_snippets/functions/layout/term.md
  6. 2 1
      docs/reference/query-languages/esql/_snippets/functions/layout/to_aggregate_metric_double.md
  7. 1 2
      docs/reference/query-languages/esql/_snippets/functions/layout/to_lower.md
  8. 1 2
      docs/reference/query-languages/esql/_snippets/functions/layout/to_upper.md
  9. 1 2
      docs/reference/query-languages/esql/_snippets/functions/layout/values.md
  10. 0 8
      docs/reference/query-languages/esql/esql-commands.md
  11. 1 1
      docs/reference/query-languages/esql/kibana/docs/functions/to_aggregate_metric_double.md
  12. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionAppliesTo.java
  13. 18 8
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionAppliesToLifecycle.java
  14. 1 4
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java
  15. 1 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Match.java
  16. 1 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java
  17. 1 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToAggregateMetricDouble.java
  18. 145 62
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3Support.java
  19. 138 14
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3SupportTests.java

+ 1 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/categorize.md

@@ -7,6 +7,7 @@ may be changed or removed in a future release. Elastic will work to fix any issu
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
 :::
 :::
 
 
+
 **Syntax**
 **Syntax**
 
 
 :::{image} ../../../images/functions/categorize.svg
 :::{image} ../../../images/functions/categorize.svg

+ 1 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/kql.md

@@ -7,6 +7,7 @@ may be changed or removed in a future release. Elastic will work to fix any issu
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
 :::
 :::
 
 
+
 **Syntax**
 **Syntax**
 
 
 :::{image} ../../../images/functions/kql.svg
 :::{image} ../../../images/functions/kql.svg

+ 4 - 3
docs/reference/query-languages/esql/_snippets/functions/layout/match.md

@@ -2,13 +2,14 @@
 
 
 ## `MATCH` [esql-match]
 ## `MATCH` [esql-match]
 :::{warning}
 :::{warning}
-###### COMING 9.1.0
-
 Do not use on production environments. This functionality is in technical preview and
 Do not use on production environments. This functionality is in technical preview and
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
+:::
 
 
-Support for optional named parameters is only available from 9.1.0
+:::{note}
+###### Serverless: GA, Elastic Stack: COMING
+Support for optional named parameters is only available in serverless, or in a future {{es}} release
 :::
 :::
 
 
 **Syntax**
 **Syntax**

+ 4 - 3
docs/reference/query-languages/esql/_snippets/functions/layout/qstr.md

@@ -2,13 +2,14 @@
 
 
 ## `QSTR` [esql-qstr]
 ## `QSTR` [esql-qstr]
 :::{warning}
 :::{warning}
-###### COMING 9.1.0
-
 Do not use on production environments. This functionality is in technical preview and
 Do not use on production environments. This functionality is in technical preview and
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
+:::
 
 
-Support for optional named parameters is only available from 9.1.0
+:::{note}
+###### Serverless: GA, Elastic Stack: COMING
+Support for optional named parameters is only available in serverless, or in a future {{es}} release
 :::
 :::
 
 
 **Syntax**
 **Syntax**

+ 1 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/term.md

@@ -7,6 +7,7 @@ may be changed or removed in a future release. Elastic will work to fix any issu
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
 :::
 :::
 
 
+
 **Syntax**
 **Syntax**
 
 
 :::{image} ../../../images/functions/term.svg
 :::{image} ../../../images/functions/term.svg

+ 2 - 1
docs/reference/query-languages/esql/_snippets/functions/layout/to_aggregate_metric_double.md

@@ -2,7 +2,8 @@
 
 
 ## `TO_AGGREGATE_METRIC_DOUBLE` [esql-to_aggregate_metric_double]
 ## `TO_AGGREGATE_METRIC_DOUBLE` [esql-to_aggregate_metric_double]
 ```{applies_to}
 ```{applies_to}
-product: COMING 9.1
+product: COMING
+serverless: GA
 ```
 ```
 
 
 **Syntax**
 **Syntax**

+ 1 - 2
docs/reference/query-languages/esql/_snippets/functions/layout/to_lower.md

@@ -2,8 +2,7 @@
 
 
 ## `TO_LOWER` [esql-to_lower]
 ## `TO_LOWER` [esql-to_lower]
 :::{note}
 :::{note}
-###### COMING 9.1.0
-
+###### Serverless: GA, Elastic Stack: COMING 9.1.0
 Support for multivalued parameters is only available from 9.1.0
 Support for multivalued parameters is only available from 9.1.0
 :::
 :::
 
 

+ 1 - 2
docs/reference/query-languages/esql/_snippets/functions/layout/to_upper.md

@@ -2,8 +2,7 @@
 
 
 ## `TO_UPPER` [esql-to_upper]
 ## `TO_UPPER` [esql-to_upper]
 :::{note}
 :::{note}
-###### COMING 9.1.0
-
+###### Serverless: GA, Elastic Stack: COMING 9.1.0
 Support for multivalued parameters is only available from 9.1.0
 Support for multivalued parameters is only available from 9.1.0
 :::
 :::
 
 

+ 1 - 2
docs/reference/query-languages/esql/_snippets/functions/layout/values.md

@@ -2,13 +2,12 @@
 
 
 ## `VALUES` [esql-values]
 ## `VALUES` [esql-values]
 :::{warning}
 :::{warning}
-###### PREVIEW 
-
 Do not use on production environments. This functionality is in technical preview and
 Do not use on production environments. This functionality is in technical preview and
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
 are not subject to the support SLA of official GA features.
 are not subject to the support SLA of official GA features.
 :::
 :::
 
 
+
 **Syntax**
 **Syntax**
 
 
 :::{image} ../../../images/functions/values.svg
 :::{image} ../../../images/functions/values.svg

+ 0 - 8
docs/reference/query-languages/esql/esql-commands.md

@@ -666,10 +666,6 @@ FROM employees
 ## `LOOKUP JOIN` [esql-lookup-join]
 ## `LOOKUP JOIN` [esql-lookup-join]
 
 
 ::::{warning}
 ::::{warning}
-```{applies_to}
-stack: preview 9.0, coming 9.1
-serverless: preview
-```
 This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
 This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
 ::::
 ::::
 
 
@@ -753,10 +749,6 @@ FROM Left
 ## `MV_EXPAND` [esql-mv_expand]
 ## `MV_EXPAND` [esql-mv_expand]
 
 
 ::::{warning}
 ::::{warning}
-```{applies_to}
-stack: preview 9.0, coming 9.1
-serverless: preview
-```
 This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
 This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
 ::::
 ::::
 
 

+ 1 - 1
docs/reference/query-languages/esql/kibana/docs/functions/to_aggregate_metric_double.md

@@ -5,7 +5,7 @@ This is generated by ESQL’s AbstractFunctionTestCase. Do no edit it. See ../RE
 ### TO_AGGREGATE_METRIC_DOUBLE
 ### TO_AGGREGATE_METRIC_DOUBLE
 Encode a numeric to an aggregate_metric_double.
 Encode a numeric to an aggregate_metric_double.
 
 
-```
+```esql
 ROW x = 3892095203
 ROW x = 3892095203
 | EVAL agg_metric = TO_AGGREGATE_METRIC_DOUBLE(x)
 | EVAL agg_metric = TO_AGGREGATE_METRIC_DOUBLE(x)
 ```
 ```

+ 2 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionAppliesTo.java

@@ -17,4 +17,6 @@ public @interface FunctionAppliesTo {
     String version() default "";
     String version() default "";
 
 
     String description() default "";
     String description() default "";
+
+    boolean serverless() default true;
 }
 }

+ 18 - 8
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionAppliesToLifecycle.java

@@ -8,12 +8,22 @@
 package org.elasticsearch.xpack.esql.expression.function;
 package org.elasticsearch.xpack.esql.expression.function;
 
 
 public enum FunctionAppliesToLifecycle {
 public enum FunctionAppliesToLifecycle {
-    PREVIEW,
-    BETA,
-    DEVELOPMENT,
-    DEPRECATED,
-    COMING,
-    DISCONTINUED,
-    UNAVAILABLE,
-    GA
+    PREVIEW(true),
+    BETA(false),
+    DEVELOPMENT(false),
+    DEPRECATED(true),
+    COMING(true),
+    DISCONTINUED(false),
+    UNAVAILABLE(false),
+    GA(true);
+
+    private final boolean serverless;
+
+    FunctionAppliesToLifecycle(boolean serverless) {
+        this.serverless = serverless;
+    }
+
+    public FunctionAppliesToLifecycle serverlessLifecycle() {
+        return serverless ? GA : this;
+    }
 }
 }

+ 1 - 4
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Values.java

@@ -23,8 +23,6 @@ import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.Example;
-import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
-import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionType;
 import org.elasticsearch.xpack.esql.expression.function.FunctionType;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.expression.function.Param;
@@ -86,8 +84,7 @@ public class Values extends AggregateFunction implements ToAggregator {
             a [Circuit Breaker Error](docs-content://troubleshoot/elasticsearch/circuit-breaker-errors.md).
             a [Circuit Breaker Error](docs-content://troubleshoot/elasticsearch/circuit-breaker-errors.md).
             ::::""",
             ::::""",
         type = FunctionType.AGGREGATE,
         type = FunctionType.AGGREGATE,
-        examples = @Example(file = "string", tag = "values-grouped"),
-        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.PREVIEW) }
+        examples = @Example(file = "string", tag = "values-grouped")
     )
     )
     public Values(
     public Values(
         Source source,
         Source source,

+ 1 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Match.java

@@ -165,8 +165,7 @@ public class Match extends FullTextFunction implements OptionalArgument, PostAna
         appliesTo = {
         appliesTo = {
             @FunctionAppliesTo(
             @FunctionAppliesTo(
                 lifeCycle = FunctionAppliesToLifecycle.COMING,
                 lifeCycle = FunctionAppliesToLifecycle.COMING,
-                version = "9.1.0",
-                description = "Support for optional named parameters is only available from 9.1.0"
+                description = "Support for optional named parameters is only available in serverless, or in a future {{es}} release"
             ) }
             ) }
     )
     )
     public Match(
     public Match(

+ 1 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java

@@ -122,8 +122,7 @@ public class QueryString extends FullTextFunction implements OptionalArgument {
         appliesTo = {
         appliesTo = {
             @FunctionAppliesTo(
             @FunctionAppliesTo(
                 lifeCycle = FunctionAppliesToLifecycle.COMING,
                 lifeCycle = FunctionAppliesToLifecycle.COMING,
-                version = "9.1.0",
-                description = "Support for optional named parameters is only available from 9.1.0"
+                description = "Support for optional named parameters is only available in serverless, or in a future {{es}} release"
             ) }
             ) }
     )
     )
     public QueryString(
     public QueryString(

+ 1 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToAggregateMetricDouble.java

@@ -71,7 +71,7 @@ public class ToAggregateMetricDouble extends AbstractConvertFunction {
         examples = {
         examples = {
             @Example(file = "convert", tag = "toAggregateMetricDouble"),
             @Example(file = "convert", tag = "toAggregateMetricDouble"),
             @Example(description = "The expression also accepts multi-values", file = "convert", tag = "toAggregateMetricDoubleMv") },
             @Example(description = "The expression also accepts multi-values", file = "convert", tag = "toAggregateMetricDoubleMv") },
-        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.COMING, version = "9.1") }
+        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.COMING) }
     )
     )
     public ToAggregateMetricDouble(
     public ToAggregateMetricDouble(
         Source source,
         Source source,

+ 145 - 62
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3Support.java

@@ -71,7 +71,23 @@ import static org.elasticsearch.xpack.esql.expression.function.AbstractFunctionT
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.mapParam;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.mapParam;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.param;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.param;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.paramWithoutAnnotation;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.paramWithoutAnnotation;
-
+import static org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle.GA;
+
+/**
+ * This class exists to support the new Docs V3 system.
+ * Between Elasticsearch 8.x and 9.0 the reference documents were completely re-written, with several key changes:
+ * <ol>
+ *     <li>Port from ASCIIDOC to MD (markdown)</li>
+ *     <li>Restructures all Elastic docs with clearer separate between reference docs and other docs</li>
+ *     <li>All versions published from the main branch,
+ *     requiring version specific information to be included in the docs as appropriate</li>
+ *     <li>Including sub-docs inside bigger docs works differently, requiring a new directory structure</li>
+ *     <li>Images and Kibana docs cannot be in the same location as snippets</li>
+ * </ol>
+ *
+ * For these reasons the docs generating code that used to live inside <code>AbstractFunctionTestCase</code> has been pulled out
+ * and partially re-written to satisfy the above requirements.
+ */
 public abstract class DocsV3Support {
 public abstract class DocsV3Support {
 
 
     static final String DOCS_WARNING =
     static final String DOCS_WARNING =
@@ -190,6 +206,7 @@ public abstract class DocsV3Support {
         operatorEntry("match_operator", ":", MatchOperator.class, OperatorCategory.SEARCH)
         operatorEntry("match_operator", ":", MatchOperator.class, OperatorCategory.SEARCH)
     );
     );
 
 
+    /** Each grouping represents a subsection in the docs. Currently, this is manually maintained, but could be partially automated */
     public enum OperatorCategory {
     public enum OperatorCategory {
         BINARY,
         BINARY,
         UNARY,
         UNARY,
@@ -200,6 +217,7 @@ public abstract class DocsV3Support {
         SEARCH
         SEARCH
     }
     }
 
 
+    /** Since operators do not exist in the function registry, we need an equivalent registry here in the docs generating code */
     public record OperatorConfig(String name, String symbol, Class<?> clazz, OperatorCategory category, boolean variadic) {}
     public record OperatorConfig(String name, String symbol, Class<?> clazz, OperatorCategory category, boolean variadic) {}
 
 
     private static Map.Entry<String, OperatorConfig> operatorEntry(
     private static Map.Entry<String, OperatorConfig> operatorEntry(
@@ -213,19 +231,53 @@ public abstract class DocsV3Support {
     }
     }
 
 
     private static Map.Entry<String, OperatorConfig> operatorEntry(String name, String symbol, Class<?> clazz, OperatorCategory category) {
     private static Map.Entry<String, OperatorConfig> operatorEntry(String name, String symbol, Class<?> clazz, OperatorCategory category) {
-        return entry(name, new OperatorConfig(name, symbol, clazz, category, false));
+        return operatorEntry(name, symbol, clazz, category, false);
+    }
+
+    @FunctionalInterface
+    interface TempFileWriter {
+        void writeToTempDir(Path dir, String extension, String str) throws IOException;
+    }
+
+    private class DocsFileWriter implements TempFileWriter {
+        @Override
+        public void writeToTempDir(Path dir, String extension, String str) throws IOException {
+            Files.createDirectories(dir);
+            Path file = dir.resolve(name + "." + extension);
+            Files.writeString(file, str);
+            logger.info("Wrote to file: {}", file);
+        }
     }
     }
 
 
     protected final String category;
     protected final String category;
     protected final String name;
     protected final String name;
+    protected final FunctionDefinition definition;
     protected final Logger logger;
     protected final Logger logger;
     private final Supplier<Map<List<DataType>, DataType>> signatures;
     private final Supplier<Map<List<DataType>, DataType>> signatures;
+    private TempFileWriter tempFileWriter;
 
 
     private DocsV3Support(String category, String name, Class<?> testClass, Supplier<Map<List<DataType>, DataType>> signatures) {
     private DocsV3Support(String category, String name, Class<?> testClass, Supplier<Map<List<DataType>, DataType>> signatures) {
+        this(category, name, null, testClass, signatures);
+    }
+
+    private DocsV3Support(
+        String category,
+        String name,
+        FunctionDefinition definition,
+        Class<?> testClass,
+        Supplier<Map<List<DataType>, DataType>> signatures
+    ) {
         this.category = category;
         this.category = category;
         this.name = name;
         this.name = name;
+        this.definition = definition == null ? definition(name) : definition;
         this.logger = LogManager.getLogger(testClass);
         this.logger = LogManager.getLogger(testClass);
         this.signatures = signatures;
         this.signatures = signatures;
+        this.tempFileWriter = new DocsFileWriter();
+    }
+
+    /** Used in tests to capture output for asserting on the content */
+    void setTempFileWriter(TempFileWriter tempFileWriter) {
+        this.tempFileWriter = tempFileWriter;
     }
     }
 
 
     String replaceLinks(String text) {
     String replaceLinks(String text) {
@@ -235,7 +287,7 @@ public abstract class DocsV3Support {
     private String replaceAsciidocLinks(String text) {
     private String replaceAsciidocLinks(String text) {
         Pattern pattern = Pattern.compile("<<([^>]*)>>");
         Pattern pattern = Pattern.compile("<<([^>]*)>>");
         Matcher matcher = pattern.matcher(text);
         Matcher matcher = pattern.matcher(text);
-        StringBuffer result = new StringBuffer();
+        StringBuilder result = new StringBuilder();
         while (matcher.find()) {
         while (matcher.find()) {
             String match = matcher.group(1);
             String match = matcher.group(1);
             matcher.appendReplacement(result, getLink(match));
             matcher.appendReplacement(result, getLink(match));
@@ -245,10 +297,10 @@ public abstract class DocsV3Support {
     }
     }
 
 
     private String replaceMacros(String text) {
     private String replaceMacros(String text) {
-        Pattern pattern = Pattern.compile("\\{([^}]+)}(/[^\\[]+)\\[([^]]+)\\]");
+        Pattern pattern = Pattern.compile("\\{([^}]+)}(/[^\\[]+)\\[([^]]+)]");
 
 
         Matcher matcher = pattern.matcher(text);
         Matcher matcher = pattern.matcher(text);
-        StringBuffer result = new StringBuffer();
+        StringBuilder result = new StringBuilder();
         while (matcher.find()) {
         while (matcher.find()) {
             String macro = matcher.group(1);
             String macro = matcher.group(1);
             String path = matcher.group(2);
             String path = matcher.group(2);
@@ -346,7 +398,7 @@ public abstract class DocsV3Support {
     void writeToTempImageDir(String str) throws IOException {
     void writeToTempImageDir(String str) throws IOException {
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("images").resolve(category);
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("images").resolve(category);
-        writeToTempDir(dir, "svg", str);
+        tempFileWriter.writeToTempDir(dir, "svg", str);
     }
     }
 
 
     void writeToTempSnippetsDir(String subdir, String str) throws IOException {
     void writeToTempSnippetsDir(String subdir, String str) throws IOException {
@@ -356,20 +408,13 @@ public abstract class DocsV3Support {
             .resolve("_snippets")
             .resolve("_snippets")
             .resolve(category)
             .resolve(category)
             .resolve(subdir);
             .resolve(subdir);
-        writeToTempDir(dir, "md", str);
+        tempFileWriter.writeToTempDir(dir, "md", str);
     }
     }
 
 
     void writeToTempKibanaDir(String subdir, String extension, String str) throws IOException {
     void writeToTempKibanaDir(String subdir, String extension, String str) throws IOException {
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("kibana").resolve(subdir).resolve(category);
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("kibana").resolve(subdir).resolve(category);
-        writeToTempDir(dir, extension, str);
-    }
-
-    private void writeToTempDir(Path dir, String extension, String str) throws IOException {
-        Files.createDirectories(dir);
-        Path file = dir.resolve(name + "." + extension);
-        Files.writeString(file, str);
-        logger.info("Wrote to file: {}", file);
+        tempFileWriter.writeToTempDir(dir, extension, str);
     }
     }
 
 
     protected abstract void renderSignature() throws IOException;
     protected abstract void renderSignature() throws IOException;
@@ -381,20 +426,37 @@ public abstract class DocsV3Support {
             super("functions", name, testClass, () -> AbstractFunctionTestCase.signatures(testClass));
             super("functions", name, testClass, () -> AbstractFunctionTestCase.signatures(testClass));
         }
         }
 
 
+        FunctionDocsSupport(
+            String name,
+            Class<?> testClass,
+            FunctionDefinition definition,
+            Supplier<Map<List<DataType>, DataType>> signatures
+        ) {
+            super("functions", name, definition, testClass, signatures);
+        }
+
         @Override
         @Override
         protected void renderSignature() throws IOException {
         protected void renderSignature() throws IOException {
             String rendered = buildFunctionSignatureSvg();
             String rendered = buildFunctionSignatureSvg();
             if (rendered == null) {
             if (rendered == null) {
-                logger.info("Skipping rendering signature because the function isn't registered");
+                logger.info("Skipping rendering signature because the function '{}' isn't registered", name);
             } else {
             } else {
-                logger.info("Writing function signature");
+                logger.info("Writing function signature: {}", name);
                 writeToTempImageDir(rendered);
                 writeToTempImageDir(rendered);
             }
             }
         }
         }
 
 
         @Override
         @Override
         protected void renderDocs() throws IOException {
         protected void renderDocs() throws IOException {
-            FunctionDefinition definition = definition(name);
+            if (definition == null) {
+                logger.info("Skipping rendering docs because the function '{}' isn't registered", name);
+            } else {
+                logger.info("Rendering function docs: {}", name);
+                renderDocs(definition);
+            }
+        }
+
+        private void renderDocs(FunctionDefinition definition) throws IOException {
             EsqlFunctionRegistry.FunctionDescription description = EsqlFunctionRegistry.description(definition);
             EsqlFunctionRegistry.FunctionDescription description = EsqlFunctionRegistry.description(definition);
             if (name.equals("case")) {
             if (name.equals("case")) {
                 /*
                 /*
@@ -460,25 +522,19 @@ public abstract class DocsV3Support {
         }
         }
 
 
         private String makePreviewText(boolean preview, FunctionAppliesTo[] functionAppliesTos) {
         private String makePreviewText(boolean preview, FunctionAppliesTo[] functionAppliesTos) {
-            StringBuilder previewDescription = new StringBuilder();
-            for (FunctionAppliesTo appliesTo : functionAppliesTos) {
-                if (appliesTo.description().isEmpty() == false) {
-                    previewDescription.append(appliesTo.description()).append("\n");
-                }
-                preview = preview || appliesTo.lifeCycle() == FunctionAppliesToLifecycle.PREVIEW;
-            }
             String appliesToTextWithAT = appliesToText(functionAppliesTos);
             String appliesToTextWithAT = appliesToText(functionAppliesTos);
             String appliesToText = appliesToTextWithoutAppliesTo(functionAppliesTos);
             String appliesToText = appliesToTextWithoutAppliesTo(functionAppliesTos);
             StringBuilder previewText = new StringBuilder();
             StringBuilder previewText = new StringBuilder();
             if (preview) {
             if (preview) {
                 // We have a preview flag, use the WARNING callout
                 // We have a preview flag, use the WARNING callout
-                previewText.append(makeCallout("warning", appliesToText + "\n" + PREVIEW_CALLOUT + "\n" + previewDescription + "\n"));
-            } else if (previewDescription.isEmpty() == false) {
-                // We have extra descriptive text, nest inside a NOTE for emphasis
-                previewText.append(makeCallout("note", appliesToText + "\n" + previewDescription));
-            } else if (appliesToTextWithAT.isEmpty() == false) {
+                previewText.append(makeCallout("warning", "\n" + PREVIEW_CALLOUT + "\n")).append("\n");
+            }
+            if (appliesToTextWithAT.isEmpty() == false) {
                 // No additional text, just use the plan applies_to syntax
                 // No additional text, just use the plan applies_to syntax
                 previewText.append(appliesToTextWithAT);
                 previewText.append(appliesToTextWithAT);
+            } else if (appliesToText.isEmpty() == false) {
+                // We have extra descriptive text, nest inside a NOTE for emphasis
+                previewText.append(makeCallout("note", appliesToText));
             }
             }
             return previewText.toString();
             return previewText.toString();
         }
         }
@@ -488,11 +544,16 @@ public abstract class DocsV3Support {
             if (functionAppliesTos.length > 0) {
             if (functionAppliesTos.length > 0) {
                 appliesToText.append("```{applies_to}\n");
                 appliesToText.append("```{applies_to}\n");
                 for (FunctionAppliesTo appliesTo : functionAppliesTos) {
                 for (FunctionAppliesTo appliesTo : functionAppliesTos) {
-                    appliesToText.append("product: ")
-                        .append(appliesTo.lifeCycle().name())
-                        .append(" ")
-                        .append(appliesTo.version())
-                        .append("\n");
+                    if (appliesTo.description().isEmpty() == false) {
+                        // If any of the appliesTo has descriptive text, we need to format things differently
+                        return "";
+                    }
+                    appliesToText.append("product: ");
+                    appendLifeCycleAndVersion(appliesToText, appliesTo);
+                    appliesToText.append("\n");
+                    if (appliesTo.serverless() && appliesTo.lifeCycle().serverlessLifecycle() == GA) {
+                        appliesToText.append("serverless: ").append(GA).append("\n");
+                    }
                 }
                 }
                 appliesToText.append("```\n");
                 appliesToText.append("```\n");
             }
             }
@@ -505,12 +566,26 @@ public abstract class DocsV3Support {
                 appliesToText.append("\n");
                 appliesToText.append("\n");
                 for (FunctionAppliesTo appliesTo : functionAppliesTos) {
                 for (FunctionAppliesTo appliesTo : functionAppliesTos) {
                     appliesToText.append("###### ");
                     appliesToText.append("###### ");
-                    appliesToText.append(appliesTo.lifeCycle().name()).append(" ").append(appliesTo.version()).append("\n");
+                    if (appliesTo.serverless() && appliesTo.lifeCycle().serverlessLifecycle() == GA) {
+                        appliesToText.append("Serverless: ").append(GA).append(", Elastic Stack: ");
+                    }
+                    appendLifeCycleAndVersion(appliesToText, appliesTo);
+                    appliesToText.append("\n");
+                    if (appliesTo.description().isEmpty() == false) {
+                        appliesToText.append(appliesTo.description()).append("\n\n");
+                    }
                 }
                 }
             }
             }
             return appliesToText.toString();
             return appliesToText.toString();
         }
         }
 
 
+        private void appendLifeCycleAndVersion(StringBuilder appliesToText, FunctionAppliesTo appliesTo) {
+            appliesToText.append(appliesTo.lifeCycle().name());
+            if (appliesTo.version().isEmpty() == false) {
+                appliesToText.append(" ").append(appliesTo.version());
+            }
+        }
+
         private void renderFullLayout(
         private void renderFullLayout(
             boolean preview,
             boolean preview,
             FunctionAppliesTo[] functionAppliesTos,
             FunctionAppliesTo[] functionAppliesTos,
@@ -559,6 +634,7 @@ public abstract class DocsV3Support {
         }
         }
     }
     }
 
 
+    /** Operator specific docs generating, since it is currently quite different from the function docs generating */
     public static class OperatorsDocsSupport extends DocsV3Support {
     public static class OperatorsDocsSupport extends DocsV3Support {
         private final OperatorConfig op;
         private final OperatorConfig op;
 
 
@@ -713,7 +789,6 @@ public abstract class DocsV3Support {
     }
     }
 
 
     protected String buildFunctionSignatureSvg() throws IOException {
     protected String buildFunctionSignatureSvg() throws IOException {
-        FunctionDefinition definition = definition(name);
         return (definition != null) ? RailRoadDiagram.functionSignature(definition) : null;
         return (definition != null) ? RailRoadDiagram.functionSignature(definition) : null;
     }
     }
 
 
@@ -750,21 +825,7 @@ public abstract class DocsV3Support {
             if (sig.getKey().size() > argNames.size()) { // skip variadic [test] cases (but not those with optional parameters)
             if (sig.getKey().size() > argNames.size()) { // skip variadic [test] cases (but not those with optional parameters)
                 continue;
                 continue;
             }
             }
-            StringBuilder b = new StringBuilder("| ");
-            for (int i = 0; i < sig.getKey().size(); i++) {
-                DataType argType = sig.getKey().get(i);
-                EsqlFunctionRegistry.ArgSignature argSignature = args.get(i);
-                if (argSignature.mapArg()) {
-                    b.append("named parameters");
-                } else {
-                    b.append(argType.esNameIfPossible());
-                }
-                b.append(" | ");
-            }
-            b.append("| ".repeat(argNames.size() - sig.getKey().size()));
-            b.append(sig.getValue().esNameIfPossible());
-            b.append(" |");
-            table.add(b.toString());
+            table.add(getTypeRow(args, sig, argNames));
         }
         }
         Collections.sort(table);
         Collections.sort(table);
         if (table.isEmpty()) {
         if (table.isEmpty()) {
@@ -775,11 +836,33 @@ public abstract class DocsV3Support {
         String rendered = DOCS_WARNING + """
         String rendered = DOCS_WARNING + """
             **Supported types**
             **Supported types**
 
 
-            """ + header + "\n" + separator + "\n" + table.stream().collect(Collectors.joining("\n")) + "\n\n";
+            """ + header + "\n" + separator + "\n" + String.join("\n", table) + "\n\n";
         logger.info("Writing function types for [{}]:\n{}", name, rendered);
         logger.info("Writing function types for [{}]:\n{}", name, rendered);
         writeToTempSnippetsDir("types", rendered);
         writeToTempSnippetsDir("types", rendered);
     }
     }
 
 
+    private static String getTypeRow(
+        List<EsqlFunctionRegistry.ArgSignature> args,
+        Map.Entry<List<DataType>, DataType> sig,
+        List<String> argNames
+    ) {
+        StringBuilder b = new StringBuilder("| ");
+        for (int i = 0; i < sig.getKey().size(); i++) {
+            DataType argType = sig.getKey().get(i);
+            EsqlFunctionRegistry.ArgSignature argSignature = args.get(i);
+            if (argSignature.mapArg()) {
+                b.append("named parameters");
+            } else {
+                b.append(argType.esNameIfPossible());
+            }
+            b.append(" | ");
+        }
+        b.append("| ".repeat(argNames.size() - sig.getKey().size()));
+        b.append(sig.getValue().esNameIfPossible());
+        b.append(" |");
+        return b.toString();
+    }
+
     void renderDescription(String description, String detailedDescription, String note) throws IOException {
     void renderDescription(String description, String detailedDescription, String note) throws IOException {
         description = replaceLinks(description.trim());
         description = replaceLinks(description.trim());
         note = replaceLinks(note);
         note = replaceLinks(note);
@@ -813,14 +896,14 @@ public abstract class DocsV3Support {
             builder.append("**Examples**\n\n");
             builder.append("**Examples**\n\n");
         }
         }
         for (Example example : info.examples()) {
         for (Example example : info.examples()) {
-            if (example.description().length() > 0) {
+            if (example.description().isEmpty() == false) {
                 builder.append(replaceLinks(example.description().trim()));
                 builder.append(replaceLinks(example.description().trim()));
                 builder.append("\n\n");
                 builder.append("\n\n");
             }
             }
             String exampleQuery = loadExampleQuery(example);
             String exampleQuery = loadExampleQuery(example);
             String exampleResult = loadExampleResult(example);
             String exampleResult = loadExampleResult(example);
             builder.append(exampleQuery).append("\n").append(exampleResult).append("\n");
             builder.append(exampleQuery).append("\n").append(exampleResult).append("\n");
-            if (example.explanation().length() > 0) {
+            if (example.explanation().isEmpty() == false) {
                 builder.append("\n");
                 builder.append("\n");
                 builder.append(replaceLinks(example.explanation().trim()));
                 builder.append(replaceLinks(example.explanation().trim()));
                 builder.append("\n\n");
                 builder.append("\n\n");
@@ -954,12 +1037,12 @@ public abstract class DocsV3Support {
     }
     }
 
 
     private String removeAsciidocLinks(String asciidoc) {
     private String removeAsciidocLinks(String asciidoc) {
-        return asciidoc.replaceAll("[^ ]+\\[([^\\]]+)\\]", "$1");
+        return asciidoc.replaceAll("[^ ]+\\[([^]]+)]", "$1");
     }
     }
 
 
     private List<Map.Entry<List<DataType>, DataType>> sortedSignatures() {
     private List<Map.Entry<List<DataType>, DataType>> sortedSignatures() {
         List<Map.Entry<List<DataType>, DataType>> sortedSignatures = new ArrayList<>(signatures.get().entrySet());
         List<Map.Entry<List<DataType>, DataType>> sortedSignatures = new ArrayList<>(signatures.get().entrySet());
-        Collections.sort(sortedSignatures, (lhs, rhs) -> {
+        sortedSignatures.sort((lhs, rhs) -> {
             int maxlen = Math.max(lhs.getKey().size(), rhs.getKey().size());
             int maxlen = Math.max(lhs.getKey().size(), rhs.getKey().size());
             for (int i = 0; i < maxlen; i++) {
             for (int i = 0; i < maxlen; i++) {
                 if (lhs.getKey().size() <= i) {
                 if (lhs.getKey().size() <= i) {
@@ -990,7 +1073,7 @@ public abstract class DocsV3Support {
         return true;
         return true;
     }
     }
 
 
-    private HashMap<String, Map<String, String>> examples = new HashMap<>();
+    private final HashMap<String, Map<String, String>> examples = new HashMap<>();
 
 
     protected String loadExampleQuery(Example example) throws IOException {
     protected String loadExampleQuery(Example example) throws IOException {
         return "```esql\n" + loadExample(example.file(), example.tag()) + "\n```\n";
         return "```esql\n" + loadExample(example.file(), example.tag()) + "\n```\n";
@@ -1040,7 +1123,7 @@ public abstract class DocsV3Support {
                         currentLines = null;
                         currentLines = null;
                         currentTag = null;
                         currentTag = null;
                     }
                     }
-                } else if (currentTag != null) {
+                } else if (currentTag != null && currentLines != null) {
                     currentLines.add(line); // Collect lines within the block
                     currentLines.add(line); // Collect lines within the block
                 }
                 }
             }
             }
@@ -1050,7 +1133,7 @@ public abstract class DocsV3Support {
 
 
     protected String reformatExample(String tag, List<String> lines) {
     protected String reformatExample(String tag, List<String> lines) {
         if (tag.endsWith("-result")) {
         if (tag.endsWith("-result")) {
-            StringBuffer sb = new StringBuffer();
+            StringBuilder sb = new StringBuilder();
             for (String line : lines) {
             for (String line : lines) {
                 sb.append(renderTableLine(line, sb.isEmpty()));
                 sb.append(renderTableLine(line, sb.isEmpty()));
             }
             }
@@ -1070,7 +1153,7 @@ public abstract class DocsV3Support {
     }
     }
 
 
     private String renderTableLine(String[] columns) {
     private String renderTableLine(String[] columns) {
-        StringBuffer sb = new StringBuffer();
+        StringBuilder sb = new StringBuilder();
         sb.append("| ");
         sb.append("| ");
         for (int i = 0; i < columns.length; i++) {
         for (int i = 0; i < columns.length; i++) {
             if (i > 0) {
             if (i > 0) {
@@ -1083,7 +1166,7 @@ public abstract class DocsV3Support {
     }
     }
 
 
     private String renderTableSpacerLine(int columns) {
     private String renderTableSpacerLine(int columns) {
-        StringBuffer sb = new StringBuffer();
+        StringBuilder sb = new StringBuilder();
         sb.append("| ");
         sb.append("| ");
         for (int i = 0; i < columns; i++) {
         for (int i = 0; i < columns; i++) {
             if (i > 0) {
             if (i > 0) {

+ 138 - 14
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3SupportTests.java

@@ -7,10 +7,20 @@
 
 
 package org.elasticsearch.xpack.esql.expression.function;
 package org.elasticsearch.xpack.esql.expression.function;
 
 
+import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.function.Function;
+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 java.io.IOException;
 import java.io.IOException;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Constructor;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.equalTo;
 
 
@@ -193,6 +203,9 @@ public class DocsV3SupportTests extends ESTestCase {
 
 
     public void testRenderingExampleFromClass() throws IOException {
     public void testRenderingExampleFromClass() throws IOException {
         String expected = """
         String expected = """
+            % This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+            **Examples**
 
 
             ```esql
             ```esql
             FROM employees
             FROM employees
@@ -261,21 +274,83 @@ public class DocsV3SupportTests extends ESTestCase {
             | --- | --- |
             | --- | --- |
             | 1 | 0 |
             | 1 | 0 |
             """;
             """;
+        TestDocsFileWriter tempFileWriter = renderTestClassDocs();
+        String rendered = tempFileWriter.rendered.get("examples/count.md");
+        assertThat(rendered.trim(), equalTo(expected.trim()));
+    }
+
+    public void testRenderingLayoutFromClass() throws IOException {
+        String expected = """
+            % This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+            ## `COUNT` [esql-count]
+            :::{warning}
+            Do not use on production environments. This functionality is in technical preview and
+            may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview
+            are not subject to the support SLA of official GA features.
+            :::
+
+            :::{note}
+            ###### Serverless: GA, Elastic Stack: COMING 9.1.0
+            Support for optional named parameters is only available from 9.1.0
+
+            ###### DEVELOPMENT
+            The ability to generate more imaginative answers to the question is under development
+
+            ###### DISCONTINUED 9.0.0
+            The ability to count the number of emojis in a string has been discontinued since 9.0.0
+            :::
+
+            **Syntax**
+
+            :::{image} ../../../images/functions/count.svg
+            :alt: Embedded
+            :class: text-center
+            :::
+
+
+            :::{include} ../parameters/count.md
+            :::
+
+            :::{include} ../description/count.md
+            :::
+
+            :::{include} ../types/count.md
+            :::
+
+            :::{include} ../examples/count.md
+            :::
+            """;
+        TestDocsFileWriter tempFileWriter = renderTestClassDocs();
+        String rendered = tempFileWriter.rendered.get("layout/count.md");
+        assertThat(rendered.trim(), equalTo(expected.trim()));
+    }
+
+    private TestDocsFileWriter renderTestClassDocs() throws IOException {
         FunctionInfo info = functionInfo(TestClass.class);
         FunctionInfo info = functionInfo(TestClass.class);
         assert info != null;
         assert info != null;
-        DocsV3Support docs = DocsV3Support.forFunctions("count", TestClass.class);
-        StringBuilder results = new StringBuilder();
-        for (Example example : info.examples()) {
-            if (example.description().isEmpty() == false) {
-                results.append("\n");
-                results.append(docs.replaceLinks(example.description().trim()));
-                results.append("\n");
-            }
-            String query = docs.loadExampleQuery(example);
-            String result = docs.loadExampleResult(example);
-            results.append("\n").append(query).append("\n").append(result);
+        FunctionDefinition definition = EsqlFunctionRegistry.def(TestClass.class, TestClass::new, "count");
+        var docs = new DocsV3Support.FunctionDocsSupport("count", TestClass.class, definition, TestClass::signatures);
+        TestDocsFileWriter tempFileWriter = new TestDocsFileWriter("count");
+        docs.setTempFileWriter(tempFileWriter);
+        docs.renderDocs();
+        return tempFileWriter;
+    }
+
+    private class TestDocsFileWriter implements DocsV3Support.TempFileWriter {
+        private final String name;
+        private final Map<String, String> rendered = new HashMap<>();
+
+        TestDocsFileWriter(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public void writeToTempDir(Path dir, String extension, String str) throws IOException {
+            String file = dir.getFileName() + "/" + name + "." + extension;
+            rendered.put(file, str);
+            logger.info("Wrote to file: {}", file);
         }
         }
-        assertThat(results.toString(), equalTo(expected));
     }
     }
 
 
     private static FunctionInfo functionInfo(Class<?> clazz) {
     private static FunctionInfo functionInfo(Class<?> clazz) {
@@ -301,9 +376,10 @@ public class DocsV3SupportTests extends ESTestCase {
         return constructors[0];
         return constructors[0];
     }
     }
 
 
-    public static class TestClass {
+    public static class TestClass extends Function {
         @FunctionInfo(
         @FunctionInfo(
             returnType = "long",
             returnType = "long",
+            preview = true,
             description = "Returns the total number (count) of input values.",
             description = "Returns the total number (count) of input values.",
             type = FunctionType.AGGREGATE,
             type = FunctionType.AGGREGATE,
             examples = {
             examples = {
@@ -325,8 +401,56 @@ public class DocsV3SupportTests extends ESTestCase {
                         `NULL`s: `COUNT(TRUE)` and `COUNT(FALSE)` are both 1, but `COUNT(NULL)` is 0.""",
                         `NULL`s: `COUNT(TRUE)` and `COUNT(FALSE)` are both 1, but `COUNT(NULL)` is 0.""",
                     file = "stats",
                     file = "stats",
                     tag = "count-or-null"
                     tag = "count-or-null"
+                ) },
+            appliesTo = {
+                @FunctionAppliesTo(
+                    lifeCycle = FunctionAppliesToLifecycle.COMING,
+                    version = "9.1.0",
+                    description = "Support for optional named parameters is only available from 9.1.0"
+                ),
+                @FunctionAppliesTo(
+                    lifeCycle = FunctionAppliesToLifecycle.DEVELOPMENT,
+                    description = "The ability to generate more imaginative answers to the question is under development"
+                ),
+                @FunctionAppliesTo(
+                    lifeCycle = FunctionAppliesToLifecycle.DISCONTINUED,
+                    version = "9.0.0",
+                    description = "The ability to count the number of emojis in a string has been discontinued since 9.0.0"
                 ) }
                 ) }
         )
         )
-        public TestClass() {}
+        public TestClass(Source source, @Param(name = "str", type = { "keyword", "text" }, description = """
+            String expression. If `null`, the function returns `null`.
+            The input can be a single- or multi-valued column or an expression.""") Expression field) {
+            super(source, List.of(field));
+        }
+
+        public static Map<List<DataType>, DataType> signatures() {
+            return Map.of(List.of(DataType.KEYWORD), DataType.LONG);
+        }
+
+        @Override
+        public DataType dataType() {
+            return DataType.LONG;
+        }
+
+        @Override
+        public Expression replaceChildren(List<Expression> newChildren) {
+            return new TestClass(source(), newChildren.getFirst());
+        }
+
+        @Override
+        protected NodeInfo<? extends Expression> info() {
+            return NodeInfo.create(this, TestClass::new, children().getFirst());
+        }
+
+        @Override
+        public String getWriteableName() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            throw new UnsupportedOperationException();
+        }
     }
     }
 }
 }