Giorgi / EntityFramework.Exceptions

Strongly typed exceptions for Entity Framework Core. Supports SQLServer, PostgreSQL, SQLite, Oracle and MySql.
https://giorgi.dev/entity-framework/introducing-entityframework-exceptions/
Other
1.44k stars 68 forks source link

ExceptionProcessorInterceptor Throws Exception #71

Closed Mike-E-angelo closed 5 months ago

Mike-E-angelo commented 5 months ago

Hello,

Thank you so much for your efforts on this project. πŸ™

I seem to be experiencing an exception with this line here:

https://github.com/Giorgi/EntityFramework.Exceptions/blob/main/EntityFramework.Exceptions.Common/ExceptionProcessorInterceptor.cs#L94

In my case I have an abstract class that serves as the table for the hierarchy of implementations. It appears it's going through each of the implementations and repeating the abstract class as the index owner, as seen here in the results from the debugger:

image

This results in the following exception:

System.ArgumentException: An item with the same key has already been added. Key: IX_CreationReason_DisbursementAccountId
   at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
   at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector)
   at EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor`1.SetConstraintDetails(DbContext context, UniqueConstraintException exception, Exception providerException)
   at EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor`1.SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Diagnostics.CoreLoggerExtensions.SaveChangesFailedAsync(IDiagnosticsLogger`1 diagnostics, DbContext context, Exception exception, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

Please let me know if there is any further information I can provide and I will assist.

Giorgi commented 5 months ago

Thanks for reporting the issue. Can you provide a (small) sample project that I can use to reproduce the error? That would help me a lot.

Mike-E-angelo commented 5 months ago

Ah I wish I could, but it would take some time with my code/model. The best I can do at the moment is provide the model and configuration:

public sealed class DisbursementDetails
{
    public Guid Id { get; set; }

    public bool Enabled { get; set; } = true;

    public DisbursementBeneficiaryAccount Owner { get; set; } = default!;

    public CreationReason Reason { get; set; } = default!;

    public DateTimeOffset? Acknowledged { get; set; }

    public DateTimeOffset? Notified { get; set; }

    // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength // TODO
    public string? Comments { get; set; }
}

public abstract class CreationReason
{
    public Guid Id { get; set; }

    // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength
    public string? Notes { get; set; }
}

public abstract class SystemCreationReason : CreationReason;
public sealed class CurrentUserReason : SystemCreationReason;
public sealed class CurrentUserNameReason : SystemCreationReason;

sealed class AccountingConfigurations : ICommand<ModelBuilder>
{
    public static AccountingConfigurations Default { get; } = new();

    AccountingConfigurations() {}

    public void Execute(ModelBuilder parameter)
    {
        parameter.Entity<SystemCreationReason>();
        parameter.Entity<CurrentUserReason>();
        parameter.Entity<CurrentUserNameReason>();
                parameter.Entity<DisbursementDetails>(x => x.HasOne(y => y.Reason)
                                                    .WithOne()
                                                    .HasForeignKey<CreationReason>("DisbursementAccountId")
                                                    .OnDelete(DeleteBehavior.Cascade));
        }
}
Mike-E-angelo commented 5 months ago

Awesome! Thank you for your efforts here @Giorgi πŸ™πŸ™πŸ™

Giorgi commented 5 months ago

@Mike-E-angelo It's now live on NuGet.

Mike-E-angelo commented 5 months ago

Looks like it's working now, I see your interceptor in a test exception that I have thrown:

 ---> EntityFramework.Exceptions.Common.MaxLengthExceededException: Maximum length exceeded
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): String or binary data would be truncated in table 'starbeam.dbo.Vendor', column 'Identifier'. Truncated value: 'asdfasdf'.
   at void Microsoft.Data.SqlClient.SqlConnection.OnError(SqlException exception, bool breakConnection, Action<Action> wrapCloseInAction)
   at void Microsoft.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, bool breakConnection, Action<Action> wrapCloseInAction)
   at void Microsoft.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj, bool callerHasConnectionLock, bool asyncClose)
   at bool Microsoft.Data.SqlClient.TdsParser.TryRun(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj, out bool dataReady)
   at bool Microsoft.Data.SqlClient.SqlDataReader.TryHasMoreRows(out bool moreRows)
   at bool Microsoft.Data.SqlClient.SqlDataReader.TryHasMoreResults(out bool moreResults)
   at bool Microsoft.Data.SqlClient.SqlDataReader.TryNextResult(out bool more)
   at Task<bool> Microsoft.Data.SqlClient.SqlDataReader.NextResultAsyncExecute(Task task, object state)
   at Task<T> Microsoft.Data.SqlClient.SqlDataReader.InvokeAsyncCall<T>(SqlDataReaderBaseAsyncCallContext<T> context)
   at async Task Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
ClientConnectionId:...
Error Number:2628,State:1,Class:16
   --- End of inner exception stack trace ---
   at Task EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor<T>.SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken)
   at Task Microsoft.EntityFrameworkCore.Diagnostics.CoreLoggerExtensions.SaveChangesFailedAsync(IDiagnosticsLogger<Update> diagnostics, DbContext context, Exception exception, CancellationToken 

Looks good. πŸ‘ Thank you once again. πŸ™

Giorgi commented 5 months ago

The indexes dictionary is populated first time unique constraint is violated, so if you tested with max length exceeded error, the dictionary would not be populated. You need to cause unique constraint violation to validate the fix.

Mike-E-angelo commented 5 months ago

Ah! Good point. Glad I checked in with you on that. :)

Now we have a new problem. πŸ˜…

Microsoft.Azure.WebJobs.Host.FunctionInvocationException: Exception while executing function: starbeam-one-Authentications
 ---> System.ArgumentException: An item with the same key has already been added. Key: IX_ExternalProcess_ResultId
   at bool System.Collections.Generic.Dictionary<TKey, TValue>.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at void System.Collections.Generic.Dictionary<TKey, TValue>.Add(TKey key, TValue value)
   at Dictionary<TKey, TElement> System.Linq.Enumerable.ToDictionary<TSource, TKey, TElement>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, IEqualityComparer<TKey> comparer) x 2
   at void EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor<T>.SetConstraintDetails(DbContext context, UniqueConstraintException exception, Exception providerException)
   at Task EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor<T>.SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken)
   at Task Microsoft.EntityFrameworkCore.Diagnostics.CoreLoggerExtensions.SaveChangesFailedAsync(IDiagnosticsLogger<Update> diagnostics, DbContext context, Exception exception, CancellationToken cancellationToken)
   at async Task<int> Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken)

I've emitted the pertinent details below in the debugger:

image

Model:

public abstract class ExternalProcess
{
    public Guid Id { get; set; }

    public bool Enabled { get; set; } = true;

    public DateTimeOffset Created { get; set; }

    public DateTimeOffset? Completed { get; set; }

    public ICollection<CompletedStep> CompletedSteps { get; init; } = default!;

    public ICollection<ProcessUpdate> Updates { get; set; } = default!;

    public ProcessState State { get; set; } = default!;
}

public sealed class DepositOrder : ExternalProcess
{
    public Checkout Subject { get; set; } = default!;

    public Deposit? Result { get; set; }
}

Configuration:

parameter.Entity<DepositOrder>(x =>
                                       {
                                           x.HasOne(y => y.Subject)
                                            .WithOne()
                                            .HasForeignKey<DepositOrder>()
                                            .IsRequired()
                                            .OnDelete(DeleteBehavior.Restrict);

                                           x.HasOne(y => y.Result)
                                            .WithOne(y => y.Order)
                                            .HasForeignKey<DepositOrder>();
                                           x.HasIndex("ResultId").HasFilter("[DepositOrder_ResultId] IS NOT NULL");
                                           x.HasIndex("SubjectId").HasFilter("[DepositOrder_SubjectId] IS NOT NULL");
                                       });
Giorgi commented 5 months ago

When I try to run app with this model I get this error on line x.HasIndex("ResultId").HasFilter("[DepositOrder_ResultId] IS NOT NULL");

System.InvalidOperationException: 'The property 'ResultId' cannot be added to the type 'DepositOrder' because no property type was specified and there is no corresponding CLR property or field. To add a shadow state property, the property type must be specified.'

Mike-E-angelo commented 5 months ago

Ah, maybe you need the Deposit entity? πŸ€”

public class Deposit : Credit
{
    public DepositOrder Order { get; set; } = default!;
}

public abstract class Credit : Transaction;

[Index(nameof(Created))]
public abstract class Transaction
{
    public Guid Id { get; set; }

    public DateTimeOffset Created { get; set; }

}
Giorgi commented 5 months ago

Same error.

Giorgi commented 5 months ago

I think you should have ResultId and SubjectId in your model otherwise EF can't know what type they should be in the database.

Mike-E-angelo commented 5 months ago

That's weird @Giorgi I will investigate further. EF knows the type because of the Id property of the respective types, and uses these shadow properties accordingly.

One thing I thought of without looking into this is that you should make sure the base tables are defined as entities. I use TBH:

parameter.Entity<ExternalProcess>();
parameter.Entity<Transaction>();
Giorgi commented 5 months ago

That's right, the exception is gone, but I don't get the exception you are encountering:

image

Mike-E-angelo commented 5 months ago

Ah! OK now add the following:

public abstract class PurchaseOrder : ExternalProcess
{
    public MarketplaceTransaction? Result { get; set; }
}
public sealed class SaleOrder : PurchaseOrder;
public sealed class MarketplaceTransaction
{
    public Guid Id { get; init; }

    public DateTimeOffset Created { get; init; }

    public decimal Value { get; init; }
}

Configuration:

parameter.Entity<PurchaseOrder>();

That should be enough to have two entities with two indexes resulting in the same name. If not I can look into this further when I have some more time here. I appreciate your patience in walking through this with me. πŸ™

Giorgi commented 5 months ago

Same. Can you provide a Minimal reproducible example?

image

Mike-E-angelo commented 5 months ago

Sure... are you able to provide what you have so far as a starting point by chance? It would greatly assist me. πŸ‘

Giorgi commented 5 months ago

I have whatever you pasted here today.

Mike-E-angelo commented 5 months ago

OK I have a failing test for you here @Giorgi ... needed to add one more entity with a Result property. I have 4 of them in my model. :D

https://github.com/DragonSpark/Framework/commit/c58bf02725ca18c02ae91310d8a80b50b85e1fdc

Giorgi commented 5 months ago

Thanks @Mike-E-angelo I think I fixed the issue finally. I'm now able to get the real names and it should avoid duplicate keys.

@NickStrupat You can check commit d280318a9f37830099c989b5ff317c2eeec210eb to see how to retrieve the real names.

Giorgi commented 5 months ago

@Mike-E-angelo The fixed version is on the NuGet now.

Mike-E-angelo commented 5 months ago

Hooray! I can confirm this is working as expected now:

 ---> EntityFramework.Exceptions.Common.UniqueConstraintException: Unique constraint violation
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): Cannot insert duplicate key row in object 'dbo.Authenticator' with unique index 'IX_Authenticator_Identifier'. The duplicate key value is (twitter).
The statement has been terminated.
   at Task<DbDataReader> Microsoft.Data.SqlClient.SqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)+(Task<SqlDataReader> result) => { }
   at void System.Threading.Tasks.ContinuationResultTaskFromResultTask<TAntecedentResult, TResult>.InnerInvoke()
   at async Task<RelationalDataReader> Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken) x 2
   at async Task Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
ClientConnectionId:...
Error Number:2601,State:1,Class:14
   --- End of inner exception stack trace ---
   at Task EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor<T>.SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken)
   at Task Microsoft.EntityFrameworkCore.Diagnostics.CoreLoggerExtensions.SaveChangesFailedAsync(IDiagnosticsLogger<Update> diagnostics, DbContext context, Exception exception, CancellationToken cancellationToken)
   at async Task<int> Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken)

Thank you again for all your efforts out there! πŸ™πŸ™πŸ™

Giorgi commented 5 months ago

Thanks for testing it so thoroughly! In this case, the Constraint property of UniqueConstraintException should be IX_Authenticator_Identifier if you need that in your code.

zefubachs commented 3 months ago

This error also occurs when there are multiple tables with the same name and indexes in separate SQL Server schemas.

I have a table [inventory].[Category] with index IX_Category_Name and a table [incidents].[Category] with index IX_Category_Name. This is valid in SQL Server because index names are scoped to their associated tables.

Giorgi commented 3 months ago

Can you upload a repro project? Just the DbContext code is enough.

zefubachs commented 3 months ago

I've created a minimal example here: https://github.com/zefubachs/EFExceptionSchema

Giorgi commented 3 months ago

I'll have a look at it in a couple of days.

Giorgi commented 3 months ago

@zefubachs This is now fixed in the latest commit.

Eric-Ans commented 1 month ago

Hi, I have the same problem with EntityFrameworkCore.Exceptions.PostgreSQL 8.1.2 but with foreign key !!

This line : (in ExceptionProcessorInterceptor, line 118)

foreignKeys = mappedConstraints.ToDictionary(arg => arg.constraint.Name, arg => arg.Properties);

generate this error :

 System.ArgumentException: An item with the same key has already been added. Key: fk_montants_pieces_pieces_piece_entreprise_id_piece_id
   at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
   at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector)
   at EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor`1.SetConstraintDetails(DbContext context, ReferenceConstraintException exception, Exception providerException)
   at EntityFramework.Exceptions.Common.ExceptionProcessorInterceptor`1.SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Diagnostics.CoreLoggerExtensions.SaveChangesFailedAsync(IDiagnosticsLogger`1 diagnostics, DbContext context, Exception exception, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

I have two foreign key with the same constraint name. (efcore generate this case when using inheritance)

image

Could you check and apply same fix to foreign key and generate a new nuget package ? EntityFrameworkCore.Exceptions.PostgreSQL version 8.1.2 does not have this commit.

Thanks.

Giorgi commented 1 month ago

@Eric-Ans Can you share the context and configuration that causes this error?

Eric-Ans commented 1 month ago

@Giorgi https://github.com/Eric-Ans/TestEntityFrameworkExceptions

This sample throw the error I describe previously when context.SaveChanges(); is called in main.

If you look at file : 20240731133414_InitialCreate.Designer.cs: you will see two entries with the same foreign key name : HasConstraintName("fk_montants_pieces_piece_id");

and in ExceptionProcessorInterceptor class line 118, mappedConstraints contains the same two constraint name, so ToDictionary fail.

image

image