This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2ab01dae67f Java SDK: Read the Dag run conf into a class with typed 
fields (#75542)
2ab01dae67f is described below

commit 2ab01dae67f6935f08e3321c419fab1e4258f487
Author: PoAn Yang <[email protected]>
AuthorDate: Wed Nov 4 11:31:49 2026 +0800

    Java SDK: Read the Dag run conf into a class with typed fields (#75542)
    
    * Java SDK: Read the Dag run conf into a class with typed fields
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Document the clashing field names case of getConf
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Reject nulls for primitives in getConf and document JsonProperty
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Move getConf details from the Java SDK guide to the API reference
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    ---------
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../language-sdks/java.rst                         |  29 ++
 .../main/kotlin/org/apache/airflow/sdk/Context.kt  |  66 ++++-
 .../org/apache/airflow/sdk/internal/ConfBinding.kt | 184 ++++++++++++
 .../kotlin/org/apache/airflow/sdk/ContextTest.kt   | 311 +++++++++++++++++++++
 4 files changed, 588 insertions(+), 2 deletions(-)

diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
index f22f54c0cdf..2e8aba27521 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
@@ -719,6 +719,35 @@ The ``Client``
    A value from a secrets backend, such as an ``AIRFLOW_VAR_*`` environment 
variable, still wins over the stored
    value when the Variable is read back. ``setVariable`` without a description 
clears the existing description.
 
+The Dag run conf
+~~~~~~~~~~~~~~~~
+
+``context.dagRun.conf`` is the conf the Dag run was triggered with. It is a 
``Map``, and its values have the Java
+types listed in :ref:`the XCom type mapping <java-sdk/types>`. 
``context.dagRun.getConf(type)`` reads the conf into
+an instance of a class with typed fields instead:
+
+.. code-block:: java
+
+    public static class EtlConf {
+      public String targetTable;
+      public int limit = 100;
+      public LocalDate since;
+    }
+
+    public class Load implements Task {
+      @Override
+      public void execute(Context context, Client client) {
+        EtlConf etl = context.dagRun.getConf(EtlConf.class);
+      }
+    }
+
+For a run triggered with the conf ``{"target_table": "sales", "since": 
"2024-01-31"}``, ``etl.targetTable`` is
+``"sales"``, ``etl.since`` is ``LocalDate.of(2024, 1, 31)``, and ``etl.limit`` 
stays ``100``. Keys match field
+names when case and underscores are ignored, and keys that match no field are 
ignored. ``getConf`` does not add
+the defaults of the Dag's params, and the conf of a scheduled run is empty. 
For the classes that ``getConf``
+accepts and the cases where it throws, see ``DagRun.getConf`` in the `Java SDK 
API reference
+<https://airflow.apache.org/docs/java-sdk/stable/>`__.
+
 .. _java-sdk/task-state-store:
 
 Task state store
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Context.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Context.kt
index 69badbd49f6..d6a6426abf6 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Context.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Context.kt
@@ -21,6 +21,7 @@ package org.apache.airflow.sdk
 
 import org.apache.airflow.sdk.execution.Logger
 import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.internal.bindConf
 import java.time.OffsetDateTime
 import org.apache.airflow.sdk.execution.comm.DagRun.DagRunType as 
CommDagRunType
 
@@ -81,7 +82,8 @@ enum class DagRunType {
  * @property dataIntervalEnd End of the data interval.
  * @property runAfter A date-time tells the scheduler when the Dag run can be 
scheduled.
  * @property runType How the run was created.
- * @property conf The configuration for this run.
+ * @property conf The configuration for this run. [getConf] reads it into an
+ *    instance of a class with typed fields.
  */
 data class DagRun(
   @JvmField val dagId: String,
@@ -92,7 +94,67 @@ data class DagRun(
   @JvmField val runAfter: OffsetDateTime?,
   @JvmField val runType: DagRunType?,
   @JvmField val conf: Map<String, Any?>,
-)
+) {
+  /**
+   * Reads [conf] into a new instance of [type]. For example, [type] can be a
+   * class with one field per conf key.
+   *
+   * ```java
+   * public static class EtlConf {
+   *   public String targetTable; // set from the "target_table" key
+   *   public int limit = 100;    // stays 100 when the conf has no "limit" key
+   * }
+   *
+   * EtlConf etl = context.dagRun.getConf(EtlConf.class);
+   * ```
+   *
+   * Each key goes to the field, setter or record component whose name matches
+   * the key when case and underscores are ignored. If one key matches a name
+   * exactly, that key wins over keys that match the same name only when case
+   * and underscores are ignored. Objects nested in the conf follow the same
+   * rules, including objects in lists and objects that are map values. The 
keys
+   * of a map field stay as written.
+   *
+   * Keys that match no field are ignored, because other tasks in the run may
+   * read them. A field with no matching key keeps its default value. The conf
+   * holds only the values that the run was triggered with. This method does 
not
+   * add the defaults of the Dag's params, so in a scheduled run, where the 
conf
+   * is empty, no field has a matching key.
+   *
+   * The class needs a no-argument constructor and public fields or setters, so
+   * a nested class must be `static`. A record also works. A record component
+   * with no matching key is null. If the component has a primitive type, this
+   * method throws instead, so a task that reads the conf into a record with a
+   * primitive component fails in a scheduled run. In Kotlin, declare the 
fields
+   * with `@JvmField` so that a property such as `isEnabled` keeps its name.
+   *
+   * Annotating a field with [ArgName] has no effect on this method. To set a
+   * field from a key that no Java name can match, such as `target-table`, add
+   * `@JsonProperty("target-table")` to that field. `@JsonProperty` needs
+   * `com.fasterxml.jackson.core:jackson-annotations` in your build, because 
the
+   * SDK keeps Jackson off your compile classpath. You can also read the key
+   * from [conf].
+   *
+   * This method throws [IllegalArgumentException] in these cases:
+   *
+   * * A value, such as an empty string for an `int` field, cannot be converted
+   *   to the type of its field.
+   * * A key holds null for a primitive field, a list holds null for a 
primitive
+   *   array such as `int[]`, or a primitive record component has no matching
+   *   key. To let a field or a component receive null, declare it with a boxed
+   *   type, such as `Integer`.
+   * * Two keys, such as `target_table` and `TARGET_TABLE`, match the same 
field
+   *   after case and underscores are ignored, but neither key matches the 
field
+   *   name exactly.
+   * * The class has two fields whose names differ only in case or underscores,
+   *   such as `targetTable` and `target_table`. This method checks the class
+   *   even when the conf is empty, so it throws in a scheduled run too.
+   *
+   * @param type Class to read the conf into.
+   * @throws IllegalArgumentException in the cases listed above.
+   */
+  fun <T : Any> getConf(type: Class<T>): T = bindConf(conf, type)
+}
 
 /**
  * Identifies the task instance that is currently executing.
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/ConfBinding.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/ConfBinding.kt
new file mode 100644
index 00000000000..f5bf61cf2ed
--- /dev/null
+++ 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/ConfBinding.kt
@@ -0,0 +1,184 @@
+/*
+ * 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.airflow.sdk.internal
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition
+import com.fasterxml.jackson.databind.json.JsonMapper
+import java.util.concurrent.ConcurrentHashMap
+
+// Other tasks in the Dag run may read keys that the target class does not
+// declare, so this mapper must not fail on an unknown key. By default, Jackson
+// moves a date-time that carries an offset to UTC. For example,
+// 2024-01-31T02:00+08:00 would become 2024-01-30T18:00Z and fall on another
+// date. This mapper keeps the offset as written instead.
+//
+// FAIL_ON_NULL_FOR_PRIMITIVES stops Jackson from turning a null into 0, for
+// example in an int[]. It also rejects a record whose primitive component has
+// no key, because Jackson handles the missing value as null. matchFields still
+// checks a key that holds null, because its message names the key and the
+// matching field.
+private val confMapper: ObjectMapper by lazy {
+  JsonMapper
+    .builder()
+    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+    .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
+    .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
+    .build()
+    .findAndRegisterModules()
+}
+
+/**
+ * Converts a Dag run conf into an instance of [type] for
+ * [org.apache.airflow.sdk.DagRun.getConf]. That function documents how keys
+ * match fields.
+ *
+ * Jackson matches a key to a field only when the key is exactly the field
+ * name. So this function first uses [foldArgName] to find the field that each
+ * key matches, then renames the key to the exact name of that field.
+ */
+internal fun <T : Any> bindConf(
+  conf: Map<String, Any?>,
+  type: Class<T>,
+): T {
+  val javaType = confMapper.constructType(type)
+  val matched = matchKeys(conf, javaType)
+  return try {
+    confMapper.convertValue(matched, javaType)
+  } catch (e: IllegalArgumentException) {
+    throw IllegalArgumentException("Cannot convert the Dag run conf to 
${type.name}: ${e.message}", e)
+  }
+}
+
+/**
+ * Renames the keys of every object in [value] that becomes an instance of a
+ * class when [value] is converted to [type]. The renaming also reaches objects
+ * in lists and arrays, objects that are map values, and the value inside a
+ * reference type such as `Optional`. The keys of a map are data rather than
+ * field names, so they stay as written.
+ */
+private fun matchKeys(
+  value: Any?,
+  type: JavaType,
+): Any? =
+  when {
+    type.isReferenceType -> matchKeys(value, type.contentType)
+    value is Map<*, *> && type.isMapLikeType -> value.mapValues { (_, v) -> 
matchKeys(v, type.contentType) }
+    value is List<*> && (type.isCollectionLikeType || type.isArrayType) -> 
value.map { matchKeys(it, type.contentType) }
+    value is Map<*, *> -> matchFields(value, type)
+    else -> value
+  }
+
+/**
+ * Renames each key of [obj] to the name of the [type] field that the key
+ * matches. If a field has exactly the key's name, the key matches that field.
+ * Otherwise the key matches the field whose name folds to the same token as 
the
+ * key, unless another key has exactly that field's name. A key that matches no
+ * field keeps its name, and Jackson ignores that key.
+ *
+ * @throws IllegalArgumentException if the names of two fields fold to the same
+ *    token, if two keys match the same field only after folding, or if a key
+ *    holds null for a primitive field.
+ */
+private fun matchFields(
+  obj: Map<*, *>,
+  type: JavaType,
+): Map<String, Any?> {
+  val fields = findFields(type)
+  val className = type.rawClass.simpleName
+  val keys = obj.keys.mapTo(mutableSetOf()) { it.toString() }
+  val keyOfField = mutableMapOf<String, String>()
+  val matched = LinkedHashMap<String, Any?>()
+  obj.forEach { (rawKey, value) ->
+    val key = rawKey.toString()
+    val field = fields.byName[key] ?: 
fields.byFold[foldArgName(key)]?.takeUnless { it.name in keys }
+    if (field == null) {
+      matched[key] = value
+      return@forEach
+    }
+    val other = keyOfField.put(field.name, key)
+    require(other == null) {
+      "Dag run conf keys '$other' and '$key' both match field 
$className.${field.name} when case and " +
+        "underscores are ignored, so the SDK cannot tell which one to use. 
Name the field exactly as the key " +
+        "it should take."
+    }
+    require(value != null || !field.primaryType.isPrimitive) {
+      "Dag run conf key '$key' is null, but field $className.${field.name} has 
a primitive type that cannot " +
+        "be null. Declare a boxed type (e.g. Integer instead of int) to 
receive null."
+    }
+    matched[field.name] = matchKeys(value, field.primaryType)
+  }
+  return matched
+}
+
+private class ConfFields(
+  val byName: Map<String, BeanPropertyDefinition>,
+  val byFold: Map<String, BeanPropertyDefinition>,
+)
+
+/**
+ * Finds the fields of [type] so that each field can be looked up by its exact
+ * name or by its folded token from [foldArgName].
+ *
+ * The result is cached per type. Jackson takes far longer to find the fields 
of
+ * a class than to convert one object of that class. Without the cache,
+ * converting a list of many objects would spend most of the time finding the
+ * same fields again.
+ *
+ * @throws IllegalArgumentException if two fields fold to the same token.
+ */
+private fun findFields(type: JavaType): ConfFields = 
confFieldsByType.computeIfAbsent(type) { indexFields(it) }
+
+private val confFieldsByType = ConcurrentHashMap<JavaType, ConfFields>()
+
+private fun indexFields(type: JavaType): ConfFields {
+  val fields =
+    confMapper.deserializationConfig
+      .introspect(type)
+      .findProperties()
+      .filter { it.isWritable() }
+  val className = type.rawClass.simpleName
+  val byFold = mutableMapOf<String, BeanPropertyDefinition>()
+  fields.forEach { field ->
+    val previous = byFold.put(foldArgName(field.name), field)
+    require(previous == null) {
+      "Fields $className.${previous?.name} and $className.${field.name} differ 
only in case or underscores, " +
+        "so the SDK cannot tell which one a Dag run conf key should go to. 
Rename one of them."
+    }
+  }
+  return ConfFields(fields.associateBy { it.name }, byFold)
+}
+
+/**
+ * Whether Jackson can set this property from a key. Jackson also lists
+ * read-only properties. For example, a `getBaseURL()` getter next to a
+ * `baseUrl` field gives a read-only property named `baseURL`. A read-only
+ * property must not take a key, and it must not clash with a field, as
+ * `baseURL` would clash with `baseUrl`. A collection or a map that has only a
+ * getter is not read-only, because Jackson adds the elements to the object 
that
+ * the getter returns.
+ */
+private fun BeanPropertyDefinition.isWritable(): Boolean =
+  hasSetter() || hasField() || hasConstructorParameter() || 
getter?.rawType?.let(::isCollectionOrMap) == true
+
+private fun isCollectionOrMap(type: Class<*>): Boolean =
+  Collection::class.java.isAssignableFrom(type) || 
Map::class.java.isAssignableFrom(type)
diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ContextTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ContextTest.kt
index 2c48be12443..4a76e4f05f2 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ContextTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ContextTest.kt
@@ -19,13 +19,137 @@
 
 package org.apache.airflow.sdk
 
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonProperty
 import org.apache.airflow.sdk.execution.CoordinatorComm
 import org.apache.airflow.sdk.execution.byteArrayFromHexString
 import org.apache.airflow.sdk.execution.comm.StartupDetails
 import org.apache.airflow.sdk.execution.comm.TIRunContext
 import org.junit.jupiter.api.Assertions
 import org.junit.jupiter.api.Test
+import java.time.LocalDate
 import java.time.OffsetDateTime
+import java.util.concurrent.atomic.AtomicReference
+
+private class EtlConf {
+  @JvmField
+  var targetTable: String? = null
+
+  @JvmField
+  var limit: Int = 100
+
+  @JvmField
+  var since: LocalDate? = null
+
+  @JvmField
+  var runAt: OffsetDateTime? = null
+
+  @JvmField
+  var source: EtlSource? = null
+
+  @JvmField
+  var current: AtomicReference<EtlTable>? = null
+
+  @JvmField
+  var tables: List<EtlTable>? = null
+
+  @JvmField
+  var tableArray: Array<EtlTable>? = null
+
+  @JvmField
+  var tablesByName: Map<String, EtlTable>? = null
+
+  @JvmField
+  var options: Map<String, Any?>? = null
+}
+
+private class EtlSource {
+  @JvmField
+  var bucketName: String? = null
+}
+
+private class EtlTable {
+  @JvmField
+  var tableName: String? = null
+}
+
+private class FoldClash {
+  @JvmField
+  var targetTable: String? = null
+
+  @JvmField
+  var targettable: String? = null
+}
+
+private class UrlConf {
+  @JvmField
+  var baseUrl: String? = null
+
+  fun getBaseURL(): String? = baseUrl
+}
+
+private class SetterConf {
+  private var stored: String? = null
+
+  fun setTargetTable(value: String?) {
+    stored = value
+  }
+
+  fun stored(): String? = stored
+}
+
+// Jackson binds a Java record through its constructor, but these tests compile
+// for Java 11, which has no records. Jackson binds a class with a @JsonCreator
+// constructor the same way, so this class stands in for a record.
+private class CreatorConf
+  @JsonCreator
+  constructor(
+    @JsonProperty("targetTable") targetTable: String?,
+    @JsonProperty("rowLimit") rowLimit: Int,
+  ) {
+    val stored = targetTable
+    val storedLimit = rowLimit
+  }
+
+private class CountsConf {
+  @JvmField
+  var counts: IntArray? = null
+
+  @JvmField
+  var matrix: Array<IntArray>? = null
+
+  @JvmField
+  var boxedCounts: List<Int?>? = null
+}
+
+private class PinnedConf {
+  @JvmField
+  @JsonProperty("target-table")
+  var table: String? = null
+}
+
+private class GetterOnlyTables {
+  private val backingList = mutableListOf<EtlTable>()
+  private val backingMap = mutableMapOf<String, EtlTable>()
+
+  fun getTables(): MutableList<EtlTable> = backingList
+
+  fun getTablesByName(): MutableMap<String, EtlTable> = backingMap
+}
+
+private fun dagRunWithConf(conf: Map<String, Any?>) = DagRun("d", "r", null, 
null, null, null, null, conf)
+
+private fun assertConfFailsOnField(
+  conf: Map<String, Any?>,
+  type: Class<*>,
+  field: String,
+) {
+  val error = Assertions.assertThrows(IllegalArgumentException::class.java) { 
dagRunWithConf(conf).getConf(type) }
+
+  val message = error.message.orEmpty()
+  Assertions.assertTrue(message.startsWith("Cannot convert the Dag run conf to 
${type.name}: ")) { message }
+  Assertions.assertTrue(message.contains("[\"$field\"]")) { message }
+}
 
 class ContextTest {
   @Test
@@ -125,4 +249,191 @@ class ContextTest {
     Assertions.assertNull(dr.dataIntervalStart)
     Assertions.assertTrue(dr.conf.isEmpty())
   }
+
+  @Test
+  fun getConfMatchesKeysIgnoringCaseAndUnderscores() {
+    val conf = dagRunWithConf(mapOf("target_table" to "sales", "LIMIT" to 
5L)).getConf(EtlConf::class.java)
+
+    Assertions.assertEquals("sales", conf.targetTable)
+    Assertions.assertEquals(5, conf.limit)
+  }
+
+  @Test
+  fun getConfIgnoresKeysWithoutAFieldAndKeepsDefaults() {
+    val conf = dagRunWithConf(mapOf("notify_email" to 
"[email protected]")).getConf(EtlConf::class.java)
+
+    Assertions.assertNull(conf.targetTable)
+    Assertions.assertEquals(100, conf.limit)
+  }
+
+  @Test
+  fun getConfPrefersAnExactKeyOverKeysThatMatchOnlyIgnoringCase() {
+    val conf =
+      dagRunWithConf(mapOf("target_table" to "folded", "targetTable" to 
"exact", "TARGET_TABLE" to "folded"))
+        .getConf(EtlConf::class.java)
+
+    Assertions.assertEquals("exact", conf.targetTable)
+  }
+
+  @Test
+  fun getConfMatchesKeysOfNestedObjects() {
+    val conf =
+      dagRunWithConf(
+        mapOf(
+          "source" to mapOf("bucket_name" to "raw"),
+          "current" to mapOf("table_name" to "staging"),
+          "tables" to listOf(mapOf("table_name" to "orders")),
+          "table_array" to listOf(mapOf("table_name" to "items")),
+          "tables_by_name" to mapOf("Sales_Key" to mapOf("table_name" to 
"sales")),
+        ),
+      ).getConf(EtlConf::class.java)
+
+    Assertions.assertEquals("raw", conf.source?.bucketName)
+    Assertions.assertEquals("staging", conf.current?.get()?.tableName)
+    Assertions.assertEquals("orders", conf.tables?.single()?.tableName)
+    Assertions.assertEquals("items", conf.tableArray?.single()?.tableName)
+    Assertions.assertEquals(setOf("Sales_Key"), conf.tablesByName?.keys)
+    Assertions.assertEquals("sales", 
conf.tablesByName?.get("Sales_Key")?.tableName)
+  }
+
+  @Test
+  fun getConfKeepsTheKeysOfAMapWithUntypedValues() {
+    val options = mapOf("Rows" to listOf(mapOf("Key_A" to "a")), "Inner_Map" 
to mapOf("Key_B" to "b"))
+
+    val conf = dagRunWithConf(mapOf("options" to 
options)).getConf(EtlConf::class.java)
+
+    Assertions.assertEquals(options, conf.options)
+  }
+
+  @Test
+  fun getConfReadsIsoDateStringsIntoJavaTimeFields() {
+    val conf =
+      dagRunWithConf(mapOf("since" to "2024-01-31", "run_at" to 
"2024-01-31T02:00:00+08:00"))
+        .getConf(EtlConf::class.java)
+
+    Assertions.assertEquals(LocalDate.of(2024, 1, 31), conf.since)
+    Assertions.assertEquals(OffsetDateTime.parse("2024-01-31T02:00:00+08:00"), 
conf.runAt)
+  }
+
+  @Test
+  fun getConfMatchesKeysToSettersAndConstructorParameters() {
+    val conf = dagRunWithConf(mapOf("target_table" to "sales", "row_limit" to 
5L))
+
+    Assertions.assertEquals("sales", 
conf.getConf(SetterConf::class.java).stored())
+    Assertions.assertEquals("sales", 
conf.getConf(CreatorConf::class.java).stored)
+    Assertions.assertEquals(5, 
conf.getConf(CreatorConf::class.java).storedLimit)
+  }
+
+  @Test
+  fun getConfRejectsAPrimitiveConstructorParameterWithNoKey() {
+    Assertions.assertNull(dagRunWithConf(mapOf("row_limit" to 
5L)).getConf(CreatorConf::class.java).stored)
+
+    assertConfFailsOnField(emptyMap(), CreatorConf::class.java, "rowLimit")
+  }
+
+  @Test
+  fun getConfRejectsAnEmptyStringForAPrimitiveField() {
+    listOf("", "  ").forEach { assertConfFailsOnField(mapOf("limit" to it), 
EtlConf::class.java, "limit") }
+  }
+
+  @Test
+  fun getConfMatchesKeysToNamesFromJsonProperty() {
+    listOf("target-table", "TARGET-TABLE").forEach { key ->
+      Assertions.assertEquals("sales", dagRunWithConf(mapOf(key to 
"sales")).getConf(PinnedConf::class.java).table)
+    }
+    Assertions.assertNull(dagRunWithConf(mapOf("table" to 
"sales")).getConf(PinnedConf::class.java).table)
+  }
+
+  @Test
+  fun getConfRejectsNullElementsOnlyForPrimitiveArrays() {
+    Assertions.assertEquals(
+      listOf(1, null),
+      dagRunWithConf(mapOf("boxed_counts" to listOf(1L, 
null))).getConf(CountsConf::class.java).boxedCounts,
+    )
+
+    assertConfFailsOnField(mapOf("counts" to listOf(1L, null)), 
CountsConf::class.java, "counts")
+    assertConfFailsOnField(mapOf("matrix" to listOf(listOf(1L, null))), 
CountsConf::class.java, "matrix")
+  }
+
+  @Test
+  fun getConfSkipsPropertiesThatOnlyHaveAGetter() {
+    val conf = dagRunWithConf(mapOf("base_url" to 
"https://example.com";)).getConf(UrlConf::class.java)
+
+    Assertions.assertEquals("https://example.com";, conf.baseUrl)
+  }
+
+  @Test
+  fun getConfFillsCollectionsAndMapsThatOnlyHaveAGetter() {
+    val conf =
+      dagRunWithConf(
+        mapOf(
+          "TABLES" to listOf(mapOf("table_name" to "orders")),
+          "tables_by_name" to mapOf("Sales_Key" to mapOf("table_name" to 
"sales")),
+        ),
+      ).getConf(GetterOnlyTables::class.java)
+
+    Assertions.assertEquals("orders", conf.getTables().single().tableName)
+    Assertions.assertEquals("sales", 
conf.getTablesByName()["Sales_Key"]?.tableName)
+  }
+
+  @Test
+  fun getConfRejectsTwoKeysThatMatchOneField() {
+    val error =
+      Assertions.assertThrows(IllegalArgumentException::class.java) {
+        dagRunWithConf(mapOf("target_table" to "a", "TargetTable" to 
"b")).getConf(EtlConf::class.java)
+      }
+
+    Assertions.assertEquals(
+      "Dag run conf keys 'target_table' and 'TargetTable' both match field 
EtlConf.targetTable when case and " +
+        "underscores are ignored, so the SDK cannot tell which one to use. 
Name the field exactly as the key " +
+        "it should take.",
+      error.message,
+    )
+  }
+
+  @Test
+  fun getConfRejectsNullOnlyForPrimitiveFields() {
+    Assertions.assertNull(dagRunWithConf(mapOf("target_table" to 
null)).getConf(EtlConf::class.java).targetTable)
+
+    listOf("limit", "LIMIT").forEach { key ->
+      val error =
+        Assertions.assertThrows(IllegalArgumentException::class.java) {
+          dagRunWithConf(mapOf(key to null)).getConf(EtlConf::class.java)
+        }
+
+      Assertions.assertEquals(
+        "Dag run conf key '$key' is null, but field EtlConf.limit has a 
primitive type that cannot be null. " +
+          "Declare a boxed type (e.g. Integer instead of int) to receive 
null.",
+        error.message,
+      )
+    }
+  }
+
+  @Test
+  fun getConfRejectsFieldsThatDifferOnlyInCaseOrUnderscores() {
+    val error =
+      Assertions.assertThrows(IllegalArgumentException::class.java) {
+        dagRunWithConf(emptyMap()).getConf(FoldClash::class.java)
+      }
+
+    Assertions.assertEquals(
+      "Fields FoldClash.targetTable and FoldClash.targettable differ only in 
case or underscores, so the SDK " +
+        "cannot tell which one a Dag run conf key should go to. Rename one of 
them.",
+      error.message,
+    )
+  }
+
+  @Test
+  fun getConfNamesTheTargetClassWhenAValueDoesNotConvert() {
+    val error =
+      Assertions.assertThrows(IllegalArgumentException::class.java) {
+        dagRunWithConf(mapOf("limit" to "many")).getConf(EtlConf::class.java)
+      }
+
+    val message = error.message.orEmpty()
+    Assertions.assertTrue(message.startsWith("Cannot convert the Dag run conf 
to org.apache.airflow.sdk.EtlConf: ")) {
+      message
+    }
+    Assertions.assertInstanceOf(IllegalArgumentException::class.java, 
error.cause)
+  }
 }

Reply via email to