Private/Test-LMFilterField.ps1

<#
.SYNOPSIS
Validates filter field names against the allowed fields for a given API endpoint.

.DESCRIPTION
The Test-LMFilterField function validates that all field names used in a filter
(either hashtable or string format) are valid for the specified API endpoint.
It loads the validation configuration from LMFilterValidationConfig.psd1 and
throws an error if invalid fields are found, with suggestions for similar valid fields.

.PARAMETER Filter
The filter object to validate. Can be a hashtable or a string.

.PARAMETER ResourcePath
The API endpoint path (e.g., '/device/devices') to validate against.

.EXAMPLE
Test-LMFilterField -Filter "displayname -eq 'test'" -ResourcePath "/device/devices"
Throws an error because 'displayname' should be 'displayName'.

.EXAMPLE
Test-LMFilterField -Filter @{displayName='test'} -ResourcePath "/device/devices"
Validates successfully as 'displayName' is a valid field.

.NOTES
This function is called internally by Format-LMFilter and should not typically
be called directly by users.
#>


function Test-LMFilterField {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Object]$Filter,

        [Parameter(Mandatory)]
        [String]$ResourcePath
    )

    # Load validation config (cached at script scope)
    if (-not $Script:LMFilterValidationConfig) {
        $ConfigPath = Join-Path $PSScriptRoot "LMFilterValidationConfig.psd1"
        
        if (-not (Test-Path $ConfigPath)) {
            Write-Warning "Filter validation configuration not found at: $ConfigPath"
            Write-Warning "Run Build-LMFilterValidationConfig.ps1 to generate it."
            return # Skip validation if config doesn't exist
        }
        
        try {
            $Script:LMFilterValidationConfig = Import-PowerShellDataFile -Path $ConfigPath
            Write-Debug "Loaded filter validation config with $($Script:LMFilterValidationConfig.Count) endpoints"
        }
        catch {
            Write-Warning "Failed to load filter validation configuration: $_"
            return # Skip validation if config can't be loaded
        }
    }

    # Normalize ResourcePath - remove any path parameters like {id}
    $NormalizedPath = $ResourcePath -replace '/\{[^}]+\}', '/{id}'
    
    # Check if we have validation rules for this endpoint
    if (-not $Script:LMFilterValidationConfig.ContainsKey($NormalizedPath)) {
        Write-Debug "No validation rules found for endpoint: $NormalizedPath"
        return # Skip validation for endpoints without rules
    }

    $ValidFields = $Script:LMFilterValidationConfig[$NormalizedPath]
    Write-Debug "Validating against $($ValidFields.Count) valid fields for $NormalizedPath"

    # Extract field names from the filter
    $FilterFields = @()

    if ($Filter -is [hashtable]) {
        # Hashtable filter (v1 format)
        $FilterFields = $Filter.Keys
    }
    else {
        # String filter (v2 format) - extract field names before operators
        # Pattern: field_name followed by -eq, -ne, -gt, -lt, -ge, -le, -contains, -notcontains
        $Pattern = '(?:^|\s+)([a-zA-Z_][a-zA-Z0-9_\.]*)\s+(?:-eq|-ne|-gt|-lt|-ge|-le|-contains|-notcontains)\s+'
        $PatternMatches = [regex]::Matches($Filter, $Pattern)
        
        foreach ($Match in $PatternMatches) {
            if ($Match.Groups.Count -ge 2) {
                $FilterFields += $Match.Groups[1].Value
            }
        }
    }

    # Validate each field
    $InvalidFields = @()

    Write-Debug "Extracted $($FilterFields.Count) field(s) to validate: $($FilterFields -join ', ')"

    foreach ($Field in $FilterFields) {
        # Check if field is valid (case-sensitive)
        if ($ValidFields -cnotcontains $Field) {
            Write-Debug "Field '$Field' is NOT in valid fields list"
            $InvalidFields += $Field
        }
        else {
            Write-Debug "Field '$Field' is valid"
        }
    }

    # If we found invalid fields, throw an error with suggestions
    if ($InvalidFields.Count -gt 0) {
        $ErrorMessage = "Invalid filter field(s) for endpoint '$ResourcePath':`n"
        
        foreach ($InvalidField in $InvalidFields) {
            $ErrorMessage += " - '$InvalidField'`n"
            
            # Try to find similar valid fields (case-insensitive match or Levenshtein distance)
            $Suggestions = @()
            
            # First try case-insensitive exact match
            $CaseInsensitiveMatch = $ValidFields | Where-Object { $_ -ieq $InvalidField }
            if ($CaseInsensitiveMatch) {
                $Suggestions += $CaseInsensitiveMatch
            }
            else {
                # Find fields that start with the same letters
                $StartsWith = $ValidFields | Where-Object { $_ -like "$InvalidField*" }
                if ($StartsWith) {
                    $Suggestions += $StartsWith | Select-Object -First 3
                }
                else {
                    # Find fields that contain the invalid field
                    $Contains = $ValidFields | Where-Object { $_ -like "*$InvalidField*" }
                    if ($Contains) {
                        $Suggestions += $Contains | Select-Object -First 3
                    }
                }
            }
            
            if ($Suggestions.Count -gt 0) {
                $ErrorMessage += " Did you mean: $($Suggestions -join ', ')?`n"
            }
        }
        
        $ErrorMessage += "`nValid fields for this endpoint include:`n"
        $ErrorMessage += " $($ValidFields[0..50] -join ', ')"
        if ($ValidFields.Count -gt 50) {
            $ErrorMessage += ", ... (and $($ValidFields.Count - 50) more)"
        }
        
        throw $ErrorMessage
    }

    Write-Debug "Filter validation passed for endpoint: $NormalizedPath"
}