Search code examples
entity-framework.net-corejson.netpolymorphismcomposite

EF Core: Serializing composite objects with polymorphic associations


In my design, I have a Challenge aggregate root that maintains a list of strategies that collectively determine if the challenge is complete.

There are several types of strategies that examine the challenge submission in their own way. Each type of strategy inherits from the base ChallengeFullfilmentStrategy, as shown in the diagram.

class diagram

When a submission is made, I load the challenge along with its strategies with the following code:

return _dbContext.Challenges
    .Where(c => c.Id == challengeId)
    .Include(c => c.Solution)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicMetricChecker).ClassMetricRules)
        .ThenInclude(r => r.Hint)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicMetricChecker).MethodMetricRules)
        .ThenInclude(r => r.Hint)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicNameChecker).Hint)
    .FirstOrDefault();

This setup is a consequence of the polymorphism introduced by the strategy hierarchy. Recently, I've tried to add a more sophisticated strategy (ProjectChecker, marked in red on the diagram). Through an intermediate class, it introduces a composite relationship, where now a Strategy has a list of Strategies (through the SnippetStrategies class).

This change severely complicates the data model, as now the code should look something like this:

return _dbContext.Challenges
    .Where(c => c.Id == challengeId)
    .Include(c => c.Solution)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicMetricChecker).ClassMetricRules)
        .ThenInclude(r => r.Hint)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicMetricChecker).MethodMetricRules)
        .ThenInclude(r => r.Hint)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as BasicNameChecker).Hint)
    .Include(c => c.FulfillmentStrategies)
        .ThenInclude(s => (s as ProjectChecker).SnippetStrategies)
        .ThenInclude(snippetStrats => snippetStrats.Strategies)
        .ThenInclude(s => (s as BasicMetricChecker).MethodMetricRules)
        //More code here to include the other children, not sure if this can even work.
    .FirstOrDefault();

I'm not sure if I've hit a limitation of EF Core or if I'm not aware of some mechanism that can solve this issue.

If it's the former, I was considering serializing my Challenge aggregate into a JSON object (I'm using postgresql) and removing this part of the relationship model. While it makes sense from a domain perspective (I either need only the challenge header or all of it - including all the strategies etc.), my research so far has revealed that System.Text.Json suffers from the same limitation and that I'll need to write a custom JSONConverter to enable serialization of this data structure.

The cleanest option I've found so far is the use of Newtonsoft along with the JsonSubtypes library, but I wanted to check if I'm missing something that would help solve my issue without introducing these dependencies (especially since JsonSubtypes seems to be less active).


Solution

  • This is a static helper method I created for when I had the same problem, utilizing reflection.

    Imagine it like a beefed up .Find(). It loops through all the properties of a class and loads them in the same fashion as you would with the .Include() and .ThenInclude() pattern that you follow in your code. This does have an extra content in the first if/else block where it finds the DbContext Model from an Interface being passed in for the Type parameter. As in our case we used quite a few interfaces to classify our Model classes (figured it could be useful). This has some hefty overhead, so be careful. We use the FullFindAsync for very specific use cases, and typically try to do .Includes().

    You could also add further depth searching to the foreach loop at the end if you want to dive deeper in what gets loaded. Again, this has some hefty overhead as well, so make sure to test it on large datasets.

    public static async Task<object> FullFindAsync(this DbContext context, Type entityType, params object[] keyValues)
    {
       object entity = null;
    
       //handle an Interface as a passed in type
       if (entityType.IsInterface)
       {
          //get all Types of Models in the DbContext that implement the Interface
          var implementedModels = context.Model.GetEntityTypes()
                                   .Select(et => et.ClrType)
                                   .Where(t => entityType.IsAssignableFrom(t))
                                   .ToList();
    
          //loop through all Models and try to find the object via the parameters
          foreach (var modelType in implementedModels)
          {
             entity = await context.FindAsync(modelType, keyValues);
             if (entity != null) break;
          }
       }
       else
        //find the object, via regular Find
        entity = await context.FindAsync(entityType, keyValues);
    
       //didnt find the object
       if (entity == null) return null;
    
       //loop through all navigation properties
       foreach (var navigation in entity.GetType().GetProperties())
       {
          //skip NotMapped properties (to avoid context collection error)
          if (context.Entry(entity).Metadata.FindNavigation(navigation.Name) == null)
                        continue;
    
          //check and load reference and collection properties
          if (typeof(IDatabaseObject).IsAssignableFrom(navigation.PropertyType))
                        context.Entry(entity).Reference(navigation.Name).Load();
          if (typeof(IEnumerable<object>).IsAssignableFrom(navigation.PropertyType))
                        context.Entry(entity).Collection(navigation.Name).Load();
       }
    
       return entity;
    }
    
    
    //***USAGES***
    var fullModel = await _dbContext.FullFindAsync(typeof(MyModelObject), model.Id);
    var correctModel = await _dbContext.FullFindAsync(typeof(IModelInterface), someRandomId);