dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.22k stars 4.72k forks source link

[API Proposal]: Have AllowedValuesAttribute and DeniedValuesAttribute support case-insensitive strings #106806

Open rjperes opened 2 months ago

rjperes commented 2 months ago

Background and motivation

One of the most common uses of AllowedValues/DeniedValues is with strings, however, they are checked case-sensitive, and we probably want to allow case-insensitive comparisons too. Maybe a case of adding an extra optional constructor argument of type StringComparison, which, of course, would only apply to string values.

API Proposal

namespace System.ComponentModel.DataAnnotations;

public class AllowedValuesAttribute : ValidationAttribute
{
    public AllowedValuesAttribute(StringComparison comparer, params string?[] values) : this(values.OfType<object>().ToArray())
    {
    }

    public AllowedValuesAttribute(params string?[] values) : this(StringComparison.CurrentCulture, values)
    {
    }
}

public class DeniedValuesAttribute : ValidationAttribute
{
    public DeniedValuesAttribute(StringComparison comparer, params string?[] values) : this(values.OfType<object>().ToArray())
    {
    }

    public DeniedValuesAttribute(params string?[] values) : this(StringComparison.CurrentCulture, values)
    {
    }
}

API Usage

[AllowedValues(StringComparison.InvariantCultureIgnoreCase, "red", "green", "blue")]
public string Color { get; set; }

[DeniedValues("a", "e", "i", "o", "u")]
public string Consonants { get; set; }

Alternative Designs

No response

Risks

No response

dotnet-policy-service[bot] commented 2 months ago

Tagging subscribers to this area: @dotnet/area-system-componentmodel-dataannotations See info in area-owners.md if you want to be subscribed.

rjperes commented 2 months ago

Not really, it’s actually an enum…


De: Juliano Leal Goncalves @.> Enviado: Thursday, August 22, 2024 3:20:36 PM Para: dotnet/runtime @.> Cc: Ricardo Peres @.>; Author @.> Assunto: Re: [dotnet/runtime] [API Proposal]: Have AllowedValuesAttribute and DeniedValuesAttribute support case-insensitive strings (Issue #106806)

[AllowedValues(“red”, “green”, “blue”, comparer: StringComparison.InvariantCultureIgnoreCase)]

Pretty sure this will not compile since StringComparison.InvariantCultureIgnoreCase is not a const value (it is static).

Attributes only allow consts to be passed into them.

— Reply to this email directly, view it on GitHubhttps://github.com/dotnet/runtime/issues/106806#issuecomment-2304798467, or unsubscribehttps://github.com/notifications/unsubscribe-auth/AABG5SNRZG6MBWM62FLCDH3ZSXXTJAVCNFSM6AAAAABM534R5GVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDGMBUG44TQNBWG4. You are receiving this because you authored the thread.Message ID: @.***>

julealgon commented 2 months ago

Not really, it’s actually an enum…

Which is why I deleted my post right after posting @rjperes .

eiriktsarpalis commented 2 months ago

The attributes don't only work with strings, so I'm not sure what should happen in the following scenario:

[AllowedValues(null, false, 1, "value", comparer: StringComparison.OrdinalIgnoreCase)]
public object? Values { get; set; }

This might sound like a niche example, but from a maintainer's perspective we need to make sure this doesn't result in unexpected behavior or unexpected exceptions. A more general-purpose solution might involve accepting an IEqualityComparer parameter instead (although granted you can't inline a StringComparer value in an attribute annotations, you would still need to subclass the attribute to support case-insensitive string values).

rjperes commented 2 months ago

As I mentioned in my example, this would apply to strings only, for all other cases this new parameter should be ignored. I think this is important because by large, in my use cases, I need to compare values case-insensitive.

eiriktsarpalis commented 2 months ago

As I mentioned in my example, this would apply to strings only, for all other cases this new parameter should be ignored.

Silently ignoring configuration parameters on the basis of input type is not a workable design, and is not consistent with how the attribute currently works or how similar APIs dealing with customized equality work (c.f. LINQ overloads accepting IEqualityComparer<T>). If we did consider this, it would need to accept an IEqualityComparer parameter which could then be subclassed.

rjperes commented 2 months ago

Or, provide a constructor overload that takes strings and the StringComparison parameter.

eiriktsarpalis commented 2 months ago

I like that idea. Could you update the proposal to reflect that?

eiriktsarpalis commented 2 months ago

Another issue with the proposal as it stands is that params must always be the last parameter so the constructor should look as follows:

public AllowedValuesAttribute(StringComparison comparer, params string?[] values);

Which would be invoked as follows:

[AllowValues(StringComparison.OrdinalIgnoreCase, "x", "y")]
rjperes commented 2 months ago

Will do! Would a PR also be useful?

eiriktsarpalis commented 2 months ago

Not yet, this would need to go through API review first, although providing a prototype branch with working unit tests should provide a useful proof of concept.

rjperes commented 2 months ago

Proposed changes for AllowedValuesAttribute:

public class AllowedValuesAttribute : ValidationAttribute
{
    private readonly StringComparison? _comparer;

    public AllowedValuesAttribute(StringComparison comparer, params string?[] values) : this(values.OfType<object>().ToArray())
    {
        _comparer = comparer;
    }

    public AllowedValuesAttribute(params string?[] values) : this(StringComparison.CurrentCulture, values)
    {
    }

    public override bool IsValid(object? value)
    {
        foreach (object? allowed in Values)
        {
            if (allowed is null ? value is null : Compare(allowed, value))
            {
                return true;
            }
        }

        return false;
    }

    private bool Compare(object allowed, object? value)
    {
        if (_comparer != null)
        {
            return string.Equals(allowed as string, value as string, _comparer.Value);
        }

        return allowed.Equals(value);
    }
}

The only way _comparer can be set is by using one of the constructors that take strings. Added an overload without StringComparison to allow skipping this parameter while supplying only strings.