CRUD Operations on Model with Object-Based Foreign Key

This scenario demonstrates performing Create, Read, Update, and Delete operations on an object that has a foreign key reference to a lookup table. The FK reference is represented as an object.

Scenario Prototype

public interface IEmployeeComplex
{
    string? CellPhone { get; set; }
    IReadOnlyEmployeeClassification? EmployeeClassification { get; set; }
    int EmployeeKey { get; set; }
    string? FirstName { get; set; }
    string? LastName { get; set; }
    string? MiddleName { get; set; }
    string? OfficePhone { get; set; }
    string? Title { get; set; }
}
public interface IModelWithLookupComplexScenario<TEmployee>
   where TEmployee : class, IEmployeeComplex, new()
{
    /// <summary>
    /// Create a new Employee row, returning the new primary key.
    /// </summary>
    /// <remarks>This may NOT modify the EmployeeClassification record.</remarks>
    int Create(TEmployee employee);

    /// <summary>
    /// Delete a Employee row using an object.
    /// </summary>
    /// <remarks>Behavior when row doesn't exist is not defined. This may NOT modify or delete the EmployeeClassification record.</remarks>
    void Delete(TEmployee employee);

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

    /// <summary>
    /// Gets an Employee row by its name.
    /// </summary>
    IList<TEmployee> FindByLastName(string lastName);

    /// <summary>
    /// Gets all Employee rows.
    /// </summary>
    IList<TEmployee> GetAll();

    /// <summary>
    /// Gets an Employee row by its primary key.
    /// </summary>
    TEmployee? GetByKey(int employeeKey);

    /// <summary>
    /// Get an employee classification by key.
    /// </summary>
    /// <param name="employeeClassificationKey">The employee classification key.</param>
    IEmployeeClassification? GetClassification(int employeeClassificationKey);

    /// <summary>
    /// Update a Employee row.
    /// </summary>
    /// <remarks>Behavior when row doesn't exist is not defined. This may NOT modify the EmployeeClassification record.</remarks>
    void Update(TEmployee employee);
}

Database Views

CREATE VIEW HR.EmployeeDetail
WITH SCHEMABINDING
AS
SELECT e.EmployeeKey,
       e.FirstName,
       e.MiddleName,
       e.LastName,
       e.Title,
       e.OfficePhone,
       e.CellPhone,
       e.EmployeeClassificationKey,
       ec.EmployeeClassificationName,
       ec.IsExempt,
       ec.IsEmployee
FROM HR.Employee e
    INNER JOIN HR.EmployeeClassification ec
        ON e.EmployeeClassificationKey = ec.EmployeeClassificationKey;

ADO.NET

In order to promote code reuse, object population has been moved into the model's constructor.

public class EmployeeComplex : IEmployeeComplex
{
    public EmployeeComplex()
    {
    }

    public EmployeeComplex(IDataReader reader)
    {
        if (reader == null)
            throw new ArgumentNullException(nameof(reader), $"{nameof(reader)} is null.");

        EmployeeKey = reader.GetInt32(reader.GetOrdinal("EmployeeKey"));
        FirstName = reader.GetString(reader.GetOrdinal("FirstName"));
        if (!reader.IsDBNull(reader.GetOrdinal("MiddleName")))
            MiddleName = reader.GetString(reader.GetOrdinal("MiddleName"));
        LastName = reader.GetString(reader.GetOrdinal("LastName"));
        if (!reader.IsDBNull(reader.GetOrdinal("Title")))
            Title = reader.GetString(reader.GetOrdinal("Title"));
        if (!reader.IsDBNull(reader.GetOrdinal("OfficePhone")))
            OfficePhone = reader.GetString(reader.GetOrdinal("OfficePhone"));
        if (!reader.IsDBNull(reader.GetOrdinal("CellPhone")))
            CellPhone = reader.GetString(reader.GetOrdinal("CellPhone"));

        EmployeeClassification = new EmployeeClassification(reader);
    }

    public string? CellPhone { get; set; }
    public IReadOnlyEmployeeClassification? EmployeeClassification { get; set; }
    public int EmployeeKey { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? MiddleName { get; set; }
    public string? OfficePhone { get; set; }
    public string? Title { get; set; }
}

Likewise, a database view was used to join the Employee table with its lookup table(s).

    public class ModelWithLookupComplexScenario : SqlServerScenarioBase, IModelWithLookupComplexScenario<EmployeeComplex>
    {
        public ModelWithLookupComplexScenario(string connectionString) : base(connectionString)
        { }

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

            const string sql = @"INSERT INTO HR.Employee
(FirstName, MiddleName, LastName, Title, OfficePhone, CellPhone, EmployeeClassificationKey)
OUTPUT Inserted.EmployeeKey
VALUES
(@FirstName, @MiddleName, @LastName, @Title, @OfficePhone, @CellPhone, @EmployeeClassificationKey);";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@FirstName", employee.FirstName);
                cmd.Parameters.AddWithValue("@MiddleName", (object?)employee.MiddleName ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@LastName", employee.LastName);
                cmd.Parameters.AddWithValue("@Title", (object?)employee.Title ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@OfficePhone", (object?)employee.OfficePhone ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@CellPhone", (object?)employee.CellPhone ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@EmployeeClassificationKey", employee.EmployeeClassification.EmployeeClassificationKey);

                return (int)cmd.ExecuteScalar();
            }
        }

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

            const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@EmployeeKey", employee.EmployeeKey);
                cmd.ExecuteNonQuery();
            }
        }

        public void DeleteByKey(int employeeKey)
        {
            const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@EmployeeKey", employeeKey);
                cmd.ExecuteNonQuery();
            }
        }

        public IList<EmployeeComplex> FindByLastName(string lastName)
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.LastName = @LastName";

            var result = new List<EmployeeComplex>();

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@LastName", lastName);

                using (var reader = cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        result.Add(new EmployeeComplex(reader));
                    }
                    return result;
                }
            }
        }

        public IList<EmployeeComplex> GetAll()
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed";

            var result = new List<EmployeeComplex>();

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            using (var reader = cmd.ExecuteReader())
            {
                while (reader.Read())
                {
                    result.Add(new EmployeeComplex(reader));
                }
                return result;
            }
        }

        public EmployeeComplex? GetByKey(int employeeKey)
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.EmployeeKey = @EmployeeKey";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@EmployeeKey", employeeKey);
                using (var reader = cmd.ExecuteReader())
                {
                    if (!reader.Read())
                        return null;

                    return new EmployeeComplex(reader);
                }
            }
        }

        public IEmployeeClassification? GetClassification(int employeeClassificationKey)
        {
            const string sql = @"SELECT ec.EmployeeClassificationKey, ec.EmployeeClassificationName, ec.IsExempt, ec.IsEmployee
                        FROM HR.EmployeeClassification ec
                        WHERE ec.EmployeeClassificationKey = @EmployeeClassificationKey;";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@EmployeeClassificationKey", employeeClassificationKey);
                using (var reader = cmd.ExecuteReader())
                {
                    if (!reader.Read())
                        return null;

                    return new EmployeeClassification()
                    {
                        EmployeeClassificationKey = reader.GetInt32(reader.GetOrdinal("EmployeeClassificationKey")),
                        EmployeeClassificationName = reader.GetString(reader.GetOrdinal("EmployeeClassificationName")),
                        IsExempt = reader.GetBoolean(reader.GetOrdinal("IsExempt")),
                        IsEmployee = reader.GetBoolean(reader.GetOrdinal("IsEmployee"))
                    };
                }
            }
        }

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

            const string sql = @"UPDATE HR.Employee
SET FirstName = @FirstName,
    MiddleName = @MiddleName,
    LastName = @LastName,
    Title = @Title,
    OfficePhone = @OfficePhone,
    CellPhone = @CellPhone,
    EmployeeClassificationKey = @EmployeeClassificationKey
WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
            using (var cmd = new SqlCommand(sql, con))
            {
                cmd.Parameters.AddWithValue("@EmployeeKey", employee.EmployeeKey);

                cmd.Parameters.AddWithValue("@FirstName", employee.FirstName);
                cmd.Parameters.AddWithValue("@MiddleName", (object?)employee.MiddleName ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@LastName", employee.LastName);
                cmd.Parameters.AddWithValue("@Title", (object?)employee.Title ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@OfficePhone", (object?)employee.OfficePhone ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@CellPhone", (object?)employee.CellPhone ?? DBNull.Value);
                cmd.Parameters.AddWithValue("@EmployeeClassificationKey", employee.EmployeeClassification.EmployeeClassificationKey);

                cmd.ExecuteNonQuery();
            }
        }
    }

Chain

Chain does not support representing FK's as child objects for create/update operations. The FK must be exposed via the parent object.

Read operations must occur against a database view in order to get the properties from the child object. The Decompose attribute indicates that the child should be populated from the same view.

[Table("HR.Employee")]
[View("HR.EmployeeDetail")]
public partial class EmployeeComplex
{
    public string? CellPhone { get; set; }

    [Decompose] //Used for Read operations
    public EmployeeClassification? EmployeeClassification { get; set; }

    //Used for Insert/Update operations
    public int EmployeeClassificationKey => EmployeeClassification?.EmployeeClassificationKey ?? 0;

    public int EmployeeKey { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? MiddleName { get; set; }
    public string? OfficePhone { get; set; }
    public string? Title { get; set; }
}

    //Used for linking the entity to the test framework. Not part of the recipe.
    partial class EmployeeComplex : IEmployeeComplex
    {
        IReadOnlyEmployeeClassification? IEmployeeComplex.EmployeeClassification
        {
            get => EmployeeClassification;
            set => EmployeeClassification = (EmployeeClassification?)value;
        }
    }
}
public class ModelWithLookupComplexScenario : IModelWithLookupComplexScenario<EmployeeComplex>
{
    readonly SqlServerDataSource m_DataSource;

    public ModelWithLookupComplexScenario(SqlServerDataSource dataSource)
    {
        m_DataSource = dataSource;
    }

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

        //The object is mapped to the view, so we need to override the table we write to.
        return m_DataSource.Insert(employee).ToInt32().Execute();
    }

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

        //The object is mapped to the view, so we need to override the table we write to.
        m_DataSource.Delete(employee).Execute();
    }

    public void DeleteByKey(int employeeKey)
    {
        m_DataSource.DeleteByKey<EmployeeComplex>(employeeKey).Execute();
    }

    public IList<EmployeeComplex> FindByLastName(string lastName)
    {
        return m_DataSource.From<EmployeeComplex>(new { LastName = lastName }).ToCollection().Execute();
    }

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

    public EmployeeComplex? GetByKey(int employeeKey)
    {
        return m_DataSource.From<EmployeeComplex>(new { employeeKey }).ToObjectOrNull().Execute();
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        return m_DataSource.GetByKey<EmployeeClassification>(employeeClassificationKey).ToObject().Execute();
    }

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

        //The object is mapped to the view, so we need to override the table we write to.
        m_DataSource.Update(employee).Execute();
    }
}

Dapper

Dapper does not support representing FK's as child objects for create/update operations. The FK must be exposed via the parent object.

Read operations must occur against a database view in order to get the properties from the child object. The Multi Mapping overload indicates that the child should be populated from the same view. Use the splitOn parameter to indicate the primary key of the second object.

public partial class EmployeeComplex : IEmployeeComplex
{
    public string? CellPhone { get; set; }
    public EmployeeClassification? EmployeeClassification { get; set; }

    //Used for Insert/Update operations
    public int EmployeeClassificationKey => EmployeeClassification?.EmployeeClassificationKey ?? 0;

    public int EmployeeKey { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? MiddleName { get; set; }
    public string? OfficePhone { get; set; }
    public string? Title { get; set; }
}

    //Used for linking the entity to the test framework. Not part of the recipe.
    partial class EmployeeComplex : IEmployeeComplex
    {
        IReadOnlyEmployeeClassification? IEmployeeComplex.EmployeeClassification
        {
            get => EmployeeClassification;
            set => EmployeeClassification = (EmployeeClassification?)value;
        }
    }
}
    public class ModelWithLookupComplexScenario : ScenarioBase, IModelWithLookupComplexScenario<EmployeeComplex>
    {
        public ModelWithLookupComplexScenario(string connectionString) : base(connectionString)
        {
        }

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

            const string sql = @"INSERT INTO HR.Employee
(FirstName, MiddleName, LastName, Title, OfficePhone, CellPhone, EmployeeClassificationKey)
OUTPUT Inserted.EmployeeKey
VALUES
(@FirstName, @MiddleName, @LastName, @Title, @OfficePhone, @CellPhone, @EmployeeClassificationKey);";

            using (var con = OpenConnection())
                return (int)con.ExecuteScalar(sql, employee);
        }

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

            const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
                con.Execute(sql, employee);
        }

        public void DeleteByKey(int employeeKey)
        {
            const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
                con.Execute(sql, new { employeeKey });
        }

        public IList<EmployeeComplex> FindByLastName(string lastName)
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.LastName = @LastName";

            var result = new List<EmployeeComplex>();

            using (var con = OpenConnection())
                return con.Query<EmployeeComplex, EmployeeClassification, EmployeeComplex>(sql,
                    (e, ec) => { e.EmployeeClassification = ec; return e; },
                    new { LastName = lastName },
                    splitOn: "EmployeeClassificationKey")
                    .ToList();
        }

        public IList<EmployeeComplex> GetAll()
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed";

            var result = new List<EmployeeComplex>();

            using (var con = OpenConnection())
                return con.Query<EmployeeComplex, EmployeeClassification, EmployeeComplex>(sql,
                    (e, ec) => { e.EmployeeClassification = ec; return e; },
                    splitOn: "EmployeeClassificationKey")
                    .ToList();
        }

        public EmployeeComplex? GetByKey(int employeeKey)
        {
            const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.EmployeeKey = @EmployeeKey";

            using (var con = OpenConnection())
                return con.Query<EmployeeComplex, EmployeeClassification, EmployeeComplex>(sql,
                    (e, ec) => { e.EmployeeClassification = ec; return e; },
                    new { EmployeeKey = employeeKey },
                    splitOn: "EmployeeClassificationKey")
                    .SingleOrDefault();
        }

        public IEmployeeClassification? GetClassification(int employeeClassificationKey)
        {
            const string sql = @"SELECT ec.EmployeeClassificationKey, ec.EmployeeClassificationName, ec.IsExempt, ec.IsEmployee
                        FROM HR.EmployeeClassification ec
                        WHERE ec.EmployeeClassificationKey = @EmployeeClassificationKey;";

            using (var con = OpenConnection())
                return con.QuerySingle<EmployeeClassification>(sql, new { employeeClassificationKey });
        }

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

            const string sql = @"UPDATE HR.Employee
SET FirstName = @FirstName,
    MiddleName = @MiddleName,
    LastName = @LastName,
    Title = @Title,
    OfficePhone = @OfficePhone,
    CellPhone = @CellPhone,
    EmployeeClassificationKey = @EmployeeClassificationKey
WHERE EmployeeKey = @EmployeeKey;";

            using (var con = OpenConnection())
                con.Execute(sql, employee);
        }
    }

DbConnector

public class ModelWithLookupComplexScenario : ScenarioBase, IModelWithLookupComplexScenario<EmployeeComplex>
{
    public ModelWithLookupComplexScenario(string connectionString) : base(connectionString)
    {
    }

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

        const string sql = @"INSERT INTO HR.Employee
        (FirstName, MiddleName, LastName, Title, OfficePhone, CellPhone, EmployeeClassificationKey)
        OUTPUT Inserted.EmployeeKey
        VALUES
        (@FirstName, @MiddleName, @LastName, @Title, @OfficePhone, @CellPhone, @EmployeeClassificationKey);";

        return DbConnector.Scalar<int>(sql,
            new
            {
                employee.FirstName,
                employee.MiddleName,
                employee.LastName,
                employee.Title,
                employee.OfficePhone,
                employee.CellPhone,
                employee.EmployeeClassificationKey
            }).Execute();
    }

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

        const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @EmployeeKey;";

        DbConnector.NonQuery(sql, new { employee.EmployeeKey }).Execute();
    }

    public void DeleteByKey(int employeeKey)
    {
        const string sql = @"DELETE HR.Employee WHERE EmployeeKey = @employeeKey;";

        DbConnector.NonQuery(sql, new { employeeKey }).Execute();
    }

    public IList<EmployeeComplex> FindByLastName(string lastName)
    {
        const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.LastName = @LastName";

        var settings = new ColumnMapSetting().WithSplitOnFor<EmployeeClassification>(e => e.EmployeeClassificationKey);

        return DbConnector.ReadToList<EmployeeComplex>(settings, sql, new { LastName = lastName }).Execute();
    }

    public IList<EmployeeComplex> GetAll()
    {
        const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed";

        //Configure Split map settings
        var settings = new ColumnMapSetting().WithSplitOnFor<EmployeeClassification>(e => e.EmployeeClassificationKey);

        return DbConnector.ReadToList<EmployeeComplex>(settings, sql).Execute();
    }

    public EmployeeComplex? GetByKey(int employeeKey)
    {
        const string sql = @"SELECT ed.EmployeeKey, ed.FirstName, ed.MiddleName, ed.LastName, ed.Title, ed.OfficePhone, ed.CellPhone, ed.EmployeeClassificationKey, ed.EmployeeClassificationName, ed.IsExempt, ed.IsEmployee FROM HR.EmployeeDetail ed WHERE ed.EmployeeKey = @EmployeeKey";

        //Configure Split map settings
        var settings = new ColumnMapSetting().WithSplitOnFor<EmployeeClassification>(e => e.EmployeeClassificationKey);

        return DbConnector.ReadSingleOrDefault<EmployeeComplex>(settings, sql, new { EmployeeKey = employeeKey }).Execute();
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        const string sql = @"SELECT ec.EmployeeClassificationKey, ec.EmployeeClassificationName, ec.IsExempt, ec.IsEmployee
                    FROM HR.EmployeeClassification ec
                    WHERE ec.EmployeeClassificationKey = @employeeClassificationKey;";

        return DbConnector.ReadSingle<EmployeeClassification>(sql, new { employeeClassificationKey }).Execute();
    }

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

        const string sql = @"UPDATE HR.Employee
        SET FirstName = @FirstName,
            MiddleName = @MiddleName,
            LastName = @LastName,
            Title = @Title,
            OfficePhone = @OfficePhone,
            CellPhone = @CellPhone,
            EmployeeClassificationKey = @EmployeeClassificationKey
        WHERE EmployeeKey = @EmployeeKey;";

        DbConnector.NonQuery(sql,
            new
            {
                employee.EmployeeKey,
                employee.FirstName,
                employee.MiddleName,
                employee.LastName,
                employee.Title,
                employee.OfficePhone,
                employee.CellPhone,
                employee.EmployeeClassificationKey
            }).Execute();
    }
}

Entity Framework 6

TODO

Entity Framework Core

Child objects outside of the DBContext (e.g. from a REST call) need to be mapped to an object created by the DBContext.

This provides a layer of safety, as otherwise clients could override data in the lookup table.

public class ModelWithLookupComplexScenario : IModelWithLookupComplexScenario<Employee>
{
    private Func<OrmCookbookContext> CreateDbContext;

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

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

        using (var context = CreateDbContext())
        {
            //Prevent updates to the lookup table
            context.Entry(employee.EmployeeClassificationKeyNavigation).State = EntityState.Unchanged;

            context.Employees.Add(employee);
            context.SaveChanges();
            return employee.EmployeeKey;
        }
    }

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

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

    public void DeleteByKey(int employeeKey)
    {
        using (var context = CreateDbContext())
        {
            //Find the row you wish to delete
            var temp = context.Employees.Find(employeeKey);
            if (temp != null)
            {
                context.Employees.Remove(temp);
                context.SaveChanges();
            }
        }
    }

    public IList<Employee> FindByLastName(string lastName)
    {
        using (var context = CreateDbContext())
            return context.Employees
                .Where(ec => ec.LastName == lastName)
                .Include(e => e.EmployeeClassificationKeyNavigation)
                .ToList();
    }

    public IList<Employee> GetAll()
    {
        using (var context = CreateDbContext())
            return context.Employees
                .Include(e => e.EmployeeClassificationKeyNavigation)
                .ToList();
    }

    public Employee? GetByKey(int employeeKey)
    {
        using (var context = CreateDbContext())
            return context.Employees
                .Include(e => e.EmployeeClassificationKeyNavigation)
                .SingleOrDefault(e => e.EmployeeKey == employeeKey);
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        using (var context = CreateDbContext())
            return context.EmployeeClassifications.Find(employeeClassificationKey);
    }

    /// <summary>
    /// Updates the specified employee.
    /// </summary>
    /// <param name="employee">The employee.</param>
    /// <exception cref="ArgumentNullException">employee</exception>
    public void Update(Employee employee)
    {
        if (employee == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee)} is null.");
        if (employee.EmployeeClassificationKeyNavigation == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee.EmployeeClassificationKeyNavigation)} is null.");

        using (var context = CreateDbContext())
        {
            //Prevent updates to the lookup table
            context.Entry(employee.EmployeeClassificationKeyNavigation).State = EntityState.Unchanged;

            context.Entry(employee).State = EntityState.Modified;
            context.SaveChanges();
        }
    }
}

LINQ to DB

TODO

LLBLGen Pro

As LLBLGen Pro supports change tracking in the entities it doesn't have to refetch an entity that's been updated. The repository code illustrates this. As it also by default persists all reachable entities in a graph, recursive saves are disabled here to make sure only the entity that's passed in is persisted.

Entity classes are always derived from a known base class so types created by the user aren't taken into account when traversing the graph, only entity classes.

public class ModelWithLookupComplexScenario : IModelWithLookupComplexScenario<EmployeeEntity>
{
    public int Create(EmployeeEntity employee)
    {
        if (employee == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee)} is null.");
        if (employee.EmployeeClassification == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee.EmployeeClassification)} is null.");

        using (var adapter = new DataAccessAdapter())
        {
            // the test FoulLookup will alter the associated lookup entity and if we persist things recursively we'll save this record too, so we don't use any
            // recursive persistence here.
            adapter.SaveEntity(employee, true, recurse: false);
            return employee.EmployeeKey;
        }
    }

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

        using (var adapter = new DataAccessAdapter())
        {
            employee.IsNew = false;
            adapter.DeleteEntity(employee);
        }
    }

    public void DeleteByKey(int employeeKey)
    {
        using (var adapter = new DataAccessAdapter())
        {
            adapter.DeleteEntitiesDirectly(typeof(EmployeeEntity), new RelationPredicateBucket(EmployeeFields.EmployeeKey.Equal(employeeKey)));
        }
    }

    public IList<EmployeeEntity> FindByLastName(string lastName)
    {
        using (var adapter = new DataAccessAdapter())
        {
            return new LinqMetaData(adapter).Employee
                                            .Where(ec => ec.LastName == lastName)
                                            .WithPath(p => p.Prefetch(e => e.EmployeeClassification))
                                            .ToList();
        }
    }

    public IList<EmployeeEntity> GetAll()
    {
        using (var adapter = new DataAccessAdapter())
        {
            return new LinqMetaData(adapter).Employee
                                            .WithPath(p => p.Prefetch(e => e.EmployeeClassification))
                                            .ToList();
        }
    }

    public EmployeeEntity? GetByKey(int employeeKey)
    {
        using (var adapter = new DataAccessAdapter())
        {
            return new LinqMetaData(adapter).Employee
                                            .WithPath(p => p.Prefetch(e => e.EmployeeClassification))
                                            .SingleOrDefault(e => e.EmployeeKey == employeeKey);
        }
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        using (var adapter = new DataAccessAdapter())
        {
            return new LinqMetaData(adapter).EmployeeClassification.FirstOrDefault(ec => ec.EmployeeClassificationKey == employeeClassificationKey);
        }
    }

    /// <summary>
    /// Updates the specified employee.
    /// </summary>
    /// <param name="employee">The employee.</param>
    /// <exception cref="ArgumentNullException">employee</exception>
    public void Update(EmployeeEntity employee)
    {
        if (employee == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee)} is null.");
        if (employee.EmployeeClassification == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee.EmployeeClassification)} is null.");

        using (var adapter = new DataAccessAdapter())
        {
            EmployeeEntity toPersist = employee;
            if (toPersist.IsNew)
            {
                toPersist = new EmployeeEntity(employee.EmployeeKey);
                adapter.FetchEntity(toPersist);
                //Copy the changed fields
                toPersist.FirstName = employee.FirstName;
                toPersist.MiddleName = employee.MiddleName;
                toPersist.LastName = employee.LastName;
                toPersist.CellPhone = employee.CellPhone;
                toPersist.OfficePhone = employee.OfficePhone;
                toPersist.Title = employee.Title;
                toPersist.EmployeeClassificationKey = employee.EmployeeClassificationKey;
            }
            if (!toPersist.IsNew)
            {
                // By default the whole graph is persisted in-order, FKs are synced etc. but there's a test in this system
                // which makes it fail if we do so, so we have to disable the recursive saves.
                adapter.SaveEntity(toPersist, refetchAfterSave: false, recurse: false);
            }
        }
    }
}

NHibernate

TODO

RepoDb

RepoDb does not support representing FK's as child objects for create/update operations. The FK must be exposed via the parent object.

RepoDb supports ExecuteQueryMultiple which can cater a much more optimal solution for fetching parent-children related data entities.

Read operations must occur against a database view in order to get the properties from the child object. The Decompose attribute indicates that the child should be populated from the same view.

[Map("[HR].[Employee]")]
public partial class EmployeeComplex : IEmployeeComplex
{
    public string? CellPhone { get; set; }

    public EmployeeClassification? EmployeeClassification { get; set; }

    public int EmployeeClassificationKey { get; set; }

    public int EmployeeKey { get; set; }

    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? MiddleName { get; set; }
    public string? OfficePhone { get; set; }
    public string? Title { get; set; }
}

    //Used for linking the entity to the test framework. Not part of the recipe.
    partial class EmployeeComplex : IEmployeeComplex
    {
        IReadOnlyEmployeeClassification? IEmployeeComplex.EmployeeClassification
        {
            get => EmployeeClassification;
            set
            {
                EmployeeClassification = (EmployeeClassification?)value;
                EmployeeClassificationKey = (value?.EmployeeClassificationKey).GetValueOrDefault();
            }
        }
    }
}
public class ModelWithLookupComplexScenario : DbRepository<SqlConnection>,
    IModelWithLookupComplexScenario<EmployeeComplex>
{
    public ModelWithLookupComplexScenario(string connectionString)
        : base(connectionString, RDB.Enumerations.ConnectionPersistency.Instance)
    { }

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

        return Insert<EmployeeComplex, int>(employee);
    }

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

        base.Delete(employee);
    }

    public void DeleteByKey(int employeeKey)
    {
        Delete<EmployeeComplex>(employeeKey);
    }

    public IList<EmployeeComplex> FindByLastName(string lastName)
    {
        return Query<EmployeeComplex>(e => e.LastName == lastName).AsList();
    }

    public IList<EmployeeComplex> GetAll()
    {
        return QueryAll<EmployeeComplex>().AsList();
    }

    public EmployeeComplex? GetByKey(int employeeKey)
    {
        var employee = Query<EmployeeComplex>(employeeKey).FirstOrDefault();
        if (employee != null)
        {
            employee.EmployeeClassification = Query<EmployeeClassification>(employee.EmployeeClassificationKey).FirstOrDefault();
        }
        return employee;
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        return Query<EmployeeClassification>(employeeClassificationKey).FirstOrDefault();
    }

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

        base.Update(employee);
    }
}

ServiceStack

public class ModelWithLookupComplexScenario : IModelWithLookupComplexScenario<Employee>
{
    private readonly IDbConnectionFactory _dbConnectionFactory;
    public ModelWithLookupComplexScenario(IDbConnectionFactory dbConnectionFactory)
    {
        _dbConnectionFactory = dbConnectionFactory;
    }

    public int Create(Employee employee)
    {
        if (employee == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee)} is null.");
        if (employee.EmployeeClassification == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee.EmployeeClassification)} is null.");
        
        employee.EmployeeClassificationId = employee.EmployeeClassification.Id;
        using (var db = _dbConnectionFactory.OpenDbConnection())
        {
            db.Save(employee);
        }

        return employee.Id;
    }

    public void Delete(Employee employee)
    {
        if (employee == null)
            throw new ArgumentNullException(nameof(employee), $"{nameof(employee)} is null.");
        
        using (var db = _dbConnectionFactory.OpenDbConnection())
            db.Delete(employee);
    }

    public void DeleteByKey(int employeeKey)
    {
        using (var db = _dbConnectionFactory.OpenDbConnection())
            db.DeleteById<Employee>(employeeKey);
    }

    public IList<Employee> FindByLastName(string lastName)
    {
        using (var db = _dbConnectionFactory.OpenDbConnection())
            return db.LoadSelect<Employee>(e => e.LastName == lastName);
    }

    public IList<Employee> GetAll()
    {
        using (var db = _dbConnectionFactory.OpenDbConnection())
            return db.LoadSelect<Employee>();
    }

    public Employee? GetByKey(int employeeKey)
    {
        using (var db = _dbConnectionFactory.OpenDbConnection())
            return db.LoadSingleById<Employee>(employeeKey);
    }

    public IEmployeeClassification? GetClassification(int employeeClassificationKey)
    {
        using (var db = _dbConnectionFactory.OpenDbConnection())
            return db.SingleById<EmployeeClassification>(employeeClassificationKey);
    }

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

        if (employee.EmployeeClassification != null)
            employee.EmployeeClassificationId = employee.EmployeeClassification.Id;

        using (var db = _dbConnectionFactory.OpenDbConnection())
            db.Update(employee);
    }
}