Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -23,6 +23,7 @@
import org.apache.flink.configuration.ReadableConfig;
import org.apache.flink.table.api.TableException;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.planner.calcite.FlinkTypeFactory;
import org.apache.flink.table.planner.codegen.sort.ComparatorCodeGenerator;
import org.apache.flink.table.planner.delegation.PlannerBase;
import org.apache.flink.table.planner.plan.nodes.exec.ExecEdge;
Expand All @@ -33,10 +34,12 @@
import org.apache.flink.table.planner.plan.nodes.exec.InputProperty;
import org.apache.flink.table.planner.plan.nodes.exec.spec.SortSpec;
import org.apache.flink.table.planner.plan.nodes.exec.utils.ExecNodeUtil;
import org.apache.flink.table.planner.plan.utils.SortUtil;
import org.apache.flink.table.planner.utils.InternalConfigOptions;
import org.apache.flink.table.runtime.generated.GeneratedRecordComparator;
import org.apache.flink.table.runtime.operators.sort.StreamSortOperator;
import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
import org.apache.flink.table.types.logical.LogicalType;
import org.apache.flink.table.types.logical.RowType;

import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
Expand Down Expand Up @@ -100,12 +103,21 @@ public StreamExecSort(
@Override
protected Transformation<RowData> translateToPlanInternal(
PlannerBase planner, ExecNodeConfig config) {
ExecEdge inputEdge = getInputEdges().get(0);
RowType inputType = (RowType) inputEdge.getOutputType();
if (!config.get(InternalConfigOptions.TABLE_EXEC_NON_TEMPORAL_SORT_ENABLED)) {
throw new TableException("Sort on a non-time-attribute field is not supported.");
// Backstop for compiled plans loaded without passing through StreamPhysicalSortRule.
int firstSortField = sortSpec.getFieldIndices()[0];
String column = inputType.getFieldNames().get(firstSortField);
LogicalType type = inputType.getTypeAt(firstSortField);
if (FlinkTypeFactory.isTimeIndicatorType(type)
&& !sortSpec.getFieldSpecs()[0].getIsAscendingOrder()) {
throw new TableException(
SortUtil.sortKeyTimeAttributeMustBeAscendingMessage(column));
}
throw new TableException(SortUtil.sortKeyNotTimeAttributeMessage(column, type));
}

ExecEdge inputEdge = getInputEdges().get(0);
RowType inputType = (RowType) inputEdge.getOutputType();
// sort code gen
GeneratedRecordComparator rowComparator =
ComparatorCodeGenerator.gen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@
*/
package org.apache.flink.table.planner.plan.rules.physical.stream

import org.apache.flink.table.api.TableException
import org.apache.flink.table.planner.calcite.FlinkTypeFactory
import org.apache.flink.table.planner.plan.`trait`.FlinkRelDistribution
import org.apache.flink.table.planner.plan.nodes.FlinkConventions
import org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalSort
import org.apache.flink.table.planner.plan.nodes.physical.stream.StreamPhysicalSort
import org.apache.flink.table.planner.plan.utils.SortUtil
import org.apache.flink.table.planner.utils.{InternalConfigOptions, ShortcutUtils}

import org.apache.calcite.plan.{RelOptRule, RelOptRuleCall}
import org.apache.calcite.rel.RelNode
Expand All @@ -41,6 +45,24 @@ class StreamPhysicalSortRule(config: Config) extends ConverterRule(config) {

override def convert(rel: RelNode): RelNode = {
val sort: FlinkLogicalSort = rel.asInstanceOf[FlinkLogicalSort]
// Reject a non-time-attribute streaming sort here instead of deferring to StreamExecSort.
if (
!ShortcutUtils
.unwrapTableConfig(sort)
.get(InternalConfigOptions.TABLE_EXEC_NON_TEMPORAL_SORT_ENABLED)
) {
val field = SortUtil.getFirstSortField(sort.getCollation, sort.getInput.getRowType)
// A time-attribute first key only reaches here when descending; ascending goes to
// StreamPhysicalTemporalSort via matches().
val message = if (FlinkTypeFactory.isTimeIndicatorType(field.getType)) {
SortUtil.sortKeyTimeAttributeMustBeAscendingMessage(field.getName)
} else {
SortUtil.sortKeyNotTimeAttributeMessage(
field.getName,
FlinkTypeFactory.toLogicalType(field.getType))
}
throw new TableException(message)
}
val input = sort.getInput(0)
val requiredTraitSet = input.getTraitSet
.replace(FlinkRelDistribution.SINGLETON)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import org.apache.flink.table.api.TableException
import org.apache.flink.table.planner.calcite.FlinkPlannerImpl
import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator
import org.apache.flink.table.planner.plan.nodes.exec.spec.SortSpec
import org.apache.flink.table.types.logical.RowType
import org.apache.flink.table.types.logical.{LogicalType, RowType}

import org.apache.calcite.rel.`type`._
import org.apache.calcite.rel.{RelCollation, RelFieldCollation}
Expand Down Expand Up @@ -74,6 +74,21 @@ object SortUtil {
rowType.getFieldList.get(idx)
}

/** Error message when the primary streaming sort key is not a time attribute. */
def sortKeyNotTimeAttributeMessage(column: String, tpe: LogicalType): String =
s"Streaming ORDER BY requires the primary sort key to be a time attribute in ascending " +
s"order, but '$column' is ${tpe.asSummaryString}. A time attribute is an event-time column " +
s"(a TIMESTAMP with a WATERMARK) or a processing-time column. Otherwise use LIMIT for " +
s"Top-N, sort within a window, or run in batch mode."

/**
* Error message when the primary streaming sort key is a time attribute but sorted descending.
*/
def sortKeyTimeAttributeMustBeAscendingMessage(column: String): String =
s"Streaming ORDER BY on time attribute '$column' must be sorted in ascending order; " +
s"descending order is not supported. Otherwise use LIMIT for Top-N, sort within a window, " +
s"or run in batch mode."

/** Returns the default null direction if not specified. */
def getNullDefaultOrders(ascendings: Array[Boolean]): Array[Boolean] = {
ascendings.map(asc => FlinkPlannerImpl.defaultNullCollation.last(!asc))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.flink.table.planner.plan.stream.sql;

import org.apache.flink.table.api.TableException;
import org.apache.flink.table.planner.utils.InternalConfigOptions;
import org.apache.flink.table.planner.utils.JavaStreamTableTestUtil;
import org.apache.flink.table.planner.utils.TableTestBase;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* Plan-time validation tests for streaming sort. A non-time-attribute sort is unsupported and must
* be rejected during optimization (so it surfaces at {@code COMPILE PLAN} / planning time), not
* deferred to execution-plan translation. Valid temporal sorts are covered by {@code SortTest}.
*/
class SortValidationTest extends TableTestBase {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add a test asserting that the plan check are only done if the TABLE_EXEC_NON_TEMPORAL_SORT_ENABLED flag isn't set?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, I've added that too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How about using new way semantic tests instead of old fashion approach ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question — I looked into migrating this to the semantic test framework (SemanticTestBase / TableTestProgram), but it does not fit this particular test:

  • runFailingSql only accepts ValidationException or TableRuntimeException (hard Preconditions check in FailingSqlTestStep), while this check throws TableException — the same type the previous execution-time check threw, so I would rather not change the user-facing exception type in this PR.
  • Three of the assertions here have no equivalent test step: the table.exec.non-temporal-sort.enabled escape hatch (asserting no exception), the COMPILE PLAN check via compilePlanSql, and asserting the message names the column and its type.

Since *ValidationTest classes extending TableTestBase are still the established pattern for planning-time rejections (e.g. the batch SortValidationTest, AggregateValidationTest), I would prefer to keep this as-is. Happy to revisit in a follow-up if we relax the exception-type restriction in FailingSqlTestStep.


private static final String MESSAGE =
"requires the primary sort key to be a time attribute in ascending order";
private static final String MESSAGE_DESC =
"must be sorted in ascending order; descending order is not supported";

private final JavaStreamTableTestUtil util = javaStreamTestUtil();

@BeforeEach
void setup() {
util.addTable(
"CREATE TABLE MyTable (\n"
+ " a INT,\n"
+ " b STRING,\n"
+ " c BIGINT,\n"
+ " proctime AS PROCTIME(),\n"
+ " rowtime TIMESTAMP(3),\n"
+ " WATERMARK FOR rowtime AS rowtime\n"
+ ") WITH ('connector' = 'values')");
}

static Stream<Arguments> nonTemporalSorts() {
return Stream.of(
// primary sort key is not a time attribute -> message A
Arguments.of("SELECT a FROM MyTable ORDER BY c", MESSAGE),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we have tests with sorting by ordinals?
Probably same for sorting by alias?

Based on FlinkSqlConformance they should be valid

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. Ordinals and aliases are expanded by the SQL validator before optimization, so they hit the same rule — but explicit coverage is cheap. I have added negative cases for ORDER BY <ordinal>, ORDER BY <alias>, and ORDER BY <time-attribute alias> DESC, plus a positive case in SortTest showing that an alias of rowtime still routes to the temporal sort.

Arguments.of("SELECT a FROM MyTable ORDER BY c, proctime", MESSAGE),
Arguments.of("SELECT a FROM MyTable ORDER BY c, rowtime", MESSAGE),
Arguments.of("SELECT a FROM MyTable ORDER BY c, proctime DESC", MESSAGE),
Arguments.of("SELECT a FROM MyTable ORDER BY c, rowtime DESC", MESSAGE),
// primary sort key is a time attribute but sorted descending -> message B
Arguments.of("SELECT a FROM MyTable ORDER BY proctime DESC, c", MESSAGE_DESC),
Arguments.of("SELECT a FROM MyTable ORDER BY rowtime DESC, c", MESSAGE_DESC),
// ordinals and aliases are expanded by the validator and hit the same rule
Arguments.of("SELECT a FROM MyTable ORDER BY 1", MESSAGE),
Arguments.of("SELECT c AS x FROM MyTable ORDER BY x", MESSAGE),
Arguments.of("SELECT rowtime AS t, a FROM MyTable ORDER BY t DESC", MESSAGE_DESC));
}

@ParameterizedTest(name = "{0}")
@MethodSource("nonTemporalSorts")
void testNonTemporalSortRejected(String query, String expectedMessage) {
assertThatThrownBy(() -> util.verifyExecPlan(query))
.isInstanceOf(TableException.class)
.hasMessageContaining(expectedMessage);
}

@Test
void testMessageNamesColumnAndType() {
assertThatThrownBy(() -> util.verifyExecPlan("SELECT a FROM MyTable ORDER BY c"))
.isInstanceOf(TableException.class)
.hasMessageContaining(MESSAGE)
.hasMessageContaining("'c' is BIGINT");
}

@Test
void testNonTemporalSortAllowedWhenEnabled() {
util.getTableEnv()
.getConfig()
.set(InternalConfigOptions.TABLE_EXEC_NON_TEMPORAL_SORT_ENABLED, true);
// With the internal flag enabled, the plan-time check is skipped and the sort is accepted.
assertThatCode(() -> util.getTableEnv().explainSql("SELECT a FROM MyTable ORDER BY c"))
.doesNotThrowAnyException();
}

@Test
void testCompilePlanRejectsNonTemporalSort() {
util.addTable("CREATE TABLE MySink (a INT) WITH ('connector' = 'values')");
assertThatThrownBy(
() ->
util.getTableEnv()
.compilePlanSql(
"INSERT INTO MySink SELECT a FROM MyTable ORDER BY c"))
.isInstanceOf(TableException.class)
.hasMessageContaining(MESSAGE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -312,29 +312,6 @@ Sink(table=[default_catalog.default_database.partitioned_sink], targetColumns=[[
+- Exchange(distribution=[hash[a, b, c, d, e]])
+- LocalHashAggregate(groupBy=[a, b, c, d, e], select=[a, b, c, d, e])
+- TableSourceScan(table=[[default_catalog, default_database, MyTable]], fields=[a, b, c, d, e])
]]>
</Resource>
</TestCase>
<TestCase name="testPartialInsertWithOrderBy[isBatch: false]">
<Resource name="sql">
<![CDATA[INSERT INTO partitioned_sink (e,a,g,f,c,d) SELECT e,a,456,123,c,d FROM MyTable ORDER BY a,e,c,d]]>
</Resource>
<Resource name="ast">
<![CDATA[
LogicalSink(table=[default_catalog.default_database.partitioned_sink], targetColumns=[[4],[0],[6],[5],[2],[3]], fields=[a, c, d, e, f, g])
+- LogicalProject(a=[$0], c=[$1], d=[$2], e=[$3], f=[CAST($4):BIGINT], g=[CAST($5):INTEGER])
+- LogicalSort(sort0=[$0], sort1=[$3], sort2=[$1], sort3=[$2], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first])
+- LogicalProject(a=[$0], c=[$2], d=[$3], e=[$4], EXPR$4=[123], EXPR$5=[456])
+- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
]]>
</Resource>
<Resource name="optimized rel plan">
<![CDATA[
Sink(table=[default_catalog.default_database.partitioned_sink], targetColumns=[[4],[0],[6],[5],[2],[3]], fields=[a, c, d, e, f, g])
+- Calc(select=[a, c, d, e, CAST(123 AS BIGINT) AS f, CAST(456 AS INTEGER) AS g])
+- Sort(orderBy=[a ASC, e ASC, c ASC, d ASC])
+- Exchange(distribution=[single])
+- TableSourceScan(table=[[default_catalog, default_database, MyTable]], fields=[a, b, c, d, e])
]]>
</Resource>
</TestCase>
Expand Down
Loading