I'm trying to update an entity (SurveyOptionSet
) which has a List<SurveyOptionSetOption>
navigation property.
public class SurveyOptionSet
{
public Guid SurveyId { get; set; }
public virtual Survey Survey { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public SurveyOptionSetType OptionSetType { get; set; } = SurveyOptionSetType.String;
public virtual ICollection<SurveyOptionSetOption> SurveyOptionSetOptions { get; set; } = new List<SurveyOptionSetOption>();
}
public class SurveyOptionSetOption
{
public virtual SurveyOptionSet SurveyOptionSet { get; set; }
public Guid SurveyOptionSetId { get; set; }
public string? Text { get; set; }
public string? Value { get; set; }
public int Order { get; set; }
}
When a SurveyOptionSet
is serialized as JSON and sent to the client, SurveyOptionSetOption[x].SurveyOptionSet
is not serialized because the serializer ignores the reference loops (ReferenceLoopHandling = ReferenceLoopHandling.Ignore
) and also would be useless data to be sent.
So a SurveyOptionSetOption
held in the navigation property does have the foreign key populated (SurveyOptionSetOption.SurveyOptionSetId
) but SurveyOptionSetOption.SurveyOptionSet
is null when sent from the client back to the API as a DTO, which is want I actually want.
Then I convert the DTO object back to the database model (which works fine, and the relevant data is properly converted)
And here's what the data that is sent by the client looks like
{
"SurveyId": "80c4e3b0-6dfa-4b65-cebd-08dbc8c2f68c",
"Description": null,
"Name": "Level",
"SurveyOptionSetOptions": [
{
"SurveyOptionSetId": "2c881fd7-b82c-4c8d-258f-08dbd8e18777",
"Text": "Low",
"Value": "LOW",
"Order": 0,
"Id": "59dd0c52-dd4c-45a2-9279-08dbd8e18785",
"CreatedOn": "2023-10-29T20:47:33.2721903",
"ModifiedOn": "2023-10-30T16:40:45.1190746"
}
],
"Id": "2c881fd7-b82c-4c8d-258f-08dbd8e18777",
"CreatedOn": "2023-10-29T20:47:33.272182",
"ModifiedOn": "2023-10-31T06:52:07.0010341"
}
Here's my update method:
public virtual async Task<TDto?> Update(TDto dto)
{
try
{
var dtoId = dto.GetType().GetProperty(DtoIdPropertyName).GetValue(dto);
var target = DbSet.AsEnumerable().SingleOrDefault(ById(dtoId));
string beforeUpdateSerialized = JsonConvert.SerializeObject(target, JsonSerializerSettings);
ApplyProperties(dto, target);
string afterUpdateSerialized = JsonConvert.SerializeObject(target, JsonSerializerSettings);
if (beforeUpdateSerialized != afterUpdateSerialized)
Context.Entry(target).State = EntityState.Modified; //FAILS HERE
await Context.SaveChangesAsync();
return ConvertToDto(target);
}
catch (Exception ex)
{
var errorMsg = $"{ClassName}.Update({JsonConvert.SerializeObject(dto)}): error updating record: {ex.Message}";
throw new Exception(errorMsg);
}
}
In target
, the items in the navigation property SurveyOptionSetOptions
do have SurveyOptionSet
populated but in the DTO, they don't.
The ApplyProperties
call applies all the values found in the DTO to the database model, thus erasing the value of SurveyOptionSetOptions[x].SurveyOptionSet
Is there any way to configure EF so having the FK (SurveyOptionSetId
) is enough to maintain the link between the entities ?
The code looks to be doing a few unnecessary things, and I suspect the ApplyChanges is as well.
The only steps you should need to do in order to update an entity from a DTO while ignoring navigation collections would be:
SaveChanges()
That's it. You definitely don't want to override the tracked entity's state to Modified. This is similar to using the
Update
method in that it tells EF to treat everything on the entity as modified. For copying values across you don't need to write your own, but can either use EF capabilities viaSetValues
or use something like Automapper'sMap(src, dest)
method which allows for configuring rules and transformations between the DTO to entity. If you do use a method like ApplyChanges, just ensure it copies values across from the DTO to the entity, ignoring stuff that shouldn't change and let the change tracker take care of building the update. There also is no need in manually comparing the before & after when using the change tracker. EF will only generate and execute anUPDATE
SQL statement if at least one value actually changed. The benefit overEntityState.Modified
or usingUpdate()
is that it will also only generate an update for the fields that actually change rather than updating every field in the table.or with Automapper:
Automapper needs to be configured for rules around what to be able to convert/map between so for instance to/from Order and OrderDto etc. This can either be set up with all mapping combinations, or set up per scenario though that doesn't fit as well with Generic implementations. (Which honestly add a lot of unnecessary complexity over EF)