Files
jdescopingtool/PLANS/2026-01-03-etl-pipeline-phase2-implementation.md
T
Joseph Doherty 61d4848955 docs: add ETL Pipeline Phase 2 implementation plan
12-task TDD implementation plan covering:
- MapOrdinal interface and binary method overrides
- ColumnDropTransformer and JdeDateTransformer ordinal mapping
- JDE date validation with 1900-01-01 sentinel
- Column rename collision detection
- SqlScriptRunner parameters support
- CommonScripts with ParseTableName and QUOTENAME
- Destination command timeouts
- Column mapping with destination schema intersection
- EtlPipelineBuilder.WithCommandTimeout validation
- Full test suite verification
- Codex MCP final review
2026-01-03 10:24:28 -05:00

32 KiB

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

[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

public interface IDataTransformer
{
    IDataReader Transform(IDataReader source);
    string TransformerName { get; }

    /// <summary>
    /// Maps a transformed ordinal to the source ordinal.
    /// Returns -1 for computed columns that have no source ordinal.
    /// </summary>
    int MapOrdinal(int transformedOrdinal, IDataReader source);
}

Step 4: Add default implementation to DataTransformerBase

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

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

[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<NotSupportedException>(() =>
        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

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

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

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

[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

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

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

[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

[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:

public static readonly DateTime DefaultInvalidDateSentinel = new(1900, 1, 1);
private readonly DateTime _invalidDateSentinel;

Update constructor:

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:

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:

public override int MapOrdinal(int transformedOrdinal, IDataReader source)
{
    EnsureInitialized(source);
    var sourceOrdinal = _ordinalMap![transformedOrdinal];
    return sourceOrdinal == _dateOrdinal ? -1 : sourceOrdinal;
}

Add GetDataTypeName override:

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

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

[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<InvalidOperationException>(() =>
        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<InvalidOperationException>(() =>
        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

protected override void OnInitialize(IDataReader source)
{
    _outputNames = new string[source.FieldCount];
    _nameToOrdinal = new Dictionary<string, int>(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

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

[Fact]
public async Task ExecuteAsync_WithParameters_PassesParametersToCommand()
{
    // Arrange
    var mockFactory = new Mock<IDbConnectionFactory>();
    var mockConnection = new Mock<SqlConnection>();
    mockFactory.Setup(f => f.CreateLotFinderConnectionAsync(It.IsAny<CancellationToken>()))
        .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

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

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

[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

public static class CommonScripts
{
    /// <summary>
    /// Parses a table name, extracting schema if present.
    /// Supports: "Table", "dbo.Table", "[dbo].[Table]"
    /// </summary>
    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

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

[Fact]
public void Constructor_CustomTimeout_SetsTimeout()
{
    // Arrange & Act
    var dest = new DbBulkMergeDestination(
        Mock.Of<IDbConnectionFactory>(),
        "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<IDbConnectionFactory>(),
        "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:

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:

using var bulkCopy = new SqlBulkCopy(connection)
{
    DestinationTableName = tempTableName,
    BatchSize = batch.Rows.Count,
    BulkCopyTimeout = _commandTimeoutSeconds
};

Update command execution:

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

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

[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

private async Task<HashSet<string>> 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<string>(
        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

private async Task ProcessBatchAsync(
    SqlConnection connection,
    DataTable batch,
    string tempTableName,
    string mergeSql,
    HashSet<string> 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

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

[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<ArgumentOutOfRangeException>(() =>
        builder.WithCommandTimeout(TimeSpan.FromSeconds(-1)));
}

[Fact]
public void WithCommandTimeout_Over24Hours_ThrowsArgumentOutOfRange()
{
    // Arrange
    var builder = new EtlPipelineBuilder();

    // Act & Assert
    Assert.Throws<ArgumentOutOfRangeException>(() =>
        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

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

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

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

git commit -m "refactor: address Codex MCP review findings for Phase 2"