Public/Invoke-SPSAeriesSqlQuery.ps1

Function Invoke-SPSAeriesSqlQuery {
    <#
    .SYNOPSIS
        Executes SQL queries against the Aeries database specified in an SPSAeries configuration.
    .DESCRIPTION
        This function allows you to run arbitrary SQL queries against the configured Aeries SQL database.
        It can take a query as a string, or read it from a .sql file.
        It leverages saved SPSAeries configurations for database connection details, simplifying ad-hoc data access and manipulation.
        The function supports returning data as PowerShell objects, DataTables, a single scalar value, or executing non-query commands.
        Safety checks are in place for queries that appear to modify data, requiring confirmation unless -Force is used.
    .PARAMETER Query
        The SQL query string to execute. Mutually exclusive with -Path.
    .PARAMETER Path
        The full path to a .sql file containing the SQL query to execute. Mutually exclusive with -Query.

    .PARAMETER As
        Determines how the query results are returned.
        - PSObject (Default): Returns an array of PSCustomObjects.
        - DataTable: Returns a System.Data.DataTable object.
        - NonQuery: For DML/DDL. Does not return data rows but indicates success/failure.
        - Scalar: For queries expected to return a single value.
    .PARAMETER QueryTimeout
        Specifies the query timeout in seconds. Defaults to 30.
    .PARAMETER Force
        Suppresses the confirmation prompt for queries that appear to modify data or schema.
    .EXAMPLE
        Invoke-SPSAeriesSqlQuery -Query "SELECT TOP 10 STU.ID, STU.LN, STU.FN FROM STU" -ConfigName "MySchool"
        # Executes the SELECT query against 'MySchool' configuration and returns results as PSObjects.

    .EXAMPLE
        Invoke-SPSAeriesSqlQuery -Path ".\MyQueries\GetStudentCount.sql" -As Scalar
        # Executes query from file, expects a single value, uses active configuration.

    .EXAMPLE
        Invoke-SPSAeriesSqlQuery -Query "UPDATE STU SET TG = 'X' WHERE ID = 12345" -Force
        # Executes an UPDATE query, bypassing the confirmation prompt, using active configuration.
    .NOTES
        Uses the existing SQL connection method from Connect-AeriesSQLDB.
        Ensure that the specified or active SPSAeries configuration has correct SQL server details and credentials.
    .LINK
        Get-SPSAeriesConfiguration
        Set-SPSAeriesConfiguration
        New-SPSAeriesConfiguration
    #>

    [CmdletBinding(DefaultParameterSetName = 'DirectQuery', 
                   SupportsShouldProcess = $true, 
                   ConfirmImpact = 'Medium')]
    Param(
        [Parameter(Mandatory = $true,
            ParameterSetName = 'DirectQuery',
            Position = 0,
            ValueFromPipeline = $true,
            HelpMessage = 'The SQL query string.')]
        [ValidateNotNullOrEmpty()]
        [string]$Query,

        [Parameter(Mandatory = $true,
            ParameterSetName = 'FromFile',
            HelpMessage = 'Path to a .sql file containing the query.')]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$Path,

        [Parameter(Mandatory = $false,
            HelpMessage = 'Output format: PSObject, DataTable, NonQuery, Scalar.')]
        [ValidateSet('PSObject', 'DataTable', 'NonQuery', 'Scalar')]
        [string]$As = 'PSObject',

        [Parameter(Mandatory = $false,
            HelpMessage = 'Query timeout in seconds.')]
        [int]$QueryTimeout = 30,

        [Parameter(Mandatory = $false,
            HelpMessage = 'Suppresses confirmation for modifying queries.')]
        [switch]$Force
    )

    Begin {
        Write-Verbose "Starting $($MyInvocation.InvocationName) with ParameterSetName '$($PsCmdlet.ParameterSetName)'"

        # Get the query from file if Path parameter is used
        $resolvedQuery = $null
        if ($PsCmdlet.ParameterSetName -eq 'FromFile') {
            try {
                $resolvedQuery = Get-Content -Path $Path -Raw -ErrorAction Stop
                Write-Verbose "Successfully read query from file: $Path"
            }
            catch {
                Throw "Failed to read query from file '$Path': $($_.Exception.Message)"
            }
        }
        else {
            $resolvedQuery = $Query
        }

        if ([string]::IsNullOrWhiteSpace($resolvedQuery)) {
            Throw "The SQL query is empty or could not be loaded."
        }
    }

    Process {
        try {
            # Use the global configuration set by Set-SPSAeriesConfig
            if (-not $Script:Config -or -not $Script:SQLCreds) {
                throw "No active SPSAeries configuration found. Please run Set-SPSAeriesConfiguration first."
            }
            
            # Set the config to use the existing Connect-AeriesSQLDB function
            $Script:Config = $Script:Config  # Keep existing config
            
            Write-Verbose "Using active SPSAeries configuration (Server: $($Script:Config.SQLServer), DB: $($Script:Config.SQLDB))"

            # Safety check for modifying queries
            $isModifyingQuery = $false
            $queryFirstWord = ($resolvedQuery -split '\s+', 2 | Select-Object -First 1).ToLower()
            $modifyingVerbs = @('insert', 'update', 'delete', 'create', 'alter', 'drop', 'truncate', 'execute', 'exec', 'merge', 'grant', 'revoke', 'deny')
            
            if ($modifyingVerbs -contains $queryFirstWord) {
                $isModifyingQuery = $true
                Write-Verbose "Query appears to be a data/schema modification query (starts with: $queryFirstWord)."
            }

            if ($isModifyingQuery -and (-not $Force)) {
                if (-not ($PSCmdlet.ShouldProcess("SQL Server: $($Script:Config.SQLServer), Database: $($Script:Config.SQLDB)", "Execute Modifying SQL Query"))) {
                    Write-Warning "Execution cancelled by user."
                    return
                }
            }
            
            # Call the existing Connect-AeriesSQLDB function to set up the connection
            . $PSScriptRoot\..\Private\Connect-AeriesSQLDB.ps1
            Connect-AeriesSQLDB
            
            # Check if connection was successful
            if (-not $Script:SQLConnection -or $Script:SQLConnection.State -ne 'Open') {
                throw "Failed to establish SQL connection to $($loadedConfig.SQLServer)."
            }
            
            # Create and execute the command
            $command = New-Object System.Data.SqlClient.SqlCommand($resolvedQuery, $Script:SQLConnection)
            $command.CommandTimeout = $QueryTimeout
            
            # Execute based on the output type
            Write-Verbose "Executing SQL query against $($loadedConfig.SQLServer)/$($loadedConfig.SQLDB)..."
            
            if ($As -eq 'Scalar') {
                $results = $command.ExecuteScalar()
            } elseif ($As -eq 'NonQuery') {
                $results = $command.ExecuteNonQuery()
            } else {
                # For PSObject or DataTable, we'll use a DataAdapter
                $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($command)
                $dataSet = New-Object System.Data.DataSet
                $adapter.Fill($dataSet) | Out-Null
                
                if ($dataSet.Tables.Count -gt 0) {
                    if ($As -eq 'DataTable') {
                        $results = $dataSet.Tables[0]
                    } else {
                        # Convert DataTable to array of PSObjects
                        $results = @($dataSet.Tables[0] | ForEach-Object { [PSCustomObject]$_ })
                    }
                } else {
                    # Create an appropriate empty result based on output type
                    if ($As -eq 'DataTable') {
                        $results = New-Object System.Data.DataTable
                    } else { # PSObject
                        $results = @()
                    }
                }
            }
            
            # Add a verbose message for empty results in PSObject or DataTable
            if ($As -in @('PSObject', 'DataTable') -and 
                (($As -eq 'PSObject' -and $results.Count -eq 0) -or 
                 ($As -eq 'DataTable' -and $results.Rows.Count -eq 0))) {
                Write-Verbose "Query returned no data."
            }

            # Output the results based on the requested format
            switch ($As) {
                'PSObject' {
                    # Results are already converted to PSObjects in the main execution block
                    Write-Output $results
                }
                'DataTable' {
                    # Results are already a DataTable when As is 'DataTable'
                    Write-Output $results
                }
                'NonQuery' {
                    # For NonQuery, output the number of rows affected
                    Write-Verbose "NonQuery operation completed. Rows affected: $results"
                    Write-Output $results
                }
                'Scalar' {
                    # For Scalar, output the single value
                    Write-Output $results
                }
            }
        }
        catch {
            $errorMessage = "An error occurred while executing the SQL query: $($_.Exception.Message)"
            if ($_.Exception.InnerException) {
                $errorMessage += "`nInner Exception: $($_.Exception.InnerException.Message)"
            }
            
            # For SQL-specific errors
            if ($_.Exception -is [System.Data.SqlClient.SqlException]) {
                $sqlException = $_.Exception
                $errorMessage += "`nSQL Error $($sqlException.Number): $($sqlException.Message)"
                $errorMessage += "`nLine Number: $($sqlException.LineNumber)"
                $errorMessage += "`nSource: $($sqlException.Source)"
                $errorMessage += "`nServer: $($sqlException.Server)"
                $errorMessage += "`nProcedure: $($sqlException.Procedure)"
            }
            
            Write-Error $errorMessage -ErrorAction Stop
        }
        finally {
            # Ensure the SQL connection is properly closed
            if ($null -ne $Script:SQLConnection -and $Script:SQLConnection.State -eq 'Open') {
                $Script:SQLConnection.Close()
                $Script:SQLConnection.Dispose()
                $Script:SQLConnection = $null
            }
        }
    }

    End {
        Write-Verbose "Ending $($MyInvocation.InvocationName)."
    }
}