Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Oracle DBMS Support #158

Merged
merged 27 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7510ff7
add deps for oracle; update other deps
dadyarri Feb 7, 2024
b6bbe3f
register oracle as supported driver
dadyarri Feb 7, 2024
0dd6b0b
write test runner for oracle (failing at this moment)
dadyarri Feb 7, 2024
b83a8f6
register command runner
dadyarri Feb 7, 2024
af075a6
add new command runner for oracle
dadyarri Feb 7, 2024
158a84d
update generated query
dadyarri Feb 9, 2024
dc64e7a
adapt to oracle requirements
dadyarri Feb 12, 2024
49c3128
reformat code
dadyarri Feb 12, 2024
6dc1c86
add disabling schema in oracle db along with mysql
dadyarri Feb 12, 2024
741abb3
add rule to escape columns for oracle
dadyarri Feb 12, 2024
5b18e6f
add dependency to generate correct namings in oracle database
dadyarri Feb 12, 2024
938f9a3
fix escaping
dadyarri Feb 12, 2024
5e837e7
override operation generating methods to support bitwise functions
dadyarri Feb 12, 2024
b2dfb09
move specifying schema name to db context
dadyarri Feb 12, 2024
e1f9e9d
make modulo operation call function in oracle
dadyarri Feb 12, 2024
2ee8d81
disable test on oracle due to lack of support of nullable primary keys
dadyarri Feb 12, 2024
38c830f
reformat code
dadyarri Feb 12, 2024
432f075
fix most tests with update condition
dadyarri Feb 13, 2024
95cf141
fixed one more test
dadyarri Feb 13, 2024
534965f
fix selecting columns in source
dadyarri Feb 13, 2024
dc887c4
update query generator to make work multiple upsert
dadyarri Feb 13, 2024
355b7f8
add oracle mention to readme
dadyarri Feb 13, 2024
07dac75
add unit tests for oracle
dadyarri Feb 13, 2024
8559733
Formatting changes
artiomchi Nov 21, 2024
8179af6
Merge branch 'develop' into support-oracle
artiomchi Nov 22, 2024
6c9c03c
Refactoring the db initialiser fixture
artiomchi Nov 22, 2024
0a44b4a
That's why it didn't run the containers!
artiomchi Nov 22, 2024
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
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
Loading