diff --git a/NEW/src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs b/NEW/src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs
index 33b3d54..2f63cd7 100644
--- a/NEW/src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs
+++ b/NEW/src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs
@@ -9,9 +9,15 @@ namespace JdeScoping.DataSync.Etl.Transformers;
///
public class JdeDateTransformer : DataTransformerBase
{
+ ///
+ /// The default sentinel value used for invalid dates (January 1, 1900).
+ ///
+ public static readonly DateTime DefaultInvalidDateSentinel = new(1900, 1, 1);
+
private readonly string _dateColumn;
private readonly string _timeColumn;
private readonly string _outputColumn;
+ private readonly DateTime _invalidDateSentinel;
private int _dateOrdinal;
private int _timeOrdinal;
@@ -28,7 +34,12 @@ public class JdeDateTransformer : DataTransformerBase
/// The name of the JDE Julian date column (CYYDDD format).
/// The name of the JDE time column (HHMMSS format).
/// The name of the output DateTime column.
- public JdeDateTransformer(string dateColumn, string timeColumn, string outputColumn)
+ /// Optional sentinel value for invalid dates (default: 1900-01-01).
+ public JdeDateTransformer(
+ string dateColumn,
+ string timeColumn,
+ string outputColumn,
+ DateTime? invalidDateSentinel = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dateColumn);
ArgumentException.ThrowIfNullOrWhiteSpace(timeColumn);
@@ -36,6 +47,7 @@ public class JdeDateTransformer : DataTransformerBase
_dateColumn = dateColumn;
_timeColumn = timeColumn;
_outputColumn = outputColumn;
+ _invalidDateSentinel = invalidDateSentinel ?? DefaultInvalidDateSentinel;
}
///
@@ -104,33 +116,84 @@ public class JdeDateTransformer : DataTransformerBase
return source.IsDBNull(sourceOrdinal);
}
+ ///
+ public override int MapOrdinal(int transformedOrdinal, IDataReader source)
+ {
+ var sourceOrdinal = _ordinalMap![transformedOrdinal];
+ // Return -1 for the computed DateTime column (which replaces the date column)
+ return sourceOrdinal == _dateOrdinal ? -1 : sourceOrdinal;
+ }
+
+ ///
+ public override string GetDataTypeName(int ordinal, IDataReader source)
+ {
+ var sourceOrdinal = _ordinalMap![ordinal];
+ return sourceOrdinal == _dateOrdinal ? "datetime" : source.GetDataTypeName(sourceOrdinal);
+ }
+
private object ParseJdeDateTimeFromSource(IDataReader source)
{
if (source.IsDBNull(_dateOrdinal)) return DBNull.Value;
var julianDate = Convert.ToDecimal(source.GetValue(_dateOrdinal));
var timeValue = source.IsDBNull(_timeOrdinal) ? 0m : Convert.ToDecimal(source.GetValue(_timeOrdinal));
- return ParseJdeDateTime(julianDate, timeValue);
+ return ParseJdeDateTime(julianDate, timeValue, _invalidDateSentinel);
}
///
/// Parses a JDE Julian date and time into a DateTime.
+ /// Uses the default sentinel value (1900-01-01) for invalid dates.
///
/// The JDE Julian date in CYYDDD format.
/// The time in HHMMSS format.
- /// The parsed DateTime.
+ /// The parsed DateTime, or the default sentinel for invalid dates.
public static DateTime ParseJdeDateTime(decimal julianDate, decimal time)
+ => ParseJdeDateTime(julianDate, time, DefaultInvalidDateSentinel);
+
+ ///
+ /// Parses a JDE Julian date and time into a DateTime with validation.
+ ///
+ /// The JDE Julian date in CYYDDD format.
+ /// The time in HHMMSS format.
+ /// The value to return for invalid dates.
+ /// The parsed DateTime, or the sentinel value for invalid dates.
+ public static DateTime ParseJdeDateTime(decimal julianDate, decimal time, DateTime sentinel)
{
var dateInt = (int)julianDate;
+
+ // Validate date is positive
+ if (dateInt <= 0) return sentinel;
+
var century = dateInt / 100000;
var year = (dateInt / 1000) % 100;
var dayOfYear = dateInt % 1000;
+
+ // Validate century (0 = 1900s, 1 = 2000s)
+ if (century < 0 || century > 1) return sentinel;
+
+ // Validate year (0-99)
+ if (year < 0 || year > 99) return sentinel;
+
+ // Validate day of year (1-366)
+ if (dayOfYear < 1 || dayOfYear > 366) return sentinel;
+
var fullYear = (century == 0 ? 1900 : 2000) + year;
+
+ // Validate day of year doesn't exceed days in that 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;
+
+ // Validate time components
+ 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);
}
}
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs
index 64057c2..82178da 100644
--- a/NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs
@@ -80,6 +80,166 @@ public class JdeDateTransformerTests
Assert.Equal(new DateTime(1999, 6, 15, 0, 0, 0), JdeDateTransformer.ParseJdeDateTime(99166m, 0m));
}
+ [Fact]
+ public void MapOrdinal_DateOutputColumn_ReturnsNegativeOne()
+ {
+ // Arrange
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var source = CreateMockReader(new[] { "UPMJ", "TDAY", "Other" }, new object[] { 124001m, 120000m, "Test" });
+ transformer.Transform(source);
+
+ // Act - ordinal 0 is the computed DateTime column (UPMJ becomes UpdatedAt at position 0)
+ 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" }, new object[] { 124001m, 120000m, "Test" });
+ 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);
+ }
+
+ [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 DefaultInvalidDateSentinel_Is1900()
+ {
+ // Assert
+ Assert.Equal(new DateTime(1900, 1, 1), JdeDateTransformer.DefaultInvalidDateSentinel);
+ }
+
+ [Fact]
+ public void GetDataTypeName_DateOutputColumn_ReturnsDatetime()
+ {
+ // Arrange
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var source = CreateMockReader(new[] { "UPMJ", "TDAY", "Other" }, new object[] { 124001m, 120000m, "Test" });
+ source.GetDataTypeName(2).Returns("nvarchar");
+ var reader = transformer.Transform(source);
+
+ // Act - ordinal 0 is the computed DateTime column
+ var result = reader.GetDataTypeName(0);
+
+ // Assert
+ Assert.Equal("datetime", result);
+ }
+
+ [Fact]
+ public void GetDataTypeName_NonComputedColumn_DelegatesToSource()
+ {
+ // Arrange
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var source = CreateMockReader(new[] { "UPMJ", "TDAY", "Other" }, new object[] { 124001m, 120000m, "Test" });
+ source.GetDataTypeName(2).Returns("nvarchar");
+ var reader = transformer.Transform(source);
+
+ // Act - ordinal 1 is "Other" which maps to source ordinal 2
+ var result = reader.GetDataTypeName(1);
+
+ // Assert
+ Assert.Equal("nvarchar", result);
+ }
+
+ [Fact]
+ public void ParseJdeDateTime_NegativeDate_ReturnsSentinel()
+ {
+ // Arrange
+ var sentinel = new DateTime(1900, 1, 1);
+
+ // Act
+ var result = JdeDateTransformer.ParseJdeDateTime(-100m, 0m, sentinel);
+
+ // Assert
+ Assert.Equal(sentinel, result);
+ }
+
+ [Fact]
+ public void ParseJdeDateTime_InvalidDayOfYear_ReturnsSentinel()
+ {
+ // Arrange
+ var sentinel = new DateTime(1900, 1, 1);
+
+ // Act - Day 400 doesn't exist
+ var result = JdeDateTransformer.ParseJdeDateTime(124400m, 0m, sentinel);
+
+ // Assert
+ Assert.Equal(sentinel, result);
+ }
+
+ [Fact]
+ public void ParseJdeDateTime_InvalidTime_ReturnsSentinel()
+ {
+ // Arrange
+ var sentinel = new DateTime(1900, 1, 1);
+
+ // Act - Hour 25 doesn't exist
+ var result = JdeDateTransformer.ParseJdeDateTime(124001m, 250000m, sentinel);
+
+ // Assert
+ Assert.Equal(sentinel, result);
+ }
+
+ [Fact]
+ public void ParseJdeDateTime_LeapYearDay366_ReturnsValidDate()
+ {
+ // Arrange
+ var sentinel = new DateTime(1900, 1, 1);
+
+ // Act - 2024 is a leap year, day 366 is valid
+ var result = JdeDateTransformer.ParseJdeDateTime(124366m, 0m, sentinel);
+
+ // Assert - December 31, 2024
+ Assert.Equal(new DateTime(2024, 12, 31), result);
+ }
+
+ [Fact]
+ public void ParseJdeDateTime_NonLeapYearDay366_ReturnsSentinel()
+ {
+ // Arrange
+ var sentinel = new DateTime(1900, 1, 1);
+
+ // Act - 2023 is NOT a leap year, day 366 is invalid
+ var result = JdeDateTransformer.ParseJdeDateTime(123366m, 0m, sentinel);
+
+ // Assert
+ Assert.Equal(sentinel, result);
+ }
+
private static IDataReader CreateMockReader(string[] columns, object[] values)
{
var reader = Substitute.For();