Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@
- Do not include "issue #N", "triangulate", "hypothesis", or similar investigation language in
inline code comments or block comments inside method bodies. Such context belongs in the pull
request description, not in the source code.

## Documentation and tests

- Always update documentation when public APIs change.
- Always update `README.md` when notable behavior or features are added or modified.
- Always add or update tests for behavior changes.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ await dbContext.ExecuteBulkInsertAsync(entities, o =>
await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities);
```

### Logging

Bulk insert operations emit EF Core-style logs when a logger factory is configured on the `DbContext` options:

```csharp
services.AddDbContext<MyDbContext>(options =>
{
options
.UseSqlite(connectionString)
.UseLoggerFactory(loggerFactory)
.UseBulkInsertSqlite();
});
```

Log events:

* `1004` (`Information`): bulk insert completion with elapsed time and destination table.
* `1005` (`Debug`): auxiliary SQL commands executed by the library (such as temp-table DDL).

### Conflict resolution / merge / upsert

Conflict resolution works by specifying columns that should be used to detect conflicts and the action to take when
Expand Down
19 changes: 19 additions & 0 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ await dbContext.ExecuteBulkInsertAsync(entities, o =>
await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities);
```

## Logging

Bulk insert operations emit EF Core-style logs when a logger factory is configured:

```csharp
services.AddDbContext<MyDbContext>(options =>
{
options
.UseSqlite(connectionString)
.UseLoggerFactory(loggerFactory)
.UseBulkInsertSqlite();
});
```

Log events:

* `1004` (`Information`): bulk insert completion with elapsed time and destination table.
* `1005` (`Debug`): auxiliary SQL commands executed by the library (for example temp-table SQL).

### Conflict resolution / merge / upsert

Conflict resolution works by specifying columns that should be used to detect conflicts and the action to take when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
namespace PhenX.EntityFrameworkCore.BulkInsert.MySql;

[UsedImplicitly]
internal class MySqlBulkInsertProvider(ILogger<MySqlBulkInsertProvider> logger) : BulkInsertProviderBase<MySqlServerDialectBuilder, MySqlBulkInsertOptions>(logger)
internal class MySqlBulkInsertProvider(ILoggerFactory? loggerFactory) : BulkInsertProviderBase<MySqlServerDialectBuilder, MySqlBulkInsertOptions>(loggerFactory)
{
//language=sql
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
namespace PhenX.EntityFrameworkCore.BulkInsert.Oracle;

[UsedImplicitly]
internal class OracleBulkInsertProvider(ILogger<OracleBulkInsertProvider>? logger) : BulkInsertProviderBase<OracleDialectBuilder, OracleBulkInsertOptions>(logger)
internal class OracleBulkInsertProvider(ILoggerFactory? loggerFactory) : BulkInsertProviderBase<OracleDialectBuilder, OracleBulkInsertOptions>(loggerFactory)
{
/// <inheritdoc />
protected override string BulkInsertId => "ROWID";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
namespace PhenX.EntityFrameworkCore.BulkInsert.PostgreSql;

[UsedImplicitly]
internal class PostgreSqlBulkInsertProvider(ILogger<PostgreSqlBulkInsertProvider>? logger) : BulkInsertProviderBase<PostgreSqlDialectBuilder, PostgreSqlBulkInsertOptions>(logger)
internal class PostgreSqlBulkInsertProvider(ILoggerFactory? loggerFactory) : BulkInsertProviderBase<PostgreSqlDialectBuilder, PostgreSqlBulkInsertOptions>(loggerFactory)
{
//language=sql
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace PhenX.EntityFrameworkCore.BulkInsert.SqlServer;

[UsedImplicitly]
internal class SqlServerBulkInsertProvider(ILogger<SqlServerBulkInsertProvider>? logger) : BulkInsertProviderBase<SqlServerDialectBuilder, SqlServerBulkInsertOptions>(logger)
internal class SqlServerBulkInsertProvider(ILoggerFactory? loggerFactory) : BulkInsertProviderBase<SqlServerDialectBuilder, SqlServerBulkInsertOptions>(loggerFactory)
{
//language=sql
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
namespace PhenX.EntityFrameworkCore.BulkInsert.Sqlite;

[UsedImplicitly]
internal class SqliteBulkInsertProvider(ILogger<SqliteBulkInsertProvider>? logger) : BulkInsertProviderBase<SqliteDialectBuilder, BulkInsertOptions>(logger)
internal class SqliteBulkInsertProvider(ILoggerFactory? loggerFactory) : BulkInsertProviderBase<SqliteDialectBuilder, BulkInsertOptions>(loggerFactory)
{
private const int MaxParams = 1000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ public DbContextOptionsExtensionInfo Info

public void ApplyServices(IServiceCollection services)
{
services.TryAddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddSingleton<IBulkInsertProvider, TProvider>();
services.TryAddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddScoped<IBulkInsertProvider, TProvider>();
}

public void Validate(IDbContextOptions options)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;

using Microsoft.EntityFrameworkCore;
Expand All @@ -11,10 +12,12 @@

namespace PhenX.EntityFrameworkCore.BulkInsert;

internal abstract class BulkInsertProviderBase<TDialect, TOptions>(ILogger? logger) : BulkInsertProviderUntyped<TDialect, TOptions>
internal abstract class BulkInsertProviderBase<TDialect, TOptions>(ILoggerFactory? loggerFactory) : BulkInsertProviderUntyped<TDialect, TOptions>
where TDialect : SqlDialectBuilder, new()
where TOptions : BulkInsertOptions, new()
{
private ILogger? _logger;
private ILogger? logger => _logger ??= loggerFactory?.CreateLogger(GetType());
protected virtual string BulkInsertId => "_bulk_insert_id";

protected abstract string AddTableCopyBulkInsertId { get; }
Expand Down Expand Up @@ -144,7 +147,15 @@ private async Task<string> PerformBulkInsertAsync<T>(
activity?.AddTag("tempTable", tempTableRequired);
activity?.AddTag("synchronous", sync);

var sw = Stopwatch.StartNew();
await BulkInsert(sync, context, tableInfo, entities, tableName, columns, options, ctk);
sw.Stop();

if (logger != null)
{
Log.BulkInsertExecuted(logger, sw.ElapsedMilliseconds, tableName);
}

return tableName;
}

Expand Down Expand Up @@ -254,16 +265,17 @@ protected virtual Task DropTempTableAsync(bool sync, DbContext dbContext, string
return Task.CompletedTask;
}

protected static async Task ExecuteAsync(
protected async Task ExecuteAsync(
bool sync,
DbContext context,
string query,
CancellationToken ctk)
{
var command = context.Database.GetDbConnection().CreateCommand();
using var command = context.Database.GetDbConnection().CreateCommand();
command.Transaction = context.Database.CurrentTransaction!.GetDbTransaction();
command.CommandText = query;

var sw = Stopwatch.StartNew();
if (sync)
{
// ReSharper disable once MethodHasAsyncOverloadWithCancellation
Expand All @@ -273,5 +285,11 @@ protected static async Task ExecuteAsync(
{
await command.ExecuteNonQueryAsync(ctk);
}
sw.Stop();

if (logger != null)
{
Log.ExecutedDbCommand(logger, sw.ElapsedMilliseconds, query);
}
}
}
12 changes: 12 additions & 0 deletions src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,16 @@ internal static partial class Log
Level = LogLevel.Error,
Message = "Failed to drop temporary table.")]
public static partial void DropTemporaryTableFailed(ILogger logger, Exception exception);

[LoggerMessage(
EventId = 1004,
Level = LogLevel.Information,
Message = "Executed BulkInsert ({ElapsedMs}ms) [DestinationTable='{DestinationTable}']")]
public static partial void BulkInsertExecuted(ILogger logger, long elapsedMs, string destinationTable);

[LoggerMessage(
EventId = 1005,
Level = LogLevel.Debug,
Message = "Executed DbCommand ({ElapsedMs}ms)\n{CommandText}")]
public static partial void ExecutedDbCommand(ILogger logger, long elapsedMs, string commandText);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Extensions.Logging;

namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Logging;

internal sealed record LogEntry(LogLevel Level, EventId EventId, string Message);

internal sealed class CapturingLogger : ILogger
{
private readonly List<LogEntry> _entries;

public CapturingLogger(List<LogEntry> entries)
{
_entries = entries;
}

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
_entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception)));
}

private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}

internal sealed class CapturingLoggerProvider(List<LogEntry> entries) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(entries);
public void Dispose() { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using FluentAssertions;

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

using PhenX.EntityFrameworkCore.BulkInsert.Extensions;
using PhenX.EntityFrameworkCore.BulkInsert.Sqlite;
using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext;

using Xunit;

namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Logging;

[Trait("Category", "Sqlite")]
public class LoggingTests
{
[Fact]
public async Task BulkInsert_LogsBulkInsertExecuted_AtInformationLevel()
{
// Arrange
var logEntries = new List<LogEntry>();
using var loggerFactory = LoggerFactory.Create(builder =>
builder
.AddProvider(new CapturingLoggerProvider(logEntries))
.SetMinimumLevel(LogLevel.Debug));

await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();

await using var context = new TestDbContextSqlite
{
ConfigureOptions = builder => builder
.UseSqlite(connection)
.UseLoggerFactory(loggerFactory)
.UseBulkInsertSqlite(),
};
await context.Database.EnsureCreatedAsync();

var run = Guid.NewGuid();
var entities = new List<TestEntity>
{
new TestEntity { TestRun = run, Name = "Entity1" },
new TestEntity { TestRun = run, Name = "Entity2" },
};

// Act
await context.ExecuteBulkInsertAsync(entities);

// Assert
logEntries.Should().Contain(e =>
e.Level == LogLevel.Information &&
e.EventId.Id == 1004 &&
e.Message.Contains("Executed BulkInsert"));
}

[Fact]
public async Task BulkInsert_LogsDbCommands_AtDebugLevel()
{
// Arrange
var logEntries = new List<LogEntry>();
using var loggerFactory = LoggerFactory.Create(builder =>
builder
.AddProvider(new CapturingLoggerProvider(logEntries))
.SetMinimumLevel(LogLevel.Debug));

await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();

await using var context = new TestDbContextSqlite
{
ConfigureOptions = builder => builder
.UseSqlite(connection)
.UseLoggerFactory(loggerFactory)
.UseBulkInsertSqlite(),
};
await context.Database.EnsureCreatedAsync();

var run = Guid.NewGuid();
var entities = new List<TestEntity>
{
new TestEntity { TestRun = run, Name = "Entity1" },
};

// Act - ReturnEntities always uses a temp table, triggering auxiliary SQL commands
await context.ExecuteBulkInsertReturnEntitiesAsync(entities);

// Assert
logEntries.Should().Contain(e =>
e.Level == LogLevel.Debug &&
e.EventId.Id == 1005 &&
e.Message.Contains("Executed DbCommand"));
}

[Fact]
public async Task BulkInsert_LogMessageContainsTableName()
{
// Arrange
var logEntries = new List<LogEntry>();
using var loggerFactory = LoggerFactory.Create(builder =>
builder
.AddProvider(new CapturingLoggerProvider(logEntries))
.SetMinimumLevel(LogLevel.Information));

await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();

await using var context = new TestDbContextSqlite
{
ConfigureOptions = builder => builder
.UseSqlite(connection)
.UseLoggerFactory(loggerFactory)
.UseBulkInsertSqlite(),
};
await context.Database.EnsureCreatedAsync();

var run = Guid.NewGuid();
var entities = new List<TestEntity>
{
new TestEntity { TestRun = run, Name = "Entity1" },
};

// Act
await context.ExecuteBulkInsertAsync(entities);

// Assert
logEntries.Should().Contain(e =>
e.Level == LogLevel.Information &&
e.EventId.Id == 1004 &&
e.Message.Contains("test_entity"));
}
}
Loading