Skip to content

Commit f860d87

Browse files
committed
Add tests for preserveCollation composite-collation fix; drop comment
Cover UnifiedQueryPlanner#preserveCollation with tests and remove the now-redundant inline comment (the rationale lives in the commit/PR). Unit tests (UnifiedQueryPlannerTest) exercise all four branches: - an existing top-level Sort is returned unchanged (no double-wrap); - a composite collation is left unwrapped instead of crashing (the regression: `sort age | eval x = age` makes x mirror age, so the top LogicalProject derives multiple collations stored as a RelCompositeTrait; the old getCollation() read threw "Trait index N has multiple values", surfacing as a 500); - a single genuine collation is materialized as a LogicalSort; - an unsorted plan is returned unwrapped. Execution coverage: UnifiedQueryCompilerTest compiles and runs the previously-crashing plan in-memory, and UnifiedQueryOpenSearchIT runs the same sort+eval-copy shape end to end against a real OpenSearch index. The composite-collation tests were confirmed to fail against the pre-fix implementation. Signed-off-by: Louis Chu <lingzhichu.clz@gmail.com>
1 parent 3f39114 commit f860d87

4 files changed

Lines changed: 106 additions & 5 deletions

File tree

api/src/main/java/org/opensearch/sql/api/UnifiedQueryPlanner.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,6 @@ private RelNode preserveCollation(RelNode logical) {
158158
if (logical instanceof Sort) {
159159
return logical;
160160
}
161-
// Only a genuine single-sort collation is materialized. A literal-row plan (makeresults
162-
// data=) carries several incidental derived collations kept as a RelCompositeTrait; those
163-
// are not a user sort, so getTraits returns more than one and we leave the plan unwrapped.
164-
// RelTraitSet.getCollation() would instead cast the composite to a single RelCollation and
165-
// throw.
166161
List<RelCollation> collations =
167162
logical.getTraitSet().getTraits(RelCollationTraitDef.INSTANCE);
168163
if (collations != null

api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerTest.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55

66
package org.opensearch.sql.api;
77

8+
import static org.junit.Assert.assertFalse;
89
import static org.junit.Assert.assertNotNull;
910
import static org.junit.Assert.assertThrows;
11+
import static org.junit.Assert.assertTrue;
1012

1113
import java.util.Map;
1214
import org.apache.calcite.rel.RelNode;
15+
import org.apache.calcite.rel.core.Sort;
1316
import org.apache.calcite.runtime.CalciteException;
1417
import org.apache.calcite.schema.Schema;
1518
import org.apache.calcite.schema.impl.AbstractSchema;
@@ -198,4 +201,56 @@ public void testPPLPatternsPicksUpDefaults() {
198201
.assertPlanContains("REGEXP_REPLACE")
199202
.assertFields("id", "name", "age", "department", "patterns_field");
200203
}
204+
205+
/**
206+
* {@code preserveCollation} returns an existing top-level {@link Sort} untouched — it must not
207+
* wrap a user sort in a second, redundant {@code LogicalSort}.
208+
*/
209+
@Test
210+
public void preserveCollationReturnsExistingSortUnchanged() {
211+
var assertion = givenQuery("source = catalog.employees | sort age");
212+
assertTrue("top node should be the user's Sort", assertion.plan() instanceof Sort);
213+
assertion.assertPlan(
214+
"LogicalSort(sort0=[$2], dir0=[ASC-nulls-first])\n"
215+
+ " LogicalTableScan(table=[[catalog, employees]])\n");
216+
}
217+
218+
/**
219+
* Regression test for the composite-collation crash. {@code eval x = age} after {@code sort age}
220+
* makes {@code x} mirror {@code age}, so the top {@link
221+
* org.apache.calcite.rel.logical.LogicalProject} derives more than one collation, which Calcite
222+
* stores as a {@code RelCompositeTrait}. The previous {@code getTraitSet().getCollation()} read
223+
* threw ({@code "Trait index 1 has multiple values ... use getTraits"}), surfacing as a 500. A
224+
* composite is not a user sort, so the plan must build and be left unwrapped.
225+
*/
226+
@Test
227+
public void preserveCollationLeavesCompositeCollationUnwrapped() {
228+
var assertion = givenQuery("source = catalog.employees | sort age | eval x = age");
229+
assertFalse(
230+
"composite-collation plan must not be re-wrapped in a Sort",
231+
assertion.plan() instanceof Sort);
232+
assertion.assertFields("id", "name", "age", "department", "x");
233+
}
234+
235+
/**
236+
* A single genuine (non-empty) collation on a non-{@link Sort} top is materialized as a {@code
237+
* LogicalSort} so the ordering survives for downstream consumers. Single-column {@code
238+
* makeresults} derives exactly one collation; this branch is unchanged by the composite fix.
239+
*/
240+
@Test
241+
public void preserveCollationMaterializesSingleDerivedCollation() {
242+
givenQuery("makeresults format=csv data='name\nJohn\nSarah'")
243+
.assertPlan(
244+
"LogicalSort(sort0=[$0], dir0=[ASC])\n"
245+
+ " LogicalProject(name=[CAST($0):VARCHAR NOT NULL])\n"
246+
+ " LogicalValues(tuples=[[{ 'John' }, { 'Sarah' }]])\n");
247+
}
248+
249+
/** A plan with no collation in its trait set is returned unchanged, with no spurious Sort. */
250+
@Test
251+
public void preserveCollationLeavesUnsortedPlanUnwrapped() {
252+
var assertion = givenQuery("source = catalog.employees");
253+
assertFalse("unsorted plan must not be wrapped in a Sort", assertion.plan() instanceof Sort);
254+
assertion.assertPlan("LogicalTableScan(table=[[catalog, employees]])\n");
255+
}
201256
}

api/src/test/java/org/opensearch/sql/api/compiler/UnifiedQueryCompilerTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,30 @@ public void testComplexQuery() throws Exception {
5757
}
5858
}
5959

60+
@Test
61+
public void testCompositeCollationQueryExecutes() throws Exception {
62+
// `eval x = age` after `sort age` yields a top projection with a composite collation trait
63+
// (x mirrors age). UnifiedQueryPlanner#preserveCollation used to crash building this plan;
64+
// verify it now compiles and executes end to end.
65+
RelNode plan = planner.plan("source = catalog.employees | sort age | eval x = age");
66+
try (PreparedStatement statement = compiler.compile(plan)) {
67+
ResultSet resultSet = statement.executeQuery();
68+
69+
verify(resultSet)
70+
.expectSchema(
71+
col("id", INTEGER),
72+
col("name", VARCHAR),
73+
col("age", INTEGER),
74+
col("department", VARCHAR),
75+
col("x", INTEGER))
76+
.expectData(
77+
row(1, "Alice", 25, "Engineering", 25),
78+
row(2, "Bob", 35, "Sales", 35),
79+
row(3, "Charlie", 45, "Engineering", 45),
80+
row(4, "Diana", 28, "Marketing", 28));
81+
}
82+
}
83+
6084
@Test(expected = IllegalStateException.class)
6185
public void testCompileFailure() {
6286
RelNode mockPlan = Mockito.mock(RelNode.class);

integ-test/src/test/java/org/opensearch/sql/api/UnifiedQueryOpenSearchIT.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,33 @@ public void testMultiplePPLQueryExecutionWithSameContext() throws Exception {
104104
}
105105
}
106106

107+
@Test
108+
public void testSortThenEvalCopyExecutesEndToEnd() throws Exception {
109+
// `eval age_copy = age` after `sort age` makes the top projection carry more than one derived
110+
// collation (age_copy mirrors age). UnifiedQueryPlanner#preserveCollation used to throw reading
111+
// that composite trait; the query must now plan and execute against a real OpenSearch index.
112+
String pplQuery =
113+
String.format(
114+
"source = opensearch.%s | sort age | eval age_copy = age"
115+
+ " | fields firstname, age, age_copy",
116+
TEST_INDEX_ACCOUNT);
117+
118+
RelNode plan = planner.plan(pplQuery);
119+
try (PreparedStatement statement = compiler.compile(plan)) {
120+
ResultSet resultSet = statement.executeQuery();
121+
122+
verify(resultSet)
123+
.expectSchema(col("firstname", VARCHAR), col("age", BIGINT), col("age_copy", BIGINT));
124+
125+
int rows = 0;
126+
while (resultSet.next()) {
127+
rows++;
128+
assertEquals("age_copy must mirror age", resultSet.getObject(2), resultSet.getObject(3));
129+
}
130+
assertTrue("expected at least one row", rows > 0);
131+
}
132+
}
133+
107134
/**
108135
* Creates a dynamic schema that creates OpenSearchIndex on-demand for any table name. This allows
109136
* querying any index without pre-registering it.

0 commit comments

Comments
 (0)