diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Extensions/DateTimeSafeExtensionsTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Extensions/DateTimeSafeExtensionsTests.cs new file mode 100644 index 0000000000..18a69495fc --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Extensions/DateTimeSafeExtensionsTests.cs @@ -0,0 +1,532 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Extensions +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class DateTimeSafeExtensionsTests + { + // SafeAddTicks - DateTime + + [Fact] + public void GivenNormalDateTime_WhenSafeAddTicksCalled_ThenReturnsExpectedResult() + { + var dt = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond); + Assert.Equal(dt.AddTicks(TimeSpan.TicksPerMillisecond), result); + } + + [Fact] + public void GivenDateTimeNearMaxValue_WhenSafeAddTicksPositive_ThenConstrainsToMaxValue() + { + var dt = DateTime.MaxValue.AddTicks(-100); + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond); + Assert.Equal(DateTime.MaxValue, result); + } + + [Fact] + public void GivenDateTimeNearMinValue_WhenSafeAddTicksNegative_ThenConstrainsToMinValue() + { + var dt = DateTime.MinValue.AddTicks(100); + DateTime result = dt.SafeAddTicks(-TimeSpan.TicksPerMillisecond); + Assert.Equal(DateTime.MinValue, result); + } + + [Fact] + public void GivenDateTime_WhenSafeAddTicksZero_ThenReturnsOriginal() + { + var dt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddTicks(0); + Assert.Equal(dt, result); + } + + [Fact] + public void GivenAnyDateTime_WhenSafeAddTicksLongMinValue_ThenConstrainsToMinValue() + { + var dt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddTicks(long.MinValue); + Assert.Equal(DateTime.MinValue, result); + } + + [Fact] + public void GivenUtcDateTime_WhenSafeAddTicksConstrainsToMaxValue_ThenPreservesKind() + { + var dt = new DateTime(DateTime.MaxValue.Ticks - 1, DateTimeKind.Utc); + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerDay); + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(DateTime.MaxValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenLocalDateTime_WhenSafeAddTicksConstrainsToMinValue_ThenPreservesKind() + { + var dt = new DateTime(DateTime.MinValue.Ticks + 1, DateTimeKind.Local); + DateTime result = dt.SafeAddTicks(-TimeSpan.TicksPerDay); + Assert.Equal(DateTimeKind.Local, result.Kind); + Assert.Equal(DateTime.MinValue.Ticks, result.Ticks); + } + + // SafeAddTicks - DateTimeOffset + + [Fact] + public void GivenNormalDateTimeOffset_WhenSafeAddTicksCalled_ThenReturnsExpectedResult() + { + var dto = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerMillisecond); + Assert.Equal(dto.AddTicks(TimeSpan.TicksPerMillisecond), result); + } + + [Fact] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddTicksPositive_ThenConstrainsToMaxValue() + { + var dto = DateTimeOffset.MaxValue.AddTicks(-100); + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerMillisecond); + Assert.Equal(DateTimeOffset.MaxValue, result); + } + + [Fact] + public void GivenDateTimeOffsetNearMinValue_WhenSafeAddTicksNegative_ThenConstrainsToMinValue() + { + var dto = DateTimeOffset.MinValue.AddTicks(100); + DateTimeOffset result = dto.SafeAddTicks(-TimeSpan.TicksPerMillisecond); + Assert.Equal(DateTimeOffset.MinValue, result); + } + + [Fact] + public void GivenDateTimeOffset_WhenSafeAddTicksZero_ThenReturnsOriginal() + { + var offset = TimeSpan.FromHours(5); + var dto = new DateTimeOffset(2024, 6, 15, 12, 0, 0, offset); + DateTimeOffset result = dto.SafeAddTicks(0); + Assert.Equal(dto, result); + Assert.Equal(offset, result.Offset); + } + + [Fact] + public void GivenDateTimeOffset_WhenSafeAddTicksConstrainsToMaxValue_ThenPreservesOffset() + { + var offset = TimeSpan.FromHours(5); + var dto = new DateTimeOffset(9999, 12, 31, 23, 59, 59, offset); + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerDay); + Assert.Equal(offset, result.Offset); + Assert.True(result.Ticks > dto.Ticks); + } + + // SafeAddDays - DateTime + + [Fact] + public void GivenNormalDateTime_WhenSafeAddDaysCalled_ThenReturnsExpectedResult() + { + var dt = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddDays(1); + Assert.Equal(dt.AddDays(1), result); + } + + [Fact] + public void GivenDateTimeNearMaxValue_WhenSafeAddDaysPositive_ThenConstrainsToMaxValue() + { + var dt = DateTime.MaxValue.AddDays(-0.5); + DateTime result = dt.SafeAddDays(1); + Assert.Equal(DateTime.MaxValue, result); + } + + [Fact] + public void GivenDateTimeNearMinValue_WhenSafeAddDaysNegative_ThenConstrainsToMinValue() + { + var dt = DateTime.MinValue.AddDays(0.5); + DateTime result = dt.SafeAddDays(-1); + Assert.Equal(DateTime.MinValue, result); + } + + // SafeAddDays - DateTimeOffset + + [Fact] + public void GivenNormalDateTimeOffset_WhenSafeAddDaysCalled_ThenReturnsExpectedResult() + { + var dto = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddDays(1); + Assert.Equal(dto.AddDays(1), result); + } + + [Fact] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddDaysPositive_ThenConstrainsToMaxValue() + { + var dto = DateTimeOffset.MaxValue.AddDays(-0.5); + DateTimeOffset result = dto.SafeAddDays(1); + Assert.Equal(DateTimeOffset.MaxValue, result); + } + + [Fact] + public void GivenDateTimeOffsetNearMinValue_WhenSafeAddDaysNegative_ThenConstrainsToMinValue() + { + var dto = DateTimeOffset.MinValue.AddDays(0.5); + DateTimeOffset result = dto.SafeAddDays(-1); + Assert.Equal(DateTimeOffset.MinValue, result); + } + + [Fact] + public void GivenDateTime_WhenSafeAddDaysIntMaxValue_ThenConstrainsToMaxValue() + { + var dt = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddDays(int.MaxValue); + Assert.Equal(DateTime.MaxValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenDateTime_WhenSafeAddDaysIntMinValue_ThenConstrainsToMinValue() + { + var dt = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddDays(int.MinValue); + Assert.Equal(DateTime.MinValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenUtcDateTime_WhenSafeAddDaysConstrainsToMaxValue_ThenPreservesKind() + { + var dt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddDays(int.MaxValue); + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(DateTime.MaxValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenLocalDateTime_WhenSafeAddDaysConstrainsToMinValue_ThenPreservesKind() + { + var dt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Local); + DateTime result = dt.SafeAddDays(int.MinValue); + Assert.Equal(DateTimeKind.Local, result.Kind); + Assert.Equal(DateTime.MinValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenDateTimeOffset_WhenSafeAddDaysIntMaxValue_ThenConstrainsToMaxValue() + { + var dto = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddDays(int.MaxValue); + Assert.Equal(DateTimeOffset.MaxValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenDateTimeOffset_WhenSafeAddDaysIntMinValue_ThenConstrainsToMinValue() + { + var dto = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddDays(int.MinValue); + Assert.Equal(DateTimeOffset.MinValue.Ticks, result.Ticks); + } + + [Fact] + public void GivenDateTimeOffset_WhenSafeAddDaysConstrainsToMaxValue_ThenPreservesOffset() + { + var offset = TimeSpan.FromHours(5); + var dto = new DateTimeOffset(2024, 1, 1, 0, 0, 0, offset); + DateTimeOffset result = dto.SafeAddDays(int.MaxValue); + Assert.Equal(offset, result.Offset); + Assert.True(result.UtcTicks <= DateTimeOffset.MaxValue.UtcTicks); + } + + [Fact] + public void GivenDateTimeOffsetWithNegativeOffset_WhenSafeAddDaysOverflows_ThenConstrainsWithoutThrowing() + { + var offset = TimeSpan.FromHours(-5); + var dto = new DateTimeOffset(2024, 1, 1, 0, 0, 0, offset); + DateTimeOffset result = dto.SafeAddDays(int.MaxValue); + Assert.Equal(offset, result.Offset); + Assert.Equal(DateTimeOffset.MaxValue.UtcTicks, result.UtcTicks); + } + + // OverflowBehavior Tests - DateTime.SafeAddTicks + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeNearMaxValue_WhenSafeAddTicksWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dt = DateTime.MaxValue.AddTicks(-100); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior)); + } + else + { + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dt, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeNearMinValue_WhenSafeAddTicksNegativeWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dt = DateTime.MinValue.AddTicks(100); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dt.SafeAddTicks(-TimeSpan.TicksPerMillisecond, behavior)); + } + else + { + DateTime result = dt.SafeAddTicks(-TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dt, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Constrain)] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenNormalDateTime_WhenSafeAddTicksWithBehavior_ThenReturnsExpectedResult(OverflowBehavior behavior) + { + var dt = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dt.AddTicks(TimeSpan.TicksPerMillisecond), result); + } + + // OverflowBehavior Tests - DateTimeOffset.SafeAddTicks + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddTicksWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dto = DateTimeOffset.MaxValue.AddTicks(-100); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dto.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior)); + } + else + { + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dto, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeOffsetNearMinValue_WhenSafeAddTicksNegativeWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dto = DateTimeOffset.MinValue.AddTicks(100); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dto.SafeAddTicks(-TimeSpan.TicksPerMillisecond, behavior)); + } + else + { + DateTimeOffset result = dto.SafeAddTicks(-TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dto, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Constrain)] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenNormalDateTimeOffset_WhenSafeAddTicksWithBehavior_ThenReturnsExpectedResult(OverflowBehavior behavior) + { + var dto = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerMillisecond, behavior); + Assert.Equal(dto.AddTicks(TimeSpan.TicksPerMillisecond), result); + } + + [Fact] + public void GivenDateTimeOffsetWithCustomOffset_WhenSafeAddTicksWithReturnOriginalBehavior_ThenReturnsOriginalWithOffsetPreserved() + { + var offset = TimeSpan.FromHours(5); + var unspecifiedDt = new DateTime(9999, 12, 31, 12, 0, 0, DateTimeKind.Unspecified); + var dto = new DateTimeOffset(unspecifiedDt, offset); + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerDay, OverflowBehavior.ReturnOriginal); + Assert.Equal(dto, result); + Assert.Equal(offset, result.Offset); + } + + // OverflowBehavior Tests - DateTime.SafeAddDays + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeNearMaxValue_WhenSafeAddDaysWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dt = DateTime.MaxValue.AddDays(-0.5); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dt.SafeAddDays(1, behavior)); + } + else + { + DateTime result = dt.SafeAddDays(1, behavior); + Assert.Equal(dt, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeNearMinValue_WhenSafeAddDaysNegativeWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dt = DateTime.MinValue.AddDays(0.5); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dt.SafeAddDays(-1, behavior)); + } + else + { + DateTime result = dt.SafeAddDays(-1, behavior); + Assert.Equal(dt, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Constrain)] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenNormalDateTime_WhenSafeAddDaysWithBehavior_ThenReturnsExpectedResult(OverflowBehavior behavior) + { + var dt = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + DateTime result = dt.SafeAddDays(1, behavior); + Assert.Equal(dt.AddDays(1), result); + } + + // OverflowBehavior Tests - DateTimeOffset.SafeAddDays + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddDaysWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dto = DateTimeOffset.MaxValue.AddDays(-0.5); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dto.SafeAddDays(1, behavior)); + } + else + { + DateTimeOffset result = dto.SafeAddDays(1, behavior); + Assert.Equal(dto, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenDateTimeOffsetNearMinValue_WhenSafeAddDaysNegativeWithBehavior_ThenHandlesOverflow(OverflowBehavior behavior) + { + var dto = DateTimeOffset.MinValue.AddDays(0.5); + if (behavior == OverflowBehavior.Throw) + { + Assert.Throws(() => dto.SafeAddDays(-1, behavior)); + } + else + { + DateTimeOffset result = dto.SafeAddDays(-1, behavior); + Assert.Equal(dto, result); + } + } + + [Theory] + [InlineData(OverflowBehavior.Constrain)] + [InlineData(OverflowBehavior.Throw)] + [InlineData(OverflowBehavior.ReturnOriginal)] + public void GivenNormalDateTimeOffset_WhenSafeAddDaysWithBehavior_ThenReturnsExpectedResult(OverflowBehavior behavior) + { + var dto = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero); + DateTimeOffset result = dto.SafeAddDays(1, behavior); + Assert.Equal(dto.AddDays(1), result); + } + + [Fact] + public void GivenDateTimeOffsetWithCustomOffset_WhenSafeAddDaysWithReturnOriginalBehavior_ThenReturnsOriginalWithOffsetPreserved() + { + var offset = TimeSpan.FromHours(-7); + var dto = new DateTimeOffset(2024, 1, 1, 0, 0, 0, offset); + DateTimeOffset result = dto.SafeAddDays(int.MaxValue, OverflowBehavior.ReturnOriginal); + Assert.Equal(dto, result); + Assert.Equal(offset, result.Offset); + } + + // Logging Tests + + [Fact] + public void GivenDateTimeNearMaxValue_WhenSafeAddTicksWithLoggerAndThrowBehavior_ThenLogsWarningBeforeThrowing() + { + var logger = Substitute.For(); + var dt = DateTime.MaxValue.AddTicks(-100); + + Assert.Throws(() => dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, OverflowBehavior.Throw, logger)); + + logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + + [Fact] + public void GivenDateTimeNearMaxValue_WhenSafeAddTicksWithLoggerAndConstrainBehavior_ThenLogsWarningAndConstrains() + { + var logger = Substitute.For(); + var dt = DateTime.MaxValue.AddTicks(-100); + + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, OverflowBehavior.Constrain, logger); + + Assert.Equal(DateTime.MaxValue, result); + logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + + [Fact] + public void GivenNormalDateTime_WhenSafeAddTicksWithLogger_ThenDoesNotLog() + { + var logger = Substitute.For(); + var dt = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + + DateTime result = dt.SafeAddTicks(TimeSpan.TicksPerMillisecond, OverflowBehavior.Constrain, logger); + + Assert.Equal(dt.AddTicks(TimeSpan.TicksPerMillisecond), result); + logger.DidNotReceiveWithAnyArgs().Log(default, default, default, default, default!); + } + + [Fact] + public void GivenDateTimeNearMaxValue_WhenSafeAddDaysWithLoggerAndConstrainBehavior_ThenLogsWarning() + { + var logger = Substitute.For(); + var dt = DateTime.MaxValue.AddDays(-0.5); + + DateTime result = dt.SafeAddDays(1, OverflowBehavior.Constrain, logger); + + Assert.Equal(DateTime.MaxValue, result); + logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + + [Fact] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddTicksWithLoggerAndConstrainBehavior_ThenLogsWarning() + { + var logger = Substitute.For(); + var dto = DateTimeOffset.MaxValue.AddTicks(-100); + + DateTimeOffset result = dto.SafeAddTicks(TimeSpan.TicksPerMillisecond, OverflowBehavior.Constrain, logger); + + Assert.Equal(DateTimeOffset.MaxValue, result); + logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + + [Fact] + public void GivenDateTimeOffsetNearMaxValue_WhenSafeAddDaysWithLoggerAndConstrainBehavior_ThenLogsWarning() + { + var logger = Substitute.For(); + var dto = DateTimeOffset.MaxValue.AddDays(-0.5); + + DateTimeOffset result = dto.SafeAddDays(1, OverflowBehavior.Constrain, logger); + + Assert.Equal(DateTimeOffset.MaxValue, result); + logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Extensions/DateTimeSafeExtensions.cs b/src/Microsoft.Health.Fhir.Core/Extensions/DateTimeSafeExtensions.cs new file mode 100644 index 0000000000..e1b160e889 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Extensions/DateTimeSafeExtensions.cs @@ -0,0 +1,209 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Health.Fhir.Core.Extensions +{ + /// + /// Defines behavior when DateTime or DateTimeOffset arithmetic overflows. + /// + public enum OverflowBehavior + { + /// + /// Constrain the result to representable bounds. For DateTime, this is or . + /// For DateTimeOffset with a non-zero offset, the bounds are adjusted to the nearest representable value for that offset. + /// This is the default and safest option. + /// + Constrain = 0, + + /// + /// Throw on overflow (same as standard AddTicks/AddDays). + /// + Throw = 1, + + /// + /// Return the original value unchanged on overflow. + /// + ReturnOriginal = 2, + } + + /// + /// Extension methods for and that provide safe arithmetic + /// operations with configurable overflow behavior. Default behavior constrains results to representable bounds + /// instead of throwing on overflow. For DateTime, bounds are /. + /// For DateTimeOffset, bounds are adjusted to the nearest representable values for the current offset. + /// + public static class DateTimeSafeExtensions + { + private const long MaxDaysBeforeTicksOverflow = long.MaxValue / TimeSpan.TicksPerDay; + + /// + /// Adds the specified number of ticks to a , constraining the result + /// to or on overflow. + /// + /// The DateTime value. + /// The number of ticks to add. + /// The behavior to apply on overflow (default: Constrain). + /// Optional logger for overflow events. + /// The result of adding ticks, or constrained/original value based on behavior. + public static DateTime SafeAddTicks(this DateTime value, long ticks, OverflowBehavior behavior = OverflowBehavior.Constrain, ILogger? logger = null) + { + if (ticks == 0) + { + return value; + } + + try + { + return value.AddTicks(ticks); + } + catch (ArgumentOutOfRangeException ex) + { + logger?.LogWarning(ex, "DateTime.AddTicks overflow: value={DateTime}, ticks={Ticks}", value, ticks); + + if (behavior == OverflowBehavior.Throw) + { + throw; + } + + if (behavior == OverflowBehavior.ReturnOriginal) + { + return value; + } + + return ticks > 0 + ? new DateTime(DateTime.MaxValue.Ticks, value.Kind) + : new DateTime(DateTime.MinValue.Ticks, value.Kind); + } + } + + /// + /// Adds the specified number of ticks to a , constraining the result + /// to the nearest representable value (for the current offset) on overflow. + /// + /// The DateTimeOffset value. + /// The number of ticks to add. + /// The behavior to apply on overflow (default: Constrain). + /// Optional logger for overflow events. + /// The result of adding ticks, or constrained/original value based on behavior. + public static DateTimeOffset SafeAddTicks(this DateTimeOffset value, long ticks, OverflowBehavior behavior = OverflowBehavior.Constrain, ILogger? logger = null) + { + if (ticks == 0) + { + return value; + } + + try + { + return value.AddTicks(ticks); + } + catch (ArgumentOutOfRangeException ex) + { + logger?.LogWarning(ex, "DateTimeOffset.AddTicks overflow: value={DateTimeOffset}, ticks={Ticks}", value, ticks); + + if (behavior == OverflowBehavior.Throw) + { + throw; + } + + if (behavior == OverflowBehavior.ReturnOriginal) + { + return value; + } + + // Calculate max/min representable local ticks for this offset. + // UTC = Local - Offset, so Local = UTC + Offset. + // Max UTC ticks = DateTimeOffset.MaxValue.Ticks, so max Local ticks = DateTimeOffset.MaxValue.Ticks + Offset.Ticks. + // When Offset >= 0, max stays at DateTimeOffset.MaxValue.Ticks (no UTC overflow). + // When Offset < 0, max = DateTimeOffset.MaxValue.Ticks + Offset.Ticks (safe addition). + long maxTicks = value.Offset.Ticks >= 0 + ? DateTimeOffset.MaxValue.Ticks + : DateTimeOffset.MaxValue.Ticks + value.Offset.Ticks; + + long minTicks = value.Offset.Ticks <= 0 + ? DateTimeOffset.MinValue.Ticks + : DateTimeOffset.MinValue.Ticks + value.Offset.Ticks; + + return ticks > 0 + ? new DateTimeOffset(maxTicks, value.Offset) + : new DateTimeOffset(minTicks, value.Offset); + } + } + + /// + /// Adds the specified number of days to a , constraining the result + /// to or on overflow. + /// + /// The DateTime value. + /// The number of days to add. + /// The behavior to apply on overflow (default: Constrain). + /// Optional logger for overflow events. + /// The result of adding days, or constrained/original value based on behavior. + public static DateTime SafeAddDays(this DateTime value, int days, OverflowBehavior behavior = OverflowBehavior.Constrain, ILogger? logger = null) + { + // Detect if days * TimeSpan.TicksPerDay would overflow long. + if (days > MaxDaysBeforeTicksOverflow || days < -MaxDaysBeforeTicksOverflow) + { + logger?.LogWarning("DateTime.AddDays overflow: value={DateTime}, days={Days}", value, days); + + if (behavior == OverflowBehavior.Throw) + { + throw new ArgumentOutOfRangeException(nameof(days)); + } + + if (behavior == OverflowBehavior.ReturnOriginal) + { + return value; + } + + return days > 0 ? new DateTime(DateTime.MaxValue.Ticks, value.Kind) : new DateTime(DateTime.MinValue.Ticks, value.Kind); + } + + long ticks = days * TimeSpan.TicksPerDay; + return value.SafeAddTicks(ticks, behavior, logger); + } + + /// + /// Adds the specified number of days to a , constraining the result + /// to the nearest representable value (for the current offset) on overflow. + /// + /// The DateTimeOffset value. + /// The number of days to add. + /// The behavior to apply on overflow (default: Constrain). + /// Optional logger for overflow events. + /// The result of adding days, or constrained/original value based on behavior. + public static DateTimeOffset SafeAddDays(this DateTimeOffset value, int days, OverflowBehavior behavior = OverflowBehavior.Constrain, ILogger? logger = null) + { + // Detect if days * TimeSpan.TicksPerDay would overflow long. + if (days > MaxDaysBeforeTicksOverflow || days < -MaxDaysBeforeTicksOverflow) + { + logger?.LogWarning("DateTimeOffset.AddDays overflow: value={DateTimeOffset}, days={Days}", value, days); + + if (behavior == OverflowBehavior.Throw) + { + throw new ArgumentOutOfRangeException(nameof(days)); + } + + if (behavior == OverflowBehavior.ReturnOriginal) + { + return value; + } + + // Constrain to the representable range for the current offset. + long offsetTicks = value.Offset.Ticks; + long maxTicks = offsetTicks < 0 ? DateTime.MaxValue.Ticks + offsetTicks : DateTime.MaxValue.Ticks; + long minTicks = offsetTicks > 0 ? DateTime.MinValue.Ticks + offsetTicks : DateTime.MinValue.Ticks; + return days > 0 ? new DateTimeOffset(maxTicks, value.Offset) : new DateTimeOffset(minTicks, value.Offset); + } + + long ticks = days * TimeSpan.TicksPerDay; + return value.SafeAddTicks(ticks, behavior, logger); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/SearchValueExpressionBuilderHelper.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/SearchValueExpressionBuilderHelper.cs index 8b76fa3d7e..ff3d44fa65 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/SearchValueExpressionBuilderHelper.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/Parsers/SearchValueExpressionBuilderHelper.cs @@ -102,8 +102,8 @@ void ISearchValueVisitor.Visit(DateTimeSearchValue dateTime) var differenceTicks = (long)((Clock.UtcNow.Ticks - Math.Max(startTicks, endTicks)) * ApproximateMultiplier); - var approximateStart = dateTime.Start.AddTicks(-differenceTicks); - var approximateEnd = dateTime.End.AddTicks(differenceTicks); + var approximateStart = dateTime.Start.SafeAddTicks(-differenceTicks); + var approximateEnd = dateTime.End.SafeAddTicks(differenceTicks); // Spec (ap): the search range overlaps the target range. Emit overlap directly // (Start <= approxEnd AND End >= approxStart) rather than the eq-shaped containment. diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs index c40cdb1cc2..f779a151ae 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------- using System; +using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Search.Expressions; using Microsoft.Health.Fhir.ValueSets; @@ -41,7 +42,7 @@ expression.Expressions[0] is BinaryExpression isLong && return Expression.And( Expression.Equals(SqlFieldName.DateTimeIsLongerThanADay, left.ComponentIndex, false), new BinaryExpression(left.BinaryOperator, FieldName.DateTimeEnd, left.ComponentIndex, left.Value), - new BinaryExpression(left.BinaryOperator, FieldName.DateTimeStart, left.ComponentIndex, ((DateTimeOffset)left.Value).AddTicks(-TimeSpan.TicksPerDay)), + new BinaryExpression(left.BinaryOperator, FieldName.DateTimeStart, left.ComponentIndex, ((DateTimeOffset)left.Value).SafeAddTicks(-TimeSpan.TicksPerDay)), new BinaryExpression(right.BinaryOperator, FieldName.DateTimeStart, right.ComponentIndex, right.Value)); } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs index 551437a1ae..0eece5b08f 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs @@ -5,6 +5,7 @@ using System; using Microsoft.Health.Core.Extensions; +using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Features.Search.Expressions; using Microsoft.Health.Fhir.SqlServer.Features.Storage; @@ -56,7 +57,7 @@ public override Expression VisitBinary(BinaryExpression expression, object conte return Expression.GreaterThanOrEqual( SqlFieldName.ResourceSurrogateId, null, - new DateTimeOffset(truncated.AddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); + new DateTimeOffset(truncated.SafeAddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); case BinaryOperator.GreaterThanOrEqual: if (original == truncated) { @@ -81,7 +82,7 @@ public override Expression VisitBinary(BinaryExpression expression, object conte return Expression.LessThan( SqlFieldName.ResourceSurrogateId, null, - new DateTimeOffset(truncated.AddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); + new DateTimeOffset(truncated.SafeAddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); case BinaryOperator.NotEqual: case BinaryOperator.Equal: // expecting eq to have been rewritten as a range default: diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs index 90f985ef5c..85e20a4fbc 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features.Search.Expressions; using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; @@ -133,7 +134,7 @@ private static Precision ClassifyPrecision(DateTimeOffset start, DateTimeOffset return Precision.NotRewritable; } - if (end == start.AddDays(1).AddTicks(-1)) + if (end == start.SafeAddDays(1).SafeAddTicks(-1)) { return Precision.ExactDay; }