Skip to content

Commit

Permalink
Merge pull request #158 from dadyarri/support-oracle
Browse files Browse the repository at this point in the history
Add Oracle DBMS Support
  • Loading branch information
artiomchi authored Nov 22, 2024
2 parents c2b96fc + 0a44b4a commit fa5ebd2
Show file tree
Hide file tree
Showing 15 changed files with 287 additions and 51 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CI build: [![FlexLabs.EntityFrameworkCore.Upsert on MyGet](https://img.shields.i

This library adds basic support for "Upsert" operations to EF Core.

Uses `INSERT … ON CONFLICT DO UPDATE` in PostgreSQL/Sqlite, `MERGE` in SqlServer and `INSERT INTO … ON DUPLICATE KEY UPDATE` in MySQL.
Uses `INSERT … ON CONFLICT DO UPDATE` in PostgreSQL/Sqlite, `MERGE` in SqlServer & Oracle and `INSERT INTO … ON DUPLICATE KEY UPDATE` in MySQL.

Also supports injecting sql command runners to add support for other providers

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static IUpsertCommandRunner[] GetRunners()
new PostgreSqlUpsertCommandRunner(),
new SqlServerUpsertCommandRunner(),
new SqliteUpsertCommandRunner(),
new OracleUpsertCommandRunner(),
};
return Runners;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using FlexLabs.EntityFrameworkCore.Upsert.Internal;

namespace FlexLabs.EntityFrameworkCore.Upsert.Runners
{
/// <summary>
/// Upsert command runner for the Oracle.EntityFrameworkCore provider
/// </summary>
public class OracleUpsertCommandRunner : RelationalUpsertCommandRunner
{
/// <inheritdoc />
public override bool Supports(string providerName) => providerName == "Oracle.EntityFrameworkCore";
/// <inheritdoc />
protected override string EscapeName([NotNull] string name) => $"\"{name}\"";
/// <inheritdoc />
protected override string? SourcePrefix => "s.";
/// <inheritdoc />
protected override string? TargetPrefix => "t.";
/// <inheritdoc />
protected override string Parameter(int index) => $":p{index}";
/// <inheritdoc />
protected override int? MaxQueryParams => 1000;

/// <inheritdoc />
public override string GenerateCommand(
string tableName,
ICollection<ICollection<(string ColumnName, ConstantValue Value, string DefaultSql, bool AllowInserts)>> entities,
ICollection<(string ColumnName, bool IsNullable)> joinColumns,
ICollection<(string ColumnName, IKnownValue Value)>? updateExpressions,
KnownExpression? updateCondition)
{
ArgumentNullException.ThrowIfNull(entities);
var result = new StringBuilder();

result.Append(CultureInfo.InvariantCulture, $"MERGE INTO {tableName} t USING (");
foreach (var item in entities.Select((e, ind) => new {e, ind}))
{
result.Append(" SELECT ");
result.Append(string.Join(", ", item.e.Select(ec => string.Join(" AS ", ExpandValue(ec.Value), EscapeName(ec.ColumnName)))));
result.Append(" FROM dual");
if (entities.Count > 1 && item.ind != entities.Count - 1)
{
result.Append(" UNION ALL ");
}
}
result.Append(") s ON (");
result.Append(string.Join(" AND ", joinColumns.Select(j => $"t.{EscapeName(j.ColumnName)} = s.{EscapeName(j.ColumnName)}")));
result.Append(") ");
result.Append(" WHEN NOT MATCHED THEN INSERT (");
result.Append(string.Join(", ", entities.First().Where(e => e.AllowInserts).Select(e => EscapeName(e.ColumnName))));
result.Append(") VALUES (");
result.Append(string.Join(", ", entities.First().Where(e => e.AllowInserts).Select(e => $"s.{EscapeName(e.ColumnName)}")));
result.Append(") ");
if (updateExpressions is not null)
{
result.Append("WHEN MATCHED ");

result.Append("THEN UPDATE SET ");
result.Append(string.Join(", ", updateExpressions.Select(e => $"t.{EscapeName(e.ColumnName)} = {ExpandValue(e.Value)}")));
if (updateCondition is not null)
{
result.Append(CultureInfo.InvariantCulture, $" WHERE {ExpandExpression(updateCondition)} ");
}
}

return result.ToString();
}

/// <inheritdoc />
protected override string ExpandExpression(KnownExpression expression, Func<string, string>? expandLeftColumn = null)
{
ArgumentNullException.ThrowIfNull(expression);

switch (expression.ExpressionType)
{
case ExpressionType.And:
{
var left = ExpandValue(expression.Value1, expandLeftColumn);
var right = ExpandValue(expression.Value2!, expandLeftColumn);
return $"BITAND({left}, {right})";
}
case ExpressionType.Or:
{
var left = ExpandValue(expression.Value1, expandLeftColumn);
var right = ExpandValue(expression.Value2!, expandLeftColumn);
return $"BITOR({left}, {right})";
}
case ExpressionType.Modulo:
{
var left = ExpandValue(expression.Value1, expandLeftColumn);
var right = ExpandValue(expression.Value2!, expandLeftColumn);
return $"MOD({left}, {right})";
}

default:
return base.ExpandExpression(expression, expandLeftColumn);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public enum DbDriver
MySQL,
InMemory,
Sqlite,
Oracle,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ public static readonly Expression<Func<PageVisit, object>> MatchKey
public DateTime FirstVisit { get; set; }
public DateTime LastVisit { get; set; }
}

[Table("SchemaTable", Schema = "testsch")]
public class SchemaTable
{
public int ID { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<GeneratedAlwaysAsIdentity>().HasIndex(b => b.Num1).IsUnique();
modelBuilder.Entity<GeneratedAlwaysAsIdentity>().Property(e => e.Num2).UseIdentityAlwaysColumn();
modelBuilder.Entity<ComputedColumn>().HasIndex(b => b.Num1).IsUnique();
modelBuilder.Entity<ComputedColumn>().Property(e => e.Num3).HasComputedColumnSql($"{EscapeColumn(dbProvider, nameof(ComputedColumn.Num2))} + 1", stored: true);
modelBuilder.Entity<ComputedColumn>().Property(e => e.Num3)
.HasComputedColumnSql($"{EscapeColumn(dbProvider, nameof(ComputedColumn.Num2))} + 1", stored: true);

if (dbProvider.Name == "Npgsql.EntityFrameworkCore.PostgreSQL")
{
Expand All @@ -48,10 +49,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<JsonData>().Ignore(j => j.Child);
}

if (dbProvider.Name != "Pomelo.EntityFrameworkCore.MySql") // Can't have a default value on TEXT columns in MySql
{
modelBuilder.Entity<NullableRequired>().Property(e => e.Text).HasDefaultValue("B");
if (dbProvider.Name == "Pomelo.EntityFrameworkCore.MySql") // Can't have table schemas in MySql
modelBuilder.Entity<SchemaTable>().Metadata.SetSchema(null);
}

if (dbProvider.Name != "Pomelo.EntityFrameworkCore.MySql" &&
dbProvider.Name != "Oracle.EntityFrameworkCore") // Can't have table schemas in MySql and Oracle
{
modelBuilder.Entity<SchemaTable>().Metadata.SetSchema("testsch");
}
}

private string EscapeColumn(IDatabaseProvider dbProvider, string columnName)
Expand All @@ -60,6 +68,7 @@ private string EscapeColumn(IDatabaseProvider dbProvider, string columnName)
"Pomelo.EntityFrameworkCore.MySql" => $"`{columnName}`",
"Npgsql.EntityFrameworkCore.PostgreSQL" => $"\"{columnName}\"",
"Microsoft.EntityFrameworkCore.Sqlite" => $"\"{columnName}\"",
"Oracle.EntityFrameworkCore" => columnName.ToUpper(),
_ => $"[{columnName}]"
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Threading.Tasks;
using DotNet.Testcontainers.Containers;

namespace FlexLabs.EntityFrameworkCore.Upsert.IntegrationTests
{
public abstract class ContainerisedDatabaseInitializerFixture<TContainer> : DatabaseInitializerFixture
where TContainer : IContainer, IDatabaseContainer
{
public TContainer TestContainer { get; }

public ContainerisedDatabaseInitializerFixture()
{
if (!BuildEnvironment.UseLocalService)
{
TestContainer = BuildContainer();
}
}

protected abstract TContainer BuildContainer();

public override async Task InitializeAsync()
{
if (TestContainer is not null)
{
await TestContainer.StartAsync();
}

await base.InitializeAsync();
}

public override async Task DisposeAsync()
{
if (TestContainer is not null)
{
await TestContainer.StopAsync();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Threading.Tasks;
using DotNet.Testcontainers.Containers;
using FlexLabs.EntityFrameworkCore.Upsert.IntegrationTests.Base;
using Microsoft.EntityFrameworkCore;
using Xunit;
Expand All @@ -8,29 +7,14 @@ namespace FlexLabs.EntityFrameworkCore.Upsert.IntegrationTests
{
public abstract class DatabaseInitializerFixture : IAsyncLifetime
{
public IContainer TestContainer { get; }
public DbContextOptions<TestDbContext> DataContextOptions { get; private set; }

public DatabaseInitializerFixture()
{
if (!BuildEnvironment.UseLocalService)
{
TestContainer = BuildContainer();
}
}

public abstract DbDriver DbDriver { get; }
protected virtual IContainer BuildContainer() => null;

protected abstract void ConfigureContextOptions(DbContextOptionsBuilder<TestDbContext> builder);

public async Task InitializeAsync()
public virtual async Task InitializeAsync()
{
if (TestContainer is not null)
{
await TestContainer.StartAsync();
}

var builder = new DbContextOptionsBuilder<TestDbContext>();
ConfigureContextOptions(builder);
DataContextOptions = builder.Options;
Expand All @@ -39,12 +23,6 @@ public async Task InitializeAsync()
await context.Database.EnsureCreatedAsync();
}

public async Task DisposeAsync()
{
if (TestContainer is not null)
{
await TestContainer.StopAsync();
}
}
public virtual Task DisposeAsync() => Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public DbTestsBase(DatabaseInitializerFixture fixture)
ISO = "AU",
Created = NewDateTime(1970, 1, 1),
};

readonly PageVisit _dbVisitOld = new()
{
UserID = 1,
Expand All @@ -33,6 +34,7 @@ public DbTestsBase(DatabaseInitializerFixture fixture)
FirstVisit = NewDateTime(1970, 1, 1),
LastVisit = NewDateTime(1970, 1, 1),
};

readonly PageVisit _dbVisit = new()
{
UserID = 1,
Expand All @@ -41,35 +43,43 @@ public DbTestsBase(DatabaseInitializerFixture fixture)
FirstVisit = NewDateTime(1970, 1, 1),
LastVisit = NewDateTime(1970, 1, 1),
};

readonly Status _dbStatus = new()
{
ID = 1,
Name = "Created",
LastChecked = NewDateTime(1970, 1, 1),
};

readonly Book _dbBook = new()
{
Name = "The Fellowship of the Ring",
Genres = new[] { "Fantasy" },
};

readonly NullableCompositeKey _nullableKey1 = new()
{
ID1 = 1,
ID2 = 2,
Value = "First",
};

readonly NullableCompositeKey _nullableKey2 = new()
{
ID1 = 1,
ID2 = null,
Value = "Second",
};

readonly ComputedColumn _computedColumn = new()
{
Num1 = 1,
Num2 = 7,
};
readonly static DateTime _now = NewDateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, DateTime.Now.Hour, DateTime.Now.Minute, DateTime.Now.Second);

readonly static DateTime _now = NewDateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day,
DateTime.Now.Hour, DateTime.Now.Minute, DateTime.Now.Second);

readonly static DateTime _today = NewDateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day);
readonly int _increment = 8;

Expand Down Expand Up @@ -1247,7 +1257,8 @@ public void Upsert_KeyOnly()
[Fact]
public void Upsert_NullableKeys()
{
if (_fixture.DbDriver == DbDriver.MySQL || _fixture.DbDriver == DbDriver.Postgres || _fixture.DbDriver == DbDriver.Sqlite)
if (_fixture.DbDriver == DbDriver.MySQL || _fixture.DbDriver == DbDriver.Postgres ||
_fixture.DbDriver == DbDriver.Sqlite || _fixture.DbDriver == DbDriver.Oracle)
return;

ResetDb();
Expand Down Expand Up @@ -1681,7 +1692,8 @@ public void Upsert_UpdateCondition_ValueCheck()
[Fact]
public void Upsert_UpdateCondition_ValueCheck_UpdateColumnFromCondition()
{
if (BuildEnvironment.IsGitHub && _fixture.DbDriver == DbDriver.MySQL && Environment.OSVersion.Platform == PlatformID.Unix)
if (BuildEnvironment.IsGitHub && _fixture.DbDriver == DbDriver.MySQL &&
Environment.OSVersion.Platform == PlatformID.Unix)
{
// Disabling this test on GitHub Ubuntu images - they're cursed?
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using DotNet.Testcontainers.Containers;
using System;
using FlexLabs.EntityFrameworkCore.Upsert.IntegrationTests.Base;
using FlexLabs.EntityFrameworkCore.Upsert.Tests.EF;
using Microsoft.EntityFrameworkCore;
Expand All @@ -10,16 +10,17 @@ namespace FlexLabs.EntityFrameworkCore.Upsert.IntegrationTests
#if !NOMYSQL
public class DbTests_MySql : DbTestsBase, IClassFixture<DbTests_MySql.DatabaseInitializer>
{
public sealed class DatabaseInitializer : DatabaseInitializerFixture
public sealed class DatabaseInitializer : ContainerisedDatabaseInitializerFixture<MySqlContainer>
{
public override DbDriver DbDriver => DbDriver.MySQL;

protected override IContainer BuildContainer()
protected override MySqlContainer BuildContainer()
=> new MySqlBuilder().Build();

protected override void ConfigureContextOptions(DbContextOptionsBuilder<TestDbContext> builder)
{
var connectionString = (TestContainer as IDatabaseContainer).GetConnectionString();
var connectionString = TestContainer?.GetConnectionString()
?? throw new InvalidOperationException("Connection string was not initialised");
builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString));
}
}
Expand Down
Loading

0 comments on commit fa5ebd2

Please sign in to comment.