internal/classes/other/FilterBuilder.ps1

class FilterBuilder {
    [System.Collections.ArrayList]$Entries = @()
    [System.Collections.ArrayList]$CustomFilter = @()

    [ValidateSet('AND','OR')][string]$Logic = 'AND'

    FilterBuilder()    { }
    FilterBuilder([string]$Logic) {
        $this.Logic = $Logic
    }

    [void]Add([string]$Property, [string]$Operator, $Value) {
        $this.Add($Property, $Operator, $Value, $false)
    }
    [void]Add([string]$Property, [string]$Operator, $Value, [bool]$NoQuotes) {
        $null = $this.Entries.Add(
            @{
                Property = $Property
                Operator = $Operator
                Value    = $Value
                NoQuotes = $NoQuotes
            }
        )
    }
    [void]Add([string]$CustomFilter) {
        $null = $this.Entries.Add($CustomFilter)
    }
    [void]Add([FilterBuilder]$NestedFilter) {
        $null = $this.Entries.Add($NestedFilter)
    }

    [int]Count() {
        $myCount = $this.Entries.Count
        if ($this.CustomFilter) { $myCount += $this.CustomFilter.Count }
        return $myCount
    }
    [string]GetHelp() {
        return @'
OData Filter Builder Guidance

This tool _mostly_ maps / implements the OData filter system, as adapted by the Microsoft Graph API.
It may be relevant to any other API supporting OData filters, but that's what it was built for.

Filter Docs:
https://learn.microsoft.com/en-us/graph/filter-query-parameter

Adding Filter Conditions:
There are two ways to provide filter conditions:

A) .Add(property, operator, value)
The .Add method adds individual filter clauses, simple comparisons or some special behaviors.
For these special rules, see below.
But any operator that has a simple "<property> <operator> <value>" from this list should be valid:
https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions
Strings need not be provided in quotes.

B) .Add(customFilter)
Specifying a single string allows providing custom filter terms/expressions.
This can be any valid fragment of OData filter, giving you greater control ... but requires you to write the filter yourself.

All conditions from A) and B) are finally combined with an AND condition.


Special Rules:

eq
The "eq" Operator is converted into a 'startswith' or 'endswith' operator, depending on wildcard use.

leq
The "leq" Operator is not an actual OData operator, but was added by this tool.
It means "Literal Equals" and is converted into an "eq" operator, but without automatic conversion to 'startswith' or 'endswith'.

any
The "any" Operator is the OData equivalent to the PowerShell -contains.

none
The "none" fake-Operator is the OData equivalent to the PowerShell -notcontains.
It translates into an OData all(...) logic with the "ne" operator applied.
'@

    }
    [string]Get() {
        $segments = :entries foreach ($entry in $this.Entries) {
            # Nested Filters
            if ($entry -is [FilterBuilder]) {
                '(' + $entry.Get() + ')'
                continue
            }
            # Custom Filters
            if ($entry -is [string]) {
                $entry
                continue
            }


            $quotes = "'"
            if ($entry.NoQuotes) { $quotes = "" }

            $valueString = $entry.Value -as [string]
            if ($null -eq $entry.Value) { $valueString = "null" }
            if (
                $entry.Value -is [string] -or
                $entry.Value -is [guid]
            ) {
                $valueString = "$($quotes)$($entry.Value)$($quotes)"
            }
            if ($entry.Value -is [DateTime]) {
                $valueString = $entry.Value.ToString('u') -replace ' ', 'T'
            }

            switch ($entry.Operator) {
                'eq' {
                    # Case: eq with Wildcard
                    if ($entry.Value -match '\*$' -and $entry.Operator -eq 'eq') {
                        "startswith($($entry.Property), $($quotes)$($entry.Value.TrimEnd('*'))$($quotes))"
                        continue entries
                    }
                    if ($entry.Value -match '^\*' -and $entry.Operator -eq 'eq') {
                        "endswith($($entry.Property), $($quotes)$($entry.Value.TrimStart('*'))$($quotes))"
                        continue entries
                    }
                    '{0} eq {1}' -f $entry.Property, $valueString
                }
                'leq' {
                    '{0} eq {1}' -f $entry.Property, $valueString
                }
                'in' {
                    '{0} in ({1})' -f $entry.Property, (@($entry.Value).ForEach{ "$($quotes)$_$($quotes)" } -join ', ')
                }
                'any' {
                    '{0}/any(x:x eq {1})' -f $entry.Property, $valueString
                }
                'none' {
                    '{0}/all(x:x ne {1})' -f $entry.Property, $valueString
                }
                default {
                    '{0} {1} {2}' -f $entry.Property, $entry.Operator, $valueString
                }
            }
        }
        if ($this.CustomFilter) {
            if ($segments) { $segments = @($segments) + $this.CustomFilter }
            else { $segments = $this.CustomFilter }
        }

        if ($this.Logic -eq 'OR') { return $segments -join ' or ' }
        return $segments -join ' and '
    }
    [hashtable]GetHeader() {
        return @{ '$filter' = $this.Get() }
    }
}