61d4848955
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
1041 lines
32 KiB
Markdown
1041 lines
32 KiB
Markdown
# 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; }
|
|
|
|
/// <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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```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
|
|
{
|
|
/// <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**
|
|
|
|
```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<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:
|
|
```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<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**
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```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"
|
|
```
|