vkhorikov / CSharpFunctionalExtensions

Functional extensions for C#
MIT License
2.42k stars 303 forks source link

Functor Applicatives => equivalent of the `Validation` type and the `<!>` and `<*>` operators #177

Open natalie-o-perret opened 4 years ago

natalie-o-perret commented 4 years ago

I would like to translate the F# code below which uses the Validation type with the <!> and <*> operators from FSharpPlus:

type Field =
    { Name: string
      Value: string }

[<AutoOpen>]
module private Impl =

    let private defaultDataSourceLoadOptions = Unchecked.defaultof<DataSourceLoadOptionsBase>

    [<Literal>]
    let requireTotalCountKey = nameof(defaultDataSourceLoadOptions.RequireTotalCount)

    [<Literal>]
    let requireGroupCountKey = nameof(defaultDataSourceLoadOptions.RequireGroupCount)

    [<Literal>]
    let isCountQueryKey = nameof(defaultDataSourceLoadOptions.IsCountQuery)

    [<Literal>]
    let skipKey = nameof(defaultDataSourceLoadOptions.Skip)

    [<Literal>]
    let takeKey = nameof(defaultDataSourceLoadOptions.Take)

    [<Literal>]
    let sortKey = nameof(defaultDataSourceLoadOptions.Sort)

    [<Literal>]
    let groupKey = nameof(defaultDataSourceLoadOptions.Group)

    [<Literal>]
    let filterKey = nameof(defaultDataSourceLoadOptions.Filter)

    [<Literal>]
    let totalSummaryKey = nameof(defaultDataSourceLoadOptions.TotalSummary)

    [<Literal>]
    let groupSummaryKey = nameof(defaultDataSourceLoadOptions.GroupSummary)

    [<Literal>]
    let selectKey = nameof(defaultDataSourceLoadOptions.Select)

    let getValue key (bindingContext: ModelBindingContext) =
        bindingContext.ValueProvider.GetValue(key).FirstOrDefault()

    let validatePrimitiveString (tryParse: string -> (bool * 'T)) fieldName bindingContext =
        let fieldValue = getValue fieldName bindingContext
        match String.IsNullOrEmpty fieldValue, tryParse fieldValue with
        | true, _ ->
            Success Unchecked.defaultof<'T>
        | false, (true, value) ->
            Success value
        | false, _ ->
            Failure [ { Name = fieldName; Value = fieldValue } ]

    let validateInt32String fieldName bindingContext =
        validatePrimitiveString Int32.TryParse fieldName bindingContext

    let validateBoolString fieldName bindingContext =
        validatePrimitiveString Boolean.TryParse fieldName bindingContext

    let validateJsonString (deserialize: string -> 'T) fieldName bindingContext =
        let fieldValue = getValue fieldName bindingContext
        if String.IsNullOrEmpty fieldValue then
            Success Unchecked.defaultof<'T>
        else
            try
                Success (deserialize fieldValue)
            with _ ->
                Failure [ { Name = fieldName; Value = fieldValue } ]

    let validateSortingInfoString fieldName bindingContext =
        validateJsonString JsonConvert.DeserializeObject<SortingInfo[]> fieldName bindingContext

    let validateGroupingInfoString fieldName bindingContext =
        validateJsonString JsonConvert.DeserializeObject<GroupingInfo[]> fieldName bindingContext

    let validateFilterString fieldName bindingContext =
        let jsonSettings = JsonSerializerSettings()
        jsonSettings.DateParseHandling <- DateParseHandling.None
        validateJsonString
            (fun fieldValue -> JsonConvert.DeserializeObject<IList>(fieldValue, jsonSettings))
            fieldName
            bindingContext

    let validateSummaryInfoString fieldName bindingContext =
        validateJsonString JsonConvert.DeserializeObject<SummaryInfo[]> fieldName bindingContext

    let validateStringArrayString fieldName bindingContext =
        validateJsonString JsonConvert.DeserializeObject<String[]> fieldName bindingContext

[<ModelBinder(BinderType = typeof<DataSourceLoadOptionsBinder>)>]
type DataSourceLoadOptions() =
    inherit DataSourceLoadOptionsBase()

and DataSourceLoadOptionsBinder() =

    let createLoadOptions 
            requireTotalCount
            requireGroupCount
            isCountQuery
            skip
            take
            sort
            group
            filter
            totalSummary
            groupSummary
            select =
                let loadOptions = DataSourceLoadOptions()
                loadOptions.RequireTotalCount <- requireTotalCount
                loadOptions.RequireGroupCount <- requireGroupCount
                loadOptions.IsCountQuery <- isCountQuery
                loadOptions.Skip <- skip
                loadOptions.Take <- take
                loadOptions.Sort <- sort
                loadOptions.Group <- group
                loadOptions.Filter <- filter
                loadOptions.TotalSummary <- totalSummary
                loadOptions.GroupSummary <- groupSummary
                loadOptions.Select <- select
                loadOptions

    let validateBindingContext bindingContext =
         createLoadOptions
         <!> validateBoolString requireTotalCountKey bindingContext 
         <*> validateBoolString requireGroupCountKey bindingContext
         <*> validateBoolString isCountQueryKey bindingContext
         <*> validateInt32String skipKey bindingContext
         <*> validateInt32String takeKey bindingContext 
         <*> validateSortingInfoString sortKey bindingContext
         <*> validateGroupingInfoString groupKey bindingContext
         <*> validateFilterString filterKey bindingContext
         <*> validateSummaryInfoString totalSummaryKey bindingContext
         <*> validateSummaryInfoString groupSummaryKey bindingContext
         <*> validateStringArrayString selectKey bindingContext

    interface IModelBinder with

        member this.BindModelAsync(bindingContext: ModelBindingContext) =
            match validateBindingContext bindingContext with
            | Success loadOptions ->
                bindingContext.Result <- ModelBindingResult.Success(loadOptions)
            | Failure errors ->
                bindingContext.Result <- ModelBindingResult.Failed()
                errors
                |> List.iter(fun x -> bindingContext.ModelState.AddModelError(x.Name, sprintf @"'%s' is not a valid value."x.Value))
            Task.CompletedTask

I tried to get inspired by the content available here

But I ended up re-creating everything from scratch and I am wondering if there is anything builtin that supports the Validation type with the <!> and <*> operators (aka functor applicatives) in your library Functional Extensions for C#?

natalie-o-perret commented 4 years ago

Just to give you a preview of how it looks like for me now in C#, aka verbose++:

[ModelBinder(BinderType = typeof(DataSourceLoadOptionsBinder))]
public class DataSourceLoadOptions : DataSourceLoadOptionsBase
{
}

internal static class DataSourceLoadOptionsBaseFieldNames
{
    private static readonly DataSourceLoadOptionsBase DefaultDataSourceLoadOptions = default;

    public const string RequireTotalCountKey = nameof(DefaultDataSourceLoadOptions.RequireTotalCount);
    public const string RequireGroupCountKey = nameof(DefaultDataSourceLoadOptions.RequireGroupCount);
    public const string IsCountQueryKey = nameof(DefaultDataSourceLoadOptions.IsCountQuery);
    public const string SkipKey = nameof(DefaultDataSourceLoadOptions.Skip);
    public const string TakeKey = nameof(DefaultDataSourceLoadOptions.Take);
    public const string SortKey = nameof(DefaultDataSourceLoadOptions.Sort);
    public const string GroupKey = nameof(DefaultDataSourceLoadOptions.Group);
    public const string FilterKey = nameof(DefaultDataSourceLoadOptions.Filter);
    public const string TotalSummaryKey = nameof(DefaultDataSourceLoadOptions.TotalSummary);
    public const string GroupSummaryKey = nameof(DefaultDataSourceLoadOptions.GroupSummary);
    public const string SelectKey = nameof(DefaultDataSourceLoadOptions.Select);
}

public class FieldError
{
    public string Name { get; set; }
    public string Value { get; set; }
}

public static class DataSourceLoadOptionsParser
{
    private static string GetFieldStringValue(ModelBindingContext modelBindingContext, string fieldName)
        => modelBindingContext.ValueProvider.GetValue(fieldName).FirstOrDefault();

    private static Result<T, FieldError> ValidatePrimitiveString<T>(
        ModelBindingContext modelBindingContext,
        string fieldName,
        Func<string, (bool IsValid, T Value)> tryParse)
    {
        var fieldStringValue = GetFieldStringValue(modelBindingContext, fieldName);
        if (string.IsNullOrEmpty(fieldStringValue))
        {
            return Result.Success<T, FieldError>(default);
        }

        var (isValid, value) = tryParse(fieldStringValue);

        return (isValid, value) switch 
        {
            (true, _) => 
                Result.Success<T, FieldError>(value),
            (false, _) => 
                Result.Failure<T, FieldError>(new FieldError { Name = fieldName, Value = fieldStringValue })
        };
    }

    private static Result<int, FieldError> ValidateInt32String(
        ModelBindingContext modelBindingContext,
        string fieldName) =>
        ValidatePrimitiveString(modelBindingContext, fieldName, str =>
        {
            var res = Int32.TryParse(str, out var value);
            return (res, value);
        });

    private static Result<bool, FieldError> ValidateBoolString(
        ModelBindingContext modelBindingContext,
        string fieldName) =>
        ValidatePrimitiveString(modelBindingContext, fieldName, str =>
        {
            var res = Boolean.TryParse(str, out var value);
            return (res, value);
        });

    private static Result<T,FieldError> ValidateJsonString<T>(
        ModelBindingContext modelBindingContext,
        string fieldName,
        Func<string, T> deserialize)
    {
        var fieldStringValue = GetFieldStringValue(modelBindingContext, fieldName);
        if (string.IsNullOrEmpty(fieldStringValue))
        {
            return Result.Success<T, FieldError>(default);
        }

        try
        {
            return Result.Success<T, FieldError>(deserialize(fieldStringValue));
        }
        catch
        {
            return Result.Failure<T, FieldError>(new FieldError { Name = fieldName, Value = fieldStringValue } );
        }
    }

    private static Result<SortingInfo[], FieldError> ValidateSortingInfoString (
        ModelBindingContext modelBindingContext, 
        string fieldName) =>
        ValidateJsonString(modelBindingContext, fieldName, JsonConvert.DeserializeObject<SortingInfo[]>);

    private static Result<GroupingInfo[],FieldError> ValidateGroupingInfoString (
        ModelBindingContext modelBindingContext, 
        string fieldName) =>
        ValidateJsonString(modelBindingContext, fieldName, JsonConvert.DeserializeObject<GroupingInfo[]>);

    private static Result<IList, FieldError> ValidateFilterString(
        ModelBindingContext modelBindingContext, 
        string fieldName) =>
        ValidateJsonString(modelBindingContext, fieldName, str => 
            JsonConvert.DeserializeObject<IList>(
                str, 
                new JsonSerializerSettings { DateParseHandling = DateParseHandling.None}));

    private static Result<SummaryInfo[], FieldError> ValidateSummaryInfoString(
        ModelBindingContext modelBindingContext, 
        string fieldName) =>
        ValidateJsonString(modelBindingContext, fieldName, JsonConvert.DeserializeObject<SummaryInfo[]>);

    private static Result<string[], FieldError> ValidateStringArrayString(
        ModelBindingContext modelBindingContext, 
        string fieldName) =>
        ValidateJsonString(modelBindingContext, fieldName, JsonConvert.DeserializeObject<string[]>);

    public static Result<DataSourceLoadOptions, IList<FieldError>> Parse(ModelBindingContext modelBindingContext)
    {
        var requireTotalCount = ValidateBoolString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.RequireTotalCountKey);
        var requireGroupCount = ValidateBoolString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.RequireGroupCountKey);
        var isCountQuery = ValidateBoolString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.IsCountQueryKey);
        var skip = ValidateInt32String(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.SkipKey);
        var take = ValidateInt32String(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.TakeKey);
        var sort = ValidateSortingInfoString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.SortKey);
        var group = ValidateGroupingInfoString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.GroupKey);
        var filter = ValidateFilterString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.FilterKey);
        var totalSummary = ValidateSummaryInfoString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.TotalSummaryKey);
        var groupSummary = ValidateSummaryInfoString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.GroupSummaryKey);
        var select = ValidateStringArrayString(modelBindingContext, DataSourceLoadOptionsBaseFieldNames.SelectKey);

        var failures = new List<FieldError>();

        if (requireGroupCount.IsFailure)
        {
            failures.Add(requireGroupCount.Error);
        }
        if (requireTotalCount.IsFailure)
        {
            failures.Add(requireTotalCount.Error);
        }
        if (isCountQuery.IsFailure)
        {
            failures.Add(isCountQuery.Error);
        }
        if (skip.IsFailure)
        {
            failures.Add(skip.Error);
        }
        if (take.IsFailure)
        {
            failures.Add(take.Error);
        }
        if (sort.IsFailure)
        {
            failures.Add(sort.Error);
        }
        if (group.IsFailure)
        {
            failures.Add(group.Error);
        }
        if (filter.IsFailure)
        {
            failures.Add(filter.Error);
        }
        if (totalSummary.IsFailure)
        {
            failures.Add(totalSummary.Error);
        }
        if (groupSummary.IsFailure)
        {
            failures.Add(groupSummary.Error);
        }
        if (select.IsFailure)
        {
            failures.Add(select.Error);
        }

        if (failures.Any())
        {
            return Result.Failure<DataSourceLoadOptions, IList<FieldError>>(failures);
        }

        var loadOptions = new DataSourceLoadOptions
        {
            RequireTotalCount = requireTotalCount.Value,
            RequireGroupCount = requireGroupCount.Value,
            IsCountQuery = isCountQuery.Value,
            Skip = skip.Value,
            Take = take.Value,
            Sort = sort.Value,
            Group = group.Value,
            Filter = filter.Value,
            TotalSummary = totalSummary.Value,
            GroupSummary = groupSummary.Value,
            Select = select.Value
        };

        return Result.Success<DataSourceLoadOptions, IList<FieldError>>(loadOptions);
    }
}

public class DataSourceLoadOptionsBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var result = DataSourceLoadOptionsParser.Parse(bindingContext);
        result.Match(
            loadOptions =>
            {
                bindingContext.Result = ModelBindingResult.Success(loadOptions);
            }, failures =>
            {
                bindingContext.Result = ModelBindingResult.Failed();
                foreach (var failure in failures)
                {
                    bindingContext.ModelState.AddModelError(
                        failure.Name,
                        $"'{failure.Value}' is not a valid value.");
                }
            });
        return Task.CompletedTask;
    }
}