MaxGekk commented on code in PR #56919:
URL: https://github.com/apache/spark/pull/56919#discussion_r3504452246


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlInferSchemaSuite.scala:
##########
@@ -700,6 +700,62 @@ class XmlInferSchemaSuite
     val dfTimeDate = readData(xmlTimeDate)
     assert(dfTimeDate.schema.fields.head.dataType === StringType)
   }
+
+  test("incremental inference widens the type across rows monotonically") {
+    // Long -> Double: once a value forces Double, a later Long value must not 
narrow it back.
+    val longThenDouble = Seq(
+      """<ROW><v>1</v></ROW>""",
+      """<ROW><v>1.5</v></ROW>""",
+      """<ROW><v>2</v></ROW>""")
+    assert(readData(longThenDouble).schema.fields.head.dataType === DoubleType)
+
+    // Long -> String: an incompatible later value widens all the way to the 
top type.
+    val longThenString = Seq(
+      """<ROW><v>1</v></ROW>""",
+      """<ROW><v>abc</v></ROW>""")
+    assert(readData(longThenString).schema.fields.head.dataType === StringType)
+
+    // The result is independent of row order (the merge is commutative).
+    val doubleThenLong = Seq(
+      """<ROW><v>1.5</v></ROW>""",
+      """<ROW><v>2</v></ROW>""")
+    assert(readData(doubleThenLong).schema.fields.head.dataType === DoubleType)
+  }
+
+  test("date is inferred regardless of preferDate") {
+    val xmlDate = Seq("""<ROW><d>2024-01-15</d></ROW>""")
+    // preferDate governs which date formatter is used, not whether date 
inference is attempted.
+    Seq("true", "false").foreach { preferDate =>
+      val df = readData(xmlDate, Map("preferDate" -> preferDate))
+      assert(df.schema.fields.head.dataType === DateType,
+        s"expected DateType with preferDate=$preferDate")
+    }
+  }
+
+  test("incremental type casting yields the same schema as the legacy batch 
path") {
+    // Incremental inference (the default) must produce the same inferred 
schema as the legacy
+    // per-record path across a range of shapes: mixed numerics, nested 
structs, repeated elements
+    // (arrays), attributes, value tags, and cross-row widening to StringType.
+    val cases = Seq(
+      Seq("""<ROW><a>1</a><b>1.5</b></ROW>""", 
"""<ROW><a>2.5</a><b>3</b></ROW>"""),
+      Seq("""<ROW><n><x>1</x><y>t</y></n></ROW>""", 
"""<ROW><n><x>2.0</x><y>u</y></n></ROW>"""),
+      Seq("""<ROW><arr>1</arr><arr>2</arr><arr>3.5</arr></ROW>"""),
+      Seq("""<ROW><e k="1">text</e></ROW>""", """<ROW><e 
k="2.5">other</e></ROW>"""),
+      Seq("""<ROW><v>2024-01-15</v></ROW>""", 
"""<ROW><v>2024-01-15T10:00:00</v></ROW>"""),
+      Seq("""<ROW><v>1</v></ROW>""", """<ROW><v>not-a-number</v></ROW>"""))
+
+    cases.foreach { xml =>
+      val incremental = withSQLConf(
+        SQLConf.XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING.key -> "true") {
+        readData(xml).schema
+      }
+      val batch = withSQLConf(
+        SQLConf.XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING.key -> "false") {
+        readData(xml).schema
+      }
+      assert(incremental === batch, s"incremental and batch schemas differ 
for: $xml")

Review Comment:
   This equivalence check is the main guard for the "flag-on == flag-off" 
claim, but none of the `cases` set `prefersDecimal` (and `readData` passes no 
options), so `prefersDecimal=false` throughout — the one case where the two 
paths diverge (see the comment on the `Decimal` dispatch arm) isn't exercised, 
and this test passes despite it. Worth adding a `prefersDecimal=true` field 
mixing a decimal and an integer, in both row orders, e.g. 
`Seq("<ROW><a>123.45</a></ROW>", "<ROW><a>5</a></ROW>")` and the reverse. It 
would fail today and would guard whichever fix you take.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchema.scala:
##########
@@ -312,41 +338,67 @@ class XmlInferSchema(private val options: XmlOptions, 
private val caseSensitive:
   }
 
   private def inferFrom(datum: String): DataType = {
+    // Start type inference from scratch, without any prior knowledge of the 
field's type.
+    inferFrom(datum, NullType)
+  }
+
+  /**
+   * Infer the type of a primitive value, refining a type already inferred for 
the same field
+   * from previous values.
+   *
+   * Rather than probing every candidate type from scratch on every value (the 
historical
+   * approach), this dispatches on `typeSoFar` -- the type accumulated for 
this field so far --
+   * and only attempts to widen it. Given a value already known to be 
`Double`, for example,
+   * there is no point checking whether it is a `Long`, as the merged type can 
only be `Double`
+   * or wider. This mirrors the schema inference used by the CSV datasource
+   * (`CSVInferSchema.inferField`) and keeps the type lattice monotonic: each 
value can only
+   * hold the type where it is or widen it, never narrow it. `compatibleType` 
reconciles the
+   * newly inferred type with `typeSoFar`, falling back to `StringType` (the 
top type) when they
+   * are incompatible.
+   */
+  private[xml] def inferFrom(datum: String, typeSoFar: DataType): DataType = {
     val value = if (datum != null && options.ignoreSurroundingSpaces) {
       datum.trim()
     } else {
       datum
     }
 
-    if (options.inferSchema) {
-      lazy val decimalTry = tryParseDecimal(value)
-      lazy val timestampNTZTry = tryParseTimestampNTZ(value)
-      value match {
-        case null => NullType
-        case v if v.isEmpty => NullType
-        case v if isLong(v) => LongType
-        case v if options.prefersDecimal && decimalTry.isDefined => 
decimalTry.get
-        case v if isDouble(v) => DoubleType
-        case v if isBoolean(v) => BooleanType
-        case v if isTimeTypeEnabled && isTime(v) => 
TimeType(TimeType.DEFAULT_PRECISION)
-        case v if isDate(v) => DateType
-        case v if timestampNTZTry.isDefined => timestampNTZTry.get
-        case v if isTimestamp(v) => TimestampType
-        case _ => StringType
-      }
-    } else {
-      StringType
+    if (!options.inferSchema) {
+      return StringType
     }
+    if (value == null || value.isEmpty) {
+      return typeSoFar
+    }
+
+    val inferredType = typeSoFar match {
+      case NullType | LongType => tryParseLong(value)
+      case _: DecimalType => tryParseDecimal(value)

Review Comment:
   This dispatch makes the incremental path diverge from the legacy path under 
`prefersDecimal`, which I think breaks the "flag-on == flag-off" invariant. For 
a field with values `123.45` then `5` (prefersDecimal on):
   
   - **Incremental**: `5` arrives with `typeSoFar = Decimal(5,2)` → 
`tryParseDecimal("5")` → `Decimal(1,0)` → `compatibleType(Decimal(5,2), 
Decimal(1,0))` → `Decimal(5,2)`.
   - **Legacy** (flag off): `5` is inferred from scratch as `Long`, then 
`compatibleType(Decimal(5,2), Long)` hits the `(DecimalType, IntegralType)` 
fallback → `DecimalType.forType(Long) = Decimal(20,0)` → `Decimal(22,2)`.
   
   So `Decimal(5,2)` vs `Decimal(22,2)`. It's also order-dependent: `5` seen 
first → `typeSoFar = Long` → a later decimal widens to `Decimal(22,2)`, so the 
incremental schema depends on row order / partition layout, whereas legacy is 
order-independent (`compatibleType` is associative/commutative). That 
contradicts "no change to inferred schemas."
   
   Root cause: entering `tryParseDecimal` for a `Decimal` type-so-far infers an 
integer as a narrow `Decimal` instead of the from-scratch `Long` that legacy 
widens via `DecimalType.forType`. Simplest fix that preserves behavior: for a 
numeric (`Long`/`Decimal`/`Double`) `typeSoFar`, still enter the cascade at 
`tryParseLong`, so an integer infers as `Long` and merges through 
`compatibleType` exactly as the legacy path does (you give up only the "skip 
the Long check" micro-optimization on the numeric branch). Alternatively, if 
converging on CSV's tighter behavior is intended, it'd be worth dropping the 
behavior-preserving framing and calling out that the default-on flag changes 
inferred Decimal schemas.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to