Search code examples
c#asp.net-core.net-coreasp.net-core-mvc

Send objects to controller, including foreign key


I'm trying to create an object called Facture which has a foreign key Client:

public class Facture
{
    public int Id { get; set; }

    [ValidateNever]
    [DataType(DataType.Date)]
    public DateTime? DateCreation { get; set; }

    [Required]
    [DataType(DataType.Date)]
    [Display(Name = "Date de facturation")]
    public DateTime DateFacturation { get; set; }

    [DataType(DataType.Date)]
    [Display(Name = "Date d'échéance")]
    public DateTime? DateEcheance { get; set; }

    [ValidateNever]
    public required Client Client { get; set; }
}

public class Client
{
    public int Id { get; set; }

    [Required]
    public required string Name { get; set; }

    ICollection<Facture>? Factures { get; set; }
}

The fact is that I don't know how to get the value of Client sent from the view. I've populated a drop down list like that in my FacturesController:

    public IActionResult Create()
    {
        ViewBag.Client = new SelectList(_context.Client, "Id", "Name");
        return View();
    }

And in the Facture view:

<form asp-action="Create">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <div class="form-group">
        <input asp-for="DateCreation" class="form-control" />
        <span asp-validation-for="DateCreation" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="DateFacturation" class="control-label"></label>
        <input asp-for="DateFacturation" class="form-control" />
        <span asp-validation-for="DateFacturation" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="DateEcheance" class="control-label"></label>
        <input asp-for="DateEcheance" class="form-control" />
        <span asp-validation-for="DateEcheance" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Client" class="control-label"></label>
        <select asp-for="Client" class="form-control" asp-items="ViewBag.Client"></select>
    </div>
    <div class="form-group">
        <input type="submit" value="Create" class="btn btn-primary" />
    </div>
</form>

And I don't know how to get the value of client in the second Create method:

public async Task<IActionResult> Create([Bind("Id,DateFacturation,DateEcheance,Client")] Facture facture)
{
    if (ModelState.IsValid)
    {
        facture.DateCreation = DateTime.Now;
        _context.Add(facture);
        await _context.SaveChangesAsync();
        return RedirectToAction(nameof(Index));
    }

    return View(facture);
}

Solution

  • Short answer is "Don't send entities to the view; Project to view models". The data isn't there because you potentially didn't provide it in the first place. Meaning, forgot .Include(f => f.Client) when sending the Facture to the view, and/or did not have inputs for the view JS to re-constitute the client data to send back.

    Detailed answer: The objects your controller provides to an MVC view do not get sent to the browser. They are sent to the view engine. The view engine builds the html + JS view to send to the browser. The "view" has no concept of what the model is/was. All it has are the input controls in the <form> element(s) that the view engine composed. If you use the debugging tools in your browser and inspect your page, you will find no "Model", only HTML elements. When your page posts back or you get the FormData to use in the post-back or an Ajax call etc. the Ids and values of input elements are composed into a simple JSON object and sent to the controller. MVC then works to marry that up to the argument in the controller action.

    So, for your MVC controller action to receive a "Facture", it will construct a new Facture instance and populate properties based on values in the passed in JSON that have the same name. Anything that doesn't match is ignored. If you want to get related data, then you need to have input controls on your view that were mapped, and named based on that related data. These will be named to follow a convention that MVC will follow to re-saturate an instance of a related entity. Often you do not have/need visible inputs for these fields, so the practice is to use hidden inputs. I.e. @Html.HiddenFor(model => model.ClientId) If you want the entire Client entity to be populated on the return, you would need inputs for every Client property. I.e. @Html.HiddenFor(model => model.Client.ClientId) etc. Possible, but excessive and unnecessary. The most important thing to note is that you do not get the same instance of the object that you sent to the view engine, and you do not even get an instance of the object that is tracked by the DbContext in your controller action.

    ViewModels are better to use because they break this bad assumption. You know that you're getting a simple "view" model back and need to turn that into a new, or fetch an existing "entity" to transpose the model back into.

    When it comes to an association where you select a "Client" for a new Facture and populate a drop-down of clients /w ClientId and Name, that ClientId would be mapped to a property in your model. If that is for a new Facture entity for example and you want the Facture's Client to be set, then fetch it from the DbContext:

    public async Task<IActionResult> Create([Bind("Id,DateFacturation,DateEcheance,Client")] Facture facture)
    {
        if (ModelState.IsValid)
        {
            var client = _context.Clients.Single(c => c.ClientId == facture.ClientId);
            facture.DateCreation = DateTime.Now;
            facture.Client = client;
            _context.Add(facture);
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
    
        return View(facture);
    
    }
    

    Even if you added hidden inputs for every property in Facture.Client, and ensured they were populated when selecting a Client and passed back to populate facture.Client on the call back to the controller, when you "add" the Facture to the DbContext, the Client reference is not a tracked reference so EF would treat it as an insert as well, resulting in either a duplicate data exception, or inserting a new duplicate row with a new PK.

    I recommend getting into the habit of instead projecting and passing view models to the view (using Select() on GET operations) and then composing and looking up entities on the POST/PUT/PATCH. It avoids confusion with references, and avoids issues which can expose more detail about your domain than you intend, or worse, issues that can see data that should not be updated getting overwritten. (If you are doing things like _context.Update(facture); for edits)