powershelloperator-overloadingcase-sensitive

How to implement a case-sensitive `Equals` for operators as `-ceq`


An Equals method on a specific class might be used to implement a PowerShell operator as -eq:

class TestClass {
    hidden [String] $_Value
    TestClass([String]$Value) { $this._Value = $Value }
    [Bool] Equals($Test, [StringComparison]$StringComparison) {
        return $this._Value.Equals([String]$Test._Value, $StringComparison)
    }
    [Bool] Equals($Test) {
        return $this.Equals($Test, [StringComparison]::CurrentCultureIgnoreCase)
    }
}

$a1Lower = [TestClass]'a'
$b1Lower = [TestClass]'b'
$a2Lower = [TestClass]'a'
$a2Upper = [TestClass]'A'

$a1Lower -eq  $b1Lower # False
$a1Lower -eq  $a2Lower # True
$a1Lower -eq  $a2Upper # True

Is it also possible to define a method for case sensitive operators such as -ceq, so that I also might do something like:

$a1Lower -ceq $a2Lower # True
$a1Lower -ceq $a2Upper # True (expected False)

Solution

  • I don't think you can, -ceq is a pure PowerShell implementation. The closest I can think of is to instantiate your classes using a StringComparer, here you could either have 2 .ctor overloads or a factory method to create instances that are case-sensitive (demo shows the factory method approach). You should also consider overriding the GetHashCode() method so your class works well on data types that use it.

    class TestClass {
        hidden [String] $_Value
    
        [System.StringComparer] $Comparer =
            [System.StringComparer]::InvariantCultureIgnoreCase
    
        TestClass([String] $Value) {
            $this._Value = $Value
        }
    
        static [TestClass] CreateCaseSensitive([string] $Value) {
            $test = [TestClass]::new($Value)
            $test.Comparer = [System.StringComparer]::InvariantCulture
            return $test
        }
    
        [Bool] Equals($That) {
            if ($testClass = $That -as [TestClass]) {
                return $this.Comparer.Equals($this._Value, $testClass._Value)
            }
    
            throw [System.ArgumentException] 'nope...'
        }
    
        [int] GetHashCode() {
            return $this.Comparer.GetHashCode($this._Value)
        }
    }
    
    [TestClass] 'a' -eq 'A'                       # True
    [TestClass]::CreateCaseSensitive('a') -eq 'A' # False
    
    $set = [System.Collections.Generic.HashSet[TestClass]]::new()
    $set.Add('a') # true
    $set.Add('A') # false
    $set.Add([TestClass]::CreateCaseSensitive('A')) # true
    

    If you're curious how does it translates to C# when you use -ceq, you can have a look at the ScriptBlockDisassembler Module:

    {
        $a1 = [TestClass]::new('a')
        $a2 = [TestClass]::new('A')
        $a1 -ceq $a2
    } | Get-ScriptBlockDisassembly -Minimal
    

    As you can see, the expression for equality comparison is created from PSBinaryOperationBinder.Get, correctly, using the argument ignoreCase: false which seem to be completely ignored during the comparison. From here, you can also check DynamicMetaObject BinaryEqualityComparison(DynamicMetaObject target, DynamicMetaObject arg), where you can see checks for .LimitType being of type char or string, otherwise ignored and cast to object which explains why -ceq is completely ignored.

    // ScriptBlock.EndBlock
    try
    {
        locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(
            PSVariableAssignmentBinder.Get())(
                Fake.Dynamic<Func<CallSite, Type, string, object>>(
                    PSCreateInstanceBinder.Get(
                        new CallInfo(1),
                        PSMethodInvocationConstraints.Default, publicTypeOnly: true))(
                        Fake.Const<Type>(typeof(TestClass)), "a"));
    
        locals.Item010 = Fake.Dynamic<Func<CallSite, object, object>>(
            PSVariableAssignmentBinder.Get())(
                Fake.Dynamic<Func<CallSite, Type, string, object>>(
                    PSCreateInstanceBinder.Get(
                        new CallInfo(1),
                        PSMethodInvocationConstraints.Default, publicTypeOnly: true))(
                        Fake.Const<Type>(typeof(TestClass)), "A"));
    
        Fake.Dynamic<Action<CallSite, object, Pipe, ExecutionContext>>(
            PSPipeWriterBinder.Get())(
                Fake.Dynamic<Func<CallSite, object, object, object>>(
                    PSBinaryOperationBinder.Get(
                        ExpressionType.Equal, ignoreCase: false, scalarCompare: false))
                (locals.Item009, locals.Item010),
                funcContext._outputPipe,
                context);
    }
    catch (FlowControlException)
    {
        throw;
    }
    catch (Exception exception)
    {
        ExceptionHandlingOps.CheckActionPreference(funcContext, exception);
    }