From 9f63e324ce0881066e39a5c824bbef6747994372 Mon Sep 17 00:00:00 2001 From: cct0831 Date: Wed, 11 Mar 2026 01:11:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20Lookup=20Cache=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20IReadOnlyList,=20ConnectionKey,=20global=20TenantIs?= =?UTF-8?q?olation,=20helper=20methods=20(Closes=20#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change GetAll/GetAllAsync return type from List to IReadOnlyList to prevent accidental cache mutation - Add ConnectionKey property to [CacheLookup] for multi-database support (e.g. ORSS read-only DB) - Change TenantIsolation to three-state (unset/true/false) with global default via LookupCacheOptions - Add GetLookupItem for single-item lookup by predicate - Add GetLookupSelectList for dropdown integration (ComboSelectListItem) - Add GetAttribute and DefaultTenantIsolation to ILookupCacheService - Update CacheLookupAttribute remarks to reference FrameworkContext.SaveChanges - Update docs with new features, stampede protection note, filter semantics, EmptyContext caveat - Add 7 new tests covering IReadOnlyList, GetAttribute, DefaultTenantIsolation, ConnectionKey Co-Authored-By: Claude Opus 4.6 --- docs/lookup-cache.md | 115 ++++++++++++++++- .../Cache/CacheLookupAttribute.cs | 32 ++++- .../Cache/ILookupCacheService.cs | 10 +- .../Cache/LookupCacheOptions.cs | 12 ++ .../Cache/LookupCacheService.cs | 13 +- src/WalkingTec.Mvvm.Core/WTMContext.cs | 105 +++++++++++++--- .../Helper/FrameworkServiceExtension.cs | 3 +- .../Cache/LookupCacheTests.cs | 119 +++++++++++++++++- 8 files changed, 373 insertions(+), 36 deletions(-) create mode 100644 src/WalkingTec.Mvvm.Core/Cache/LookupCacheOptions.cs diff --git a/docs/lookup-cache.md b/docs/lookup-cache.md index 561f6aa17..0c8790910 100644 --- a/docs/lookup-cache.md +++ b/docs/lookup-cache.md @@ -29,10 +29,10 @@ public class CityCode : BasePoco ### 2. 在任意 VM 或 Controller 內存取 ```csharp -// 取全表 +// 取全表(回傳 IReadOnlyList,防止意外修改快取內容) var cities = Wtm.GetLookup(); -// 在記憶體中過濾(不觸發額外 DB 查詢) +// 在記憶體中過濾(Func,不觸發額外 DB 查詢) var active = Wtm.GetLookup(x => x.IsActive && x.Province == "北部"); // 非同步版本 @@ -42,6 +42,10 @@ var active = await Wtm.GetLookupAsync(x => x.IsActive); > **零設定**:快取失效已整合進 `FrameworkContext.SaveChanges()` 與 `SaveChangesAsync()`。只要透過 WTM 的 DC 寫入資料,快取即自動失效,無需任何 `Program.cs` 修改。 +> **注意**:透過 `EmptyContext` 或原生 EF Core `DbContext` 直接寫入時,**不會**觸發自動失效。 + +> **IReadOnlyList 回傳型別**:`GetLookup` 和 `GetLookupAsync` 回傳 `IReadOnlyList` 而非 `List`,防止呼叫端意外修改快取中的物件。若需要可變集合,可 `.ToList()` 複製一份。 + --- ## Attribute 參數 @@ -49,8 +53,84 @@ var active = await Wtm.GetLookupAsync(x => x.IsActive); | 參數 | 預設值 | 說明 | |------|--------|------| | `TtlMinutes` | `30` | 快取存活時間(分鐘) | -| `TenantIsolation` | `true` | 依 TenantCode 隔離快取鍵,避免租戶資料互串 | +| `TenantIsolation` | 未設定(使用全域預設) | 依 TenantCode 隔離快取鍵,避免租戶資料互串。未設定時使用 `LookupCacheOptions.DefaultTenantIsolation`(預設 `true`) | | `WarmOnStartup` | `true` | 應用啟動後在後台預熱快取(不阻擋啟動) | +| `ConnectionKey` | `null` | 指定此 Model 所在的資料庫連線鍵(對應 appsettings.json 中 Connections 的 Key)。為 null 時使用預設 DC | + +--- + +## 全域 TenantIsolation 預設值 + +單一租戶的應用不需要每個 Model 都設定 `TenantIsolation = false`。在 `Program.cs` 註冊全域預設值: + +```csharp +// Program.cs +builder.Services.AddSingleton(new LookupCacheOptions +{ + DefaultTenantIsolation = false // 單租戶應用:關閉租戶隔離 +}); + +// 之後照常呼叫 +builder.Services.AddWtmContext(builder.Configuration); +``` + +個別 Attribute 可覆蓋全域預設: + +```csharp +// 即使全域預設 false,此 Model 仍啟用租戶隔離 +[CacheLookup(TenantIsolation = true)] +public class TenantSpecificDict : BasePoco { ... } +``` + +--- + +## 多資料庫支援(ConnectionKey) + +若應用有多個資料庫(例如主庫 + 唯讀 ORSS 庫),可透過 `ConnectionKey` 指定 Model 所在的連線: + +```json +// appsettings.json +{ + "Connections": [ + { "Key": "default", "Value": "...", "DbType": "SqlServer" }, + { "Key": "orss", "Value": "...", "DbType": "SqlServer" } + ] +} +``` + +```csharp +[CacheLookup(TtlMinutes = 120, ConnectionKey = "orss")] +public class OrssProduct : BasePoco +{ + public string ProductCode { get; set; } = null!; + public string ProductName { get; set; } = null!; +} + +// 使用方式不變,框架會自動使用 orss 連線 +var products = Wtm.GetLookup(); +``` + +--- + +## 便利方法 + +### GetLookupItem — 查找單筆資料 + +```csharp +// 從快取中查找單筆,回傳 T? (找不到時為 null) +var city = Wtm.GetLookupItem(x => x.Name == "台北"); +``` + +### GetLookupSelectList — 下拉選單整合 + +```csharp +// 從快取生成 ComboSelectListItem 下拉選項 +var options = Wtm.GetLookupSelectList( + valueField: x => x.ID, + textField: x => x.Name, + filter: x => x.IsActive +); +``` --- @@ -73,6 +153,8 @@ wtm:lookup:{type.FullName}:{tenantId} | 手動 | `ILookupCacheService.Invalidate()` | 指定型別 + 租戶 | | 手動(跨租戶) | `ILookupCacheService.InvalidateType(type)` | 指定型別全部租戶 | +> **注意**:`EmptyContext` 不繼承 `FrameworkContext`,因此透過 `EmptyContext` 寫入不會觸發自動失效。 + ### Startup Warm-up 標記 `WarmOnStartup = true`(預設)的型別會在應用啟動後 3 秒由 `LookupCacheWarmupService` 在後台預熱。 @@ -80,6 +162,14 @@ wtm:lookup:{type.FullName}:{tenantId} - 失敗只記 warning log,不阻擋啟動 - 僅預熱 main tenant(`TenantCode = null`),其他租戶在首次請求時自動暖機 +### Stampede Protection + +`IMemoryCache.GetOrCreate` / `GetOrCreateAsync` 並非原子操作 — 在 cache miss 時,多個並發請求可能各自執行一次 DB 查詢。這是安全的,因為每個呼叫者使用自己的 scoped `DbContext`,最壞情況是一次 burst 內多讀一次 DB。 + +### 過濾語義 + +`GetLookup(predicate)` 的 `predicate` 參數是 `Func`,在記憶體中執行,不會轉換為 SQL。全表資料先從快取載入,再在記憶體中過濾。 + --- ## 多副本(多 Pod)部署說明 @@ -117,6 +207,8 @@ LookupCacheService(Singleton) ├── startup 掃描所有 Assembly,建立型別白名單 ├── GetAll(): IMemoryCache.GetOrCreate(防 stampede) ├── InvalidateType(): 取消 per-type CancellationTokenSource,批次清除所有租戶 key + ├── GetAttribute(): 查詢型別的 CacheLookupAttribute + ├── DefaultTenantIsolation: 全域預設值(從 LookupCacheOptions 取得) └── GetWarmupTypes(): 供 LookupCacheWarmupService 使用 FrameworkContext(內建,無需設定) @@ -128,7 +220,9 @@ LookupCacheWarmupService(BackgroundService) └── ExecuteAsync: 3s 延遲後預熱 WarmOnStartup=true 的型別 WTMContext.GetLookup() - └── DC as DbContext → ILookupCacheService.GetAll + ├── ConnectionKey → CreateDC(cskey) 或使用預設 DC + ├── TenantIsolationOrNull → 全域預設 fallback + └── ILookupCacheService.GetAll → IReadOnlyList ``` --- @@ -136,13 +230,22 @@ WTMContext.GetLookup() ## 常見問題 **Q: 貼了 `[CacheLookup]` 但快取沒有失效?** -A: 確認寫入是透過 WTM 的 `DC`(即 `FrameworkContext` 子類別)進行的。若使用原生 EF Core `DbContext` 直接寫入,則不會觸發自動失效。 +A: 確認寫入是透過 WTM 的 `DC`(即 `FrameworkContext` 子類別)進行的。若使用原生 EF Core `DbContext` 或 `EmptyContext` 直接寫入,則不會觸發自動失效。 **Q: Warm-up 失敗日誌顯示「DbContext not available」?** A: `IDataContext` 必須設定正確。確認 `AddWtmContext()` 已在 `services.AddDbContext()` 之後呼叫。 **Q: 不同租戶的 Model 要標記什麼?** -A: 保持 `TenantIsolation = true`(預設)。若該表的資料在所有租戶間完全相同(如幣別代碼),可設 `TenantIsolation = false` 減少重複快取。 +A: 保持 `TenantIsolation = true`(或不設定,使用全域預設 `true`)。若該表的資料在所有租戶間完全相同(如幣別代碼),可設 `TenantIsolation = false` 減少重複快取。 **Q: 可以關掉 Warm-up 嗎?** A: 在 Attribute 上設 `WarmOnStartup = false` 即可。 + +**Q: 單租戶應用每個 Model 都要設 `TenantIsolation = false`?** +A: 不用。註冊 `LookupCacheOptions { DefaultTenantIsolation = false }` 即可全域關閉,個別 Model 仍可覆蓋。 + +**Q: Model 在不同的資料庫(如 ORSS)怎麼辦?** +A: 在 Attribute 上設 `ConnectionKey = "orss"`(對應 appsettings.json 的 Connections Key),框架會自動使用該連線。 + +**Q: `GetLookup(predicate)` 是 DB 查詢嗎?** +A: 不是。`predicate` 是 `Func`,在記憶體中對已快取的全表資料執行過濾。 diff --git a/src/WalkingTec.Mvvm.Core/Cache/CacheLookupAttribute.cs b/src/WalkingTec.Mvvm.Core/Cache/CacheLookupAttribute.cs index e4403ed1f..8e7a68f97 100644 --- a/src/WalkingTec.Mvvm.Core/Cache/CacheLookupAttribute.cs +++ b/src/WalkingTec.Mvvm.Core/Cache/CacheLookupAttribute.cs @@ -18,8 +18,8 @@ namespace WalkingTec.Mvvm.Core.Cache /// var active = Wtm.GetLookup<CityCode>(x => x.IsActive); /// /// - /// 失效時機:透過 在 SaveChanges 時自動失效。 - /// 應用需在 DbContextOptions 加入該 Interceptor 才能觸發自動失效(見 docs/lookup-cache.md)。 + /// 失效時機:透過 在 SaveChanges 時自動失效。 + /// 只要透過 WTM 的 DC(FrameworkContext 子類別)寫入資料,快取即自動失效。 /// [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] public sealed class CacheLookupAttribute : Attribute @@ -27,16 +27,38 @@ public sealed class CacheLookupAttribute : Attribute /// 快取存活時間(分鐘),預設 30 分鐘 public int TtlMinutes { get; set; } = 30; + // Attribute 參數不支援 bool?,使用 sentinel 模式模擬三態 + private const int Unset = -1; + private int _tenantIsolation = Unset; + + /// + /// 是否依租戶隔離快取鍵。未設定時使用全域預設值(見 )。 + /// 明確設為 true/false 會覆蓋全域預設。 + /// 實作 ITenant 的 Model 建議保持預設(或明確設為 true),避免租戶資料互串。 + /// + public bool TenantIsolation + { + get => _tenantIsolation != Unset && _tenantIsolation != 0; + set => _tenantIsolation = value ? 1 : 0; + } + /// - /// 是否依租戶隔離快取鍵。 - /// 實作 ITenant 的 Model 建議保持預設值 true,避免租戶資料互串。 + /// 取得 TenantIsolation 的三態值:true、false 或 null(未設定,使用全域預設)。 + /// 此屬性供框架內部使用,不在 Attribute 語法中顯示。 /// - public bool TenantIsolation { get; set; } = true; + internal bool? TenantIsolationOrNull => + _tenantIsolation == Unset ? null : _tenantIsolation != 0; /// /// 是否在應用啟動時預熱快取(BackgroundService 背景執行,不阻擋啟動)。 /// 預設 true。 /// public bool WarmOnStartup { get; set; } = true; + + /// + /// 指定此 Model 所在的資料庫連線鍵(對應 appsettings.json 中 Connections 的 Key)。 + /// 為 null 時使用預設的 Wtm.DC。 + /// + public string? ConnectionKey { get; set; } } } diff --git a/src/WalkingTec.Mvvm.Core/Cache/ILookupCacheService.cs b/src/WalkingTec.Mvvm.Core/Cache/ILookupCacheService.cs index 04d43b7d4..4eb0c862b 100644 --- a/src/WalkingTec.Mvvm.Core/Cache/ILookupCacheService.cs +++ b/src/WalkingTec.Mvvm.Core/Cache/ILookupCacheService.cs @@ -16,12 +16,12 @@ public interface ILookupCacheService /// /// 取得指定型別的全表快取資料(cache miss 時自動從 DB 補充)。 /// - List GetAll(DbContext dc, string? tenantId = null) where T : TopBasePoco; + IReadOnlyList GetAll(DbContext dc, string? tenantId = null) where T : TopBasePoco; /// /// 非同步取得指定型別的全表快取資料(cache miss 時自動從 DB 補充)。 /// - Task> GetAllAsync(DbContext dc, string? tenantId = null, CancellationToken ct = default) + Task> GetAllAsync(DbContext dc, string? tenantId = null, CancellationToken ct = default) where T : TopBasePoco; /// 使指定型別、指定租戶的快取失效。 @@ -35,5 +35,11 @@ Task> GetAllAsync(DbContext dc, string? tenantId = null, Cancellation /// 取得所有標記 WarmOnStartup=true 的型別清單(供 warmup service 使用)。 IReadOnlyList GetWarmupTypes(); + + /// 取得指定型別的 ,未標記時回傳 null。 + CacheLookupAttribute? GetAttribute(Type entityType); + + /// 全域預設 TenantIsolation 值。 + bool DefaultTenantIsolation { get; } } } diff --git a/src/WalkingTec.Mvvm.Core/Cache/LookupCacheOptions.cs b/src/WalkingTec.Mvvm.Core/Cache/LookupCacheOptions.cs new file mode 100644 index 000000000..b2861a3f2 --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Cache/LookupCacheOptions.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace WalkingTec.Mvvm.Core.Cache +{ + /// + /// Lookup Cache 全域設定。透過 DI 注入,個別 可覆蓋。 + /// + public class LookupCacheOptions + { + /// 全域預設 TenantIsolation 值(預設 true)。個別 Attribute 可覆蓋。 + public bool DefaultTenantIsolation { get; set; } = true; + } +} diff --git a/src/WalkingTec.Mvvm.Core/Cache/LookupCacheService.cs b/src/WalkingTec.Mvvm.Core/Cache/LookupCacheService.cs index 5ef19ee12..799691399 100644 --- a/src/WalkingTec.Mvvm.Core/Cache/LookupCacheService.cs +++ b/src/WalkingTec.Mvvm.Core/Cache/LookupCacheService.cs @@ -21,6 +21,7 @@ namespace WalkingTec.Mvvm.Core.Cache public class LookupCacheService : ILookupCacheService { private readonly IMemoryCache _cache; + private readonly LookupCacheOptions _options; // per-type CTS,用於 InvalidateType(IMemoryCache 無 Clear 方法) private readonly Dictionary _ctsByType = new(); @@ -29,15 +30,16 @@ public class LookupCacheService : ILookupCacheService // 啟動時掃描結果 private readonly Dictionary _registry; - public LookupCacheService(IMemoryCache cache, IEnumerable assemblies) + public LookupCacheService(IMemoryCache cache, IEnumerable assemblies, LookupCacheOptions? options = null) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _options = options ?? new LookupCacheOptions(); _registry = ScanAssemblies(assemblies); } // ─── ILookupCacheService ────────────────────────────────────────────── - public List GetAll(DbContext dc, string? tenantId = null) where T : TopBasePoco + public IReadOnlyList GetAll(DbContext dc, string? tenantId = null) where T : TopBasePoco { var key = BuildKey(typeof(T), tenantId); return _cache.GetOrCreate(key, entry => @@ -47,7 +49,7 @@ public List GetAll(DbContext dc, string? tenantId = null) where T : TopBas }) ?? new List(); } - public async Task> GetAllAsync( + public async Task> GetAllAsync( DbContext dc, string? tenantId = null, CancellationToken ct = default) where T : TopBasePoco @@ -96,6 +98,11 @@ public IReadOnlyList GetWarmupTypes() => .Select(kv => kv.Key) .ToList(); + public CacheLookupAttribute? GetAttribute(Type entityType) => + _registry.TryGetValue(entityType, out var attr) ? attr : null; + + public bool DefaultTenantIsolation => _options.DefaultTenantIsolation; + // ─── 私有輔助 ───────────────────────────────────────────────────────── private static string BuildKey(Type type, string? tenantId) diff --git a/src/WalkingTec.Mvvm.Core/WTMContext.cs b/src/WalkingTec.Mvvm.Core/WTMContext.cs index b208361ec..b3b2931cb 100644 --- a/src/WalkingTec.Mvvm.Core/WTMContext.cs +++ b/src/WalkingTec.Mvvm.Core/WTMContext.cs @@ -302,8 +302,9 @@ public virtual LoginUserInfo? /// 取得靜態/參數表的快取資料(cache miss 時自動從 DB 補充)。 /// Model 必須標記 。 /// - /// 可選的記憶體過濾條件,不觸發額外 DB 查詢。 - public System.Collections.Generic.List GetLookup( + /// 可選的記憶體過濾條件(Func,在記憶體中執行),不觸發額外 DB 查詢。 + /// IReadOnlyList 防止呼叫端意外修改快取內容。 + public System.Collections.Generic.IReadOnlyList GetLookup( System.Func? predicate = null) where T : TopBasePoco { var svc = ServiceProvider?.GetService(typeof(WalkingTec.Mvvm.Core.Cache.ILookupCacheService)) @@ -312,22 +313,46 @@ public System.Collections.Generic.List GetLookup( throw new InvalidOperationException( "ILookupCacheService is not registered. Call services.AddWtmContext() first."); - if (DC is not Microsoft.EntityFrameworkCore.DbContext dbCtx) - throw new InvalidOperationException( - "GetLookup requires an EF Core DbContext. Ensure IDataContext is configured."); + var attr = svc.GetAttribute(typeof(T)); + Microsoft.EntityFrameworkCore.DbContext dbCtx; + IDataContext? altDc = null; + try + { + if (!string.IsNullOrEmpty(attr?.ConnectionKey)) + { + altDc = CreateDC(cskey: attr.ConnectionKey); + dbCtx = altDc as Microsoft.EntityFrameworkCore.DbContext + ?? throw new InvalidOperationException( + $"ConnectionKey '{attr.ConnectionKey}' did not produce an EF Core DbContext."); + } + else + { + dbCtx = DC as Microsoft.EntityFrameworkCore.DbContext + ?? throw new InvalidOperationException( + "GetLookup requires an EF Core DbContext. Ensure IDataContext is configured."); + } + + // 解析 tenant isolation:Attribute 明確設定優先,否則使用全域預設 + bool useTenant = attr?.TenantIsolationOrNull ?? svc.DefaultTenantIsolation; + var tenantId = useTenant ? LoginUserInfo?.TenantCode : null; - var tenantId = LoginUserInfo?.TenantCode; - var all = svc.GetAll(dbCtx, tenantId); - return predicate == null ? all : all.Where(predicate).ToList(); + var all = svc.GetAll(dbCtx, tenantId); + return predicate == null ? all : (System.Collections.Generic.IReadOnlyList)all.Where(predicate).ToList(); + } + finally + { + (altDc as IDisposable)?.Dispose(); + } } /// /// 非同步取得靜態/參數表的快取資料(cache miss 時自動從 DB 補充)。 /// Model 必須標記 。 /// - /// 可選的記憶體過濾條件,不觸發額外 DB 查詢。 + /// 可選的記憶體過濾條件(Func,在記憶體中執行),不觸發額外 DB 查詢。 /// 取消 token。 - public async System.Threading.Tasks.Task> GetLookupAsync( + /// IReadOnlyList 防止呼叫端意外修改快取內容。 + public async System.Threading.Tasks.Task> GetLookupAsync( System.Func? predicate = null, System.Threading.CancellationToken ct = default) where T : TopBasePoco { @@ -337,13 +362,61 @@ public System.Collections.Generic.List GetLookup( throw new InvalidOperationException( "ILookupCacheService is not registered. Call services.AddWtmContext() first."); - if (DC is not Microsoft.EntityFrameworkCore.DbContext dbCtx) - throw new InvalidOperationException( - "GetLookupAsync requires an EF Core DbContext. Ensure IDataContext is configured."); + var attr = svc.GetAttribute(typeof(T)); + Microsoft.EntityFrameworkCore.DbContext dbCtx; + IDataContext? altDc = null; + try + { + if (!string.IsNullOrEmpty(attr?.ConnectionKey)) + { + altDc = CreateDC(cskey: attr.ConnectionKey); + dbCtx = altDc as Microsoft.EntityFrameworkCore.DbContext + ?? throw new InvalidOperationException( + $"ConnectionKey '{attr.ConnectionKey}' did not produce an EF Core DbContext."); + } + else + { + dbCtx = DC as Microsoft.EntityFrameworkCore.DbContext + ?? throw new InvalidOperationException( + "GetLookupAsync requires an EF Core DbContext. Ensure IDataContext is configured."); + } + + bool useTenant = attr?.TenantIsolationOrNull ?? svc.DefaultTenantIsolation; + var tenantId = useTenant ? LoginUserInfo?.TenantCode : null; + + var all = await svc.GetAllAsync(dbCtx, tenantId, ct).ConfigureAwait(false); + return predicate == null ? all : (System.Collections.Generic.IReadOnlyList)all.Where(predicate).ToList(); + } + finally + { + (altDc as IDisposable)?.Dispose(); + } + } - var tenantId = LoginUserInfo?.TenantCode; - var all = await svc.GetAllAsync(dbCtx, tenantId, ct).ConfigureAwait(false); - return predicate == null ? all : all.Where(predicate).ToList(); + /// + /// 從快取中查找單筆資料。 + /// + public T? GetLookupItem(System.Func predicate) where T : TopBasePoco + { + return GetLookup().FirstOrDefault(predicate); + } + + /// + /// 從快取生成下拉選單項目(ComboSelectListItem)。 + /// + public System.Collections.Generic.List GetLookupSelectList( + System.Func valueField, + System.Func textField, + System.Func? filter = null) where T : TopBasePoco + { + var items = filter == null + ? (System.Collections.Generic.IEnumerable)GetLookup() + : GetLookup().Where(filter); + return items.Select(x => new ComboSelectListItem + { + Value = valueField(x)?.ToString(), + Text = textField(x) + }).ToList(); } #endregion diff --git a/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs b/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs index c6686af44..88b6fb9a2 100644 --- a/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs +++ b/src/WalkingTec.Mvvm.Mvc/Helper/FrameworkServiceExtension.cs @@ -552,7 +552,8 @@ public static IServiceCollection AddWtmContext(this IServiceCollection services, services.AddSingleton(sp => new WalkingTec.Mvvm.Core.Cache.LookupCacheService( sp.GetRequiredService(), - AppDomain.CurrentDomain.GetAssemblies())); + AppDomain.CurrentDomain.GetAssemblies(), + sp.GetService())); services.AddHostedService(); var cs = conf.Connections.Where(x => x.Enabled).ToList(); foreach (var item in cs) diff --git a/test/WalkingTec.Mvvm.Core.Test/Cache/LookupCacheTests.cs b/test/WalkingTec.Mvvm.Core.Test/Cache/LookupCacheTests.cs index bf348e4d9..20f5f1214 100644 --- a/test/WalkingTec.Mvvm.Core.Test/Cache/LookupCacheTests.cs +++ b/test/WalkingTec.Mvvm.Core.Test/Cache/LookupCacheTests.cs @@ -54,7 +54,8 @@ public LookupTestContext(DbContextOptions opts) : base(opts) { } internal static class TestHelper { - public static (LookupTestContext ctx, LookupCacheService svc, IMemoryCache mc) Create(SqliteConnection conn) + public static (LookupTestContext ctx, LookupCacheService svc, IMemoryCache mc) Create( + SqliteConnection conn, LookupCacheOptions? options = null) { var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; var ctx = new LookupTestContext(opts); @@ -62,7 +63,7 @@ public static (LookupTestContext ctx, LookupCacheService svc, IMemoryCache mc) C var mc = new MemoryCache(new MemoryCacheOptions()); // 只掃描含有測試 fixture 型別的 assembly - var svc = new LookupCacheService(mc, new[] { typeof(CityCode).Assembly }); + var svc = new LookupCacheService(mc, new[] { typeof(CityCode).Assembly }, options); return (ctx, svc, mc); } } @@ -153,6 +154,23 @@ public void GetAll_second_call_returns_cached_without_hitting_db() Assert.AreSame(first, second, "Should be identical reference from cache"); } + // ── GetAll returns IReadOnlyList ───────────────────────────────────────── + + [TestMethod] + public void GetAll_returns_IReadOnlyList() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var (ctx, svc, _) = TestHelper.Create(conn); + ctx.CityCodes.Add(new CityCode { ID = Guid.NewGuid(), Name = "台北", Province = "北部" }); + ctx.SaveChanges(); + + IReadOnlyList result = svc.GetAll(ctx, tenantId: null); + + Assert.IsInstanceOfType(result, typeof(IReadOnlyList)); + Assert.AreEqual(1, result.Count); + } + // ── Invalidate ──────────────────────────────────────────────────────── [TestMethod] @@ -251,6 +269,56 @@ public async Task GetAllAsync_returns_same_data_as_GetAll() Assert.AreEqual(sync.Count, async_.Count); Assert.AreEqual(sync[0].Name, async_[0].Name); } + + // ── GetAttribute ──────────────────────────────────────────────────────── + + [TestMethod] + public void GetAttribute_returns_attribute_for_cacheable_type() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var (_, svc, _) = TestHelper.Create(conn); + + var attr = svc.GetAttribute(typeof(CityCode)); + + Assert.IsNotNull(attr); + Assert.AreEqual(10, attr!.TtlMinutes); + } + + [TestMethod] + public void GetAttribute_returns_null_for_non_cacheable_type() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var (_, svc, _) = TestHelper.Create(conn); + + var attr = svc.GetAttribute(typeof(OrderRecord)); + + Assert.IsNull(attr); + } + + // ── DefaultTenantIsolation ────────────────────────────────────────────── + + [TestMethod] + public void DefaultTenantIsolation_is_true_by_default() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var (_, svc, _) = TestHelper.Create(conn); + + Assert.IsTrue(svc.DefaultTenantIsolation); + } + + [TestMethod] + public void DefaultTenantIsolation_can_be_overridden_via_options() + { + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var options = new LookupCacheOptions { DefaultTenantIsolation = false }; + var (_, svc, _) = TestHelper.Create(conn, options); + + Assert.IsFalse(svc.DefaultTenantIsolation); + } } // ─── FrameworkContext 整合測試(SaveChanges 自動失效)──────────────────────── @@ -369,8 +437,9 @@ public void Attribute_default_values_are_correct() var attr = new CacheLookupAttribute(); Assert.AreEqual(30, attr.TtlMinutes); - Assert.IsTrue(attr.TenantIsolation); + Assert.IsNull(attr.TenantIsolationOrNull, "TenantIsolation should be null (unset) by default"); Assert.IsTrue(attr.WarmOnStartup); + Assert.IsNull(attr.ConnectionKey, "ConnectionKey should be null by default"); } [TestMethod] @@ -385,9 +454,53 @@ public void Attribute_custom_values_are_applied() Assert.AreEqual(120, attr.TtlMinutes); Assert.IsFalse(attr.TenantIsolation); + Assert.IsFalse(attr.TenantIsolationOrNull); Assert.IsFalse(attr.WarmOnStartup); } + [TestMethod] + public void Attribute_TenantIsolation_null_by_default() + { + var attr = new CacheLookupAttribute(); + + Assert.IsNull(attr.TenantIsolationOrNull, + "TenantIsolation should be null (unset) by default, meaning use global default"); + } + + [TestMethod] + public void Attribute_TenantIsolation_explicit_true_overrides_null() + { + var attr = new CacheLookupAttribute { TenantIsolation = true }; + + Assert.IsTrue(attr.TenantIsolationOrNull); + Assert.IsTrue(attr.TenantIsolation); + } + + [TestMethod] + public void Attribute_TenantIsolation_explicit_false_overrides_null() + { + var attr = new CacheLookupAttribute { TenantIsolation = false }; + + Assert.IsFalse(attr.TenantIsolationOrNull); + Assert.IsFalse(attr.TenantIsolation); + } + + [TestMethod] + public void Attribute_ConnectionKey_null_by_default() + { + var attr = new CacheLookupAttribute(); + + Assert.IsNull(attr.ConnectionKey); + } + + [TestMethod] + public void Attribute_ConnectionKey_can_be_set() + { + var attr = new CacheLookupAttribute { ConnectionKey = "orss" }; + + Assert.AreEqual("orss", attr.ConnectionKey); + } + [TestMethod] public void Attribute_is_not_inherited_by_subclass() {