Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
import org.opensearch.sql.ast.tree.UnresolvedPlan;
import org.opensearch.sql.ast.tree.Values;
import org.opensearch.sql.ast.tree.Window;
import org.opensearch.sql.ast.tree.Xyseries;
import org.opensearch.sql.common.antlr.SyntaxCheckException;
import org.opensearch.sql.data.model.ExprMissingValue;
import org.opensearch.sql.data.type.ExprCoreType;
Expand Down Expand Up @@ -842,6 +843,11 @@ public LogicalPlan visitChart(Chart node, AnalysisContext context) {
throw getOnlyForCalciteException("Chart");
}

@Override
public LogicalPlan visitXyseries(Xyseries node, AnalysisContext context) {
throw getOnlyForCalciteException("Xyseries");
}

@Override
public LogicalPlan visitWindow(Window node, AnalysisContext context) {
throw getOnlyForCalciteException("Window");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
import org.opensearch.sql.ast.tree.Union;
import org.opensearch.sql.ast.tree.Values;
import org.opensearch.sql.ast.tree.Window;
import org.opensearch.sql.ast.tree.Xyseries;

/** AST nodes visitor Defines the traverse path. */
public abstract class AbstractNodeVisitor<T, C> {
Expand Down Expand Up @@ -520,4 +521,8 @@ public T visitMvExpand(MvExpand node, C context) {
public T visitGraphLookup(GraphLookup node, C context) {
return visitChildren(node, context);
}

public T visitXyseries(Xyseries node, C context) {
return visitChildren(node, context);
}
}
64 changes: 64 additions & 0 deletions core/src/main/java/org/opensearch/sql/ast/tree/Xyseries.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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.Setter;
import lombok.ToString;
import org.opensearch.sql.ast.AbstractNodeVisitor;
import org.opensearch.sql.ast.expression.UnresolvedExpression;

/**
* AST node representing the xyseries command. Converts row-oriented grouped results into a wide
* table where one field is the X axis (row key), one field provides pivot values for column naming,
* and one or more data fields fill the pivoted cells.
*/
@Getter
@ToString
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor
public class Xyseries extends UnresolvedPlan {

/** The x-axis field (row key in output). */
private final UnresolvedExpression xField;

/** The y-name field whose values become part of the output column names. */
private final UnresolvedExpression yNameField;

/** Explicit pivot values from the IN (...) clause. */
private final List<String> pivotValues;

/** One or more y-data fields whose values fill the pivoted cells. */
private final List<UnresolvedExpression> yDataFields;

/** Separator between y-data-field name and pivot value in column names. Default ":". */
private final String separator;

/** Optional format template for output column names using $AGG$ and $VAL$ placeholders. */
private final String format;

@Setter private UnresolvedPlan child;

@Override
public Xyseries 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.visitXyseries(this, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -171,6 +172,7 @@
import org.opensearch.sql.ast.tree.UnresolvedPlan;
import org.opensearch.sql.ast.tree.Values;
import org.opensearch.sql.ast.tree.Window;
import org.opensearch.sql.ast.tree.Xyseries;
import org.opensearch.sql.calcite.plan.AliasFieldsWrappable;
import org.opensearch.sql.calcite.plan.HighlightPushDown;
import org.opensearch.sql.calcite.plan.OpenSearchConstants;
Expand Down Expand Up @@ -4071,6 +4073,131 @@ static ChartConfig fromArguments(ArgumentMap argMap) {
}
}

@Override
public RelNode visitXyseries(Xyseries node, CalcitePlanContext context) {
visitChildren(node, context);

RelBuilder b = context.relBuilder;
RexBuilder rx = context.rexBuilder;

// Resolve x-field and y-name-field names
String xFieldName = resolveFieldName(node.getXField());
String yNameFieldName = resolveFieldName(node.getYNameField());

// Resolve y-data field names
List<String> yDataFieldNames =
node.getYDataFields().stream().map(this::resolveFieldName).collect(Collectors.toList());

List<String> pivotValues = node.getPivotValues() != null ? node.getPivotValues() : List.of();
String separator = node.getSeparator();
String format = node.getFormat();

// Build the pivot axis - cast to VARCHAR if needed for string comparison
RexNode yNameRef = b.field(yNameFieldName);
RelDataType yNameType = yNameRef.getType();
RexNode axis;
if (!SqlTypeUtil.isCharacter(yNameRef.getType())) {
if (!SqlTypeUtil.isAtomic(yNameType)) {
throw new IllegalArgumentException(
"xyseries y-name-field must be a scalar type, got: " + yNameType.getSqlTypeName());
}
RelDataType varchar =
rx.getTypeFactory()
.createTypeWithNullability(
rx.getTypeFactory().createSqlType(SqlTypeName.VARCHAR), true);
axis = rx.makeCast(varchar, yNameRef, true);
} else {
axis = yNameRef;
}
Comment thread
asifabashar marked this conversation as resolved.

// Build aggregate calls - MAX for each y-data field
List<AggCall> aggCalls =
yDataFieldNames.stream()
.map(name -> b.max(b.field(name)).as(name))
.collect(Collectors.toList());

// Build pivot value entries: alias -> [literal(value)]
// LinkedHashMap preserves insertion order for deterministic column ordering
LinkedHashMap<String, List<RexNode>> pivotValueMap = new LinkedHashMap<>();
for (String val : pivotValues) {
pivotValueMap.put(val, ImmutableList.of(b.literal(val)));
}

// Execute pivot: decomposes into GROUP BY x-field with FILTER-based aggregation
// Produces columns: x-field, {val1}_{agg1}, {val1}_{agg2}, {val2}_{agg1}, ...
b.pivot(
b.groupKey(b.field(xFieldName)),
aggCalls,
ImmutableList.of(axis),
pivotValueMap.entrySet());

// Pivot produces value-first column ordering: val1_agg1, val1_agg2, val2_agg1, ...
// Reorder to agg-first and apply custom column naming: agg1: val1, agg1: val2, ...
List<RexNode> reorderProjections = new ArrayList<>();
List<String> reorderNames = new ArrayList<>();

reorderProjections.add(b.field(xFieldName));
reorderNames.add(xFieldName);

for (String aggName : yDataFieldNames) {
for (String pivotVal : pivotValues) {
// Reference pivot output column by its generated name: {value}_{agg}
String pivotColName = pivotVal + "_" + aggName;
try {
reorderProjections.add(b.field(pivotColName));
} catch (IllegalArgumentException e) {
throw new IllegalStateException(
"xyseries: expected pivot output column '" + pivotColName + "' not found", e);
}
boolean singleDataField = yDataFieldNames.size() == 1;
reorderNames.add(generateColumnName(aggName, pivotVal, separator, format, singleDataField));
}
}
// Fail fast with a clear message if the naming scheme produced collisions
// (e.g. a format template that omits $VAL$ or $AGG$ with multiple series).
Set<String> seenNames = new HashSet<>();
for (String name : reorderNames) {
if (!seenNames.add(name)) {
throw new IllegalArgumentException(
"xyseries produced duplicate output column name '"
+ name
+ "'. Use a format template containing both $AGG$ and $VAL$ so column names"
+ " are unique.");
}
}
b.project(reorderProjections, reorderNames, true);

// Order by x-field
b.sort(b.field(0));

return b.peek();
}

private String resolveFieldName(UnresolvedExpression expr) {
if (expr instanceof Field) {
return ((Field) expr).getField().toString();
}
if (expr instanceof Alias) {
return ((Alias) expr).getName();
}
return expr.toString();
}

private String generateColumnName(
String yDataFieldName,
String pivotValue,
String separator,
String format,
boolean singleDataField) {
if (format != null) {
return format.replace("$AGG$", yDataFieldName).replace("$VAL$", pivotValue);
}
if (singleDataField) {
return pivotValue;
}
return yDataFieldName + separator + pivotValue;
}
Comment thread
asifabashar marked this conversation as resolved.

@Override
public RelNode visitTrendline(Trendline node, CalcitePlanContext context) {
visitChildren(node, context);
Expand Down
Loading
Loading