Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Trying to use AutoMapper for model with child collections, getting null error in Asp.Net MVC 3

I'm completely new to AutoMapper, and I have a View that looks like this:

@using (Html.BeginForm(null, null, FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.ValidationSummary(true)
    <fieldset>
        <legend>Consultant</legend>
        <div class="editor-label">
            @Html.LabelFor(model => model.FirstName)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.FirstName)
            @Html.ValidationMessageFor(model => model.FirstName)
        </div>
        <div class="editor-label">
            @Html.LabelFor(model => model.LastName)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.LastName)
            @Html.ValidationMessageFor(model => model.LastName)
        </div>
        <div class="editor-label">
            @Html.LabelFor(model => model.Description)
        </div>
        <div class="editor-field">
            @Html.TextAreaFor(model => model.Description)
            @Html.ValidationMessageFor(model => model.Description)
        </div>
        <div class="editor-label">
            Program du behärskar:
        </div>
        <div>
            <table id="programEditorRows">
                <tr>
                    <th>
                        Program
                    </th>
                    <th>
                        Nivå
                    </th>
                </tr>
                @foreach (var item in Model.Programs)
                {
                    Html.RenderPartial("ProgramEditorRow", item);
                }
            </table>
            <a href="#" id="addProgram">Lägg till</a>
        </div>
        <div class="editor-label">
            Språk du behärskar:
        </div>
        <div>
            <table id="languageEditorRows">
                <tr>
                    <th>
                        Språk
                    </th>
                    <th>
                        Nivå
                    </th>
                </tr>
                @foreach (var item in Model.Languages)
                {
                    Html.RenderPartial("LanguageEditorRow", item);
                }
            </table>
            <a href="#" id="addLanguage">Lägg till</a>
        </div>
        <div>
            <table id="educationEditorRows">
                <tr>
                    <th>
                        Utbildning
                    </th>
                    <th>
                        Nivå
                    </th>
                </tr>
                @foreach (var item in Model.Educations)
                {
                    Html.RenderPartial("EducationEditorRow", item);
                }
            </table>
            <a href="#" id="addEducation">Lägg till</a>
        </div>
        <div>
            <table id="workExperienceEditorRows">
                <tr>
                    <th>
                        Arbetserfarenhet
                    </th>
                    <th>
                        Startdatum
                    </th>
                    <th>
                        Slutdatum
                    </th>
                </tr>
                @foreach (var item in Model.WorkExperiences)
                {
                    Html.RenderPartial("WorkExperienceEditorRow", item);
                }
            </table>
            <a href="#" id="addWorkExperience">Lägg till</a>
        </div>
        <div>
            <table id="competenceAreaEditorRows">
                <tr>
                    <th>
                        Kompetensområde
                    </th>
                    <th>
                        Nivå
                    </th>
                </tr>
                @foreach (var item in Model.CompetenceAreas)
                {
                    Html.RenderPartial("CompetenceAreaEditorRow", item);
                }
            </table>
            <a href="#" id="addCompetenceArea">Lägg till</a>
        </div>
        <div>
            <input id="fileInput" name="FileInput" type="file" />
        </div>
        <p>
            <input type="submit" value="Spara" />
        </p>
    </fieldset>
}
<div>
    @Html.ActionLink("Back to List", "Index")
</div>

Here's the GET Edit method:

    public ActionResult Edit(int id)
    {
        Consultant consultant = _repository.GetConsultant(id);
        ConsultantViewModel vm = Mapper.Map<Consultant, ConsultantViewModel>(consultant);
        return View(vm);
    }

And the POST Edit method:

    [HttpPost]
    [ValidateInput(false)] //To allow HTML in description box
    public ActionResult Edit(int id, ConsultantViewModel vm, FormCollection collection)
    {

            Consultant consultant = Mapper.Map<ConsultantViewModel, Consultant>(vm);
            _repository.Save();
            return RedirectToAction("Index");

    }

Now, when AutoMapper creates the ViewModel it seems to be working fine (using its simplest form, no resolver or anything, just mapping Consultant to ConsultantViewModel), including the child collections and all. Also, the UserName property is there. Now, in the View I don't have a field for UserName, because it is always automatically filled in by the current User (User.Identity.Name). But when I get the vm back, the UserName property is null, presumably because there was no field for it in the View.

I suspect some of the collections will cause the same error even if I put a hidden field in there for UserName, because it is optional for the Consultant to fill in Languages etc... So even though the ViewModel has an instantiated list for each of these child collections going in to the View (with a count of 0), they come back with a null value instead.

How do I solve this? I don't want to force having values filled in all the child collections by the user. I mean, I could always create a Language object with an empty string for the Name property, but that would mean unnecessary extra code, and all I really want is to get the child collections (and UserName) back the way they went in to the View - with UserName filled in, and with the child collections being instantiated, but with a count of 0 if the user hasn't added any items.

UPDATE:

I don't know, I think I'm misunderstanding AutoMapper somehow... I found that in fact the child collections were'nt really a problem with regard to the mapping, that worked fine mapping it back into the Consultant object. However...I also needed to put the id back in the Consultant object, because the ViewModel didn't have any. But even so, when I save to repository, it doesn't get saved. This is where I think I misunderstand AutoMapper - I had thought it would somehow populate the Consultant object with values from the ViewModel, but I guess it causes the consultant variable to refer to a different object in the Map() statement? Because none of it is persisted...

Here's the modified POST method (which doesn't work):

    [HttpPost]
    [ValidateInput(false)] //To allow HTML in description box
    public ActionResult Edit(int id, ConsultantViewModel vm, FormCollection collection)
    {

        vm.UserName = User.Identity.Name;
        Consultant consultant = _repository.GetConsultant(id);
        consultant = Mapper.Map<ConsultantViewModel, Consultant>(vm);
        consultant.Id = id;
        _repository.Save();
        return RedirectToAction("Index");

    }

What am I doing wrong? How do I get the populated values in the ViewModel back into the Consultant object and persist it to the database???

UPDATE 2:

Ok, thorougly confused... Starting over a bit: Here's the map creation in Application_Start:

        Mapper.CreateMap<ConsultantViewModel, Consultant>().ForMember("Id", opts => opts.Ignore());
        Mapper.CreateMap<Consultant, ConsultantViewModel>();

Edit methods:

    // GET: /Consultant/Edit/5

    public ActionResult Edit(int id)
    {
        Consultant consultant = _repository.GetConsultant(id);
        ConsultantViewModel vm = Mapper.Map<Consultant, ConsultantViewModel>(consultant);
        return View(vm);
    }

    //
    // POST: /Consultant/Edit/5

    [HttpPost]
    [ValidateInput(false)] //To allow HTML in description box
    public ActionResult Edit(int id, ConsultantViewModel vm, FormCollection collection)
    {
        vm.UserName = User.Identity.Name;
        Consultant consultant = _repository.GetConsultant(id);
        consultant = Mapper.Map<ConsultantViewModel, Consultant>(vm, consultant);
        _repository.Save();
        return RedirectToAction("Index");
    }

This doesn't work either, but apparently at least tries to update the entity model, because now I get an exception:

The EntityCollection could not be initialized because the relationship manager for the object to which the EntityCollection belongs is already attached to an ObjectContext. The InitializeRelatedCollection method should only be called to initialize a new EntityCollection during deserialization of an object graph.

And YSOD error code sample:

Line 698:                if ((value != null))
Line 699:                {
Line 700:                    ((IEntityWithRelationships)this).RelationshipManager.InitializeRelatedCollection<Program>("ConsultantsModel.ConsultantProgram", "Program", value);
Line 701:                }
Line 702:            }

This is pretty much the same error I got when trying to use the entity object directly as model, rather than having AutoMapper create a ViewModel. So what am I doing wrong? This is driving me crazy...

UPDATE 3:

Well, neverending story... I found some info on using UseDestinationValue on the CreateMap method in AutoMapper. So I tried that, and well, that actually got me a bit further. But...now I get a new exception on SaveChanges() (in the EF model). The exception now is: "The operation failed: The relationship could not be changed because one or more of the foreign-key properties is non-nullable." This appears to be an exception that also occurs when trying to delete child objects in a one-to-many relationship if you don't have cascade delete set, but that's not what I'm trying to do here...

Here's the updated CreateMap methods:

Mapper.CreateMap<ConsultantViewModel, Consultant>().ForMember("Id", opts => opts.Ignore()).ForMember(
                x => x.Programs, opts => opts.UseDestinationValue());
            Mapper.CreateMap<Consultant, ConsultantViewModel>();

Any ideas?

like image 838
Anders Avatar asked Feb 26 '11 11:02

Anders


1 Answers

Haven't gotten any answers yet, and I actually found a way to make it work. Still doesn't feel good, because the code is kind of verbose... So if anyone has better ideas, please bring them!

I changed it so that I now have a DTO object for each of the types in the child collections (probably should have had that to begin with when using AutoMapper). So for instance I now have a ProgramDTO type to map with Program.

I tried doing the mapping simply with the Consultant object, hoping for the nested collections to work themselves out, but only got the "EntityCollection has already been initialized" error again. So on a hunch I tried this method instead:

    private Consultant CreateConsultant(ConsultantViewModel vm, Consultant consultant) //Parameter Consultant needed because an object may already exist from Edit method.
    {

        Mapper.Map(vm, consultant);
//To do this I had to add an Ignore in the mapping configuration:
//Mapper.CreateMap<ConsultantViewModel, Consultant>().ForMember(x => x.Programs, opts => opts.Ignore());

        //Delete items "marked for deletion" by removing with jQuery in the View:
        var programs = consultant.Programs.Except(consultant.Programs.Join(vm.Programs, p => p.Id, d => d.Id, (p, d) => p)).ToList();
        Delete(programs);

        foreach (var programDto in vm.Programs)
        {
            Program program = consultant.Programs.SingleOrDefault(x => x.Id == programDto.Id);
            if (program == null)
            {
                program = new Program();
                consultant.Programs.Add(program);
            }
            program = Mapper.Map(programDto, program);
        }

        _repository.Save();

        return consultant;
    }

The difference being that I fill the simple properties of Consultant by UpdateModel(), and then iterate through the collection of ProgramDTOs and map up each Program.

Well this worked, although I don't like the code very much... It also hit me after I had done this, that I also need to be able to delete any items that the user has "marked for deletion" so to speak in the View. I'm using a view where you can add and remove textboxes etc by jQuery, following a tutorial by Steven Sanderson. But this made the code even more complicated... Anyway, this was the best I could think of, so again please offer any other ideas if you can improve on this! I particularly would have liked a solution where I didn't have to manually loop through the collections on POST, but have AutoMapper handle the nested collections itself, without the errors mentioned above!

like image 67
Anders Avatar answered Nov 07 '22 09:11

Anders