diff --git a/src/Unosquare.DateTimeExt/BaseGrouper.cs b/src/Unosquare.DateTimeExt/BaseGrouper.cs new file mode 100644 index 0000000..11edf33 --- /dev/null +++ b/src/Unosquare.DateTimeExt/BaseGrouper.cs @@ -0,0 +1,20 @@ +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public abstract class BaseGrouper : IGrouper +{ + public abstract IEnumerable> GroupByDateRange(); + + public IDictionary> GroupByLabel() => + GroupByDateRange() + .ToDictionary(x => x.Key!.ToString()!, x => x.ToList()); + + public IDictionary> GroupByLabel(Func selector) => + GroupByDateRange() + .ToDictionary(x => x.Key!.ToString()!, x => x.Select(selector).ToList()); + + public IDictionary GroupCount() => + GroupByDateRange() + .ToDictionary(x => x.Key!.ToString()!, x => x.Count()); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/DateOnlyRange.cs b/src/Unosquare.DateTimeExt/DateOnlyRange.cs index 4e4891c..d2c8b34 100644 --- a/src/Unosquare.DateTimeExt/DateOnlyRange.cs +++ b/src/Unosquare.DateTimeExt/DateOnlyRange.cs @@ -6,7 +6,7 @@ namespace Unosquare.DateTimeExt; /// /// Represents a range of dates with only the date component (no time component). /// -public sealed class DateOnlyRange : RangeBase, IReadOnlyDateOnlyRange, IComparable, +public sealed class DateOnlyRange : RangeBase, IReadOnlyDateOnlyRange, IComparable, IEnumerable, IHasBusinessDaysDateOnly { /// @@ -25,7 +25,7 @@ public DateOnlyRange() /// The end date (optional). /// Thrown when the end date is before the start date. public DateOnlyRange(DateOnly startDate, DateOnly? endDate = null) - : base(startDate, endDate) + : base(startDate, endDate ?? startDate) { if (EndDate < StartDate) throw new ArgumentOutOfRangeException(nameof(endDate), "End Date should be after Start Date"); diff --git a/src/Unosquare.DateTimeExt/DateRange.cs b/src/Unosquare.DateTimeExt/DateRange.cs index d820274..76da2ef 100644 --- a/src/Unosquare.DateTimeExt/DateRange.cs +++ b/src/Unosquare.DateTimeExt/DateRange.cs @@ -6,7 +6,7 @@ namespace Unosquare.DateTimeExt; /// /// Represents a range of dates with a start and end date. /// -public class DateRange : RangeBase, IReadOnlyDateRange, IHasReadOnlyMidnightEndDate, IComparable, IEnumerable, IHasBusinessDays +public class DateRange : RangeBase, IReadOnlyDateRange, IHasReadOnlyMidnightEndDate, IComparable, IEnumerable, IHasBusinessDays { /// /// Initializes a new instance of the class with the current UTC date and time. @@ -22,7 +22,7 @@ public DateRange(IReadOnlyDateRange range) } public DateRange(DateTime startDate, DateTime? endDate = null) - : base(startDate, endDate) + : base(startDate, endDate ?? startDate) { if (EndDate < StartDate) throw new ArgumentOutOfRangeException(nameof(endDate), "End Date should be after Start Date"); diff --git a/src/Unosquare.DateTimeExt/Interfaces/ICanHaveReadOnlyEndDate.cs b/src/Unosquare.DateTimeExt/Interfaces/ICanHaveReadOnlyEndDate.cs new file mode 100644 index 0000000..a299432 --- /dev/null +++ b/src/Unosquare.DateTimeExt/Interfaces/ICanHaveReadOnlyEndDate.cs @@ -0,0 +1,6 @@ +namespace Unosquare.DateTimeExt.Interfaces; + +public interface ICanHaveReadOnlyEndDate +{ + DateTime? EndDate { get; } +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/Interfaces/IGrouper.cs b/src/Unosquare.DateTimeExt/Interfaces/IGrouper.cs new file mode 100644 index 0000000..ff0790d --- /dev/null +++ b/src/Unosquare.DateTimeExt/Interfaces/IGrouper.cs @@ -0,0 +1,12 @@ +namespace Unosquare.DateTimeExt.Interfaces; + +public interface IGrouper +{ + IEnumerable> GroupByDateRange(); + + IDictionary> GroupByLabel(); + + IDictionary> GroupByLabel(Func selector); + + IDictionary GroupCount(); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/Interfaces/IReadOnlyOpenDateRange.cs b/src/Unosquare.DateTimeExt/Interfaces/IReadOnlyOpenDateRange.cs new file mode 100644 index 0000000..0481888 --- /dev/null +++ b/src/Unosquare.DateTimeExt/Interfaces/IReadOnlyOpenDateRange.cs @@ -0,0 +1,5 @@ +namespace Unosquare.DateTimeExt.Interfaces; + +public interface IReadOnlyOpenDateRange : IHasReadOnlyStartDate, ICanHaveReadOnlyEndDate +{ +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/OpenDateRange.cs b/src/Unosquare.DateTimeExt/OpenDateRange.cs new file mode 100644 index 0000000..8812ba9 --- /dev/null +++ b/src/Unosquare.DateTimeExt/OpenDateRange.cs @@ -0,0 +1,46 @@ +using System.Collections; +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public class OpenDateRange(DateTime startDate, DateTime? endDate = null) : RangeBase(startDate, endDate), IReadOnlyOpenDateRange, IComparable, IEnumerable, IHasBusinessDays +{ + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public override IEnumerator GetEnumerator() + { + if (EndDate is not null) + { + var daysInBetween = (EndDate.Value - StartDate).Days; + for (var i = 0; i <= daysInBetween; i++) + yield return StartDate.AddDays(i); + } + else + { + var i = 0; + + while (DateTime.MaxValue != StartDate.AddDays(i)) + yield return StartDate.AddDays(++i); + } + } + + public int CompareTo(OpenDateRange? other) + { + if (ReferenceEquals(this, other)) + return 0; + + if (other is null) + return 1; + + var startDateComparison = StartDate.CompareTo(other.StartDate); + + return startDateComparison != 0 ? startDateComparison : (EndDate?.CompareTo(other.EndDate) ?? 0); + } + + public override string ToString() => $"{StartDate.ToShortDateString()} - {EndDate?.ToShortDateString()}"; + + public override int GetHashCode() => StartDate.GetHashCode() + EndDate.GetHashCode(); + + public DateTime FirstBusinessDay => StartDate.GetFirstBusinessDayOfMonth(); + public DateTime LastBusinessDay => (EndDate ?? DateTime.Now).GetLastBusinessDayOfMonth(); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/RangeBase.cs b/src/Unosquare.DateTimeExt/RangeBase.cs index 8f2ffa7..f356193 100644 --- a/src/Unosquare.DateTimeExt/RangeBase.cs +++ b/src/Unosquare.DateTimeExt/RangeBase.cs @@ -1,18 +1,18 @@ namespace Unosquare.DateTimeExt; -public abstract class RangeBase where T : struct +public abstract class RangeBase where TStart : struct { - protected RangeBase(T startDate, T? endDate = default) + protected RangeBase(TStart startDate, TEnd endDate) { StartDate = startDate; - EndDate = endDate ?? startDate; + EndDate = endDate; } - public T StartDate { get; } + public TStart StartDate { get; } - public T EndDate { get; } + public TEnd EndDate { get; } - public IEnumerable Select(Func selector) + public IEnumerable Select(Func selector) { using var enumerator = GetEnumerator(); @@ -20,9 +20,9 @@ public IEnumerable Select(Func selector) yield return selector(enumerator.Current); } - public abstract IEnumerator GetEnumerator(); + public abstract IEnumerator GetEnumerator(); - public void Deconstruct(out T startDate, out T endDate) + public void Deconstruct(out TStart startDate, out TEnd endDate) { startDate = StartDate; endDate = EndDate; diff --git a/src/Unosquare.DateTimeExt/Unosquare.DateTimeExt.csproj b/src/Unosquare.DateTimeExt/Unosquare.DateTimeExt.csproj index aac046a..afcdfeb 100644 --- a/src/Unosquare.DateTimeExt/Unosquare.DateTimeExt.csproj +++ b/src/Unosquare.DateTimeExt/Unosquare.DateTimeExt.csproj @@ -4,9 +4,10 @@ net8.0;net6.0 enable enable + preview latest ..\..\StyleCop.Analyzers.ruleset - 1.2.1 + 1.3.0 diff --git a/src/Unosquare.DateTimeExt/YearMonthGrouper.cs b/src/Unosquare.DateTimeExt/YearMonthGrouper.cs new file mode 100644 index 0000000..20d1459 --- /dev/null +++ b/src/Unosquare.DateTimeExt/YearMonthGrouper.cs @@ -0,0 +1,8 @@ +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public class YearMonthGrouper(IEnumerable query) : BaseGrouper where T : IYearMonth +{ + public override IEnumerable> GroupByDateRange() => query.GroupBy(x => new YearMonth(x.Month, x.Year)); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/YearMonthRecordGrouper.cs b/src/Unosquare.DateTimeExt/YearMonthRecordGrouper.cs new file mode 100644 index 0000000..e912542 --- /dev/null +++ b/src/Unosquare.DateTimeExt/YearMonthRecordGrouper.cs @@ -0,0 +1,8 @@ +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public class YearMonthRecordGrouper(IEnumerable query) : BaseGrouper where T : IYearMonth +{ + public override IEnumerable> GroupByDateRange() => query.GroupBy(x => new YearMonthRecord { Month = x.Month, Year = x.Year }); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/YearQuarterGrouper.cs b/src/Unosquare.DateTimeExt/YearQuarterGrouper.cs new file mode 100644 index 0000000..5973fd8 --- /dev/null +++ b/src/Unosquare.DateTimeExt/YearQuarterGrouper.cs @@ -0,0 +1,8 @@ +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public class YearQuarterGrouper(IEnumerable query) : BaseGrouper where T : IYearQuarter +{ + public override IEnumerable> GroupByDateRange() => query.GroupBy(x => new YearQuarter(x.Quarter, x.Year)); +} \ No newline at end of file diff --git a/src/Unosquare.DateTimeExt/YearQuarterRecordGrouper.cs b/src/Unosquare.DateTimeExt/YearQuarterRecordGrouper.cs new file mode 100644 index 0000000..a4d1436 --- /dev/null +++ b/src/Unosquare.DateTimeExt/YearQuarterRecordGrouper.cs @@ -0,0 +1,8 @@ +using Unosquare.DateTimeExt.Interfaces; + +namespace Unosquare.DateTimeExt; + +public class YearQuarterRecordGrouper(IEnumerable query) : BaseGrouper where T : IYearQuarter +{ + public override IEnumerable> GroupByDateRange() => query.GroupBy(x => new YearQuarterRecord { Year = x.Year, Quarter = x.Quarter }); +} \ No newline at end of file diff --git a/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/OpenDateRangeTest.cs b/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/OpenDateRangeTest.cs new file mode 100644 index 0000000..c051534 --- /dev/null +++ b/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/OpenDateRangeTest.cs @@ -0,0 +1,188 @@ +namespace Unosquare.DateTimeExt.Test; + +public class OpenDateRangeTests +{ + [Fact] + public void GetEnumerator_ShouldReturnAllDatesInRange_WhenEndDateIsNotNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var endDate = new DateTime(2022, 1, 5); + var openDateRange = new OpenDateRange(startDate, endDate); + var expectedDates = new List + { + new(2022, 1, 1), + new(2022, 1, 2), + new(2022, 1, 3), + new(2022, 1, 4), + new(2022, 1, 5) + }; + + // Act + var actualDates = new List(openDateRange); + + // Assert + Assert.Equal(expectedDates, actualDates); + } + + [Fact] + public void GetEnumerator_ShouldReturnAllDatesFromStartDateToMaxValue_WhenEndDateIsNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var openDateRange = new OpenDateRange(startDate); + var expectedDates = new List + { + new(2022, 1, 2), + new(2022, 1, 3), + }; + + // Act + var actualDates = openDateRange.Take(2).ToList(); + + // Assert + Assert.Equal(expectedDates, actualDates); + } + + [Fact] + public void CompareTo_ShouldReturnNegativeValue_WhenStartDateIsEarlier() + { + // Arrange + var startDate1 = new DateTime(2022, 1, 1); + var startDate2 = new DateTime(2022, 1, 2); + var openDateRange1 = new OpenDateRange(startDate1); + var openDateRange2 = new OpenDateRange(startDate2); + + // Act + var result = openDateRange1.CompareTo(openDateRange2); + + // Assert + Assert.True(result < 0); + } + + [Fact] + public void CompareTo_ShouldReturnPositiveValue_WhenStartDateIsLater() + { + // Arrange + var startDate1 = new DateTime(2022, 1, 2); + var startDate2 = new DateTime(2022, 1, 1); + var openDateRange1 = new OpenDateRange(startDate1); + var openDateRange2 = new OpenDateRange(startDate2); + + // Act + var result = openDateRange1.CompareTo(openDateRange2); + + // Assert + Assert.True(result > 0); + } + + [Fact] + public void CompareTo_ShouldReturnZero_WhenStartDateAndEndDateAreEqual() + { + // Arrange + var startDate1 = new DateTime(2022, 1, 1); + var startDate2 = new DateTime(2022, 1, 1); + var openDateRange1 = new OpenDateRange(startDate1); + var openDateRange2 = new OpenDateRange(startDate2); + + // Act + var result = openDateRange1.CompareTo(openDateRange2); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void ToString_ShouldReturnFormattedString_WhenEndDateIsNotNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var endDate = new DateTime(2022, 1, 5); + var openDateRange = new OpenDateRange(startDate, endDate); + const string expectedString = "1/1/2022 - 1/5/2022"; + + // Act + var actualString = openDateRange.ToString(); + + // Assert + Assert.Equal(expectedString, actualString); + } + + [Fact] + public void ToString_ShouldReturnFormattedString_WhenEndDateIsNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var openDateRange = new OpenDateRange(startDate); + const string expectedString = "1/1/2022 - "; + + // Act + var actualString = openDateRange.ToString(); + + // Assert + Assert.Equal(expectedString, actualString); + } + + [Fact] + public void GetHashCode_ShouldReturnSameValue_WhenStartDateAndEndDateAreEqual() + { + // Arrange + var startDate1 = new DateTime(2022, 1, 1); + var startDate2 = new DateTime(2022, 1, 1); + var openDateRange1 = new OpenDateRange(startDate1); + var openDateRange2 = new OpenDateRange(startDate2); + + // Act + var hashCode1 = openDateRange1.GetHashCode(); + var hashCode2 = openDateRange2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void FirstBusinessDay_ShouldReturnFirstBusinessDayOfMonth() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var openDateRange = new OpenDateRange(startDate); + var expectedFirstBusinessDay = new DateTime(2022, 1, 3); + + // Act + var actualFirstBusinessDay = openDateRange.FirstBusinessDay; + + // Assert + Assert.Equal(expectedFirstBusinessDay, actualFirstBusinessDay); + } + + [Fact] + public void LastBusinessDay_ShouldReturnLastBusinessDayOfMonth_WhenEndDateIsNotNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var endDate = new DateTime(2022, 1, 31); + var openDateRange = new OpenDateRange(startDate, endDate); + var expectedLastBusinessDay = new DateTime(2022, 1, 31); + + // Act + var actualLastBusinessDay = openDateRange.LastBusinessDay; + + // Assert + Assert.Equal(expectedLastBusinessDay, actualLastBusinessDay); + } + + [Fact] + public void LastBusinessDay_ShouldReturnLastBusinessDayOfMonth_WhenEndDateIsNull() + { + // Arrange + var startDate = new DateTime(2022, 1, 1); + var openDateRange = new OpenDateRange(startDate); + var expectedLastBusinessDay = DateTime.Now.GetLastBusinessDayOfMonth(); + + // Act + var actualLastBusinessDay = openDateRange.LastBusinessDay; + + // Assert + Assert.Equal(expectedLastBusinessDay, actualLastBusinessDay); + } +} \ No newline at end of file diff --git a/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/YearMonthGrouperTest.cs b/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/YearMonthGrouperTest.cs new file mode 100644 index 0000000..6a829f7 --- /dev/null +++ b/test/Unosquare.DateTimeExt.Test/Unosquare.DateTimeExt.Test/YearMonthGrouperTest.cs @@ -0,0 +1,85 @@ +namespace Unosquare.DateTimeExt.Test; + +public class YearMonthGrouperTests +{ + private readonly List _data = new() + { + new() { Year = 2022, Month = 1, Total = 5 }, + new() { Year = 2022, Month = 2, Total = 6 }, + new() { Year = 2022, Month = 2, Total = 1 }, + new() { Year = 2022, Month = 1, Total = 1 }, + new() { Year = 2022, Month = 2, Total = 1 }, + new() { Year = 2022, Month = 1, Total = 1 } + }; + + [Fact] + public void GroupByDateRange_ShouldGroupByYearMonth() + { + // Arrange + var grouper = new YearMonthGrouper(_data); + + // Act + var result = grouper.GroupByDateRange().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal(new(1, 2022), result[0].Key); + Assert.Equal(new(2, 2022), result[1].Key); + Assert.Equal(3, result[0].Count()); + Assert.Equal(3, result[1].Count()); + } + + [Fact] + public void GroupByLabel_ShouldGroupByLabel() + { + // Arrange + var grouper = new YearMonthGrouper(_data); + + // Act + var result = grouper.GroupByLabel().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("2022-01", result[0].Key); + Assert.Equal("2022-02", result[1].Key); + Assert.Equal(3, result[0].Value.Count); + Assert.Equal(3, result[1].Value.Count); + } + + [Fact] + public void GroupByLabel_ShouldGroupByLabelWithTotals() + { + // Arrange + var grouper = new YearMonthGrouper(_data); + + // Act + var result = grouper.GroupByLabel(x => x.Total).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("2022-02", result[1].Key); + Assert.Equal(7, result[0].Value.Sum()); + Assert.Equal(8, result[1].Value.Sum()); + } + + [Fact] + public void GroupByCount_ShouldGroupByCount() + { + // Arrange + var grouper = new YearMonthGrouper(_data); + + // Act + var result = grouper.GroupCount().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("2022-02", result[1].Key); + Assert.Equal(3, result[0].Value); + Assert.Equal(3, result[1].Value); + } + + private sealed record YearMonthRecordWithData : YearMonthRecord + { + public int Total { get; init; } + } +} \ No newline at end of file