Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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 @@ -99,6 +99,7 @@
import org.opensearch.sql.ast.tree.StreamWindow;
import org.opensearch.sql.ast.tree.SubqueryAlias;
import org.opensearch.sql.ast.tree.TableFunction;
import org.opensearch.sql.ast.tree.Transpose;
import org.opensearch.sql.ast.tree.Trendline;
import org.opensearch.sql.ast.tree.UnresolvedPlan;
import org.opensearch.sql.ast.tree.Values;
Expand Down Expand Up @@ -703,6 +704,11 @@ public LogicalPlan visitML(ML node, AnalysisContext context) {
return new LogicalML(child, node.getArguments());
}

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

@Override
public LogicalPlan visitBin(Bin node, AnalysisContext context) {
throw getOnlyForCalciteException("Bin");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import org.opensearch.sql.ast.tree.StreamWindow;
import org.opensearch.sql.ast.tree.SubqueryAlias;
import org.opensearch.sql.ast.tree.TableFunction;
import org.opensearch.sql.ast.tree.Transpose;
import org.opensearch.sql.ast.tree.Trendline;
import org.opensearch.sql.ast.tree.Values;
import org.opensearch.sql.ast.tree.Window;
Expand Down Expand Up @@ -282,6 +283,10 @@ public T visitReverse(Reverse node, C context) {
return visitChildren(node, context);
}

public T visitTranspose(Transpose node, C context) {
return visitChildren(node, context);
}

public T visitChart(Chart node, C context) {
return visitChildren(node, context);
}
Expand Down
60 changes: 60 additions & 0 deletions core/src/main/java/org/opensearch/sql/ast/tree/Transpose.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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.*;
import org.opensearch.sql.ast.AbstractNodeVisitor;
import org.opensearch.sql.ast.expression.Argument;

/** AST node represent Transpose operation. */
@Getter
@Setter
@ToString
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor
public class Transpose extends UnresolvedPlan {
private final @NonNull java.util.Map<String, Argument> arguments;
private UnresolvedPlan child;

public Integer getMaxRows() {
Integer maxRows = 5;
if (arguments.containsKey("number")) {
try {
maxRows = Integer.parseInt(arguments.get("number").getValue().toString());
} catch (NumberFormatException e) {
// log warning and use default
maxRows = 5;
}
}
return maxRows;
}

public String getColumnName() {
String columnName = "column";
if (arguments.containsKey("columnName")) {
columnName = arguments.get("columnName").getValue().toString();
}
return columnName;
}

@Override
public Transpose 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.visitTranspose(this, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
Expand Down Expand Up @@ -696,6 +697,110 @@ public RelNode visitReverse(
return context.relBuilder.peek();
}

@Override
public RelNode visitTranspose(
org.opensearch.sql.ast.tree.Transpose node, CalcitePlanContext context) {
visitChildren(node, context);
Integer maxRows = node.getMaxRows();
if (maxRows == null || maxRows <= 0) {
throw new IllegalArgumentException("maxRows must be a positive integer");
}
String columnName = node.getColumnName();
// Get the current schema to transpose
RelNode currentNode = context.relBuilder.peek();
List<String> fieldNames = currentNode.getRowType().getFieldNames();
List<RelDataTypeField> fields = currentNode.getRowType().getFieldList();
if (fieldNames.isEmpty()) {
return currentNode;
}

// Add row numbers to identify each row uniquely
RexNode rowNumber =
context
.relBuilder
.aggregateCall(SqlStdOperatorTable.ROW_NUMBER)
.over()
.rowsTo(RexWindowBounds.CURRENT_ROW)
.as("__row_id__");
context.relBuilder.projectPlus(rowNumber);

// Unpivot the data - convert columns to rows
// Each field becomes a row with: row_id, column, value
List<String> measureColumns = ImmutableList.of("value");
List<String> axisColumns = ImmutableList.of(columnName);

// Create the unpivot value mappings
List<Map.Entry<List<RexLiteral>, List<RexNode>>> valueMappings = new ArrayList<>();
RelDataType varcharType =
context.rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR);

for (String fieldName : fieldNames) {
if (fieldName.equals("__row_id__")) {
continue; // Skip the row number column
}

// Create the axis value (column name as literal)
RexLiteral columnNameLiteral = context.rexBuilder.makeLiteral(fieldName);
List<RexLiteral> axisValues = ImmutableList.of(columnNameLiteral);

// Create the measure value (field expression cast to VARCHAR)
RexNode fieldValue = context.relBuilder.field(fieldName);
RexNode castValue = context.rexBuilder.makeCast(varcharType, fieldValue, true);
List<RexNode> measureValues = ImmutableList.of(castValue);

// Create the mapping entry
valueMappings.add(new AbstractMap.SimpleEntry<>(axisValues, measureValues));
}

// Apply the unpivot operation
context.relBuilder.unpivot(
false, // includeNulls = false
measureColumns, // measure column names: ["value"]
axisColumns, // axis column names: ["column"]
valueMappings // field mappings
);

// Pivot the data to transpose rows as columns
// Pivot on __row_id__ with column as the grouping key
// This creates: column, row1, row2, row3, ...

// Create conditional aggregations for each row position
// We'll use ROW_NUMBER to determine the row positions dynamically
RexNode rowPos =
context
.relBuilder
.aggregateCall(SqlStdOperatorTable.ROW_NUMBER)
.over()
.partitionBy(context.relBuilder.field(columnName))
.orderBy(context.relBuilder.field("__row_id__"))
.rowsTo(RexWindowBounds.CURRENT_ROW)
.as("__row_pos__");
context.relBuilder.projectPlus(rowPos);

// Create aggregation calls for each possible row position
List<AggCall> pivotAggCalls = new ArrayList<>();

for (int i = 1; i <= maxRows; i++) {
// Create CASE WHEN __row_id__ = i THEN value END for each row position
RexNode caseExpr =
context.relBuilder.call(
SqlStdOperatorTable.CASE,
context.relBuilder.equals(
context.relBuilder.field("__row_id__"), context.relBuilder.literal(i)),
context.relBuilder.field("value"),
context.relBuilder.literal(null));

AggCall maxCase = context.relBuilder.max(caseExpr).as("row " + i);
pivotAggCalls.add(maxCase);
}

// Group by column and apply the conditional aggregations
context.relBuilder.aggregate(
context.relBuilder.groupKey(context.relBuilder.field(columnName)), pivotAggCalls);

return context.relBuilder.peek();
}

@Override
public RelNode visitBin(Bin node, CalcitePlanContext context) {
visitChildren(node, context);
Expand Down
1 change: 1 addition & 0 deletions docs/category.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"user/ppl/cmd/timechart.md",
"user/ppl/cmd/top.md",
"user/ppl/cmd/trendline.md",
"user/ppl/cmd/transpose.md",
"user/ppl/cmd/where.md",
"user/ppl/functions/aggregations.md",
"user/ppl/functions/collection.md",
Expand Down
92 changes: 92 additions & 0 deletions docs/user/ppl/cmd/transpose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# transpose

## Description

The `transpose` command outputs the requested number of rows as columns, effectively transposing each result row into a corresponding column of field values.

## Syntax

transpose [int] [column_name=<string>]

* number-of-rows: optional. The number of rows to transform into columns.
* column_name: optional. The name of the first column to use when transposing rows. This column holds the field names.


## Example 1: Transpose results

This example shows transposing wihtout any parameters. It transforms 5 rows into columns as default is 5.

```ppl
source=accounts
| head 5
| fields account_number, firstname, lastname, balance
| transpose
```

Expected output:

```text
fetched rows / total rows = 4/4
+----------------+-------+--------+---------+-------+-------+
| column | row 1 | row 2 | row 3 | row 4 | row 5 |
|----------------+-------+--------+---------+-------+-------|
| account_number | 1 | 6 | 13 | 18 | null |
| firstname | Amber | Hattie | Nanette | Dale | null |
| balance | 39225 | 5686 | 32838 | 4180 | null |
| lastname | Duke | Bond | Bates | Adams | null |
+----------------+-------+--------+---------+-------+-------+
```

## Example 2: Tranpose results up to a provided number of rows.

This example shows transposing wihtout any parameters. It transforms 4 rows into columns as default is 5.

```ppl
source=accounts
| head 5
| fields account_number, firstname, lastname, balance
| transpose 4
```

Expected output:

```text
fetched rows / total rows = 4/4
+----------------+-------+--------+---------+-------+
| column | row 1 | row 2 | row 3 | row 4 |
|----------------+-------+--------+---------+-------|
| account_number | 1 | 6 | 13 | 18 |
| firstname | Amber | Hattie | Nanette | Dale |
| balance | 39225 | 5686 | 32838 | 4180 |
| lastname | Duke | Bond | Bates | Adams |
+----------------+-------+--------+---------+-------+
```

## Example 2: Tranpose results up to a provided number of rows and first column with specified column name.

This example shows transposing wihtout any parameters. It transforms 4 rows into columns as default is 5.

```ppl
source=accounts
| head 5
| fields account_number, firstname, lastname, balance
| transpose 4 column_name='column_names'
```

Expected output:

```text
fetched rows / total rows = 4/4
+----------------+-------+--------+---------+-------+
| column_names | row 1 | row 2 | row 3 | row 4 |
|----------------+-------+--------+---------+-------|
| account_number | 1 | 6 | 13 | 18 |
| firstname | Amber | Hattie | Nanette | Dale |
| balance | 39225 | 5686 | 32838 | 4180 |
| lastname | Duke | Bond | Bates | Adams |
+----------------+-------+--------+---------+-------+
```

## Limitations

The `transpose` command transforms up to a number of rows specified and if not enough rows found, it shows those transposed rows as null columns.
Loading
Loading