From 509e4bffed6c1fe022f9b5bd69a425c7bfb5a23d Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 18 Mar 2020 21:37:49 -0400 Subject: [PATCH 1/3] Add Tests --- Tests/Invoke-SqlCmd2.Tests.ps1 | 245 ++++++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 7 deletions(-) diff --git a/Tests/Invoke-SqlCmd2.Tests.ps1 b/Tests/Invoke-SqlCmd2.Tests.ps1 index 0a44ffb..c352c49 100644 --- a/Tests/Invoke-SqlCmd2.Tests.ps1 +++ b/Tests/Invoke-SqlCmd2.Tests.ps1 @@ -2,14 +2,19 @@ $PSVersion = $PSVersionTable.PSVersion.Major if(-not $ENV:BHProjectName) {Set-BuildEnvironment} $ModuleName = $ENV:BHProjectName +$PSDefaultParameterValues = @{ + 'Mock:ModuleName' = $ModuleName + 'Assert-MockCalled:ModuleName' = $ModuleName +} + # Verbose output for non-master builds on appveyor # Handy for troubleshooting. # Splat @Verbose against commands as needed (here or in pester tests) - $Verbose = @{} - if($ENV:BHBranchName -notlike "master" -or $env:BHCommitMessage -match "!verbose") - { - $Verbose.add("Verbose",$True) - } +$Verbose = @{} +if($ENV:BHBranchName -notlike "master" -or $env:BHCommitMessage -match "!verbose") +{ + $Verbose.add("Verbose",$True) +} Import-Module $PSScriptRoot\..\$ModuleName -Force @@ -18,7 +23,7 @@ Describe "$ModuleName PS$PSVersion" { Set-StrictMode -Version latest - It 'Should load' { + It 'Module imports successfully' { $Module = Get-Module $ModuleName $Module.Name | Should be $ModuleName $Commands = $Module.ExportedCommands.Keys @@ -32,10 +37,236 @@ Describe "Invoke-SqlCmd2 PS$PSVersion" { Set-StrictMode -Version latest - It 'Exists' { + It 'Invoke-SqlCmd2 command exports from module' { $Output = Get-Command Invoke-SqlCmd2 $Output.Module | Should be 'Invoke-SqlCmd2' } } } + + +Describe "Invoke-SqlCmd2 with System.Data mocked on PS$PSVersion" { + + # Prevent SQL from actually ... running + Mock New-Object { + switch ($TypeName) { + # A SqlConnection that never connects + "System.Data.SqlClient.SQLConnection" { + [PSCustomObject]@{ + PSTypeName = $_ + ConnectionString = @($ArgumentList)[0] + FireInfoMessageEventOnUserErrors = $true + } | Add-Member Open -MemberType ScriptMethod { } -Passthru | + Add-Member Close -MemberType ScriptMethod { } -Passthru | + Add-Member Dispose -MemberType ScriptMethod { } -Passthru | + Add-Member add_InfoMessage -MemberType ScriptMethod -Passthru { + $this | Add-Member InfoMessageHandler -MemberType NoteProperty $Args[0] + } | + Add-Member remove_InfoMessage -MemberType ScriptMethod { } -Passthru + } + # A SqlCommand that doesn't complain about that connection + "System.Data.SqlClient.SqlCommand" { + $cmd = [System.Data.SqlClient.SqlCommand]::new($ArgumentList[0]) + $cmd | Add-Member -NotePropertyName Connection -NotePropertyValue $ArgumentList[1] -Force -Passthru + } + # A phoney data adapter that always returns one row + "System.Data.SqlClient.SqlDataAdapter" { + [PSCustomObject]@{ + PSTypeName = $_ + SelectCommand = $ArgumentList[0] + } | Add-Member Dispose -MemberType ScriptMethod { } -Passthru | + Add-Member Fill -MemberType ScriptMethod { + $table = $args[0].Tables.Add("results") + $null = $Table.Columns.Add("Id", [int]) + $null = $Table.Columns.Add("First", [string]) + $null = $Table.Columns.Add("Last", [string]) + $null = $Table.Columns.Add("Superpower", [string]) + $table.Rows.Add(1, "Joel", "Bennett", [DBNull]::Value) + } -PassThru + } + default { + # We do not need to support the -Property parameter + if (!$ArgumentList) { + $ArgumentList = @() + } + ($TypeName -as [Type])::New.Invoke($ArgumentList) + } + } + } + + Context "Running simple queries" { + $TestQuery = "SELECT Top 5 FROM [Users]" + + $result = Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost + + It "Returns DataRow by default" { + $result | Should -BeOfType [Data.DataRow] + } + + It "Creates a SqlCommand with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlCommand" -and $ArgumentList[0] -eq $TestQuery + } + } + + It "Creates a SqlDataAdapter with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlDataAdapter" -and $ArgumentList[0].CommandText -eq $TestQuery + } + } + + It "Returns the result of the Fill command on the dataset" { + $Result["First"] | Should -eq "Joel" + $Result["Last"] | Should -eq "Bennett" + } + } + + Context "Running parameterized queries" { + # $Command is the command we -Named + $TestQuery = "SELECT Top 5 FROM [Users] Where First = @FirstName" + # Which is great, because it's a private command, but I don't need to run my tests InModuleScope + $result = Invoke-SqlCmd2 -Query $TestQuery -SqlParameters @{ FirstName = 'Joel' } -ServerInstance localhost + + It "Populates the Parameters on the SqlCommand" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlDataAdapter" -and $ArgumentList[0].CommandText -eq $TestQuery -and $ArgumentList[0].Parameters.Count -eq 1 -and $ArgumentList[0].Parameters[0].Value -eq 'Joel' + } + } + + # These are the same tests from the simple query + It "Returns DataRow by default" { + $result | Should -Not -BeNullOrEmpty + $result | Should -BeOfType [Data.DataRow] + } + + It "Creates a SqlCommand with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlCommand" -and $ArgumentList[0] -eq $TestQuery + } + } + + It "Returns the result of the Fill command on the dataset" { + $Result["First"] | Should -eq "Joel" + $Result["Last"] | Should -eq "Bennett" + } + } + + Context "Returning PSObject, with multiple servers" { + $TestQuery = "SELECT Top 5 FROM [Users]" + + # Which is great, because it's a private command, but I don't need to run my tests InModuleScope + $result = Invoke-SqlCmd2 -Query $TestQuery -ServerInstance host1, host2 -As PSObject -AppendServerInstance + + It "Initializes DBNullScrubber" { + "DBNullScrubber" -as [Type] | Should -Not -BeNullOrEmpty + } + It "Returns PSCustomObject" { + $result | Should -BeOfType [PSCustomObject] + } + + It "Returns the results as objects" { + $Result.First | Should -eq "Joel", "Joel" + $Result.Last | Should -eq "Bennett", "Bennett" + } + It "Returns results from multiple instances" { + $Result.ServerInstance | Should -eq "host1", "host2" + } + It "Converts DBNull to `$null" { + $result.Superpower[0] | Should -BeNull + $result.Superpower[1] | Should -BeNull + $result.Superpower | Should -Not -Be ([DBNull]::Value) + } + + It "Creates a SqlCommand with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlCommand" -and $ArgumentList[0] -eq $TestQuery + } + } + + It "Creates a SqlDataAdapter with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlDataAdapter" -and $ArgumentList[0].CommandText -eq $TestQuery + } + } + } + + Context "Handling SQL Errors" { + Mock New-Object -ParameterFilter { $TypeName -eq "System.Data.SqlClient.SqlDataAdapter" } { + [PSCustomObject]@{ + PSTypeName = $_ + SelectCommand = $ArgumentList[0] + } | Add-Member Fill -MemberType ScriptMethod { + throw ( + New-MockObject System.Data.SqlClient.SqlException | + Add-Member -NotePropertyName Message -NotePropertyValue "Error Getting Data" -Passthru -Force) + } -PassThru + } + + Mock Write-Debug { } + Mock Write-Verbose { } + + $TestQuery = "SELECT Top 5 FROM [Users]" + # $Command is the command we -Named + # Which is great, because it's a private command, but I don't need to run my tests InModuleScope + It "Logs exception to debug stream and rethrows" { + { + Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost + } | Should -Throw + + Assert-MockCalled Write-Debug -ParameterFilter { + $Message -match "^Capture.*Error" + } + } + + It "Creates a SqlCommand with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlCommand" -and $ArgumentList[0] -eq $TestQuery + } + } + + It "Creates a SqlDataAdapter with the query" { + Assert-MockCalled New-Object -ParameterFilter { + $TypeName -eq "System.Data.SqlClient.SqlDataAdapter" -and $ArgumentList[0].CommandText -eq $TestQuery + } + } + } + + Context "ConnectionString Parameters" { + $TestQuery = "SELECT Top 5 FROM [Users]" + + It "Accepts Credentials, Encryption, and App Names" { + $credential = [PSCredential]::new("sa", (ConvertTo-SecureString 'S3cr3ts' -AsPlainText -Force)) + Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost -Credential $credential -Encrypt -ApplicationName "QMODHelper" + } + + It "Puts the parameters in the Connection String" { + Assert-MockCalled New-Object -ParameterFilter { + if ($TypeName -eq "System.Data.SqlClient.SqlCommand") { + $ArgumentList[1].ConnectionString -match "Data Source=localhost" -and + $ArgumentList[1].ConnectionString -match "Password=S3cr3ts" -and + $ArgumentList[1].ConnectionString -match "Encrypt=True" -and + $ArgumentList[1].ConnectionString -match "Application Name=QMODHelper" + } + } + } + } + + Context "Output -As Works" { + $TestQuery = "SELECT Top 5 FROM [Users]" + + It "Returns a DataTable on demand" { + Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost -As DataTable | + Should -BeOfType [Data.DataTable] + } + It "Returns a DataSet on demand" { + Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost -As DataSet | + Should -BeOfType [Data.DataSet] + } + + It "Returns a SingleValue on demand" { + Invoke-SqlCmd2 -Query $TestQuery -ServerInstance localhost -As SingleValue | + Should -Be 1 + } + } +} From 561f0a6314034fb66d38097497f468cf2f4e4597 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 18 Mar 2020 23:32:23 -0400 Subject: [PATCH 2/3] Add connection test --- Tests/Invoke-SqlCmd2.Tests.ps1 | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Tests/Invoke-SqlCmd2.Tests.ps1 b/Tests/Invoke-SqlCmd2.Tests.ps1 index c352c49..3410b09 100644 --- a/Tests/Invoke-SqlCmd2.Tests.ps1 +++ b/Tests/Invoke-SqlCmd2.Tests.ps1 @@ -270,3 +270,55 @@ Describe "Invoke-SqlCmd2 with System.Data mocked on PS$PSVersion" { } } } + +Describe "Invoke-SqlCmd2 Without Mocking" -Tag Integration { + + function Get-NetConnection { + [CmdletBinding()] + param($Filter = ".*") + foreach ($connection in (netstat -n | where { $_ -match $Filter })) { + $Proto, $LocalAddress, $ForeignAddress, $State = $connection.Trim() -split "\s+" + [PSCustomObject]@{ + PSTypeName = "NetStat.Unresolved" + Proto = $Proto + LocalAddress = $LocalAddress -replace ':[^:]+' + ForeignAddress = $ForeignAddress -replace ':[^:]+' + State = $State + } | Add-Member LocalPort -NotePropertyValue ($LocalAddress -replace '.*:') -Passthru | + Add-Member ForeignPort -NotePropertyValue ($ForeignAddress -replace '.*:') -Passthru + } + } + + if (!$SqlServerConnection) { + # In order for these tests to work, you need a server to test against + # The simplest thing is: + # docker pull mcr.microsoft.com/mssql/server:2019-latest + # docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=Sup3rS3cr3t!Pass**' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2019-latest + # And then define this, globally: + $SqlServerConnection = @{ + ServerInstance = '127.0.0.1' + Credential = '' + } + } + + if (!$SqlServerConnection.Credential.Password.Length -gt 0) { + It "Runs SQL Server Integration Tests" { + Set-ItResult -Skipped -Because "there are no credentials" + } + } else { + Context "Cleaning up SQL network connections" { + $TestQuery = "SELECT {0} AS TenantId" + + It "Runs 1000 queries like a walk in the park" { + for ($i = 40001; $i -lt 41000; $i++) { + (Invoke-SqlCmd2 @SqlServerConnection -Query ($TestQuery -f $i)).TenantId | Should -Be $i + } + } + + It "But leaves no more than a single connection open" { + # | Where ForeignPort ... so you can run against 127.0.0.1 (e.g. a docker container) + @(Get-NetConnection ":1433" | Where ForeignPort -eq 1433).Count | Should -Be 1 + } + } + } +} \ No newline at end of file From 1dada3bd545b261d1c5bb27dec0dc127b377f653 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 18 Dec 2020 16:36:35 -0500 Subject: [PATCH 3/3] wip whitespace cleanup --- Invoke-SqlCmd2/Public/Invoke-SqlCmd2.ps1 | 58 +++++++++++------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/Invoke-SqlCmd2/Public/Invoke-SqlCmd2.ps1 b/Invoke-SqlCmd2/Public/Invoke-SqlCmd2.ps1 index d347e37..b897edc 100644 --- a/Invoke-SqlCmd2/Public/Invoke-SqlCmd2.ps1 +++ b/Invoke-SqlCmd2/Public/Invoke-SqlCmd2.ps1 @@ -212,11 +212,13 @@ function Invoke-Sqlcmd2 { [Alias('Instance', 'Instances', 'ComputerName', 'Server', 'Servers', 'SqlInstance')] [ValidateNotNullOrEmpty()] [string[]]$ServerInstance, + [Parameter(Position = 1, Mandatory = $false, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [string]$Database, + [Parameter(ParameterSetName = 'Ins-Que', Position = 2, Mandatory = $true, @@ -228,6 +230,7 @@ function Invoke-Sqlcmd2 { ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [string]$Query, + [Parameter(ParameterSetName = 'Ins-Fil', Position = 2, Mandatory = $true, @@ -240,6 +243,7 @@ function Invoke-Sqlcmd2 { ValueFromRemainingArguments = $false)] [ValidateScript( { Test-Path -LiteralPath $_ })] [string]$InputFile, + [Parameter(ParameterSetName = 'Ins-Que', Position = 3, Mandatory = $false, @@ -252,6 +256,7 @@ function Invoke-Sqlcmd2 { ValueFromRemainingArguments = $false)] [Alias('SqlCredential')] [System.Management.Automation.PSCredential]$Credential, + [Parameter(ParameterSetName = 'Ins-Que', Position = 4, Mandatory = $false, @@ -261,11 +266,13 @@ function Invoke-Sqlcmd2 { Mandatory = $false, ValueFromRemainingArguments = $false)] [switch]$Encrypt, + [Parameter(Position = 5, Mandatory = $false, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [Int32]$QueryTimeout = 600, + [Parameter(ParameterSetName = 'Ins-Fil', Position = 6, Mandatory = $false, @@ -277,23 +284,28 @@ function Invoke-Sqlcmd2 { ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [Int32]$ConnectionTimeout = 15, + [Parameter(Position = 7, Mandatory = $false, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [ValidateSet("DataSet", "DataTable", "DataRow", "PSObject", "SingleValue")] [string]$As = "DataRow", + [Parameter(Position = 8, Mandatory = $false, ValueFromPipelineByPropertyName = $true, ValueFromRemainingArguments = $false)] [System.Collections.IDictionary]$SqlParameters, + [Parameter(Position = 9, Mandatory = $false)] [switch]$AppendServerInstance, + [Parameter(Position = 10, Mandatory = $false)] [switch]$ParseGO, + [Parameter(ParameterSetName = 'Con-Que', Position = 11, Mandatory = $false, @@ -309,10 +321,12 @@ function Invoke-Sqlcmd2 { [Alias('Connection', 'Conn')] [ValidateNotNullOrEmpty()] [System.Data.SqlClient.SQLConnection]$SQLConnection, + [Parameter(Position = 12, Mandatory = $false)] [Alias( 'Application', 'AppName' )] [String]$ApplicationName, + [Parameter(Position = 13, Mandatory = $false)] [switch]$MessagesToOutput @@ -322,35 +336,11 @@ function Invoke-Sqlcmd2 { function Resolve-SqlError { param($Err) if ($Err) { - if ($Err.Exception.GetType().Name -eq 'SqlException') { - # For SQL exception - #$Err = $_ - Write-Debug -Message "Capture SQL Error" - if ($PSBoundParameters.Verbose) { - Write-Verbose -Message "SQL Error: $Err" - } #Shiyang, add the verbose output of exception - switch ($ErrorActionPreference.ToString()) { - { 'SilentlyContinue', 'Ignore' -contains $_ } { } - 'Stop' { throw $Err } - 'Continue' { throw $Err } - Default { Throw $Err } - } - } - else { - # For other exception - Write-Debug -Message "Capture Other Error" - if ($PSBoundParameters.Verbose) { - Write-Verbose -Message "Other Error: $Err" - } - switch ($ErrorActionPreference.ToString()) { - { 'SilentlyContinue', 'Ignore' -contains $_ } { } - 'Stop' { throw $Err } - 'Continue' { throw $Err } - Default { throw $Err } - } + Write-Debug -Message "Logged $($Err.Exception.GetType().Name): $($Err.Message)" + if ($ErrorActionPreference -notin 'SilentlyContinue', 'Ignore') { + throw $_ } } - } if ($InputFile) { $filePath = $(Resolve-Path -LiteralPath $InputFile).ProviderPath @@ -398,7 +388,7 @@ function Invoke-Sqlcmd2 { Add-Type $cSharp -ErrorAction stop } - + } catch { if (-not $_.ToString() -like "*The type name 'DBNullScrubber' already exists*") { @@ -500,7 +490,7 @@ function Invoke-Sqlcmd2 { # Only execute non-empty statements $Pieces = $Pieces | Where-Object { $_.Trim().Length -gt 0 } foreach ($piece in $Pieces) { - $cmd = New-Object system.Data.SqlClient.SqlCommand($piece, $conn) + $cmd = New-Object System.Data.SqlClient.SqlCommand $piece, $conn $cmd.CommandTimeout = $QueryTimeout if ($null -ne $SqlParameters) { @@ -515,8 +505,8 @@ function Invoke-Sqlcmd2 { } > $null } - $ds = New-Object system.Data.DataSet - $da = New-Object system.Data.SqlClient.SqlDataAdapter($cmd) + $ds = New-Object System.Data.DataSet + $da = New-Object System.Data.SqlClient.SqlDataAdapter $cmd if ($MessagesToOutput) { $pool = [RunspaceFactory]::CreateRunspacePool(1, [int]$env:NUMBER_OF_PROCESSORS + 1) @@ -577,7 +567,11 @@ function Invoke-Sqlcmd2 { #Following EventHandler is used for PRINT and RAISERROR T-SQL statements. Executed when -Verbose parameter specified by caller and no -MessageToOutput if ($PSBoundParameters.Verbose) { $conn.FireInfoMessageEventOnUserErrors = $false - $handler = [System.Data.SqlClient.SqlInfoMessageEventHandler] { Write-Verbose "$($_)" } + $handler = if ($PSVersionTable.PSVersion.Major -lt 5) { + [System.Data.SqlClient.SqlInfoMessageEventHandler] { Write-Verbose "$($_)" } + } else { + [System.Data.SqlClient.SqlInfoMessageEventHandler] { $PSCmdlet.WriteInformation($_, @()) } + } $conn.add_InfoMessage($handler) } try {