Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
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.Multikv;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
Expand Down Expand Up @@ -568,6 +569,11 @@ public LogicalPlan visitMakeResults(MakeResults node, AnalysisContext context) {
throw getOnlyForCalciteException("makeresults");
}

@Override
public LogicalPlan visitMultikv(Multikv node, AnalysisContext context) {
throw getOnlyForCalciteException("multikv");
}

@Override
public LogicalPlan visitMvExpand(MvExpand node, AnalysisContext context) {
throw getOnlyForCalciteException("mvexpand");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
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.Multikv;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
Expand Down Expand Up @@ -517,6 +518,10 @@ public T visitMvExpand(MvExpand node, C context) {
return visitChildren(node, context);
}

public T visitMultikv(Multikv node, C context) {
return visitChildren(node, context);
}

public T visitGraphLookup(GraphLookup node, C context) {
return visitChildren(node, context);
}
Expand Down
89 changes: 89 additions & 0 deletions core/src/main/java/org/opensearch/sql/ast/tree/Multikv.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 javax.annotation.Nullable;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.opensearch.sql.ast.AbstractNodeVisitor;
import org.opensearch.sql.ast.expression.Field;

/**
* AST node representing the {@code multikv} PPL command.
*
* <p>{@code multikv} extracts field values from an input field (default {@code _raw}) and emits one
* row per source row. The input field is either table-formatted text (split into columns) or an
* array of objects (one row per element, each declared column read from the element). This is a
* one-to-many (row-multiplying) command.
*
* <p>The output column names must be determinable at plan time, sourced from the declared {@link
* #fields} list, a literal {@link #forceHeader} line, or positional naming when {@link #noHeader}
* is set. Runtime header auto-detection (no fields, no forceheader, no noheader) is not supported;
* such a query is rejected at the field-resolution phase with a message directing the author to add
* a {@code fields} clause.
*/
@ToString
@EqualsAndHashCode(callSuper = false)
@Getter
public class Multikv extends UnresolvedPlan {

/** Default Splunk input field for multikv. */
public static final String DEFAULT_INPUT_FIELD = "_raw";

private UnresolvedPlan child;

/** Input field carrying the table text. Defaults to {@code _raw}. */
private final String inField;

/** Declared output columns (the {@code fields} option). Null when not declared. */
@Nullable private final List<Field> fields;

/** Filter terms; a table row is kept only if it contains at least one term. Null when absent. */
@Nullable private final List<String> filterTerms;

/** 1-based header line to force (the {@code forceheader} option). Null when absent. */
@Nullable private final Integer forceHeader;

/** When true, columns are named positionally (Column_1, Column_2, ...). */
private final boolean noHeader;

/** When true (default), the original event is dropped from the output. */
private final boolean rmOrig;

public Multikv(
String inField,
@Nullable List<Field> fields,
@Nullable List<String> filterTerms,
@Nullable Integer forceHeader,
boolean noHeader,
boolean rmOrig) {
this.inField = inField;
this.fields = fields;
this.filterTerms = filterTerms;
this.forceHeader = forceHeader;
this.noHeader = noHeader;
this.rmOrig = rmOrig;
}

@Override
public Multikv attach(UnresolvedPlan child) {
this.child = child;
return this;
}

@Override
public List<UnresolvedPlan> getChild() {
return this.child == null ? ImmutableList.of() : ImmutableList.of(this.child);
}

@Override
public <T, C> T accept(AbstractNodeVisitor<T, C> nodeVisitor, C context) {
return nodeVisitor.visitMultikv(this, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
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.Multikv;
import org.opensearch.sql.ast.tree.Multisearch;
import org.opensearch.sql.ast.tree.MvCombine;
import org.opensearch.sql.ast.tree.MvExpand;
Expand Down Expand Up @@ -197,6 +198,7 @@
import org.opensearch.sql.expression.function.BuiltinFunctionName;
import org.opensearch.sql.expression.function.PPLBuiltinOperators;
import org.opensearch.sql.expression.function.PPLFuncImpTable;
import org.opensearch.sql.expression.function.multikv.MultikvParser;
import org.opensearch.sql.expression.parse.RegexCommonUtils;
import org.opensearch.sql.utils.ParseUtils;
import org.opensearch.sql.utils.WildcardRenameUtils;
Expand Down Expand Up @@ -4469,6 +4471,146 @@ public RelNode visitMvExpand(MvExpand mvExpand, CalcitePlanContext context) {
return relBuilder.peek();
}

/**
* multikv (fixed-schema): rewrite to an equivalent pipeline using existing operators.
*
* <pre>
* eval __multikv_record__ = MULTIKV_SPLIT(inField, forceHeader, noHeader, filter) // array&lt;varchar&gt;
* | mvexpand __multikv_record__ // 1 -&gt; N rows
* | eval &lt;col&gt; = MULTIKV_EXTRACT(__multikv_record__, '&lt;col&gt;') for each declared field
* | fields &lt;col1&gt;, &lt;col2&gt;, ... // declared output schema
* </pre>
*
* The output column names come from the declared {@code fields} list and are therefore known at
* plan time. When no {@code fields} clause is declared, the output schema is not determinable at
* plan time and is rejected with guidance to add a {@code fields} clause.
*
* <p>When the input field is an object or an array of objects instead of text, the command
* dispatches to a native rewrite: {@code mvexpand} an array (a single object needs no explosion),
* then read each declared column from the object with {@code ITEM}. This preserves value types
* and shares the {@code fields} contract.
*/
@Override
public RelNode visitMultikv(Multikv node, CalcitePlanContext context) {
List<Field> fields = node.getFields();
boolean noFields = (fields == null || fields.isEmpty());

// Fixed-schema: named output columns must be declared via the fields clause. The only
// no-fields form that is supported is positional noheader, which produces row-explosion with
// no named columns (e.g. `... | multikv noheader=true | stats count`). Any other no-fields
// form (bare auto-header, or forceheader without fields) cannot yield a plan-time schema and
// is rejected here with guidance, rather than failing at execution.
if (noFields && !node.isNoHeader()) {
throw ErrorReport.wrap(
new SemanticCheckException(
"multikv has no declared output columns. Add an explicit fields clause, for"
+ " example: multikv fields <col1> <col2>"))
.code(ErrorCode.FIELD_NOT_FOUND)
.location("while resolving the output schema for multikv")
.context("command", "multikv")
.build();
}

// Dispatch on the input field's type. A structured (array of objects) input is exploded
// natively with mvexpand and each declared column is read with ITEM, preserving element types.
// A text input falls through to the split pipeline below. The child is built once here to read
// its schema; the structured branch reuses that build, the text branch discards it.
boolean savedProjectVisited = context.isProjectVisited();
RelNode probe = node.getChild().get(0).accept(this, context);
RelDataTypeField probeField = probe.getRowType().getField(node.getInField(), true, false);
boolean structuredArray =
probeField != null
&& (SqlTypeUtil.isArray(probeField.getType())
|| SqlTypeUtil.isMultiset(probeField.getType()));
boolean structuredMap = probeField != null && SqlTypeUtil.isMap(probeField.getType());
if (structuredArray || structuredMap) {
RelBuilder relBuilder = context.relBuilder;
if (structuredArray) {
// Array of objects: one row per element. A single object (map) needs no explosion.
buildExpandRelNode(
relBuilder.field(node.getInField()),
node.getInField(),
node.getInField(),
null,
context);
}
if (noFields) {
return relBuilder.peek();
}
List<RexNode> projected = new ArrayList<>();
List<String> names = new ArrayList<>();
for (Field f : fields) {
String col = f.getField().toString();
RexNode item =
PPLFuncImpTable.INSTANCE.resolve(
context.rexBuilder,
BuiltinFunctionName.INTERNAL_ITEM,
relBuilder.field(node.getInField()),
context.rexBuilder.makeLiteral(
col,
context.rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR),
true));
projected.add(item);
names.add(col);
}
relBuilder.project(projected, names);
context.setProjectVisited(true);
return relBuilder.peek();
}
// Text input: discard the probe build and run the split pipeline on a fresh build.
context.relBuilder.build();
context.setProjectVisited(savedProjectVisited);

final String lineField = "__multikv_record__";
final int forceHeader = node.getForceHeader() == null ? -1 : node.getForceHeader();
final String filterJoined =
(node.getFilterTerms() == null || node.getFilterTerms().isEmpty())
? ""
: String.join(MultikvParser.FS, node.getFilterTerms());

// 1. eval __multikv_record__ = MULTIKV_SPLIT(inField, forceHeader, noHeader, filter)
UnresolvedPlan plan =
AstDSL.eval(
node.getChild().get(0),
AstDSL.let(
AstDSL.field(lineField),
AstDSL.function(
"multikv_split",
AstDSL.field(node.getInField()),
AstDSL.intLiteral(forceHeader),
AstDSL.booleanLiteral(node.isNoHeader()),
AstDSL.stringLiteral(filterJoined))));

// 2. mvexpand __multikv_record__ (one row per table data row)
plan = new MvExpand(AstDSL.field(lineField), null).attach(plan);

if (noFields) {
// Positional noheader, no named columns: row-explosion only. The helper record column is
// retained (downstream typically only counts rows). Naming positional columns is deferred.
return plan.accept(this, context);
}

// 3. eval each declared column = MULTIKV_EXTRACT(__multikv_record__, '<col>')
Let[] lets =
fields.stream()
.map(
f -> {
String col = f.getField().toString();
return AstDSL.let(
AstDSL.field(col),
AstDSL.function(
"multikv_extract", AstDSL.field(lineField), AstDSL.stringLiteral(col)));
})
.toArray(Let[]::new);
plan = AstDSL.eval(plan, lets);

// 4. project the declared columns only (drops original event fields and the helper record)
UnresolvedExpression[] projections = fields.toArray(new UnresolvedExpression[0]);
plan = AstDSL.project(plan, projections);

return plan.accept(this, context);
}

@Override
public RelNode visitValues(Values values, CalcitePlanContext context) {
List<List<Literal>> rows = values.getValues();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ public enum BuiltinFunctionName {
JSON_ARRAY_LENGTH(FunctionName.of("json_array_length")),
JSON_EXTRACT(FunctionName.of("json_extract")),
JSON_EXTRACT_ALL(FunctionName.of("json_extract_all"), true),
MULTIKV_SPLIT(FunctionName.of("multikv_split"), true),
MULTIKV_EXTRACT(FunctionName.of("multikv_extract"), true),
JSON_KEYS(FunctionName.of("json_keys")),
JSON_SET(FunctionName.of("json_set")),
JSON_DELETE(FunctionName.of("json_delete")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ public class PPLBuiltinOperators extends ReflectiveSqlOperatorTable {
public static final SqlOperator JSON_APPEND = new JsonAppendFunctionImpl().toUDF("JSON_APPEND");
public static final SqlOperator JSON_EXTEND = new JsonExtendFunctionImpl().toUDF("JSON_EXTEND");

// multikv internal functions
public static final SqlOperator MULTIKV_SPLIT =
new org.opensearch.sql.expression.function.multikv.MultikvSplitFunctionImpl()
.toUDF("MULTIKV_SPLIT");
public static final SqlOperator MULTIKV_EXTRACT =
new org.opensearch.sql.expression.function.multikv.MultikvExtractFunctionImpl()
.toUDF("MULTIKV_EXTRACT");

// Math functions
public static final SqlOperator SPAN = new SpanFunction().toUDF("SPAN");
public static final SqlOperator E = new EulerFunction().toUDF("E");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MONTHNAME;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MONTH_OF_YEAR;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MSTIME;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIKV_EXTRACT;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIKV_SPLIT;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIMATCH;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIMATCHQUERY;
import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIPLY;
Expand Down Expand Up @@ -1335,6 +1337,8 @@ void populate() {
registerOperator(JSON_APPEND, PPLBuiltinOperators.JSON_APPEND);
registerOperator(JSON_EXTEND, PPLBuiltinOperators.JSON_EXTEND);
registerOperator(JSON_EXTRACT_ALL, PPLBuiltinOperators.JSON_EXTRACT_ALL); // internal
registerOperator(MULTIKV_SPLIT, PPLBuiltinOperators.MULTIKV_SPLIT); // internal
registerOperator(MULTIKV_EXTRACT, PPLBuiltinOperators.MULTIKV_EXTRACT); // internal

// Register operators with a different type checker

Expand Down
Loading
Loading