Architektura a vzory¶
Result pattern¶
Všechny servisní operace vrací Result nebo Result<T> (Eva.Data/Common/Result.cs).
// Úspěšný výsledek
return Result.Success();
return Result<Article>.Success(article);
// Chybový výsledek
return Result.Failure("Majetek nenalezen.");
return Result.Failure(new[] { "Chyba 1", "Chyba 2" });
Vlastnosti Result:
| Vlastnost | Typ | Popis |
|---|---|---|
Succeeded |
bool | Operace proběhla úspěšně |
Errors |
List<string> |
Seznam chybových zpráv |
Data |
T | Výsledná data (pouze Result<T>) |
Použití v controlleru:
var result = await _service.CreateAsync(article);
if (!result.Succeeded)
return BadRequest(result.Errors);
return CreatedAtRoute(..., result.Data);
BaseDataService\<T> — Generický CRUD¶
Všechny datové služby dědí z BaseDataService<T> a dostávají IDbContextFactory<EvaDataContext>:
public class ArticleDataService : BaseDataService<Article>, IArticleDataService
{
public ArticleDataService(IDbContextFactory<EvaDataContext> factory) : base(factory) { }
}
Dostupné metody:
Task<IEnumerable<T>> GetAllAsync()
Task<IQueryable<T>> GetAllAsQueryableAsync()
Task<T?> GetByIdAsync(long id)
Task<T> CreateAsync(T entity)
Task UpdateAsync(T entity)
Task DeleteAsync(T entity, bool setIsDeleted = true)
Task SetPropertyValueAsync(long id, string prop, object value)
Task RemoveItemsAsync(IEnumerable<T> items)
Task<IEnumerable<T>> SearchAsync(Expression<Func<T, bool>> predicate)
EF Core — vzory použití¶
// Vždy používat factory pattern — nikdy singleton DbContext
await using var db = await _factory.CreateDbContextAsync();
// Read operace — vždy NoTracking
var articles = await db.Articles
.AsNoTracking()
.Include(a => a.Category)
.ToListAsync();
// Write operace
await using var db = await _factory.CreateDbContextAsync();
db.Articles.Add(article);
await db.SaveChangesAsync();
Uložené procedury — od v1.4.2 nahrazeny C# metodami (SP neměly TenantID filtr):
| Metoda | Stará SP | Nová C# metoda |
|---|---|---|
| Generování položek inventury | sg_CreateInventoryItems |
InventoryDataService.CreateInventoryItemsFromLocationAsync |
| Provedení přesunu | sg_ExecuteArticleTransfer |
ArticleTransferDataService.ExecuteTransferWithAuditAsync |
| Uzavření inventury | sg_UpdateArticlesAfterInventory |
InventoryDataService.CloseInventoryWithAuditAsync |
// Nové C# metody — tenant-safe, s audit logem
var result = await inventoryDataService
.CreateInventoryItemsFromLocationAsync(inventoryId, locationId, force: false);
// result.Errors[0] == IInventoryDataService.ConfirmOverwriteError → UI zobrazí potvrzení
var execResult = await articleTransferDataService
.ExecuteTransferWithAuditAsync(transfer);
// execResult.Data: TransferExecuteResult { LocationChanges, StateChanges, UserChanges }
Dapper — výkonnostní dotazy¶
Dapper se používá souběžně s EF Core pro složitější READ dotazy:
await using var db = await _factory.CreateDbContextAsync();
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
var result = await connection.QueryAsync<ArticleDto>(
"SELECT a.\"OID\", a.\"Name\", l.\"Name\" as LocationName FROM dbo.\"Article\" a ...",
new { locationId });
MVVM (Eva.Mobile — MAUI)¶
// ViewModel s CommunityToolkit.Mvvm
[ObservableObject]
public partial class InventoryListViewModel
{
[ObservableProperty]
private ObservableCollection<Inventory> _inventories = [];
[RelayCommand]
private async Task LoadInventoriesAsync()
{
var result = await _restService.GetInventoriesAsync();
Inventories = new ObservableCollection<Inventory>(result);
}
}
Komunikace mezi ViewModels:
// Odeslání zprávy
WeakReferenceMessenger.Default.Send(new ArticleSelectedMessage(article));
// Přihlášení k odběru
WeakReferenceMessenger.Default.Register<ArticleSelectedMessage>(this, (r, m) => {
SelectedArticle = m.Article;
});
Multitenancy¶
EVA podporuje SaaS multi-tenant režim — jedna DB instance obsluhuje více organizací s plnou izolací dat.
Konfigurace¶
Architektura tenant kontextu¶
Přihlášení uživatele
│
▼
TenantClaimsPrincipalFactory
└─ přidá claim "tenant_id" = user.TenantID do auth cookie
HTTP request / Blazor circuit
│
▼
ClaimsPrincipalTenantAccessor (Singleton)
├─ 1. Cookie claim: HttpContext?.User.FindFirst("tenant_id")
└─ 2. Per-circuit cache: ConcurrentDictionary[SyncCtxKey]
▲
│ SetTenant(user.TenantID) volán z MainLayout.OnInitializedAsync
EvaDataContext (per-query instance přes IDbContextFactory)
└─ HasQueryFilter: !IsMultitenancyEnabled || e.TenantID == accessor.TenantID
│
▼ EF Core SQL
WHERE (@p2 OR "TenantID" = @tenantId) ← správně
WHERE (@p2 OR "TenantID" IS NULL) ← chyba: TenantID null v accessoru
ITenantAware + Global Query Filter¶
Každá entita s tenant-specifickými daty implementuje ITenantAware (28 entit: Article, Location, Inventory, Report, Printer, ...). EvaDataContext automaticky aplikuje filtr na všechny tyto entity:
// EvaDataContext — volá se jednou při ModelBuilding
modelBuilder.Entity<T>().HasQueryFilter(e =>
!_tenantAccessor.IsMultitenancyEnabled || e.TenantID == _tenantAccessor.TenantID);
Výsledek: žádný EF Core dotaz nemůže vrátit data jiného tenanta bez explicitního IgnoreQueryFilters().
Proč ConcurrentDictionary, ne AsyncLocal¶
AsyncLocal<T> propaguje hodnotu pouze do child-tasků (kopie při vzniku tasku). Sibling async operace (MainLayout a DxGrid data source) sdílejí stejný Blazor circuit, ale běží v různých async execution kontextech — hodnota nastavená v MainLayout.OnInitializedAsync se proto nepropaguje do kontextu, kde DxGrid spouští EF Core dotaz.
Důsledek: _tenantAccessor.TenantID vrátí null → EF Core generuje WHERE TenantID IS NULL → žádná data.
Řešení: ConcurrentDictionary<int, long?> keyovaný přes SynchronizationContext.Current.GetHashCode(). V Blazor Server má každý circuit vlastní RendererSynchronizationContext — jeho hash je stabilní pro celý circuit a sdílený napříč všemi render fázemi (MainLayout, stránky, DxGrid callbacky).
// SetTenant — volán z MainLayout.OnInitializedAsync
public void SetTenant(long? tenantId)
{
var key = SynchronizationContext.Current?.GetHashCode()
?? Thread.CurrentThread.ManagedThreadId;
if (tenantId.HasValue)
_circuitTenantMap[key] = tenantId;
else
_circuitTenantMap.TryRemove(key, out _);
}
// TenantID — čteno při každém EF Core dotazu
public long? TenantID
{
get
{
// 1. Cookie claim (spolehlivé pro HTTP a počáteční render)
var claim = httpContextAccessor.HttpContext?.User.FindFirst("tenant_id");
if (claim != null && long.TryParse(claim.Value, out var id))
return id;
// 2. Per-circuit cache (spolehlivé pro DxGrid, navigaci, re-rendery)
var key = SynchronizationContext.Current?.GetHashCode()
?? Thread.CurrentThread.ManagedThreadId;
_circuitTenantMap.TryGetValue(key, out var cached);
return cached;
}
}
Záchranná síť při zápisu¶
EvaDataContext.SaveChangesAsync override automaticky doplní TenantID na všech Added entitách, kde je null. Pokrývá kód, který volá context.Add() přímo mimo BaseDataService.CreateAsync.
// EvaDataContext.cs — spustí se před každým SaveChanges
private void ApplyTenantIdToAddedEntities()
{
if (!_tenantAccessor.IsMultitenancyEnabled || _tenantAccessor.TenantID == null) return;
foreach (var entry in ChangeTracker.Entries<ITenantAware>()
.Where(e => e.State == EntityState.Added && e.Entity.TenantID == null))
{
entry.Entity.TenantID = _tenantAccessor.TenantID;
}
}
Automatické nastavení TenantID¶
BaseDataService.CreateAsync() nastaví TenantID automaticky. Záchranná síť v SaveChanges pokryje i přímé context.Add() volání.
DB Unique indexy¶
Všechny unique indexy na tenant-aware tabulkách obsahují TenantID, např.:
- UK_Article_Code_Tenant (Code, TenantID)
- UIX_t_Report_Name (Name, TenantID)
- UK_Printer_PrinterCD (PrinterCD, TenantID)
Diagnostika problémů¶
Pokud se data nezobrazují po importu nebo přihlášení:
- Ověř SQL log — hledej
WHERE TenantID IS NULL(chyba) vsWHERE TenantID = @p(správně). - Ověř přihlášení — po novém Docker deploy jsou staré cookies neplatné (rotace DataProtection klíčů). Uživatel se musí znovu přihlásit.
- Ověř DB —
SELECT COUNT(*), "TenantID" FROM dbo."Article" GROUP BY "TenantID"potvrdí, zda data v DB jsou. - SuperAdmin nemá TenantID —
ApplicationUser.TenantID = nullpro SuperAdmin.SetTenant(null)vymaže záznam z_circuitTenantMap. PřiIsMultitenancyEnabled = truevidí SuperAdmin jen záznamy sTenantID IS NULL— pro přístup na všechna data musí SuperAdmin použít admin stránky sIgnoreQueryFilters().
Viz doc/tenant_guide.md pro kompletní přehled a troubleshooting.
DI konfigurace¶
Eva.Data¶
// Program.cs
builder.Services.AddEvaDataServices(connectionString);
// Registruje: EvaDataContext factory + všechny IXxxDataService implementace