Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ public class AnalysisQueryEngine
private readonly IAnalysisCache? _cache;

/// <summary>
/// 使用預設 Resolver(Phase 1 一律 InProcess)與無快取,保持向後相容
/// 建立不帶快取的引擎,使用預設 Resolver(向後相容)
/// </summary>
public AnalysisQueryEngine() : this(GroupByStrategyResolver.Default, null) { }

/// <summary>
/// 使用指定的 GroupByStrategyResolver 與 快取,供測試或 Phase 2 注入
/// 使用指定的 Resolver,不帶快取
/// </summary>
public AnalysisQueryEngine(GroupByStrategyResolver resolver, IAnalysisCache? cache = null)
public AnalysisQueryEngine(GroupByStrategyResolver resolver) : this(resolver, null) { }

/// <summary>
/// 使用指定的快取,預設 Resolver。
/// </summary>
public AnalysisQueryEngine(IAnalysisCache? cache) : this(GroupByStrategyResolver.Default, cache) { }

/// <summary>
/// 使用指定的 Resolver 和快取。
/// </summary>
public AnalysisQueryEngine(GroupByStrategyResolver resolver, IAnalysisCache? cache)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_cache = cache;
Expand Down
99 changes: 99 additions & 0 deletions src/WalkingTec.Mvvm.Core/Analysis/DateTruncator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#nullable enable
using System;
using System.Linq.Expressions;

namespace WalkingTec.Mvvm.Core.Analysis
{
/// <summary>
/// 建構日期截斷 Expression,將 DateTime 轉為整數 key 用於 GroupBy。
/// 使用 .Year/.Month/.Day CLR 屬性,確保 MSSQL 和 Oracle EF Core provider 都能翻譯。
/// </summary>
public static class DateTruncator
{
/// <summary>
/// 根據 DateHierarchy 建構截斷 Expression。
/// 輸入為 DateTime 或 DateTime? 的 Expression,輸出為 int Expression。
/// </summary>
public static Expression BuildTruncExpression(Expression dateExpr, DateHierarchy hierarchy)
{
// Handle DateTime? -> extract .Value first (caller should filter nulls)
var actualExpr = dateExpr;
var exprType = dateExpr.Type;
if (exprType == typeof(DateTime?))
actualExpr = Expression.Property(dateExpr, nameof(Nullable<DateTime>.Value));

return hierarchy switch
{
DateHierarchy.Year =>
// x.OrderDate.Year -> e.g. 2026
Expression.Property(actualExpr, nameof(DateTime.Year)),

DateHierarchy.Quarter =>
// x.OrderDate.Year * 10 + ((x.OrderDate.Month - 1) / 3 + 1) -> e.g. 20261
BuildQuarterKey(actualExpr),

DateHierarchy.Month =>
// x.OrderDate.Year * 100 + x.OrderDate.Month -> e.g. 202603
BuildMonthKey(actualExpr),

DateHierarchy.Day =>
// x.OrderDate.Year * 10000 + x.OrderDate.Month * 100 + x.OrderDate.Day -> e.g. 20260309
BuildDayKey(actualExpr),

_ => throw new ArgumentException($"DateHierarchy.{hierarchy} is not supported for truncation.")
};
}

/// <summary>
/// 將整數 key 格式化為人類可讀字串(供前端或匯出使用)。
/// </summary>
public static string FormatKey(int key, DateHierarchy hierarchy)
{
return hierarchy switch
{
DateHierarchy.Year => key.ToString(),
DateHierarchy.Quarter => $"{key / 10} Q{key % 10}",
DateHierarchy.Month => $"{key / 100}-{key % 100:D2}",
DateHierarchy.Day => $"{key / 10000}-{key / 100 % 100:D2}-{key % 100:D2}",
_ => key.ToString()
};
}

private static Expression BuildQuarterKey(Expression dateExpr)
{
var year = Expression.Property(dateExpr, nameof(DateTime.Year));
var month = Expression.Property(dateExpr, nameof(DateTime.Month));
// (month - 1) / 3 + 1
var quarter = Expression.Add(
Expression.Divide(
Expression.Subtract(month, Expression.Constant(1)),
Expression.Constant(3)),
Expression.Constant(1));
// year * 10 + quarter
return Expression.Add(
Expression.Multiply(year, Expression.Constant(10)),
quarter);
}

private static Expression BuildMonthKey(Expression dateExpr)
{
var year = Expression.Property(dateExpr, nameof(DateTime.Year));
var month = Expression.Property(dateExpr, nameof(DateTime.Month));
return Expression.Add(
Expression.Multiply(year, Expression.Constant(100)),
month);
}

private static Expression BuildDayKey(Expression dateExpr)
{
var year = Expression.Property(dateExpr, nameof(DateTime.Year));
var month = Expression.Property(dateExpr, nameof(DateTime.Month));
var day = Expression.Property(dateExpr, nameof(DateTime.Day));
return Expression.Add(
Expression.Add(
Expression.Multiply(year, Expression.Constant(10000)),
Expression.Multiply(month, Expression.Constant(100))),
day);
}
}
}
129 changes: 129 additions & 0 deletions test/WalkingTec.Mvvm.Core.Test/Analysis/DateTruncatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#nullable enable
using System;
using System.Linq.Expressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WalkingTec.Mvvm.Core.Analysis;

namespace WalkingTec.Mvvm.Core.Test.Analysis
{
[TestClass]
public class DateTruncatorTests
{
private class TestModel
{
public DateTime OrderDate { get; set; }
public DateTime? ShipDate { get; set; }
}

// ── Year ───────────────────────────────────────────────

[TestMethod]
public void Year_truncation_returns_year_int()
{
var func = CompileDateTime(DateHierarchy.Year);
var result = func(new TestModel { OrderDate = new DateTime(2026, 3, 9) });
Assert.AreEqual(2026, result);
}

// ── Quarter ────────────────────────────────────────────

[DataTestMethod]
[DataRow(1, 20261)] // Jan -> Q1
[DataRow(3, 20261)] // Mar -> Q1
[DataRow(4, 20262)] // Apr -> Q2
[DataRow(6, 20262)] // Jun -> Q2
[DataRow(7, 20263)] // Jul -> Q3
[DataRow(9, 20263)] // Sep -> Q3
[DataRow(10, 20264)] // Oct -> Q4
[DataRow(12, 20264)] // Dec -> Q4
public void Quarter_truncation_maps_month_to_correct_quarter(int month, int expectedKey)
{
var func = CompileDateTime(DateHierarchy.Quarter);
var result = func(new TestModel { OrderDate = new DateTime(2026, month, 15) });
Assert.AreEqual(expectedKey, result);
}

// ── Month ──────────────────────────────────────────────

[TestMethod]
public void Month_truncation_returns_yearmonth_int()
{
var func = CompileDateTime(DateHierarchy.Month);
var result = func(new TestModel { OrderDate = new DateTime(2026, 3, 9) });
Assert.AreEqual(202603, result);
}

// ── Day ────────────────────────────────────────────────

[TestMethod]
public void Day_truncation_returns_yearmonthday_int()
{
var func = CompileDateTime(DateHierarchy.Day);
var result = func(new TestModel { OrderDate = new DateTime(2026, 3, 9) });
Assert.AreEqual(20260309, result);
}

// ── Nullable DateTime ──────────────────────────────────

[TestMethod]
public void Nullable_datetime_with_value_works_same_as_nonnullable()
{
var param = Expression.Parameter(typeof(TestModel), "x");
var dateProp = Expression.Property(param, nameof(TestModel.ShipDate));
var truncExpr = DateTruncator.BuildTruncExpression(dateProp, DateHierarchy.Month);
var lambda = Expression.Lambda<Func<TestModel, int>>(truncExpr, param).Compile();

var result = lambda(new TestModel { ShipDate = new DateTime(2026, 7, 20) });
Assert.AreEqual(202607, result);
}

// ── FormatKey ──────────────────────────────────────────

[TestMethod]
public void FormatKey_Year_returns_plain_year()
{
Assert.AreEqual("2026", DateTruncator.FormatKey(2026, DateHierarchy.Year));
}

[TestMethod]
public void FormatKey_Quarter_returns_year_space_quarter()
{
Assert.AreEqual("2026 Q1", DateTruncator.FormatKey(20261, DateHierarchy.Quarter));
Assert.AreEqual("2026 Q4", DateTruncator.FormatKey(20264, DateHierarchy.Quarter));
}

[TestMethod]
public void FormatKey_Month_returns_dash_separated()
{
Assert.AreEqual("2026-03", DateTruncator.FormatKey(202603, DateHierarchy.Month));
Assert.AreEqual("2026-12", DateTruncator.FormatKey(202612, DateHierarchy.Month));
}

[TestMethod]
public void FormatKey_Day_returns_iso_date()
{
Assert.AreEqual("2026-03-09", DateTruncator.FormatKey(20260309, DateHierarchy.Day));
}

// ── Invalid hierarchy ──────────────────────────────────

[TestMethod]
public void None_hierarchy_throws_ArgumentException()
{
var param = Expression.Parameter(typeof(TestModel), "x");
var dateProp = Expression.Property(param, nameof(TestModel.OrderDate));
Assert.ThrowsException<ArgumentException>(
() => DateTruncator.BuildTruncExpression(dateProp, DateHierarchy.None));
}

// ── Helpers ────────────────────────────────────────────

private static Func<TestModel, int> CompileDateTime(DateHierarchy hierarchy)
{
var param = Expression.Parameter(typeof(TestModel), "x");
var dateProp = Expression.Property(param, nameof(TestModel.OrderDate));
var truncExpr = DateTruncator.BuildTruncExpression(dateProp, hierarchy);
return Expression.Lambda<Func<TestModel, int>>(truncExpr, param).Compile();
}
}
}
Loading