Entity Framework Core(EF Core)拦截器允许截获、修改和/或抑制 EF Core 操作。 这包括执行命令等低级别数据库作,以及更高级别的作,例如对 SaveChanges 的调用。
拦截器与日志记录和诊断不同,因为拦截器允许修改或抑制正在拦截的操作。 简单的日志记录 或 Microsoft.Extensions.Logging 是日志记录的更好选择。
配置上下文时,会为每个 DbContext 实例注册侦听器。 使用 诊断侦听器 获取相同的信息,但对于进程中的所有 DbContext 实例。
注册拦截器
配置 DbContext 实例时,会使用AddInterceptors拦截器进行注册。 这通常是在重写中完成的 DbContext.OnConfiguring。 例如:
public class ExampleContext : BlogsContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}
或者,AddInterceptors
可以被调用作为 AddDbContext 的一部分或在创建 DbContextOptions 实例以传递给 DbContext 构造函数时调用。
小窍门
在使用 AddDbContext 或将 DbContextOptions 实例传递给 DbContext 构造函数时,仍会调用 OnConfiguring。 这样,无论如何构造 DbContext,它都是应用上下文配置的理想位置。
侦听器通常是无状态的,这意味着单个侦听器实例可用于所有 DbContext 实例。 例如:
public class TaggedQueryCommandInterceptorContext : BlogsContext
{
private static readonly TaggedQueryCommandInterceptor _interceptor
= new TaggedQueryCommandInterceptor();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
每个拦截器实例都必须实现派生自 IInterceptor的一个或多个接口。 即使每个实例实现多个拦截接口,每个实例也应注册一次;EF Core 将根据需要为每个接口路由事件。
数据库拦截
注释
数据库拦截仅适用于关系数据库提供程序。
低级数据库拦截拆分为下表所示的三个接口。
拦截 器 | 已拦截的数据库操作 |
---|---|
IDbCommandInterceptor | 创建命令 执行命令 命令失败 释放命令的 DbDataReader |
IDbConnectionInterceptor | 打开和关闭连接 连接失败 |
IDbTransactionInterceptor | 创建事务 使用现有事务 提交事务 回滚事务 创建和使用保存点事务失败 |
基类 DbCommandInterceptor, DbConnectionInterceptor并 DbTransactionInterceptor 包含相应接口中每个方法的 no-op 实现。 使用基类可避免实现未使用的拦截方法。
每种拦截器类型的方法成对出现,第一个在数据库操作开始前被调用,第二个在操作完成后调用。 例如, DbCommandInterceptor.ReaderExecuting 在执行查询之前调用,并在 DbCommandInterceptor.ReaderExecuted 查询发送到数据库后调用。
每对方法都有同步和异步变体。 这允许异步 I/O(例如请求访问令牌)作为拦截异步数据库操作的一部分进行。
示例:用于添加查询提示的命令拦截
小窍门
可以从 GitHub 下载命令拦截器示例 。
在将 SQL 发送到数据库之前,可以使用 IDbCommandInterceptor 来修改 SQL。 此示例演示如何修改 SQL 以包含查询提示。
通常,截获最棘手的部分是确定命令何时对应于需要修改的查询。 分析 SQL 是一个选项,但往往很脆弱。 另一个选项是使用 EF Core 查询标记 标记应修改的每个查询。 例如:
var blogs1 = await context.Blogs.TagWith("Use hint: robust plan").ToListAsync();
然后,可以在侦听器中检测此标记,因为它始终作为注释包含在命令文本的第一行中。 在检测标记时,将修改查询 SQL 以添加相应的提示:
public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
ManipulateCommand(command);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ManipulateCommand(command);
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
private static void ManipulateCommand(DbCommand command)
{
if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
{
command.CommandText += " OPTION (ROBUST PLAN)";
}
}
}
注意:
- 侦听器继承自 DbCommandInterceptor ,以避免在侦听器接口中实现每个方法。
- 侦听器实现同步和异步方法。 这可确保将相同的查询提示应用于同步和异步查询。
- 拦截器实现
Executing
由 EF Core 调用的方法,这些方法使用生成的 SQL 在发送到数据库之前。 与Executed
方法的形成对比,这些方法是在数据库调用返回后才被调用。
在标记查询时,运行此示例中的代码将生成以下内容:
-- Use hint: robust plan
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)
另一方面,如果未标记查询,则会将其发送到未修改的数据库:
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
示例:使用 AAD 进行 SQL Azure 身份验证的连接拦截
小窍门
可以从 GitHub 下载连接拦截器示例 。
一个 IDbConnectionInterceptor 可用于在连接到数据库之前对 DbConnection 进行处理。 这可用于获取 Azure Active Directory (AAD) 访问令牌。 例如:
public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
public override InterceptionResult ConnectionOpening(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result)
=> throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");
public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
var sqlConnection = (SqlConnection)connection;
var provider = new AzureServiceTokenProvider();
// Note: in some situations the access token may not be cached automatically the Azure Token Provider.
// Depending on the kind of token requested, you may need to implement your own caching here.
sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);
return result;
}
}
小窍门
Microsoft.Data.SqlClient 现在通过连接字符串支持 AAD 身份验证。 有关详细信息,请参阅 SqlAuthenticationMethod。
警告
请注意,如果进行同步调用以打开连接,侦听器将引发。 这是因为没有用于获取访问令牌的非异步方法, 并且没有通用和简单的方法来从非异步上下文调用异步方法,而不会造成死锁的风险。
警告
在某些情况下,访问令牌可能不会由 Azure 令牌提供程序自动缓存。 具体根据所请求的令牌类型,您可能需要在此处实现自己的缓存方案。
示例:用于缓存的高级命令拦截
小窍门
可以从 GitHub 下载高级命令拦截器示例 。
EF Core 侦听器可以:
- 告知 EF Core 禁止执行正在截获的操作
- 将操作结果报告给 EF Core
此示例展示了一个拦截器,它利用这些功能,行为类似于简单的二级缓存。 为特定查询返回缓存的查询结果,避免数据库往返。
警告
以这种方式更改 EF Core 默认行为时请小心。 如果 EF Core 收到无法正确处理的异常结果,则 EF Core 的行为可能出乎意料。 此外,此示例演示拦截器概念;它不用作可靠的二级缓存实现的模板。
在此示例中,应用程序经常执行查询以获取最新的“每日消息”:
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
此查询 已标记 ,以便可以轻松在拦截器中检测到它。 其思路是每天只查询数据库一次新消息。 在其他情况下,应用程序将使用缓存的结果。 (此示例使用样本中的延迟 10 秒来模拟新的一天。
侦听器状态
此侦听器是有状态的:它存储查询的最新每日消息的 ID 和消息文本,以及执行该查询的时间。 由于此状态,我们还需要一个 锁 ,因为缓存要求多个上下文实例必须使用相同的侦听器。
private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;
执行之前
在 Executing
方法(即在进行数据库调用之前),侦听器会检测标记的查询,然后检查是否存在缓存的结果。 如果找到此类结果,则会取消查询并改用缓存结果。
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
{
lock (_lock)
{
if (_message != null
&& DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
{
command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
}
}
}
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
请注意代码如何调用 InterceptionResult<TResult>.SuppressWithResult 并传递包含缓存数据的替换 DbDataReader 项。 然后返回此 InterceptionResult,导致查询执行被阻止。 替换读取器由 EF Core 用作查询结果。
此拦截器还修改命令文本。 此操作不是必需的,但可以提高日志消息的清晰度。 命令文本不需要是有效的 SQL,因为查询现在不会执行。
执行后
如果没有缓存的消息可用,或者消息已过期,则上述代码不会禁止显示结果。 因此,EF Core 会像正常一样执行查询。 在执行完毕后,将返回到拦截器Executed
的方法。 此时,如果结果尚未缓存读取器,则会从实际读取器中提取新的消息 ID 和字符串,并缓存该查询以供下一次使用此查询。
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
&& !(result is CachedDailyMessageDataReader))
{
try
{
await result.ReadAsync(cancellationToken);
lock (_lock)
{
_id = result.GetInt32(0);
_message = result.GetString(1);
_queriedAt = DateTime.UtcNow;
return new CachedDailyMessageDataReader(_id, _message);
}
}
finally
{
await result.DisposeAsync();
}
}
return result;
}
演示
缓存侦听器示例包含一个简单的控制台应用程序,用于查询每日消息以测试缓存:
// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
context.AddRange(
new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
new DailyMessage { Message = "Keep calm and drink tea" });
await context.SaveChangesAsync();
}
// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
context.Add(new DailyMessage { Message = "Free beer for unicorns" });
await context.SaveChangesAsync();
}
// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 5. Pretend it's the next day.
Thread.Sleep(10000);
// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
这会导致生成以下输出:
info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Keep calm and drink tea
info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
INSERT INTO "DailyMessages" ("Message")
VALUES (@p0);
SELECT "Id"
FROM "DailyMessages"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();
info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message: Skipping DB call; using cache.
Keep calm and drink tea
info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Free beer for unicorns
请注意,在日志输出中,应用程序继续使用缓存的消息,直到超时过期,此时再次查询数据库以获取任何新消息。
保存更改拦截
小窍门
可以从 GitHub 下载 SaveChanges 拦截器示例 。
SaveChanges 和 SaveChangesAsync 截距点由 ISaveChangesInterceptor 接口定义。 至于其他拦截器,已经作为便利提供了带有 no-op 方法的 SaveChangesInterceptor 基类。
小窍门
拦截器很强大。 但是,在许多情况下,重写 SaveChanges 方法或使用 DbContext 上提供的 用于 SaveChanges 的 .NET 事件 可能会更简单。
示例:用于审核的 SaveChanges 拦截器
可以截获 SaveChanges 以创建所做更改的独立审核记录。
注释
这不是一个可靠的审核解决方案。 相反,它是一个简单示例,用于演示截获的特征。
应用程序上下文
用于审核的示例使用包含博客和文章的简单 DbContext。
public class BlogsContext : DbContext
{
private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(_auditingInterceptor)
.UseSqlite("DataSource=blogs.db");
public DbSet<Blog> Blogs { get; set; }
}
public class Blog
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Post> Posts { get; } = new List<Post>();
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public Blog Blog { get; set; }
}
请注意,为每个 DbContext 实例会注册拦截器的新实例。 这是因为审计拦截器包含与当前上下文实例相关联的状态。
审核上下文
此示例还包含用于审核数据库的第二个 DbContext 和模型。
public class AuditContext : DbContext
{
private readonly string _connectionString;
public AuditContext(string connectionString)
{
_connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlite(_connectionString);
public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}
public class SaveChangesAudit
{
public int Id { get; set; }
public Guid AuditId { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public bool Succeeded { get; set; }
public string ErrorMessage { get; set; }
public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}
public class EntityAudit
{
public int Id { get; set; }
public EntityState State { get; set; }
public string AuditMessage { get; set; }
public SaveChangesAudit SaveChangesAudit { get; set; }
}
拦截器
使用侦听器进行审核的一般思路是:
- 审核消息在 SaveChanges 的开头创建,并写入审核数据库
- 允许 SaveChanges 继续进行
- 如果 SaveChanges 成功,则会更新审核消息以指示成功
- 如果 SaveChanges 失败,则会更新审核消息以指示失败
在将任何更改发送到数据库之前,使用ISaveChangesInterceptor.SavingChanges和ISaveChangesInterceptor.SavingChangesAsync的覆盖来处理第一阶段。
public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
await auditContext.SaveChangesAsync();
return result;
}
public InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
auditContext.SaveChanges();
return result;
}
重写同步和异步方法可确保无论调用SaveChanges
还是SaveChangesAsync
,审核都会进行。 另请注意,异步重载本身能够对审核数据库执行非阻塞的异步 I/O 操作。 你可能希望从同步 SavingChanges
方法引发,以确保所有数据库 I/O 都是异步的。 然后,这要求应用程序始终调用SaveChangesAsync
,永远不要调用SaveChanges
。
审核消息
每个拦截器方法都有一个 eventData
参数,提供有关正在截获的事件的上下文信息。 在这种情况下,当前应用程序 DbContext 包含在事件数据中,然后用于创建审核消息。
private static SaveChangesAudit CreateAudit(DbContext context)
{
context.ChangeTracker.DetectChanges();
var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };
foreach (var entry in context.ChangeTracker.Entries())
{
var auditMessage = entry.State switch
{
EntityState.Deleted => CreateDeletedMessage(entry),
EntityState.Modified => CreateModifiedMessage(entry),
EntityState.Added => CreateAddedMessage(entry),
_ => null
};
if (auditMessage != null)
{
audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
}
}
return audit;
string CreateAddedMessage(EntityEntry entry)
=> entry.Properties.Aggregate(
$"Inserting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateModifiedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
$"Updating {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateDeletedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
$"Deleting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}
结果是一个 SaveChangesAudit
实体,其中包含一个 EntityAudit
实体集合,每个插入、更新或删除操作对应一个 EntityAudit
实体。 然后,侦听器将这些实体插入审核数据库中。
小窍门
ToString 方法在每个 EF Core 事件数据类中被重定义,以生成事件的相应日志消息。 例如,调用 ContextInitializedEventData.ToString
会生成“Entity Framework Core 5.0.0 使用提供程序‘Microsoft.EntityFrameworkCore.Sqlite’和选项‘无’初始化‘BlogsContext’”。
检测成功
审核实体存储在侦听器上,以便在 SaveChanges 成功或失败后再次访问它。 对于成功, ISaveChangesInterceptor.SavedChanges 或 ISaveChangesInterceptor.SavedChangesAsync 被调用。
public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
auditContext.SaveChanges();
return result;
}
public async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
await auditContext.SaveChangesAsync(cancellationToken);
return result;
}
审核实体附加到审核上下文,因为它已存在于数据库中,需要更新。 然后,我们将Succeeded
EndTime
这些属性标记为已修改,以便 SaveChanges 将更新发送到审核数据库。
检测失败
失败的处理方式与成功的方式大致相同,但在ISaveChangesInterceptor.SaveChangesFailed方法或ISaveChangesInterceptor.SaveChangesFailedAsync方法中。 事件数据包含引发的异常。
public void SaveChangesFailed(DbContextErrorEventData eventData)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.Message;
auditContext.SaveChanges();
}
public async Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.InnerException?.Message;
await auditContext.SaveChangesAsync(cancellationToken);
}
演示
审核示例包含一个简单的控制台应用程序,该应用程序对博客数据库进行更改,然后显示创建的审核。
// Insert, update, and delete some entities
using (var context = new BlogsContext())
{
context.Add(
new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });
await context.SaveChangesAsync();
}
using (var context = new BlogsContext())
{
var blog = await context.Blogs.Include(e => e.Posts).SingleAsync();
blog.Name = "EF Core Blog";
context.Remove(blog.Posts.First());
blog.Posts.Add(new Post { Title = "EF Core 6.0!" });
await context.SaveChangesAsync();
}
// Do an insert that will fail
using (var context = new BlogsContext())
{
try
{
context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });
await context.SaveChangesAsync();
}
catch (DbUpdateException)
{
}
}
// Look at the audit trail
using (var context = new AuditContext("DataSource=audit.db"))
{
foreach (var audit in await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
{
Console.WriteLine(
$"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");
foreach (var entity in audit.Entities)
{
Console.WriteLine($" {entity.AuditMessage}");
}
if (!audit.Succeeded)
{
Console.WriteLine($" Error: {audit.ErrorMessage}");
}
}
}
结果显示审核数据库的内容:
Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
Updating Blog with Id: '1' Name: 'EF Core Blog'
Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.