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 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: + * + *

+ * + *

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 row : rows) { + List out = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + Object v = row.get(i); + out.add(v == null ? new Literal(null, DataType.NULL) : new Literal(v, dataTypes.get(i))); + } + literalRows.add(out); + } + return new Values(literalRows, names, types, withImplicitTimestamp); + } + + private static DataType exprToDataType(ExprCoreType t) { + switch (t) { + case BOOLEAN: + return DataType.BOOLEAN; + case INTEGER: + return DataType.INTEGER; + case LONG: + return DataType.LONG; + case FLOAT: + return DataType.FLOAT; + case DOUBLE: + return DataType.DOUBLE; + case STRING: + default: + return DataType.STRING; + } + } + + private static Values parseJson(String data) { + JsonNode arr; + try { + arr = MAPPER.readTree(data); + } catch (Exception e) { + throw new SyntaxCheckException("makeresults data is not valid JSON: " + e.getMessage()); + } + if (arr == null || !arr.isArray()) { + throw new SyntaxCheckException("makeresults JSON data must be an array of objects"); + } + LinkedHashMap cols = new LinkedHashMap<>(); + List> raw = new ArrayList<>(); + for (JsonNode node : arr) { + if (!node.isObject()) { + throw new SyntaxCheckException("makeresults JSON data must be an array of objects"); + } + Map row = new LinkedHashMap<>(); + for (Iterator> it = node.fields(); it.hasNext(); ) { + Map.Entry f = it.next(); + String name = f.getKey(); + JsonNode v = f.getValue(); + ExprCoreType inferred = inferJsonType(v); + if (inferred == null) { + cols.putIfAbsent(name, null); + } else { + cols.merge(name, inferred, MakeResultsDataParser::widen); + } + row.put(name, jsonValue(v)); + } + raw.add(row); + } + List names = new ArrayList<>(cols.keySet()); + List types = new ArrayList<>(); + for (String n : names) { + ExprCoreType t = cols.get(n); + if (t == null) { + throw new SyntaxCheckException( + "makeresults column '" + + n + + "' has only null values; provide at least one non-null" + + " value so its type can be determined"); + } + types.add(t); + } + List> rows = new ArrayList<>(); + for (Map r : raw) { + List out = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + out.add(coerce(r.get(names.get(i)), types.get(i))); + } + rows.add(out); + } + return toValues(names, types, rows, true); + } + + private static Values parseCsv(String data) { + String[] lines = data.split("\r?\n", -1); + if (lines.length == 0 || lines[0].trim().isEmpty()) { + throw new SyntaxCheckException("makeresults CSV data must start with a header line"); + } + String[] header = splitCsvLine(lines[0]).toArray(new String[0]); + List names = new ArrayList<>(); + List types = new ArrayList<>(); + for (String token : header) { + String t = token.trim(); + String name = t; + ExprCoreType type = ExprCoreType.STRING; + int colon = t.lastIndexOf(':'); + if (colon > 0 && colon < t.length() - 1) { + ExprCoreType declared = resolveType(t.substring(colon + 1).trim()); + if (declared != null) { + name = t.substring(0, colon).trim(); + type = declared; + } + } + if (name.isEmpty()) { + throw new SyntaxCheckException( + "makeresults CSV header has a blank column name: " + lines[0]); + } + names.add(name); + types.add(type); + } + names = uniquify(names); + List> rows = new ArrayList<>(); + for (int li = 1; li < lines.length; li++) { + if (lines[li].isEmpty()) { + continue; + } + List cells = splitCsvLine(lines[li]); + if (cells.size() > names.size()) { + throw new SyntaxCheckException( + "makeresults CSV row has more columns than the header: " + lines[li]); + } + List out = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + String cell = i < cells.size() ? cells.get(i).trim() : ""; + out.add(coerce(cell.isEmpty() ? null : cell, types.get(i))); + } + rows.add(out); + } + return toValues(names, types, rows, false); + } + + /** Split one CSV line honoring double-quoted fields (embedded commas, "" escape). */ + private static List splitCsvLine(String line) { + List out = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + boolean inQuotes = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + cur.append('"'); + i++; + } else { + inQuotes = false; + } + } else { + cur.append(c); + } + } else if (c == '"') { + inQuotes = true; + } else if (c == ',') { + out.add(cur.toString()); + cur.setLength(0); + } else { + cur.append(c); + } + } + if (inQuotes) { + throw new SyntaxCheckException( + "makeresults CSV data has an unterminated quoted field: " + line); + } + out.add(cur.toString()); + return out; + } + + /** + * Uniquify duplicate column names by appending a numeric suffix, keeping the first occurrence + * unchanged (e.g. {@code name, name} becomes {@code name, name0}), so a CSV header with repeated + * fields yields addressable columns rather than an ambiguous relation. + */ + private static List uniquify(List names) { + List out = new ArrayList<>(); + Set seen = new HashSet<>(); + for (String n : names) { + String candidate = n; + int suffix = 0; + while (!seen.add(candidate)) { + candidate = n + suffix++; + } + out.add(candidate); + } + return out; + } + + private static ExprCoreType inferJsonType(JsonNode v) { + if (v.isNull()) { + return null; + } + if (v.isObject() || v.isArray()) { + return ExprCoreType.STRING; + } + if (v.isBoolean()) { + return ExprCoreType.BOOLEAN; + } + if (v.isIntegralNumber()) { + // A JSON integer wider than long keeps full precision as a string rather than overflowing. + return v.canConvertToLong() ? ExprCoreType.LONG : ExprCoreType.STRING; + } + if (v.isNumber()) { + return ExprCoreType.FLOAT; + } + return ExprCoreType.STRING; + } + + private static ExprCoreType widen(ExprCoreType a, ExprCoreType b) { + if (a == null) { + return b; + } + if (b == null || a == b) { + return a; + } + boolean an = a == ExprCoreType.LONG || a == ExprCoreType.FLOAT; + boolean bn = b == ExprCoreType.LONG || b == ExprCoreType.FLOAT; + if (an && bn) { + return ExprCoreType.FLOAT; + } + return ExprCoreType.STRING; + } + + private static Object jsonValue(JsonNode v) { + if (v.isNull()) { + return null; + } + if (v.isObject() || v.isArray()) { + return v.toString(); + } + if (v.isBoolean()) { + return v.booleanValue(); + } + if (v.isIntegralNumber()) { + return v.canConvertToLong() ? v.longValue() : v.asText(); + } + if (v.isNumber()) { + return v.doubleValue(); + } + return v.asText(); + } + + /** Resolve a subset of cast's type vocabulary; returns null for unknown names. */ + private static ExprCoreType resolveType(String name) { + switch (name.toLowerCase(Locale.ROOT)) { + case "string": + return ExprCoreType.STRING; + case "boolean": + return ExprCoreType.BOOLEAN; + case "int": + case "integer": + return ExprCoreType.INTEGER; + case "long": + return ExprCoreType.LONG; + case "float": + return ExprCoreType.FLOAT; + case "double": + return ExprCoreType.DOUBLE; + case "date": + case "time": + case "timestamp": + case "ip": + case "json": + throw new SyntaxCheckException( + "makeresults inline type '" + name + "' is not yet supported; use string and cast"); + default: + return null; + } + } + + private static Object coerce(Object value, ExprCoreType type) { + if (value == null) { + return null; + } + String s = String.valueOf(value); + try { + switch (type) { + case STRING: + return s; + case BOOLEAN: + return value instanceof Boolean ? value : parseBooleanStrict(s.trim()); + case INTEGER: + return Integer.parseInt(s.trim()); + case LONG: + return value instanceof Long ? value : Long.parseLong(s.trim()); + case FLOAT: + case DOUBLE: + return value instanceof Double ? value : Double.parseDouble(s.trim()); + default: + return s; + } + } catch (NumberFormatException e) { + throw new SyntaxCheckException( + "makeresults cannot parse \"" + s + "\" as " + type.typeName()); + } + } + + private static Boolean parseBooleanStrict(String s) { + if ("true".equalsIgnoreCase(s)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(s)) { + return Boolean.FALSE; + } + throw new SyntaxCheckException( + "makeresults cannot parse \"" + s + "\" as boolean; expected true or false"); + } +} diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java b/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java index 7d849517951..8d7e5fc9a79 100644 --- a/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java +++ b/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java @@ -877,6 +877,11 @@ public String visitValues(Values node, String context) { return ""; } + @Override + public String visitMakeResults(org.opensearch.sql.ast.tree.MakeResults node, String context) { + return "makeresults"; + } + private String visitFieldList(List fieldList) { return fieldList.stream().map(this::visitExpression).collect(Collectors.joining(",")); } diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLMakeResultsTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLMakeResultsTest.java new file mode 100644 index 00000000000..63f908975b7 --- /dev/null +++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLMakeResultsTest.java @@ -0,0 +1,190 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.ppl.calcite; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.test.CalciteAssert; +import org.junit.Test; + +/** Logical-plan tests for the makeresults leading command (count path + format/data path). */ +public class CalcitePPLMakeResultsTest extends CalcitePPLAbstractTest { + public CalcitePPLMakeResultsTest() { + super(CalciteAssert.SchemaSpec.SCOTT_WITH_TEMPORAL); + } + + private void expectError(String ppl, String messageFragment) { + try { + getRelNode(ppl); + fail("expected an error for: " + ppl); + } catch (Exception e) { + String msg = String.valueOf(e.getMessage()); + assertTrue( + "expected message containing '" + messageFragment + "' but got: " + msg, + msg.contains(messageFragment)); + } + } + + @Test + public void testMakeResultsBare() { + RelNode root = getRelNode("makeresults"); + verifyLogical(root, "LogicalProject(@timestamp=[NOW()])\n LogicalValues(tuples=[[{ 0 }]])\n"); + } + + @Test + public void testMakeResultsCount() { + RelNode root = getRelNode("makeresults count=3"); + verifyLogical( + root, + "LogicalProject(@timestamp=[NOW()])\n" + + " LogicalValues(tuples=[[{ 0 }, { 1 }, { 2 }]])\n"); + } + + @Test + public void testMakeResultsCountZero() { + RelNode root = getRelNode("makeresults count=0"); + verifyLogical(root, "LogicalValues(tuples=[[]])\n"); + } + + @Test + public void testMakeResultsJson() { + RelNode root = + getRelNode( + "makeresults format=json data='[{\"name\":\"John\",\"age\":35,\"score\":3.5}," + + "{\"name\":\"Sarah\",\"age\":39,\"score\":4.0}]'"); + verifyLogical( + root, + "LogicalProject(@timestamp=[NOW()], name=[CAST($0):VARCHAR NOT NULL]," + + " age=[CAST($1):BIGINT NOT NULL], score=[CAST($2):REAL NOT NULL])\n" + + " LogicalValues(tuples=[[{ 'John', 35, 3.5E0 }, { 'Sarah', 39, 4.0E0 }]])\n"); + } + + @Test + public void testMakeResultsTypedCsv() { + RelNode root = + getRelNode("makeresults format=csv data='name:string,age:int\nJohn,35\nSarah,39'"); + verifyLogical( + root, + "LogicalProject(name=[CAST($0):VARCHAR NOT NULL], age=[$1])\n" + + " LogicalValues(tuples=[[{ 'John', 35 }, { 'Sarah', 39 }]])\n"); + } + + @Test + public void testMakeResultsBareCsv() { + RelNode root = getRelNode("makeresults format=csv data='name,age\nJohn,35\nSarah,39'"); + verifyLogical( + root, + "LogicalProject(name=[CAST($0):VARCHAR NOT NULL], age=[CAST($1):VARCHAR NOT NULL])\n" + + " LogicalValues(tuples=[[{ 'John', '35' }, { 'Sarah', '39' }]])\n"); + } + + @Test + public void testMakeResultsHeaderOnlyCsv() { + RelNode root = getRelNode("makeresults format=csv data='name,age'"); + verifyLogical(root, "LogicalValues(tuples=[[]])\n"); + } + + @Test + public void testMakeResultsCsvQuotedComma() { + RelNode root = getRelNode("makeresults format=csv data='name,note\nJohn,\"a,b\"'"); + verifyLogical( + root, + "LogicalProject(name=[CAST($0):VARCHAR NOT NULL], note=[CAST($1):VARCHAR NOT NULL])\n" + + " LogicalValues(tuples=[[{ 'John', 'a,b' }]])\n"); + } + + @Test + public void testMakeResultsJsonBigIntKeepsPrecision() { + RelNode root = getRelNode("makeresults format=json data='[{\"n\":99999999999999999999}]'"); + verifyLogical( + root, + "LogicalProject(@timestamp=[NOW()], n=[CAST($0):VARCHAR NOT NULL])\n" + + " LogicalValues(tuples=[[{ '99999999999999999999' }]])\n"); + } + + @Test + public void testMakeResultsSerializesNestedJson() { + RelNode root = getRelNode("makeresults format=json data='[{\"a\":{\"x\":1},\"b\":[1,2]}]'"); + verifyLogical( + root, + "LogicalProject(@timestamp=[NOW()], a=[CAST($0):VARCHAR NOT NULL]," + + " b=[CAST($1):VARCHAR NOT NULL])\n" + + " LogicalValues(tuples=[[{ '{\"x\":1}', '[1,2]' }]])\n"); + } + + @Test + public void testMakeResultsRejectsAllNullColumn() { + expectError("makeresults format=json data='[{\"a\":null},{\"a\":null}]'", "only null values"); + } + + @Test + public void testMakeResultsRejectsDataWithoutFormat() { + expectError("makeresults data='[{\"a\":1}]'", "format and data must be provided together"); + } + + @Test + public void testMakeResultsNegativeCountYieldsZeroRows() { + // A negative count silently yields zero rows rather than an error. + RelNode root = getRelNode("makeresults count=-1"); + verifyLogical(root, "LogicalValues(tuples=[[]])\n"); + } + + @Test + public void testMakeResultsRejectsCountOverCap() { + // T1: count beyond the codegen-safe bound (5000) is rejected cleanly, not with an opaque + // "Code grows beyond 64 KB" codegen failure. + expectError("makeresults count=6000", "must not exceed 5000"); + } + + @Test + public void testMakeResultsRejectsCountOverflow() { + // T3: a count outside int range yields a clean validation error, not a raw + // NumberFormatException. + expectError("makeresults count=99999999999999", "not a valid integer"); + } + + @Test + public void testMakeResultsJsonUserTimestampWins() { + RelNode root = getRelNode("makeresults format=json data='[{\"@timestamp\":\"2020\",\"x\":1}]'"); + verifyLogical( + root, + "LogicalProject(@timestamp=[CAST($0):VARCHAR NOT NULL], x=[CAST($1):BIGINT NOT NULL])\n" + + " LogicalValues(tuples=[[{ '2020', 1 }]])\n"); + } + + @Test + public void testMakeResultsRejectsInvalidBoolean() { + expectError( + "makeresults format=csv data='active:boolean\nnot true'", "cannot parse \"not true\""); + } + + @Test + public void testMakeResultsAcceptsBooleanCaseInsensitive() { + RelNode root = getRelNode("makeresults format=csv data='active:boolean\nTRUE\nFalse'"); + verifyLogical(root, "LogicalValues(tuples=[[{ true }, { false }]])\n"); + } + + @Test + public void testMakeResultsRejectsUnterminatedQuote() { + expectError("makeresults format=csv data='name\n\"unterminated'", "unterminated quoted field"); + } + + @Test + public void testMakeResultsUniquifiesDuplicateCsvHeaders() { + RelNode root = getRelNode("makeresults format=csv data='name,name\nJohn,Doe'"); + verifyLogical( + root, + "LogicalProject(name=[CAST($0):VARCHAR NOT NULL], name0=[CAST($1):VARCHAR NOT NULL])\n" + + " LogicalValues(tuples=[[{ 'John', 'Doe' }]])\n"); + } + + @Test + public void testMakeResultsRejectsBlankCsvHeader() { + expectError("makeresults format=csv data=',field\n1,2'", "blank column name"); + } +} diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java index d57ca8a69bb..2986200af78 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstBuilderTest.java @@ -81,6 +81,7 @@ import org.opensearch.sql.ast.tree.Join; import org.opensearch.sql.ast.tree.Kmeans; import org.opensearch.sql.ast.tree.ML; +import org.opensearch.sql.ast.tree.MakeResults; import org.opensearch.sql.ast.tree.RareTopN.CommandType; import org.opensearch.sql.common.antlr.SyntaxCheckException; import org.opensearch.sql.common.setting.Settings.Key; @@ -1107,6 +1108,12 @@ public void testDescribeCommand() { assertEqual("describe t", describe(mappingTable("t"))); } + @Test + public void testMakeResultsCommand() { + assertEqual("makeresults", new MakeResults(1)); + assertEqual("makeresults count=5", new MakeResults(5)); + } + @Test public void testDescribeMatchAllCrossClusterSearchCommand() { assertEqual("describe *:t", describe(mappingTable("*:t"))); diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java index d11348457c3..894fcd2f8d6 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java @@ -36,6 +36,11 @@ public void testSearchCommand() { assertEquals("source=table identifier = ***", anonymize("search source=t a=1")); } + @Test + public void testMakeResultsCommand() { + assertEquals("makeresults", anonymize("makeresults count=5")); + } + @Test public void testTableFunctionCommand() { assertEquals(