diff --git a/core/src/main/java/org/opensearch/sql/analysis/Analyzer.java b/core/src/main/java/org/opensearch/sql/analysis/Analyzer.java
index d7b8ceb1bff..07e5abfff3d 100644
--- a/core/src/main/java/org/opensearch/sql/analysis/Analyzer.java
+++ b/core/src/main/java/org/opensearch/sql/analysis/Analyzer.java
@@ -81,6 +81,7 @@
import org.opensearch.sql.ast.tree.Limit;
import org.opensearch.sql.ast.tree.Lookup;
import org.opensearch.sql.ast.tree.ML;
+import org.opensearch.sql.ast.tree.MakeResults;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
@@ -561,6 +562,11 @@ public LogicalPlan visitNoMv(NoMv node, AnalysisContext context) {
throw getOnlyForCalciteException("nomv");
}
+ @Override
+ public LogicalPlan visitMakeResults(MakeResults node, AnalysisContext context) {
+ throw getOnlyForCalciteException("makeresults");
+ }
+
@Override
public LogicalPlan visitMvExpand(MvExpand node, AnalysisContext context) {
throw getOnlyForCalciteException("mvexpand");
diff --git a/core/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java b/core/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java
index 6d8415fd7ea..ddc58913df1 100644
--- a/core/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java
+++ b/core/src/main/java/org/opensearch/sql/ast/AbstractNodeVisitor.java
@@ -69,6 +69,7 @@
import org.opensearch.sql.ast.tree.Limit;
import org.opensearch.sql.ast.tree.Lookup;
import org.opensearch.sql.ast.tree.ML;
+import org.opensearch.sql.ast.tree.MakeResults;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
@@ -334,6 +335,10 @@ public T visitValues(Values node, C context) {
return visitChildren(node, context);
}
+ public T visitMakeResults(MakeResults node, C context) {
+ return visitChildren(node, context);
+ }
+
public T visitAlias(Alias node, C context) {
return visitChildren(node, context);
}
diff --git a/core/src/main/java/org/opensearch/sql/ast/tree/MakeResults.java b/core/src/main/java/org/opensearch/sql/ast/tree/MakeResults.java
new file mode 100644
index 00000000000..a8b9a388f92
--- /dev/null
+++ b/core/src/main/java/org/opensearch/sql/ast/tree/MakeResults.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.ast.tree;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.ToString;
+import org.opensearch.sql.ast.AbstractNodeVisitor;
+import org.opensearch.sql.ast.Node;
+
+/**
+ * AST node for the {@code makeresults} leading command (count path). Generates {@code count}
+ * in-memory rows, each carrying a single {@code @timestamp} column set to query time.
+ *
+ *
The {@code format=csv|json data="..."} form is parsed into a shared {@link Values} node
+ * instead (see {@code MakeResultsDataParser}), so inline literal rows flow through the common
+ * {@code visitValues} builder.
+ */
+@ToString
+@Getter
+@EqualsAndHashCode(callSuper = false)
+@RequiredArgsConstructor
+public class MakeResults extends UnresolvedPlan {
+
+ private final int count;
+
+ @Override
+ public UnresolvedPlan attach(UnresolvedPlan child) {
+ throw new UnsupportedOperationException("MakeResults node is supposed to have no child node");
+ }
+
+ @Override
+ public T accept(AbstractNodeVisitor nodeVisitor, C context) {
+ return nodeVisitor.visitMakeResults(this, context);
+ }
+
+ @Override
+ public List extends Node> getChild() {
+ return ImmutableList.of();
+ }
+}
diff --git a/core/src/main/java/org/opensearch/sql/ast/tree/Values.java b/core/src/main/java/org/opensearch/sql/ast/tree/Values.java
index 65d7e8d7cb2..379f67b8590 100644
--- a/core/src/main/java/org/opensearch/sql/ast/tree/Values.java
+++ b/core/src/main/java/org/opensearch/sql/ast/tree/Values.java
@@ -9,21 +9,61 @@
import java.util.List;
import lombok.EqualsAndHashCode;
import lombok.Getter;
-import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.ast.AbstractNodeVisitor;
import org.opensearch.sql.ast.Node;
import org.opensearch.sql.ast.expression.Literal;
+import org.opensearch.sql.data.type.ExprCoreType;
/** AST node class for a sequence of literal values. */
@ToString
@Getter
@EqualsAndHashCode(callSuper = false)
-@RequiredArgsConstructor
public class Values extends UnresolvedPlan {
private final List> values;
+ /**
+ * Optional explicit column names (e.g. from {@code makeresults data=}). When {@code null},
+ * columns are positional/auto-named.
+ */
+ private final List columnNames;
+
+ /**
+ * Optional explicit column types (e.g. from {@code makeresults data=}), authoritative for the
+ * schema. Required to type a zero-row relation (header-only CSV / empty JSON array), where there
+ * are no {@link Literal}s to infer from. When {@code null}, types are inferred from the literals.
+ */
+ private final List columnTypes;
+
+ /**
+ * When {@code true}, an implicit {@code @timestamp = NOW()} column is prepended to the relation
+ * (e.g. from {@code makeresults format=json data=}, where each JSON object is treated as an event
+ * carrying a query-time timestamp). CSV {@code data=} leaves this {@code false} (pure table, no
+ * timestamp), and subsearch/dual-table callers never set it.
+ */
+ private final boolean withImplicitTimestamp;
+
+ public Values(List> values) {
+ this(values, null, null);
+ }
+
+ public Values(
+ List> values, List columnNames, List columnTypes) {
+ this(values, columnNames, columnTypes, false);
+ }
+
+ public Values(
+ List> values,
+ List columnNames,
+ List columnTypes,
+ boolean withImplicitTimestamp) {
+ this.values = values;
+ this.columnNames = columnNames;
+ this.columnTypes = columnTypes;
+ this.withImplicitTimestamp = withImplicitTimestamp;
+ }
+
@Override
public UnresolvedPlan attach(UnresolvedPlan child) {
throw new UnsupportedOperationException("Values node is supposed to have no child node");
diff --git a/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java b/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java
index 13ccdf15197..5ffe18b081a 100644
--- a/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java
+++ b/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java
@@ -97,6 +97,7 @@
import org.opensearch.sql.ast.expression.AllFieldsExcludeMeta;
import org.opensearch.sql.ast.expression.Argument;
import org.opensearch.sql.ast.expression.Argument.ArgumentMap;
+import org.opensearch.sql.ast.expression.DataType;
import org.opensearch.sql.ast.expression.Field;
import org.opensearch.sql.ast.expression.Function;
import org.opensearch.sql.ast.expression.Let;
@@ -139,6 +140,7 @@
import org.opensearch.sql.ast.tree.Lookup;
import org.opensearch.sql.ast.tree.Lookup.OutputStrategy;
import org.opensearch.sql.ast.tree.ML;
+import org.opensearch.sql.ast.tree.MakeResults;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
@@ -176,6 +178,7 @@
import org.opensearch.sql.calcite.plan.rel.LogicalSystemLimit.SystemLimitType;
import org.opensearch.sql.calcite.utils.BinUtils;
import org.opensearch.sql.calcite.utils.JoinAndLookupUtils;
+import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory;
import org.opensearch.sql.calcite.utils.PPLHintUtils;
import org.opensearch.sql.calcite.utils.PlanUtils;
import org.opensearch.sql.calcite.utils.TimewrapUtils;
@@ -185,6 +188,7 @@
import org.opensearch.sql.common.error.ErrorReport;
import org.opensearch.sql.common.patterns.PatternUtils;
import org.opensearch.sql.common.utils.StringUtils;
+import org.opensearch.sql.data.type.ExprCoreType;
import org.opensearch.sql.datasource.DataSourceService;
import org.opensearch.sql.exception.CalciteUnsupportedException;
import org.opensearch.sql.exception.SemanticCheckException;
@@ -4459,17 +4463,154 @@ public RelNode visitMvExpand(MvExpand mvExpand, CalcitePlanContext context) {
@Override
public RelNode visitValues(Values values, CalcitePlanContext context) {
List> rows = values.getValues();
- if (rows == null || rows.isEmpty()) {
+ RelBuilder relBuilder = context.relBuilder;
+ boolean hasExplicitSchema = values.getColumnNames() != null || values.getColumnTypes() != null;
+ if (!hasExplicitSchema && (rows == null || rows.isEmpty())) {
// PPL empty subsearch (e.g., `... | append [ ]`): zero rows, no columns.
- context.relBuilder.values(context.relBuilder.getTypeFactory().builder().build());
- return context.relBuilder.peek();
+ relBuilder.values(relBuilder.getTypeFactory().builder().build());
+ return relBuilder.peek();
}
- if (rows.size() == 1 && rows.get(0).isEmpty()) {
+ if (rows != null && rows.size() == 1 && rows.get(0).isEmpty()) {
// SQL FROM-less SELECT (dual table) encoded as Values([[]]): one-row relation for Project.
- context.relBuilder.push(LogicalValues.createOneRow(context.relBuilder.getCluster()));
- return context.relBuilder.peek();
+ relBuilder.push(LogicalValues.createOneRow(relBuilder.getCluster()));
+ return relBuilder.peek();
+ }
+ // Inline literal rows (e.g. `makeresults format=csv|json data=...`): build a typed
+ // LogicalValues
+ // with the given/derived schema, then project each column cast to the resolved type.
+ return buildLiteralValues(
+ relBuilder,
+ values.getColumnNames(),
+ values.getColumnTypes(),
+ rows,
+ values.isWithImplicitTimestamp());
+ }
+
+ /**
+ * Build a typed {@link LogicalValues} (+ a cast {@code Project}) from inline literal rows. Column
+ * names/types are taken from the explicit lists when provided (authoritative, and required to
+ * type a zero-row relation); otherwise names are positional and types are inferred from the
+ * literals.
+ */
+ private RelNode buildLiteralValues(
+ RelBuilder relBuilder,
+ List explicitNames,
+ List explicitTypes,
+ List> rows,
+ boolean withImplicitTimestamp) {
+ int nc;
+ if (explicitTypes != null) {
+ nc = explicitTypes.size();
+ } else if (explicitNames != null) {
+ nc = explicitNames.size();
+ } else {
+ nc = rows.isEmpty() ? 0 : rows.get(0).size();
+ }
+
+ List names = new java.util.ArrayList<>();
+ for (int i = 0; i < nc; i++) {
+ names.add(explicitNames != null ? explicitNames.get(i) : "column_" + i);
+ }
+
+ List types = new java.util.ArrayList<>();
+ for (int c = 0; c < nc; c++) {
+ if (explicitTypes != null) {
+ types.add(explicitTypes.get(c));
+ } else {
+ // infer from the first non-null literal in this column, defaulting to STRING.
+ ExprCoreType t = ExprCoreType.STRING;
+ for (List row : rows) {
+ DataType dt = row.get(c).getType();
+ if (dt != DataType.NULL) {
+ t = dt.getCoreType();
+ break;
+ }
+ }
+ types.add(t);
+ }
+ }
+
+ boolean prependTimestamp =
+ withImplicitTimestamp && !names.contains(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP);
+ RelDataType tsType =
+ OpenSearchTypeFactory.convertExprTypeToRelDataType(ExprCoreType.TIMESTAMP, false);
+
+ var typeBuilder = relBuilder.getTypeFactory().builder();
+ if (prependTimestamp) {
+ typeBuilder.add(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP, tsType);
}
- throw new CalciteUnsupportedException("Inline VALUES with literal rows is unsupported");
+ for (int i = 0; i < nc; i++) {
+ typeBuilder.add(
+ names.get(i), OpenSearchTypeFactory.convertExprTypeToRelDataType(types.get(i), true));
+ }
+ RelDataType rowType = typeBuilder.build();
+
+ if (rows.isEmpty()) {
+ // header-only CSV / empty JSON array: a zero-row relation with the resolved schema.
+ relBuilder.values(ImmutableList.>of(), rowType);
+ return relBuilder.peek();
+ }
+
+ // Build literal rows from the raw Java values, letting RelBuilder infer the initial types, then
+ // project each column cast to the resolved (OpenSearch-faithful) type.
+ Object[] flat = new Object[rows.size() * nc];
+ int k = 0;
+ for (List row : rows) {
+ for (Literal cell : row) {
+ flat[k++] = cell.getValue();
+ }
+ }
+ relBuilder.values(names.toArray(new String[0]), flat);
+ List projects = new java.util.ArrayList<>();
+ if (prependTimestamp) {
+ projects.add(
+ relBuilder.alias(
+ relBuilder.call(PPLBuiltinOperators.NOW),
+ OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP));
+ }
+ for (int i = 0; i < nc; i++) {
+ projects.add(
+ relBuilder.alias(
+ relBuilder.cast(
+ relBuilder.field(i),
+ rowType.getField(names.get(i), true, false).getType().getSqlTypeName()),
+ names.get(i)));
+ }
+ relBuilder.project(projects);
+ return relBuilder.peek();
+ }
+
+ @Override
+ public RelNode visitMakeResults(MakeResults node, CalcitePlanContext context) {
+ // Count path only: the `format=csv|json data=...` form is parsed into a shared Values node
+ // (see MakeResultsDataParser) and handled by visitValues.
+ RelBuilder relBuilder = context.relBuilder;
+ int count = node.getCount();
+ RelDataType tsType =
+ OpenSearchTypeFactory.convertExprTypeToRelDataType(ExprCoreType.TIMESTAMP, false);
+ if (count == 0) {
+ RelDataType rowType =
+ relBuilder
+ .getTypeFactory()
+ .builder()
+ .add(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP, tsType)
+ .build();
+ relBuilder.values(ImmutableList.>of(), rowType);
+ return relBuilder.peek();
+ }
+ // Build `count` one-column dummy rows, then project them away with a single @timestamp column
+ // set to query time. @timestamp is OpenSearch's implicit time field, recognized by the
+ // time-aware commands (timechart, reverse, span, timewrap). The dummy column only carries row
+ // multiplicity.
+ Object[] dummy = new Object[count];
+ for (int i = 0; i < count; i++) {
+ dummy[i] = i;
+ }
+ relBuilder.values(new String[] {"__makeresults_dummy__"}, dummy);
+ RexNode now = relBuilder.call(PPLBuiltinOperators.NOW);
+ relBuilder.project(
+ List.of(relBuilder.alias(now, OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP)));
+ return relBuilder.peek();
}
@Override
diff --git a/docs/user/ppl/cmd/makeresults.md b/docs/user/ppl/cmd/makeresults.md
new file mode 100644
index 00000000000..4a614b359c0
--- /dev/null
+++ b/docs/user/ppl/cmd/makeresults.md
@@ -0,0 +1,88 @@
+
+# makeresults
+
+The `makeresults` command generates in-memory rows. With no arguments it produces a single row containing only the `@timestamp` field, set to the query time. It is commonly used as a seed for `eval` and to generate test data. The time column is named `@timestamp` (OpenSearch's implicit time field) so it is recognized by the time-aware commands such as `timechart`, `reverse`, and `span`.
+
+> **Note**: The `makeresults` command is a leading command (it opens a query) and is executed only on the coordinating node. It has no backing index. It requires the Calcite engine (`plugins.calcite.enabled=true`).
+
+## Syntax
+
+The `makeresults` command has the following syntax:
+
+```syntax
+makeresults [count=] [format=csv|json data=]
+```
+
+## Parameters
+
+| Parameter | Required/Optional | Description |
+| --- | --- | --- |
+| `count` | Optional | The number of rows to generate. Must be a non-negative integer up to 5000. A negative value produces zero rows. Each row has a single `@timestamp` (timestamp) column. Default is `1`. |
+| `format` + `data` | Optional | Generate rows from an inline `csv` or `json` literal instead (up to 5000 rows, and the `data` string must not exceed 29999 characters). When provided, `count` is ignored. |
+
+### Inline data typing
+
+Column types for `data=` follow OpenSearch dynamic-mapping semantics:
+
+- JSON: an integer becomes `long`, a decimal becomes `float`, `true`/`false` becomes `boolean`, and a string becomes `string`. A nested object or array is serialized to its compact JSON string and typed as `string`; use `spath` or the `json_extract` function to re-parse it downstream.
+- CSV: a header token of the form `name:type` declares the column type using the same vocabulary as `cast` (for example `age:int`); a bare header token defaults to `string`.
+
+The `date`, `time`, `timestamp`, `ip`, and `json` inline types are not yet supported on this path; use `string` and `cast`.
+
+### Implicit `@timestamp` column
+
+`format=json data=` treats each JSON object as an event and prepends an implicit `@timestamp`
+(timestamp) column set to the query time, in addition to the object's own fields. If the JSON data
+already defines an `@timestamp` field, that value is kept and no implicit column is added.
+`format=csv data=` is a pure table and does not add an `@timestamp` column.
+
+## Example 1: Generate rows for testing
+
+The following query generates five rows:
+
+```ppl
+makeresults count=5
+```
+
+## Example 2: Seed a row for eval
+
+```ppl
+makeresults
+| eval message="hello"
+```
+
+## Example 3: Generate typed rows from JSON
+
+```ppl
+makeresults format=json data='[{"name":"John","age":35},{"name":"Sarah","age":39}]'
+```
+
+The query returns two rows with an `@timestamp` (timestamp, query time) column followed by a `name` (string) column and an `age` (bigint) column. A JSON integer is typed as a long value; because makeresults rows have no index mapping, the column reports its Calcite type name `bigint` in the response schema.
+
+## Example 4: Generate typed rows from CSV
+
+```ppl
+makeresults format=csv data='name:string,age:int
+John,35
+Sarah,39'
+```
+
+The query returns two rows with a `name` (string) column and an `age` (int) column.
+
+## Limitations
+
+A global aggregate that references no input column, applied directly to `makeresults`, is not
+currently supported and raises an error:
+
+```ppl
+makeresults count=5 | stats count() as c
+```
+
+This is due to an upstream Apache Calcite field-trimming defect on zero-column relations, not a
+`makeresults`-specific issue. Use any of the following equivalent forms instead:
+
+```ppl
+makeresults count=5 | stats count(1) as c
+makeresults count=5 | stats count() as c by @timestamp
+makeresults count=5 | eval g=1 | stats count() as c by g
+```
diff --git a/docs/user/ppl/index.md b/docs/user/ppl/index.md
index 939684c0ecc..df2fe2f0233 100644
--- a/docs/user/ppl/index.md
+++ b/docs/user/ppl/index.md
@@ -81,6 +81,7 @@ source=accounts
| [describe command](cmd/describe.md) | 2.1 | stable (since 2.1) | Query the metadata of an index. |
| [explain command](cmd/explain.md) | 3.1 | stable (since 3.1) | Explain the plan of query. |
| [show datasources command](cmd/showdatasources.md) | 2.4 | stable (since 2.4) | Query datasources configured in the PPL engine. |
+| [makeresults command](cmd/makeresults.md) | 3.5 | experimental | Generate in-memory rows for testing and seeding, optionally from inline CSV/JSON data. |
| [addtotals command](cmd/addtotals.md) | 3.5 | stable (since 3.5) | Adds row and column values and appends a totals column and row. |
| [addcoltotals command](cmd/addcoltotals.md) | 3.5 | stable (since 3.5) | Adds column values and appends a totals row. |
| [transpose command](cmd/transpose.md) | 3.5 | stable (since 3.5) | Transpose rows to columns. |
diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/standalone/CalcitePPLMakeResultsIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/standalone/CalcitePPLMakeResultsIT.java
new file mode 100644
index 00000000000..ba623c649e0
--- /dev/null
+++ b/integ-test/src/test/java/org/opensearch/sql/calcite/standalone/CalcitePPLMakeResultsIT.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.calcite.standalone;
+
+import static org.opensearch.sql.util.MatcherUtils.rows;
+import static org.opensearch.sql.util.MatcherUtils.schema;
+import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;
+import static org.opensearch.sql.util.MatcherUtils.verifySchema;
+
+import java.io.IOException;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for the makeresults leading command.
+ *
+ *
The count path output ({@code @timestamp = now()}) is non-deterministic, so it asserts schema
+ * + row count only. The data= path is deterministic and asserts schema + datarows.
+ */
+public class CalcitePPLMakeResultsIT extends CalcitePPLIntegTestCase {
+ @Override
+ public void init() throws IOException {
+ super.init();
+ enableCalcite();
+ }
+
+ @Test
+ public void testCount() throws IOException {
+ JSONObject result = executeQuery("makeresults count=5");
+ verifySchema(result, schema("@timestamp", "timestamp"));
+ assertEquals(5, result.getInt("total"));
+ }
+
+ @Test
+ public void testBare() throws IOException {
+ JSONObject result = executeQuery("makeresults");
+ verifySchema(result, schema("@timestamp", "timestamp"));
+ assertEquals(1, result.getInt("total"));
+ }
+
+ @Test
+ public void testJson() throws IOException {
+ String data =
+ "makeresults format=json data='[{\"name\":\"John\",\"age\":35,\"score\":3.5},"
+ + "{\"name\":\"Sarah\",\"age\":39,\"score\":4.0}]'";
+ JSONObject result = executeQuery(data);
+ verifySchema(
+ result,
+ schema("@timestamp", "timestamp"),
+ schema("name", "string"),
+ schema("age", "bigint"),
+ schema("score", "float"));
+ JSONObject projected = executeQuery(data + " | fields name, age, score");
+ verifyDataRows(projected, rows("John", 35, 3.5), rows("Sarah", 39, 4.0));
+ }
+
+ @Test
+ public void testNestedJsonSerializesToString() throws IOException {
+ String data =
+ "makeresults format=json data='[{\"name\":\"John\","
+ + "\"addr\":{\"city\":\"NYC\",\"zip\":10001},\"tags\":[\"a\",\"b\"]}]'";
+ JSONObject result = executeQuery(data);
+ verifySchema(
+ result,
+ schema("@timestamp", "timestamp"),
+ schema("name", "string"),
+ schema("addr", "string"),
+ schema("tags", "string"));
+ JSONObject projected = executeQuery(data + " | fields name, addr, tags");
+ verifyDataRows(projected, rows("John", "{\"city\":\"NYC\",\"zip\":10001}", "[\"a\",\"b\"]"));
+ }
+
+ @Test
+ public void testNestedJsonSpathRoundTrip() throws IOException {
+ JSONObject result =
+ executeQuery(
+ "makeresults format=json data='[{\"addr\":{\"city\":\"NYC\"}}]'"
+ + " | spath input=addr output=city path=city | fields addr, city");
+ verifyDataRows(result, rows("{\"city\":\"NYC\"}", "NYC"));
+ }
+
+ @Test
+ public void testTypedCsv() throws IOException {
+ JSONObject result =
+ executeQuery("makeresults format=csv data='name:string,age:int\nJohn,35\nSarah,39'");
+ verifySchema(result, schema("name", "string"), schema("age", "int"));
+ verifyDataRows(result, rows("John", 35), rows("Sarah", 39));
+ }
+
+ @Test
+ public void testBareCsv() throws IOException {
+ JSONObject result = executeQuery("makeresults format=csv data='name,age\nJohn,35\nSarah,39'");
+ verifySchema(result, schema("name", "string"), schema("age", "string"));
+ verifyDataRows(result, rows("John", "35"), rows("Sarah", "39"));
+ }
+
+ @Test
+ public void testComposesAsSource() throws IOException {
+ JSONObject result = executeQuery("makeresults count=3 | eval n=1");
+ verifySchema(result, schema("@timestamp", "timestamp"), schema("n", "int"));
+ assertEquals(3, result.getInt("total"));
+ }
+
+ @Test
+ public void testBareGlobalCountIsUnsupported() {
+ assertThrows(Exception.class, () -> executeQuery("makeresults count=5 | stats count() as c"));
+ }
+
+ @Test
+ public void testBareGlobalCountWorkaroundCountArg() throws IOException {
+ JSONObject result = executeQuery("makeresults count=5 | stats count(1) as c");
+ verifySchema(result, schema("c", "bigint"));
+ verifyDataRows(result, rows(5));
+ }
+
+ @Test
+ public void testBareGlobalCountWorkaroundByTimestamp() throws IOException {
+ JSONObject result = executeQuery("makeresults count=5 | stats count() as c by @timestamp");
+ verifyDataRows(result, rows(5, result.getJSONArray("datarows").getJSONArray(0).get(1)));
+ }
+
+ @Test
+ public void testBareGlobalCountWorkaroundEvalGroup() throws IOException {
+ JSONObject result = executeQuery("makeresults count=5 | eval g=1 | stats count() as c by g");
+ verifyDataRows(result, rows(5, 1));
+ }
+}
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 2ccda31eea7..e87fb77a4c1 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -331,6 +331,27 @@ public void testNoMvUnsupportedInV2() throws IOException {
verifyQuery(result);
}
+ @Test
+ public void testMakeResults() throws IOException {
+ JSONObject result;
+ try {
+ result = executeQuery("makeresults count=2");
+ } catch (ResponseException e) {
+ result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
+ }
+
+ if (isCalciteEnabled()) {
+ assertThat(result.getJSONArray("datarows").length(), equalTo(2));
+ } else {
+ JSONObject error = result.getJSONObject("error");
+ assertThat(
+ error.getString("details"),
+ containsString(
+ "is supported only when " + CALCITE_ENGINE_ENABLED.getKeyValue() + "=true"));
+ assertThat(error.getString("type"), equalTo("UnsupportedOperationException"));
+ }
+ }
+
@Test
public void testMvExpandCommandBasicExpansion() throws IOException {
JSONObject result;
diff --git a/ppl/src/main/antlr/OpenSearchPPLLexer.g4 b/ppl/src/main/antlr/OpenSearchPPLLexer.g4
index f9d67bd46ff..7d2e8985fde 100644
--- a/ppl/src/main/antlr/OpenSearchPPLLexer.g4
+++ b/ppl/src/main/antlr/OpenSearchPPLLexer.g4
@@ -15,6 +15,10 @@ DESCRIBE: 'DESCRIBE';
SHOW: 'SHOW';
REST: 'REST';
TIMEOUT: 'TIMEOUT';
+MAKERESULTS: 'MAKERESULTS';
+FORMAT: 'FORMAT';
+CSV: 'CSV';
+DATA: 'DATA';
EXPLAIN: 'EXPLAIN';
FROM: 'FROM';
WHERE: 'WHERE';
diff --git a/ppl/src/main/antlr/OpenSearchPPLParser.g4 b/ppl/src/main/antlr/OpenSearchPPLParser.g4
index 354ffcd425d..3372e90d2ca 100644
--- a/ppl/src/main/antlr/OpenSearchPPLParser.g4
+++ b/ppl/src/main/antlr/OpenSearchPPLParser.g4
@@ -47,6 +47,7 @@ pplCommands
: describeCommand
| restCommand
| showDataSourcesCommand
+ | makeresultsCommand
| searchCommand
| multisearchCommand
| graphLookupCommand
@@ -153,6 +154,7 @@ commandName
| TRANSPOSE
| GRAPHLOOKUP
| TIMEWRAP
+ | MAKERESULTS
;
searchCommand
@@ -224,6 +226,16 @@ showDataSourcesCommand
: SHOW DATASOURCES
;
+makeresultsCommand
+ : MAKERESULTS makeresultsArg*
+ ;
+
+makeresultsArg
+ : COUNT EQUAL integerLiteral
+ | FORMAT EQUAL (CSV | JSON)
+ | DATA EQUAL stringLiteral
+ ;
+
whereCommand
: WHERE logicalExpression
;
@@ -1716,6 +1728,9 @@ searchableKeyWord
// ARGUMENT KEYWORDS
| KEEPEMPTY
| CONSECUTIVE
+ | FORMAT
+ | CSV
+ | DATA
| DEDUP_SPLITVALUES
| PARTITIONS
| ALLNUM
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java
index 2f9168383b8..862318ab511 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java
@@ -94,6 +94,7 @@
import org.opensearch.sql.ast.tree.Kmeans;
import org.opensearch.sql.ast.tree.Lookup;
import org.opensearch.sql.ast.tree.ML;
+import org.opensearch.sql.ast.tree.MakeResults;
import org.opensearch.sql.ast.tree.MinSpanBin;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
@@ -143,6 +144,7 @@
import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.StatsByClauseContext;
import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParserBaseVisitor;
import org.opensearch.sql.ppl.utils.ArgumentFactory;
+import org.opensearch.sql.ppl.utils.MakeResultsDataParser;
import org.opensearch.sql.ppl.utils.UnresolvedPlanHelper;
import org.opensearch.sql.utils.SystemIndexUtils;
@@ -287,6 +289,55 @@ public UnresolvedPlan visitRestCommand(OpenSearchPPLParser.RestCommandContext ct
return new RestRelation(new QualifiedName(token));
}
+ /** makeresults command. */
+ @Override
+ public UnresolvedPlan visitMakeresultsCommand(OpenSearchPPLParser.MakeresultsCommandContext ctx) {
+ int count = 1;
+ String format = null;
+ String data = null;
+ for (OpenSearchPPLParser.MakeresultsArgContext arg : ctx.makeresultsArg()) {
+ if (arg.integerLiteral() != null) {
+ String raw = arg.integerLiteral().getText();
+ try {
+ count = Integer.parseInt(raw);
+ } catch (NumberFormatException e) {
+ // T3: a count outside int range (or otherwise unparseable) yields a clean validation
+ // error rather than a raw NumberFormatException surfaced to the client.
+ throw new SyntaxCheckException(
+ "makeresults count \"" + raw + "\" is not a valid integer within the allowed range");
+ }
+ } else if (arg.stringLiteral() != null) {
+ data = StringUtils.unquoteText(arg.stringLiteral().getText());
+ } else if (arg.JSON() != null) {
+ format = "json";
+ } else if (arg.CSV() != null) {
+ format = "csv";
+ }
+ }
+ if (data != null || format != null) {
+ if (data == null || format == null) {
+ throw new SyntaxCheckException("makeresults format and data must be provided together");
+ }
+ if (data.length() > 29999) {
+ throw new SyntaxCheckException("makeresults data must not exceed 29999 characters");
+ }
+ return MakeResultsDataParser.parse(format, data);
+ }
+ if (count < 0) {
+ // A negative count silently yields zero rows rather than an error. Clamp to 0 so
+ // `makeresults count=-1` produces an empty result.
+ count = 0;
+ }
+ if (count > 5000) {
+ // T1: the count path materializes `count` literal rows inline; beyond ~5000 rows the
+ // generated Calcite bytecode exceeds the JVM 64 KB per-method limit ("Code grows beyond
+ // 64 KB"). Cap at a verified-safe bound with a clear message rather than emitting an opaque
+ // codegen failure. (The query-size limit already truncates output well within this bound.)
+ throw new SyntaxCheckException("makeresults count must not exceed 5000");
+ }
+ return new MakeResults(count);
+ }
+
/** Where command. */
@Override
public UnresolvedPlan visitWhereCommand(WhereCommandContext ctx) {
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/utils/MakeResultsDataParser.java b/ppl/src/main/java/org/opensearch/sql/ppl/utils/MakeResultsDataParser.java
new file mode 100644
index 00000000000..1534aa462b4
--- /dev/null
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/utils/MakeResultsDataParser.java
@@ -0,0 +1,386 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.ppl.utils;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.opensearch.sql.ast.expression.DataType;
+import org.opensearch.sql.ast.expression.Literal;
+import org.opensearch.sql.ast.tree.Values;
+import org.opensearch.sql.common.antlr.SyntaxCheckException;
+import org.opensearch.sql.data.type.ExprCoreType;
+
+/**
+ * Parses the inline {@code data=} literal of {@code makeresults format=csv|json data="..."} into a
+ * shared {@link Values} node (typed literal rows) at plan time, so it flows through the same {@code
+ * visitValues} builder as any inline {@code VALUES}.
+ *
+ *
Typing follows OpenSearch dynamic-mapping semantics, surfaced through the same ExprCoreType
+ * path an index scan uses:
+ *
+ *
CSV header token {@code name:type} declares the type via cast's vocabulary; bare {@code
+ * name} -> string.
+ *
+ *
+ *
Per-column heterogeneity widens to a common numeric type, else to string. Inline UDT types
+ * (timestamp/date/time/ip/json) are not yet supported on this path.
+ */
+public final class MakeResultsDataParser {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private MakeResultsDataParser() {}
+
+ public static Values parse(String format, String data) {
+ String fmt = format == null ? null : format.toLowerCase(Locale.ROOT);
+ Values result;
+ if ("json".equals(fmt)) {
+ result = parseJson(data);
+ } else if ("csv".equals(fmt)) {
+ result = parseCsv(data);
+ } else {
+ throw new SyntaxCheckException("makeresults format must be 'csv' or 'json'");
+ }
+ // T1: rows are materialized as inline literals; beyond ~5000 the generated Calcite bytecode
+ // exceeds the JVM 64 KB per-method limit. Cap with a clear message rather than an opaque
+ // codegen failure (the char cap alone does not bound tiny-row counts, e.g. many 1-char CSV
+ // rows).
+ if (result.getValues() != null && result.getValues().size() > 5000) {
+ throw new SyntaxCheckException("makeresults data must not exceed 5000 rows");
+ }
+ return result;
+ }
+
+ /**
+ * Assemble a shared {@link Values} node from parsed names, per-column types, and coerced rows.
+ */
+ private static Values toValues(
+ List names,
+ List types,
+ List> rows,
+ boolean withImplicitTimestamp) {
+ List dataTypes = new ArrayList<>();
+ for (ExprCoreType t : types) {
+ dataTypes.add(exprToDataType(t));
+ }
+ List> literalRows = new ArrayList<>();
+ for (List