Search code examples
c#sql-serverasp.net-coreasp.net-core-2.2

Adding a new child entity to database also inserts a new empty parent entity


I recently updated my project from ASP.Net Core 2.1 to 2.2 (and followed Microsoft's migration guide), after the update some of my razor pages for adding a child object to the database, started also adding new empty parent objects to the database at the same time. Oddly, this behavior is not consistent across all child-parent entities, and some of the razor pages continue to work fine.

I dug up the following links that are somewhat related to my problem, but do not provide a solution:

This github issue describes exactly what seems to be happening to me, but I am not using a custom ModelBinder.

Here is the Create.cshtml:

<h2>Create</h2>

<h4>Branch</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Branch.BranchName" class="control-label"></label>
                <input asp-for="Branch.BranchName" class="form-control" />
                <span asp-validation-for="Branch.BranchName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Name" class="control-label"></label>
                <input asp-for="Branch.Name" class="form-control" />
                <span asp-validation-for="Branch.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Address" class="control-label"></label>
                <input asp-for="Branch.Address" class="form-control" />
                <span asp-validation-for="Branch.Address" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.City" class="control-label"></label>
                <input asp-for="Branch.City" class="form-control" />
                <span asp-validation-for="Branch.City" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Province" class="control-label"></label>
                <input asp-for="Branch.Province" class="form-control" />
                <span asp-validation-for="Branch.Province" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Country" class="control-label"></label>
                <input asp-for="Branch.Country" class="form-control" />
                <span asp-validation-for="Branch.Country" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.PostalCode" class="control-label"></label>
                <input asp-for="Branch.PostalCode" class="form-control" />
                <span asp-validation-for="Branch.PostalCode" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Status" class="control-label"></label>
                <input asp-for="Branch.Status" class="form-control" />
                <span asp-validation-for="Branch.Status" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Phone" class="control-label"></label>
                <input asp-for="Branch.Phone" class="form-control" />
                <span asp-validation-for="Branch.Phone" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Fax" class="control-label"></label>
                <input asp-for="Branch.Fax" class="form-control" />
                <span asp-validation-for="Branch.Fax" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.Comments" class="control-label"></label>
                <textarea asp-for="Branch.Comments" class="form-control"></textarea>
                <span asp-validation-for="Branch.Comments" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Branch.DealerId" class="control-label"></label>
                <select asp-for="Branch.DealerId" class ="form-control" asp-items="ViewBag.DealerId"></select>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>

Here is the Create.cshtml.cs:

public class CreateModel : PageModel
    {
        private readonly ApplicationDbContext _context;

        public CreateModel(ApplicationDbContext context)
        {
            _context = context;
        }

        public IActionResult OnGet()
        {
            Branch = new Branch();
            //Usually the DealerId would be passed in the URL, this is just for testing
            ViewData["DealerId"] = new SelectList(_context.Dealer, "DealerId", "DealerId");
            return Page();
        }

        //This is weird for debugging purposes
        private Branch _branch;

        [BindProperty]
        public Branch Branch {
            get
            {
                return _branch;
            }
            set
            {
                var t = value;
                _branch = value;
            }}

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            _context.Branches.Add(Branch);
            await _context.SaveChangesAsync();

            return RedirectToPage("./Index");
        }
    }

Branch.cs:

public class Branch
    {
        [Key]
        public string BranchId { get; set; }

        [Display(Name = "Branch Name")]
        public string BranchName { get; set; }

        public string Name { get; set; }
        public string Address { get; set; }
        public string City { get; set; }
        public string Province { get; set; }
        public string Country { get; set; }

        [Display(Name = "Postal Code")]
        [DataType(DataType.PostalCode)]
        public string PostalCode { get; set; }


        public string Status { get; set; }

        [DataType(DataType.PhoneNumber)]
        public string Phone { get; set; }

        [DataType(DataType.PhoneNumber)]
        public string Fax { get; set; }

        [DataType(DataType.MultilineText)]
        public string Comments { get; set; }


        public string DealerId { get; set; }

        [ForeignKey("DealerId")]
        public Dealer Dealer { get; set; }

        public List<ApplicationUser> Users { get; set; }
    }

When the page is posted back, the Branch object should have a null Dealer property (and with ASP.Net Core 2.1 it does), but has a freshly instantiated Dealer object (with null fields) here's a screenshot of the breakpoint on the Branch setter. When the branch object gets added to the database, it should use the DealerId to find the related object and link them up (a proper and functional FK relationship is set up between these classes/tables), but because the Dealer navigation property is not null, both the Branch and the Empty Dealer are added to the database with a FK relationship set up between them. This can be fixed by setting the Dealer property to null before adding the Branch to the database _context, but this is clearly not the expected behavior, and doing so isn't necessary for several other database entities with similar relationships.

I have been ripping my hair out trying to figure out the source of this error. I have even went so far as to create a new ASP.Net Core project, copy over the necessary models, and scaffold a new database from them only to run into the same issue.

From what I can tell, this is an issue with the ModelBinder not playing nice with these and other related classes. The Binder should be setting the Dealer property to null on the post-back, but for some reason it is invoking the default constructor instead (and then invoking the default constructor for the Dealer's parent class).


Solution

  • After much searching, I have discovered that this behavior is being caused by an issue related to the way model binding treats entities with an IFormFile property.

    See:

    In my case, the Dealer class had an IFormFile property, which caused the model binder to invoke the default constructor for Dealer resulting in the Branch.Dealer property being populated with a default Dealer object, instead of being left null. According to https://github.com/aspnet/AspNetCore/issues/8917 this has been fixed in ASP.NET Core 3.0.