Skip to content

Commit c6f4ed9

Browse files
committed
validation
1 parent 267e64e commit c6f4ed9

3 files changed

Lines changed: 332 additions & 10 deletions

File tree

common/utils/src/main/resources/error/error-conditions.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@
203203
],
204204
"sqlState" : "42703"
205205
},
206+
"AUTOCDC_RESERVED_COLUMN_NAME_CONFLICT" : {
207+
"message" : [
208+
"Using <caseSensitivity> column name comparison, the column `<columnName>` in the <schemaName> schema conflicts with the reserved AutoCDC column name `<reservedColumnName>`. Rename or remove the column."
209+
],
210+
"sqlState" : "42710"
211+
},
206212
"AVRO_CANNOT_WRITE_NULL_FIELD" : {
207213
"message" : [
208214
"Cannot write null value for field <name> defined as non-null Avro data type <dataType>.",

sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd1BatchProcessor.scala

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,25 @@
1717

1818
package org.apache.spark.sql.pipelines.autocdc
1919

20-
import org.apache.spark.sql.{functions => F}
20+
import org.apache.spark.SparkException
21+
import org.apache.spark.sql.{functions => F, AnalysisException}
22+
import org.apache.spark.sql.Column
2123
import org.apache.spark.sql.catalyst.util.QuotingUtils
2224
import org.apache.spark.sql.classic.DataFrame
25+
import org.apache.spark.sql.types.{DataType, StructField, StructType}
2326
import org.apache.spark.util.ArrayImplicits._
2427

2528
/**
2629
* Per-microbatch processor for SCD Type 1 AutoCDC flows, complying to the specified [[changeArgs]]
2730
* configuration.
31+
*
32+
* @param changeArgs The CDC flow configuration.
33+
* @param resolvedSequencingType The post-analysis [[DataType]] of the sequencing column, derived
34+
* from the flow's resolved DataFrame at flow setup time.
2835
*/
29-
case class Scd1BatchProcessor(changeArgs: ChangeArgs) {
36+
case class Scd1BatchProcessor(
37+
changeArgs: ChangeArgs,
38+
resolvedSequencingType: DataType) {
3039

3140
/**
3241
* Deduplicate the incoming CDC microbatch by key, keeping the most recent event per key
@@ -56,4 +65,100 @@ case class Scd1BatchProcessor(changeArgs: ChangeArgs) {
5665
)
5766
.select(F.col(s"$winningRowCol.*"))
5867
}
68+
69+
/**
70+
* Project the CDC metadata column onto the microbatch.
71+
*/
72+
def extendMicrobatchRowsWithCdcMetadata(microbatchDf: DataFrame): DataFrame = {
73+
// Proactively validate the reserved CDC metadata column does not exist in the microbatch.
74+
validateCdcMetadataColumnNotPresent(microbatchDf)
75+
76+
val rowDeleteSequence: Column = changeArgs.deleteCondition match {
77+
case Some(deleteCondition) =>
78+
F.when(deleteCondition, changeArgs.sequencing).otherwise(F.lit(null))
79+
case None =>
80+
F.lit(null)
81+
}
82+
83+
val rowUpsertSequence: Column =
84+
// A row that is not a delete must be an upsert, these are mutually exclusive and a complete
85+
// set of CDC event types.
86+
F.when(rowDeleteSequence.isNull, changeArgs.sequencing).otherwise(F.lit(null))
87+
88+
microbatchDf.withColumn(
89+
Scd1BatchProcessor.cdcMetadataColName,
90+
Scd1BatchProcessor.constructCdcMetadataCol(
91+
deleteSequence = rowDeleteSequence,
92+
upsertSequence = rowUpsertSequence,
93+
sequencingType = resolvedSequencingType
94+
)
95+
)
96+
}
97+
98+
private def validateCdcMetadataColumnNotPresent(microbatchDf: DataFrame): Unit = {
99+
val ignoreColumnNameCase =
100+
!microbatchDf.sparkSession.sessionState.conf.caseSensitiveAnalysis
101+
102+
microbatchDf.schema.fieldNames
103+
.find { fieldName =>
104+
if (ignoreColumnNameCase) {
105+
fieldName.equalsIgnoreCase(Scd1BatchProcessor.cdcMetadataColName)
106+
} else {
107+
fieldName.equals(Scd1BatchProcessor.cdcMetadataColName)
108+
}
109+
}
110+
.foreach { conflictingColumnName =>
111+
throw new AnalysisException(
112+
errorClass = "AUTOCDC_RESERVED_COLUMN_NAME_CONFLICT",
113+
messageParameters = Map(
114+
"caseSensitivity" -> CaseSensitivityLabels.of(ignoreColumnNameCase),
115+
"columnName" -> conflictingColumnName,
116+
"schemaName" -> "microbatch",
117+
"reservedColumnName" -> Scd1BatchProcessor.cdcMetadataColName
118+
)
119+
)
120+
}
121+
}
122+
}
123+
124+
object Scd1BatchProcessor {
125+
private[autocdc] val cdcMetadataColName: String = "_cdc_metadata"
126+
127+
private[autocdc] val cdcDeleteSequenceFieldName: String = "deleteSequence"
128+
private[autocdc] val cdcUpsertSequenceFieldName: String = "upsertSequence"
129+
130+
/**
131+
* Schema of the CDC metadata struct column for SCD1.
132+
*/
133+
private def cdcMetadataColSchema(sequencingType: DataType): StructType =
134+
StructType(
135+
Seq(
136+
// The sequencing of the event if it represents a delete, null otherwise.
137+
StructField(cdcDeleteSequenceFieldName, sequencingType, nullable = true),
138+
// The sequencing of the event if it represents an upsert, null otherwise.
139+
StructField(cdcUpsertSequenceFieldName, sequencingType, nullable = true)
140+
)
141+
)
142+
143+
/**
144+
* Construct the CDC metadata struct column for SCD1, following the exact schema and field
145+
* ordering defined by [[cdcMetadataColSchema]].
146+
*/
147+
private[autocdc] def constructCdcMetadataCol(
148+
deleteSequence: Column,
149+
upsertSequence: Column,
150+
sequencingType: DataType): Column = {
151+
val cdcMetadataFieldsInOrder = cdcMetadataColSchema(sequencingType).fields.map { field =>
152+
val value = field.name match {
153+
case `cdcDeleteSequenceFieldName` => deleteSequence
154+
case `cdcUpsertSequenceFieldName` => upsertSequence
155+
case other =>
156+
throw SparkException.internalError(
157+
s"Unable to construct SCD1 CDC metadata column due to unknown `${other}` field."
158+
)
159+
}
160+
value.cast(field.dataType).as(field.name)
161+
}
162+
F.struct(cdcMetadataFieldsInOrder.toImmutableArraySeq: _*)
163+
}
59164
}

0 commit comments

Comments
 (0)