src/Solutions/Layers/Test-XrmComponentCustomization.ps1

<#
    .SYNOPSIS
    Test active-layer customization for a solution component.

    .DESCRIPTION
    Checks whether a component has meaningful customizations in the Active layer
    by querying msdyn_componentlayer and parsing msdyn_changes.

    .PARAMETER XrmClient
    Xrm connector initialized to target instance. Use latest one by default. (Dataverse ServiceClient)

    .PARAMETER ComponentId
    Solution component unique identifier.

    .PARAMETER SolutionComponentName
    Solution component name (for example: Entity, Attribute, SavedQuery, SystemForm).

    .PARAMETER ExcludedProperties
    Changed properties to ignore when evaluating meaningful customizations.

    .PARAMETER IncludedProperties
    If provided, only these changed properties are evaluated.

    .PARAMETER ReturnDetails
    Return a detailed object instead of a boolean.

    .OUTPUTS
    System.Boolean or PSCustomObject.

    .EXAMPLE
    $isCustomized = Test-XrmComponentCustomization -ComponentId $componentId -SolutionComponentName "Attribute";

    .EXAMPLE
    $details = Test-XrmComponentCustomization -ComponentId $componentId -SolutionComponentName "SystemForm" -ReturnDetails;
#>

function Test-XrmComponentCustomization {
    [CmdletBinding()]
    [OutputType([System.Boolean], [PSCustomObject])]
    param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $XrmClient = $Global:XrmClient,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [Guid]
        $ComponentId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $SolutionComponentName,

        [Parameter(Mandatory = $false)]
        [String[]]
        $ExcludedProperties = @("displaymask", "createdon", "modifiedon", "attributetypeid", "attributelogicaltypeid"),

        [Parameter(Mandatory = $false)]
        [String[]]
        $IncludedProperties = @(),

        [Parameter(Mandatory = $false)]
        [switch]
        $ReturnDetails
    )
    begin {
        $StopWatch = [System.Diagnostics.Stopwatch]::StartNew();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Start -Parameters ($MyInvocation.MyCommand.Parameters);
    }
    process {

        $queryUnmanagedComponent = New-XrmQueryExpression -LogicalName "msdyn_componentlayer" -Columns "msdyn_changes", "msdyn_componentid", "msdyn_solutioncomponentname" -TopCount 1;
        $queryUnmanagedComponent = $queryUnmanagedComponent | Add-XrmQueryCondition -Field "msdyn_solutionname" -Condition Equal -Values "Active";
        $queryUnmanagedComponent = $queryUnmanagedComponent | Add-XrmQueryCondition -Field "msdyn_solutioncomponentname" -Condition Equal -Values $SolutionComponentName;
        $queryUnmanagedComponent = $queryUnmanagedComponent | Add-XrmQueryCondition -Field "msdyn_componentid" -Condition Equal -Values $ComponentId.ToString();

        $unmanagedComponents = @($XrmClient | Get-XrmMultipleRecords -Query $queryUnmanagedComponent);
        if ($unmanagedComponents.Count -eq 0) {
            if ($ReturnDetails) {
                return [pscustomobject]@{
                    "ComponentId"           = $ComponentId;
                    "SolutionComponentName" = $SolutionComponentName;
                    "HasCustomization"      = $false;
                    "ChangedProperties"     = @();
                    "LayerId"               = [Guid]::Empty;
                    "RawChanges"            = $null;
                    "Layer"                 = $null;
                };
            }
            return $false;
        }

        $layer = $unmanagedComponents | Select-Object -First 1;
        $rawChanges = $layer.msdyn_changes;
        if ([string]::IsNullOrWhiteSpace([string]$rawChanges)) {
            if ($ReturnDetails) {
                return [pscustomobject]@{
                    "ComponentId"           = $ComponentId;
                    "SolutionComponentName" = $SolutionComponentName;
                    "HasCustomization"      = $false;
                    "ChangedProperties"     = @();
                    "LayerId"               = $layer.Id;
                    "RawChanges"            = $rawChanges;
                    "Layer"                 = $layer;
                };
            }
            return $false;
        }

        try {
            $changes = $rawChanges | ConvertFrom-Json;
        }
        catch {
            if ($ReturnDetails) {
                return [pscustomobject]@{
                    "ComponentId"           = $ComponentId;
                    "SolutionComponentName" = $SolutionComponentName;
                    "HasCustomization"      = $false;
                    "ChangedProperties"     = @();
                    "LayerId"               = $layer.Id;
                    "RawChanges"            = $rawChanges;
                    "Layer"                 = $layer;
                    "Error"                 = $_.Exception.Message;
                };
            }
            return $false;
        }

        $changedPropertiesSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase);
        $changeAttributes = @();

        if ($changes -and $changes.PSObject.Properties.Match("Attributes").Count -gt 0) {
            $changeAttributes = @($changes.Attributes);
        }
        elseif ($changes -is [System.Collections.IDictionary]) {
            $changeAttributes = @($changes.GetEnumerator());
        }

        foreach ($change in $changeAttributes) {
            $changePropertyName = $null;

            if ($null -eq $change) {
                continue;
            }
            elseif ($change -is [string]) {
                $changePropertyName = $change;
            }
            elseif ($change -is [System.Collections.DictionaryEntry]) {
                $changePropertyName = [string]$change.Key;
            }
            elseif ($change.PSObject.Properties.Match("Key").Count -gt 0) {
                $changePropertyName = [string]$change.Key;
            }
            elseif ($change.PSObject.Properties.Match("key").Count -gt 0) {
                $changePropertyName = [string]$change.key;
            }

            if ([string]::IsNullOrWhiteSpace($changePropertyName)) {
                continue;
            }

            if ($IncludedProperties.Count -gt 0 -and -not ($IncludedProperties -contains $changePropertyName)) {
                continue;
            }

            if ($ExcludedProperties -contains $changePropertyName) {
                continue;
            }

            [void]$changedPropertiesSet.Add($changePropertyName);
        }

        $changedProperties = @($changedPropertiesSet | Sort-Object);
        $hasCustomization = ($changedProperties.Count -gt 0);

        if ($ReturnDetails) {
            return [pscustomobject]@{
                "ComponentId"           = $ComponentId;
                "SolutionComponentName" = $SolutionComponentName;
                "HasCustomization"      = $hasCustomization;
                "ChangedProperties"     = $changedProperties;
                "LayerId"               = $layer.Id;
                "RawChanges"            = $rawChanges;
                "Layer"                 = $layer;
            };
        }

        $hasCustomization;
    }
    end {
        $StopWatch.Stop();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Stop -StopWatch $StopWatch;
    }
}

Export-ModuleMember -Function Test-XrmComponentCustomization -Alias *;