lib/functions/Private/New-OmadaQueryRequest.ps1

function New-OmadaQueryRequest {
    <#
    .SYNOPSIS
    Build the method, URI and body for one Omada request in the execute path, from plain values.
 
    .DESCRIPTION
    Every URL and body shape the execute path uses, in one place, expressed as a pure function of a
    context hashtable: no $Script: reads, no logging, no network. That makes it usable from two
    places that must never disagree - the UI-thread functions (Save-Query,
    New-TemporarySqlQueryObject, Remove-SqlQueryObject, Invoke-ExecuteQuery) and
    Invoke-OmadaExecutePipeline, which runs the same sequence inside a background worker (issue #40,
    C1-5).
 
    Written as one function with a -Kind rather than six small ones so that the request shapes sit
    side by side: they share a base URL, an entity path and a body convention, and a change to one is
    usually a change to its neighbours.
 
    Being pure is also what makes these testable at all. Before this, "which URL does saving a query
    use, and when is the body empty?" could only be answered by running the app against a tenant.
 
    .PARAMETER Kind
    Which request to build:
 
      SaveExistingQuery PUT the current query. Returns $null when nothing changed - the caller must
                         treat that as "no request needed", exactly as Save-Query's "No changes
                         detected!" branch always has.
      ExecuteQuery POST GetPagingData for a target query id.
      TempQueryProbe GET the TMP_<guid> object by name, including deleted ones.
      TempQueryUndelete POST UndeleteDataObject for a soft-deleted temporary object.
      TempQueryUpsert PUT (reuse) or POST (create) the temporary object carrying the selection.
      DeleteQuery DELETE a query object by id.
 
    .PARAMETER Context
    Plain values gathered on the UI thread. Keys used, by Kind:
      BaseUrl all
      QueryDoId SaveExistingQuery
      QueryText SaveExistingQuery
      CurrentQueryText SaveExistingQuery (the text last known to be saved)
      SavedQueryText SaveExistingQuery (C_QUERY from the freshly fetched object)
      DisplayName SaveExistingQuery (what the Display name box holds now)
      CurrentDisplayName SaveExistingQuery (the name last known to be saved)
      DataConnectionDoId SaveExistingQuery, TempQueryUpsert
      SavedDataConnectionDoId SaveExistingQuery (C_SQLTROUBLESHOOTING_DATACONNECTION.Id from the
                           freshly fetched object)
      TargetQueryDoId ExecuteQuery
      TempName TempQueryProbe, TempQueryUpsert
      TempQueryDoId TempQueryUndelete, TempQueryUpsert (reuse), DeleteQuery
      SelectionText TempQueryUpsert
 
    .OUTPUTS
    Hashtable @{ Method; Uri; Body }, or $null when no request is needed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet("SaveExistingQuery", "ExecuteQuery", "TempQueryProbe", "TempQueryUndelete", "TempQueryUpsert", "DeleteQuery")]
        [string]$Kind,

        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    $Private:BaseUrl = [string]$Context.BaseUrl
    $Private:EntityUrl = "{0}/odata/dataobjects/C_P_SQLTROUBLESHOOTING" -f $Private:BaseUrl

    switch ($Kind) {

        "SaveExistingQuery" {
            # The diff rules live in Get-SaveExistingQueryBody. An empty body means nothing changed.
            $Private:Body = Get-SaveExistingQueryBody -Context $Context

            if (($Private:Body.Keys | Measure-Object).Count -le 0) {
                return $null
            }

            return @{
                Method = "PUT"
                Uri    = "{0}({1})" -f $Private:EntityUrl, $Context.QueryDoId
                Body   = $Private:Body
            }
        }

        "ExecuteQuery" {
            return @{
                Method = "POST"
                Uri    = "{0}/webservice/jQGridPopulationWebService.asmx/GetPagingData" -f $Private:BaseUrl
                Body   = @{
                    "dataType"     = "SqlDataProducer"
                    "dataTypeArgs" = @{
                        "targetId" = $Context.TargetQueryDoId
                    }
                    "page"         = 1
                    "rows"         = 100000
                    "sidx"         = $null
                    "sord"         = "asc"
                    "_search"      = $false
                    "searchField"  = $null
                    "searchString" = $null
                    "filters"      = $null
                    "searchOper"   = $null
                }
            }
        }

        "TempQueryProbe" {
            # DeletedStatus=Both on purpose: the temporary object is soft-deleted after every run, so
            # the one being looked for is normally in the deleted set.
            return @{
                Method = "GET"
                Uri    = "{0}?DeletedStatus=Both&`$filter=NAME eq '{1}'" -f $Private:EntityUrl, $Context.TempName
                Body   = $null
            }
        }

        "TempQueryUndelete" {
            return @{
                Method = "POST"
                Uri    = "{0}/WebService/DataObjectWebService.asmx/UndeleteDataObject" -f $Private:BaseUrl
                # Pre-serialized, as this endpoint has always been called: it is an ASMX method that
                # wants a JSON string rather than the object the OData endpoints take.
                Body   = (@{ id = $Context.TempQueryDoId } | ConvertTo-Json)
            }
        }

        "TempQueryUpsert" {
            $Private:Body = @{
                "NAME"    = $Context.TempName
                "C_QUERY" = $Context.SelectionText
            }
            if (![string]::IsNullOrWhiteSpace($Context.DataConnectionDoId)) {
                $Private:Body.Add("C_SQLTROUBLESHOOTING_DATACONNECTION", @{ Id = $Context.DataConnectionDoId })
            }

            # PUT onto the recovered object when there is one, POST a new one otherwise. The name is
            # always TMP_<InstanceGuid> - never a fresh guid - so a missing temporary object is
            # recreated under the same name instead of piling up stale ones on the tenant.
            if (![string]::IsNullOrWhiteSpace($Context.TempQueryDoId)) {
                return @{
                    Method = "PUT"
                    Uri    = "{0}({1})" -f $Private:EntityUrl, $Context.TempQueryDoId
                    Body   = $Private:Body
                }
            }

            return @{
                Method = "POST"
                Uri    = $Private:EntityUrl
                Body   = $Private:Body
            }
        }

        "DeleteQuery" {
            return @{
                Method = "DELETE"
                Uri    = "{0}({1})" -f $Private:EntityUrl, $Context.TempQueryDoId
                Body   = $null
            }
        }
    }
}

function Get-SaveExistingQueryBody {
    <#
    .SYNOPSIS
    The PUT body for saving an existing query: only what changed, empty when nothing did.
 
    .DESCRIPTION
    The query text is sent when it differs from either what this session last saved OR what the
    server actually holds, and the name is sent when the Display name box no longer matches the name
    last saved.
 
    The data connection is a change in its own right (issue #179). A single unprefixed statement
    executes the SAVED object, so the connection it runs against is the one stored on it - switching
    the dropdown without editing the text must still reach the tenant, or the query keeps running
    against the previous connection. Compared as strings: OData answers the reference's Id as a
    number, the dropdown holds a string. No selected connection never clears the stored one.
 
    In the same file as New-OmadaQueryRequest, not a file of its own, because a background worker
    dot-sources exactly the files Get-OmadaPipelineWorkerFunction lists for its chain.
 
    .PARAMETER Context
    The SaveExistingQuery keys documented on New-OmadaQueryRequest.
 
    .OUTPUTS
    [hashtable] the body; empty when there is nothing to save.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Context
    )

    $Private:Body = @{}

    $Private:TextChanged = $Context.QueryText -ne $Context.CurrentQueryText -or $Context.QueryText -ne $Context.SavedQueryText
    $Private:ConnectionChanged = [string]$Context.DataConnectionDoId -ne [string]$Context.SavedDataConnectionDoId

    if ($Private:TextChanged) {
        $Private:Body.Add("C_QUERY", $Context.QueryText)
    }

    if (![string]::IsNullOrWhiteSpace($Context.DataConnectionDoId) -and ($Private:TextChanged -or $Private:ConnectionChanged)) {
        $Private:Body.Add("C_SQLTROUBLESHOOTING_DATACONNECTION", @{ Id = $Context.DataConnectionDoId })
    }

    if ($Context.CurrentDisplayName -ne $Context.DisplayName) {
        $Private:Body.Add("NAME", $Context.DisplayName)
    }

    return $Private:Body
}