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 @@
+