@@ -21,20 +21,25 @@ import java.util.{HashMap => JHashMap}
2121
2222import scala .jdk .CollectionConverters ._
2323
24+ import io .delta .kernel .internal .SnapshotImpl
2425import io .delta .spark .internal .v2 .catalog .SparkTable
26+ import io .delta .spark .internal .v2 .snapshot .PathBasedSnapshotManager
2527import io .delta .storage .commit .uccommitcoordinator .UCCommitCoordinatorClient
28+ import org .apache .spark .sql .delta .DeltaLog
2629import org .apache .spark .sql .catalyst .TableIdentifier
2730import org .apache .spark .sql .catalyst .plans .logical .LogicalPlan
2831import org .apache .spark .sql .catalyst .catalog .CatalogTable
2932import org .apache .spark .sql .catalyst .catalog .{CatalogStorageFormat , CatalogTableType }
3033import org .apache .spark .sql .catalyst .streaming .StreamingRelationV2
34+ import org .apache .spark .sql .catalyst .types .DataTypeUtils .toAttributes
35+ import org .apache .spark .sql .delta .DeltaOptions
3136import org .apache .spark .sql .delta .Relocated .StreamingRelation
32- import org .apache .spark .sql .delta .sources .DeltaSQLConf
37+ import org .apache .spark .sql .delta .sources .{ DeltaSourceMetadataTrackingLog , DeltaSQLConf , PersistedMetadata }
3338import org .apache .spark .sql .delta .test .DeltaSQLCommandTest
3439import org .apache .spark .sql .execution .datasources .v2 .DataSourceV2Relation
3540import org .apache .spark .sql .execution .datasources .DataSource
3641import org .apache .spark .sql .connector .catalog .Identifier
37- import org .apache .spark .sql .types .StructType
42+ import org .apache .spark .sql .types .{ StringType , StructType }
3843import org .apache .spark .sql .util .CaseInsensitiveStringMap
3944
4045class ApplyV2StreamingSuite extends DeltaSQLCommandTest {
@@ -140,4 +145,223 @@ class ApplyV2StreamingSuite extends DeltaSQLCommandTest {
140145 }
141146 }
142147 }
148+
149+ // ---------------------------------------------------------------------------
150+ // Rebuild StreamingRelationV2 if provided schema tracking log provided
151+ // ---------------------------------------------------------------------------
152+
153+ /** The data-schema seeded into the tracking log by [[seedSchemaLogWithExtraColumn ]]. */
154+ private val seededFieldNames : Seq [String ] = Seq (" id" , " extra" )
155+
156+ private def buildStreamingRelationV2 (
157+ table : SparkTable , extraOptions : Map [String , String ]): StreamingRelationV2 = {
158+ StreamingRelationV2 (
159+ source = None ,
160+ sourceName = " delta" ,
161+ table = table,
162+ extraOptions = new CaseInsensitiveStringMap (extraOptions.asJava),
163+ output = toAttributes(table.schema),
164+ catalog = None ,
165+ identifier = Some (table.getIdentifier),
166+ v1Relation = None )
167+ }
168+
169+ /**
170+ * Pre-seed the schema-tracking log at `schemaLogPath` with a 2-column schema
171+ * (`id LONG, extra STRING`) that differs from the underlying snapshot's 1-column schema
172+ */
173+ private def seedSchemaLogWithExtraColumn (tablePath : String , schemaLogPath : String ): Unit = {
174+ val deltaLog = DeltaLog .forTable(spark, tablePath)
175+ val snapshotManager =
176+ new PathBasedSnapshotManager (tablePath, deltaLog.newDeltaHadoopConf())
177+ val tableId =
178+ snapshotManager.loadLatestSnapshot.asInstanceOf [SnapshotImpl ].getMetadata.getId
179+ val trackingLog = DeltaSourceMetadataTrackingLog .create(
180+ spark, schemaLogPath, tableId, tablePath, parameters = Map .empty[String , String ])
181+ val customSchemaJson =
182+ """ {"type":"struct","fields":[
183+ |{"name":"id","type":"long","nullable":true,"metadata":{}},
184+ |{"name":"extra","type":"string","nullable":true,"metadata":{}}]}""" .stripMargin
185+ val emptyPartitionJson = """ {"type":"struct","fields":[]}"""
186+ val seededEntry = PersistedMetadata (
187+ tableId,
188+ deltaCommitVersion = 0L ,
189+ dataSchemaJson = customSchemaJson,
190+ partitionSchemaJson = emptyPartitionJson,
191+ sourceMetadataPath = tablePath + " /_delta_log/_streaming_metadata" )
192+ trackingLog.writeNewMetadata(seededEntry, replaceCurrent = false )
193+ }
194+
195+ /** Asserts the table's schema matches the entry written by [[seedSchemaLogWithExtraColumn ]]. */
196+ private def assertSchemaMatchesSeededLogEntry (table : SparkTable ): Unit = {
197+ assert(table.schema.fieldNames.toSeq == seededFieldNames)
198+ assert(table.schema.fields(1 ).dataType == StringType )
199+ }
200+
201+ /**
202+ * Build a catalog-backed SparkTable rooted at `tableLocationUri`. Mirrors the common production
203+ * path through DeltaCatalog and is the default for tests that do not specifically distinguish
204+ * between path-based and catalog-based construction.
205+ */
206+ private def buildCatalogBasedSparkTable (
207+ tableLocationUri : URI , options : JHashMap [String , String ]): SparkTable = {
208+ val catalogTable = createCatalogTable(tableLocationUri, ucManaged = false )
209+ val identifier = Identifier .of(
210+ catalogTable.identifier.database.toArray, catalogTable.identifier.table)
211+ new SparkTable (identifier, catalogTable, options)
212+ }
213+
214+ test(" schema-tracking rebuild: path-based SparkTable picks up the persisted schema" ) {
215+ withTempDir { tableDir =>
216+ withTempDir { schemaLogDir =>
217+ val tablePath = tableDir.getCanonicalPath
218+ createDeltaTable(tablePath) // snapshot schema: id BIGINT
219+ val schemaLogPath = schemaLogDir.getCanonicalPath
220+ seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
221+
222+ val identifier = Identifier .of(Array (" default" ), " tbl" )
223+ val table = new SparkTable (identifier, tablePath)
224+ assert(! table.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
225+
226+ val plan = buildStreamingRelationV2(
227+ table, Map (DeltaOptions .SCHEMA_TRACKING_LOCATION -> schemaLogPath))
228+ val result = applyRule(plan).asInstanceOf [StreamingRelationV2 ]
229+ val rebuiltTable = result.table.asInstanceOf [SparkTable ]
230+
231+ assert(rebuiltTable ne table, " rebuild should produce a new SparkTable" )
232+ assert(rebuiltTable.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
233+ assert(rebuiltTable.getOptions.get(DeltaOptions .SCHEMA_TRACKING_LOCATION ) ==
234+ schemaLogPath)
235+ assert(! rebuiltTable.getCatalogTable.isPresent,
236+ " path branch should not have catalogTable" )
237+ // Rebuilt schema is driven by the persisted entry, not the snapshot.
238+ assertSchemaMatchesSeededLogEntry(rebuiltTable)
239+ // And the rule's output is re-derived from that rebuilt schema.
240+ assert(result.output.map(_.name) == seededFieldNames)
241+
242+ // Idempotent: re-applying the rule does not rebuild a second time.
243+ val reappliedResult = applyRule(result).asInstanceOf [StreamingRelationV2 ]
244+ assert(reappliedResult.table eq rebuiltTable, " re-applying rule should not rebuild" )
245+ }
246+ }
247+ }
248+
249+ test(" schema-tracking rebuild: catalog-based SparkTable picks up the persisted schema and " +
250+ " keeps its CatalogTable" ) {
251+ withTempDir { tableDir =>
252+ withTempDir { schemaLogDir =>
253+ val tablePath = tableDir.getCanonicalPath
254+ createDeltaTable(tablePath)
255+ val schemaLogPath = schemaLogDir.getCanonicalPath
256+ seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
257+
258+ val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap [String , String ]())
259+ assert(! table.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
260+ assert(table.getCatalogTable.isPresent)
261+
262+ val plan = buildStreamingRelationV2(
263+ table, Map (DeltaOptions .SCHEMA_TRACKING_LOCATION -> schemaLogPath))
264+ val result = applyRule(plan).asInstanceOf [StreamingRelationV2 ]
265+ val rebuiltTable = result.table.asInstanceOf [SparkTable ]
266+
267+ assert(rebuiltTable.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
268+ assert(rebuiltTable.getCatalogTable.isPresent,
269+ " catalog branch should keep CatalogTable" )
270+ assertSchemaMatchesSeededLogEntry(rebuiltTable)
271+ }
272+ }
273+ }
274+
275+ test(" schema-tracking rebuild: triggered by SCHEMA_TRACKING_LOCATION_ALIAS option key" ) {
276+ withTempDir { tableDir =>
277+ withTempDir { schemaLogDir =>
278+ val tablePath = tableDir.getCanonicalPath
279+ createDeltaTable(tablePath)
280+ val schemaLogPath = schemaLogDir.getCanonicalPath
281+ seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
282+
283+ val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap [String , String ]())
284+
285+ val plan = buildStreamingRelationV2(
286+ table, Map (DeltaOptions .SCHEMA_TRACKING_LOCATION_ALIAS -> schemaLogPath))
287+ val result = applyRule(plan).asInstanceOf [StreamingRelationV2 ]
288+ val rebuiltTable = result.table.asInstanceOf [SparkTable ]
289+
290+ assert(rebuiltTable.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION_ALIAS ))
291+ assert(rebuiltTable.getOptions.get(DeltaOptions .SCHEMA_TRACKING_LOCATION_ALIAS ) ==
292+ schemaLogPath)
293+ assertSchemaMatchesSeededLogEntry(rebuiltTable)
294+ }
295+ }
296+ }
297+
298+ test(" schema-tracking rebuild: skipped when extraOptions has no schema-tracking option" ) {
299+ withTempDir { tableDir =>
300+ val tablePath = tableDir.getCanonicalPath
301+ createDeltaTable(tablePath)
302+ val table = buildCatalogBasedSparkTable(tableDir.toURI, new JHashMap [String , String ]())
303+
304+ val plan = buildStreamingRelationV2(table, Map .empty)
305+ val result = applyRule(plan)
306+ assert(result eq plan, " no rebuild expected when schema-tracking option not present" )
307+ }
308+ }
309+
310+ test(" schema-tracking rebuild: skipped when SparkTable already carries the " +
311+ " schema-tracking option" ) {
312+ withTempDir { tableDir =>
313+ withTempDir { schemaLogDir =>
314+ val tablePath = tableDir.getCanonicalPath
315+ createDeltaTable(tablePath)
316+ val schemaLogPath = schemaLogDir.getCanonicalPath
317+ val tableOptions = new JHashMap [String , String ]()
318+ tableOptions.put(DeltaOptions .SCHEMA_TRACKING_LOCATION , schemaLogPath)
319+ val table = buildCatalogBasedSparkTable(tableDir.toURI, tableOptions)
320+ assert(table.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
321+
322+ val plan = buildStreamingRelationV2(
323+ table, Map (DeltaOptions .SCHEMA_TRACKING_LOCATION -> schemaLogPath))
324+ val result = applyRule(plan)
325+ assert(result eq plan, " no rebuild expected when table already carries the option" )
326+ }
327+ }
328+ }
329+
330+ test(" schema-tracking via V1 StreamingRelation: option propagates through V1 -> V2 conversion" ) {
331+ // Counterpart to the V2 rebuild tests above: those start from StreamingRelationV2 and exercise
332+ // the rebuild branch. This test starts from a V1 StreamingRelation carrying the schema-tracking
333+ // option in dataSource.options, and verifies the V1 -> V2 conversion branch hands the option to
334+ // the new SparkTable so its schema is driven by the persisted log entry.
335+ withTempDir { tableDir =>
336+ withTempDir { schemaLogDir =>
337+ val tablePath = tableDir.getCanonicalPath
338+ createDeltaTable(tablePath)
339+ val schemaLogPath = schemaLogDir.getCanonicalPath
340+ seedSchemaLogWithExtraColumn(tablePath, schemaLogPath)
341+
342+ val catalogTable = createCatalogTable(tableDir.toURI, ucManaged = false )
343+ val dataSource = DataSource (
344+ sparkSession = spark,
345+ userSpecifiedSchema = None ,
346+ className = " delta" ,
347+ options = Map (
348+ " path" -> tablePath,
349+ DeltaOptions .SCHEMA_TRACKING_LOCATION -> schemaLogPath),
350+ catalogTable = Some (catalogTable))
351+ val plan = StreamingRelation (dataSource)
352+
353+ // STRICT mode forces V1 -> V2 conversion in ApplyV2Streaming.
354+ withSQLConf(DeltaSQLConf .V2_ENABLE_MODE .key -> " STRICT" ) {
355+ val result = applyRule(plan).asInstanceOf [StreamingRelationV2 ]
356+ val convertedTable = result.table.asInstanceOf [SparkTable ]
357+ assert(convertedTable.getOptions.containsKey(DeltaOptions .SCHEMA_TRACKING_LOCATION ))
358+ assert(convertedTable.getOptions.get(DeltaOptions .SCHEMA_TRACKING_LOCATION ) ==
359+ schemaLogPath)
360+ // Schema is driven by the seeded log entry, not the underlying snapshot.
361+ assertSchemaMatchesSeededLogEntry(convertedTable)
362+ assert(result.output.map(_.name) == seededFieldNames)
363+ }
364+ }
365+ }
366+ }
143367}
0 commit comments