diff --git a/.gitignore b/.gitignore index 28572a3b9..c33149d66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # wtm custom +.worktrees/ demo/WalkingTec.Mvvm.Demo/wwwroot/layuiadminsrc .Publish/ diff --git a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs index c93ecf36c..38d553191 100644 --- a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs +++ b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs @@ -1,7 +1,7 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.Linq; +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -17,6 +17,21 @@ public class AnalysisQueryEngine // 防止全表載入造成記憶體耗盡(C-1) private const int MaxMaterializeRows = 50_000; + private readonly IAnalysisCache? _cache; + + /// + /// 建立不帶快取的引擎(向後相容)。 + /// + public AnalysisQueryEngine() : this(null) { } + + /// + /// 建立帶快取的引擎。傳入 null 表示不使用快取。 + /// + public AnalysisQueryEngine(IAnalysisCache? cache) + { + _cache = cache; + } + /// /// 執行分析查詢,回傳聚合結果。 /// @@ -28,6 +43,13 @@ public AnalysisQueryResponse Execute( var wl = whitelist.ToDictionary(f => f.FieldName); ValidateFields(req, wl); + // 先計算 hash,用於快取查詢(hash 僅由 request 決定,與資料無關) + var queryHash = ComputeHash(req); + + // 快取命中時直接回傳 + if (_cache != null && _cache.TryGet(queryHash, out var cached) && cached != null) + return cached; + var filtered = ApplyFilters(baseQuery, req.Filters, wl); var rows = ExecuteGroupBy(filtered, req, wl); int totalCount = rows.Count; // 截斷前的真實筆數(I-3) @@ -38,41 +60,45 @@ public AnalysisQueryResponse Execute( .Concat(req.Measures.Select(m => $"{m.Field}_{m.Func}")) .ToList(); - return new AnalysisQueryResponse + var response = new AnalysisQueryResponse { Columns = columns, Rows = rows, TotalCount = totalCount, Truncated = truncated, - QueryHash = ComputeHash(req) + QueryHash = queryHash }; + + _cache?.Set(queryHash, response); + + return response; } /// /// 非泛型入口,供 Controller 使用(IQueryable 無型別參數時)。 /// - public AnalysisQueryResponse ExecuteDynamic( - IQueryable baseQuery, - AnalysisQueryRequest req, - IEnumerable whitelist) - { - var elementType = baseQuery.ElementType; - var method = typeof(AnalysisQueryEngine) - .GetMethod(nameof(Execute)); - if (method is null) - throw new InvalidOperationException("Execute method not found."); - method = method.MakeGenericMethod(elementType); - try - { - var result = method.Invoke(this, new object[] { baseQuery, req, whitelist }) as AnalysisQueryResponse; - if (result is null) - throw new InvalidOperationException("ExecuteDynamic did not return a valid AnalysisQueryResponse."); - return result; - } - catch (System.Reflection.TargetInvocationException ex) - { - throw ex.InnerException ?? ex; - } + public AnalysisQueryResponse ExecuteDynamic( + IQueryable baseQuery, + AnalysisQueryRequest req, + IEnumerable whitelist) + { + var elementType = baseQuery.ElementType; + var method = typeof(AnalysisQueryEngine) + .GetMethod(nameof(Execute)); + if (method is null) + throw new InvalidOperationException("Execute method not found."); + method = method.MakeGenericMethod(elementType); + try + { + var result = method.Invoke(this, new object[] { baseQuery, req, whitelist }) as AnalysisQueryResponse; + if (result is null) + throw new InvalidOperationException("ExecuteDynamic did not return a valid AnalysisQueryResponse."); + return result; + } + catch (System.Reflection.TargetInvocationException ex) + { + throw ex.InnerException ?? ex; + } } private static void ValidateFields(AnalysisQueryRequest req, Dictionary wl) @@ -130,17 +156,17 @@ private static IQueryable ApplyFilters( case FilterOperator.Lte: body = Expression.LessThanOrEqual(prop, constant); break; - case FilterOperator.Contains: - if (meta.ClrType != typeof(string)) - throw new InvalidOperationException( - $"Contains 只適用於字串欄位,'{filter.Field}' 的型別為 {meta.ClrType.Name}。"); - var containsMethod = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) }); - if (containsMethod is null) - throw new InvalidOperationException("string.Contains(string) method not found."); - body = Expression.Call(prop, - containsMethod, - constant); - break; + case FilterOperator.Contains: + if (meta.ClrType != typeof(string)) + throw new InvalidOperationException( + $"Contains 只適用於字串欄位,'{filter.Field}' 的型別為 {meta.ClrType.Name}。"); + var containsMethod = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) }); + if (containsMethod is null) + throw new InvalidOperationException("string.Contains(string) method not found."); + body = Expression.Call(prop, + containsMethod, + constant); + break; default: throw new InvalidOperationException($"Operator '{filter.Operator}' is not supported."); } @@ -150,11 +176,11 @@ private static IQueryable ApplyFilters( return query; } - private static object? ConvertValue(string value, Type targetType) - { - var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; - try - { + private static object? ConvertValue(string value, Type targetType) + { + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + try + { return Convert.ChangeType(value, underlying); } catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) @@ -163,10 +189,10 @@ private static IQueryable ApplyFilters( } } - private static List> ExecuteGroupBy( - IQueryable query, - AnalysisQueryRequest req, - Dictionary wl) + private static List> ExecuteGroupBy( + IQueryable query, + AnalysisQueryRequest req, + Dictionary wl) { // Phase 1: materialise then group in-process (SQLite + InMemory safe) // 限制載入筆數防止 OOM(C-1);超出上限時查詢結果可能不完整,由呼叫端決策 @@ -174,22 +200,22 @@ private static IQueryable ApplyFilters( return items .GroupBy(row => BuildGroupKey(row, req.Dimensions)) - .Take(MaxRows + 1) - .Select(g => - { - var dict = new Dictionary(); - var keyParts = g.Key.Split('\0'); - for (int i = 0; i < req.Dimensions.Count; i++) - dict[req.Dimensions[i]] = keyParts[i]; - - foreach (var m in req.Measures) - { - var propInfo = typeof(TModel).GetProperty(m.Field); - if (propInfo is null) - throw new InvalidOperationException($"Property '{m.Field}' not found on {typeof(TModel).Name}."); - // 過濾 null 值,避免 nullable 型別的 Convert.ToDecimal 例外(I-10) - var values = g - .Select(row => propInfo.GetValue(row)) + .Take(MaxRows + 1) + .Select(g => + { + var dict = new Dictionary(); + var keyParts = g.Key.Split('\0'); + for (int i = 0; i < req.Dimensions.Count; i++) + dict[req.Dimensions[i]] = keyParts[i]; + + foreach (var m in req.Measures) + { + var propInfo = typeof(TModel).GetProperty(m.Field); + if (propInfo is null) + throw new InvalidOperationException($"Property '{m.Field}' not found on {typeof(TModel).Name}."); + // 過濾 null 值,避免 nullable 型別的 Convert.ToDecimal 例外(I-10) + var values = g + .Select(row => propInfo.GetValue(row)) .Where(v => v != null) .Select(v => Convert.ToDecimal(v)) .ToList(); @@ -206,18 +232,18 @@ private static IQueryable ApplyFilters( dict[$"{m.Field}_{m.Func}"] = aggValue; } return dict; - }) - .ToList(); - } - - private static string BuildGroupKey(TModel row, List dimensions) - => string.Join('\0', dimensions.Select(d => - { - var propInfo = typeof(TModel).GetProperty(d); - if (propInfo is null) - throw new InvalidOperationException($"Property '{d}' not found on {typeof(TModel).Name}."); - return propInfo.GetValue(row)?.ToString() ?? string.Empty; - })); + }) + .ToList(); + } + + private static string BuildGroupKey(TModel row, List dimensions) + => string.Join('\0', dimensions.Select(d => + { + var propInfo = typeof(TModel).GetProperty(d); + if (propInfo is null) + throw new InvalidOperationException($"Property '{d}' not found on {typeof(TModel).Name}."); + return propInfo.GetValue(row)?.ToString() ?? string.Empty; + })); private static string ComputeHash(AnalysisQueryRequest req) { diff --git a/src/WalkingTec.Mvvm.Core/Analysis/IAnalysisCache.cs b/src/WalkingTec.Mvvm.Core/Analysis/IAnalysisCache.cs new file mode 100644 index 000000000..e0c100ebe --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Analysis/IAnalysisCache.cs @@ -0,0 +1,16 @@ +#nullable enable +using System; + +namespace WalkingTec.Mvvm.Core.Analysis +{ + /// + /// 分析查詢快取介面,用於快取 QueryHash 對應的查詢結果。 + /// + public interface IAnalysisCache + { + bool TryGet(string queryHash, out AnalysisQueryResponse? cached); + void Set(string queryHash, AnalysisQueryResponse response, TimeSpan? ttl = null); + void Invalidate(string queryHash); + void InvalidateAll(); + } +} diff --git a/src/WalkingTec.Mvvm.Core/Analysis/MemoryAnalysisCache.cs b/src/WalkingTec.Mvvm.Core/Analysis/MemoryAnalysisCache.cs new file mode 100644 index 000000000..a4b051100 --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Analysis/MemoryAnalysisCache.cs @@ -0,0 +1,62 @@ +#nullable enable +using System; +using System.Threading; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Primitives; + +namespace WalkingTec.Mvvm.Core.Analysis +{ + /// + /// 基於 IMemoryCache 的分析查詢快取實作。 + /// 使用 CancellationTokenSource 實現 InvalidateAll(IMemoryCache 無 Clear 方法)。 + /// + public class MemoryAnalysisCache : IAnalysisCache + { + private readonly IMemoryCache _cache; + private readonly TimeSpan _defaultTtl; + private CancellationTokenSource _cts; + private readonly object _lock = new(); + + public MemoryAnalysisCache(IMemoryCache cache, TimeSpan? defaultTtl = null) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _defaultTtl = defaultTtl ?? TimeSpan.FromMinutes(5); + _cts = new CancellationTokenSource(); + } + + public bool TryGet(string queryHash, out AnalysisQueryResponse? cached) + { + return _cache.TryGetValue(queryHash, out cached); + } + + public void Set(string queryHash, AnalysisQueryResponse response, TimeSpan? ttl = null) + { + var options = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl ?? _defaultTtl + }; + + lock (_lock) + { + options.AddExpirationToken(new CancellationChangeToken(_cts.Token)); + } + + _cache.Set(queryHash, response, options); + } + + public void Invalidate(string queryHash) + { + _cache.Remove(queryHash); + } + + public void InvalidateAll() + { + lock (_lock) + { + _cts.Cancel(); + _cts.Dispose(); + _cts = new CancellationTokenSource(); + } + } + } +} diff --git a/src/WalkingTec.Mvvm.Core/Analysis/NullAnalysisCache.cs b/src/WalkingTec.Mvvm.Core/Analysis/NullAnalysisCache.cs new file mode 100644 index 000000000..e30bb0aa6 --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Analysis/NullAnalysisCache.cs @@ -0,0 +1,21 @@ +#nullable enable +using System; + +namespace WalkingTec.Mvvm.Core.Analysis +{ + /// + /// 不執行任何快取的空實作,用於停用快取時。 + /// + public class NullAnalysisCache : IAnalysisCache + { + public bool TryGet(string queryHash, out AnalysisQueryResponse? cached) + { + cached = null; + return false; + } + + public void Set(string queryHash, AnalysisQueryResponse response, TimeSpan? ttl = null) { } + public void Invalidate(string queryHash) { } + public void InvalidateAll() { } + } +} diff --git a/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj b/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj index e37d7a080..a1fd22ab3 100644 --- a/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj +++ b/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs b/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs index dab370cc6..27e2042db 100644 --- a/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs +++ b/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs @@ -546,6 +546,8 @@ public static IServiceCollection AddWtmContext(this IServiceCollection services, var analysisRegistry = new WalkingTec.Mvvm.Core.Analysis.AnalysisVmRegistry(); analysisRegistry.Build(AppDomain.CurrentDomain.GetAssemblies()); services.AddSingleton(analysisRegistry); + services.AddMemoryCache(); + services.AddSingleton(); var cs = conf.Connections.Where(x => x.Enabled).ToList(); foreach (var item in cs) { diff --git a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs index 80d76022a..ec12f1b0e 100644 --- a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs +++ b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs @@ -21,9 +21,13 @@ namespace WalkingTec.Mvvm.Mvc public class _AnalysisController : BaseController { private readonly AnalysisVmRegistry _registry; + private readonly IAnalysisCache? _cache; - public _AnalysisController(AnalysisVmRegistry registry) - => _registry = registry; + public _AnalysisController(AnalysisVmRegistry registry, IAnalysisCache? cache = null) + { + _registry = registry; + _cache = cache; + } /// /// GET /_analysis/meta?listVmType=Foo.BarListVM @@ -71,7 +75,7 @@ public IActionResult Query([FromBody] AnalysisQueryRequest req) try { - var result = new AnalysisQueryEngine().ExecuteDynamic(baseQuery, req, fields); + var result = new AnalysisQueryEngine(_cache).ExecuteDynamic(baseQuery, req, fields); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(ex.Message); } @@ -99,7 +103,7 @@ public IActionResult Export([FromBody] AnalysisQueryRequest req, if (baseQuery == null) return BadRequest("無法取得查詢來源。"); AnalysisQueryResponse result; - try { result = new AnalysisQueryEngine().ExecuteDynamic(baseQuery, req, fields); } + try { result = new AnalysisQueryEngine(_cache).ExecuteDynamic(baseQuery, req, fields); } catch (InvalidOperationException ex) { return BadRequest(ex.Message); } if (format.Equals("csv", StringComparison.OrdinalIgnoreCase)) diff --git a/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisCacheTests.cs b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisCacheTests.cs new file mode 100644 index 000000000..2a365827a --- /dev/null +++ b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisCacheTests.cs @@ -0,0 +1,207 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using WalkingTec.Mvvm.Core; +using WalkingTec.Mvvm.Core.Analysis; + +namespace WalkingTec.Mvvm.Core.Test.Analysis +{ + [TestClass] + public class AnalysisCacheTests + { + // ─── NullAnalysisCache ────────────────────────────────────────────── + + [TestMethod] + public void NullCache_TryGet_always_returns_false() + { + var cache = new NullAnalysisCache(); + var response = new AnalysisQueryResponse { QueryHash = "ABCD1234ABCD1234" }; + cache.Set("ABCD1234ABCD1234", response); + + var hit = cache.TryGet("ABCD1234ABCD1234", out var cached); + + Assert.IsFalse(hit); + Assert.IsNull(cached); + } + + // ─── MemoryAnalysisCache ──────────────────────────────────────────── + + [TestMethod] + public void MemoryCache_Set_then_TryGet_returns_cached_response() + { + var mc = new MemoryCache(new MemoryCacheOptions()); + var cache = new MemoryAnalysisCache(mc); + var response = MakeResponse("HASH0001"); + + cache.Set("HASH0001", response); + var hit = cache.TryGet("HASH0001", out var cached); + + Assert.IsTrue(hit); + Assert.IsNotNull(cached); + Assert.AreEqual("HASH0001", cached!.QueryHash); + Assert.AreEqual(response.TotalCount, cached.TotalCount); + } + + [TestMethod] + public void MemoryCache_Invalidate_removes_specific_entry() + { + var mc = new MemoryCache(new MemoryCacheOptions()); + var cache = new MemoryAnalysisCache(mc); + cache.Set("KEY_A", MakeResponse("KEY_A")); + cache.Set("KEY_B", MakeResponse("KEY_B")); + + cache.Invalidate("KEY_A"); + + Assert.IsFalse(cache.TryGet("KEY_A", out _)); + Assert.IsTrue(cache.TryGet("KEY_B", out _)); + } + + [TestMethod] + public void MemoryCache_InvalidateAll_clears_everything() + { + var mc = new MemoryCache(new MemoryCacheOptions()); + var cache = new MemoryAnalysisCache(mc); + cache.Set("KEY_A", MakeResponse("KEY_A")); + cache.Set("KEY_B", MakeResponse("KEY_B")); + + cache.InvalidateAll(); + + Assert.IsFalse(cache.TryGet("KEY_A", out _)); + Assert.IsFalse(cache.TryGet("KEY_B", out _)); + } + + [TestMethod] + public void MemoryCache_expired_entry_is_not_returned() + { + var mc = new MemoryCache(new MemoryCacheOptions()); + var cache = new MemoryAnalysisCache(mc); + cache.Set("SHORT", MakeResponse("SHORT"), ttl: TimeSpan.FromMilliseconds(1)); + + Thread.Sleep(50); + + Assert.IsFalse(cache.TryGet("SHORT", out _)); + } + + // ─── Engine + Cache 整合 ──────────────────────────────────────────── + + private class SaleRecord : TopBasePoco + { + [Dimension(DisplayName = "地區")] + public string Region { get; set; } = string.Empty; + + [Measure(AllowedFuncs = AggregateFunc.Sum | AggregateFunc.Count, + DisplayName = "金額")] + public decimal Amount { get; set; } + } + + private class SaleTestContext : DbContext + { + public SaleTestContext(DbContextOptions opts) : base(opts) { } + public DbSet SaleRecords { get; set; } = null!; + } + + [TestMethod] + public void Engine_with_cache_returns_cached_on_second_call() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; + using var ctx = new SaleTestContext(opts); + ctx.Database.EnsureCreated(); + ctx.SaleRecords.Add(new SaleRecord + { + ID = Guid.NewGuid(), Region = "北", Amount = 100m + }); + ctx.SaveChanges(); + + var whitelist = AnalysisFieldScanner.ScanModel(typeof(SaleRecord)); + var mc = new MemoryCache(new MemoryCacheOptions()); + var cache = new MemoryAnalysisCache(mc); + var engine = new AnalysisQueryEngine(cache); + + var req = new AnalysisQueryRequest + { + Dimensions = new List { "Region" }, + Measures = new List + { + new MeasureRequest { Field = "Amount", Func = AggregateFunc.Sum } + }, + Filters = new List() + }; + + var result1 = engine.Execute(ctx.SaleRecords.AsQueryable(), req, whitelist); + Assert.AreEqual(1, result1.Rows.Count); + Assert.AreEqual(100m, Convert.ToDecimal(result1.Rows[0]["Amount_Sum"])); + + // 新增資料後,因快取命中,第二次查詢應回傳與第一次相同的結果 + ctx.SaleRecords.Add(new SaleRecord + { + ID = Guid.NewGuid(), Region = "南", Amount = 200m + }); + ctx.SaveChanges(); + + var result2 = engine.Execute(ctx.SaleRecords.AsQueryable(), req, whitelist); + + // 快取命中:結果應與第一次一致(1 列,非 2 列) + Assert.AreEqual(1, result2.Rows.Count); + Assert.AreSame(result1, result2); + } + + [TestMethod] + public void Engine_without_cache_works_as_before() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; + using var ctx = new SaleTestContext(opts); + ctx.Database.EnsureCreated(); + ctx.SaleRecords.Add(new SaleRecord + { + ID = Guid.NewGuid(), Region = "北", Amount = 100m + }); + ctx.SaveChanges(); + + var whitelist = AnalysisFieldScanner.ScanModel(typeof(SaleRecord)); + var engine = new AnalysisQueryEngine(); // 無快取 + + var req = new AnalysisQueryRequest + { + Dimensions = new List { "Region" }, + Measures = new List + { + new MeasureRequest { Field = "Amount", Func = AggregateFunc.Sum } + }, + Filters = new List() + }; + + var result = engine.Execute(ctx.SaleRecords.AsQueryable(), req, whitelist); + + Assert.AreEqual(1, result.Rows.Count); + Assert.AreEqual(100m, Convert.ToDecimal(result.Rows[0]["Amount_Sum"])); + Assert.AreEqual(16, result.QueryHash.Length); + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + private static AnalysisQueryResponse MakeResponse(string hash) + { + return new AnalysisQueryResponse + { + QueryHash = hash, + Columns = new List { "Col1" }, + Rows = new List> + { + new Dictionary { ["Col1"] = "val" } + }, + TotalCount = 1, + Truncated = false + }; + } + } +} diff --git a/test/WalkingTec.Mvvm.Core.Test/WalkingTec.Mvvm.Core.Test.csproj b/test/WalkingTec.Mvvm.Core.Test/WalkingTec.Mvvm.Core.Test.csproj index 9fbf48082..9fe32b1e8 100644 --- a/test/WalkingTec.Mvvm.Core.Test/WalkingTec.Mvvm.Core.Test.csproj +++ b/test/WalkingTec.Mvvm.Core.Test/WalkingTec.Mvvm.Core.Test.csproj @@ -15,6 +15,7 @@ +