src/Solutions/Metadata/Columns/Set-XrmColumn.ps1

<#
    .SYNOPSIS
    Update a column in Microsoft Dataverse.

    .DESCRIPTION
    Update an existing attribute / column metadata using UpdateAttributeRequest.

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

    .PARAMETER EntityLogicalName
    Table / Entity logical name.

    .PARAMETER Attribute
    The AttributeMetadata object with updated properties.

    .PARAMETER SolutionUniqueName
    Solution unique name context for the update.

    .PARAMETER MergeLabels
    Whether to merge labels. Default: true. With DisplayNameLabels or DescriptionLabels, the current labels of the other languages are read and sent too: the platform would otherwise copy the given text into the language of the caller when it is missing.

    .PARAMETER IsAuditEnabled
    Whether auditing is enabled on the column. When specified, overrides the value set on the AttributeMetadata.
    
    .PARAMETER EnableForInteractiveExperience
    Enables the column for interactive dashboards (sets IsGlobalFilterEnabled and IsSortableEnabled).

    .PARAMETER DisplayNameLabels
    Hashtable of language code to display name for multilingual labels. When provided, overrides the DisplayName set on the AttributeMetadata. Example: @{ 1033 = "Project Code"; 1036 = "Code projet" }

    .PARAMETER DescriptionLabels
    Hashtable of language code to description for multilingual labels. When provided, overrides the Description set on the AttributeMetadata.

    .PARAMETER LogicalName
    Column logical name, instead of Attribute: a minimal metadata of the column type is sent, carrying only the given settings (the platform validates every property the request carries).

    .PARAMETER RequiredLevel
    Requirement level: None, Recommended, ApplicationRequired. Raises an error when the column does not allow it to change (RequiredLevel.CanBeChanged), and when the level read back after the update is not the one asked (the platform can accept the request without applying it).

    .PARAMETER MinValue
    Minimum value of a whole number, decimal, float or currency column. The other bound is kept when not given.

    .PARAMETER MaxValue
    Maximum value of a whole number, decimal, float or currency column. The other bound is kept when not given.

    .OUTPUTS
    Microsoft.Xrm.Sdk.OrganizationResponse. The UpdateAttribute response.

    .EXAMPLE
    $attr = Get-XrmColumn -EntityLogicalName "account" -LogicalName "new_code";
    $attr.DisplayName = New-XrmLabel -Text "Project Code";
    Set-XrmColumn -EntityLogicalName "account" -Attribute $attr;

    .EXAMPLE
    $attr = Get-XrmColumn -EntityLogicalName "account" -LogicalName "new_code";
    Set-XrmColumn -EntityLogicalName "account" -Attribute $attr -DisplayNameLabels @{ 1033 = "Project Code"; 1036 = "Code projet" };

    .EXAMPLE
    Set-XrmColumn -XrmClient $xrmClient -EntityLogicalName "account" -LogicalName "new_score" -RequiredLevel ApplicationRequired -MinValue 0 -MaxValue 1000;

    .LINK
    https://github.com/AymericM78/PowerDataOps/blob/main/documentation/commands/Set-XrmColumn.md
#>

function Set-XrmColumn {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([Microsoft.Xrm.Sdk.OrganizationResponse])]
    param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $XrmClient = $Global:XrmClient,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $EntityLogicalName,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [Microsoft.Xrm.Sdk.Metadata.AttributeMetadata]
        $Attribute,

        [Parameter(Mandatory = $false)]
        [string]
        $SolutionUniqueName,

        [Parameter(Mandatory = $false)]
        [bool]
        $MergeLabels = $true,

        [Parameter(Mandatory = $false)]
        [bool]
        $IsAuditEnabled,

        [Parameter(Mandatory = $false)]
        [switch]
        $EnableForInteractiveExperience,

        [Parameter(Mandatory = $false)]
        [Hashtable]
        $DisplayNameLabels,

        [Parameter(Mandatory = $false)]
        [Hashtable]
        $DescriptionLabels,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $LogicalName,

        [Parameter(Mandatory = $false)]
        [Microsoft.Xrm.Sdk.Metadata.AttributeRequiredLevel]
        $RequiredLevel,

        [Parameter(Mandatory = $false)]
        [double]
        $MinValue,

        [Parameter(Mandatory = $false)]
        [double]
        $MaxValue
    )
    begin {
        $StopWatch = [System.Diagnostics.Stopwatch]::StartNew();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Start -Parameters ($MyInvocation.MyCommand.Parameters);
    }
    process {
        if (-not $PSBoundParameters.ContainsKey('Attribute') -and -not $PSBoundParameters.ContainsKey('LogicalName')) {
            throw "Attribute or LogicalName is required.";
        }

        # The current definition is read when a check needs it, or to build a minimal metadata of the same type
        $mergeLabelsClientSide = $MergeLabels -and ($PSBoundParameters.ContainsKey('DisplayNameLabels') -or $PSBoundParameters.ContainsKey('DescriptionLabels'));
        $needsCurrent = $mergeLabelsClientSide -or $PSBoundParameters.ContainsKey('LogicalName') -or $PSBoundParameters.ContainsKey('RequiredLevel') -or $PSBoundParameters.ContainsKey('MinValue') -or $PSBoundParameters.ContainsKey('MaxValue');
        $current = $null;
        if ($needsCurrent) {
            $columnName = $(if ($PSBoundParameters.ContainsKey('LogicalName')) { $LogicalName } else { $Attribute.LogicalName });
            $current = Get-XrmColumn -XrmClient $XrmClient -EntityLogicalName $EntityLogicalName -LogicalName $columnName -IfExists;
            if (-not $current) {
                throw "Column '$columnName' not found on table '$EntityLogicalName'.";
            }
        }
        if (-not $PSBoundParameters.ContainsKey('Attribute')) {
            $Attribute = [Activator]::CreateInstance($current.GetType());
            $Attribute.LogicalName = $current.LogicalName;
            $Attribute.SchemaName = $current.SchemaName;
        }

        if ($PSBoundParameters.ContainsKey('RequiredLevel')) {
            if ($null -ne $current.RequiredLevel -and -not $current.RequiredLevel.CanBeChanged -and $current.RequiredLevel.Value -ne $RequiredLevel) {
                throw "The requirement level of column '$($current.LogicalName)' cannot be changed (RequiredLevel.CanBeChanged is false).";
            }
            $Attribute.RequiredLevel = [Microsoft.Xrm.Sdk.Metadata.AttributeRequiredLevelManagedProperty]::new($RequiredLevel);
        }

        if ($PSBoundParameters.ContainsKey('MinValue') -or $PSBoundParameters.ContainsKey('MaxValue')) {
            $rangeTypes = @{
                "IntegerAttributeMetadata" = [int];
                "BigIntAttributeMetadata"  = [long];
                "DecimalAttributeMetadata" = [decimal];
                "DoubleAttributeMetadata"  = [double];
                "MoneyAttributeMetadata"   = [double];
            };
            $valueType = $rangeTypes[$current.GetType().Name];
            if (-not $valueType) {
                throw "Column '$($current.LogicalName)' ($($current.GetType().Name)) has no MinValue / MaxValue.";
            }
            # Both bounds are always sent, so the new range is checked as a whole
            $newMin = $(if ($PSBoundParameters.ContainsKey('MinValue')) { $MinValue } else { $current.MinValue });
            $newMax = $(if ($PSBoundParameters.ContainsKey('MaxValue')) { $MaxValue } else { $current.MaxValue });
            if ($null -ne $newMin -and $null -ne $newMax -and [double]$newMin -gt [double]$newMax) {
                throw "MinValue ($newMin) is greater than MaxValue ($newMax).";
            }
            if ($null -ne $newMin) { $Attribute.MinValue = $newMin -as $valueType; }
            if ($null -ne $newMax) { $Attribute.MaxValue = $newMax -as $valueType; }
        }

        if ($PSBoundParameters.ContainsKey('IsAuditEnabled')) {
            $Attribute.IsAuditEnabled = [Microsoft.Xrm.Sdk.BooleanManagedProperty]::new($IsAuditEnabled);
        }

        if ($EnableForInteractiveExperience.IsPresent) {
            $Attribute.IsGlobalFilterEnabled = [Microsoft.Xrm.Sdk.BooleanManagedProperty]::new($true);
            $Attribute.IsSortableEnabled = [Microsoft.Xrm.Sdk.BooleanManagedProperty]::new($true);
        }

        # With MergeLabels, the languages not given are sent with their current text: the platform
        # otherwise copies the given text into the language of the caller when that one is missing
        if ($PSBoundParameters.ContainsKey('DisplayNameLabels')) {
            $labels = $DisplayNameLabels;
            if ($mergeLabelsClientSide) {
                $labels = ConvertFrom-XrmLabel -Label $current.DisplayName;
                foreach ($languageCode in $DisplayNameLabels.Keys) { $labels[[int]$languageCode] = $DisplayNameLabels[$languageCode]; }
            }
            $Attribute.DisplayName = New-XrmLabel -Labels $labels;
        }

        if ($PSBoundParameters.ContainsKey('DescriptionLabels')) {
            $labels = $DescriptionLabels;
            if ($mergeLabelsClientSide) {
                $labels = ConvertFrom-XrmLabel -Label $current.Description;
                foreach ($languageCode in $DescriptionLabels.Keys) { $labels[[int]$languageCode] = $DescriptionLabels[$languageCode]; }
            }
            $Attribute.Description = New-XrmLabel -Labels $labels;
        }

        $request = [Microsoft.Xrm.Sdk.Messages.UpdateAttributeRequest]::new();
        $request.EntityName = $EntityLogicalName;
        $request.Attribute = $Attribute;
        $request.MergeLabels = $MergeLabels;

        if ($PSBoundParameters.ContainsKey('SolutionUniqueName')) {
            $request.Parameters["SolutionUniqueName"] = $SolutionUniqueName;
        }

        $response = Invoke-XrmRequest -XrmClient $XrmClient -Request $request;

        # The platform can accept a requirement level change without applying it: read it back
        if ($null -ne $response -and $PSBoundParameters.ContainsKey('RequiredLevel')) {
            $updated = Get-XrmColumn -XrmClient $XrmClient -EntityLogicalName $EntityLogicalName -LogicalName $Attribute.LogicalName;
            if ($updated.RequiredLevel.Value -ne $RequiredLevel) {
                throw "The platform accepted the update of column '$($Attribute.LogicalName)' but kept its requirement level $($updated.RequiredLevel.Value) (asked: $RequiredLevel).";
            }
        }
        $response;
    }
    end {
        $StopWatch.Stop();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Stop -StopWatch $StopWatch;
    }
}

Export-ModuleMember -Function Set-XrmColumn -Alias *;

Register-ArgumentCompleter -CommandName Set-XrmColumn -ParameterName "EntityLogicalName" -ScriptBlock {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $validLogicalNames = Get-XrmEntitiesLogicalName;
    return $validLogicalNames | Where-Object { $_ -like "$wordToComplete*" };
}