< Summary

Information
Class: Elsa.AI.Persistence.EFCore.Stores.EFCoreAIProposalStore
Assembly: Elsa.AI.Persistence.EFCore
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.AI.Persistence.EFCore/Stores/EFCoreAIProposalStore.cs
Line coverage
97%
Covered lines: 84
Uncovered lines: 2
Coverable lines: 86
Total lines: 140
Line coverage: 97.6%
Branch coverage
80%
Covered branches: 29
Total branches: 36
Branch coverage: 80.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
FindAsync()100%22100%
SaveAsync()100%44100%
RetryAsUpdateAsync()50%4480%
Map(...)50%88100%
Map(...)75%44100%
ParseEnum(...)100%22100%
BelongsToTenant(...)100%11100%
NormalizeTenantId(...)100%22100%
ValidateUserOwnership(...)100%44100%
Validate(...)100%66100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.AI.Persistence.EFCore/Stores/EFCoreAIProposalStore.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Nodes;
 3using Elsa.AI.Abstractions.Contracts;
 4using Elsa.AI.Abstractions.Models;
 5using Elsa.AI.Persistence.EFCore.Entities;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace Elsa.AI.Persistence.EFCore.Stores;
 9
 1610public class EFCoreAIProposalStore(AIDbContext dbContext) : IAIProposalStore
 11{
 12    public async ValueTask<AIProposal?> FindAsync(string id, string? tenantId, CancellationToken cancellationToken = def
 13    {
 1314        var normalizedTenantId = NormalizeTenantId(tenantId);
 1315        var record = await dbContext.Proposals.AsNoTracking().FirstOrDefaultAsync(
 1316            x => x.Id == id && (x.TenantId ?? "") == normalizedTenantId,
 1317            cancellationToken);
 1318        return record == null ? null : Map(record);
 1319    }
 20
 21    public async ValueTask SaveAsync(AIProposal proposal, CancellationToken cancellationToken = default)
 22    {
 1623        Validate(proposal);
 1324        var isNew = false;
 1325        var record = await dbContext.Proposals.FindAsync([proposal.Id], cancellationToken);
 1326        if (record == null)
 27        {
 1028            record = new AIProposalRecord { Id = proposal.Id };
 1029            dbContext.Proposals.Add(record);
 1030            isNew = true;
 31        }
 332        else if (!BelongsToTenant(record.TenantId, proposal.TenantId))
 33        {
 134            throw new InvalidOperationException("Cannot overwrite an AI proposal that belongs to another tenant.");
 35        }
 36        else
 37        {
 238            ValidateUserOwnership(record, proposal);
 39        }
 40
 1141        Map(proposal, record);
 42
 43        try
 44        {
 1145            await dbContext.SaveChangesAsync(cancellationToken);
 1046        }
 147        catch (DbUpdateException e) when (isNew)
 48        {
 149            await RetryAsUpdateAsync(proposal, e, cancellationToken);
 50        }
 1151    }
 52
 53    private async ValueTask RetryAsUpdateAsync(AIProposal proposal, DbUpdateException originalException, CancellationTok
 54    {
 155        dbContext.ChangeTracker.Clear();
 156        var record = await dbContext.Proposals.FindAsync([proposal.Id], cancellationToken);
 157        if (record == null)
 058            throw new DbUpdateException($"Failed to insert AI proposal {proposal.Id}, and no existing record was found f
 59
 160        if (!BelongsToTenant(record.TenantId, proposal.TenantId))
 061            throw new InvalidOperationException("Cannot overwrite an AI proposal that belongs to another tenant.");
 62
 163        ValidateUserOwnership(record, proposal);
 64
 165        Map(proposal, record);
 166        await dbContext.SaveChangesAsync(cancellationToken);
 167    }
 68
 69    private static AIProposal Map(AIProposalRecord record) =>
 1170        new()
 1171        {
 1172            Id = record.Id,
 1173            TenantId = record.TenantId,
 1174            ConversationId = record.ConversationId,
 1175            Kind = ParseEnum(record.Kind, AIProposalKind.WorkflowCreate),
 1176            Status = ParseEnum(record.Status, AIProposalStatus.Draft),
 1177            BaselineWorkflowDefinitionId = record.BaselineWorkflowDefinitionId,
 1178            BaselineVersionId = record.BaselineVersionId,
 1179            WorkflowPayload = JsonSerializer.Deserialize<JsonObject>(record.WorkflowPayload) ?? [],
 1180            Rationale = record.Rationale,
 1181            Warnings = JsonSerializer.Deserialize<ICollection<string>>(record.Warnings) ?? [],
 1182            ValidationDiagnostics = JsonSerializer.Deserialize<ICollection<AIValidationDiagnostic>>(record.ValidationDia
 1183            GraphDiff = record.GraphDiff == null ? null : JsonSerializer.Deserialize<AIGraphDiff>(record.GraphDiff),
 1184            CreatedBy = record.CreatedBy,
 1185            CreatedAt = record.CreatedAt,
 1186            ReviewedBy = record.ReviewedBy,
 1187            ReviewedAt = record.ReviewedAt,
 1188            AppliedBy = record.AppliedBy,
 1189            AppliedAt = record.AppliedAt
 1190        };
 91
 92    private static void Map(AIProposal proposal, AIProposalRecord record)
 93    {
 1294        record.TenantId = NormalizeTenantId(proposal.TenantId);
 1295        record.ConversationId = proposal.ConversationId;
 1296        record.Kind = proposal.Kind.ToString();
 1297        record.Status = proposal.Status.ToString();
 1298        record.BaselineWorkflowDefinitionId = proposal.BaselineWorkflowDefinitionId;
 1299        record.BaselineVersionId = proposal.BaselineVersionId;
 12100        record.WorkflowPayload = proposal.WorkflowPayload.ToJsonString();
 12101        record.Rationale = proposal.Rationale;
 12102        record.Warnings = JsonSerializer.Serialize(proposal.Warnings);
 12103        record.ValidationDiagnostics = JsonSerializer.Serialize(proposal.ValidationDiagnostics);
 12104        record.GraphDiff = proposal.GraphDiff == null ? null : JsonSerializer.Serialize(proposal.GraphDiff);
 12105        record.CreatedBy = proposal.CreatedBy;
 12106        if (record.CreatedAt == default)
 10107            record.CreatedAt = proposal.CreatedAt;
 108
 12109        record.ReviewedBy = proposal.ReviewedBy;
 12110        record.ReviewedAt = proposal.ReviewedAt;
 12111        record.AppliedBy = proposal.AppliedBy;
 12112        record.AppliedAt = proposal.AppliedAt;
 12113    }
 114
 115    private static TEnum ParseEnum<TEnum>(string value, TEnum defaultValue) where TEnum : struct =>
 22116        Enum.TryParse<TEnum>(value, ignoreCase: true, out var result) ? result : defaultValue;
 117
 118    private static bool BelongsToTenant(string? storedTenantId, string? requestedTenantId) =>
 4119        string.Equals(NormalizeTenantId(storedTenantId), NormalizeTenantId(requestedTenantId), StringComparison.Ordinal)
 120
 33121    private static string NormalizeTenantId(string? tenantId) => tenantId ?? "";
 122
 123    private static void ValidateUserOwnership(AIProposalRecord record, AIProposal proposal)
 124    {
 3125        if (!string.IsNullOrWhiteSpace(record.CreatedBy) && !string.Equals(record.CreatedBy, proposal.CreatedBy, StringC
 1126            throw new InvalidOperationException("Cannot overwrite an AI proposal that belongs to another user.");
 2127    }
 128
 129    private static void Validate(AIProposal proposal)
 130    {
 16131        if (string.IsNullOrWhiteSpace(proposal.Id))
 1132            throw new ArgumentException("A proposal ID is required.", nameof(proposal));
 133
 15134        if (string.IsNullOrWhiteSpace(proposal.ConversationId))
 1135            throw new ArgumentException("A proposal conversation ID is required.", nameof(proposal));
 136
 14137        if (string.IsNullOrWhiteSpace(proposal.CreatedBy))
 1138            throw new ArgumentException("A proposal creator is required.", nameof(proposal));
 13139    }
 140}