This is an automated email from the ASF dual-hosted git repository.
gengliangwang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 763b7c907cc1 [SPARK-58794][SQL] Add standardSemantics foundation for
CHAR/VARCHAR
763b7c907cc1 is described below
commit 763b7c907cc198a367335e7fdcbc6ee255f2cf0f
Author: Serge Rielau <[email protected]>
AuthorDate: Wed Aug 19 10:29:27 2026 -0700
[SPARK-58794][SQL] Add standardSemantics foundation for CHAR/VARCHAR
### What changes were proposed in this pull request?
This is the foundation for SQL standard-aligned `CHAR(n)` / `VARCHAR(n)`
support. It adds a single gate,
`spark.sql.charVarchar.standardSemantics.enabled` (default `false`), under
which `CHAR`/`VARCHAR` behave as first-class types with proper length semantics
instead of being silently annotated `STRING`.
The change is best read as a path a `CHAR`/`VARCHAR` value travels through
the engine, hardened one stage at a time:
1. **Typing** - `CHAR`/`VARCHAR` may now appear as first-class types in
schemas and plans, rather than being erased to `STRING` during analysis.
2. **Introduction (`CAST`)** - `CAST` / `try_cast` to `CHAR(n)` /
`VARCHAR(n)` keep the target type and enforce its length: `CHAR` is padded,
`VARCHAR` is trimmed, and an oversized value fails with `EXCEED_LIMIT_LENGTH`
(after trailing blanks are trimmed).
3. **Combination (least common type)** - `COALESCE`, `CASE`, `UNION`, `IN`,
and friends widen along `CHAR -> VARCHAR -> STRING`, taking `max(n, m)` for the
length, so combining constrained values yields the tightest type that fits all
of them.
4. **Transformation** - functions that rewrite string content (`upper`,
`substr`, `concat` / `||`, `regexp_*`, `split`, `mask`, `reverse`, `hex`,
`array_join`, `str_to_map`, `json_tuple`, ...) return plain `STRING`, because
their result length is not the input's length. This is implemented as a
promotion in `ImplicitTypeCasts` (`CHAR`/`VARCHAR` -> `STRING` where a plain
string is expected, analogous to `SHORT` -> `INT`); the few expressions that
bypass implicit casting derive the same [...]
5. **Persistence (scan / write)** - write-side checks preserve the declared
type, and scans pad `CHAR` back to its length and reject oversized values, so
what is read back matches what the schema promises.
6. **Delivery (Thrift/JDBC)** - `RowSetUtils` no longer wraps
`CHAR`/`VARCHAR` (or collated `STRING`) results in quotes. Its fast path
matched only the default-collation `STRING` singleton, so these types fell
through to a rendering path that quoted them (`"ab"` instead of `ab`). This fix
is intentionally not gated on the flag, since collated strings were
mis-rendered regardless.
`spark.sql.preserveCharVarcharTypeInfo` remains the pre-existing
experimental path; only `standardSemantics` applies the
transformation-returns-`STRING` rule and the hardening above.
### Out of scope / follow-up
This PR is the **engine foundation** only. The feature is not complete
without the work below; each item is intentionally deferred so this change
stays reviewable.
**Clients**
- HiveServer2 / JDBC `ResultSetMetaData`: report `CHAR`/`VARCHAR` (with
precision `n`) instead of generic `STRING` / `VARCHAR` with unbounded precision.
- `DatabaseMetaData.getColumns`: fix `COLUMN_SIZE = 0` for `VARCHAR`.
- Spark Connect JDBC type mapping for `CHAR`/`VARCHAR` + precision.
**Language surfaces**
- Schema fidelity for `CTAS` / `CREATE VIEW` / CTEs, `ALTER`, temp tables,
and external file tables.
- SQL `FUNCTION` parameters / `RETURNS`, and session variables (`DECLARE` /
`SET`).
- Format round-trips (Parquet / ORC / Avro / CSV) that preserve the logical
type end to end.
Parameterized lengths (`CHAR(:n)` / `VARCHAR(:n)`) are covered in #58080
(parser already binds markers in length position; that PR pins the behavior
under the flag).
**Known engine gap (addressed in follow-up)**
- Combining two collated `CHAR`s of different lengths but the same
collation used to resolve to an indeterminate collation. Fixed and covered,
along with compare / `IN` / set-op LCT behavior, in #58080.
**Explicitly not in this effort**
- Deprecating `preserveCharVarcharTypeInfo`.
- Iceberg `CHAR`/`VARCHAR` metadata.
- Full ISO operator-result typing for every string operator.
### Why are the changes needed?
Without this gate, `CHAR`/`VARCHAR` are effectively aliases for `STRING`:
`CAST` stringifies the target, combinations always widen to `STRING`, and
length is neither enforced on write nor restored on read. Standard-compliant
`CHAR`/`VARCHAR` need a consistent, opt-in path through the whole engine.
### Does this PR introduce _any_ user-facing change?
Yes, when `spark.sql.charVarchar.standardSemantics.enabled` is `true`
(default remains `false`):
- `CAST AS CHAR/VARCHAR` returns a typed result and enforces length.
- `COALESCE` / `CASE` / `UNION` may return `CHAR`/`VARCHAR`.
- Content-transforming string functions return `STRING`.
- Scans pad `CHAR` and reject oversized values.
Independently of the flag, Thrift/JDBC clients no longer see spurious
quotes around `CHAR`/`VARCHAR` and collated `STRING` column values.
### How was this patch tested?
- New cases in `CharVarcharTestSuite` / `BasicCharVarcharTestSuite` /
`FileSourceCharVarcharTestSuite` covering `CAST`, least-common-type,
transformation, `createDataFrame`, and scan pad/oversize.
- Golden file `charvarchar-standard-semantics.sql` (plus analyzer results).
- A parity matrix asserting the fixed-point and single-pass analyzers agree
under the flag.
- A registry-wide inventory test that fails if any function outside the
pass-through allowlist returns `CHAR`/`VARCHAR`.
- `RowSetUtilsSuite` for unquoted rendering of `CHAR`, `VARCHAR`, and
collated `STRING`; verified it fails when the `RowSetUtils` fix is reverted.
Closes #58033 from srielau/SPARK-58794.
Authored-by: Serge Rielau <[email protected]>
Signed-off-by: Gengliang Wang <[email protected]>
---
.../main/scala/org/apache/spark/sql/Encoders.scala | 4 +-
.../sql/catalyst/encoders/AgnosticEncoder.scala | 6 +-
.../spark/sql/catalyst/encoders/RowEncoder.scala | 8 +-
.../sql/catalyst/util/SparkCharVarcharUtils.scala | 12 +-
.../org/apache/spark/sql/internal/SqlApiConf.scala | 9 +
.../org/apache/spark/sql/types/StringType.scala | 61 ++-
.../sql/catalyst/util/CharVarcharCodegenUtils.java | 23 +
.../sql/catalyst/DeserializerBuildHelper.scala | 8 +-
.../spark/sql/catalyst/SerializerBuildHelper.scala | 4 +-
.../sql/catalyst/analysis/AnsiTypeCoercion.scala | 4 +
.../sql/catalyst/analysis/CheckAnalysis.scala | 8 +-
.../sql/catalyst/analysis/ResolveCatalogs.scala | 2 +-
.../spark/sql/catalyst/analysis/TypeCoercion.scala | 4 +
.../sql/catalyst/analysis/TypeCoercionBase.scala | 46 +-
.../expressions/collectionOperations.scala | 13 +-
.../catalyst/expressions/complexTypeCreator.scala | 9 +-
.../sql/catalyst/expressions/jsonExpressions.scala | 7 +-
.../spark/sql/catalyst/expressions/literals.scala | 2 +-
.../sql/catalyst/expressions/maskExpressions.scala | 5 +-
.../sql/catalyst/expressions/mathExpressions.scala | 4 +-
.../catalyst/expressions/regexpExpressions.scala | 12 +-
.../catalyst/expressions/stringExpressions.scala | 63 ++-
.../spark/sql/catalyst/util/CharVarcharUtils.scala | 29 +-
.../org/apache/spark/sql/internal/SQLConf.scala | 28 +-
.../sql/catalyst/encoders/RowEncoderSuite.scala | 67 +--
.../datasources/ApplyCharTypePadding.scala | 23 +-
.../charvarchar-standard-semantics.sql.out | 410 +++++++++++++++++
.../inputs/charvarchar-standard-semantics.sql | 97 ++++
.../results/charvarchar-standard-semantics.sql.out | 459 +++++++++++++++++++
.../apache/spark/sql/CharVarcharTestSuite.scala | 488 ++++++++++++++++++++-
.../test/scala/org/apache/spark/sql/UDFSuite.scala | 15 +
.../spark/sql/hive/thriftserver/RowSetUtils.scala | 4 +
.../sql/hive/thriftserver/RowSetUtilsSuite.scala | 51 +++
33 files changed, 1885 insertions(+), 100 deletions(-)
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/Encoders.scala
b/sql/api/src/main/scala/org/apache/spark/sql/Encoders.scala
index 72cd1190ba40..7c97d223a2a8 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/Encoders.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/Encoders.scala
@@ -86,14 +86,14 @@ object Encoders {
*
* @since 4.0.0
*/
- def CHAR(length: Int): Encoder[java.lang.String] = CharEncoder(length)
+ def CHAR(length: Int): Encoder[java.lang.String] =
CharEncoder(CharType(length))
/**
* An encoder for nullable varchar type.
*
* @since 4.0.0
*/
- def VARCHAR(length: Int): Encoder[java.lang.String] = VarcharEncoder(length)
+ def VARCHAR(length: Int): Encoder[java.lang.String] =
VarcharEncoder(VarcharType(length))
/**
* An encoder for nullable string type.
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/AgnosticEncoder.scala
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/AgnosticEncoder.scala
index 57c15de4f0db..56dc001b227d 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/AgnosticEncoder.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/AgnosticEncoder.scala
@@ -237,8 +237,10 @@ object AgnosticEncoders {
// Nullable leaf encoders
case object NullEncoder extends LeafEncoder[java.lang.Void](NullType)
case object StringEncoder extends LeafEncoder[String](StringType)
- case class CharEncoder(length: Int) extends
LeafEncoder[String](CharType(length))
- case class VarcharEncoder(length: Int) extends
LeafEncoder[String](VarcharType(length))
+ // Carry the full constrained type (length + collation), matching
GeographyEncoder /
+ // GeometryEncoder. Reconstructing from length alone would drop a declared
collation.
+ case class CharEncoder(dt: CharType) extends LeafEncoder[String](dt)
+ case class VarcharEncoder(dt: VarcharType) extends LeafEncoder[String](dt)
case object BinaryEncoder extends LeafEncoder[Array[Byte]](BinaryType)
case object ScalaBigIntEncoder extends
LeafEncoder[BigInt](DecimalType.BigIntDecimal)
case object JavaBigIntEncoder extends
LeafEncoder[JBigInt](DecimalType.BigIntDecimal)
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/RowEncoder.scala
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/RowEncoder.scala
index 5fce0d1491ba..51157b534490 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/RowEncoder.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/encoders/RowEncoder.scala
@@ -91,10 +91,10 @@ object RowEncoder extends DataTypeErrorsBase {
case DoubleType => BoxedDoubleEncoder
case dt: DecimalType => JavaDecimalEncoder(dt, lenientSerialization =
true)
case BinaryType => BinaryEncoder
- case c: CharType if SqlApiConf.get.preserveCharVarcharTypeInfo =>
- CharEncoder(c.length)
- case v: VarcharType if SqlApiConf.get.preserveCharVarcharTypeInfo =>
- VarcharEncoder(v.length)
+ case c: CharType if SqlApiConf.get.charVarcharFirstClassTypes =>
+ CharEncoder(c)
+ case v: VarcharType if SqlApiConf.get.charVarcharFirstClassTypes =>
+ VarcharEncoder(v)
case s: StringType if StringHelper.isPlainString(s) => StringEncoder
case TimestampType if SqlApiConf.get.datetimeJava8ApiEnabled =>
InstantEncoder(lenient)
case TimestampType => TimestampEncoder(lenient)
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkCharVarcharUtils.scala
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkCharVarcharUtils.scala
index 2e7806294ec0..1e3589020671 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkCharVarcharUtils.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkCharVarcharUtils.scala
@@ -31,11 +31,13 @@ trait SparkCharVarcharUtils {
}
/**
- * Validate the given [[DataType]] to fail if it is char or varchar types or
contains nested
- * ones
+ * Fail if the type contains CHAR/VARCHAR unless legacy-as-string or
first-class CHAR/VARCHAR is
+ * enabled (standard semantics or preserveCharVarcharTypeInfo).
*/
def failIfHasCharVarchar(dt: DataType): DataType = {
- if (!SqlApiConf.get.charVarcharAsString && hasCharVarchar(dt)) {
+ if (SqlApiConf.get.charVarcharFirstClassTypes) {
+ dt
+ } else if (!SqlApiConf.get.charVarcharAsString && hasCharVarchar(dt)) {
throw DataTypeErrors.charOrVarcharTypeAsStringUnsupportedError()
} else {
replaceCharVarcharWithString(dt)
@@ -54,8 +56,8 @@ trait SparkCharVarcharUtils {
StructType(fields.map { field =>
field.copy(dataType = replaceCharVarcharWithString(field.dataType))
})
- case c: CharType if !SqlApiConf.get.preserveCharVarcharTypeInfo =>
c.toStringType
- case v: VarcharType if !SqlApiConf.get.preserveCharVarcharTypeInfo =>
v.toStringType
+ case c: CharType if !SqlApiConf.get.charVarcharFirstClassTypes =>
c.toStringType
+ case v: VarcharType if !SqlApiConf.get.charVarcharFirstClassTypes =>
v.toStringType
case _ => dt
}
}
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/internal/SqlApiConf.scala
b/sql/api/src/main/scala/org/apache/spark/sql/internal/SqlApiConf.scala
index 7538580fb234..854545088c8b 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/internal/SqlApiConf.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/internal/SqlApiConf.scala
@@ -42,6 +42,14 @@ private[sql] trait SqlApiConf {
def allowNegativeScaleOfDecimalEnabled: Boolean
def charVarcharAsString: Boolean
def preserveCharVarcharTypeInfo: Boolean
+ def charVarcharStandardSemantics: Boolean
+
+ /**
+ * True when CHAR/VARCHAR may appear as first-class types in schemas and
plans (either the
+ * legacy preserve path or SQL standard semantics).
+ */
+ def charVarcharFirstClassTypes: Boolean =
+ preserveCharVarcharTypeInfo || charVarcharStandardSemantics
def datetimeJava8ApiEnabled: Boolean
def sessionLocalTimeZone: String
def legacyTimeParserPolicy: LegacyBehaviorPolicy.Value
@@ -104,6 +112,7 @@ private[sql] object DefaultSqlApiConf extends SqlApiConf {
override def allowNegativeScaleOfDecimalEnabled: Boolean = false
override def charVarcharAsString: Boolean = false
override def preserveCharVarcharTypeInfo: Boolean = false
+ override def charVarcharStandardSemantics: Boolean = false
override def datetimeJava8ApiEnabled: Boolean = false
override def sessionLocalTimeZone: String = TimeZone.getDefault.getID
override def legacyTimeParserPolicy: LegacyBehaviorPolicy.Value =
LegacyBehaviorPolicy.CORRECTED
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/types/StringType.scala
b/sql/api/src/main/scala/org/apache/spark/sql/types/StringType.scala
index 34467c258d6c..9de50cd758e8 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/types/StringType.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/types/StringType.scala
@@ -159,25 +159,76 @@ case object StringHelper extends
PartialOrdering[StringConstraint] {
def isPlainString(s: StringType): Boolean = s.constraint == NoConstraint
+ /**
+ * Strip CHAR/VARCHAR length constraints, preserving collation.
+ *
+ * Used by transforming string expressions (upper, substr, concat, ...) so
their result type is
+ * plain STRING even when inputs are CharType/VarcharType (SQL standard
CHAR/VARCHAR R1), when
+ * standard semantics are on.
+ */
+ def plainStringType(dt: DataType): DataType = dt match {
+ case c: CharType => c.toStringType
+ case v: VarcharType => v.toStringType
+ case other => other
+ }
+
+ def plainStringType(s: StringType): StringType = s match {
+ case c: CharType => c.toStringType
+ case v: VarcharType => v.toStringType
+ case other => other
+ }
+
+ /**
+ * Result type for transforming string expressions. Under
+ * spark.sql.charVarchar.standardSemantics.enabled, always plain STRING
(R1). Under
+ * preserveCharVarcharTypeInfo alone, keep child type (legacy leaky path).
+ */
+ def transformingStringResultType(dt: DataType): DataType = {
+ if (SqlApiConf.get.charVarcharStandardSemantics) {
+ plainStringType(dt)
+ } else {
+ dt
+ }
+ }
+
def isMoreConstrained(a: StringType, b: StringType): Boolean =
gteq(a.constraint, b.constraint)
+ /**
+ * Least common string type: CHAR -> VARCHAR -> STRING, with length max(n,
m) when the result
+ * remains CHAR or VARCHAR.
+ *
+ * When first-class CHAR/VARCHAR are off, always widens to unbounded STRING
(legacy
+ * annotated-STRING path where Char/Varchar do not appear in plans).
+ */
def tightestCommonString(s1: StringType, s2: StringType): Option[StringType]
= {
if (s1.collationId != s2.collationId) {
return None
}
- if (!SqlApiConf.get.preserveCharVarcharTypeInfo) {
+ if (!SqlApiConf.get.charVarcharFirstClassTypes) {
return Some(StringType(s1.collationId))
}
+ // Carry the declared collation onto CHAR/VARCHAR results. This propagates
the Option rather
+ // than the id: None means "not explicitly declared" and renders as
char(n) instead of
+ // char(n) collate UTF8_BINARY, so an all-default LCT keeps printing (and
comparing) as before.
+ // The two collation ids are already known to be equal here.
+ val collation = declaredCollation(s1).orElse(declaredCollation(s2))
Some((s1.constraint, s2.constraint) match {
- case (FixedLength(l1), FixedLength(l2)) => CharType(l1.max(l2))
- case (MaxLength(l1), FixedLength(l2)) => VarcharType(l1.max(l2))
- case (FixedLength(l1), MaxLength(l2)) => VarcharType(l1.max(l2))
- case (MaxLength(l1), MaxLength(l2)) => VarcharType(l1.max(l2))
+ case (FixedLength(l1), FixedLength(l2)) => new CharType(l1.max(l2),
collation)
+ case (MaxLength(l1), FixedLength(l2)) => new VarcharType(l1.max(l2),
collation)
+ case (FixedLength(l1), MaxLength(l2)) => new VarcharType(l1.max(l2),
collation)
+ case (MaxLength(l1), MaxLength(l2)) => new VarcharType(l1.max(l2),
collation)
case _ => StringType(s1.collationId)
})
}
+ /** The explicitly declared collation of a CHAR/VARCHAR type, if any. */
+ private def declaredCollation(s: StringType): Option[Int] = s match {
+ case c: CharType => c.collation
+ case v: VarcharType => v.collation
+ case _ => None
+ }
+
def removeCollation(s: StringType): StringType = s match {
case c: CharType => CharType(c.length)
case v: VarcharType => VarcharType(v.length)
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/util/CharVarcharCodegenUtils.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/util/CharVarcharCodegenUtils.java
index 1e183b9be597..659833851ccd 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/util/CharVarcharCodegenUtils.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/util/CharVarcharCodegenUtils.java
@@ -64,4 +64,27 @@ public class CharVarcharCodegenUtils {
return inputStr;
}
}
+
+ /**
+ * Read-side CHAR check under standard semantics: pad to limit, or trim
trailing
+ * spaces then error if still longer than limit.
+ *
+ * Standard semantics require a read to observe the same value a write would
have
+ * produced, so this is deliberately the write-side check rather than
+ * {@link #readSidePadding}, which tolerates over-long values. Keep the two
sides
+ * identical: a fix to one is a fix to both.
+ */
+ public static UTF8String charTypeReadSideCheck(UTF8String inputStr, int
limit) {
+ return charTypeWriteSideCheck(inputStr, limit);
+ }
+
+ /**
+ * Read-side VARCHAR check under standard semantics: allow up to limit
characters,
+ * or trim trailing spaces then error if still longer than limit.
+ *
+ * Identical to the write-side check by design; see {@link
#varcharTypeWriteSideCheck}.
+ */
+ public static UTF8String varcharTypeReadSideCheck(UTF8String inputStr, int
limit) {
+ return varcharTypeWriteSideCheck(inputStr, limit);
+ }
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/DeserializerBuildHelper.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/DeserializerBuildHelper.scala
index b08804dccc67..d4c37f7eb5fa 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/DeserializerBuildHelper.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/DeserializerBuildHelper.scala
@@ -327,10 +327,10 @@ object DeserializerBuildHelper {
createDeserializerForGeographyType(path, g.dt)
case g: GeometryEncoder =>
createDeserializerForGeometryType(path, g.dt)
- case CharEncoder(length) =>
- createDeserializerForChar(path, returnNullable = false, length)
- case VarcharEncoder(length) =>
- createDeserializerForVarchar(path, returnNullable = false, length)
+ case CharEncoder(dt) =>
+ createDeserializerForChar(path, returnNullable = false, dt.length)
+ case VarcharEncoder(dt) =>
+ createDeserializerForVarchar(path, returnNullable = false, dt.length)
case StringEncoder =>
createDeserializerForString(path, returnNullable = false)
case _: ScalaDecimalEncoder =>
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/SerializerBuildHelper.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/SerializerBuildHelper.scala
index 3531ebc77c98..370749b1d9bf 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/SerializerBuildHelper.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/SerializerBuildHelper.scala
@@ -358,8 +358,8 @@ object SerializerBuildHelper {
messageParameters = scala.collection.immutable.Map.empty)
case g: GeographyEncoder => createSerializerForGeographyType(input, g.dt)
case g: GeometryEncoder => createSerializerForGeometryType(input, g.dt)
- case CharEncoder(length) => createSerializerForChar(input, length)
- case VarcharEncoder(length) => createSerializerForVarchar(input, length)
+ case CharEncoder(dt) => createSerializerForChar(input, dt.length)
+ case VarcharEncoder(dt) => createSerializerForVarchar(input, dt.length)
case StringEncoder => createSerializerForString(input)
case ScalaDecimalEncoder(dt) => createSerializerForBigDecimal(input, dt)
case JavaDecimalEncoder(dt, false) => createSerializerForBigDecimal(input,
dt)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AnsiTypeCoercion.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AnsiTypeCoercion.scala
index 23c416dd4b38..efac72d250ce 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AnsiTypeCoercion.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/AnsiTypeCoercion.scala
@@ -168,6 +168,10 @@ object AnsiTypeCoercion extends TypeCoercionBase {
private def implicitCast(
inType: DataType,
expectedType: AbstractDataType): Option[DataType] = {
+ // R1 CHAR/VARCHAR promotion is checked first: the acceptsType case below
would otherwise
+ // accept the constrained type unchanged, since CharType and VarcharType
extend StringType.
+ charVarcharToPlainString(inType, expectedType).foreach(dt => return
Some(dt))
+
(inType, expectedType) match {
// If the expected type equals the input type, no need to cast.
case _ if expectedType.acceptsType(inType) => Some(inType)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
index 6687c163efad..36eeedaf30ed 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
@@ -363,11 +363,13 @@ trait CheckAnalysis extends LookupCatalog with
QueryErrorsBase with PlanToString
plan.foreachUp {
case p if p.analyzed => // Skip already analyzed sub-plans
- case leaf: LeafNode if !SQLConf.get.preserveCharVarcharTypeInfo &&
- leaf.output.map(_.dataType).exists(CharVarcharUtils.hasCharVarchar) =>
+ case leaf: LeafNode
+ if !SQLConf.get.charVarcharFirstClassTypes &&
+ leaf.output.exists(attr =>
CharVarcharUtils.hasCharVarchar(attr.dataType)) =>
throw SparkException.internalError(
s"Logical plan should not have output of char/varchar type when " +
- s"${SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key} is false: " +
leaf)
+ s"${SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key} and " +
+ s"${SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key} are both false: "
+ leaf)
case u: UnresolvedNamespace =>
u.schemaNotFound(u.multipartIdentifier)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveCatalogs.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveCatalogs.scala
index 185a5503b110..aa119b4d5b1e 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveCatalogs.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveCatalogs.scala
@@ -143,7 +143,7 @@ class ResolveCatalogs(val catalogManager: CatalogManager)
allowTemp: Boolean,
columns: Seq[ColumnDefinition]): ResolvedIdentifier = {
val columnOutput = columns.map { col =>
- val dataType = if (conf.preserveCharVarcharTypeInfo) {
+ val dataType = if (conf.charVarcharFirstClassTypes) {
col.dataType
} else {
replaceCharVarcharWithString(col.dataType)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercion.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercion.scala
index 7c76e692bb5b..b6a7e8aed67b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercion.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercion.scala
@@ -202,6 +202,10 @@ object TypeCoercion extends TypeCoercionBase {
private def implicitCast(inType: DataType, expectedType: AbstractDataType):
Option[DataType] = {
// Note that ret is nullable to avoid typing a lot of Some(...) in this
local scope.
// We wrap immediately an Option after this.
+ // R1 CHAR/VARCHAR promotion is checked first: the acceptsType case below
would otherwise
+ // accept the constrained type unchanged, since CharType and VarcharType
extend StringType.
+ charVarcharToPlainString(inType, expectedType).foreach(dt => return
Some(dt))
+
@Nullable val ret: DataType = (inType, expectedType) match {
// If the expected type is already a parent of the input type, no need
to cast.
case _ if expectedType.acceptsType(inType) => inType
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
index 7c83a5282910..1be2d52e8842 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
@@ -51,10 +51,54 @@ import
org.apache.spark.sql.catalyst.util.ResolveDefaultColumns
import
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure
import
org.apache.spark.sql.errors.DataTypeErrors.cannotMergeIncompatibleDataTypesError
-import org.apache.spark.sql.types.DataType
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.types.AbstractStringType
+import org.apache.spark.sql.types.{
+ AbstractDataType,
+ DataType,
+ StringHelper,
+ StringType,
+ TypeCollection
+}
abstract class TypeCoercionBase extends TypeCoercionHelper {
+ /**
+ * R1 promotion for CHAR(n)/VARCHAR(n): where a plain string is expected,
promote to STRING the
+ * same way SHORT promotes to INT, and return the promoted type.
+ *
+ * CharType and VarcharType extend StringType, so an expectation such as
+ * `StringTypeWithCollation` accepts them as-is and the implicit cast rules
leave the length
+ * constraint in place. Expressions that then require all their string
inputs to share a single
+ * type (`overlay`, `string_agg`, ...) cannot unify CHAR(n) with STRING, and
RuntimeReplaceable
+ * ones (`right`) build literals from the constrained type that no longer
match their other
+ * branches.
+ *
+ * The expectation must actually mention a string type. Promoting at an
`AnyDataType` site would
+ * strip the length from pass-through expressions such as `max`, `lag`, and
`element_at`, which
+ * are required to preserve CHAR/VARCHAR (R2/R3).
+ */
+ protected def charVarcharToPlainString(
+ inType: DataType,
+ expectedType: AbstractDataType): Option[DataType] = inType match {
+ case st: StringType
+ if SQLConf.get.charVarcharStandardSemantics &&
!StringHelper.isPlainString(st) =>
+ val plain = StringHelper.plainStringType(st)
+ if (expectsStringType(expectedType) && expectedType.acceptsType(plain)) {
+ Some(plain)
+ } else {
+ None
+ }
+ case _ => None
+ }
+
+ private def expectsStringType(expectedType: AbstractDataType): Boolean =
expectedType match {
+ case _: StringType => true
+ case _: AbstractStringType => true
+ case TypeCollection(types) => types.exists(expectsStringType)
+ case _ => false
+ }
+
/**
* Type coercion rule that combines multiple type coercion rules and applies
them in a single tree
* traversal.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala
index 051c1b43664c..4565c63e1922 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala
@@ -1430,7 +1430,11 @@ case class Reverse(child: Expression)
BinaryType,
ArrayType))
- override def dataType: DataType = child.dataType
+ // Reversing a string transforms its content, so a CHAR/VARCHAR input yields
plain STRING (R1).
+ // Array and binary inputs are unaffected. ImplicitTypeCasts already
promotes the string branch
+ // (its promotion looks inside a TypeCollection), so this covers the paths
that do not go
+ // through implicit casting, such as an expression built directly.
+ override def dataType: DataType =
StringHelper.transformingStringResultType(child.dataType)
private def resultArrayElementNullable =
dataType.asInstanceOf[ArrayType].containsNull
@@ -2424,7 +2428,10 @@ case class ArrayJoin(
}
}
- override def dataType: DataType =
array.dataType.asInstanceOf[ArrayType].elementType
+ // The joined result concatenates every element plus delimiters, so it must
not inherit the
+ // element's CHAR/VARCHAR length constraint (R1).
+ override def dataType: DataType =
+
StringHelper.transformingStringResultType(array.dataType.asInstanceOf[ArrayType].elementType)
override def prettyName: String = "array_join"
@@ -3111,7 +3118,7 @@ case class Concat(children: Seq[Expression]) extends
ComplexTypeMergingExpressio
if (children.isEmpty) {
StringType
} else {
- super.dataType
+ StringHelper.transformingStringResultType(super.dataType)
}
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala
index 8629f900ac43..bff416537d4b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala
@@ -640,9 +640,14 @@ case class StringToMap(text: Expression, pairDelim:
Expression, keyValueDelim: E
override def inputTypes: Seq[AbstractDataType] =
Seq(StringTypeNonCSAICollation, StringTypeNonCSAICollation,
StringTypeNonCSAICollation)
- override def dataType: DataType = MapType(first.dataType, first.dataType)
+ // The entries are split out of the input, so they do not carry its
CHAR(n)/VARCHAR(n) length
+ // (R1). ExpectsInputTypes does not insert a cast, so the constraint has to
be dropped here.
+ private lazy val entryType: DataType =
+ StringHelper.transformingStringResultType(first.dataType)
- private lazy val mapBuilder = new ArrayBasedMapBuilder(first.dataType,
first.dataType)
+ override def dataType: DataType = MapType(entryType, entryType)
+
+ private lazy val mapBuilder = new ArrayBasedMapBuilder(entryType, entryType)
private final lazy val collationId: Int =
text.dataType.asInstanceOf[StringType].collationId
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
index f6239cd7f24f..fbd6b9decb5e 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
@@ -286,8 +286,13 @@ case class JsonTuple(children: Seq[Expression])
}.toArray
}
+ // The extracted fields are values from inside the JSON document, so they do
not carry the
+ // CHAR(n)/VARCHAR(n) length of the document itself (R1).
+ private lazy val fieldType: DataType =
+ StringHelper.transformingStringResultType(children.head.dataType)
+
override def elementSchema: StructType =
StructType(fieldExpressions.zipWithIndex.map {
- case (_, idx) => StructField(s"c$idx", children.head.dataType, nullable =
true)
+ case (_, idx) => StructField(s"c$idx", fieldType, nullable = true)
})
override def prettyName: String = "json_tuple"
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
index 546d82546d03..b0cdbcc321c1 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
@@ -171,7 +171,7 @@ object Literal {
case _: DayTimeIntervalType if v.isInstanceOf[Duration] =>
Literal(CatalystTypeConverters.createToCatalystConverter(dataType)(v),
dataType)
case _: ObjectType => Literal(v, dataType)
- case _: CharType | _: VarcharType if
SQLConf.get.preserveCharVarcharTypeInfo =>
+ case _: CharType | _: VarcharType if
SQLConf.get.charVarcharFirstClassTypes =>
Literal(CatalystTypeConverters.createToCatalystConverter(dataType)(v),
dataType)
case _ if requiresSchemaAwareNanosConversion(dataType, v) =>
Literal(CatalystTypeConverters.createToCatalystConverter(dataType)(v),
dataType)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
index a283eb1c8143..dbf2331d6517 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
@@ -25,7 +25,7 @@ import
org.apache.spark.sql.catalyst.expressions.codegen.Block._
import org.apache.spark.sql.catalyst.plans.logical.{FunctionSignature,
InputParameter}
import org.apache.spark.sql.errors.QueryErrorsBase
import org.apache.spark.sql.internal.types.StringTypeWithCollation
-import org.apache.spark.sql.types.{AbstractDataType, DataType, StringType}
+import org.apache.spark.sql.types.{AbstractDataType, DataType, StringHelper,
StringType}
import org.apache.spark.unsafe.types.UTF8String
// scalastyle:off line.size.limit
@@ -290,7 +290,8 @@ case class Mask(
* Returns the [[DataType]] of the result of evaluating this expression. It
is invalid to query
* the dataType of an unresolved expression (i.e., when `resolved` == false).
*/
- override def dataType: DataType = input.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(input.dataType)
/**
* Returns a Seq of the children of this node. Children should not change.
Immutability required
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala
index 0cf7b7db157a..92ce8c20d391 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala
@@ -1282,7 +1282,9 @@ case class Hex(child: Expression)
Seq(TypeCollection(LongType, BinaryType,
StringTypeWithCollation(supportsTrimCollation = true)))
override def dataType: DataType = child.dataType match {
- case st: StringType => st
+ // Hex expands each input character to two hex digits, so a
CHAR(n)/VARCHAR(n) input must not
+ // carry its length constraint into the result (R1).
+ case st: StringType => StringHelper.transformingStringResultType(st)
case _ => super.dataType
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala
index 07d1d00ec756..78c0eacf124c 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala
@@ -617,7 +617,8 @@ case class RLike(left: Expression, right: Expression)
extends StringRegexExpress
case class StringSplit(str: Expression, regex: Expression, limit: Expression)
extends TernaryExpression with ImplicitCastInputTypes {
override def nullIntolerant: Boolean = true
- override def dataType: DataType = ArrayType(str.dataType, containsNull =
false)
+ override def dataType: DataType =
+ ArrayType(StringHelper.transformingStringResultType(str.dataType),
containsNull = false)
override def inputTypes: Seq[AbstractDataType] =
Seq(StringTypeBinaryLcase, StringTypeWithCollation, IntegerType)
override def first: Expression = str
@@ -765,7 +766,8 @@ case class RegExpReplace(subject: Expression, regexp:
Expression, rep: Expressio
RegExpUtils.replace(pattern, s.toString, lastReplacement,
i.asInstanceOf[Int])
}
- override def dataType: DataType = subject.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(subject.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(StringTypeBinaryLcase,
StringTypeWithCollation, StringTypeBinaryLcase, IntegerType)
@@ -940,7 +942,8 @@ case class RegExpExtract(subject: Expression, regexp:
Expression, idx: Expressio
RegExpExtractBase.extract(getLastMatcher(s, p), r.asInstanceOf[Int],
prettyName)
}
- override def dataType: DataType = subject.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(subject.dataType)
override def prettyName: String = "regexp_extract"
override protected def doGenCode(ctx: CodegenContext, ev: ExprCode):
ExprCode = {
@@ -1016,7 +1019,8 @@ case class RegExpExtractAll(subject: Expression, regexp:
Expression, idx: Expres
RegExpExtractBase.extractAll(getLastMatcher(s, p), r.asInstanceOf[Int],
prettyName)
}
- override def dataType: DataType = ArrayType(subject.dataType)
+ override def dataType: DataType =
+ ArrayType(StringHelper.transformingStringResultType(subject.dataType))
override def prettyName: String = "regexp_extract_all"
override protected def doGenCode(ctx: CodegenContext, ev: ExprCode):
ExprCode = {
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
index 45c9e9256aed..c9a287799a1a 100755
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
@@ -99,7 +99,8 @@ case class ConcatWs(children: Seq[Expression])
Seq.fill(children.size - 1)(arrayOrStr)
}
- override def dataType: DataType = children.head.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(children.head.dataType)
override def nullable: Boolean = children.head.nullable
override def foldable: Boolean = children.forall(_.foldable)
@@ -453,7 +454,8 @@ trait String2StringExpression extends
ImplicitCastInputTypes {
def convert(v: UTF8String): UTF8String
- override def dataType: DataType = child.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(child.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(StringTypeWithCollation(supportsTrimCollation = true))
override def contextIndependentFoldable: Boolean =
child.contextIndependentFoldable
@@ -1073,7 +1075,8 @@ case class StringReplace(srcExpr: Expression, searchExpr:
Expression, replaceExp
CollationSupport.StringReplace.genCode(src, search, replace,
collationId))
}
- override def dataType: DataType = srcExpr.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(srcExpr.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeNonCSAICollation(supportsTrimCollation = true),
@@ -1167,7 +1170,8 @@ case class Overlay(input: Expression, replace:
Expression, pos: Expression, len:
this(str, replace, pos, Literal.create(-1, IntegerType))
}
- override def dataType: DataType = input.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(input.dataType)
override def inputTypes: Seq[AbstractDataType] = Seq(
TypeCollection(
@@ -1356,7 +1360,8 @@ case class StringTranslate(srcExpr: Expression,
matchingExpr: Expression, replac
})
}
- override def dataType: DataType = srcExpr.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(srcExpr.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeNonCSAICollation(supportsTrimCollation = true),
@@ -1436,7 +1441,8 @@ trait String2TrimExpression extends Expression with
ImplicitCastInputTypes {
protected def direction: String
override def children: Seq[Expression] = srcStr +: trimStr.toSeq
- override def dataType: DataType = srcStr.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(srcStr.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq.fill(children.size)(StringTypeWithCollation(supportsTrimCollation =
true))
@@ -1993,7 +1999,8 @@ case class SubstringIndex(strExpr: Expression, delimExpr:
Expression, countExpr:
override def nullIntolerant: Boolean = true
final lazy val collationId: Int =
first.dataType.asInstanceOf[StringType].collationId
- override def dataType: DataType = strExpr.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(strExpr.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeNonCSAICollation(supportsTrimCollation = true),
@@ -2206,7 +2213,8 @@ case class StringLPad(str: Expression, len: Expression,
pad: Expression)
override def second: Expression = len
override def third: Expression = pad
- override def dataType: DataType = str.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(str.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeWithCollation(supportsTrimCollation = true),
@@ -2302,7 +2310,8 @@ case class StringRPad(
override def second: Expression = len
override def third: Expression = pad
- override def dataType: DataType = str.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(str.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeWithCollation(supportsTrimCollation = true),
@@ -2358,7 +2367,8 @@ case class FormatString(children: Expression*) extends
Expression with ImplicitC
override def foldable: Boolean = children.forall(_.foldable)
override def contextIndependentFoldable: Boolean =
children.forall(_.contextIndependentFoldable)
override def nullable: Boolean = children(0).nullable
- override def dataType: DataType = children(0).dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(children(0).dataType)
override def inputTypes: Seq[AbstractDataType] =
StringTypeWithCollation(supportsTrimCollation = true) ::
@@ -2480,7 +2490,8 @@ case class InitCap(child: Expression)
override def inputTypes: Seq[AbstractDataType] =
Seq(StringTypeWithCollation(supportsTrimCollation = true))
- override def dataType: DataType = child.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(child.dataType)
override def nullSafeEval(string: Any): Any = {
CollationSupport.InitCap.exec(string.asInstanceOf[UTF8String],
collationId, useICU)
@@ -2517,7 +2528,8 @@ case class StringRepeat(str: Expression, times:
Expression)
override def nullIntolerant: Boolean = true
override def left: Expression = str
override def right: Expression = times
- override def dataType: DataType = str.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(str.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeWithCollation(supportsTrimCollation = true),
@@ -2629,7 +2641,8 @@ case class Substring(str: Expression, pos: Expression,
len: Expression)
this(str, pos, Literal(Integer.MAX_VALUE))
}
- override def dataType: DataType = str.dataType
+ override def dataType: DataType =
+ StringHelper.transformingStringResultType(str.dataType)
override def inputTypes: Seq[AbstractDataType] =
Seq(
@@ -2693,12 +2706,18 @@ case class Substring(str: Expression, pos: Expression,
len: Expression)
case class Right(str: Expression, len: Expression) extends RuntimeReplaceable
with ImplicitCastInputTypes with BinaryLike[Expression] {
+ // Type the literal branches after R1: Substring returns plain STRING for a
CHAR(n)/VARCHAR(n)
+ // input, so deriving the literals from str.dataType would leave the If with
branches of
+ // different types. CheckAnalysis does not see inside a RuntimeReplaceable's
replacement, so
+ // that mismatch would surface later as COMPLEX_EXPRESSION_UNSUPPORTED_INPUT.
+ private lazy val resultType: DataType =
StringHelper.transformingStringResultType(str.dataType)
+
override lazy val replacement: Expression = If(
IsNull(str),
- Literal(null, str.dataType),
+ Literal(null, resultType),
If(
LessThanOrEqual(len, Literal(0)),
- Literal(UTF8String.EMPTY_UTF8, str.dataType),
+ Literal(UTF8String.EMPTY_UTF8, resultType),
new Substring(str, UnaryMinus(len, failOnError = false))
)
)
@@ -4066,8 +4085,10 @@ case class Sentences(
def this(str: Expression, language: Expression) = this(str, language,
Literal(""))
override def nullable: Boolean = true
- override def dataType: DataType =
- ArrayType(ArrayType(str.dataType, containsNull = false), containsNull =
false)
+ override def dataType: DataType = {
+ val elementType = StringHelper.transformingStringResultType(str.dataType)
+ ArrayType(ArrayType(elementType, containsNull = false), containsNull =
false)
+ }
override def inputTypes: Seq[AbstractDataType] =
Seq(
StringTypeWithCollation(supportsTrimCollation = true),
@@ -4102,7 +4123,8 @@ case class Sentences(
case class StringSplitSQL(
str: Expression,
delimiter: Expression) extends BinaryExpression {
- override def dataType: DataType = ArrayType(str.dataType, containsNull =
false)
+ override def dataType: DataType =
+ ArrayType(StringHelper.transformingStringResultType(str.dataType),
containsNull = false)
final lazy val collationId: Int =
left.dataType.asInstanceOf[StringType].collationId
override def left: Expression = str
override def right: Expression = delimiter
@@ -4190,6 +4212,11 @@ case class SplitPart (
case class Empty2Null(child: Expression) extends UnaryExpression with
String2StringExpression {
override def convert(v: UTF8String): UTF8String = if (v.numBytes() == 0)
null else v
+ // Not a transforming function: every non-empty value is returned unchanged,
so this keeps the
+ // child's type rather than taking the plain-STRING result that
String2StringExpression gives
+ // its transforming implementations (R1).
+ override def dataType: DataType = child.dataType
+
override def nullable: Boolean = true
override def doGenCode(ctx: CodegenContext, ev: ExprCode): ExprCode = {
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala
index 9501986bb0c5..1b8f68175878 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala
@@ -82,9 +82,10 @@ object CharVarcharUtils extends Logging with
SparkCharVarcharUtils {
* warning message if it has char or varchar types
*/
def replaceCharVarcharWithStringForCast(dt: DataType): DataType = {
- if (SQLConf.get.charVarcharAsString) {
+ // standardSemantics takes precedence over legacy charVarcharAsString.
+ if (SQLConf.get.charVarcharAsString &&
!SQLConf.get.charVarcharStandardSemantics) {
replaceCharVarcharWithString(dt)
- } else if (hasCharVarchar(dt) && !SQLConf.get.preserveCharVarcharTypeInfo)
{
+ } else if (hasCharVarchar(dt) && !SQLConf.get.charVarcharFirstClassTypes) {
logWarning(log"The Spark cast operator does not support char/varchar
type and simply treats" +
log" them as string type. Please use string type directly to avoid
confusion. Otherwise," +
log" you can set ${MDC(CONFIG,
SQLConf.LEGACY_CHAR_VARCHAR_AS_STRING.key)} " +
@@ -183,7 +184,7 @@ object CharVarcharUtils extends Logging with
SparkCharVarcharUtils {
case c: CharType if charFuncName.isDefined =>
StaticInvoke(
classOf[CharVarcharCodegenUtils],
- if (SQLConf.get.preserveCharVarcharTypeInfo) {
+ if (SQLConf.get.charVarcharFirstClassTypes) {
c
} else {
c.toStringType
@@ -195,7 +196,7 @@ object CharVarcharUtils extends Logging with
SparkCharVarcharUtils {
case v: VarcharType if varcharFuncName.isDefined =>
StaticInvoke(
classOf[CharVarcharCodegenUtils],
- if (SQLConf.get.preserveCharVarcharTypeInfo) {
+ if (SQLConf.get.charVarcharFirstClassTypes) {
v
} else {
v.toStringType
@@ -256,9 +257,23 @@ object CharVarcharUtils extends Logging with
SparkCharVarcharUtils {
}
def addPaddingForScan(attr: Attribute): Expression = {
- getRawType(attr.metadata).map { rawType =>
- processStringForCharVarchar(
- attr, rawType, charFuncName = Some("readSidePadding"), varcharFuncName
= None)
+ // Driven by metadata rather than attr.dataType even when Char/Varchar are
first-class types.
+ // The metadata is the "not yet padded" marker: ApplyCharTypePadding
rebuilds the relation via
+ // cleanAttrMetadata, so a second application of the rule finds no raw
type and leaves the plan
+ // alone. Keying off attr.dataType instead would re-pad an already-padded
scan on every pass and
+ // break the Once strategy's idempotence check.
+ getRawType(attr.metadata).map { dt =>
+ if (SQLConf.get.charVarcharStandardSemantics) {
+ // Pad CHAR and enforce length limits for CHAR/VARCHAR (trim trailing
blanks first).
+ processStringForCharVarchar(
+ attr,
+ dt,
+ charFuncName = Some("charTypeReadSideCheck"),
+ varcharFuncName = Some("varcharTypeReadSideCheck"))
+ } else {
+ processStringForCharVarchar(
+ attr, dt, charFuncName = Some("readSidePadding"), varcharFuncName =
None)
+ }
}.getOrElse(attr)
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 4deaa0bcf7f2..c76adab2f8a2 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -7349,13 +7349,29 @@ object SQLConf {
.createWithDefault(false)
val PRESERVE_CHAR_VARCHAR_TYPE_INFO =
buildConf("spark.sql.preserveCharVarcharTypeInfo")
- .doc("When true, Spark does not replace CHAR/VARCHAR types the STRING
type, which is the " +
- "default behavior of Spark 3.0 and earlier versions. This means the
length checks for " +
- "CHAR/VARCHAR types is enforced and CHAR type is also properly padded.")
+ .doc("When true, Spark does not replace CHAR/VARCHAR with STRING in
schemas and plans. " +
+ "This is the Spark 4.0 experimental path: types can leak through
transforming string " +
+ "functions via child.dataType. Prefer
spark.sql.charVarchar.standardSemantics.enabled " +
+ "for SQL standard CHAR/VARCHAR behavior (CAST/LCT/STRING-returning
transforms).")
.version("4.0.0")
.booleanConf
.createWithDefault(false)
+ val CHAR_VARCHAR_STANDARD_SEMANTICS =
+ buildConf("spark.sql.charVarchar.standardSemantics.enabled")
+ .doc("When true, enable SQL standard CHAR/VARCHAR semantics: first-class
types in " +
+ "schemas and CAST targets; least-common-type for COALESCE/CASE/UNION
may return " +
+ "CHAR/VARCHAR; transforming string functions and operators return
plain STRING. " +
+ "This is a breaking change from the annotated-STRING default and from
" +
+ "preserveCharVarcharTypeInfo (which keeps Char/Varchar through
transforms).")
+ .version("4.4.0")
+ // PERSISTED, like ANSI mode: the flag decides the types a view body
resolves to, so a view
+ // created under standard semantics must keep computing CHAR/VARCHAR
regardless of the
+ // caller's session setting.
+ .withBindingPolicy(ConfigBindingPolicy.PERSISTED)
+ .booleanConf
+ .createWithDefault(false)
+
val READ_FILE_SOURCE_TABLE_CACHE_IGNORE_OPTIONS =
buildConf("spark.sql.legacy.readFileSourceTableCacheIgnoreOptions")
.internal()
@@ -7369,7 +7385,9 @@ object SQLConf {
val READ_SIDE_CHAR_PADDING = buildConf("spark.sql.readSideCharPadding")
.doc("When true, Spark applies string padding when reading CHAR type
columns/fields, " +
"in addition to the write-side padding. This config is true by default
to better enforce " +
- "CHAR type semantic in cases such as external tables.")
+ "CHAR type semantic in cases such as external tables. When " +
+ s"'${CHAR_VARCHAR_STANDARD_SEMANTICS.key}' is true, this config is
ignored: read-side " +
+ "CHAR/VARCHAR checks are always applied, and setting it to false logs a
warning.")
.version("3.4.0")
.booleanConf
.createWithDefault(true)
@@ -9627,6 +9645,8 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def preserveCharVarcharTypeInfo: Boolean =
getConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO)
+ def charVarcharStandardSemantics: Boolean =
getConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS)
+
def avoidDoubleFilterEval: Boolean = getConf(AVOID_DOUBLE_FILTER_EVAL)
def structPredicateDecomposeEnabled: Boolean =
getConf(STRUCT_PREDICATE_DECOMPOSE_ENABLED)
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/encoders/RowEncoderSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/encoders/RowEncoderSuite.scala
index 69f4995220fe..b7a052d5fc4e 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/encoders/RowEncoderSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/encoders/RowEncoderSuite.scala
@@ -592,38 +592,55 @@ class RowEncoderSuite extends CodegenInterpretedPlanTest {
test("do not allow serializing too long strings into char/varchar") {
Seq(CharType(5), VarcharType(5)).foreach { typ =>
- withSQLConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true") {
- val schema = new StructType().add("c", typ)
- val encoder = ExpressionEncoder(schema).resolveAndBind()
- val value = "abcdef"
- checkError(
- exception = intercept[SparkRuntimeException]({
- val row = toRow(encoder, Row(value))
- }),
- condition = "EXCEED_LIMIT_LENGTH",
- parameters = Map("limit" -> "5")
- )
+ Seq(
+ SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true",
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true").foreach { conf
=>
+ withSQLConf(conf) {
+ val schema = new StructType().add("c", typ)
+ val encoder = ExpressionEncoder(schema).resolveAndBind()
+ val value = "abcdef"
+ checkError(
+ exception = intercept[SparkRuntimeException]({
+ val row = toRow(encoder, Row(value))
+ }),
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "5")
+ )
+ }
}
}
}
test("do not allow deserializing too long strings into char/varchar") {
Seq(CharType(5), VarcharType(5)).foreach { typ =>
- withSQLConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true") {
- val fromSchema = new StructType().add("c", StringType)
- val fromEncoder = ExpressionEncoder(fromSchema).resolveAndBind()
- val toSchema = new StructType().add("c", typ)
- val toEncoder = ExpressionEncoder(toSchema).resolveAndBind()
- val value = "abcdef"
- val row = toRow(fromEncoder, Row(value))
- checkError(
- exception = intercept[SparkRuntimeException]({
- val value = fromRow(toEncoder, row)
- }),
- condition = "EXCEED_LIMIT_LENGTH",
- parameters = Map("limit" -> "5")
- )
+ Seq(
+ SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true",
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true").foreach { conf
=>
+ withSQLConf(conf) {
+ val fromSchema = new StructType().add("c", StringType)
+ val fromEncoder = ExpressionEncoder(fromSchema).resolveAndBind()
+ val toSchema = new StructType().add("c", typ)
+ val toEncoder = ExpressionEncoder(toSchema).resolveAndBind()
+ val value = "abcdef"
+ val row = toRow(fromEncoder, Row(value))
+ checkError(
+ exception = intercept[SparkRuntimeException]({
+ val value = fromRow(toEncoder, row)
+ }),
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "5")
+ )
+ }
}
}
}
+
+ test("SPARK-58803: RowEncoder pads CHAR under standardSemantics") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val schema = new StructType().add("c", CharType(5)).add("v",
VarcharType(5))
+ val encoder = ExpressionEncoder(schema).resolveAndBind()
+ val row = toRow(encoder, Row("ab", "cd"))
+ assert(fromRow(encoder, row) === Row("ab ", "cd"))
+ }
+ }
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ApplyCharTypePadding.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ApplyCharTypePadding.scala
index d952927f9d30..a1cc576b2b23 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ApplyCharTypePadding.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ApplyCharTypePadding.scala
@@ -17,6 +17,9 @@
package org.apache.spark.sql.execution.datasources
+import java.util.concurrent.atomic.AtomicBoolean
+
+import org.apache.spark.internal.LogKeys
import org.apache.spark.sql.catalyst.analysis.ApplyCharTypePaddingHelper
import org.apache.spark.sql.catalyst.catalog.HiveTableRelation
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
@@ -36,12 +39,28 @@ import org.apache.spark.sql.internal.SQLConf
*/
object ApplyCharTypePadding extends Rule[LogicalPlan] {
+ private val readSidePaddingOverrideWarned = new AtomicBoolean(false)
+
+ private def warnReadSidePaddingOverride(): Unit = {
+ if (readSidePaddingOverrideWarned.compareAndSet(false, true)) {
+ logWarning(log"${MDC(LogKeys.CONFIG,
SQLConf.READ_SIDE_CHAR_PADDING.key)} is disabled but " +
+ log"${MDC(LogKeys.CONFIG2,
SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key)} is enabled; " +
+ log"read-side CHAR/VARCHAR checks are still applied because standard
semantics require " +
+ log"a read to observe the value a write would have produced.")
+ }
+ }
+
override def apply(plan: LogicalPlan): LogicalPlan = {
- if (conf.charVarcharAsString) {
+ // standardSemantics takes precedence over legacy charVarcharAsString.
+ if (conf.charVarcharAsString && !conf.charVarcharStandardSemantics) {
return plan
}
- if (conf.readSideCharPadding) {
+ if (conf.charVarcharStandardSemantics && !conf.readSideCharPadding) {
+ warnReadSidePaddingOverride()
+ }
+
+ if (conf.readSideCharPadding || conf.charVarcharStandardSemantics) {
val newPlan = plan.resolveOperatorsUpWithNewOutput {
case r: LogicalRelation =>
ApplyCharTypePaddingHelper.readSidePadding(r, () =>
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar-standard-semantics.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar-standard-semantics.sql.out
new file mode 100644
index 000000000000..81fb27a17e34
--- /dev/null
+++
b/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar-standard-semantics.sql.out
@@ -0,0 +1,410 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SELECT typeof(CAST('ab' AS CHAR(5)))
+-- !query analysis
+Project [typeof(cast(ab as char(5))) AS typeof(CAST(ab AS CHAR(5)))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(CAST('hello' AS VARCHAR(5)))
+-- !query analysis
+Project [typeof(cast(hello as varchar(5))) AS typeof(CAST(hello AS
VARCHAR(5)))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT 'X' || CAST('5' AS CHAR(5)) || 'X'
+-- !query analysis
+Project [concat(concat(X, cast(cast(5 as char(5)) as string)), X) AS
concat(concat(X, CAST(5 AS CHAR(5))), X)#x]
++- OneRowRelation
+
+
+-- !query
+SELECT CAST('ab ' AS CHAR(2))
+-- !query analysis
+Project [cast(ab as char(2)) AS CAST(ab AS CHAR(2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT CAST('abcdef' AS CHAR(2))
+-- !query analysis
+Project [cast(abcdef as char(2)) AS CAST(abcdef AS CHAR(2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT CAST('abcdef' AS VARCHAR(2))
+-- !query analysis
+Project [cast(abcdef as varchar(2)) AS CAST(abcdef AS VARCHAR(2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT try_cast('abcdef' AS CHAR(2))
+-- !query analysis
+Project [try_cast(abcdef as char(2)) AS TRY_CAST(abcdef AS CHAR(2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT try_cast('abcdef' AS VARCHAR(2))
+-- !query analysis
+Project [try_cast(abcdef as varchar(2)) AS TRY_CAST(abcdef AS VARCHAR(2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world' AS
VARCHAR(10))))
+-- !query analysis
+Project [typeof(coalesce(cast(cast(hello as varchar(5)) as varchar(10)),
cast(world as varchar(10)))) AS typeof(coalesce(CAST(hello AS VARCHAR(5)),
CAST(world AS VARCHAR(10))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world!' AS CHAR(6))))
+-- !query analysis
+Project [typeof(coalesce(cast(cast(hello as varchar(5)) as varchar(6)),
cast(cast(world! as char(6)) as varchar(6)))) AS typeof(coalesce(CAST(hello AS
VARCHAR(5)), CAST(world! AS CHAR(6))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), cast('world!' AS CHAR(6))))
+-- !query analysis
+Project [typeof(coalesce(cast(cast(hello as char(5)) as char(6)), cast(world!
as char(6)))) AS typeof(coalesce(CAST(hello AS CHAR(5)), CAST(world! AS
CHAR(6))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), 'world'))
+-- !query analysis
+Project [typeof(coalesce(cast(cast(hello as varchar(5)) as string collate
UTF8_BINARY), world)) AS typeof(coalesce(CAST(hello AS VARCHAR(5)), world))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), NULL))
+-- !query analysis
+Project [typeof(coalesce(cast(hello as char(5)), cast(null as char(5)))) AS
typeof(coalesce(CAST(hello AS CHAR(5)), NULL))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(
+ CASE WHEN true THEN cast('a' AS CHAR(2)) ELSE cast('bb' AS CHAR(4)) END)
+-- !query analysis
+Project [typeof(CASE WHEN true THEN cast(cast(a as char(2)) as char(4)) ELSE
cast(bb as char(4)) END) AS typeof(CASE WHEN true THEN CAST(a AS CHAR(2)) ELSE
CAST(bb AS CHAR(4)) END)#x]
++- OneRowRelation
+
+
+-- !query
+SELECT cast('a' AS CHAR(2)) IN (cast('a ' AS CHAR(2)), cast('bbb' AS
VARCHAR(3)))
+-- !query analysis
+Project [cast(cast(a as char(2)) as varchar(3)) IN (cast(cast(a as char(2))
as varchar(3)),cast(cast(bbb as varchar(3)) as varchar(3))) AS (CAST(a AS
CHAR(2)) IN (CAST(a AS CHAR(2)), CAST(bbb AS VARCHAR(3))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(c) FROM (SELECT cast('a' AS CHAR(2)) AS c) t WHERE c IN ('a ',
'b')
+-- !query analysis
+Project [typeof(c#x) AS typeof(c)#x]
++- Filter cast(c#x as string collate UTF8_BINARY) IN (cast(a as string
collate UTF8_BINARY),cast(b as string collate UTF8_BINARY))
+ +- SubqueryAlias t
+ +- Project [cast(a as char(2)) AS c#x]
+ +- OneRowRelation
+
+
+-- !query
+SELECT typeof(upper(cast('ab' AS CHAR(2))))
+-- !query analysis
+Project [typeof(upper(cast(cast(ab as char(2)) as string))) AS
typeof(upper(CAST(ab AS CHAR(2))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(lower(cast('AB' AS VARCHAR(2))))
+-- !query analysis
+Project [typeof(lower(cast(cast(AB as varchar(2)) as string))) AS
typeof(lower(CAST(AB AS VARCHAR(2))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(cast('a' AS CHAR(1)) || cast('b' AS VARCHAR(1)))
+-- !query analysis
+Project [typeof(concat(cast(cast(a as char(1)) as string), cast(cast(b as
varchar(1)) as string))) AS typeof(concat(CAST(a AS CHAR(1)), CAST(b AS
VARCHAR(1))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(substr(cast('hello' AS VARCHAR(5)), 1, 2))
+-- !query analysis
+Project [typeof(substr(cast(cast(hello as varchar(5)) as string), 1, 2)) AS
typeof(substr(CAST(hello AS VARCHAR(5)), 1, 2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(upper(coalesce(cast('a' AS CHAR(2)), cast('b' AS CHAR(4)))))
+-- !query analysis
+Project [typeof(upper(cast(coalesce(cast(cast(a as char(2)) as char(4)),
cast(b as char(4))) as string))) AS typeof(upper(coalesce(CAST(a AS CHAR(2)),
CAST(b AS CHAR(4)))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(concat(cast('a' AS CHAR(2)), cast('b' AS CHAR(3))))
+-- !query analysis
+Project [typeof(concat(cast(cast(a as char(2)) as string), cast(cast(b as
char(3)) as string))) AS typeof(concat(CAST(a AS CHAR(2)), CAST(b AS
CHAR(3))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(trim(cast('ab ' AS CHAR(4))))
+-- !query analysis
+Project [typeof(trim(cast(cast(ab as char(4)) as string), None)) AS
typeof(trim(CAST(ab AS CHAR(4))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(lpad(cast('ab' AS CHAR(2)), 5, 'x'))
+-- !query analysis
+Project [typeof(lpad(cast(cast(ab as char(2)) as string), 5, x)) AS
typeof(lpad(CAST(ab AS CHAR(2)), 5, x))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(regexp_replace(cast('ab' AS CHAR(2)), 'a', 'x'))
+-- !query analysis
+Project [typeof(regexp_replace(cast(cast(ab as char(2)) as string), a, x, 1))
AS typeof(regexp_replace(CAST(ab AS CHAR(2)), a, x, 1))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(regexp_extract(cast('ab' AS VARCHAR(2)), '(a)', 1))
+-- !query analysis
+Project [typeof(regexp_extract(cast(cast(ab as varchar(2)) as string), (a),
1)) AS typeof(regexp_extract(CAST(ab AS VARCHAR(2)), (a), 1))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(regexp_extract_all(cast('aab' AS VARCHAR(3)), '(a)', 1))
+-- !query analysis
+Project [typeof(regexp_extract_all(cast(cast(aab as varchar(3)) as string),
(a), 1)) AS typeof(regexp_extract_all(CAST(aab AS VARCHAR(3)), (a), 1))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(split(cast('a,b' AS CHAR(3)), ','))
+-- !query analysis
+Project [typeof(split(cast(cast(a,b as char(3)) as string), ,, -1)) AS
typeof(split(CAST(a,b AS CHAR(3)), ,, -1))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(mask(cast('ab' AS CHAR(2))))
+-- !query analysis
+Project [typeof(mask(cast(ab as char(2)), X, x, n, null)) AS
typeof(mask(CAST(ab AS CHAR(2)), X, x, n, NULL))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1))
+-- !query analysis
+Project [typeof(overlay(cast(cast(ab as char(5)) as string), x, 1, -1)) AS
typeof(overlay(CAST(ab AS CHAR(5)), x, 1, -1))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT concat('<', overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1), '>')
+-- !query analysis
+Project [concat(<, overlay(cast(cast(ab as char(5)) as string), x, 1, -1), >)
AS concat(<, overlay(CAST(ab AS CHAR(5)), x, 1, -1), >)#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(elt(1, cast('ab' AS CHAR(5)), 'x'))
+-- !query analysis
+Project [typeof(elt(1, cast(cast(ab as char(5)) as string), x, true)) AS
typeof(elt(1, CAST(ab AS CHAR(5)), x))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(right(cast('ab' AS CHAR(5)), 2))
+-- !query analysis
+Project [typeof(right(cast(cast(ab as char(5)) as string), 2)) AS
typeof(right(CAST(ab AS CHAR(5)), 2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT concat('<', right(cast('ab' AS CHAR(5)), 2), '>')
+-- !query analysis
+Project [concat(<, right(cast(cast(ab as char(5)) as string), 2), >) AS
concat(<, right(CAST(ab AS CHAR(5)), 2), >)#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(left(cast('ab' AS CHAR(5)), 2))
+-- !query analysis
+Project [typeof(left(cast(cast(ab as char(5)) as string), 2)) AS
typeof(left(CAST(ab AS CHAR(5)), 2))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(reverse(cast('ab' AS CHAR(5))))
+-- !query analysis
+Project [typeof(reverse(cast(cast(ab as char(5)) as string))) AS
typeof(reverse(CAST(ab AS CHAR(5))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(hex(cast('ab' AS CHAR(5))))
+-- !query analysis
+Project [typeof(hex(cast(cast(ab as char(5)) as string))) AS
typeof(hex(CAST(ab AS CHAR(5))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT hex(cast('ab' AS CHAR(5)))
+-- !query analysis
+Project [hex(cast(cast(ab as char(5)) as string)) AS hex(CAST(ab AS
CHAR(5)))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS CHAR(5))),
'-'))
+-- !query analysis
+Project [typeof(array_join(array(cast(ab as char(5)), cast(cd as char(5))), -,
None)) AS typeof(array_join(array(CAST(ab AS CHAR(5)), CAST(cd AS CHAR(5))),
-))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT concat('<', array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS
CHAR(5))), '-'), '>')
+-- !query analysis
+Project [concat(<, array_join(array(cast(ab as char(5)), cast(cd as char(5))),
-, None), >) AS concat(<, array_join(array(CAST(ab AS CHAR(5)), CAST(cd AS
CHAR(5))), -), >)#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(reverse(array(1, 2)))
+-- !query analysis
+Project [typeof(reverse(array(1, 2))) AS typeof(reverse(array(1, 2)))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(str_to_map(cast('a:1,b:2' AS CHAR(7))))
+-- !query analysis
+Project [typeof(str_to_map(cast(a:1,b:2 as char(7)), ,, :)) AS
typeof(str_to_map(CAST(a:1,b:2 AS CHAR(7)), ,, :))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(c0) FROM (SELECT json_tuple(cast('{"a":"1"}' AS CHAR(9)), 'a')
AS c0)
+-- !query analysis
+Project [typeof(c0#x) AS typeof(c0)#x]
++- SubqueryAlias __auto_generated_subquery_name
+ +- Project [c0#x]
+ +- Generate json_tuple(cast({"a":"1"} as char(9)), a), false, [c0#x]
+ +- OneRowRelation
+
+
+-- !query
+SELECT typeof(cast('a' AS CHAR(2) COLLATE UTF8_LCASE))
+-- !query analysis
+Project [typeof(cast(a as char(2) collate UTF8_LCASE)) AS typeof(CAST(a AS
CHAR(2) COLLATE UTF8_LCASE))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(coalesce(
+ cast('a' AS CHAR(2) COLLATE UTF8_LCASE), cast('bb' AS CHAR(2) COLLATE
UTF8_LCASE)))
+-- !query analysis
+Project [typeof(coalesce(cast(a as char(2) collate UTF8_LCASE), cast(bb as
char(2) collate UTF8_LCASE))) AS typeof(coalesce(CAST(a AS CHAR(2) COLLATE
UTF8_LCASE), CAST(bb AS CHAR(2) COLLATE UTF8_LCASE)))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(c) FROM (
+ SELECT cast('a' AS VARCHAR(3)) AS c
+ UNION ALL
+ SELECT cast('abcd' AS VARCHAR(8)) AS c
+) t LIMIT 1
+-- !query analysis
+GlobalLimit 1
++- LocalLimit 1
+ +- Project [typeof(c#x) AS typeof(c)#x]
+ +- SubqueryAlias t
+ +- Union false, false
+ :- Project [cast(c#x as varchar(8)) AS c#x]
+ : +- Project [cast(a as varchar(3)) AS c#x]
+ : +- OneRowRelation
+ +- Project [cast(abcd as varchar(8)) AS c#x]
+ +- OneRowRelation
+
+
+-- !query
+SELECT typeof(array(cast('a' AS CHAR(2)), cast('bb' AS CHAR(3))))
+-- !query analysis
+Project [typeof(array(cast(cast(a as char(2)) as char(3)), cast(bb as
char(3)))) AS typeof(array(CAST(a AS CHAR(2)), CAST(bb AS CHAR(3))))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(struct(cast('a' AS CHAR(2)) AS f))
+-- !query analysis
+Project [typeof(struct(f, cast(a as char(2)))) AS typeof(struct(CAST(a AS
CHAR(2)) AS f))#x]
++- OneRowRelation
+
+
+-- !query
+SELECT typeof(map('k', cast('a' AS VARCHAR(2))))
+-- !query analysis
+Project [typeof(map(k, cast(a as varchar(2)))) AS typeof(map(k, CAST(a AS
VARCHAR(2))))#x]
++- OneRowRelation
+
+
+-- !query
+CREATE TABLE char_varchar_std (c CHAR(5), v VARCHAR(5)) USING parquet
+-- !query analysis
+CreateDataSourceTableCommand `spark_catalog`.`default`.`char_varchar_std`,
false
+
+
+-- !query
+INSERT INTO char_varchar_std VALUES ('ab', 'ab')
+-- !query analysis
+InsertIntoHadoopFsRelationCommand file:[not included in
comparison]/{warehouse_dir}/char_varchar_std, false, Parquet, [path=file:[not
included in comparison]/{warehouse_dir}/char_varchar_std], Append,
`spark_catalog`.`default`.`char_varchar_std`,
org.apache.spark.sql.execution.datasources.InMemoryFileIndex(file:[not included
in comparison]/{warehouse_dir}/char_varchar_std), [c, v]
++- Project
[static_invoke(CharVarcharCodegenUtils.charTypeWriteSideCheck(cast(col1#x as
char(5)), 5)) AS c#x,
static_invoke(CharVarcharCodegenUtils.varcharTypeWriteSideCheck(cast(col2#x as
varchar(5)), 5)) AS v#x]
+ +- LocalRelation [col1#x, col2#x]
+
+
+-- !query
+SELECT typeof(c), typeof(v) FROM char_varchar_std
+-- !query analysis
+Project [typeof(c#x) AS typeof(c)#x, typeof(v#x) AS typeof(v)#x]
++- SubqueryAlias spark_catalog.default.char_varchar_std
+ +- Project
[static_invoke(CharVarcharCodegenUtils.charTypeReadSideCheck(c#x, 5)) AS c#x,
static_invoke(CharVarcharCodegenUtils.varcharTypeReadSideCheck(v#x, 5)) AS v#x]
+ +- Relation spark_catalog.default.char_varchar_std[c#x,v#x] parquet
+
+
+-- !query
+SELECT concat('[', c, ']'), concat('[', v, ']') FROM char_varchar_std
+-- !query analysis
+Project [concat([, cast(c#x as string), ]) AS concat([, c, ])#x, concat([,
cast(v#x as string), ]) AS concat([, v, ])#x]
++- SubqueryAlias spark_catalog.default.char_varchar_std
+ +- Project
[static_invoke(CharVarcharCodegenUtils.charTypeReadSideCheck(c#x, 5)) AS c#x,
static_invoke(CharVarcharCodegenUtils.varcharTypeReadSideCheck(v#x, 5)) AS v#x]
+ +- Relation spark_catalog.default.char_varchar_std[c#x,v#x] parquet
+
+
+-- !query
+SELECT length(c), length(v) FROM char_varchar_std
+-- !query analysis
+Project [length(cast(c#x as string)) AS length(c)#x, length(cast(v#x as
string)) AS length(v)#x]
++- SubqueryAlias spark_catalog.default.char_varchar_std
+ +- Project
[static_invoke(CharVarcharCodegenUtils.charTypeReadSideCheck(c#x, 5)) AS c#x,
static_invoke(CharVarcharCodegenUtils.varcharTypeReadSideCheck(v#x, 5)) AS v#x]
+ +- Relation spark_catalog.default.char_varchar_std[c#x,v#x] parquet
+
+
+-- !query
+DROP TABLE char_varchar_std
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog), default.char_varchar_std
diff --git
a/sql/core/src/test/resources/sql-tests/inputs/charvarchar-standard-semantics.sql
b/sql/core/src/test/resources/sql-tests/inputs/charvarchar-standard-semantics.sql
new file mode 100644
index 000000000000..6d591bc7e8cf
--- /dev/null
+++
b/sql/core/src/test/resources/sql-tests/inputs/charvarchar-standard-semantics.sql
@@ -0,0 +1,97 @@
+--SET spark.sql.charVarchar.standardSemantics.enabled=true
+
+-- R3: CAST introduces CHAR/VARCHAR
+SELECT typeof(CAST('ab' AS CHAR(5)));
+SELECT typeof(CAST('hello' AS VARCHAR(5)));
+SELECT 'X' || CAST('5' AS CHAR(5)) || 'X';
+
+-- CAST length enforcement: trailing spaces are trimmed, real overflow errors
+SELECT CAST('ab ' AS CHAR(2));
+SELECT CAST('abcdef' AS CHAR(2));
+SELECT CAST('abcdef' AS VARCHAR(2));
+SELECT try_cast('abcdef' AS CHAR(2));
+SELECT try_cast('abcdef' AS VARCHAR(2));
+
+-- R2: least common type (COALESCE / CASE)
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world' AS
VARCHAR(10))));
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world!' AS
CHAR(6))));
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), cast('world!' AS CHAR(6))));
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), 'world'));
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), NULL));
+SELECT typeof(
+ CASE WHEN true THEN cast('a' AS CHAR(2)) ELSE cast('bb' AS CHAR(4)) END);
+
+-- R2: least common type for IN lists
+SELECT cast('a' AS CHAR(2)) IN (cast('a ' AS CHAR(2)), cast('bbb' AS
VARCHAR(3)));
+SELECT typeof(c) FROM (SELECT cast('a' AS CHAR(2)) AS c) t WHERE c IN ('a ',
'b');
+
+-- R1: transforming functions return STRING
+SELECT typeof(upper(cast('ab' AS CHAR(2))));
+SELECT typeof(lower(cast('AB' AS VARCHAR(2))));
+SELECT typeof(cast('a' AS CHAR(1)) || cast('b' AS VARCHAR(1)));
+SELECT typeof(substr(cast('hello' AS VARCHAR(5)), 1, 2));
+SELECT typeof(upper(coalesce(cast('a' AS CHAR(2)), cast('b' AS CHAR(4)))));
+SELECT typeof(concat(cast('a' AS CHAR(2)), cast('b' AS CHAR(3))));
+SELECT typeof(trim(cast('ab ' AS CHAR(4))));
+SELECT typeof(lpad(cast('ab' AS CHAR(2)), 5, 'x'));
+
+-- R1: regexp / mask / split family
+SELECT typeof(regexp_replace(cast('ab' AS CHAR(2)), 'a', 'x'));
+SELECT typeof(regexp_extract(cast('ab' AS VARCHAR(2)), '(a)', 1));
+SELECT typeof(regexp_extract_all(cast('aab' AS VARCHAR(3)), '(a)', 1));
+SELECT typeof(split(cast('a,b' AS CHAR(3)), ','));
+SELECT typeof(mask(cast('ab' AS CHAR(2))));
+
+-- R1: CHAR/VARCHAR promote to STRING where a plain string is expected, so
expressions that
+-- require all their string inputs to share one type accept them alongside a
STRING argument.
+-- Values are wrapped in sentinels because the golden format trims trailing
blanks, which would
+-- otherwise hide the CHAR padding these expressions operate on.
+SELECT typeof(overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1));
+SELECT concat('<', overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1), '>');
+SELECT typeof(elt(1, cast('ab' AS CHAR(5)), 'x'));
+SELECT typeof(right(cast('ab' AS CHAR(5)), 2));
+SELECT concat('<', right(cast('ab' AS CHAR(5)), 2), '>');
+SELECT typeof(left(cast('ab' AS CHAR(5)), 2));
+
+-- R1: transforms whose result length differs from the input must not inherit
the constraint.
+SELECT typeof(reverse(cast('ab' AS CHAR(5))));
+SELECT typeof(hex(cast('ab' AS CHAR(5))));
+SELECT hex(cast('ab' AS CHAR(5)));
+SELECT typeof(array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS CHAR(5))),
'-'));
+SELECT concat('<', array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS
CHAR(5))), '-'), '>');
+-- reverse() on a non-string input is unaffected.
+SELECT typeof(reverse(array(1, 2)));
+-- Expressions that pull values out of a wrapper do not inherit the wrapper's
constraint. These
+-- take their inputs through ExpectsInputTypes, so no implicit cast strips the
length for them.
+SELECT typeof(str_to_map(cast('a:1,b:2' AS CHAR(7))));
+SELECT typeof(c0) FROM (SELECT json_tuple(cast('{"a":"1"}' AS CHAR(9)), 'a')
AS c0);
+
+-- R2 with collation. A declared collation survives the CAST and an LCT over
equally constrained
+-- operands. The mixed-length case (CHAR(2) with CHAR(4), same collation) is
deliberately not
+-- covered here: CollationTypeCoercion reads the differing lengths as a
collation mismatch and
+-- yields an indeterminate collation. That predates this change (it reproduces
under
+-- spark.sql.preserveCharVarcharTypeInfo) and is tracked separately, so
goldening it would
+-- normalize the bug.
+SELECT typeof(cast('a' AS CHAR(2) COLLATE UTF8_LCASE));
+SELECT typeof(coalesce(
+ cast('a' AS CHAR(2) COLLATE UTF8_LCASE), cast('bb' AS CHAR(2) COLLATE
UTF8_LCASE)));
+
+-- UNION LCT
+SELECT typeof(c) FROM (
+ SELECT cast('a' AS VARCHAR(3)) AS c
+ UNION ALL
+ SELECT cast('abcd' AS VARCHAR(8)) AS c
+) t LIMIT 1;
+
+-- Nested types keep CHAR/VARCHAR
+SELECT typeof(array(cast('a' AS CHAR(2)), cast('bb' AS CHAR(3))));
+SELECT typeof(struct(cast('a' AS CHAR(2)) AS f));
+SELECT typeof(map('k', cast('a' AS VARCHAR(2))));
+
+-- R3: bare column references keep the declared type; write and read pad CHAR
+CREATE TABLE char_varchar_std (c CHAR(5), v VARCHAR(5)) USING parquet;
+INSERT INTO char_varchar_std VALUES ('ab', 'ab');
+SELECT typeof(c), typeof(v) FROM char_varchar_std;
+SELECT concat('[', c, ']'), concat('[', v, ']') FROM char_varchar_std;
+SELECT length(c), length(v) FROM char_varchar_std;
+DROP TABLE char_varchar_std;
diff --git
a/sql/core/src/test/resources/sql-tests/results/charvarchar-standard-semantics.sql.out
b/sql/core/src/test/resources/sql-tests/results/charvarchar-standard-semantics.sql.out
new file mode 100644
index 000000000000..1830a80cac51
--- /dev/null
+++
b/sql/core/src/test/resources/sql-tests/results/charvarchar-standard-semantics.sql.out
@@ -0,0 +1,459 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SELECT typeof(CAST('ab' AS CHAR(5)))
+-- !query schema
+struct<typeof(CAST(ab AS CHAR(5))):string>
+-- !query output
+char(5)
+
+
+-- !query
+SELECT typeof(CAST('hello' AS VARCHAR(5)))
+-- !query schema
+struct<typeof(CAST(hello AS VARCHAR(5))):string>
+-- !query output
+varchar(5)
+
+
+-- !query
+SELECT 'X' || CAST('5' AS CHAR(5)) || 'X'
+-- !query schema
+struct<concat(concat(X, CAST(5 AS CHAR(5))), X):string>
+-- !query output
+X5 X
+
+
+-- !query
+SELECT CAST('ab ' AS CHAR(2))
+-- !query schema
+struct<CAST(ab AS CHAR(2)):char(2)>
+-- !query output
+ab
+
+
+-- !query
+SELECT CAST('abcdef' AS CHAR(2))
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "EXCEED_LIMIT_LENGTH",
+ "sqlState" : "54006",
+ "messageParameters" : {
+ "limit" : "2"
+ }
+}
+
+
+-- !query
+SELECT CAST('abcdef' AS VARCHAR(2))
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "EXCEED_LIMIT_LENGTH",
+ "sqlState" : "54006",
+ "messageParameters" : {
+ "limit" : "2"
+ }
+}
+
+
+-- !query
+SELECT try_cast('abcdef' AS CHAR(2))
+-- !query schema
+struct<TRY_CAST(abcdef AS CHAR(2)):char(2)>
+-- !query output
+NULL
+
+
+-- !query
+SELECT try_cast('abcdef' AS VARCHAR(2))
+-- !query schema
+struct<TRY_CAST(abcdef AS VARCHAR(2)):varchar(2)>
+-- !query output
+NULL
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world' AS
VARCHAR(10))))
+-- !query schema
+struct<typeof(coalesce(CAST(hello AS VARCHAR(5)), CAST(world AS
VARCHAR(10)))):string>
+-- !query output
+varchar(10)
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), cast('world!' AS CHAR(6))))
+-- !query schema
+struct<typeof(coalesce(CAST(hello AS VARCHAR(5)), CAST(world! AS
CHAR(6)))):string>
+-- !query output
+varchar(6)
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), cast('world!' AS CHAR(6))))
+-- !query schema
+struct<typeof(coalesce(CAST(hello AS CHAR(5)), CAST(world! AS
CHAR(6)))):string>
+-- !query output
+char(6)
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS VARCHAR(5)), 'world'))
+-- !query schema
+struct<typeof(coalesce(CAST(hello AS VARCHAR(5)), world)):string>
+-- !query output
+string collate UTF8_BINARY
+
+
+-- !query
+SELECT typeof(coalesce(cast('hello' AS CHAR(5)), NULL))
+-- !query schema
+struct<typeof(coalesce(CAST(hello AS CHAR(5)), NULL)):string>
+-- !query output
+char(5)
+
+
+-- !query
+SELECT typeof(
+ CASE WHEN true THEN cast('a' AS CHAR(2)) ELSE cast('bb' AS CHAR(4)) END)
+-- !query schema
+struct<typeof(CASE WHEN true THEN CAST(a AS CHAR(2)) ELSE CAST(bb AS CHAR(4))
END):string>
+-- !query output
+char(4)
+
+
+-- !query
+SELECT cast('a' AS CHAR(2)) IN (cast('a ' AS CHAR(2)), cast('bbb' AS
VARCHAR(3)))
+-- !query schema
+struct<(CAST(a AS CHAR(2)) IN (CAST(a AS CHAR(2)), CAST(bbb AS
VARCHAR(3)))):boolean>
+-- !query output
+true
+
+
+-- !query
+SELECT typeof(c) FROM (SELECT cast('a' AS CHAR(2)) AS c) t WHERE c IN ('a ',
'b')
+-- !query schema
+struct<typeof(c):string>
+-- !query output
+char(2)
+
+
+-- !query
+SELECT typeof(upper(cast('ab' AS CHAR(2))))
+-- !query schema
+struct<typeof(upper(CAST(ab AS CHAR(2)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(lower(cast('AB' AS VARCHAR(2))))
+-- !query schema
+struct<typeof(lower(CAST(AB AS VARCHAR(2)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(cast('a' AS CHAR(1)) || cast('b' AS VARCHAR(1)))
+-- !query schema
+struct<typeof(concat(CAST(a AS CHAR(1)), CAST(b AS VARCHAR(1)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(substr(cast('hello' AS VARCHAR(5)), 1, 2))
+-- !query schema
+struct<typeof(substr(CAST(hello AS VARCHAR(5)), 1, 2)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(upper(coalesce(cast('a' AS CHAR(2)), cast('b' AS CHAR(4)))))
+-- !query schema
+struct<typeof(upper(coalesce(CAST(a AS CHAR(2)), CAST(b AS CHAR(4))))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(concat(cast('a' AS CHAR(2)), cast('b' AS CHAR(3))))
+-- !query schema
+struct<typeof(concat(CAST(a AS CHAR(2)), CAST(b AS CHAR(3)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(trim(cast('ab ' AS CHAR(4))))
+-- !query schema
+struct<typeof(trim(CAST(ab AS CHAR(4)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(lpad(cast('ab' AS CHAR(2)), 5, 'x'))
+-- !query schema
+struct<typeof(lpad(CAST(ab AS CHAR(2)), 5, x)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(regexp_replace(cast('ab' AS CHAR(2)), 'a', 'x'))
+-- !query schema
+struct<typeof(regexp_replace(CAST(ab AS CHAR(2)), a, x, 1)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(regexp_extract(cast('ab' AS VARCHAR(2)), '(a)', 1))
+-- !query schema
+struct<typeof(regexp_extract(CAST(ab AS VARCHAR(2)), (a), 1)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(regexp_extract_all(cast('aab' AS VARCHAR(3)), '(a)', 1))
+-- !query schema
+struct<typeof(regexp_extract_all(CAST(aab AS VARCHAR(3)), (a), 1)):string>
+-- !query output
+array<string>
+
+
+-- !query
+SELECT typeof(split(cast('a,b' AS CHAR(3)), ','))
+-- !query schema
+struct<typeof(split(CAST(a,b AS CHAR(3)), ,, -1)):string>
+-- !query output
+array<string>
+
+
+-- !query
+SELECT typeof(mask(cast('ab' AS CHAR(2))))
+-- !query schema
+struct<typeof(mask(CAST(ab AS CHAR(2)), X, x, n, NULL)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1))
+-- !query schema
+struct<typeof(overlay(CAST(ab AS CHAR(5)), x, 1, -1)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT concat('<', overlay(cast('ab' AS CHAR(5)) PLACING 'x' FROM 1), '>')
+-- !query schema
+struct<concat(<, overlay(CAST(ab AS CHAR(5)), x, 1, -1), >):string>
+-- !query output
+<xb >
+
+
+-- !query
+SELECT typeof(elt(1, cast('ab' AS CHAR(5)), 'x'))
+-- !query schema
+struct<typeof(elt(1, CAST(ab AS CHAR(5)), x)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(right(cast('ab' AS CHAR(5)), 2))
+-- !query schema
+struct<typeof(right(CAST(ab AS CHAR(5)), 2)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT concat('<', right(cast('ab' AS CHAR(5)), 2), '>')
+-- !query schema
+struct<concat(<, right(CAST(ab AS CHAR(5)), 2), >):string>
+-- !query output
+< >
+
+
+-- !query
+SELECT typeof(left(cast('ab' AS CHAR(5)), 2))
+-- !query schema
+struct<typeof(left(CAST(ab AS CHAR(5)), 2)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(reverse(cast('ab' AS CHAR(5))))
+-- !query schema
+struct<typeof(reverse(CAST(ab AS CHAR(5)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(hex(cast('ab' AS CHAR(5))))
+-- !query schema
+struct<typeof(hex(CAST(ab AS CHAR(5)))):string>
+-- !query output
+string
+
+
+-- !query
+SELECT hex(cast('ab' AS CHAR(5)))
+-- !query schema
+struct<hex(CAST(ab AS CHAR(5))):string>
+-- !query output
+6162202020
+
+
+-- !query
+SELECT typeof(array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS CHAR(5))),
'-'))
+-- !query schema
+struct<typeof(array_join(array(CAST(ab AS CHAR(5)), CAST(cd AS CHAR(5))),
-)):string>
+-- !query output
+string
+
+
+-- !query
+SELECT concat('<', array_join(array(cast('ab' AS CHAR(5)), cast('cd' AS
CHAR(5))), '-'), '>')
+-- !query schema
+struct<concat(<, array_join(array(CAST(ab AS CHAR(5)), CAST(cd AS CHAR(5))),
-), >):string>
+-- !query output
+<ab -cd >
+
+
+-- !query
+SELECT typeof(reverse(array(1, 2)))
+-- !query schema
+struct<typeof(reverse(array(1, 2))):string>
+-- !query output
+array<int>
+
+
+-- !query
+SELECT typeof(str_to_map(cast('a:1,b:2' AS CHAR(7))))
+-- !query schema
+struct<typeof(str_to_map(CAST(a:1,b:2 AS CHAR(7)), ,, :)):string>
+-- !query output
+map<string,string>
+
+
+-- !query
+SELECT typeof(c0) FROM (SELECT json_tuple(cast('{"a":"1"}' AS CHAR(9)), 'a')
AS c0)
+-- !query schema
+struct<typeof(c0):string>
+-- !query output
+string
+
+
+-- !query
+SELECT typeof(cast('a' AS CHAR(2) COLLATE UTF8_LCASE))
+-- !query schema
+struct<typeof(CAST(a AS CHAR(2) COLLATE UTF8_LCASE)):string>
+-- !query output
+char(2) collate UTF8_LCASE
+
+
+-- !query
+SELECT typeof(coalesce(
+ cast('a' AS CHAR(2) COLLATE UTF8_LCASE), cast('bb' AS CHAR(2) COLLATE
UTF8_LCASE)))
+-- !query schema
+struct<typeof(coalesce(CAST(a AS CHAR(2) COLLATE UTF8_LCASE), CAST(bb AS
CHAR(2) COLLATE UTF8_LCASE))):string>
+-- !query output
+char(2) collate UTF8_LCASE
+
+
+-- !query
+SELECT typeof(c) FROM (
+ SELECT cast('a' AS VARCHAR(3)) AS c
+ UNION ALL
+ SELECT cast('abcd' AS VARCHAR(8)) AS c
+) t LIMIT 1
+-- !query schema
+struct<typeof(c):string>
+-- !query output
+varchar(8)
+
+
+-- !query
+SELECT typeof(array(cast('a' AS CHAR(2)), cast('bb' AS CHAR(3))))
+-- !query schema
+struct<typeof(array(CAST(a AS CHAR(2)), CAST(bb AS CHAR(3)))):string>
+-- !query output
+array<char(3)>
+
+
+-- !query
+SELECT typeof(struct(cast('a' AS CHAR(2)) AS f))
+-- !query schema
+struct<typeof(struct(CAST(a AS CHAR(2)) AS f)):string>
+-- !query output
+struct<f:char(2)>
+
+
+-- !query
+SELECT typeof(map('k', cast('a' AS VARCHAR(2))))
+-- !query schema
+struct<typeof(map(k, CAST(a AS VARCHAR(2)))):string>
+-- !query output
+map<string,varchar(2)>
+
+
+-- !query
+CREATE TABLE char_varchar_std (c CHAR(5), v VARCHAR(5)) USING parquet
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+INSERT INTO char_varchar_std VALUES ('ab', 'ab')
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT typeof(c), typeof(v) FROM char_varchar_std
+-- !query schema
+struct<typeof(c):string,typeof(v):string>
+-- !query output
+char(5) varchar(5)
+
+
+-- !query
+SELECT concat('[', c, ']'), concat('[', v, ']') FROM char_varchar_std
+-- !query schema
+struct<concat([, c, ]):string,concat([, v, ]):string>
+-- !query output
+[ab ] [ab]
+
+
+-- !query
+SELECT length(c), length(v) FROM char_varchar_std
+-- !query schema
+struct<length(c):int,length(v):int>
+-- !query output
+5 2
+
+
+-- !query
+DROP TABLE char_varchar_std
+-- !query schema
+struct<>
+-- !query output
+
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala
index 8d22575f5d09..34e268d3ba79 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/CharVarcharTestSuite.scala
@@ -17,7 +17,10 @@
package org.apache.spark.sql
-import org.apache.spark.{SparkConf, SparkRuntimeException}
+import scala.util.Try
+
+import org.apache.spark.{SparkConf, SparkException, SparkRuntimeException}
+import org.apache.spark.sql.catalyst.analysis.FunctionRegistry
import org.apache.spark.sql.catalyst.expressions.{Attribute, EqualTo,
GreaterThan, ScalarSubquery, StringRPad}
import org.apache.spark.sql.catalyst.expressions.Cast.toSQLId
import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
@@ -27,6 +30,7 @@ import org.apache.spark.sql.connector.SchemaRequiredDataSource
import org.apache.spark.sql.connector.catalog.{CatalogV2Util,
InMemoryPartitionTableCatalog}
import org.apache.spark.sql.execution.datasources.LogicalRelation
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.functions
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.sources.SimpleInsertSource
import org.apache.spark.sql.test.SharedSparkSession
@@ -854,6 +858,453 @@ class BasicCharVarcharTestSuite extends
SharedSparkSession {
}
}
+ test("SPARK-58797: CAST to CHAR/VARCHAR with standardSemantics") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val charDf = sql("SELECT CAST('ab' AS CHAR(5)) AS c")
+ assert(charDf.schema.head.dataType === CharType(5))
+ checkAnswer(charDf, Row("ab "))
+
+ val varcharDf = sql("SELECT CAST('hello' AS VARCHAR(5)) AS v")
+ assert(varcharDf.schema.head.dataType === VarcharType(5))
+ checkAnswer(varcharDf, Row("hello"))
+
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ sql("SELECT CAST('hello!' AS VARCHAR(5))").collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "5")
+ )
+
+ // Multi-byte characters: length is in characters, not octets.
+ // scalastyle:off nonascii
+ checkAnswer(sql("SELECT CAST('你好' AS VARCHAR(2)) AS v"), Row("你好"))
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ sql("SELECT CAST('你好啊' AS VARCHAR(2))").collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "2")
+ )
+ // scalastyle:on nonascii
+ }
+ }
+
+ test("SPARK-58798: least common type for COALESCE/CASE with CHAR/VARCHAR") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ assert(sql(
+ "SELECT coalesce(cast('hello' AS VARCHAR(5)), cast('world' AS
VARCHAR(10))) AS c")
+ .schema.head.dataType === VarcharType(10))
+ assert(sql(
+ "SELECT coalesce(cast('hello' AS VARCHAR(5)), cast('world!' AS
CHAR(6))) AS c")
+ .schema.head.dataType === VarcharType(6))
+ assert(sql(
+ "SELECT coalesce(cast('hello' AS CHAR(5)), cast('world!' AS CHAR(6)))
AS c")
+ .schema.head.dataType === CharType(6))
+ assert(sql(
+ "SELECT coalesce(cast('hello' AS VARCHAR(5)), 'world') AS c")
+ .schema.head.dataType === StringType)
+ assert(sql(
+ """SELECT CASE WHEN true THEN cast('a' AS CHAR(2))
+ |ELSE cast('bb' AS CHAR(4)) END AS c""".stripMargin)
+ .schema.head.dataType === CharType(4))
+ // LCT(NULL, T) = T
+ assert(sql("SELECT coalesce(null, cast('a' AS CHAR(5))) AS c")
+ .schema.head.dataType === CharType(5))
+ }
+ }
+
+ test("SPARK-58799: transforming string functions return STRING") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ assert(sql("SELECT upper(cast('ab' AS CHAR(2))) AS c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT lower(cast('AB' AS VARCHAR(2))) AS c")
+ .schema.head.dataType === StringType)
+ assert(sql(
+ "SELECT cast('a' AS CHAR(1)) || cast('b' AS VARCHAR(1)) AS c")
+ .schema.head.dataType === StringType)
+ // Pads from CHAR participate in the concatenated value.
+ checkAnswer(
+ sql("SELECT cast('he' AS CHAR(4)) || cast('llo' AS CHAR(3)) AS c"),
+ Row("he llo"))
+ assert(sql("SELECT substr(cast('hello' AS VARCHAR(5)), 1, 2) AS c")
+ .schema.head.dataType === StringType)
+ assert(sql(
+ "SELECT upper(coalesce(cast('a' AS CHAR(2)), cast('b' AS CHAR(4)))) AS
c")
+ .schema.head.dataType === StringType)
+ }
+ }
+
+ test("SPARK-58798: LCT preserves collation on CHAR/VARCHAR") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val c1 = CharType(2, "UTF8_LCASE")
+ val c2 = CharType(4, "UTF8_LCASE")
+ assert(StringHelper.tightestCommonString(c1, c2).contains(CharType(4,
"UTF8_LCASE")))
+ val v1 = VarcharType(3, "UTF8_LCASE")
+ val v2 = VarcharType(5, "UTF8_LCASE")
+ assert(StringHelper.tightestCommonString(v1, v2).contains(VarcharType(5,
"UTF8_LCASE")))
+ assert(StringHelper.tightestCommonString(c1, v2).contains(VarcharType(5,
"UTF8_LCASE")))
+ }
+ }
+
+ test("SPARK-58799: regexp/mask/split return STRING under standardSemantics")
{
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ assert(sql("SELECT regexp_replace(cast('ab' AS CHAR(2)), 'a', 'x') AS c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT regexp_extract(cast('ab' AS VARCHAR(2)), '(a)', 1) AS
c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT split(cast('a,b' AS CHAR(3)), ',') AS c")
+ .schema.head.dataType === ArrayType(StringType, containsNull = false))
+ assert(sql("SELECT mask(cast('ab' AS CHAR(2))) AS c")
+ .schema.head.dataType === StringType)
+ }
+ }
+
+ test("SPARK-58796: preserve vs standardSemantics R1 matrix") {
+ // preserve-only: transforming ops may keep Char/Varchar (leaky
experimental path).
+ withSQLConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true") {
+ assert(sql("SELECT upper(cast('ab' AS CHAR(2))) AS c")
+ .schema.head.dataType === CharType(2))
+ }
+ // standardSemantics: R1 forces STRING even if preserve is also on.
+ withSQLConf(
+ SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> "true",
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ assert(sql("SELECT upper(cast('ab' AS CHAR(2))) AS c")
+ .schema.head.dataType === StringType)
+ }
+ }
+
+ test("SPARK-58797: standardSemantics wins over charVarcharAsString") {
+ withSQLConf(
+ SQLConf.LEGACY_CHAR_VARCHAR_AS_STRING.key -> "true",
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val df = sql("SELECT CAST('ab' AS CHAR(5)) AS c")
+ assert(df.schema.head.dataType === CharType(5))
+ checkAnswer(df, Row("ab "))
+ }
+ }
+
+ test("SPARK-58796: createDataFrame allows CHAR/VARCHAR when
standardSemantics") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val df = spark.range(1).map(_.toString).toDF()
+ val schema = new StructType().add("id", CharType(5))
+ val created = spark.createDataFrame(df.collectAsList(), schema)
+ assert(created.schema.head.dataType === CharType(5))
+ checkAnswer(created, Row("0 "))
+
+ // RowEncoder must retain a declared collation on the constrained type,
not rebuild
+ // CharType(length) / VarcharType(length) with the default collation.
+ val collated = new StructType()
+ .add("c", CharType(5, "UTF8_LCASE"))
+ .add("v", VarcharType(5, "UTF8_LCASE"))
+ val collatedDf = spark.createDataFrame(
+ java.util.Arrays.asList(Row("ab", "cd")), collated)
+ assert(collatedDf.schema("c").dataType === CharType(5, "UTF8_LCASE"))
+ assert(collatedDf.schema("v").dataType === VarcharType(5, "UTF8_LCASE"))
+ checkAnswer(collatedDf, Row("ab ", "cd"))
+ }
+ }
+
+ test("SPARK-58803: Dataset/encoder/UDF CHAR/VARCHAR under
standardSemantics") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ // createDataFrame / RowEncoder write-side: pad CHAR, reject oversize.
+ val charSchema = new StructType().add("c", CharType(3))
+ checkAnswer(
+ spark.createDataFrame(java.util.Arrays.asList(Row("ab")), charSchema),
+ Row("ab "))
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ spark.createDataFrame(java.util.Arrays.asList(Row("abcd")),
charSchema).collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "3"))
+ val varcharSchema = new StructType().add("v", VarcharType(3))
+ checkAnswer(
+ spark.createDataFrame(java.util.Arrays.asList(Row("ab")),
varcharSchema),
+ Row("ab"))
+ // Oversize by trailing blanks only: trim just enough to fit the limit.
+ checkAnswer(
+ spark.createDataFrame(java.util.Arrays.asList(Row("abc ")),
varcharSchema),
+ Row("abc"))
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ spark.createDataFrame(java.util.Arrays.asList(Row("abcd")),
varcharSchema).collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "3"))
+
+ // Explicit Encoders.CHAR / VARCHAR: typed Dataset write-side checks.
+ val charDs = spark.createDataset(Seq("ab"))(Encoders.CHAR(4))
+ assert(charDs.schema.head.dataType === CharType(4))
+ checkAnswer(charDs.toDF(), Row("ab "))
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ spark.createDataset(Seq("abcde"))(Encoders.VARCHAR(3)).collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "3"))
+
+ // UDF register: return type stays CHAR/VARCHAR; write-side pad / length
apply.
+ spark.udf.register("std_char_udf", () => "B", CharType(3))
+ spark.udf.register("std_varchar_udf", (x: String) => x, VarcharType(3))
+ val charUdf = sql("SELECT std_char_udf() AS c")
+ assert(charUdf.schema.head.dataType === CharType(3))
+ checkAnswer(charUdf, Row("B "))
+ val varcharUdf = sql("SELECT std_varchar_udf('ab') AS v")
+ assert(varcharUdf.schema.head.dataType === VarcharType(3))
+ checkAnswer(varcharUdf, Row("ab"))
+ checkError(
+ exception = intercept[SparkException] {
+ sql("SELECT std_varchar_udf('abcd')").collect()
+ }.getCause.asInstanceOf[SparkRuntimeException],
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "3"))
+
+ // Java udf(..., returnType) path and Dataset.encoder from CHAR result
schema.
+ val javaUdf = functions.udf(
+ new org.apache.spark.sql.api.java.UDF0[String] {
+ override def call(): String = "a"
+ },
+ CharType(5))
+ val javaUdfDf = spark.range(1).select(javaUdf().as("c"))
+ assert(javaUdfDf.schema.head.dataType === CharType(5))
+ checkAnswer(javaUdfDf, Row("a "))
+ assert(javaUdfDf.encoder.schema.head.dataType === CharType(5))
+
+ // Dataset.to: CHAR/VARCHAR target schema allowed; Cast applies store
assignment.
+ withTable("std_cv_to") {
+ sql("CREATE TABLE std_cv_to (c CHAR(10), v VARCHAR(255)) USING
parquet")
+ sql("INSERT INTO std_cv_to VALUES ('spark', 'awesome')")
+ val df = sql("SELECT * FROM std_cv_to")
+ assert(df.schema("c").dataType === CharType(10))
+ assert(df.schema("v").dataType === VarcharType(255))
+ val reordered = StructType.fromDDL("v VARCHAR(255), c CHAR(10)")
+ val toDf = df.to(reordered)
+ assert(toDf.schema.map(_.dataType) === Seq(VarcharType(255),
CharType(10)))
+ checkAnswer(toDf, Row("awesome", "spark "))
+ // Narrowing CHAR length is store assignment and must enforce length.
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ df.select($"c").to(new StructType().add("c",
CharType(3))).collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "3"))
+ }
+
+ // DataFrameReader / DataStreamReader user schemas keep CHAR/VARCHAR.
+ val readerSchema = new StructType().add("id", CharType(5))
+ val csvInput = spark.range(1).map(_.toString)
+ val csvDf = spark.read.schema(readerSchema).csv(csvInput)
+ assert(csvDf.schema.head.dataType === CharType(5))
+ checkAnswer(csvDf, Row("0 "))
+ val csvDfDdl = spark.read.schema("id VARCHAR(5)").csv(csvInput)
+ assert(csvDfDdl.schema.head.dataType === VarcharType(5))
+ withTempPath { dir =>
+ spark.range(1).write.save(dir.toString)
+ val streamDf = spark.readStream.schema(readerSchema).load(dir.toString)
+ assert(streamDf.schema.head.dataType === CharType(5))
+ val streamDdl = spark.readStream.schema("id
VARCHAR(5)").load(dir.toString)
+ assert(streamDdl.schema.head.dataType === VarcharType(5))
+ }
+ }
+ }
+
+ test("SPARK-58794: R1 promotion unifies CHAR/VARCHAR with STRING at
plain-string inputs") {
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ withTable("std_promote") {
+ sql("CREATE TABLE std_promote (c CHAR(5), v VARCHAR(5)) USING parquet")
+ sql("INSERT INTO std_promote VALUES ('ab', 'ab')")
+
+ // Expressions requiring all their string inputs to share one type
must accept a
+ // CHAR/VARCHAR argument alongside a STRING one by promoting it to
STRING.
+ Seq(
+ "overlay(c PLACING 'x' FROM 1)" -> "xb ",
+ "overlay(v PLACING 'x' FROM 1)" -> "xb",
+ "string_agg(c, '-')" -> "ab ",
+ "listagg(c, '-')" -> "ab ",
+ "elt(1, c, 'x')" -> "ab ",
+ // right() is RuntimeReplaceable; its literal branches must agree
with the substring
+ // branch, which R1 has already reduced to STRING.
+ "right(c, 2)" -> " ",
+ "left(c, 2)" -> "ab").foreach { case (expr, expected) =>
+ val df = sql(s"SELECT $expr AS r FROM std_promote")
+ assert(df.schema.head.dataType === StringType, s"$expr should return
STRING")
+ checkAnswer(df, Row(expected))
+ }
+
+ // Transforming expressions must not inherit the input's length
constraint: each of these
+ // produces a value whose length differs from the CHAR(5) input.
+ Seq(
+ "reverse(c)" -> " ba",
+ "hex(c)" -> "6162202020",
+ "array_join(array(c, c), '-')" -> "ab -ab ").foreach { case
(expr, expected) =>
+ val df = sql(s"SELECT $expr AS r FROM std_promote")
+ assert(df.schema.head.dataType === StringType, s"$expr should return
STRING")
+ checkAnswer(df, Row(expected))
+ }
+
+ // Promotion must not reach pass-through / LCT sites, which preserve
CHAR/VARCHAR (R2/R3).
+ Seq(
+ "c", "coalesce(c, c)", "case when true then c else c end", "max(c)",
+ "element_at(array(c), 1)", "transform(array(c), x -> x)[0]",
+ "first_value(c) over (order by 1)").foreach { expr =>
+ val df = sql(s"SELECT $expr AS r FROM std_promote")
+ assert(df.schema.head.dataType === CharType(5), s"$expr should stay
CHAR(5)")
+ }
+
+ // reverse() on non-string inputs is unaffected by the R1 change.
+ assert(sql("SELECT reverse(array(1, 2)) AS r").schema.head.dataType ===
+ ArrayType(IntegerType, containsNull = false))
+ }
+ }
+ }
+
+ // Allowlist for the inventory below: R2/R3 pass-through and container cases
that may keep
+ // CHAR(n)/VARCHAR(n): aggregates/ordering that return an input unchanged,
null-handling,
+ // element access, array/map/struct constructors, and collection
rearrangements that keep
+ // element types. Coverage is limited to the seven fixed argumentShapes
templates in the test;
+ // a leak only at another arity or nested shape would not fail here. For
those shapes,
+ // anything not listed must reduce to plain STRING (R1).
+ private val charVarcharPassThroughFunctions = Set(
+ "any_value", "approx_top_k", "approx_top_k_accumulate", "array",
"array_agg", "array_compact",
+ "array_distinct", "array_max", "array_min", "array_repeat", "array_sort",
"arrays_zip",
+ "coalesce", "collect_list", "collect_set", "collect_union", "concat",
"explode",
+ "explode_outer", "first", "first_value", "get", "greatest", "ifnull",
"last", "last_value",
+ "least", "map", "max", "max_by", "measure", "min", "min_by", "mode",
"named_struct", "nullif",
+ "nullifzero", "nvl", "reverse", "shuffle", "sort_array", "struct", "when")
+
+ test("SPARK-58794: inventoried shapes do not leak CHAR/VARCHAR under
standardSemantics") {
+ val argumentShapes = Seq(
+ "%s(c)", "%s(c, c)", "%s(c, 'x')", "%s('x', c)", "%s(c, 1)",
"%s(array(c))",
+ "%s(array(c), '-')")
+
+ withTable("std_inventory") {
+ sql("CREATE TABLE std_inventory (c CHAR(5)) USING parquet")
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ val leaks = FunctionRegistry.functionSet.map(_.funcName).toSeq.sorted
+ .filterNot(charVarcharPassThroughFunctions.contains)
+ .flatMap { name =>
+ argumentShapes.map(_.format(name)).filter { call =>
+ // Most shapes do not typecheck for a given function; those are
simply not evidence.
+ Try(sql(s"SELECT $call AS r FROM
std_inventory").schema.head.dataType)
+ .toOption
+ .exists(CharVarcharUtils.hasCharVarchar)
+ }
+ }
+
+ assert(leaks.isEmpty,
+ "these inventoried calls returned a CHAR/VARCHAR type; either fix
the expression to " +
+ "return plain STRING or add it to charVarcharPassThroughFunctions:
" +
+ leaks.mkString(", "))
+ }
+ }
+ }
+
+ test("SPARK-58802: single-pass resolver agrees with fixed-point under
standardSemantics") {
+ // Dual run defaults to on under tests, but pin it explicitly so this
coverage cannot be
+ // silently lost: the HybridAnalyzer compares output schema and normalized
plan across the
+ // two analyzers and fails with HYBRID_ANALYZER_EXCEPTION on any
divergence. Resolver has no
+ // Char/Varchar-specific logic; it inherits Expression.dataType and shared
TypeCoercion, so
+ // this matrix is the proof that LCT/CAST/R1 stay aligned (D19).
+ withSQLConf(
+ SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true",
+ SQLConf.ANALYZER_DUAL_RUN_LEGACY_AND_SINGLE_PASS_RESOLVER.key ->
"true",
+ SQLConf.ANALYZER_DUAL_RUN_SAMPLE_RATE.key -> "1.0",
+ SQLConf.ANALYZER_SINGLE_PASS_RESOLVER_ENABLED_TENTATIVELY.key ->
"false",
+
SQLConf.ANALYZER_SINGLE_PASS_RESOLVER_EXPOSE_RESOLVER_GUARD_FAILURE.key ->
"true") {
+ // CAST / try_cast introduce the type (R3).
+ assert(sql("SELECT CAST('ab' AS CHAR(5)) AS c").schema.head.dataType ===
CharType(5))
+ assert(sql("SELECT CAST('hello' AS VARCHAR(5)) AS
c").schema.head.dataType ===
+ VarcharType(5))
+ assert(sql("SELECT try_cast('abcdef' AS CHAR(2)) AS
c").schema.head.dataType ===
+ CharType(2))
+ checkAnswer(sql("SELECT try_cast('abcdef' AS VARCHAR(2)) AS c"),
Row(null))
+
+ // Least common type (R2): COALESCE / CASE / NULL / CHAR+VARCHAR /
CHAR+STRING.
+ assert(sql(
+ "SELECT coalesce(CAST('a' AS VARCHAR(3)), CAST('bb' AS VARCHAR(7))) AS
c")
+ .schema.head.dataType === VarcharType(7))
+ assert(sql(
+ "SELECT coalesce(CAST('a' AS CHAR(2)), CAST('bb' AS VARCHAR(4))) AS c")
+ .schema.head.dataType === VarcharType(4))
+ assert(sql(
+ "SELECT coalesce(CAST('a' AS CHAR(2)), 'bb') AS c")
+ .schema.head.dataType === StringType)
+ assert(sql(
+ "SELECT coalesce(CAST('a' AS CHAR(5)), CAST(NULL AS CHAR(5))) AS c")
+ .schema.head.dataType === CharType(5))
+ assert(sql(
+ "SELECT CASE WHEN true THEN CAST('a' AS CHAR(2)) ELSE CAST('bb' AS
CHAR(4)) END AS c")
+ .schema.head.dataType === CharType(4))
+ assert(sql(
+ "SELECT CASE WHEN false THEN CAST('a' AS VARCHAR(2)) ELSE CAST('bb' AS
CHAR(4)) END AS c")
+ .schema.head.dataType === VarcharType(4))
+
+ // IN-list common type (side condition uses LCT; result is boolean).
+ checkAnswer(
+ sql("SELECT CAST('a' AS CHAR(2)) IN (CAST('a ' AS CHAR(2)), CAST('bbb'
AS VARCHAR(3)))"),
+ Row(true))
+
+ // Transforming operators return STRING (R1).
+ assert(sql("SELECT upper(CAST('ab' AS CHAR(2))) AS
c").schema.head.dataType === StringType)
+ assert(sql("SELECT lower(CAST('AB' AS VARCHAR(2))) AS
c").schema.head.dataType ===
+ StringType)
+ assert(sql("SELECT CAST('a' AS CHAR(1)) || CAST('b' AS VARCHAR(1)) AS c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT concat(CAST('a' AS CHAR(2)), CAST('b' AS CHAR(3))) AS
c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT substr(CAST('hello' AS VARCHAR(5)), 1, 2) AS c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT trim(CAST('ab ' AS CHAR(4))) AS
c").schema.head.dataType === StringType)
+ assert(sql("SELECT regexp_replace(CAST('ab' AS CHAR(2)), 'a', 'x') AS c")
+ .schema.head.dataType === StringType)
+ assert(sql("SELECT mask(CAST('ab' AS CHAR(2))) AS
c").schema.head.dataType === StringType)
+ assert(sql("SELECT split(CAST('a,b' AS CHAR(3)), ',') AS
c").schema.head.dataType ===
+ ArrayType(StringType, containsNull = false))
+ // R1 after LCT: coalesce stays CHAR, upper widens to STRING.
+ assert(sql(
+ "SELECT upper(coalesce(CAST('a' AS CHAR(2)), CAST('b' AS CHAR(4)))) AS
c")
+ .schema.head.dataType === StringType)
+
+ // Set-operation LCT.
+ val union = sql(
+ """SELECT CAST('a' AS VARCHAR(3)) AS c
+ |UNION ALL
+ |SELECT CAST('abcd' AS VARCHAR(8)) AS c""".stripMargin)
+ assert(union.schema.head.dataType === VarcharType(8))
+ checkAnswer(union, Seq(Row("a"), Row("abcd")))
+
+ val intersect = sql(
+ """SELECT CAST('ab' AS CHAR(2)) AS c
+ |INTERSECT
+ |SELECT CAST('ab' AS CHAR(4)) AS c""".stripMargin)
+ assert(intersect.schema.head.dataType === CharType(4))
+ checkAnswer(intersect, Seq(Row("ab ")))
+
+ // Nested types keep CHAR/VARCHAR through analysis.
+ assert(sql("SELECT array(CAST('a' AS CHAR(2)), CAST('bb' AS CHAR(3))) AS
c")
+ .schema.head.dataType === ArrayType(CharType(3), containsNull = false))
+ assert(sql("SELECT struct(CAST('a' AS CHAR(2)) AS f) AS c")
+ .schema.head.dataType ===
+ StructType(Seq(StructField("f", CharType(2), nullable = false))))
+
+ // Collated CAST keeps the declared collation under both analyzers.
+ assert(sql("SELECT CAST('ab' AS CHAR(2) COLLATE UTF8_LCASE) AS c")
+ .schema.head.dataType === CharType(2, "UTF8_LCASE"))
+
+ // Bare column references keep the declared type (R3) through dual-run
analysis.
+ withTable("char_varchar_dual_run") {
+ sql("CREATE TABLE char_varchar_dual_run (c CHAR(5), v VARCHAR(5))
USING parquet")
+ sql("INSERT INTO char_varchar_dual_run VALUES ('ab', 'ab')")
+ val df = sql("SELECT c, v FROM char_varchar_dual_run")
+ assert(df.schema("c").dataType === CharType(5))
+ assert(df.schema("v").dataType === VarcharType(5))
+ checkAnswer(df, Row("ab ", "ab"))
+ }
+ }
+ }
+
test("invalidate char/varchar in functions") {
checkError(
exception = intercept[AnalysisException] {
@@ -1040,6 +1491,41 @@ class FileSourceCharVarcharTestSuite extends
CharVarcharTestSuite with SharedSpa
}
}
+ test("SPARK-58801: standardSemantics scan pads CHAR and errors on oversize")
{
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ withTempPath { dir =>
+ withTable("t") {
+ sql("SELECT '12' as col").write.format(format).save(dir.toString)
+ sql(s"CREATE TABLE t (col CHAR(3)) using $format LOCATION '$dir'")
+ checkAnswer(sql("SELECT * FROM t"), Row("12 "))
+ }
+ }
+ Seq("CHAR", "VARCHAR").foreach { typ =>
+ withTempPath { dir =>
+ withTable("t") {
+ sql("SELECT '123456' as
col").write.format(format).save(dir.toString)
+ sql(s"CREATE TABLE t (col $typ(2)) using $format LOCATION '$dir'")
+ checkError(
+ exception = intercept[SparkRuntimeException] {
+ sql("SELECT * FROM t").collect()
+ },
+ condition = "EXCEED_LIMIT_LENGTH",
+ parameters = Map("limit" -> "2")
+ )
+ }
+ }
+ }
+ // An oversized value consisting only of trailing blanks is trimmed
successfully.
+ withTempPath { dir =>
+ withTable("t") {
+ sql("SELECT '12 ' as col").write.format(format).save(dir.toString)
+ sql(s"CREATE TABLE t (col VARCHAR(2)) using $format LOCATION '$dir'")
+ checkAnswer(sql("SELECT * FROM t"), Row("12"))
+ }
+ }
+ }
+ }
+
test("alter table set location w/ fit length values") {
withTempPath { dir =>
withTable("t") {
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/UDFSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/UDFSuite.scala
index 4e39f01bf537..21384d7c4b91 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/UDFSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/UDFSuite.scala
@@ -1252,6 +1252,21 @@ class UDFSuite extends SharedSparkSession {
parameters = Map("dataType" -> s"\"${dt.sql}\"")
)
}
+ withSQLConf(SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+ Seq(CharType(5), VarcharType(5)).foreach { dt =>
+ val f = udf(
+ new UDF0[String] {
+ override def call(): String = "a"
+ },
+ dt
+ )
+ val df = spark.range(1).select(f().as("c"))
+ assert(df.schema.head.dataType === dt)
+ assert(df.encoder.schema.head.dataType === dt)
+ val expected = if (dt.isInstanceOf[CharType]) "a " else "a"
+ checkAnswer(df, Row(expected))
+ }
+ }
}
test("SPARK-47927: ScalaUDF null handling") {
diff --git
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtils.scala
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtils.scala
index df31ca311d46..c4a7d1727bdc 100644
---
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtils.scala
+++
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtils.scala
@@ -147,8 +147,12 @@ object RowSetUtils {
// types that reach this branch do not use the `nested` flag in
`toHiveString`. Now,
// Geospatial types use it for wrapping EWKT in quotes when nested
= true, so we need
// to set `nested` here to false to avoid spurious quotes for
standalone geo values.
+ // String types need the same treatment: the fast path above
matches only the
+ // default-collation StringType singleton, so CHAR/VARCHAR and
collated strings land
+ // here and would otherwise be rendered as "value" instead of
value.
val nested = typ match {
case _: GeometryType | _: GeographyType => false
+ case _: StringType => false
case _ => true
}
toHiveString((row.get(ordinal), typ), nested, timeFormatters,
binaryFormatter)
diff --git
a/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtilsSuite.scala
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtilsSuite.scala
new file mode 100644
index 000000000000..b2f0d462a3a7
--- /dev/null
+++
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/RowSetUtilsSuite.scala
@@ -0,0 +1,51 @@
+/*
+ * 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.spark.sql.hive.thriftserver
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.hive.service.rpc.thrift.TProtocolVersion
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.{CharType, DataType, StringType, VarcharType}
+
+class RowSetUtilsSuite extends SparkFunSuite {
+
+ private def stringValues(value: String, dataType: DataType): Seq[String] = {
+ val rowSet = RowSetUtils.toTRowSet(
+ 0,
+ Seq(Row(value)),
+ Array(dataType),
+ TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V8)
+ rowSet.getColumns.asScala.head.getStringVal.getValues.asScala.toSeq
+ }
+
+ // Only the default-collation StringType singleton takes the fast path in
toTColumn. CHAR,
+ // VARCHAR and collated strings fall through to the generic branch, which
renders values with
+ // toHiveString and used to quote them there.
+ test("SPARK-58794: CHAR, VARCHAR and collated string values are not quoted")
{
+ Seq(
+ CharType(4),
+ VarcharType(4),
+ StringType("UTF8_LCASE"),
+ StringType).foreach { dataType =>
+ assert(stringValues("ab", dataType) === Seq("ab"), s"$dataType was
rendered with quotes")
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]