# ETL Pipeline Phase 2 Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Extend ETL pipeline with column mapping, schema support, timeouts, date validation, ordinal mapping, and collision detection. **Architecture:** Enhance existing transformer/destination infrastructure with MapOrdinal interface method, QUOTENAME-based SQL generation, and intersection-based column mapping. **Tech Stack:** C#/.NET 10, SqlBulkCopy, Dapper, xUnit --- ## Task 1: Add MapOrdinal to IDataTransformer Interface **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/DataTransformerBaseTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public void MapOrdinal_DefaultImplementation_ReturnsOrdinalUnchanged() { // Arrange var transformer = new PassThroughTransformer(); var reader = CreateMockReader(new[] { "A", "B", "C" }); // Act var result = transformer.MapOrdinal(1, reader); // Assert Assert.Equal(1, result); } private class PassThroughTransformer : DataTransformerBase { public override string TransformerName => "PassThrough"; } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "MapOrdinal_DefaultImplementation" --no-build` Expected: FAIL - MapOrdinal method doesn't exist **Step 3: Add MapOrdinal to IDataTransformer interface** ```csharp public interface IDataTransformer { IDataReader Transform(IDataReader source); string TransformerName { get; } /// /// Maps a transformed ordinal to the source ordinal. /// Returns -1 for computed columns that have no source ordinal. /// int MapOrdinal(int transformedOrdinal, IDataReader source); } ``` **Step 4: Add default implementation to DataTransformerBase** ```csharp public virtual int MapOrdinal(int transformedOrdinal, IDataReader source) => transformedOrdinal; ``` **Step 5: Run test to verify it passes** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "MapOrdinal_DefaultImplementation" --no-build` Expected: PASS **Step 6: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/DataTransformerBaseTests.cs git commit -m "feat(etl): add MapOrdinal to IDataTransformer interface" ``` --- ## Task 2: Add Binary Method Overrides to DataTransformerBase **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs` - Modify: `NEW/src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs` **Step 1: Write the failing test for GetBytes with computed column** ```csharp [Fact] public void GetBytes_ComputedColumn_ThrowsNotSupportedException() { // Arrange - transformer that returns -1 for ordinal 0 (computed) var transformer = new ComputedColumnTransformer(); var source = CreateMockReader(new[] { "A", "B" }); var reader = new TransformingDataReader(source, transformer); transformer.Initialize(source); // Act & Assert Assert.Throws(() => reader.GetBytes(0, 0, null, 0, 0)); } private class ComputedColumnTransformer : DataTransformerBase { public override string TransformerName => "Computed"; public override int MapOrdinal(int ordinal, IDataReader source) => ordinal == 0 ? -1 : ordinal; } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "GetBytes_ComputedColumn" --no-build` Expected: FAIL - doesn't throw **Step 3: Add virtual methods to DataTransformerBase** ```csharp public virtual long GetBytes(int ordinal, long fieldOffset, byte[]? buffer, int bufferOffset, int length, IDataReader source) { var sourceOrdinal = MapOrdinal(ordinal, source); if (sourceOrdinal < 0) throw new NotSupportedException( $"GetBytes not supported for computed column at ordinal {ordinal}."); return source.GetBytes(sourceOrdinal, fieldOffset, buffer, bufferOffset, length); } public virtual long GetChars(int ordinal, long fieldOffset, char[]? buffer, int bufferOffset, int length, IDataReader source) { var sourceOrdinal = MapOrdinal(ordinal, source); if (sourceOrdinal < 0) throw new NotSupportedException( $"GetChars not supported for computed column at ordinal {ordinal}."); return source.GetChars(sourceOrdinal, fieldOffset, buffer, bufferOffset, length); } public virtual IDataReader GetData(int ordinal, IDataReader source) { var sourceOrdinal = MapOrdinal(ordinal, source); if (sourceOrdinal < 0) throw new NotSupportedException( $"GetData not supported for computed column at ordinal {ordinal}."); return source.GetData(sourceOrdinal); } public virtual string GetDataTypeName(int ordinal, IDataReader source) { var sourceOrdinal = MapOrdinal(ordinal, source); if (sourceOrdinal < 0) throw new NotSupportedException( $"GetDataTypeName not supported for computed column at ordinal {ordinal}."); return source.GetDataTypeName(sourceOrdinal); } ``` **Step 4: Update TransformingDataReader to delegate** ```csharp public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) => _transformer.GetBytes(i, fieldOffset, buffer, bufferoffset, length, _source); public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) => _transformer.GetChars(i, fieldoffset, buffer, bufferoffset, length, _source); public IDataReader GetData(int i) => _transformer.GetData(i, _source); public string GetDataTypeName(int i) => _transformer.GetDataTypeName(i, _source); ``` **Step 5: Run test to verify it passes** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "GetBytes_ComputedColumn" --no-build` Expected: PASS **Step 6: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs git commit -m "feat(etl): add binary method overrides to DataTransformerBase" ``` --- ## Task 3: Add MapOrdinal Override to ColumnDropTransformer **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public void MapOrdinal_DroppedColumn_MapsCorrectly() { // Arrange - drop column B (ordinal 1), so C becomes ordinal 1 var transformer = new ColumnDropTransformer("B"); var source = CreateMockReader(new[] { "A", "B", "C" }); transformer.Transform(source); // Act - transformed ordinal 1 should map to source ordinal 2 (C) var result = transformer.MapOrdinal(1, source); // Assert Assert.Equal(2, result); } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "MapOrdinal_DroppedColumn" --no-build` Expected: FAIL - returns 1 instead of 2 **Step 3: Add MapOrdinal override to ColumnDropTransformer** ```csharp public override int MapOrdinal(int transformedOrdinal, IDataReader source) { EnsureInitialized(source); return _ordinalMap![transformedOrdinal]; } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "MapOrdinal_DroppedColumn" --no-build` Expected: PASS **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs git commit -m "feat(etl): add MapOrdinal override to ColumnDropTransformer" ``` --- ## Task 4: Add MapOrdinal and Sentinel to JdeDateTransformer **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs` **Step 1: Write the failing test for MapOrdinal** ```csharp [Fact] public void MapOrdinal_DateOutputColumn_ReturnsNegativeOne() { // Arrange var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); var source = CreateMockReader(new[] { "UPMJ", "TDAY", "Other" }); transformer.Transform(source); // Act - ordinal 0 is the computed DateTime column var result = transformer.MapOrdinal(0, source); // Assert - computed columns return -1 Assert.Equal(-1, result); } [Fact] public void MapOrdinal_NonComputedColumn_ReturnsSourceOrdinal() { // Arrange var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"); var source = CreateMockReader(new[] { "UPMJ", "TDAY", "Other" }); transformer.Transform(source); // Act - ordinal 1 is "Other" which maps to source ordinal 2 var result = transformer.MapOrdinal(1, source); // Assert Assert.Equal(2, result); } ``` **Step 2: Run tests to verify they fail** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "MapOrdinal_DateOutputColumn|MapOrdinal_NonComputedColumn" --no-build` Expected: FAIL **Step 3: Write the failing test for sentinel** ```csharp [Fact] public void ParseJdeDateTime_InvalidDate_ReturnsSentinel() { // Arrange var sentinel = new DateTime(1900, 1, 1); // Act - 999999 is invalid (century 9 doesn't exist) var result = JdeDateTransformer.ParseJdeDateTime(999999m, 0m, sentinel); // Assert Assert.Equal(sentinel, result); } [Fact] public void ParseJdeDateTime_ZeroDate_ReturnsSentinel() { // Arrange var sentinel = new DateTime(1900, 1, 1); // Act var result = JdeDateTransformer.ParseJdeDateTime(0m, 0m, sentinel); // Assert Assert.Equal(sentinel, result); } [Fact] public void Constructor_DefaultSentinel_Is1900() { // Arrange & Act var transformer = new JdeDateTransformer("D", "T", "Out"); // Assert Assert.Equal(new DateTime(1900, 1, 1), JdeDateTransformer.DefaultInvalidDateSentinel); } ``` **Step 4: Run tests to verify they fail** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "ParseJdeDateTime_InvalidDate|ParseJdeDateTime_ZeroDate|Constructor_DefaultSentinel" --no-build` Expected: FAIL **Step 5: Update JdeDateTransformer** Add to class: ```csharp public static readonly DateTime DefaultInvalidDateSentinel = new(1900, 1, 1); private readonly DateTime _invalidDateSentinel; ``` Update constructor: ```csharp public JdeDateTransformer( string dateColumn, string timeColumn, string outputColumn, DateTime? invalidDateSentinel = null) { ArgumentException.ThrowIfNullOrWhiteSpace(dateColumn); ArgumentException.ThrowIfNullOrWhiteSpace(timeColumn); ArgumentException.ThrowIfNullOrWhiteSpace(outputColumn); _dateColumn = dateColumn; _timeColumn = timeColumn; _outputColumn = outputColumn; _invalidDateSentinel = invalidDateSentinel ?? DefaultInvalidDateSentinel; } ``` Update ParseJdeDateTime to accept sentinel and validate: ```csharp public static DateTime ParseJdeDateTime(decimal julianDate, decimal time, DateTime sentinel) { var dateInt = (int)julianDate; if (dateInt <= 0) return sentinel; var century = dateInt / 100000; var year = (dateInt / 1000) % 100; var dayOfYear = dateInt % 1000; if (century < 0 || century > 1) return sentinel; if (year < 0 || year > 99) return sentinel; if (dayOfYear < 1 || dayOfYear > 366) return sentinel; var fullYear = (century == 0 ? 1900 : 2000) + year; var daysInYear = DateTime.IsLeapYear(fullYear) ? 366 : 365; if (dayOfYear > daysInYear) return sentinel; var date = new DateTime(fullYear, 1, 1).AddDays(dayOfYear - 1); var timeInt = (int)time; var hours = timeInt / 10000; var minutes = (timeInt / 100) % 100; var seconds = timeInt % 100; if (hours < 0 || hours > 23) return sentinel; if (minutes < 0 || minutes > 59) return sentinel; if (seconds < 0 || seconds > 59) return sentinel; return date.AddHours(hours).AddMinutes(minutes).AddSeconds(seconds); } ``` Add MapOrdinal override: ```csharp public override int MapOrdinal(int transformedOrdinal, IDataReader source) { EnsureInitialized(source); var sourceOrdinal = _ordinalMap![transformedOrdinal]; return sourceOrdinal == _dateOrdinal ? -1 : sourceOrdinal; } ``` Add GetDataTypeName override: ```csharp public override string GetDataTypeName(int ordinal, IDataReader source) { EnsureInitialized(source); var sourceOrdinal = _ordinalMap![ordinal]; return sourceOrdinal == _dateOrdinal ? "datetime" : source.GetDataTypeName(sourceOrdinal); } ``` **Step 6: Run all tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "JdeDateTransformer" --no-build` Expected: PASS **Step 7: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs git commit -m "feat(etl): add MapOrdinal and date validation with sentinel to JdeDateTransformer" ``` --- ## Task 5: Add Collision Detection to ColumnRenameTransformer **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public void OnInitialize_RenameCollision_ThrowsInvalidOperationException() { // Arrange - renaming A to B when B already exists var transformer = new ColumnRenameTransformer(("A", "B")); var source = CreateMockReader(new[] { "A", "B", "C" }); // Act & Assert var ex = Assert.Throws(() => transformer.Transform(source)); Assert.Contains("A", ex.Message); Assert.Contains("B", ex.Message); Assert.Contains("collision", ex.Message.ToLower()); } [Fact] public void OnInitialize_PreExistingDuplicates_ThrowsInvalidOperationException() { // Arrange - source has duplicate column names (case-insensitive) var transformer = new ColumnRenameTransformer(); var source = CreateMockReaderWithDuplicates(new[] { "Name", "name" }); // Act & Assert Assert.Throws(() => transformer.Transform(source)); } ``` **Step 2: Run tests to verify they fail** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "RenameCollision|PreExistingDuplicates" --no-build` Expected: FAIL - doesn't throw **Step 3: Update OnInitialize with collision detection** ```csharp protected override void OnInitialize(IDataReader source) { _outputNames = new string[source.FieldCount]; _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < source.FieldCount; i++) { var originalName = source.GetName(i); var outputName = _renames.TryGetValue(originalName, out var newName) ? newName : originalName; if (_nameToOrdinal.ContainsKey(outputName)) { var existingOrdinal = _nameToOrdinal[outputName]; var existingOriginal = source.GetName(existingOrdinal); throw new InvalidOperationException( $"Column name collision: '{originalName}' → '{outputName}' conflicts with " + $"'{existingOriginal}' (already at ordinal {existingOrdinal}). " + $"Each output column name must be unique."); } _outputNames[i] = outputName; _nameToOrdinal[outputName] = i; } } ``` **Step 4: Run tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "RenameCollision|PreExistingDuplicates" --no-build` Expected: PASS **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs git commit -m "feat(etl): add collision detection to ColumnRenameTransformer" ``` --- ## Task 6: Add Parameters Support to SqlScriptRunner **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public async Task ExecuteAsync_WithParameters_PassesParametersToCommand() { // Arrange var mockFactory = new Mock(); var mockConnection = new Mock(); mockFactory.Setup(f => f.CreateLotFinderConnectionAsync(It.IsAny())) .ReturnsAsync(mockConnection.Object); var parameters = new { tableName = "WorkOrder", schemaName = "dbo" }; var runner = new SqlScriptRunner( mockFactory.Object, "SELECT @tableName, @schemaName", "Test", parameters: parameters); // This test verifies the constructor accepts parameters Assert.NotNull(runner); } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "WithParameters_PassesParametersToCommand" --no-build` Expected: FAIL - constructor doesn't accept parameters **Step 3: Update SqlScriptRunner constructor** ```csharp public class SqlScriptRunner : IScriptRunner { private readonly IDbConnectionFactory _connectionFactory; private readonly string _sql; private readonly string _scriptName; private readonly object? _parameters; private readonly int _timeoutSeconds; public SqlScriptRunner( IDbConnectionFactory connectionFactory, string sql, string scriptName, object? parameters = null, int timeoutSeconds = 30) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); ArgumentException.ThrowIfNullOrWhiteSpace(sql); ArgumentException.ThrowIfNullOrWhiteSpace(scriptName); _sql = sql; _scriptName = scriptName; _parameters = parameters; _timeoutSeconds = timeoutSeconds; } public string ScriptName => _scriptName; public async Task ExecuteAsync(CancellationToken cancellationToken = default) { await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); await connection.ExecuteAsync( new CommandDefinition(_sql, _parameters, commandTimeout: _timeoutSeconds, cancellationToken: cancellationToken)); } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "WithParameters_PassesParametersToCommand" --no-build` Expected: PASS **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs git commit -m "feat(etl): add parameters support to SqlScriptRunner" ``` --- ## Task 7: Update CommonScripts with ParseTableName and QUOTENAME **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs` **Step 1: Write the failing tests** ```csharp [Theory] [InlineData("WorkOrder", "dbo", "WorkOrder")] [InlineData("dbo.WorkOrder", "dbo", "WorkOrder")] [InlineData("[dbo].[WorkOrder]", "dbo", "WorkOrder")] [InlineData("Config.Settings", "Config", "Settings")] public void ParseTableName_VariousFormats_ParsesCorrectly(string input, string expectedSchema, string expectedTable) { // Act var (schema, table) = CommonScripts.ParseTableName(input); // Assert Assert.Equal(expectedSchema, schema); Assert.Equal(expectedTable, table); } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "ParseTableName_VariousFormats" --no-build` Expected: FAIL - method doesn't exist **Step 3: Add ParseTableName and update all methods** ```csharp public static class CommonScripts { /// /// Parses a table name, extracting schema if present. /// Supports: "Table", "dbo.Table", "[dbo].[Table]" /// public static (string Schema, string Table) ParseTableName(string tableName) { var cleaned = tableName.Replace("[", "").Replace("]", ""); var parts = cleaned.Split('.', 2); return parts.Length == 2 ? (parts[0], parts[1]) : ("dbo", parts[0]); } public static IScriptRunner DisableIndexes( IDbConnectionFactory factory, string tableName, int timeoutSeconds = 300) { var (schema, table) = ParseTableName(tableName); var sql = @" DECLARE @sql NVARCHAR(MAX) = ''; DECLARE @fullTableName NVARCHAR(256) = QUOTENAME(@schemaName) + '.' + QUOTENAME(@tableName); SELECT @sql = @sql + 'ALTER INDEX ' + QUOTENAME(i.name) + ' ON ' + @fullTableName + ' DISABLE;' + CHAR(13) FROM sys.indexes i INNER JOIN sys.tables t ON i.object_id = t.object_id INNER JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE t.name = @tableName AND s.name = @schemaName AND i.type = 2 AND i.is_disabled = 0; IF LEN(@sql) > 0 EXEC sp_executesql @sql;"; return new SqlScriptRunner(factory, sql, $"DisableIndexes:{schema}.{table}", parameters: new { tableName = table, schemaName = schema }, timeoutSeconds: timeoutSeconds); } public static IScriptRunner RebuildIndexes( IDbConnectionFactory factory, string tableName, int timeoutSeconds = 3600) { var (schema, table) = ParseTableName(tableName); var sql = @" DECLARE @sql NVARCHAR(256) = 'ALTER INDEX ALL ON ' + QUOTENAME(@schemaName) + '.' + QUOTENAME(@tableName) + ' REBUILD WITH (FILLFACTOR = 95)'; EXEC sp_executesql @sql;"; return new SqlScriptRunner(factory, sql, $"RebuildIndexes:{schema}.{table}", parameters: new { tableName = table, schemaName = schema }, timeoutSeconds: timeoutSeconds); } public static IScriptRunner UpdateStatistics( IDbConnectionFactory factory, string tableName, int timeoutSeconds = 600) { var (schema, table) = ParseTableName(tableName); var sql = @" DECLARE @sql NVARCHAR(256) = 'UPDATE STATISTICS ' + QUOTENAME(@schemaName) + '.' + QUOTENAME(@tableName); EXEC sp_executesql @sql;"; return new SqlScriptRunner(factory, sql, $"UpdateStats:{schema}.{table}", parameters: new { tableName = table, schemaName = schema }, timeoutSeconds: timeoutSeconds); } public static IScriptRunner CustomSql( IDbConnectionFactory factory, string sql, string name, object? parameters = null, int timeoutSeconds = 30) { return new SqlScriptRunner(factory, sql, name, parameters, timeoutSeconds); } } ``` **Step 4: Run tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "CommonScripts" --no-build` Expected: PASS **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs git commit -m "feat(etl): add ParseTableName and QUOTENAME to CommonScripts" ``` --- ## Task 8: Add Command Timeout to Destinations **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs` - Modify: `NEW/src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public void Constructor_CustomTimeout_SetsTimeout() { // Arrange & Act var dest = new DbBulkMergeDestination( Mock.Of(), "TestTable", new[] { "Id" }, commandTimeoutSeconds: 1800); // Assert - can't directly test private field, but constructor should accept it Assert.NotNull(dest); } [Fact] public void Constructor_ZeroTimeout_UsesDefault() { // Arrange & Act var dest = new DbBulkMergeDestination( Mock.Of(), "TestTable", new[] { "Id" }, commandTimeoutSeconds: 0); // Assert Assert.NotNull(dest); } ``` **Step 2: Run tests to verify they fail** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "Constructor_CustomTimeout|Constructor_ZeroTimeout" --no-build` Expected: FAIL - parameter doesn't exist **Step 3: Update DbBulkMergeDestination** Add fields and update constructor: ```csharp private const int DefaultCommandTimeoutSeconds = 600; private readonly int _commandTimeoutSeconds; public DbBulkMergeDestination( IDbConnectionFactory connectionFactory, string tableName, string[] matchColumns, string[]? updateColumns = null, int batchSize = 0, int commandTimeoutSeconds = 0) { // ... existing validation ... _commandTimeoutSeconds = commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds; } ``` Update bulk copy: ```csharp using var bulkCopy = new SqlBulkCopy(connection) { DestinationTableName = tempTableName, BatchSize = batch.Rows.Count, BulkCopyTimeout = _commandTimeoutSeconds }; ``` Update command execution: ```csharp await using var cmd = connection.CreateCommand(); cmd.CommandText = sql; cmd.CommandTimeout = _commandTimeoutSeconds; await cmd.ExecuteNonQueryAsync(ct); ``` **Step 4: Apply same changes to DbBulkImportDestination** **Step 5: Run tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "DbBulkMergeDestination|DbBulkImportDestination" --no-build` Expected: PASS **Step 6: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/ git commit -m "feat(etl): add commandTimeoutSeconds to destinations" ``` --- ## Task 9: Add Column Mapping to Destinations **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs` - Modify: `NEW/src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs` **Step 1: Write the failing test** ```csharp [Fact] public async Task WriteAsync_SourceHasExtraColumns_IgnoresExtraColumns() { // This is an integration test concept - // we need to verify that column mappings are applied // The actual test would use a real DB or comprehensive mock } ``` **Step 2: Add GetDestinationColumnsAsync method** ```csharp private async Task> GetDestinationColumnsAsync( SqlConnection connection, CancellationToken ct) { var (schema, table) = CommonScripts.ParseTableName(_tableName); var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName"; var columns = await connection.QueryAsync( new CommandDefinition(sql, new { tableName = table, schemaName = schema }, commandTimeout: _commandTimeoutSeconds, cancellationToken: ct)); return columns.ToHashSet(StringComparer.OrdinalIgnoreCase); } ``` **Step 3: Update ProcessBatchAsync to use column mappings** ```csharp private async Task ProcessBatchAsync( SqlConnection connection, DataTable batch, string tempTableName, string mergeSql, HashSet destColumns, CancellationToken ct) { using var bulkCopy = new SqlBulkCopy(connection) { DestinationTableName = tempTableName, BatchSize = batch.Rows.Count, BulkCopyTimeout = _commandTimeoutSeconds }; // Map only columns that exist in destination foreach (DataColumn col in batch.Columns) { if (destColumns.Contains(col.ColumnName)) { bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); } } await bulkCopy.WriteToServerAsync(batch, ct); // ... rest of method } ``` **Step 4: Run tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "DbBulkMergeDestination" --no-build` Expected: PASS **Step 5: Apply same changes to DbBulkImportDestination** **Step 6: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/ git commit -m "feat(etl): add column mapping to destinations (intersect with dest schema)" ``` --- ## Task 10: Add WithCommandTimeout to EtlPipelineBuilder **Files:** - Modify: `NEW/src/JdeScoping.DataSync/Etl/Pipeline/EtlPipelineBuilder.cs` - Test: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineBuilderTests.cs` **Step 1: Write the failing tests** ```csharp [Fact] public void WithCommandTimeout_ValidTimeout_SetsTimeout() { // Arrange var builder = new EtlPipelineBuilder(); // Act var result = builder.WithCommandTimeout(TimeSpan.FromMinutes(30)); // Assert Assert.Same(builder, result); } [Fact] public void WithCommandTimeout_NegativeTimeout_ThrowsArgumentOutOfRange() { // Arrange var builder = new EtlPipelineBuilder(); // Act & Assert Assert.Throws(() => builder.WithCommandTimeout(TimeSpan.FromSeconds(-1))); } [Fact] public void WithCommandTimeout_Over24Hours_ThrowsArgumentOutOfRange() { // Arrange var builder = new EtlPipelineBuilder(); // Act & Assert Assert.Throws(() => builder.WithCommandTimeout(TimeSpan.FromHours(25))); } ``` **Step 2: Run tests to verify they fail** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "WithCommandTimeout" --no-build` Expected: FAIL - method doesn't exist **Step 3: Add WithCommandTimeout method** ```csharp private int _defaultCommandTimeoutSeconds = 600; public EtlPipelineBuilder WithCommandTimeout(TimeSpan timeout) { if (timeout < TimeSpan.Zero || timeout > TimeSpan.FromHours(24)) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be between 0 and 24 hours."); _defaultCommandTimeoutSeconds = (int)timeout.TotalSeconds; return this; } ``` **Step 4: Run tests to verify they pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "WithCommandTimeout" --no-build` Expected: PASS **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Etl/Pipeline/EtlPipelineBuilder.cs tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineBuilderTests.cs git commit -m "feat(etl): add WithCommandTimeout to EtlPipelineBuilder with validation" ``` --- ## Task 11: Run Full Test Suite and Verify **Step 1: Build the solution** Run: `dotnet build NEW/JdeScoping.sln` Expected: Build succeeded **Step 2: Run all DataSync tests** Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests --verbosity normal` Expected: All tests pass **Step 3: Run all solution tests** Run: `dotnet test NEW/JdeScoping.sln --verbosity normal` Expected: All tests pass **Step 4: Commit final verification** ```bash git add -A git commit -m "test: verify all Phase 2 ETL tests pass" ``` --- ## Task 12: Final Review with Codex MCP **Step 1: Run Codex MCP review** Consult Codex MCP for final code review of all Phase 2 changes. **Step 2: Address any findings** Fix any issues identified by the review. **Step 3: Final commit** ```bash git commit -m "refactor: address Codex MCP review findings for Phase 2" ```