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();