Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@
import org.apache.calcite.adapter.enumerable.RexToLixTranslator;
import org.apache.calcite.plan.RelOptTable;
import org.apache.calcite.plan.ViewExpanders;
import org.apache.calcite.rel.BiRel;
import org.apache.calcite.rel.RelCollation;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.Aggregate;
import org.apache.calcite.rel.core.JoinRelType;
import org.apache.calcite.rel.core.SetOp;
import org.apache.calcite.rel.core.Uncollect;
import org.apache.calcite.rel.logical.LogicalProject;
import org.apache.calcite.rel.logical.LogicalValues;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFamily;
Expand Down Expand Up @@ -667,25 +672,133 @@ public RelNode visitHead(Head node, CalcitePlanContext context) {
return context.relBuilder.peek();
}

private static final String REVERSE_ROW_NUM = "__reverse_row_num__";
/**
* Backtrack through the RelNode tree to find the first Sort node with non-empty collation. Stops
* at blocking operators that break ordering:
*
* <ul>
* <li>Aggregate - aggregation destroys input ordering
* <li>BiRel - covers Join, Correlate, and other binary relations
* <li>SetOp - covers Union, Intersect, Except
* <li>Uncollect - unnesting operation that may change ordering
* <li>Project with window functions (RexOver) - ordering determined by window's ORDER BY
* </ul>
*
* @param node the starting RelNode to backtrack from
* @return the collation found, or null if no sort or blocking operator encountered
*/
private RelCollation backtrackForCollation(RelNode node) {
while (node != null) {
// Check for blocking operators that destroy collation
// BiRel covers Join, Correlate, and other binary relations
// SetOp covers Union, Intersect, Except
// Uncollect unnests arrays/multisets which may change ordering
if (node instanceof Aggregate
|| node instanceof BiRel
|| node instanceof SetOp
|| node instanceof Uncollect) {
return null;
}

// Project with window functions has ordering determined by the window's ORDER BY clause
// We should not destroy its output order by inserting a reversed sort
if (node instanceof LogicalProject && ((LogicalProject) node).containsOver()) {
return null;
}

// Check for Sort node with collation
if (node instanceof org.apache.calcite.rel.core.Sort) {
org.apache.calcite.rel.core.Sort sort = (org.apache.calcite.rel.core.Sort) node;
if (sort.getCollation() != null && !sort.getCollation().getFieldCollations().isEmpty()) {
return sort.getCollation();
}
}

// Continue to child node
if (node.getInputs().isEmpty()) {
break;
}
node = node.getInput(0);
}
return null;
}

/**
* Insert a reversed sort node after finding the original sort in the tree. This rebuilds the tree
* with the reversed sort inserted right after the original sort.
*
* @param root the root of the tree to rebuild
* @param reversedCollation the reversed collation to insert
* @param context the Calcite plan context
* @return the rebuilt tree with reversed sort inserted
*/
private RelNode insertReversedSortInTree(
RelNode root, RelCollation reversedCollation, CalcitePlanContext context) {
return root.accept(
new org.apache.calcite.rel.RelHomogeneousShuttle() {
boolean sortFound = false;

@Override
public RelNode visit(RelNode other) {
// Check if this is a Sort node and we haven't inserted the reversed sort yet
if (!sortFound && other instanceof org.apache.calcite.rel.core.Sort) {
org.apache.calcite.rel.core.Sort sort = (org.apache.calcite.rel.core.Sort) other;
if (sort.getCollation() != null
&& !sort.getCollation().getFieldCollations().isEmpty()) {
// Found the sort node - insert reversed sort after it
sortFound = true;
// First visit the sort's children
RelNode visitedSort = super.visit(other);
// Create a new reversed sort on top of the original sort
return org.apache.calcite.rel.logical.LogicalSort.create(
visitedSort, reversedCollation, null, null);
}
}
// For all other nodes, continue traversal
return super.visit(other);
}
});
}

@Override
public RelNode visitReverse(
org.opensearch.sql.ast.tree.Reverse node, CalcitePlanContext context) {
visitChildren(node, context);
// Add ROW_NUMBER() column
RexNode rowNumber =
context
.relBuilder
.aggregateCall(SqlStdOperatorTable.ROW_NUMBER)
.over()
.rowsTo(RexWindowBounds.CURRENT_ROW)
.as(REVERSE_ROW_NUM);
context.relBuilder.projectPlus(rowNumber);
// Sort by row number descending
context.relBuilder.sort(context.relBuilder.desc(context.relBuilder.field(REVERSE_ROW_NUM)));
// Remove row number column
context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM));

// Check if there's an existing sort to reverse
List<RelCollation> collations =
context.relBuilder.getCluster().getMetadataQuery().collations(context.relBuilder.peek());
RelCollation collation = collations != null && !collations.isEmpty() ? collations.get(0) : null;

if (collation != null && !collation.getFieldCollations().isEmpty()) {
// If there's an existing sort, reverse its direction
RelCollation reversedCollation = PlanUtils.reverseCollation(collation);
context.relBuilder.sort(reversedCollation);
} else {
// Collation not found on current node - try backtracking
RelNode currentNode = context.relBuilder.peek();
RelCollation backtrackCollation = backtrackForCollation(currentNode);

if (backtrackCollation != null && !backtrackCollation.getFieldCollations().isEmpty()) {
// Found collation through backtracking - rebuild tree with reversed sort
RelCollation reversedCollation = PlanUtils.reverseCollation(backtrackCollation);
RelNode rebuiltTree = insertReversedSortInTree(currentNode, reversedCollation, context);
// Replace the current node in the builder with the rebuilt tree
context.relBuilder.build(); // Pop the current node
context.relBuilder.push(rebuiltTree); // Push the rebuilt tree
} else {
// Check if @timestamp field exists in the row type
List<String> fieldNames = context.relBuilder.peek().getRowType().getFieldNames();
if (fieldNames.contains(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP)) {
// If @timestamp exists, sort by it in descending order
context.relBuilder.sort(
context.relBuilder.desc(
context.relBuilder.field(OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP)));
}
// If neither collation nor @timestamp exists, ignore the reverse command (no-op)
}
}

return context.relBuilder.peek();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.calcite.plan.RelOptTable;
import org.apache.calcite.rel.RelCollation;
import org.apache.calcite.rel.RelCollations;
import org.apache.calcite.rel.RelFieldCollation;
import org.apache.calcite.rel.RelHomogeneousShuttle;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.RelShuttle;
Expand Down Expand Up @@ -588,6 +591,37 @@ public Void visitCorrelVariable(RexCorrelVariable correlVar) {
}
}

/**
* Reverses the direction of a RelCollation.
*
* @param original The original collation to reverse
* @return A new RelCollation with reversed directions
*/
public static RelCollation reverseCollation(RelCollation original) {
if (original == null || original.getFieldCollations().isEmpty()) {
return original;
}

List<RelFieldCollation> reversedFields = new ArrayList<>();
for (RelFieldCollation field : original.getFieldCollations()) {
RelFieldCollation.Direction reversedDirection = field.direction.reverse();

// Handle null direction properly - reverse it as well
RelFieldCollation.NullDirection reversedNullDirection =
field.nullDirection == RelFieldCollation.NullDirection.FIRST
? RelFieldCollation.NullDirection.LAST
: field.nullDirection == RelFieldCollation.NullDirection.LAST
? RelFieldCollation.NullDirection.FIRST
: field.nullDirection;

RelFieldCollation reversedField =
new RelFieldCollation(field.getFieldIndex(), reversedDirection, reversedNullDirection);
reversedFields.add(reversedField);
}

return RelCollations.of(reversedFields);
}

/** Adds a rel node to the top of the stack while preserving the field names and aliases. */
static void replaceTop(RelBuilder relBuilder, RelNode relNode) {
try {
Expand Down
1 change: 1 addition & 0 deletions docs/category.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"user/ppl/cmd/rename.rst",
"user/ppl/cmd/multisearch.rst",
"user/ppl/cmd/replace.rst",
"user/ppl/cmd/reverse.rst",
"user/ppl/cmd/rex.rst",
"user/ppl/cmd/search.rst",
"user/ppl/cmd/showdatasources.rst",
Expand Down
95 changes: 68 additions & 27 deletions docs/user/ppl/cmd/reverse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,26 @@ reverse

Description
===========
| The ``reverse`` command reverses the display order of search results. The same results are returned, but in reverse order.
| The ``reverse`` command reverses the display order of search results. The behavior depends on the query context:
|
| **1. With existing sort**: Reverses the sort direction(s)
| **2. With @timestamp field (no explicit sort)**: Sorts by @timestamp in descending order
| **3. Without sort or @timestamp**: The command is ignored (no effect)
============
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix misplaced RST heading underline.

Line 19 has a stray ============ that appears to be a formatting artifact. In RST, heading underlines should directly follow heading text. This line should likely be removed or the preceding content restructured.

 | **2. With @timestamp field (no explicit sort)**: Sorts by @timestamp in descending order
 | **3. Without sort or @timestamp**: The command is ignored (no effect)
-============
 
 Behavior
 ========
🤖 Prompt for AI Agents
In docs/user/ppl/cmd/reverse.rst around line 19, remove the stray line
containing only "============" (or move it to directly underline a preceding
heading) because it is a misplaced RST heading underline; ensure any heading
underline in this file directly follows the heading text and that no standalone
underline artifacts remain.


Behavior
========
The ``reverse`` command follows a three-tier logic:

1. **If there's an explicit sort command before reverse**: The reverse command flips all sort directions (ASC ↔ DESC)
2. **If no explicit sort but the index has an @timestamp field**: The reverse command sorts by @timestamp in descending order (most recent first)
3. **If neither condition is met**: The reverse command is ignored and has no effect on the result order

This design optimizes performance by avoiding expensive operations when reverse has no meaningful semantic interpretation.

Version
=======
3.2.0

Syntax
======
Expand All @@ -21,16 +40,16 @@ reverse

Note
====
| The `reverse` command processes the entire dataset. If applied directly to millions of records, it will consume significant memory resources on the coordinating node. Users should only apply the `reverse` command to smaller datasets, typically after aggregation operations.
The ``reverse`` command is optimized to avoid unnecessary memory consumption. When applied without an explicit sort or @timestamp field, it is ignored. When used with an explicit sort, it efficiently reverses the sort direction(s) without materializing the entire dataset.

Example 1: Basic reverse operation
==================================
Example 1: Reverse with explicit sort
======================================

This example shows reversing the order of all documents.
The example shows reversing the order of all documents.

PPL query::

os> source=accounts | fields account_number, age | reverse;
os> source=accounts | sort age | fields account_number, age | reverse;
fetched rows / total rows = 4/4
+----------------+-----+
| account_number | age |
Expand All @@ -42,33 +61,52 @@ PPL query::
+----------------+-----+


Example 2: Reverse with sort
============================
Example 2: Reverse with @timestamp field
=========================================

This example shows reversing results after sorting by age in ascending order, effectively giving descending order.
The example shows reverse on a time-series index automatically sorts by @timestamp in descending order (most recent first).

PPL query::

os> source=accounts | sort age | fields account_number, age | reverse;
fetched rows / total rows = 4/4
os> source=time_test | fields value, @timestamp | reverse | head 3;
fetched rows / total rows = 3/3
+-------+---------------------+
| value | @timestamp |
|-------+---------------------|
| 9243 | 2025-07-28 09:41:29 |
| 7654 | 2025-07-28 08:22:11 |
| 8321 | 2025-07-28 07:05:33 |
+-------+---------------------+

Note: When the index contains an @timestamp field and no explicit sort is specified, reverse will sort by @timestamp DESC to show the most recent events first. This is particularly useful for log analysis and time-series data.

Example 3: Reverse ignored (no sort, no @timestamp)
===================================================

The example shows that reverse is ignored when there's no explicit sort and no @timestamp field.

PPL query::

os> source=accounts | fields account_number, age | reverse | head 2;
fetched rows / total rows = 2/2
+----------------+-----+
| account_number | age |
|----------------+-----|
| 6 | 36 |
| 18 | 33 |
| 1 | 32 |
| 13 | 28 |
| 6 | 36 |
+----------------+-----+

Note: Results appear in natural order (same as without reverse) because accounts index has no @timestamp field and no explicit sort was specified.


Example 3: Reverse with head
============================
Example 4: Reverse with sort and head
=====================================

This example shows using reverse with head to get the last 2 records from the original order.
The example shows using reverse with sort and head to get the top 2 records by age.

PPL query::

os> source=accounts | reverse | head 2 | fields account_number, age;
os> source=accounts | sort age | reverse | head 2 | fields account_number, age;
fetched rows / total rows = 2/2
+----------------+-----+
| account_number | age |
Expand All @@ -78,14 +116,14 @@ PPL query::
+----------------+-----+


Example 4: Double reverse
=========================
Example 5: Double reverse with sort
===================================

This example shows that applying reverse twice returns to the original order.
The example shows that applying reverse twice with an explicit sort returns to the original sort order.

PPL query::

os> source=accounts | reverse | reverse | fields account_number, age;
os> source=accounts | sort age | reverse | reverse | fields account_number, age;
fetched rows / total rows = 4/4
+----------------+-----+
| account_number | age |
Expand All @@ -97,19 +135,22 @@ PPL query::
+----------------+-----+


Example 5: Reverse with complex pipeline
========================================
Example 6: Reverse with multiple sort fields
============================================

This example shows reverse working with filtering and field selection.
The example shows reverse flipping all sort directions when multiple fields are sorted.

PPL query::

os> source=accounts | where age > 30 | fields account_number, age | reverse;
fetched rows / total rows = 3/3
os> source=accounts | sort +age, -account_number | reverse | fields account_number, age;
fetched rows / total rows = 4/4
+----------------+-----+
| account_number | age |
|----------------+-----|
| 6 | 36 |
| 18 | 33 |
| 1 | 32 |
| 13 | 28 |
+----------------+-----+

Note: Original sort is ASC age, DESC account_number. After reverse, it becomes DESC age, ASC account_number.
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
CalciteQueryAnalysisIT.class,
CalciteRareCommandIT.class,
CalciteRegexCommandIT.class,
CalciteReverseCommandIT.class,
CalciteRexCommandIT.class,
CalciteRenameCommandIT.class,
CalciteReplaceCommandIT.class,
Expand Down
Loading
Loading