Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
import org.opensearch.sql.ast.tree.Relation;
import org.opensearch.sql.ast.tree.RelationSubquery;
import org.opensearch.sql.ast.tree.Rename;
import org.opensearch.sql.ast.tree.Reverse;
import org.opensearch.sql.ast.tree.Sort;
import org.opensearch.sql.ast.tree.Sort.SortOption;
import org.opensearch.sql.ast.tree.SubqueryAlias;
Expand Down Expand Up @@ -682,6 +683,12 @@ public LogicalPlan visitFlatten(Flatten node, AnalysisContext context) {
"FLATTEN is supported only when " + CALCITE_ENGINE_ENABLED.getKeyValue() + "=true");
}

@Override
public LogicalPlan visitReverse(Reverse node, AnalysisContext context) {
throw new UnsupportedOperationException(
"REVERSE is supported only when " + CALCITE_ENGINE_ENABLED.getKeyValue() + "=true");
}

@Override
public LogicalPlan visitPaginate(Paginate paginate, AnalysisContext context) {
LogicalPlan child = paginate.getChild().get(0).accept(this, context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import org.opensearch.sql.ast.tree.Relation;
import org.opensearch.sql.ast.tree.RelationSubquery;
import org.opensearch.sql.ast.tree.Rename;
import org.opensearch.sql.ast.tree.Reverse;
import org.opensearch.sql.ast.tree.Sort;
import org.opensearch.sql.ast.tree.SubqueryAlias;
import org.opensearch.sql.ast.tree.TableFunction;
Expand Down Expand Up @@ -244,6 +245,10 @@ public T visitSort(Sort node, C context) {
return visitChildren(node, context);
}

public T visitReverse(Reverse node, C context) {
return visitChildren(node, context);
}

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

/** AST node represent Reverse operation. */
@Getter
@Setter
@ToString
@EqualsAndHashCode(callSuper = false)
@RequiredArgsConstructor
public class Reverse extends UnresolvedPlan {

private UnresolvedPlan child;

@Override
public Reverse 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.visitReverse(this, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,28 @@ public RelNode visitHead(Head node, CalcitePlanContext context) {
return context.relBuilder.peek();
}

private static final String REVERSE_ROW_NUM = "__reverse_row_num__";

@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)));
Comment on lines +364 to +374
Copy link
Member

Choose a reason for hiding this comment

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

IMO, reverse by row_number should only apply to the plan (RelNode) which doesn't contain collation. For example:
source=t | ... | sort - age | reverse should apply sort by age asc, rather than row_number.
You can try:

RelCollation collation = context.relBuilder.peek().getTraitSet().getCollation();
if (collation == null || collation == RelCollations.EMPTY) {
  // Add ROW_NUMBER() column
    RexNode rowNumber =
   ...
} else {
  RelCollation reversedCollation = reverseCollation(collation);
  context.relBuilder.sort(reversedCollation)
}

Add reverseCollation() to PlanUtils.class

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();
        
        RelFieldCollation reversedField = new RelFieldCollation(
            field.getFieldIndex(),
            reversedDirection,
            field.nullDirection
        );
        reversedFields.add(reversedField);
    }

    return RelCollations.of(reversedFields);
}

Copy link
Member

@LantaoJin LantaoJin Aug 5, 2025

Choose a reason for hiding this comment

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

Then add some tests:

source=t | ... | sort - age | reverse
source=t | ... | sort - age, + gender | reverse
source=t | ... | sort - age | reverse | reverse
source=t | ... | sort - age, + gender | reverse  | reverse

Copy link
Collaborator

Choose a reason for hiding this comment

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

@LantaoJin @selsong is it correct issue? or performance imporvement?
If it is performance improvement, we can track by issue #3924. And raise 2nd PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion! This is a performance improvement, and it's already tracked under issue #3924. The current implementation still produces correct results, but applying reverseCollation where applicable would avoid unnecessary computation. I'll address this enhancement in a follow-up PR.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think my suggestion is a pushdown enhancement, but is okey to refactor in separate PR.

// Remove row number column
context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM));
return context.relBuilder.peek();
}

@Override
public RelNode visitParse(Parse node, CalcitePlanContext context) {
visitChildren(node, context);
Expand Down
120 changes: 120 additions & 0 deletions docs/user/ppl/cmd/reverse.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
=============
reverse
=============

.. rubric:: Table of contents

.. contents::
:local:
:depth: 2


Description
============
| Using ``reverse`` command to reverse the display order of search results. The same results are returned, but in reverse order.

Version
=======
3.2.0

Syntax
============
reverse


* No parameters: The reverse command takes no arguments or options.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we add a note to call out limitation.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks!

  1. Yes, performance results from current implementation have been added to the PR description.
  2. Issue [FEATURE] Support reverse pushdown with Calcite #3924 has been created to track pushdown.

Copy link
Collaborator

Choose a reason for hiding this comment

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


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.

Example 1: Basic reverse operation
==================================

The example shows reversing the order of all documents.

PPL query::

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


Example 2: Reverse with sort
============================

The example shows reversing results after sorting by age in ascending order, effectively giving descending order.

PPL query::

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


Example 3: Reverse with head
============================

The example shows using reverse with head to get the last 2 records from the original order.

PPL query::

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


Example 4: Double reverse
=========================

The example shows that applying reverse twice returns to the original order.

PPL query::

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


Example 5: Reverse with complex pipeline
=======================================

The example shows reverse working with filtering and field selection.

PPL query::

os> source=accounts | where age > 30 | fields account_number, age | reverse;
fetched rows / total rows = 3/3
+----------------+-----+
| account_number | age |
|----------------+-----|
| 6 | 36 |
| 18 | 33 |
| 1 | 32 |
+----------------+-----+
2 changes: 2 additions & 0 deletions docs/user/ppl/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ The query start with search command and then flowing a set of command delimited

- `subquery (aka subsearch) command <cmd/subquery.rst>`_

- `reverse command <cmd/reverse.rst>`_

- `top command <cmd/top.rst>`_

- `trendline command <cmd/trendline.rst>`_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,31 @@ public void testFilterScriptPushDownExplain() throws Exception {
public void testFilterFunctionScriptPushDownExplain() throws Exception {
super.testFilterFunctionScriptPushDownExplain();
}

@Test
public void testExplainWithReverse() throws IOException {
String result =
executeWithReplace(
"explain source=opensearch-sql_test_index_account | sort age | reverse | head 5");

// Verify that the plan contains a LogicalSort with fetch (from head 5)
assertTrue(result.contains("LogicalSort") && result.contains("fetch=[5]"));

// Verify that reverse added a ROW_NUMBER and another sort (descending)
assertTrue(result.contains("ROW_NUMBER()"));
assertTrue(result.contains("dir0=[DESC]"));
}

/**
* Executes the PPL query and returns the result as a string with windows-style line breaks
* replaced with Unix-style ones.
*
* @param ppl the PPL query to execute
* @return the result of the query as a string with line breaks replaced
* @throws IOException if an error occurs during query execution
*/
private String executeWithReplace(String ppl) throws IOException {
var result = executeQueryToString(ppl);
return result.replace("\\r\\n", "\\n");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.calcite.remote;

import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
import static org.opensearch.sql.util.MatcherUtils.rows;
import static org.opensearch.sql.util.MatcherUtils.schema;
import static org.opensearch.sql.util.MatcherUtils.verifyDataRowsInOrder;
import static org.opensearch.sql.util.MatcherUtils.verifySchema;

import java.io.IOException;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.opensearch.sql.ppl.PPLIntegTestCase;

public class CalciteReverseCommandIT extends PPLIntegTestCase {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could u add a test in ExplainIT for reverse command?


@Override
public void init() throws Exception {
super.init();
enableCalcite();
disallowCalciteFallback();
loadIndex(Index.BANK);
}

@Test
public void testReverse() throws IOException {
JSONObject result =
executeQuery(String.format("source=%s | fields account_number | reverse", TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(
result, rows(32), rows(25), rows(20), rows(18), rows(13), rows(6), rows(1));
}

@Test
public void testReverseWithFields() throws IOException {
JSONObject result =
executeQuery(
String.format(
"source=%s | fields account_number, firstname | reverse", TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"), schema("firstname", "string"));
verifyDataRowsInOrder(
result,
rows(32, "Dillard"),
rows(25, "Virginia"),
rows(20, "Elinor"),
rows(18, "Dale"),
rows(13, "Nanette"),
rows(6, "Hattie"),
rows(1, "Amber JOHnny"));
}

@Test
public void testReverseWithSort() throws IOException {
JSONObject result =
executeQuery(
String.format(
"source=%s | sort account_number | fields account_number | reverse",
TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(
result, rows(32), rows(25), rows(20), rows(18), rows(13), rows(6), rows(1));
}

@Test
public void testDoubleReverse() throws IOException {
JSONObject result =
executeQuery(
String.format(
"source=%s | fields account_number | reverse | reverse", TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(
result, rows(1), rows(6), rows(13), rows(18), rows(20), rows(25), rows(32));
}

@Test
public void testReverseWithHead() throws IOException {
JSONObject result =
executeQuery(
String.format("source=%s | fields account_number | reverse | head 3", TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(result, rows(32), rows(25), rows(20));
}

@Test
public void testReverseWithComplexPipeline() throws IOException {
JSONObject result =
executeQuery(
String.format(
"source=%s | where account_number > 18 | fields account_number | reverse | head 2",
TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(result, rows(32), rows(25));
}

@Test
public void testReverseWithMultipleSorts() throws IOException {
// Use the existing BANK data but with a simpler, more predictable query
JSONObject result =
executeQuery(
String.format(
"source=%s | sort account_number | fields account_number | reverse | head 3",
TEST_INDEX_BANK));
verifySchema(result, schema("account_number", "bigint"));
verifyDataRowsInOrder(result, rows(32), rows(25), rows(20));
}
}
Loading
Loading