Public/Invoke-OracleQuery.ps1

<#
.SYNOPSIS
    PowerShell script to query an Oracle database, using Windows Authentication. The user can pass a query directly, or a SQL file to run against a database.
 
.DESCRIPTION
    This script allows the user to query an Oracle database. the primary features are;
        - You can pass a query directly or a sql file with queries in it.
        - It uses Windows Authentication, so no credentials are requried if you are part of the ORA_DBA group.
        - It uses the Oracle.ManagedDataAccess.dll to access the database.
        - Can pass multiple queries at once, either directly through the Query parameter, or in a SQL file.
 
.EXAMPLE
    Invoke-v1OracleQuery -HostName HostServer1 -ServiceName PATPDB1 -Query "select username, account_status from dba_users;"
        This example runs the given query against the PATPDB1 database on HostServer1
 
.EXAMPLE
    Invoke-v1OracleQuery -HostName HostServer1 -ServiceName PATPDB1 -SqlFile C:\example\oracle.sql
        This runs the oracle.sql file against the database.
 
.NOTES
    Author: Patrick Cull
    Date: 2020-02-03
 
    Update 2020-02-25
    - Now compatible with Oracle 11g
    - Now uses full connection string instead of EZConnect string to connect to databases.
 
    Update 2020-03-12
    - Now defaults to localhost to use the dll files, if the required files are available. This makes it faster and also allows the function to query db's on Linux servers.
    - If ODP.NET is not installed, function tries to use the remote computers ODP.NET to query the database instead.
 
    Update 2020-09-23
    - Query can now contain PL/SQL blocks.
    - Automatically remove comment lines - they do not work with ODP.NET commands.
    - Added ExitOnError switch so the user can decide to stop the execution when a query encounters an error. Default behaviour is to continue with remaining queries.
 
    Update 2021-02-05
    - Query returns a "Query" and "ResultSet" even if there is only one query. Previously single queries would only return "ResultSet"
    - Function now uses an included Oracle.ManagedDataAccess.dll to access the remote database.
        - This speeds up the query massively and removes all dependencies, including remoting to the Host to run queries if ODP was not installed locally.
 
    Update 2021-11-29
     - Added -ResultSetOnly switch to optionally return only the result set and not the query
     - Added PortNumber parameter
 
#>

function Invoke-OracleQuery {
    [Cmdletbinding()]
    param(   
        #Server the database is on
        [Parameter(Mandatory)]
        [string] $HostName,

        #The database service name to query
        [Parameter(Mandatory)]
        [string] $ServiceName,

        #User credential to connect to the database with. Defaults to current windows user as sysdba if not passed.
        [System.Management.Automation.PSCredential] $DatabaseCredential,

        # Query to run
        [string[]] $Query,
        
        #Sql file to run against the database.
        [string] $SqlFile,

        #Port number to use for the connection string.
        [int] $PortNumber = 1521,

        #Connect as sysdba
        [switch]$AsSysdba,

        #Optionally stop as soon as a query encounters an error. Default behaviour is to continue with the remaining queries after an error occcurs.
        [switch]$ExitOnError,

        #By default, the function will return the Query and the ResultSet - which is useful for multiple queries. This switch will return only the ResultSet - which can be useful for single queries.
        [switch] $ResultSetOnly
    )

    if(!$Query -and !$SqlFile){
        Throw "Please pass either a Query or a SqlFile to run against the target database."
    }

    if($Query -and $SqlFile){
        Throw "Cannot use the Query and SqlFile parameters together, use one or the other."
    }

    if($SqlFile) {
        $Query = Get-Content $SqlFile -ErrorAction Stop
    }
   

    ##################################
    # Start Query Prep
    ##################################
    #Split the input command up. We need to account for PL/SQL Declare/Begin/End blocks
        #Remove comments, they don't work when using ODP.NET to run the query.
        $NoCommentQuery = ($Query | Where-Object {$_ -notlike 'set *' -and $_ -notlike 'PROMPT*' -and $_ -notlike 'column*' -and $_ -notlike 'compute*' -and $_ -notlike 'Rem *'}) | Out-String

        #Get the location of Declare/Begin/End blocks as well as the locations of the end command characters - i.e. a ; outside single quotes or a / on it's own line
        $BeginEndBlocks = $NoCommentQuery | Select-String 'DECLARE[^/]+/|DECLARE[^/]+END;|BEGIN[^/]+/|BEGIN[^/]+END;' -AllMatches | ForEach-Object { $_.Matches } | Sort-Object Index -Descending
        $EndCommandCharacters = $NoCommentQuery | Select-String ";+(?=(?:[^\']*\'[^\']*\')*[^\']*$)|`r`n/" -AllMatches | ForEach-Object { $_.Matches } 

        #Find any end command characters that are not within a PL/SQL block
        $NonBlockCommands = $EndCommandCharacters | ForEach-Object {
            $BlockChecks = 0
            foreach($block in $BeginEndBlocks) {
                $StartBlockLocation = $block.Index
                $EndBlockLocation = ($block.Index + $Block.Length)

                #If the index of the end character is outside the block range, we count the block as checked
                if($_.Index -le $StartBlockLocation -or $_.Index -ge $EndBlockLocation) {
                    $BlockChecks++
                }
            }

            #Only if all blocks have been checked and do not contain the character do we include this as a non block character.
            if($BlockChecks -eq $BeginEndBlocks.Count) {
                $_
            }
        }

        #Remove the PL/SQL Blocks from the query (so we can find non block commands)
        $RemovedEndBlocks = $NoCommentQuery
        foreach($block in $BeginEndBlocks) {
            $startIndex = $block.Index
            $Length = $block.Length
            $RemovedEndBlocks = $RemovedEndBlocks.Remove($startIndex, $Length)
        }


        #Split the remaining, non block queries
        $NonBlockQueries = $RemovedEndBlocks -Split ";+(?=(?:[^\']*\'[^\']*\')*[^\']*$)"
        $NonBlockQueries = $NonBlockQueries.Split([string[]]"`r`n/", [StringSplitOptions]::None)

        #Cleanup and remove any empty elements
        $NonBlockQueries = ($NonBlockQueries.Trim()) | Where-Object {$_}

        #Now that we know the locations of any blocks and non-block commands, we can build the array of commands in order that they appear.
        $AllCommandLocations = @()
        $AllCommandLocations += $BeginEndBlocks 
        $AllCommandLocations += $NonBlockCommands

        [string[]]$OracleQueries = @()

        $CommandIndex = 0
        foreach($command in ($AllCommandLocations | Sort-Object Index)) {
            #Any object with a length of over 3 is a command block, as opposed to ';' or '\r\n/'
            
            if($command.Length -gt 3) {
                #remove trailing backslash from a PL/SQL block if it exists. The trailing backslash will cause an error.
                if($command.Value[-1] -eq '/') {
                    $sqlText = $command.Value -replace ".$"
                }
                else {
                    $sqlText = $command.Value
                }

                $OracleQueries += $sqlText
            }
            else {
                #We can only access using the array index if there is more than one member
                if($NonBlockQueries.Count -gt 1) {
                    $OracleQueries += $NonBlockQueries[$CommandIndex]
                }
                else {
                    $OracleQueries += $NonBlockQueries
                }
                $commandIndex++
            }
        }

        #This ensures that if the last query in the command is missing a ';' is also included. CommandLocations works off of ';' locations, so if it's missing one it means the query is at the end, but we still need to include it.
        if($NonBlockQueries.Count -eq ($AllCommandLocations.Count + 1)) {
            if($NonBlockQueries.Count -gt 1) {
                $OracleQueries += $NonBlockQueries[-1] 
            }
            else {
                $OracleQueries += $NonBlockQueries
            }
        }

    ##################################
    # End Query Prep
    ##################################

    $ModuleRoot = $MyInvocation.MyCommand.Module.ModuleBase
    
    # Load the Oracle.ManagedDataAccess.dll
    $LocalOracleFiles = "$ModuleRoot\bin"
    $DllFilePath = "$LocalOracleFiles\Oracle.ManagedDataAccess.dll"
    
    if(Test-Path $DllFilePath) {
        Write-Verbose "$DllFilePath found successfully. Attempting to run Unblock-File"
        Unblock-File $DllFilePath
    }
    else {
        Throw "Oracle.ManagedDataAccess.dll not found at $LocalOracleFiles"
    }

    try {
        Add-Type -Path $DllFilePath
    }
    catch {
        Write-Error $_.Exception.Message
        Throw "Issue adding Oracle.ManagedDataAccess.dll from the Oracle Home."           
    }

    # Update the TNS_ADMIN to use the local sqlnet.ora file (this sqlnet.ora SQLNET.AUTHENTICATION_SERVICES = (NTS))
    $CurrentTnsAdmin = $env:TNS_ADMIN 
    $env:TNS_ADMIN = $LocalOracleFiles

    Write-Verbose "Current TNS_ADMIN: $CurrentTnsAdmin"
    Write-Verbose "Setting TNS_ADMIN to $LocalOracleFiles temporarily."

    #Get IP address for a better Connection String. Using "localhost" in connect descriptor caused queries on some servers to fail, using the IP address is better.
    $TargetIpAddress = Test-Connection -ComputerName $HostName -Count 1  | Select-Object -ExpandProperty IPV4Address | Select-Object -ExpandProperty IPAddressToString

    #If a credential is passed, we use that instead of using windows auth
    if($DatabaseCredential) {
        $DbAccountUsername = $DatabaseCredential.UserName
        $DbAccountPassword = $DatabaseCredential.GetNetworkCredential().password

        if($AsSysdba) {
            $ConnectionString = "User Id=$DbAccountUsername;Password=$DbAccountPassword;DBA Privilege=SYSDBA;Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=$TargetIpAddress)(PORT=$PortNumber))(CONNECT_DATA=(SERVICE_NAME=$ServiceName)))"
        }
        else {
            $ConnectionString = "User Id=$DbAccountUsername;Password=$DbAccountPassword;Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=$TargetIpAddress)(PORT=$PortNumber))(CONNECT_DATA=(SERVICE_NAME=$ServiceName)))"
        }
        Write-Verbose "Connection String: $($ConnectionString -replace $DbAccountPassword, 'xxxx')"

    }

    else {
        $ConnectionString = "User Id=/;DBA Privilege=SYSDBA;Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=$TargetIpAddress)(PORT=$PortNumber))(CONNECT_DATA=(SERVICE_NAME=$ServiceName)))"
        Write-Verbose "Connection String: $ConnectionString"
    }

    :Queries foreach($Query in $OracleQueries) {
        #Clear any empty lines and uneeded whitespace.
        $Query = $Query.Trim()

        $QueryResult = @()
        try { 
            $Connection = New-Object Oracle.ManagedDataAccess.Client.OracleConnection($ConnectionString)
            $cmd=$Connection.CreateCommand()

            $cmd.CommandText= $Query

            $da=New-Object Oracle.ManagedDataAccess.Client.OracleDataAdapter($cmd);
            $QueryResult=New-Object System.Data.DataTable
            [void]$da.fill($QueryResult)
            #This expands the results set to human readable form - without this it just shows "System.Data.DataRow" for each resultset.
            $QueryResult = $QueryResult | Select-Object -Property * -ExcludeProperty RowError,RowState,table,ItemArray,HasErrors
        } 

        catch {
            $ErrorMessage = $_.Exception.Message.ToString()

            $EncounteredError = $true

            #If the error message is a connection error, we set connectionError to true, so we can break out of the query loop.
            if(($ErrorMessage -like '*ORA-12154: TNS:could not resolve the connect identifier specified*') -or ($ErrorMessage -like "*ORA-01017: invalid username/password; logon denied*")){
                $ConnectionError = $True
            }

            #If the error message is a normal ORA- Error, we return that;
            if($ErrorMessage -like '*ORA-*'){
                $QueryResult = "`"ORA-" + ($ErrorMessage -split 'ORA-')[1]
            }
            #Otherwise return it, unchanged.
            else {
                $QueryResult = $ErrorMessage
            }
        } 
        finally {
            if ($Connection.State -eq 'Open') { $Connection.close() }
        }   

        #If nothing is returned, it means there were no errors or tables returned. So we give it a value based on the query.
        if(!$QueryResult) {

            #Split the query up so we can access the words separately.
            $QueryWords = $Query -split " "
            
            #The first word of the command is the verb - e.g. create, drop, select.
            $QueryVerb = $QueryWords[0] 
            
            #The subject of the query is the second word -e.g. "DROP TABLE.." the subject is the table. The Get-Culture function lets us capitalize the first letter.
            $QuerySubjectType = (Get-Culture).TextInfo.ToTitleCase($QueryWords[1])

            #If the verb is SELECT and there is no query result, that means the result set is empty.
            if($QueryVerb -eq 'SELECT') {
                $QueryResult = "no rows selected"
            }
            elseif($QueryVerb -like 'BEGIN*' -or $QueryVerb -like 'DECLARE*') {
                $QueryResult = "PL/SQL procedure successfully completed."                    
            }
            
            #if it wasn't a select, and there was nothing returned, it means it was successful. So we create a result message.
            else {
                if($QueryVerb -eq 'DROP'){
                    $QueryVerbPastTense = "dropped"
                }
                elseif($QueryVerb -eq 'CREATE'){
                    $QueryVerbPastTense = "created"
                }
                elseif($QueryVerb -eq 'TRUNCATE'){
                    $QueryVerbPastTense = "truncated"
                }
                elseif($QueryVerb -eq 'DELETE'){
                    $QueryVerbPastTense = "deleted"
                }
                elseif($QueryVerb -eq 'ALTER'){
                    $QueryVerbPastTense = "altered"
                }
                elseif($QueryVerb -eq 'EXECUTE'){
                    $QueryVerbPastTense = "executed"
                }
                elseif($QueryVerb -eq 'GRANT'){
                    $QueryVerbPastTense = "granted"
                }
                #If it's not one of the above verbs, we default to succeeded.
                else{
                    $QueryVerbPastTense = "succeeded"
                }

                $QueryResult = "$QuerySubjectType $QueryVerbPastTense."
            }
        }   


        if(!$ResultSetOnly) {
            $QueryObject = New-Object PSObject  
            Add-Member -memberType NoteProperty -InputObject $QueryObject -Name Query -Value $Query  
            Add-Member -memberType NoteProperty -InputObject $QueryObject -Name ResultSet -Value $QueryResult     
        }
        else {
            $QueryObject = @() 
            $QueryObject += , $QueryResult  

        }

        $QueryObject 


        #If there was a connection error, we do not continue with the remaining queries.
        if($ConnectionError){
            Write-Warning "Issue connecting to the database. Not continuing with remaining queries to prevent lockouts."
            break Queries
        }
        elseif($EncounteredError -and $ExitOnError) {
            Write-Warning "Error encountered and EncounteredError switch is set to true. Stopping script."
            break Queries
        }

    }#End foreach Query loop
    
    #Reset TNS_ADMIN to default value in the session.
    Write-Verbose "Resetting TNS_ADMIN to $CurrentTnsAdmin."
    $env:TNS_ADMIN = $CurrentTnsAdmin
}