TrackableEntities / EntityFrameworkCore.Scaffolding.Handlebars

Scaffold EF Core models using Handlebars templates.
MIT License
210 stars 53 forks source link

Data Annotations for non-nullable fields include "AllowEmptyStrings" attribute? #77

Closed tonysneed closed 5 years ago

tonysneed commented 5 years ago

Continuation of this issue.

tonysneed commented 5 years ago

@netbitshift The first thing you need to do is create a class in your project that inherits from HbsCSharpEntityTypeGenerator and overrides GeneratePropertyDataAnnotations, customizing the RequiredAttribute that is inserted by EF Core scaffolding.

Then you will add a ScaffoldingDesignTimeServices class that implements IDesignTimeServices in which you call services.AddHandlebarsScaffolding, followed by the registration of your custom HbsCSharpEntityTypeGenerator:

services.AddSingleton<ICSharpEntityTypeGenerator, MyHbsCSharpEntityTypeGenerator>();

MyCSharpEntityTypeGenerator is a bit involved, but not too complicated. Here is the code.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using EntityFrameworkCore.Scaffolding.Handlebars;
using EntityFrameworkCore.Scaffolding.Handlebars.Internal;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal;
using Microsoft.EntityFrameworkCore.Metadata.Internal;

namespace MyHandlebarsScaffoldingExtensions
{
    public class MyHbsCSharpEntityTypeGenerator : HbsCSharpEntityTypeGenerator
    {
        public MyHbsCSharpEntityTypeGenerator(
            IEntityTypeTemplateService entityTypeTemplateService,
            IEntityTypeTransformationService entityTypeTransformationService,
            ICSharpHelper cSharpHelper) : base(entityTypeTemplateService, entityTypeTransformationService, cSharpHelper)
        {
        }

        protected override void GeneratePropertyDataAnnotations(IProperty property)
        {
            if (property == null) throw new ArgumentNullException(nameof(property));

            GenerateKeyAttribute(property);
            GenerateRequiredAttribute(property);
            GenerateColumnAttribute(property);
            GenerateMaxLengthAttribute(property);
        }

        private void GenerateRequiredAttribute(IProperty property)
        {
            if (!property.IsNullable
                && property.ClrType.IsNullableType()
                && !property.IsPrimaryKey())
            {
                // Add RequiredAttribute with parameter
                var requiredAttribute = new AttributeWriter(nameof(RequiredAttribute));
                requiredAttribute.AddParameter("AllowEmptyStrings=true");
                PropertyAnnotationsData.Add(new Dictionary<string, object>
                {
                    { "property-annotation", requiredAttribute.ToString()},
                });
            }
        }

        private void GenerateKeyAttribute(IProperty property)
        {
            var key = property.AsProperty().PrimaryKey;

            if (key?.Properties.Count == 1)
            {
                if (key is Key concreteKey
                    && key.Properties.SequenceEqual(new KeyDiscoveryConvention(null).DiscoverKeyProperties(concreteKey.DeclaringEntityType, concreteKey.DeclaringEntityType.GetProperties().ToList())))
                {
                    return;
                }

                if (key.Relational().Name != ConstraintNamer.GetDefaultName(key))
                {
                    return;
                }

                PropertyAnnotationsData.Add(new Dictionary<string, object>
                {
                    { "property-annotation", new AttributeWriter(nameof(KeyAttribute)) },
                });
            }
        }

        private void GenerateColumnAttribute(IProperty property)
        {
            var columnName = property.Relational().ColumnName;
            var columnType = property.GetConfiguredColumnType();

            var delimitedColumnName = columnName != null && columnName != property.Name ? CSharpHelper.Literal(columnName) : null;
            var delimitedColumnType = columnType != null ? CSharpHelper.Literal(columnType) : null;

            if ((delimitedColumnName ?? delimitedColumnType) != null)
            {
                var columnAttribute = new AttributeWriter(nameof(ColumnAttribute));

                if (delimitedColumnName != null)
                {
                    columnAttribute.AddParameter(delimitedColumnName);
                }

                if (delimitedColumnType != null)
                {
                    columnAttribute.AddParameter($"{nameof(ColumnAttribute.TypeName)} = {delimitedColumnType}");
                }

                PropertyAnnotationsData.Add(new Dictionary<string, object>
                {
                    { "property-annotation", columnAttribute },
                });
            }
        }

        private void GenerateMaxLengthAttribute(IProperty property)
        {
            var maxLength = property.GetMaxLength();

            if (maxLength.HasValue)
            {
                var lengthAttribute = new AttributeWriter(
                    property.ClrType == typeof(string)
                        ? nameof(StringLengthAttribute)
                        : nameof(MaxLengthAttribute));

                lengthAttribute.AddParameter(CSharpHelper.Literal(maxLength.Value));

                PropertyAnnotationsData.Add(new Dictionary<string, object>
                {
                    { "property-annotation", lengthAttribute.ToString() },
                });
            }
        }

        private class AttributeWriter
        {
            private readonly string _attibuteName;
            private readonly List<string> _parameters = new List<string>();

            public AttributeWriter(string attributeName)
            {
                _attibuteName = attributeName ?? throw new ArgumentNullException(nameof(attributeName));
            }

            public void AddParameter(string parameter)
            {
                if (parameter == null) throw new ArgumentNullException(nameof(parameter));

                _parameters.Add(parameter);
            }

            public override string ToString()
                => "[" + (_parameters.Count == 0
                       ? StripAttribute(_attibuteName)
                       : StripAttribute(_attibuteName) + "(" + string.Join(", ", _parameters) + ")") + "]";

            private static string StripAttribute(string attributeName)
            {
                if (attributeName == null) throw new ArgumentNullException(nameof(attributeName));
                return attributeName.EndsWith("Attribute", StringComparison.Ordinal)
                    ? attributeName.Substring(0, attributeName.Length - 9)
                    : attributeName;
            }
        }
    }
}

The most interesting part for you would be the GenerateRequiredAttribute method, in which you add the desired parameter.

The ScaffoldingDesignTimeServices class looks like this.

public class ScaffoldingDesignTimeServices : IDesignTimeServices
{
    public void ConfigureDesignTimeServices(IServiceCollection services)
    {
        // Add Handlebars scaffolding templates
        services.AddHandlebarsScaffolding();

        // Add custom data annotations
        services.AddSingleton<ICSharpEntityTypeGenerator, MyHbsCSharpEntityTypeGenerator>();
    }
}

The sad part of this story is that, for this to work, you need to perform scaffolding from the command line using dotnet ef context scaffold, rather than the nice GUI of the EF Core Power Tools by @ErikEJ.

netbitshift commented 5 years ago

hmmm, well at least its possible, but our workflow needs to stay within the use of the ef powertools ui and/or the handlebar templates

ErikEJ commented 5 years ago

I suggest you raise an issue in the EF Core repository, I have had several PRs to scaffolding improvements accepted recently.

tonysneed commented 5 years ago

👍for @ErikEJ suggestion to see if the built-in EF Core scaffolding lets you specify the parameter you need.

If not, there are two ways you can use the Handlebars templates. One way is with the EF Core Power Tools, but you will not be able to customize generation at the code level or use Handlebars helpers. The other way is to generate the entities by running dotnet ef context scaffold from the command-line, which is definitely more tedious than the UI approach, but it's possible.

netbitshift commented 5 years ago

Yes @ErikEJ - This does seem more like a EF core issue rather than a tool issue and Yes @tonysneed , we were using the command line before we started using powertools.

All this because as is often the case, one or more fields in the Db are marked "not null" but we still want to allow a record to be generated, so those fields where allowed would get an empty string.

It's really sort of a data issue. If you require a given field of a record to be provided to complete a valid record, then NEITHER empty string or null should be allowed, but i digress.