This is an automated email from the ASF dual-hosted git repository.
xuang7 pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.2 by this push:
new 115423fd66 fix(workflow-operator): prevent NPE when Sort operator is
unconfigured (#5416) [release/v1.2 backport] (#5616)
115423fd66 is described below
commit 115423fd66432f6a2dfbdf073a10fb83056065e8
Author: Matthew B. <[email protected]>
AuthorDate: Wed Jun 10 22:13:23 2026 -0700
fix(workflow-operator): prevent NPE when Sort operator is unconfigured
(#5416) [release/v1.2 backport] (#5616)
### What changes were proposed in this PR?
Backport of #5416 ("fix(workflow-operator): prevent NPE when Sort
operator is unconfigured") to `release/v1.2`.
Applies as a clean `git cherry-pick -x` of the merged squash commit
`5f542752`, no conflicts and no prerequisite chain. 2 files changed, 72
insertions, 1 deletion, identical to the original.
For the full change description, see #5416. In brief: `SortOpDesc` threw
a `NullPointerException` when the operator's sort attributes were left
unconfigured; this guards that path so an unconfigured Sort operator
fails gracefully instead of NPE-ing.
### Any related issues, documentation, discussions?
Closes: #5619 (backport tracking task). Backports #5416 (which closes
#3191 on `main`). Requested by @xuang7 for the v1.2 release; the PR was
merged before the `release/v1.2` label existed, so auto-backport did not
trigger.
### How was this PR tested?
This is a backport with no changes beyond the single cherry-picked
commit, so it relies on the existing tests carried over from #5416
(added `SortOpDescSpec` cases). Run them with `sbt
"workflowOperator/testOnly *SortOpDescSpec"` from the repo root.
Backport fidelity was verified locally: after the cherry-pick, every
file touched by #5416 is byte-identical to its state on `main` at the
merged commit (`5f542752`), and `release/v1.2` had no independent
changes to any of those files.
Full compile and unit-test runs are left to CI.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
---
.../texera/amber/operator/sort/SortOpDesc.scala | 7 ++-
.../amber/operator/sort/SortOpDescSpec.scala | 66 ++++++++++++++++++++++
2 files changed, 72 insertions(+), 1 deletion(-)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala
index 0825422e6c..db2ae21e7f 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala
@@ -28,9 +28,14 @@ import
org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, Operat
class SortOpDesc extends PythonOperatorDescriptor {
@JsonProperty(required = true)
@JsonPropertyDescription("column to perform sorting on")
- var attributes: List[SortCriteriaUnit] = _
+ var attributes: List[SortCriteriaUnit] = List.empty
override def generatePythonCode(): String = {
+ require(attributes.nonEmpty, "Sort operator requires at least one sort
key.")
+ require(
+ attributes.forall(c => c.attributeName != null &&
c.attributeName.trim.nonEmpty),
+ "Each sort key must have an attribute selected."
+ )
val attributeName = "[" + attributes
.map { criteria =>
pyb"""${criteria.attributeName}"""
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sort/SortOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sort/SortOpDescSpec.scala
new file mode 100644
index 0000000000..6979d0b630
--- /dev/null
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/sort/SortOpDescSpec.scala
@@ -0,0 +1,66 @@
+/*
+ * 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.texera.amber.operator.sort
+
+import org.apache.texera.amber.operator.LogicalOp
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.scalatest.funsuite.AnyFunSuite
+
+class SortOpDescSpec extends AnyFunSuite {
+
+ private def deserialize(json: String): SortOpDesc =
+ objectMapper.readValue(json, classOf[LogicalOp]).asInstanceOf[SortOpDesc]
+
+ test("deserializes JSON that omits the attributes field into an empty list,
not null") {
+ val desc = deserialize("""{"operatorType": "Sort"}""")
+ assert(desc.attributes != null)
+ assert(desc.attributes.isEmpty)
+ }
+
+ test("deserializes JSON with an explicit empty attributes array") {
+ val desc = deserialize("""{"operatorType": "Sort", "attributes": []}""")
+ assert(desc.attributes.isEmpty)
+ }
+
+ test("deserializes JSON with a fully configured sort key") {
+ val json =
+ """{"operatorType": "Sort", "attributes": [{"attribute": "age",
"sortPreference": "DESC"}]}"""
+ val desc = deserialize(json)
+ assert(desc.attributes.size == 1)
+ assert(desc.attributes.head.attributeName == "age")
+ assert(desc.attributes.head.sortPreference == SortPreference.DESC)
+ val code = desc.generatePythonCode()
+ assert(code.contains("ascending_orders = [False]")) // DESC maps to
ascending=False
+ }
+
+ test("generatePythonCode raises a clear message when no sort key is
configured") {
+ val desc = deserialize("""{"operatorType": "Sort"}""")
+ val ex = intercept[IllegalArgumentException](desc.generatePythonCode())
+ assert(ex.getMessage.contains("at least one sort key"))
+ }
+
+ test("generatePythonCode raises a clear message when a sort key has no
attribute") {
+ val json =
+ """{"operatorType": "Sort", "attributes": [{"attribute": "",
"sortPreference": "ASC"}]}"""
+ val desc = deserialize(json)
+ val ex = intercept[IllegalArgumentException](desc.generatePythonCode())
+ assert(ex.getMessage.contains("must have an attribute selected"))
+ }
+}