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"))
+  }
+}

Reply via email to