diff --git a/PhenX.EntityFrameworkCore.BulkInsert.sln b/PhenX.EntityFrameworkCore.BulkInsert.sln index c42b00c..37c50e6 100644 --- a/PhenX.EntityFrameworkCore.BulkInsert.sln +++ b/PhenX.EntityFrameworkCore.BulkInsert.sln @@ -35,6 +35,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{45366E91-4 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhenX.EntityFrameworkCore.BulkInsert.MySql", "src\PhenX.EntityFrameworkCore.BulkInsert.MySql\PhenX.EntityFrameworkCore.BulkInsert.MySql.csproj", "{17649766-EA68-4333-8DA8-47B014A8B2CC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhenX.EntityFrameworkCore.BulkInsert.Oracle", "src\PhenX.EntityFrameworkCore.BulkInsert.Oracle\PhenX.EntityFrameworkCore.BulkInsert.Oracle.csproj", "{98CC5F0A-5739-4570-A384-A3A067D09755}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,10 @@ Global {17649766-EA68-4333-8DA8-47B014A8B2CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {17649766-EA68-4333-8DA8-47B014A8B2CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {17649766-EA68-4333-8DA8-47B014A8B2CC}.Release|Any CPU.Build.0 = Release|Any CPU + {98CC5F0A-5739-4570-A384-A3A067D09755}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98CC5F0A-5739-4570-A384-A3A067D09755}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98CC5F0A-5739-4570-A384-A3A067D09755}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98CC5F0A-5739-4570-A384-A3A067D09755}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -81,5 +87,6 @@ Global {E4EB1C53-575C-45F8-924A-93DC42E8ACCA} = {F8A83782-311C-454D-8B97-B3FB86478BF4} {450E859C-411F-4D67-A0B4-4E02C3D30E14} = {CBEBA2A8-79E0-412E-93C1-C88F4473D78B} {17649766-EA68-4333-8DA8-47B014A8B2CC} = {CBEBA2A8-79E0-412E-93C1-C88F4473D78B} + {98CC5F0A-5739-4570-A384-A3A067D09755} = {CBEBA2A8-79E0-412E-93C1-C88F4473D78B} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 7414107..b7faa33 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PhenX.EntityFrameworkCore.BulkInsert -A high-performance, provider-agnostic bulk insert extension for Entity Framework Core 8+. Supports SQL Server, PostgreSQL, SQLite and MySQL. +A high-performance, provider-agnostic bulk insert extension for Entity Framework Core 8+. Supports SQL Server, PostgreSQL, SQLite, MySQL and Oracle. Its main purpose is to provide a fast way to perform simple bulk inserts in Entity Framework Core applications. @@ -21,6 +21,7 @@ but they are in [the roadmap](#roadmap). | `PhenX.EntityFrameworkCore.BulkInsert.PostgreSql` | For PostgreSQL | [![NuGet](https://img.shields.io/nuget/v/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql.svg)](https://www.nuget.org/packages/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql) | | `PhenX.EntityFrameworkCore.BulkInsert.Sqlite` | For SQLite | [![NuGet](https://img.shields.io/nuget/v/PhenX.EntityFrameworkCore.BulkInsert.Sqlite.svg)](https://www.nuget.org/packages/PhenX.EntityFrameworkCore.BulkInsert.Sqlite) | | `PhenX.EntityFrameworkCore.BulkInsert.MySql` | For MySql | [![NuGet](https://img.shields.io/nuget/v/PhenX.EntityFrameworkCore.BulkInsert.Sqlite.svg)](https://www.nuget.org/packages/PhenX.EntityFrameworkCore.BulkInsert.MySql) | +| `PhenX.EntityFrameworkCore.BulkInsert.Oracle` | For Oracle | [![NuGet](https://img.shields.io/nuget/v/PhenX.EntityFrameworkCore.BulkInsert.Oracle.svg)](https://www.nuget.org/packages/PhenX.EntityFrameworkCore.BulkInsert.Oracle) | | `PhenX.EntityFrameworkCore.BulkInsert` | Common library | [![NuGet](https://img.shields.io/nuget/v/PhenX.EntityFrameworkCore.BulkInsert.svg)](https://www.nuget.org/packages/PhenX.EntityFrameworkCore.BulkInsert) | ## Installation @@ -39,6 +40,9 @@ Install-Package PhenX.EntityFrameworkCore.BulkInsert.Sqlite # For MySql Install-Package PhenX.EntityFrameworkCore.BulkInsert.MySql + +# For Oracle +Install-Package PhenX.EntityFrameworkCore.BulkInsert.Oracle ``` ## Usage @@ -58,6 +62,8 @@ services.AddDbContext(options => .UseBulkInsertSqlite() // OR .UseBulkInsertMySql() + // OR + .UseBulkInsertOracle() ; }); ``` @@ -183,6 +189,10 @@ MySQL results with 500 000 rows : ![bench-mysql.png](https://raw.githubusercontent.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/refs/heads/main/images/bench-mysql.png) +Oracle results with 500 000 rows : + +![bench-oracle.png](https://raw.githubusercontent.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/refs/heads/main/images/bench-oracle.png) + ## Contributing Contributions are welcome! Please open issues or submit pull requests for bug fixes, features, or documentation improvements. diff --git a/images/bench-oracle.png b/images/bench-oracle.png new file mode 100644 index 0000000..e4e6f56 Binary files /dev/null and b/images/bench-oracle.png differ diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertOptions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertOptions.cs new file mode 100644 index 0000000..0bf3384 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertOptions.cs @@ -0,0 +1,14 @@ +using Oracle.ManagedDataAccess.Client; + +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +/// +/// Options specific to Oracle bulk insert. +/// +public class OracleBulkInsertOptions : BulkInsertOptions +{ + /// + public OracleBulkCopyOptions CopyOptions { get; set; } = OracleBulkCopyOptions.Default; +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs new file mode 100644 index 0000000..35ee9b2 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleBulkInsertProvider.cs @@ -0,0 +1,94 @@ +using JetBrains.Annotations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Oracle.ManagedDataAccess.Client; + +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +[UsedImplicitly] +internal class OracleBulkInsertProvider(ILogger? logger) : BulkInsertProviderBase(logger) +{ + /// + protected override string BulkInsertId => "ROWID"; + + /// + protected override string AddTableCopyBulkInsertId => ""; // No need to add an ID column in Oracle + + /// + /// + /// The temporary table name is generated with a GUID to ensure uniqueness, but limited to less than 30 characters, + /// because Oracle prior 12.2 has a limit of 30 characters for identifiers. + /// + protected override string GetTempTableName(string tableName) => $"#temp_bulk_insert_{Guid.NewGuid().ToString("N")[..8]}"; + + protected override OracleBulkInsertOptions CreateDefaultOptions() => new() + { + BatchSize = 50_000, + }; + + /// + protected override IAsyncEnumerable BulkInsertReturnEntities( + bool sync, + DbContext context, + TableMetadata tableInfo, + IEnumerable entities, + OracleBulkInsertOptions options, + OnConflictOptions? onConflict, + CancellationToken ctk) + { + throw new NotSupportedException("Provider does not support returning entities."); + } + + /// + protected override Task BulkInsert( + bool sync, + DbContext context, + TableMetadata tableInfo, + IEnumerable entities, + string tableName, + IReadOnlyList columns, + OracleBulkInsertOptions options, + CancellationToken ctk) + { + var connection = (OracleConnection) context.Database.GetDbConnection(); + + using var bulkCopy = new OracleBulkCopy(connection, options.CopyOptions); + + bulkCopy.DestinationTableName = tableInfo.QuotedTableName; + bulkCopy.BatchSize = options.BatchSize; + bulkCopy.BulkCopyTimeout = options.GetCopyTimeoutInSeconds(); + + foreach (var column in columns) + { + bulkCopy.ColumnMappings.Add(column.PropertyName, column.QuotedColumName); + } + + var dataReader = new EnumerableDataReader(entities, columns, options); + + bulkCopy.WriteToServer(dataReader); + + return Task.CompletedTask; + } + + /// + protected override async Task DropTempTableAsync(bool sync, DbContext dbContext, string tableName) + { + var commandText = $""" + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE {tableName}'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -942 THEN -- ORA-00942: table or view does not exist + RAISE; + END IF; + END; + """; + + await ExecuteAsync(sync, dbContext, commandText, CancellationToken.None); + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDbContextOptionsExtensions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDbContextOptionsExtensions.cs new file mode 100644 index 0000000..2a78133 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDbContextOptionsExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Extensions; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +/// +/// DbContext options extension for Oracle. +/// +public static class OracleDbContextOptionsExtensions +{ + /// + /// Configures the DbContext to use the Oracle bulk insert provider. + /// + public static DbContextOptionsBuilder UseBulkInsertOracle(this DbContextOptionsBuilder optionsBuilder) + { + return optionsBuilder.UseProvider(); + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs new file mode 100644 index 0000000..b5d9ce3 --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -0,0 +1,115 @@ +using System.Text; + +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Dialect; +using PhenX.EntityFrameworkCore.BulkInsert.Metadata; +using PhenX.EntityFrameworkCore.BulkInsert.Options; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +internal class OracleDialectBuilder : SqlDialectBuilder +{ + protected override string OpenDelimiter => "\""; + protected override string CloseDelimiter => "\""; + protected override string ConcatOperator => "||"; + + protected override bool SupportsMoveRows => false; + + public override string CreateTableCopySql(string tempTableName, TableMetadata tableInfo, IReadOnlyList columns) + { + return CreateTableCopySqlBase(tempTableName, columns); + } + + public override string BuildMoveDataSql( + DbContext context, + TableMetadata target, + string source, + IReadOnlyList insertedColumns, + IReadOnlyList returnedColumns, + BulkInsertOptions options, + OnConflictOptions? onConflict = null) + { + var q = new StringBuilder(); + + // Merge handling + if (onConflict is OnConflictOptions onConflictTyped) + { + IEnumerable matchColumns; + if (onConflictTyped.Match != null) + { + matchColumns = GetColumns(target, onConflictTyped.Match); + } + else if (target.PrimaryKey.Count > 0) + { + matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName); + } + else + { + throw new InvalidOperationException("Table has no primary key that can be used for conflict detection."); + } + + q.AppendLine($"MERGE INTO {target.QuotedTableName} AS {PseudoTableInserted}"); + + q.Append("USING (SELECT "); + q.AppendColumns(insertedColumns); + q.Append($" FROM {source}) AS {PseudoTableExcluded} ("); + q.AppendColumns(insertedColumns); + q.AppendLine(")"); + + q.Append("ON "); + q.AppendJoin(" AND ", matchColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col} = {PseudoTableExcluded}.{col}")); + q.AppendLine(); + + if (onConflictTyped.Update != null) + { + var columns = target.GetColumns(false); + + q.AppendLine("WHEN MATCHED THEN UPDATE SET "); + q.AppendJoin(", ", GetUpdates(context, target, columns, onConflictTyped.Update)); + q.AppendLine(); + } + + q.Append("WHEN NOT MATCHED THEN INSERT ("); + q.AppendColumns(insertedColumns); + q.AppendLine(")"); + + q.Append("VALUES ("); + q.AppendJoin(", ", insertedColumns, (b, col) => b.Append($"{PseudoTableExcluded}.{col.QuotedColumName}")); + q.AppendLine(")"); + + if (returnedColumns.Count != 0) + { + q.Append("OUTPUT "); + q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($"{PseudoTableInserted}.{col.QuotedColumName} AS {col.QuotedColumName}")); + q.AppendLine(); + } + } + + // No conflict handling + else + { + q.Append($"INSERT INTO {target.QuotedTableName} ("); + q.AppendColumns(insertedColumns); + q.AppendLine(")"); + q.Append("SELECT "); + q.AppendColumns(insertedColumns); + q.AppendLine(); + q.Append($"FROM {source}"); + q.AppendLine(); + + if (returnedColumns.Count != 0) + { + q.Append("RETURNING "); + q.AppendJoin(", ", returnedColumns, (b, col) => b.Append(col.QuotedColumName)); + q.Append(" INTO "); + q.AppendJoin(", ", returnedColumns, (b, col) => b.Append($":{col.ColumnName}")); + q.AppendLine(); + } + } + + q.AppendLine(";"); + + return q.ToString(); + } +} diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/PhenX.EntityFrameworkCore.BulkInsert.Oracle.csproj b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/PhenX.EntityFrameworkCore.BulkInsert.Oracle.csproj new file mode 100644 index 0000000..5056ecc --- /dev/null +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/PhenX.EntityFrameworkCore.BulkInsert.Oracle.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs index 8ed724c..42552f2 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs @@ -18,22 +18,7 @@ internal class SqlServerDialectBuilder : SqlDialectBuilder public override string CreateTableCopySql(string tempTableName, TableMetadata tableInfo, IReadOnlyList columns) { - var q = new StringBuilder(); - q.Append($"CREATE TABLE {tempTableName} ("); - - foreach (var column in columns) - { - q.Append($"{column.QuotedColumName} {column.StoreDefinition}"); - if (column != columns[^1]) - { - q.Append(','); - } - q.AppendLine(); - } - - q.AppendLine(")"); - - return q.ToString(); + return CreateTableCopySqlBase(tempTableName, columns); } protected override string Trim(string lhs) => $"TRIM({lhs})"; diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs index 007aad4..cb670e2 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs @@ -24,7 +24,7 @@ internal class SqliteBulkInsertProvider(ILogger? logge protected override string AddTableCopyBulkInsertId => "--"; // No need to add an ID column in SQLite /// - protected override string GetTempTableName(string tableName) => $"_temp_bulk_insert_test_entity_{Guid.NewGuid():N}"; + protected override string GetTempTableName(string tableName) => $"_temp_bulk_insert_{Guid.NewGuid():N}"; /// protected override BulkInsertOptions CreateDefaultOptions() => new() diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs index fa9826a..c03e156 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs @@ -184,6 +184,12 @@ protected virtual async Task AddBulkInsertIdColumn( string tempTableName, CancellationToken ctk) where T : class { + if (string.IsNullOrEmpty(AddTableCopyBulkInsertId)) + { + // No need to add an ID column in this provider + return; + } + var alterQuery = string.Format(AddTableCopyBulkInsertId, tempTableName); await ExecuteAsync(sync, context, alterQuery, ctk); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index fcfa3aa..4508f69 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -29,6 +29,17 @@ internal abstract class SqlDialectBuilder /// protected virtual bool SupportsInsertIntoAlias => true; + protected static string CreateTableCopySqlBase(string tempTableName, IReadOnlyList columns) + { + var q = new StringBuilder(); + + q.Append($"CREATE TABLE {tempTableName} ("); + q.AppendJoin(",", columns, (sb, column) => sb.AppendLine($"{column.QuotedColumName} {column.StoreDefinition}")); + q.AppendLine(")"); + + return q.ToString(); + } + public abstract string CreateTableCopySql(string tempNameName, TableMetadata tableInfo, IReadOnlyList columns); /// diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Enums/ProviderType.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Enums/ProviderType.cs index 1010c28..dd9a86e 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Enums/ProviderType.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Enums/ProviderType.cs @@ -24,4 +24,9 @@ public enum ProviderType /// MySQL provider. /// MySql, + + /// + /// Oracle provider. + /// + Oracle, } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs index 111e8c7..fa959e8 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs @@ -74,13 +74,13 @@ internal static async Task GetConnection( /// /// Tells if the current provider is the specified provider type. /// - internal static bool IsProvider(this DbContext context, ProviderType providerType) + internal static bool IsProvider(this DbContext context, params ProviderType[] providerType) { if (context.Database.ProviderName == null) { throw new InvalidOperationException("Database provider name is null."); } - return context.Database.ProviderName.Contains(providerType.ToString(), StringComparison.OrdinalIgnoreCase); + return providerType.Any(p => context.Database.ProviderName.Contains(p.ToString(), StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/PhenX.EntityFrameworkCore.BulkInsert.csproj b/src/PhenX.EntityFrameworkCore.BulkInsert/PhenX.EntityFrameworkCore.BulkInsert.csproj index 90be5d2..070d77a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/PhenX.EntityFrameworkCore.BulkInsert.csproj +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/PhenX.EntityFrameworkCore.BulkInsert.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.RawInsert.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.RawInsert.cs index 2916f6a..79d045e 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.RawInsert.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.RawInsert.cs @@ -8,6 +8,8 @@ using Npgsql; +using Oracle.ManagedDataAccess.Client; + namespace PhenX.EntityFrameworkCore.BulkInsert.Benchmark; public abstract partial class LibComparator @@ -194,4 +196,57 @@ private void RawInsertMySql() bulkCopy.WriteToServer(dataTable); } } + + private void RawInsertOracle() + { + var connection = (OracleConnection)DbContext.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + { + connection.Open(); + } + + using var bulkCopy = new OracleBulkCopy(connection); + + bulkCopy.DestinationTableName = "\"" + nameof(TestEntity) + "\""; + bulkCopy.BatchSize = 50_000; + bulkCopy.BulkCopyTimeout = 60; + + bulkCopy.ColumnMappings.Add("Name", "\"Name\""); + bulkCopy.ColumnMappings.Add("Price", "\"Price\""); + bulkCopy.ColumnMappings.Add("Identifier", "\"Identifier\""); + bulkCopy.ColumnMappings.Add("CreatedAt", "\"CreatedAt\""); + bulkCopy.ColumnMappings.Add("UpdatedAt", "\"UpdatedAt\""); + bulkCopy.ColumnMappings.Add("NumericEnumValue", "\"NumericEnumValue\""); + + var dataTable = new DataTable(); + dataTable.Columns.Add("Name", typeof(string)); + dataTable.Columns.Add("Price", typeof(decimal)); + dataTable.Columns.Add("Identifier", typeof(Guid)); + dataTable.Columns.Add("CreatedAt", typeof(DateTime)); + dataTable.Columns.Add("UpdatedAt", typeof(DateTimeOffset)); + dataTable.Columns.Add("NumericEnumValue", typeof(int)); + + foreach (var entity in data) + { + var row = dataTable.NewRow(); + row["Name"] = entity.Name; + row["Price"] = entity.Price; + row["Identifier"] = entity.Identifier; + row["CreatedAt"] = entity.CreatedAt; + row["UpdatedAt"] = entity.UpdatedAt; + row["NumericEnumValue"] = (int)entity.NumericEnumValue; + dataTable.Rows.Add(row); + + if (dataTable.Rows.Count >= 50_000) + { + bulkCopy.WriteToServer(dataTable); + dataTable.Clear(); + } + } + + if (dataTable.Rows.Count > 0) + { + bulkCopy.WriteToServer(dataTable); + } + } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs index c225d3e..ccc76a0 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/LibComparator.cs @@ -90,6 +90,11 @@ public void RawInsert() // Use MySqlBulkCopy for PostgreSQL RawInsertMySql(); } + else if (DbContext.Database.ProviderName!.Contains("Oracle", StringComparison.InvariantCultureIgnoreCase)) + { + // Use OracleBulkCopy for Oracle + RawInsertOracle(); + } } [Benchmark] diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj index 79d08a0..ff1122a 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/PhenX.EntityFrameworkCore.BulkInsert.Benchmark.csproj @@ -11,6 +11,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Program.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Program.cs index 67edc71..625a58b 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Program.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Program.cs @@ -14,7 +14,7 @@ public static void Main(string[] args) .WithOptions(ConfigOptions.DisableOptimizationsValidator); // Micro benchmark for value getters - BenchmarkRunner.Run(config); + // BenchmarkRunner.Run(config); // Library comparison benchmarks var comparators = new[] @@ -23,6 +23,7 @@ public static void Main(string[] args) typeof(LibComparatorPostgreSql), typeof(LibComparatorSqlite), typeof(LibComparatorSqlServer), + typeof(LibComparatorOracle), }; BenchmarkRunner.Run(comparators, config); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Providers/LibComparatorOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Providers/LibComparatorOracle.cs new file mode 100644 index 0000000..b6eb92f --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/Providers/LibComparatorOracle.cs @@ -0,0 +1,32 @@ +using DotNet.Testcontainers.Containers; + +using LinqToDB.EntityFrameworkCore; + +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +using Testcontainers.Oracle; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Benchmark.Providers; + +public class LibComparatorOracle : LibComparator +{ + protected override void ConfigureDbContext() + { + var connectionString = GetConnectionString(); + + DbContext = new TestDbContext(p => p + .UseOracle(connectionString) + .UseBulkInsertOracle() + .UseLinqToDB() + ); + } + + protected override IDatabaseContainer? GetDbContainer() + { + return new OracleBuilder() + .WithImage("gvenzl/oracle-free:23-slim-faststart") + .Build(); + } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainerOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainerOracle.cs new file mode 100644 index 0000000..0ccbbaa --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainerOracle.cs @@ -0,0 +1,47 @@ +using DotNet.Testcontainers.Containers; + +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Oracle; + +using Testcontainers.Oracle; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; + +[CollectionDefinition(Name)] +public class TestDbContainerOracleCollection : ICollectionFixture +{ + public const string Name = "Oracle"; +} + +public class TestDbContainerOracle : TestDbContainer +{ + protected override IDatabaseContainer? GetDbContainer() + { + return new OracleBuilder() + .WithImage("gvenzl/oracle-free:23-slim-faststart") + .WithReuse(true) + .Build(); + } + + protected override void Configure(DbContextOptionsBuilder optionsBuilder, string databaseName) + { + optionsBuilder + .UseOracle(GetConnectionString(databaseName)) + .UseBulkInsertOracle(); + } + + protected override string GetConnectionString(string databaseName) + { + if (DbContainer == null) + { + return string.Empty; + } + + var port = DbContainer.GetMappedPublicPort(1521); + + return $"Data Source=(DESCRIPTION = (ADDRESS_LIST = (ADDRESS = (PROTOCOL = TCP)(HOST = localhost)(PORT = {port})) ) (CONNECT_DATA = (SERVICE_NAME = FREEPDB1) ) );User ID=oracle;Password=oracle"; + } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs index a15504a..07916ea 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs @@ -38,6 +38,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { b.Property(x => x.Json).AsJsonString("jsonb"); }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); } } @@ -51,6 +56,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { b.Property(x => x.Json).AsJsonString("json"); }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); } } @@ -64,10 +74,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { b.Property(x => x.Json).AsJsonString(null); }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); } } - public class TestDbContextSqlite : TestDbContext { protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -78,6 +92,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { b.Property(x => x.Json).AsJsonString(null); }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); + } +} + +public class TestDbContextOracle : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.Json).AsJsonString(null); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("nvarchar2(255)"); + }); } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs index 89dfab1..b679c32 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs @@ -22,9 +22,9 @@ public class TestEntity : TestEntityBase [Column("the_identifier")] public Guid Identifier { get; set; } - [Column("string_enum_value", TypeName = "text")] + [Column("string_enum_value")] public StringEnum StringEnumValue { get; set; } - [Column("num_enum_value", TypeName = "text")] + [Column("num_enum_value")] public NumericEnum NumericEnumValue { get; set; } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj index a6a61a4..37ff290 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj @@ -29,6 +29,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs index dac6711..3cdca2a 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs @@ -27,7 +27,12 @@ public static async Task> InsertWithStrategyAsync( OnConflictOptions? onConflict = null) where T : TestEntityBase { - Skip.If(strategy is InsertStrategy.InsertReturn or InsertStrategy.InsertReturnAsync && dbContext.IsProvider(ProviderType.MySql)); + ProviderType[] returningNotSupported = [ + ProviderType.MySql, + ProviderType.Oracle, + ]; + + Skip.If(strategy is InsertStrategy.InsertReturn or InsertStrategy.InsertReturnAsync && dbContext.IsProvider(returningNotSupported)); var runId = Guid.NewGuid(); if (entities.Any(x => x.TestRun == default)) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs index 86b47ce..ca54851 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs @@ -69,7 +69,7 @@ public async Task InsertEntities_WithJson(InsertStrategy strategy) [SkippableFact] public async Task InsertEntities_AndReturn_AsyncEnumerable() { - Skip.If(_context.IsProvider(ProviderType.MySql)); + Skip.If(_context.IsProvider(ProviderType.MySql, ProviderType.Oracle)); // Arrange var entities = new List @@ -96,6 +96,8 @@ public async Task InsertEntities_AndReturn_AsyncEnumerable() [CombinatorialData] public async Task InsertEntities_MoveRows(InsertStrategy strategy) { + Skip.If(_context.IsProvider(ProviderType.Oracle), "Unstable with Oracle"); + // Arrange var entities = new List { diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsOracle.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsOracle.cs new file mode 100644 index 0000000..aaec9c7 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsOracle.cs @@ -0,0 +1,12 @@ +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; + +[Trait("Category", "Oracle")] +[Collection(TestDbContainerOracleCollection.Name)] +public class BasicTestsOracle(TestDbContainerOracle dbContainer) : BasicTestsBase(dbContainer) +{ +}