Multiple Databases

These scenarios demonstrate how to support multiple databases with the same code. For demonstration purposes, SQL Server and PostgreSQL will be used.

Scenario Prototype

public interface IMultipleDBScenario<TModel>
   where TModel : class, IEmployeeClassification, new()
{
    /// <summary>
    /// Create a new EmployeeClassification row, returning the new primary key.
    /// </summary>
    int Create(TModel classification);

    /// <summary>
    /// Delete a EmployeeClassification row using an object.
    /// </summary>
    /// <remarks>Behavior when row doesn't exist is not defined.</remarks>
    void Delete(TModel classification);

    /// <summary>
    /// Delete a EmployeeClassification row using its primary key.
    /// </summary>
    /// <remarks>Behavior when row doesn't exist is not defined.</remarks>
    void DeleteByKey(int employeeClassificationKey);

    /// <summary>
    /// Gets an EmployeeClassification row by its name. Assume the name is unique.
    /// </summary>
    TModel? FindByName(string employeeClassificationName);

    /// <summary>
    /// Gets all EmployeeClassification rows.
    /// </summary>
    IList<TModel> GetAll();

    /// <summary>
    /// Gets an EmployeeClassification row by its primary key.
    /// </summary>
    TModel? GetByKey(int employeeClassificationKey);

    /// <summary>
    /// Update a EmployeeClassification row.
    /// </summary>
    /// <remarks>Behavior when row doesn't exist is not defined.</remarks>
    void Update(TModel classification);
}

ADO.NET

In order to support multiple databases with the same code, ADO.NET provides a DbProviderFactory implmentation for each database. This can be used to create the connection, command, and parameter objects.

public class MultipleDBScenario_DbProviderFactory : IMultipleDBScenario<EmployeeClassification>
{
    readonly DbProviderFactory m_ProviderFactory;
    readonly string m_ConnectionString;
    readonly DatabaseType m_DatabaseType;

    public MultipleDBScenario_DbProviderFactory(string connectionString, DatabaseType databaseType)
    {
        m_ConnectionString = connectionString;
        m_DatabaseType = databaseType;

        m_ProviderFactory = databaseType switch
        {
            DatabaseType.SqlServer => Microsoft.Data.SqlClient.SqlClientFactory.Instance,
            DatabaseType.PostgreSql => Npgsql.NpgsqlFactory.Instance,
            _ => throw new NotImplementedException()
        };
    }

Alternately, commands can be created from connections and parameters from commands.

public class MultipleDBScenario_Chained : IMultipleDBScenario<EmployeeClassification>
{
    readonly string m_ConnectionString;
    readonly DatabaseType m_DatabaseType;

    public MultipleDBScenario_Chained(string connectionString, DatabaseType databaseType)
    {
        m_ConnectionString = connectionString;
        m_DatabaseType = databaseType;
    }

    DbConnection OpenConnection()
    {
        DbConnection con = m_DatabaseType switch
        {
            DatabaseType.SqlServer => new Microsoft.Data.SqlClient.SqlConnection(m_ConnectionString),
            DatabaseType.PostgreSql => new Npgsql.NpgsqlConnection(m_ConnectionString),
            _ => throw new NotImplementedException()
        };

        con.Open();
        return con;
    }

Chain

In Chain, each named DataSource exposes database-specific functionality. For functionality that's common across multiple databases, a set of interfaces are offered.

  • IClass0DataSource: Raw SQL only.
  • IClass1DataSource: CRUD operations. Database reflection.
  • IClass2DataSource: Functions and Stored procedures
public class MultipleDBScenario : IMultipleDBScenario<EmployeeClassification>
{
    const string TableName = "HR.EmployeeClassification";
    readonly ICrudDataSource m_DataSource;

    public MultipleDBScenario(ICrudDataSource dataSource)
    {
        m_DataSource = dataSource;
    }

    public int Create(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        return m_DataSource.Insert(classification).ToInt32().Execute();
    }

    public void Delete(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        m_DataSource.Delete(classification).Execute();
    }

    public void DeleteByKey(int employeeClassificationKey)
    {
        m_DataSource.DeleteByKey(TableName, employeeClassificationKey).Execute();
    }

    public EmployeeClassification FindByName(string employeeClassificationName)
    {
        return m_DataSource.From<EmployeeClassification>(new { employeeClassificationName })
            .ToObject().Execute();
    }

    public IList<EmployeeClassification> GetAll()
    {
        return m_DataSource.From<EmployeeClassification>().ToCollection().Execute();
    }

    public EmployeeClassification? GetByKey(int employeeClassificationKey)
    {
        return m_DataSource.GetByKey(TableName, employeeClassificationKey)
            .ToObjectOrNull<EmployeeClassification>().Execute();
    }

    public void Update(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        m_DataSource.Update(classification).Execute();
    }
}

Dapper

TODO

DbConnector

public class MultipleDBScenario : IMultipleDBScenario<EmployeeClassification>
{
    IDbConnector DbConnector { get; }
    readonly DatabaseType m_DatabaseType;

    public MultipleDBScenario(string connectionString, DatabaseType databaseType)
    {
        if (databaseType == DatabaseType.SqlServer)
        {
            DbConnector = new DbConnector<SqlConnection>(connectionString);
        }
        else
        {
            DbConnector = new DbConnector<NpgsqlConnection>(connectionString);
        }

        //DbConnector.ConnectionType could also be used...
        m_DatabaseType = databaseType;
    }

    public int Create(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        string sql = m_DatabaseType switch
        {
            DatabaseType.SqlServer =>
                @"INSERT INTO HR.EmployeeClassification (EmployeeClassificationName)
                    OUTPUT Inserted.EmployeeClassificationKey
                    VALUES(@EmployeeClassificationName )",
            DatabaseType.PostgreSql =>
                @"INSERT INTO HR.EmployeeClassification (EmployeeClassificationName)
                    VALUES(@EmployeeClassificationName )
                    RETURNING EmployeeClassificationKey",
            _ => throw new NotImplementedException()
        };

        return DbConnector.Scalar<int>(sql, classification).Execute();
    }

Entity Framework 6

TODO

Entity Framework Core

Conceptually, you just replace .UseSqlServer(SqlServerConnectionString) with .UseNpgsql(PostgreSqlConnectionString) to change databases.

Due to differences in naming conventions between the two, you may find that a naming converter is needed.

/// <summary>
/// CaseConventionConverter allows one DBContext to connect to multiple databases that using
/// differ only in naming conventions.
/// </summary>
public abstract class CaseConventionConverter : IDatabaseConventionConverter
{
    public void SetConvention(ModelBuilder builder)
    {
        if (builder == null)
            throw new ArgumentNullException(nameof(builder), $"{nameof(builder)} is null.");

        foreach (var entity in builder.Model.GetEntityTypes())
        {

            // Replace table names
            entity.SetTableName(ConvertName(entity.GetTableName()));
            entity.SetSchema(ConvertName(entity.GetSchema()));

            var soi = StoreObjectIdentifier.Table(entity.GetTableName(), entity.GetSchema());
            System.Diagnostics.Debug.WriteLine($"Remapping table {entity.GetSchema()}.{entity.GetTableName()}");
            System.Diagnostics.Debug.IndentLevel += 1;

            // Replace column names
            foreach (var property in entity.GetProperties())
                property.SetColumnName(ConvertName(property.GetColumnName(soi)), soi);

            foreach (var key in entity.GetKeys())
                key.SetName(ConvertName(key.GetName()));

            foreach (var key in entity.GetForeignKeys())
                key.SetConstraintName(ConvertName(key.GetConstraintName()));

            foreach (var key in entity.GetIndexes())
                key.SetDatabaseName(ConvertName(key.GetDatabaseName()));
        }
    }

    protected abstract string? ConvertName(string? input);
}

The two most common conventions for PostgreSQL are snake_case and lowercase.

/// <summary>
/// SnakeCaseConverter is used in database where table/columns use the "table_name" convention.
/// </summary>
public sealed class SnakeCaseConverter : CaseConventionConverter
{
    //Based on: https://github.com/avi1989/DbContextForMultipleDatabases, Used with permission.
    //Reference: https://andrewlock.net/customising-asp-net-core-identity-ef-core-naming-conventions-for-postgresql/

    [SuppressMessage("Globalization", "CA1308")]
    [return: NotNullIfNotNull("input")]
    protected override string? ConvertName(string? input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        var startUnderscores = Regex.Match(input, @"^_+");
        return startUnderscores + Regex.Replace(input, @"([a-z0-9])([A-Z])", "$1_$2").ToLowerInvariant();
    }
}
/// <summary>
/// LowerCaseConverter is used in database where table/columns use the "tablename" convention.
/// </summary>

public sealed class LowerCaseConverter : CaseConventionConverter
{
    [SuppressMessage("Globalization", "CA1308")]
    [return: NotNullIfNotNull("input")]
    protected override string? ConvertName(string? input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        return input.ToLowerInvariant();
    }
}

No changes were needed to the actual DB access code.

public class MultipleDBScenario : IMultipleDBScenario<EmployeeClassification>
{
    private Func<OrmCookbookContext> CreateDbContext;

    public MultipleDBScenario(Func<OrmCookbookContext> dBContextFactory)
    {
        CreateDbContext = dBContextFactory;
    }

    public int Create(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        using (var context = CreateDbContext())
        {
            context.EmployeeClassifications.Add(classification);
            context.SaveChanges();
            return classification.EmployeeClassificationKey;
        }
    }

    public void Delete(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        using (var context = CreateDbContext())
        {
            context.Entry(classification).State = EntityState.Deleted;
            context.SaveChanges();
        }
    }

    public void DeleteByKey(int employeeClassificationKey)
    {
        using (var context = CreateDbContext())
        {
            var temp = new EmployeeClassification() { EmployeeClassificationKey = employeeClassificationKey };
            context.Entry(temp).State = EntityState.Deleted;
            context.SaveChanges();
        }
    }

    public EmployeeClassification? FindByName(string employeeClassificationName)
    {
        using (var context = CreateDbContext())
        {
            return context.EmployeeClassifications.Where(ec => ec.EmployeeClassificationName == employeeClassificationName).SingleOrDefault();
        }
    }

    public IList<EmployeeClassification> GetAll()
    {
        using (var context = CreateDbContext())
        {
            return context.EmployeeClassifications.ToList();
        }
    }

    public EmployeeClassification GetByKey(int employeeClassificationKey)
    {
        using (var context = CreateDbContext())
        {
            return context.EmployeeClassifications.Find(employeeClassificationKey);
        }
    }

    public void Update(EmployeeClassification classification)
    {
        if (classification == null)
            throw new ArgumentNullException(nameof(classification), $"{nameof(classification)} is null.");

        using (var context = CreateDbContext())
        {
            context.Entry(classification).State = EntityState.Modified;
            context.SaveChanges();
        }
    }
}

LINQ to DB

TODO

LLBLGen Pro

TODO

NHibernate

TODO

RepoDb

TODO

ServiceStack

TODO