mattbrailsford / umbraco-fluidity

A fluent CRUD user interface generator for Umbraco
https://our.umbraco.org/projects/backoffice-extensions/fluidity/
Apache License 2.0
48 stars 40 forks source link

Unable to Create Entity with Custom Repository (Null Exception in FluidityEntityService.SaveEntity) #94

Closed Nicholas-Westby closed 5 years ago

Nicholas-Westby commented 5 years ago

Here's the relevant portion of the stack trace from the Umbraco error log file:

 2019-03-01 15:43:40,998 [P6720/D15/T11] ERROR Fluidity.Web.Api.FluidityApiController - Unhandled controller exception occurred
System.NullReferenceException: Object reference not set to an instance of an object.
   at Fluidity.Services.FluidityEntityService.SaveEntity(FluidityCollectionConfig collection, Object entity)
   at Fluidity.Web.Api.FluidityApiController.SaveEntity(FluidityEntityPostModel postModel)
   at System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ActionExecutor.<>c__DisplayClass10.<GetExecutor>b__9(Object instance, Object[] methodParameters)
   at System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ExecuteAsync(HttpControllerContext controllerContext, IDictionary`2 arguments, CancellationToken cancellationToken)

Here's my model:

public class SamplePerson
{
    public string Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Here's my custom repository:

using Fluidity;
using Fluidity.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Umbraco.Core.Models;

public class SampleRepo : FluidityRepository<SamplePerson, string>
{
    private static List<SamplePerson> People = new List<SamplePerson>()
    {
        new SamplePerson()
        {
            Id = "Bob",
            FirstName = "Bob",
            LastName = "Boberson"
        },
        new SamplePerson()
        {
            Id = "Jane",
            FirstName = "Jane",
            LastName = "Janerson"
        }
    };

    protected override void DeleteImpl(string id)
    {
        People = People.Where(x => x.Id != id).ToList();
    }

    protected override IEnumerable<SamplePerson> GetAllImpl()
    {
        return People;
    }

    protected override string GetIdImpl(SamplePerson entity)
    {
        return entity?.Id;
    }

    protected override SamplePerson GetImpl(string id)
    {
        return People.Where(x => x.Id == id).FirstOrDefault();
    }

    protected override PagedResult<SamplePerson> GetPagedImpl(int pageNumber, int pageSize,
        Expression<Func<SamplePerson, bool>> whereClause, Expression<Func<SamplePerson, object>> orderBy,
        SortDirection orderDirection)
    {
        var pageIndex = pageNumber - 1;
        var compiledWhere = whereClause == null
            ? new Func<SamplePerson, bool>(x => true)
            : whereClause.Compile();
        var compiledOrder = orderBy == null
            ? new Func<SamplePerson, object>(x => x.FirstName)
            : orderBy.Compile();
        var result = People
            .Where(x => compiledWhere(x))
            .OrderBy(x => compiledOrder(x))
            .Skip(pageIndex * pageSize)
            .Take(pageSize)
            .ToList();
        return new PagedResult<SamplePerson>(People.Count, pageNumber, pageSize)
        {
            Items = result
        };
    }

    protected override long GetTotalRecordCountImpl()
    {
        return People.Count;
    }

    protected override SamplePerson SaveImpl(SamplePerson entity)
    {
        if (string.IsNullOrWhiteSpace(entity.Id))
        {
            entity.Id = Guid.NewGuid().ToString("N");
        }
        People.Add(entity);
        return entity;
    }
}

And here's my bootstrap file:

using Fluidity.Configuration;

public class SampleBootstrap : FluidityConfigModule
{
    public override void Configure(FluidityConfig config)
    {
        config.AddSection("Fluidity Sample", sampleConfig =>
        {
            sampleConfig.SetTree("Sample Tree", treeConfig =>
            {
                treeConfig.AddCollection<SamplePerson>(x => x.Id, "Person", "People", "A collection of people", collConfig =>
                {
                    collConfig.SetRepositoryType<SampleRepo>();
                    collConfig.SetNameProperty(y => y.FirstName);
                    collConfig.SetViewMode(Fluidity.FluidityViewMode.List);
                    collConfig.ShowOnDashboard();
                    collConfig.ListView(listView =>
                    {
                        //listView.AddField(y => y.FirstName);
                        listView.AddField(y => y.LastName);
                    });

                    collConfig.Editor(editorConfig =>
                    {
                        editorConfig.AddTab("About", tab =>
                        {
                            //tab.AddField(y => y.FirstName).MakeRequired().SetDescription("The first name of this individual.");
                            tab.AddField(y => y.LastName).MakeRequired();
                        });
                        editorConfig.AddTab("Meta", tab =>
                        {
                            tab.AddField(y => y.Id).MakeReadOnly();
                        });
                    });
                });
            });
        });
    }
}

It mostly works. When I try to save a new entity, it bombs out, like this:

save-error

Not really sure what the issue could be. Maybe I've missed some config or maybe it's a Fluidity bug relating to custom repositories?

More info:

Nicholas-Westby commented 5 years ago

Looks like the problem is on this line:

save-entity

The portion that says entity.GetPropertyValue(collection.IdProperty) returns null, which makes sense, since this is a new entity and a new entity can have a null ID.

The .Equals( is then called on this null instance. Since .Equals is not an extension method, I imagine this is why it's bombing out (because you can't call methods on null instances).

My guess is that this is not usually an issue because you typically work with non-nullable value types (e.g., integer) for your ID types. I'm using a string as my ID in this case. So I imagine the fix is to ensure this works with nullable types by accounting for the possibility that the ID may be null.

Nicholas-Westby commented 5 years ago

Until the pull request gets merged, I created a weird workaround. I'll share it here for others in the same predicament.

The model class:

public class SamplePerson
{
    public SampleStringIdentifier Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

Repo class:

using Fluidity;
using Fluidity.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Umbraco.Core.Models;

public class SampleRepo : FluidityRepository<SamplePerson, SampleStringIdentifier>
{
    private static List<SamplePerson> People = new List<SamplePerson>()
    {
        new SamplePerson()
        {
            Id = new SampleStringIdentifier("Bob"),
            FirstName = "Bob",
            LastName = "Boberson"
        },
        new SamplePerson()
        {
            Id = new SampleStringIdentifier("Jane"),
            FirstName = "Jane",
            LastName = "Janerson"
        }
    };

    protected override void DeleteImpl(SampleStringIdentifier id)
    {
        People = People.Where(x => x.Id.Id != id.Id).ToList();
    }

    protected override IEnumerable<SamplePerson> GetAllImpl()
    {
        return People;
    }

    protected override SampleStringIdentifier GetIdImpl(SamplePerson entity)
    {
        return entity.Id;
    }

    protected override SamplePerson GetImpl(SampleStringIdentifier id)
    {
        return People.Where(x => x.Id.Id == id.Id).FirstOrDefault();
    }

    protected override PagedResult<SamplePerson> GetPagedImpl(int pageNumber, int pageSize,
        Expression<Func<SamplePerson, bool>> whereClause, Expression<Func<SamplePerson, object>> orderBy,
        SortDirection orderDirection)
    {
        var pageIndex = pageNumber - 1;
        var compiledWhere = whereClause == null
            ? new Func<SamplePerson, bool>(x => true)
            : whereClause.Compile();
        var compiledOrder = orderBy == null
            ? new Func<SamplePerson, object>(x => x.FirstName)
            : orderBy.Compile();
        var result = People
            .Where(x => compiledWhere(x))
            .OrderBy(x => compiledOrder(x))
            .Skip(pageIndex * pageSize)
            .Take(pageSize)
            .ToList();
        return new PagedResult<SamplePerson>(People.Count, pageNumber, pageSize)
        {
            Items = result
        };
    }

    protected override long GetTotalRecordCountImpl()
    {
        return People.Count;
    }

    protected override SamplePerson SaveImpl(SamplePerson entity)
    {
        if (string.IsNullOrWhiteSpace(entity.Id.Id))
        {
            entity.Id = new SampleStringIdentifier(Guid.NewGuid().ToString("N"));
            People.Add(entity);
        }
        else
        {
            People = People.Select(x => x.Id.Id == entity.Id.Id ? entity : x).ToList();
        }
        return entity;
    }
}

Bootstrap file:

using Fluidity.Configuration;

public class SampleBootstrap : FluidityConfigModule
{
    public override void Configure(FluidityConfig config)
    {
        config.AddSection("Fluidity Sample", sampleConfig =>
        {
            sampleConfig.SetTree("Sample Tree", treeConfig =>
            {
                treeConfig.AddCollection<SamplePerson>(x => x.Id, "Person", "People", "A collection of people", collConfig =>
                {
                    collConfig.SetRepositoryType<SampleRepo>();
                    collConfig.SetNameProperty(y => y.FirstName);
                    collConfig.SetViewMode(Fluidity.FluidityViewMode.List);
                    collConfig.ShowOnDashboard();
                    collConfig.ListView(listView =>
                    {
                        //listView.AddField(y => y.FirstName);
                        listView.AddField(y => y.LastName);
                    });

                    collConfig.Editor(editorConfig =>
                    {
                        editorConfig.AddTab("About", tab =>
                        {
                            //tab.AddField(y => y.FirstName).MakeRequired().SetDescription("The first name of this individual.");
                            tab.AddField(y => y.LastName).MakeRequired();
                        });
                        editorConfig.AddTab("Meta", tab =>
                        {
                            tab.AddField(y => y.Id).MakeReadOnly();
                        });
                    });
                });
            });
        });
    }
}

New identifier struct:

[TypeConverter(typeof(SampleStringIdentifierConverter))]
[Serializable]
public struct SampleStringIdentifier
{
    public string Id { get; set; }
    public SampleStringIdentifier(string id)
    {
        Id = id;
    }
    public override string ToString()
    {
        return Id ?? string.Empty;
    }
}

Type converter for identifier struct:

using System;
using System.ComponentModel;
using System.Globalization;

public class SampleStringIdentifierConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType == typeof(string);
    }
    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value is string)
        {
            if (string.IsNullOrWhiteSpace(value as string))
            {
                return default(SampleStringIdentifier);
            }
            else
            {
                return new SampleStringIdentifier(value as string);
            }
        }
        return base.ConvertFrom(context, culture, value);
    }
}

Here's what I had to do:

Pretty wonky. Looking forward to the next release.

mattbrailsford commented 5 years ago

Should be fixed in 690f30a where I've swapped it to

var isNew = Equals(entity.GetPropertyValue(collection.IdProperty), collection.IdProperty.Type.GetDefaultValue());