-
Notifications
You must be signed in to change notification settings - Fork 221
Add PPL multikv command (fixed-schema) #5641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't have such metadata field yet. Is it expected to be explicitly generated by user in |
||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -143,6 +143,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; | ||
|
|
@@ -199,6 +200,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; | ||
|
|
@@ -4663,6 +4665,143 @@ 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<varchar> | ||
| * | mvexpand __multikv_record__ // 1 -> N rows | ||
| * | eval <col> = MULTIKV_EXTRACT(__multikv_record__, '<col>') for each declared field | ||
| * | fields <col1>, <col2>, ... // 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}. The extracted columns are | ||
| * typed {@code ANY}: object and nested fields collapse to ANY-valued containers in the type | ||
| * layer, so the mapped scalar type is not recovered (cast downstream). 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: output columns must come from the fields clause. The only supported no-fields | ||
| // form is positional noheader (row-explosion, no named columns). Any other no-fields form | ||
| // (bare auto-header, or forceheader without fields) has no plan-time schema and is rejected. | ||
| 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; the extracted column is | ||
| // typed ANY (element types are erased to ANY upstream), not the mapped scalar type. 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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For input types other than ARRAY and MAP, the current implementation silently falls back to the text path and treats them as strings. Should we validate the input type here and reject unsupported types during planning? |
||
| 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); | ||
|
Comment on lines
+4755
to
+4757
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type-based dispatch is reasonable, but the current implementation visits the child twice. The first visit mutates the shared RelBuilder and CalcitePlanContext; text mode then calls build() and only restores isProjectVisited before rebuilding the child. This is not a full rollback and may affect features such as correlation binding or plan-node tracking. Could we build the child once and lower text mode from the resulting RelNode? Is there a specific reason to discard the build in text mode? The schema is known at stack peek and it seems feasible to build text mode specific MvExpand Relnode on top of it. |
||
|
|
||
| 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()); | ||
|
|
||
| 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)))); | ||
|
|
||
| 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); | ||
| } | ||
|
|
||
| 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); | ||
|
|
||
| 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(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /* | ||
| * Copyright OpenSearch Contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package org.opensearch.sql.expression.function.multikv; | ||
|
|
||
| import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.TYPE_FACTORY; | ||
|
|
||
| import java.util.List; | ||
| import org.apache.calcite.adapter.enumerable.NotNullImplementor; | ||
| import org.apache.calcite.adapter.enumerable.NullPolicy; | ||
| import org.apache.calcite.adapter.enumerable.RexImpTable; | ||
| import org.apache.calcite.adapter.enumerable.RexToLixTranslator; | ||
| import org.apache.calcite.linq4j.tree.Expression; | ||
| import org.apache.calcite.linq4j.tree.Types; | ||
| import org.apache.calcite.rex.RexCall; | ||
| import org.apache.calcite.schema.impl.ScalarFunctionImpl; | ||
| import org.apache.calcite.sql.type.ReturnTypes; | ||
| import org.apache.calcite.sql.type.SqlReturnTypeInference; | ||
| import org.apache.calcite.sql.type.SqlTypeName; | ||
| import org.opensearch.sql.expression.function.ImplementorUDF; | ||
| import org.opensearch.sql.expression.function.UDFOperandMetadata; | ||
|
|
||
| /** | ||
| * Internal UDF backing the {@code multikv} command. Extracts a single named cell value out of one | ||
| * serialized per-row record produced by {@link MultikvSplitFunctionImpl}. | ||
| * | ||
| * <p>Signature: {@code MULTIKV_EXTRACT(recordString, columnName)} returns {@code varchar} (null | ||
| * when the column is absent from the record). | ||
| */ | ||
| public class MultikvExtractFunctionImpl extends ImplementorUDF { | ||
|
|
||
| public MultikvExtractFunctionImpl() { | ||
| super(new MultikvExtractImplementor(), NullPolicy.ANY); | ||
| } | ||
|
|
||
| @Override | ||
| public SqlReturnTypeInference getReturnTypeInference() { | ||
| return ReturnTypes.explicit( | ||
| TYPE_FACTORY.createTypeWithNullability( | ||
| TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR), true)); | ||
| } | ||
|
|
||
| @Override | ||
| public UDFOperandMetadata getOperandMetadata() { | ||
| return null; | ||
| } | ||
|
|
||
| public static Object eval(Object... args) { | ||
| if (args.length < 2 || args[0] == null || args[1] == null) { | ||
| return null; | ||
| } | ||
| return MultikvParser.extract((String) args[0], (String) args[1]); | ||
| } | ||
|
|
||
| public static class MultikvExtractImplementor implements NotNullImplementor { | ||
| @Override | ||
| public Expression implement( | ||
| RexToLixTranslator translator, RexCall call, List<Expression> translatedOperands) { | ||
| ScalarFunctionImpl function = | ||
| (ScalarFunctionImpl) | ||
| ScalarFunctionImpl.create( | ||
| Types.lookupMethod(MultikvExtractFunctionImpl.class, "eval", Object[].class)); | ||
| return function.getImplementor().implement(translator, call, RexImpTable.NullAs.NULL); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid Splunk wording in comments.