Search code examples
c#entity-frameworkentity-framework-core.net-5asp.net5

Entity Framework Core - ValueConverter with ValueComparer to convert Enum to Class not working


I'm trying to send this Json to my API with Postman:

{
    "name": "yummy food",
    "brand": "brand",
    "tags": [
        "1",
        "2"
    ]
}

But I'm getting this error:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-b7a6042817b5124294f5c6d2f6169f05-70d797d0744bfe40-00",
    "errors": {
        "$.tags[0]": [
            "The JSON value could not be converted to GroceryItemTag. Path: $.tags[0] | LineNumber: 4 | BytePositionInLine: 11."
        ]
    }
}

The GroceryItemTag field ("tags") in post, is an enum but using a lookup table to be a GroceryItemTag object with enum id, name, and iconCodePoint fields.

The postman request:

enter image description here

Here is my entity framework core model:

GroceryItem:

using System.Collections.Generic;

namespace Vepo.Domain
{
    public class GroceryItem : VeganItem<GroceryItem, GroceryItemTagEnum, GroceryItemTag>
    {
        public string Name {get; set;}
        public string Brand {get; set;}
        public string Description {get; set;}
        public string Image {get; set;}
        public virtual ICollection<GroceryItemGroceryStore> GroceryItemGroceryStores { get; set; }

    }
}

The base class of GroceryItem (take note of virtual Tags with TagIds):

using System.Collections.Generic;

namespace Vepo.Domain
{
    public abstract class VeganItem<VeganItemType, VeganItemTagEnumType, VeganItemTagType>
    {
        public int Id { get; set; }
        public int IsNotVeganCount { get; set; }
        public int IsVeganCount { get; set; }
        public int RatingsCount { get; set; }
        public int Rating { get; set; }
        public List<VeganItemTagEnumType> TagIds { get; set; }
        public virtual List<VeganItemTagType> Tags { get; set; }

        public List<Establishment<VeganItemType>> Establishments { get; set; }
        public int CurrentRevisionId { get; set; }

    }
}

GroceryItemTagEnum:

public enum GroceryItemTagEnum
{
  BabyAndChild = 1,
  Baking,
  Bathroom,
  BeerAndWine,
  Condiments,
  Confectionary,  
  Cooking,
  Dessert,
  Drinks,
  FauxDairy,
  FauxMeat,
  FauxSeafood,
  FridgeAndDeli,
  Frozen,
  HealthFood,
  HouseHold,
  Other,
  Pantry,
  Pet,
}     

GroceryItemTag class for lookup table:

public class GroceryItemTag
{
    public GroceryItemTagEnum Id { get; set; }
    public int IconCodePoint {get; set;}
    public string Name { get; set; }
}

The controller, take note of PostGroceryItem:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Vepo.Domain;

namespace Vepo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class GroceryItemsController : ControllerBase
    {
        private readonly VepoContext _context;

        public GroceryItemsController(VepoContext context)
        {
            _context = context;
        }

        // GET: api/GroceryItems
        [HttpGet]
        public async Task<ActionResult<IEnumerable<GroceryItem>>> GetGroceryItems()
        {
            return await _context.GroceryItems.ToListAsync();
        }

        // GET: api/GroceryItems/5
        [HttpGet("{id}")]
        public async Task<ActionResult<GroceryItem>> GetGroceryItem(int id)
        {
            var groceryItem = await _context.GroceryItems.FindAsync(id);

            if (groceryItem == null)
            {
                return NotFound();
            }

            return groceryItem;
        }

        // PUT: api/GroceryItems/5
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPut("{id}")]
        public async Task<IActionResult> PutGroceryItem(int id, GroceryItem groceryItem)
        {
            if (id != groceryItem.Id)
            {
                return BadRequest();
            }

            _context.Entry(groceryItem).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!GroceryItemExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return NoContent();
        }

        // POST: api/GroceryItems
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<GroceryItem>> PostGroceryItem(GroceryItem groceryItem)
        {
            _context.GroceryItems.Add(groceryItem);
            await _context.SaveChangesAsync();

            return CreatedAtAction("GetGroceryItem", new { id = groceryItem.Id }, groceryItem);
        }

        // DELETE: api/GroceryItems/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteGroceryItem(int id)
        {
            var groceryItem = await _context.GroceryItems.FindAsync(id);
            if (groceryItem == null)
            {
                return NotFound();
            }

            _context.GroceryItems.Remove(groceryItem);
            await _context.SaveChangesAsync();

            return NoContent();
        }

        private bool GroceryItemExists(int id)
        {
            return _context.GroceryItems.Any(e => e.Id == id);
        }
    }
}

My database context which seeds the GroceryItemTag lookup table:

using Microsoft.EntityFrameworkCore;

namespace Vepo.Domain
{
    public class VepoContext : DbContext
    {
        public VepoContext(DbContextOptions<VepoContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<GroceryItemGroceryStore>().HasKey(table => new
            {
                table.GroceryItemId,
                table.GroceryStoreId
            });

            builder.Entity<MenuItemRestaurant>().HasKey(table => new
            {
                table.MenuItemId,
                table.RestaurantId
            });

            builder.Entity<GroceryItemTag>()
            .Property(tag => tag.Id)
            .ValueGeneratedNever();

            builder.Entity<MenuItemTag>()
            .Property(tag => tag.Id)
            .ValueGeneratedNever();

            builder.Entity<GroceryItemTag>().HasData(
                new GroceryItemTag[] {
                new GroceryItemTag {
                    Name = "Baby & Child",
                    Id = GroceryItemTagEnum.BabyAndChild,
                    IconCodePoint = 0xf77c
                },
                new GroceryItemTag {
                    Name = "Baking",
                    Id = GroceryItemTagEnum.Baking,
                    IconCodePoint = 0xf563
                },
                new GroceryItemTag {
                    Name = "Beer & Wine",
                    Id = GroceryItemTagEnum.BeerAndWine,
                    IconCodePoint = 0xf4e3
                },
                new GroceryItemTag {
                    Name = "Condiments",
                    Id = GroceryItemTagEnum.Condiments,
                    IconCodePoint = 0xf72f
                },
                new GroceryItemTag {
                    Name = "Confectionary",
                    Id = GroceryItemTagEnum.Confectionary,
                    IconCodePoint = 0xf819
                },
                new GroceryItemTag {
                    Name = "Cooking",
                    Id = GroceryItemTagEnum.Cooking,
                    IconCodePoint = 0xe01d
                },
                new GroceryItemTag {
                    Name = "Dessert",
                    Id = GroceryItemTagEnum.Dessert,
                    IconCodePoint = 0xf810
                },
                new GroceryItemTag {
                    Name = "Drinks",
                    Id = GroceryItemTagEnum.Drinks,
                    IconCodePoint = 0xf804
                },
                new GroceryItemTag {
                    Name = "Faux Meat",
                    Id = GroceryItemTagEnum.FauxMeat,
                    IconCodePoint = 0xf814
                },
                new GroceryItemTag {
                    Name = "Faux Dairy",
                    Id = GroceryItemTagEnum.FauxDairy,
                    IconCodePoint = 0xf7f0
                },
                new GroceryItemTag {
                    Name = "Faux Seafood",
                    Id = GroceryItemTagEnum.FauxSeafood,
                    IconCodePoint = 0xf7fe
                },
                new GroceryItemTag {
                    Name = "Fridge & Deli",
                    Id = GroceryItemTagEnum.FridgeAndDeli,
                    IconCodePoint = 0xe026
                },
                new GroceryItemTag {
                    Name = "Frozen",
                    Id = GroceryItemTagEnum.Frozen,
                    IconCodePoint = 0xf7ad
                },
                new GroceryItemTag {
                    Name = "Bathroom",
                    Id = GroceryItemTagEnum.Bathroom,
                    IconCodePoint = 0xe06b
                },
                new GroceryItemTag {
                    Name = "Health Food",
                    Id = GroceryItemTagEnum.HealthFood,
                    IconCodePoint = 0xf787
                },
                new GroceryItemTag {
                    Name = "Household",
                    Id = GroceryItemTagEnum.HouseHold,
                    IconCodePoint = 0xf898
                },
                new GroceryItemTag {
                    Name = "Pantry",
                    Id = GroceryItemTagEnum.Pantry,
                    IconCodePoint = 0xf7eb
                },
                new GroceryItemTag {
                    Name = "Pet",
                    Id = GroceryItemTagEnum.Pet,
                    IconCodePoint = 0xf6d3
                },
                new GroceryItemTag {
                    Name = "Other",
                    Id = GroceryItemTagEnum.Other,
                    IconCodePoint = 0xf39b
                }});


        builder.Entity<GroceryItem>()
        .Property(e => e.Tags)
        .HasConversion(
            v => JsonSerializer.Serialize(v, null),
            v => JsonSerializer.Deserialize<List<GroceryItemTag>>(v, null),
            new ValueComparer<IList<GroceryItemTag>>(
                (c1, c2) => c1.SequenceEqual(c2),
                c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
                c => (IList<GroceryItemTag>)c.ToList()));


        }

        public DbSet<GroceryItem> GroceryItems { get; set; }
        public DbSet<GroceryItemGroceryStore> GroceryItemGroceryStores { get; set; }
        public DbSet<MenuItemRestaurant> MenuItemRestaurants { get; set; }
        public DbSet<MenuItem> MenuItems { get; set; }
        public DbSet<GroceryStore> GroceryStores { get; set; }
        public DbSet<GroceryItemTag> GroceryItemTags { get; set; }
        public DbSet<MenuItemTag> MenuItemTags { get; set; }
        public DbSet<Restaurant> Restaurants { get; set; }
    }
}

How do I send the post from postman that will be able to convert the tag id to a GroceryItemTag

I know that I need to do this (See official Microsoft docs):

    builder.Entity<GroceryItem>()
    .Property(e => e.Tags)
    .HasConversion(
        v => JsonSerializer.Serialize(v, null),
        v => JsonSerializer.Deserialize<List<GroceryItemTag>>(v, null),
        new ValueComparer<IList<GroceryItemTag>>(
            (c1, c2) => c1.SequenceEqual(c2),
            c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
            c => (IList<GroceryItemTag>)c.ToList()));

I just can't manage to get it working, for example, it compiles but I get the exact same error. No change at all.


Solution

  • I believe I misunderstood a ValueConverter/ValueComparer for storing a whole object rather than just an enum id. I think the JSON sent needs to have the full object value, not just the enum (id). I think the ValueConverter/ValueComparer renders the lookup table obsolete.

    I removed VeganItem.TagIds, made VeganItem.Tags NOT virtual, and changed the json payload to this:

    {
        "name": "yummy food",
        "brand": "brand",
        "tags": [
            {"name":"Baking", "id":2, "iconCodePoint": 23145}
        ]
    }
    

    And it worked. I believe this is what is meant to be done because I also tried it whilst commenting out the code here:

    builder.Entity<GroceryItem>()
                .Property(e => e.Tags)
                .HasConversion(
                    v => JsonSerializer.Serialize(v, null),
                    v => JsonSerializer.Deserialize<List<GroceryItemTag>>(v, null),
                    new ValueComparer<IList<GroceryItemTag>>(
                        (c1, c2) => c1.SequenceEqual(c2),
                        c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
                        c => (IList<GroceryItemTag>)c.ToList()));
    

    And it didn't manage to successfully convert the JSON when the above code was commented out but did when it was uncommented, leading me to believe the ValueComparer and ValueConverter are doing their job as intended by Entity Framework Core.

    This causes the Tags database column to store this serialized value:

    enter image description here